Skip to content

Commit 6a4cc28

Browse files
committed
fix(core): late-bind expect to current file under isolate: false
A follow-up to the test.extend() fix: under `isolate: false` a context-bound `@rstest/core` API value-copied into a module shared across files goes stale. A shared helper doing `Object.assign(impl, expect)` (or `const { poll } = expect`) captures the first file's concrete `expect`, whose `expect.poll` is bound to that file's `getCurrentTest`; from the second file on it threw "expect.poll() must be called inside a test". Make the per-file `expect` self-delegate: when invoked from a stale cross-file reference it forwards to the current file's live expect (`globalThis[GLOBAL_EXPECT]`, reassigned per file), so test attribution and assertion state track the running file. The per-test local expect leaves `isFileExpect: false` so it never delegates and keeps `test.concurrent` assertion isolation. For the current file's own expect `live === expect`, so this is a no-op fast path. Refs #1376.
1 parent 101313b commit 6a4cc28

7 files changed

Lines changed: 186 additions & 6 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { test } from '@rstest/core';
2+
import { pollFor } from './sharedExpect';
3+
4+
test('pollA: shared expect.poll resolves this file test context', async () => {
5+
let v = 0;
6+
setTimeout(() => {
7+
v = 10;
8+
}, 20);
9+
await pollFor(() => v).toBe(10);
10+
});
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { test } from '@rstest/core';
2+
import { pollFor } from './sharedExpect';
3+
4+
test('pollB: shared expect.poll still resolves this file test context', async () => {
5+
let v = 0;
6+
setTimeout(() => {
7+
v = 10;
8+
}, 20);
9+
await pollFor(() => v).toBe(10);
10+
});
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
// A shared helper that value-copies the `@rstest/core` expect (including
2+
// `expect.poll`) at module top-level — the common pattern of re-exporting a
3+
// wrapped `expect` plus pre-bound helpers from one workspace module.
4+
//
5+
// Under `isolate: false` this module is evaluated once per worker (#1373), but
6+
// `@rstest/core` is reset per file. The captured `poll` must still resolve the
7+
// CURRENT file's test context — otherwise from the second file on it throws
8+
// "expect.poll() must be called inside a test".
9+
// See https://github.com/web-infra-dev/rstest/issues/1376.
10+
import { expect as rstestExpect } from '@rstest/core';
11+
12+
const expectImpl = ((actual: unknown, message?: string) =>
13+
rstestExpect(actual, message)) as typeof rstestExpect;
14+
Object.assign(expectImpl, rstestExpect);
15+
16+
export const expect = expectImpl;
17+
18+
// `poll` value-captured at module scope, mirroring a shared `expectPoll` helper.
19+
export const pollFor = (fn: () => unknown) =>
20+
expect.poll(fn, { interval: 10, timeout: 500 });

e2e/no-isolate/moduleSharing.test.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,12 @@ describe('module state sharing under isolate: false', () => {
1212
// - https://github.com/web-infra-dev/rstest/issues/1373: a module imported by
1313
// multiple files is evaluated once per worker (state shared), while setup
1414
// files still re-run per file (a.test.ts / b.test.ts + shared.ts).
15-
// - https://github.com/web-infra-dev/rstest/issues/1376: a `test.extend(...)`
16-
// captured in a module shared across files must still register against the
17-
// current file's runner, not the first file's torn-down one
18-
// (extendA.test.ts / extendB.test.ts + sharedFixture.ts).
15+
// - https://github.com/web-infra-dev/rstest/issues/1376: a context-bound
16+
// `@rstest/core` API captured in a module shared across files must still
17+
// resolve the current file's context, not the first file's torn-down one —
18+
// `test.extend(...)` against the current runner (extendA/extendB +
19+
// sharedFixture.ts) and `expect.poll(...)` against the current test
20+
// (pollA/pollB + sharedExpect.ts).
1921
it('shares imported module state across files while re-running setup', async ({
2022
onTestFinished,
2123
}) => {

packages/core/src/runtime/api/expect.ts

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,10 +57,19 @@ export function createExpect({
5757
getCurrentTest,
5858
workerState,
5959
snapshotPlugin,
60+
isFileExpect = false,
6061
}: {
6162
workerState: WorkerState;
6263
getCurrentTest: () => TestCase | undefined;
6364
snapshotPlugin?: ChaiPlugin;
65+
/**
66+
* Whether this is the per-file `expect` published as `globalThis[GLOBAL_EXPECT]`
67+
* (and injected as `@rstest/core`'s `expect`). Only the per-file instance
68+
* self-delegates to the current file's live expect when invoked from a stale
69+
* (cross-file shared) reference. The per-test local expect leaves this `false`
70+
* so it never delegates and keeps `test.concurrent` assertion isolation.
71+
*/
72+
isFileExpect?: boolean;
6473
}): RstestExpect {
6574
use(JestExtend);
6675
use(JestChaiExpect);
@@ -69,7 +78,24 @@ export function createExpect({
6978
}
7079
use(JestAsymmetricMatchers);
7180

81+
// Resolve the expect whose state/context should be used right now. Under
82+
// `isolate: false` the per-file `expect` (and members value-copied off it,
83+
// e.g. `expect.poll`) may be captured in a module shared across files; when a
84+
// later file calls such a stale reference, delegate to that file's live
85+
// expect — `globalThis[GLOBAL_EXPECT]` is reassigned per file — so assertion
86+
// state and test attribution track the running file. For the current file's
87+
// own expect `live === expect`, so this is a no-op fast path.
88+
// See https://github.com/web-infra-dev/rstest/issues/1376.
89+
const activeExpect = (): RstestExpect => {
90+
const live = (globalThis as any)[GLOBAL_EXPECT] as RstestExpect | undefined;
91+
return isFileExpect && live && live !== expect ? live : expect;
92+
};
93+
7294
const expect = ((value: any, message?: string): Assertion => {
95+
const live = activeExpect();
96+
if (live !== expect) {
97+
return live(value, message);
98+
}
7399
const { assertionCalls } = getState(expect);
74100
setState({ assertionCalls: assertionCalls + 1 }, expect);
75101
const assert = chaiExpect(value, message) as unknown as Assertion;
@@ -83,8 +109,11 @@ export function createExpect({
83109
Object.assign(expect, chaiExpect);
84110
Object.assign(expect, (globalThis as any)[ASYMMETRIC_MATCHERS_OBJECT]);
85111

86-
expect.getState = () => getState<MatcherState>(expect);
87-
expect.setState = (state) => setState(state, expect);
112+
// Route the public state accessors through `activeExpect()` so `assertions`,
113+
// `hasAssertions`, `soft`, and `poll` (which call these) land on the running
114+
// file's state key when reached via a stale cross-file reference.
115+
expect.getState = () => getState<MatcherState>(activeExpect());
116+
expect.setState = (state) => setState(state, activeExpect());
88117

89118
const globalState = getState((globalThis as any)[GLOBAL_EXPECT]) || {};
90119

packages/core/src/runtime/api/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ export const createRstestRuntime = async (
4040
workerState,
4141
getCurrentTest: () => runner.getCurrentTest(),
4242
snapshotPlugin: SnapshotPlugin(workerState),
43+
isFileExpect: true,
4344
});
4445

4546
Object.defineProperty(globalThis, GLOBAL_EXPECT, {
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import { util } from 'chai';
2+
import { createExpect, GLOBAL_EXPECT } from '../../../src/runtime/api/expect';
3+
import type { TestCase, WorkerState } from '../../../src/types';
4+
5+
function createWorkerState(testPath: string): WorkerState {
6+
return { testPath, runtimeConfig: {} } as WorkerState;
7+
}
8+
9+
const fakeTest = (name: string) => ({ name }) as unknown as TestCase;
10+
11+
// This test hijacks `globalThis[GLOBAL_EXPECT]` — the same slot the host rstest
12+
// runtime uses — so each case swaps it only briefly, captures what it needs,
13+
// then restores it before running the outer assertions.
14+
function withLiveExpect<T>(live: unknown, fn: () => T): T {
15+
// @ts-expect-error symbol index
16+
const prev = globalThis[GLOBAL_EXPECT];
17+
// @ts-expect-error symbol index
18+
globalThis[GLOBAL_EXPECT] = live;
19+
try {
20+
return fn();
21+
} finally {
22+
// @ts-expect-error symbol index
23+
globalThis[GLOBAL_EXPECT] = prev;
24+
}
25+
}
26+
27+
/**
28+
* Regression for https://github.com/web-infra-dev/rstest/issues/1376: under
29+
* `isolate: false` the per-file `expect` (and members value-copied off it) can
30+
* be captured in a module shared across files. When a later file invokes that
31+
* stale reference, it must delegate to the current file's live expect
32+
* (`globalThis[GLOBAL_EXPECT]`) so assertion state and test attribution track
33+
* the running file — while the per-test local expect must NOT delegate.
34+
*/
35+
describe('createExpect cross-file delegation (isolate: false)', () => {
36+
it('attributes a stale per-file expect to the live file test', () => {
37+
const file1 = createExpect({
38+
workerState: createWorkerState('/f1'),
39+
getCurrentTest: () => fakeTest('t1'),
40+
isFileExpect: true,
41+
});
42+
const file2 = createExpect({
43+
workerState: createWorkerState('/f2'),
44+
getCurrentTest: () => fakeTest('t2'),
45+
isFileExpect: true,
46+
});
47+
48+
// File 2 is the running file; the stale file-1 expect must resolve t2.
49+
const attributed = withLiveExpect(file2, () =>
50+
util.flag(file1(1) as unknown as object, 'vitest-test'),
51+
) as TestCase;
52+
expect(attributed.name).toBe('t2');
53+
});
54+
55+
it('routes assertion state of a stale per-file expect onto the live expect', () => {
56+
const file1 = createExpect({
57+
workerState: createWorkerState('/f1'),
58+
getCurrentTest: () => undefined,
59+
isFileExpect: true,
60+
});
61+
const file2 = createExpect({
62+
workerState: createWorkerState('/f2'),
63+
getCurrentTest: () => undefined,
64+
isFileExpect: true,
65+
});
66+
67+
const calls = withLiveExpect(file2, () => {
68+
file1.setState({ assertionCalls: 7 });
69+
// State must land on the live (file 2) key, not file 1's own object.
70+
return file2.getState().assertionCalls;
71+
});
72+
expect(calls).toBe(7);
73+
});
74+
75+
it('does not delegate the per-test local expect (concurrent isolation)', () => {
76+
const fileExpect = createExpect({
77+
workerState: createWorkerState('/f2'),
78+
getCurrentTest: () => fakeTest('file'),
79+
isFileExpect: true,
80+
});
81+
const localExpect = createExpect({
82+
workerState: createWorkerState('/f2'),
83+
getCurrentTest: () => fakeTest('local'),
84+
// isFileExpect omitted -> false: a per-test local expect never delegates.
85+
});
86+
87+
const { attributed, localCalls, fileCalls } = withLiveExpect(
88+
fileExpect,
89+
() => {
90+
const a = util.flag(
91+
localExpect(1) as unknown as object,
92+
'vitest-test',
93+
) as TestCase;
94+
localExpect.setState({ assertionCalls: 3 });
95+
return {
96+
attributed: a,
97+
localCalls: localExpect.getState().assertionCalls,
98+
fileCalls: fileExpect.getState().assertionCalls,
99+
};
100+
},
101+
);
102+
103+
expect(attributed.name).toBe('local');
104+
expect(localCalls).toBe(3);
105+
// The live file expect's state is untouched by the local expect.
106+
expect(fileCalls).toBe(0);
107+
});
108+
});

0 commit comments

Comments
 (0)