Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

vi.fn() performs an extra _await_ leading to mismatching behaviour when compared with normal function #5747

Closed
6 tasks done
dubzzz opened this issue May 17, 2024 · 6 comments · Fixed by #5749
Closed
6 tasks done
Labels
p3-minor-bug An edge case that only affects very specific usage (priority)

Comments

@dubzzz
Copy link
Contributor

dubzzz commented May 17, 2024

Describe the bug

Replacing vi.fn(async () => {}) by async () => {} may lead to different results.

In some cases, the ordering of asynchronous tasks might be key. In fast-check, we have a scheduler able to help our users to automatically detect potential race conditions leading to bugs in their code. In order to test it, we used to rely on Jest but recently we moved to Vitest to benefit from ES Module support.

While playing with vi.fn() I encountered some issues related to when promises resolved: the order order is not consistent with a version not relying on vi.fn().

Reproduction

Here is an example highlighting the difference I encounter (diff between no vi.fn() or manual version of it and vi.fn()):

import { vi, test, expect } from "vitest";

// Passes!
test("should pass without vi.fn()", async () => {
  let obj = { done: false };
  const f = async () => {};
  Promise.resolve()
    .then(() => {})
    .then(() => (obj.done = true));
  await f();
  expect(obj.done).toBe(false);
});

// It should pass, but it fails!
test("should pass with vi.fn()", async () => {
  let obj = { done: false };
  const f = vi.fn(async () => {});
  Promise.resolve()
    .then(() => {})
    .then(() => (obj.done = true));
  await f();
  expect(obj.done).toBe(false);
});

System Info

System:
    OS: Windows 11 10.0.22621
    CPU: (4) x64 Intel(R) Core(TM) i5-3570K CPU @ 3.40GHz
    Memory: 1.25 GB / 7.89 GB
  Binaries:
    Node: 20.8.0 - C:\Program Files\nodejs\node.EXE
    Yarn: 1.22.21 - ~\AppData\Roaming\npm\yarn.CMD
    npm: 10.2.3 - C:\Program Files\nodejs\npm.CMD
  Browsers:
    Edge: Chromium (123.0.2420.97)
    Internet Explorer: 11.0.22621.3527
  npmPackages:
    vitest: ^1.5.3 => 1.5.3

Used Package Manager

npm

Validations

@dubzzz
Copy link
Contributor Author

dubzzz commented May 17, 2024

The source of the issue seems to be in tinyspy:

function I(e) {
  R(u("function", e) || u("undefined", e), "cannot spy on a non-function value");
  let t = function(...s) {
    let r = A(t);
    r.called = !0, r.callCount++, r.calls.push(s);
    let m = r.next.shift();
    if (m) {
      r.results.push(m);
      let [l, o] = m;
      if (l === "ok")
        return o;
      throw o;
    }
    let p, d = "ok";
    if (r.impl)
      try {
        new.target ? p = Reflect.construct(r.impl, s, new.target) : p = r.impl.apply(this, s), d = "ok";
        //                                                          ^ It calls my function and puts its
        //                                                            result in p without any extra wrapping 
      } catch (l) {
        throw p = l, d = "error", r.results.push([d, l]), l;
      }
    let a = [d, p];
    if (b(p)) {
      let l = p.then((o) => a[1] = o).catch((o) => {
        throw a[0] = "error", a[1] = o, o;
      });
      Object.assign(l, p), p = l;
      //                   ^ It assigns l to p, l being p with extra .then.catch
    }
    return r.results.push(a), p;
  };
  i(t, "_isMockFunction", !0), i(t, "length", e ? e.length : 0), i(t, "name", e && e.name || "spy");
  let n = A(t);
  return n.reset(), n.impl = e, t;
}

See my comments in the minified code

@dubzzz
Copy link
Contributor Author

dubzzz commented May 17, 2024

Without the minified code, the source of the issue is the rewrapping of the Promise done by:

    if (isPromise(result)) {
      const newPromise = result
        .then((r: any) => (resultTuple[1] = r))
        .catch((e: any) => {
          // @ts-expect-error TS for some reasons narrows down the type
          resultTuple[0] = 'error'
          resultTuple[1] = e
          throw e
        })
      // we need to reassign it because if it fails, the suite will fail
      // see `async error` test in `test/index.test.ts`
      Object.assign(newPromise, result)
      result = newPromise
    }

Result is not anymore the original result but a value derived from it and paying the cost of an extra .then. This extra .then is the reason why the code with vi.fn() does not behave the same way.

@dubzzz
Copy link
Contributor Author

dubzzz commented May 17, 2024

I'm attempting to come up with a fix (if feasible).

@dubzzz
Copy link
Contributor Author

dubzzz commented May 17, 2024

Fix sent: tinylibs/tinyspy#45

@dubzzz
Copy link
Contributor Author

dubzzz commented May 21, 2024

Is it worth adding a dedicated test on Vitest's side once the fix gets released on tinyspy? If so I can work on adding such test

@sheremet-va sheremet-va added p3-minor-bug An edge case that only affects very specific usage (priority) and removed pending triage labels May 31, 2024
@dubzzz
Copy link
Contributor Author

dubzzz commented May 31, 2024

Thanks you so much @sheremet-va, you are doing an awesome job!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
p3-minor-bug An edge case that only affects very specific usage (priority)
Projects
None yet
Development

Successfully merging a pull request may close this issue.

2 participants