From 88fac87241d674f5c49b2de0d36039cadd7eaeb5 Mon Sep 17 00:00:00 2001 From: fi3ework Date: Fri, 5 Jun 2026 11:20:59 +0800 Subject: [PATCH] fix(core): share imported module state across files under isolate: false MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Under `isolate: false` a single worker runs many test files: `@rstest/core` is reset per file (its module cache is evicted and re-imported, #1373) while user modules persist — a helper imported by several files is evaluated only once. Two problems followed from that asymmetry: 1. Context-bound `@rstest/core` APIs value-copied into a persisted helper (e.g. `export const test = base.extend({})`, a re-exported hook, `onTestFinished`, `{ ...rstest }`, or a snapshotted `expect.poll`) froze to the FIRST file's torn-down context and silently misbehaved from the second file on. 2. Setup files were matched against the module cache by raw path, so on Windows / mixed separators the cache-control plugin failed to re-run them per file. Fix the binding problem with a single live-binding contract: the whole injected API surface (`runtimeAPI`, `runnerAPI`, `expect`, `rstest`) is built ONCE per worker with a stable identity, and every context-bound member resolves the running file's `FileContext` at call time — one module-level binding holding the file's worker state, collection registrar, and test runner, republished as one unit by `createRunner` — never closing over a per-file instance. Per-file state is RESET, not rebuilt: `expect` clears its assertion bookkeeping and re-establishes the live `testPath` getter, `rstest` drops its stub/timer/mock bookkeeping and rewinds the shared `invocationCallOrder` counter. Because the file-level `expect` is a build-once singleton, a value-copied `expect.poll`/`.soft` can never go stale, so no cross-file delegation is needed; the per-test local expect intentionally stays a pinned per-test instance to keep `test.concurrent` isolation. Construction and publication are separated (`createRuntimeAPI` is a pure factory; only `createRunner` publishes the context), and globalThis carries only true cross-boundary contracts (`RSTEST_API` for the user bundle, `GLOBAL_EXPECT` for the assertion ecosystem). The contract is documented in one place, `runtime/api/index.ts`. Fix the setup-file problem by normalizing paths with `pathe` before matching. Sharing user modules means keeping the runtime chunk (which owns the only `__webpack_module_cache__`) across files. In watch mode the pool reuses the worker across rebuilds, so that kept cache also survived rebuild boundaries and a changed shared module was served stale from the previous build. Thread a per-compile `buildId` to the worker and fully flush its module cache when the id changes, so every rebuild re-evaluates shared modules while in-build sharing is preserved. Add an e2e surface guard that drives the whole context-bound API surface (`test.extend`, hooks, `describe`, `expect.poll`, `rstest.fn`/`clearMocks`) through a persisted shared helper from a non-first file, a watch regression test asserting an edited shared module is re-evaluated on rerun, and document that module-scope code runs once per worker under `isolate: false`. Closes #1373 --- e2e/no-isolate/fixtures/sharing/a.test.ts | 17 ++ e2e/no-isolate/fixtures/sharing/b.test.ts | 17 ++ e2e/no-isolate/fixtures/sharing/dynShared.ts | 7 + .../fixtures/sharing/rstest.config.ts | 11 ++ e2e/no-isolate/fixtures/sharing/setup.ts | 7 + e2e/no-isolate/fixtures/sharing/shared.ts | 8 + .../fixtures/sharing/surfaceFirst.test.ts | 6 + .../fixtures/sharing/surfaceHelper.ts | 26 +++ .../fixtures/sharing/surfaceSecond.test.ts | 68 ++++++++ .../fixtures/sharing/surfaceThird.test.ts | 9 + .../fixtures/watch-sharing/basic.test.ts | 8 + .../fixtures/watch-sharing/rstest.config.ts | 8 + .../fixtures/watch-sharing/shared.ts | 4 + e2e/no-isolate/moduleSharing.test.ts | 37 +++++ e2e/no-isolate/watchSharing.test.ts | 60 +++++++ packages/browser/src/client/entry.ts | 6 + .../src/core/plugins/moduleCacheControl.ts | 27 ++- packages/core/src/core/rsbuild.ts | 13 +- packages/core/src/core/runTests.ts | 8 + packages/core/src/pool/index.ts | 9 + packages/core/src/runtime/api/expect.ts | 85 ++++++++-- packages/core/src/runtime/api/index.ts | 56 +++++-- packages/core/src/runtime/api/snapshot.ts | 58 ++++--- packages/core/src/runtime/api/spy.ts | 9 + packages/core/src/runtime/api/utilities.ts | 70 ++++++-- packages/core/src/runtime/fileContext.ts | 43 +++++ packages/core/src/runtime/runner/index.ts | 49 ++++-- packages/core/src/runtime/runner/runner.ts | 2 +- packages/core/src/runtime/runner/runtime.ts | 157 ++++++++++-------- .../core/src/runtime/worker/loadEsModule.ts | 44 ++++- .../core/src/runtime/worker/loadModule.ts | 37 ++++- packages/core/src/runtime/worker/runInPool.ts | 28 +++- packages/core/src/types/worker.ts | 6 + packages/core/src/utils/getSetupFiles.ts | 15 ++ packages/core/tests/runner/runtime.test.ts | 79 +++++---- .../core/tests/runtime/api/expect.test.ts | 111 +++++++++++++ .../core/tests/runtime/api/fakeTimers.test.ts | 21 +-- packages/core/tests/runtime/api/helpers.ts | 30 ++++ .../core/tests/runtime/api/utilities.test.ts | 60 +++---- website/docs/en/config/test/isolate.mdx | 4 + website/docs/zh/config/test/isolate.mdx | 4 + 41 files changed, 1072 insertions(+), 252 deletions(-) create mode 100644 e2e/no-isolate/fixtures/sharing/a.test.ts create mode 100644 e2e/no-isolate/fixtures/sharing/b.test.ts create mode 100644 e2e/no-isolate/fixtures/sharing/dynShared.ts create mode 100644 e2e/no-isolate/fixtures/sharing/rstest.config.ts create mode 100644 e2e/no-isolate/fixtures/sharing/setup.ts create mode 100644 e2e/no-isolate/fixtures/sharing/shared.ts create mode 100644 e2e/no-isolate/fixtures/sharing/surfaceFirst.test.ts create mode 100644 e2e/no-isolate/fixtures/sharing/surfaceHelper.ts create mode 100644 e2e/no-isolate/fixtures/sharing/surfaceSecond.test.ts create mode 100644 e2e/no-isolate/fixtures/sharing/surfaceThird.test.ts create mode 100644 e2e/no-isolate/fixtures/watch-sharing/basic.test.ts create mode 100644 e2e/no-isolate/fixtures/watch-sharing/rstest.config.ts create mode 100644 e2e/no-isolate/fixtures/watch-sharing/shared.ts create mode 100644 e2e/no-isolate/moduleSharing.test.ts create mode 100644 e2e/no-isolate/watchSharing.test.ts create mode 100644 packages/core/src/runtime/fileContext.ts create mode 100644 packages/core/tests/runtime/api/expect.test.ts create mode 100644 packages/core/tests/runtime/api/helpers.ts diff --git a/e2e/no-isolate/fixtures/sharing/a.test.ts b/e2e/no-isolate/fixtures/sharing/a.test.ts new file mode 100644 index 000000000..a643ecf21 --- /dev/null +++ b/e2e/no-isolate/fixtures/sharing/a.test.ts @@ -0,0 +1,17 @@ +import { expect, test } from '@rstest/core'; +import { getSharedEvalId } from './shared'; + +test('a: statically imported module is shared across files', () => { + expect(getSharedEvalId()).toBe(1); +}); + +test('a: dynamically imported module is shared across files', async () => { + const { getDynEvalId } = await import('./dynShared'); + expect(getDynEvalId()).toBe(1); +}); + +test('a: setup re-ran for this file', () => { + expect((globalThis as Record).__rstestSetupFor).toContain( + 'a.test', + ); +}); diff --git a/e2e/no-isolate/fixtures/sharing/b.test.ts b/e2e/no-isolate/fixtures/sharing/b.test.ts new file mode 100644 index 000000000..2f547be55 --- /dev/null +++ b/e2e/no-isolate/fixtures/sharing/b.test.ts @@ -0,0 +1,17 @@ +import { expect, test } from '@rstest/core'; +import { getSharedEvalId } from './shared'; + +test('b: statically imported module is shared across files', () => { + expect(getSharedEvalId()).toBe(1); +}); + +test('b: dynamically imported module is shared across files', async () => { + const { getDynEvalId } = await import('./dynShared'); + expect(getDynEvalId()).toBe(1); +}); + +test('b: setup re-ran for this file', () => { + expect((globalThis as Record).__rstestSetupFor).toContain( + 'b.test', + ); +}); diff --git a/e2e/no-isolate/fixtures/sharing/dynShared.ts b/e2e/no-isolate/fixtures/sharing/dynShared.ts new file mode 100644 index 000000000..e17794790 --- /dev/null +++ b/e2e/no-isolate/fixtures/sharing/dynShared.ts @@ -0,0 +1,7 @@ +// Same contract as `shared.ts`, but reached via dynamic `import()` so the +// dynamic-import + asset-resolution path is covered too. +const g = globalThis as Record; +g.__rstestDynEvalCount = (g.__rstestDynEvalCount ?? 0) + 1; +const evalIdAtLoad: number = g.__rstestDynEvalCount; + +export const getDynEvalId = (): number => evalIdAtLoad; diff --git a/e2e/no-isolate/fixtures/sharing/rstest.config.ts b/e2e/no-isolate/fixtures/sharing/rstest.config.ts new file mode 100644 index 000000000..fb055e064 --- /dev/null +++ b/e2e/no-isolate/fixtures/sharing/rstest.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from '@rstest/core'; + +export default defineConfig({ + isolate: false, + setupFiles: ['./setup.ts'], + // Lets the surface guard assert a mock from a persisted `rstest` reference is + // reset per test (surfaceSecond.test.ts); no-op for the other fixtures. + clearMocks: true, + // One worker so module sharing is deterministic. + pool: { maxWorkers: 1, minWorkers: 1 }, +}); diff --git a/e2e/no-isolate/fixtures/sharing/setup.ts b/e2e/no-isolate/fixtures/sharing/setup.ts new file mode 100644 index 000000000..dfc78b626 --- /dev/null +++ b/e2e/no-isolate/fixtures/sharing/setup.ts @@ -0,0 +1,7 @@ +import { beforeAll } from '@rstest/core'; + +// Setup must re-run per file under `isolate: false`: each run records its own +// file, so a stale value would mean setup stopped re-running. +beforeAll((ctx) => { + (globalThis as Record).__rstestSetupFor = ctx.filepath; +}); diff --git a/e2e/no-isolate/fixtures/sharing/shared.ts b/e2e/no-isolate/fixtures/sharing/shared.ts new file mode 100644 index 000000000..6eb58254c --- /dev/null +++ b/e2e/no-isolate/fixtures/sharing/shared.ts @@ -0,0 +1,8 @@ +// Counts its own evaluations on the worker's `globalThis`. Evaluated once per +// worker (correct) → every file sees id `1`; re-evaluated per file (the bug) → +// the second file sees `2`. See https://github.com/web-infra-dev/rstest/issues/1373. +const g = globalThis as Record; +g.__rstestSharedEvalCount = (g.__rstestSharedEvalCount ?? 0) + 1; +const evalIdAtLoad: number = g.__rstestSharedEvalCount; + +export const getSharedEvalId = (): number => evalIdAtLoad; diff --git a/e2e/no-isolate/fixtures/sharing/surfaceFirst.test.ts b/e2e/no-isolate/fixtures/sharing/surfaceFirst.test.ts new file mode 100644 index 000000000..589a3e054 --- /dev/null +++ b/e2e/no-isolate/fixtures/sharing/surfaceFirst.test.ts @@ -0,0 +1,6 @@ +import { test } from './surfaceHelper'; + +// Exists only so surfaceSecond is a NON-FIRST file in the reused worker. +test('surfaceFirst: warmup', ({ expect }) => { + expect(true).toBe(true); +}); diff --git a/e2e/no-isolate/fixtures/sharing/surfaceHelper.ts b/e2e/no-isolate/fixtures/sharing/surfaceHelper.ts new file mode 100644 index 000000000..9290dbec5 --- /dev/null +++ b/e2e/no-isolate/fixtures/sharing/surfaceHelper.ts @@ -0,0 +1,26 @@ +// Consolidated guard for the #1376 family: one shared helper re-exporting the +// WHOLE `@rstest/core` surface by value (extended `test` + `Object.assign` + +// namespace re-exports). Evaluated once per worker; every captured API must +// still resolve the CURRENT file when used from a non-first file +// (surfaceSecond.test.ts). See https://github.com/web-infra-dev/rstest/issues/1376. +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + onTestFinished, + rstest, + test as base, +} from '@rstest/core'; + +export const test = Object.assign(base.extend({}), { + beforeAll, + beforeEach, + afterAll, + afterEach, + describe, +}); + +export { expect, onTestFinished, rstest }; diff --git a/e2e/no-isolate/fixtures/sharing/surfaceSecond.test.ts b/e2e/no-isolate/fixtures/sharing/surfaceSecond.test.ts new file mode 100644 index 000000000..e8810bb27 --- /dev/null +++ b/e2e/no-isolate/fixtures/sharing/surfaceSecond.test.ts @@ -0,0 +1,68 @@ +// Drives the ENTIRE `@rstest/core` surface through one persisted helper +// (surfaceHelper.ts) from a NON-FIRST file, so every captured API must late-bind +// to THIS file rather than the first file's torn-down context: +// - `test.extend(...)` → registers against this file's runner +// - file-level hooks → run for this file (beforeAll/beforeEach/afterEach/afterAll) +// - `test.describe(...)` → collects into this file's tree +// - `expect` / `expect.poll` → resolve this file's test context +// - `rstest.fn` + clearMocks → the mock registry being cleared is this file's +// - `onTestFinished(...)` → defers onto this file's runner, fires at test end +// See https://github.com/web-infra-dev/rstest/issues/1376. +import { expect, onTestFinished, rstest, test } from './surfaceHelper'; + +const mock = rstest.fn(); + +let beforeAllRan = false; +let beforeEachRuns = 0; +let afterEachRuns = 0; +let onTestFinishedRan = false; + +test.beforeAll(() => { + beforeAllRan = true; +}); +test.beforeEach(() => { + beforeEachRuns += 1; +}); +test.afterEach(() => { + afterEachRuns += 1; +}); +test.afterAll(() => { + // Read by surfaceThird.test.ts to prove the shared afterAll ran for THIS file. + (globalThis as Record).__rstestSurfaceAfterAll = true; +}); + +test.describe('surfaceSecond: full shared surface binds to this file', () => { + test('hooks + describe + extended test run for this file', () => { + expect(beforeAllRan).toBe(true); + expect(beforeEachRuns).toBe(1); + mock(); + expect(mock.mock.calls.length).toBe(1); + // A shared `onTestFinished` frozen to the first file's runner would throw + // here ("can only be called inside a test"); registering cleanly and firing + // proves it late-binds to this file's runner. + onTestFinished(() => { + onTestFinishedRan = true; + }); + expect(onTestFinishedRan).toBe(false); + }); + + test('clearMocks reset the shared-helper mock between tests', () => { + // `clearMocks: true` clears the CURRENT file's registry before each test; + // the mock came through the persisted `rstest` reference, so this proves the + // reference resolved this file's utilities (not the first file's). + expect(mock.mock.calls.length).toBe(0); + expect(afterEachRuns).toBe(1); + }); + + test('expect.poll resolves this file test context', async () => { + let v = 0; + setTimeout(() => { + v = 10; + }, 20); + await expect.poll(() => v, { interval: 10, timeout: 500 }).toBe(10); + }); + + test('shared onTestFinished fired for this file', () => { + expect(onTestFinishedRan).toBe(true); + }); +}); diff --git a/e2e/no-isolate/fixtures/sharing/surfaceThird.test.ts b/e2e/no-isolate/fixtures/sharing/surfaceThird.test.ts new file mode 100644 index 000000000..10c5f60c2 --- /dev/null +++ b/e2e/no-isolate/fixtures/sharing/surfaceThird.test.ts @@ -0,0 +1,9 @@ +import { expect, test } from './surfaceHelper'; + +// Proves surfaceSecond's shared `afterAll` actually ran (it bound to that file +// and fired at its teardown), not silently dropped into a stale collector. +test('surfaceThird: previous file shared afterAll fired', () => { + expect((globalThis as Record).__rstestSurfaceAfterAll).toBe( + true, + ); +}); diff --git a/e2e/no-isolate/fixtures/watch-sharing/basic.test.ts b/e2e/no-isolate/fixtures/watch-sharing/basic.test.ts new file mode 100644 index 000000000..73e2111db --- /dev/null +++ b/e2e/no-isolate/fixtures/watch-sharing/basic.test.ts @@ -0,0 +1,8 @@ +import { test } from '@rstest/core'; +import { marker } from './shared'; + +test('surfaces the current shared module value', () => { + // Printed to stdout (console interception disabled) so the e2e can assert the + // rerun observed the rebuilt value. + console.log(`SHARED_MARKER=${marker}`); +}); diff --git a/e2e/no-isolate/fixtures/watch-sharing/rstest.config.ts b/e2e/no-isolate/fixtures/watch-sharing/rstest.config.ts new file mode 100644 index 000000000..9fab7b7ee --- /dev/null +++ b/e2e/no-isolate/fixtures/watch-sharing/rstest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from '@rstest/core'; + +export default defineConfig({ + isolate: false, + // One worker, reused across watch rebuilds — the exact condition under which a + // kept runtime chunk could otherwise serve a changed shared module stale. + pool: { maxWorkers: 1, minWorkers: 1 }, +}); diff --git a/e2e/no-isolate/fixtures/watch-sharing/shared.ts b/e2e/no-isolate/fixtures/watch-sharing/shared.ts new file mode 100644 index 000000000..b40750a66 --- /dev/null +++ b/e2e/no-isolate/fixtures/watch-sharing/shared.ts @@ -0,0 +1,4 @@ +// Evaluated once per worker under `isolate: false`. When its source changes in +// watch mode, the rerun must observe the NEW value, not the previous build's +// cached evaluation in the kept runtime chunk. +export const marker = 'ORIGINAL'; diff --git a/e2e/no-isolate/moduleSharing.test.ts b/e2e/no-isolate/moduleSharing.test.ts new file mode 100644 index 000000000..e25dbd43a --- /dev/null +++ b/e2e/no-isolate/moduleSharing.test.ts @@ -0,0 +1,37 @@ +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, it } from '@rstest/core'; +import { runRstestCli } from '../scripts/'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +describe('module state sharing under isolate: false', () => { + // Runs the whole `sharing` fixture dir (one worker, isolate: false) and + // asserts every file passes. Covers two regressions: + // - https://github.com/web-infra-dev/rstest/issues/1373: a module imported by + // multiple files is evaluated once per worker (state shared) while setup + // still re-runs per file (a/b.test.ts + shared.ts). + // - https://github.com/web-infra-dev/rstest/issues/1376: a context-bound API + // captured in a shared module must resolve the current file, not the first + // file's torn-down context. The surface guard drives the WHOLE surface + // through one persisted helper from a non-first file (surfaceFirst/Second/ + // Third + surfaceHelper.ts); the subtle `expect` self-delegation is + // unit-covered (tests/runtime/api/expect.test.ts). + it('shares imported module state across files while re-running setup', async ({ + onTestFinished, + }) => { + const { expectExecSuccess } = await runRstestCli({ + command: 'rstest', + args: ['run'], + onTestFinished, + options: { + nodeOptions: { + cwd: join(__dirname, 'fixtures', 'sharing'), + }, + }, + }); + + await expectExecSuccess(); + }); +}); diff --git a/e2e/no-isolate/watchSharing.test.ts b/e2e/no-isolate/watchSharing.test.ts new file mode 100644 index 000000000..9ffa0510a --- /dev/null +++ b/e2e/no-isolate/watchSharing.test.ts @@ -0,0 +1,60 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it, rs } from '@rstest/core'; +import { prepareFixtures, runRstestCli } from '../scripts'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +// File-watch latency makes the rerun timing slightly racy; retry like the other +// watch e2e suites. +rs.setConfig({ retry: 3 }); + +// EBUSY when rming the temp fixture dir mid-run on Windows (see +// watch/index.test.ts for the same skip). +describe.skipIf(process.platform === 'win32')( + 'module sharing under isolate: false (watch rebuild)', + () => { + it('serves the rebuilt shared module value, not the previous build cache', async ({ + onTestFinished, + }) => { + const fixturesTargetPath = `${__dirname}/fixtures-test-watch-sharing${ + process.env.RSTEST_OUTPUT_MODULE !== 'false' ? '-module' : '' + }`; + + const { fs } = await prepareFixtures({ + fixturesPath: `${__dirname}/fixtures/watch-sharing`, + fixturesTargetPath, + }); + + const { cli } = await runRstestCli({ + command: 'rstest', + args: ['watch', '--disableConsoleIntercept'], + onTestFinished, + options: { + nodeOptions: { + cwd: fixturesTargetPath, + }, + }, + }); + + // Initial run evaluates `shared.ts` and caches it in the kept runtime + // chunk (the optimization that shares module state across files). + await cli.waitForStdout('SHARED_MARKER=ORIGINAL'); + await cli.waitForStdout('Duration'); + + // Edit the shared module: Rsbuild marks basic.test.ts affected and reruns + // it on the same reused worker. + cli.resetStd(); + fs.update(path.join(fixturesTargetPath, 'shared.ts'), (content) => + content.replace("'ORIGINAL'", "'UPDATED'"), + ); + + // Wait for the rerun to finish (its console.log is flushed before the run + // summary). The rerun must observe the rebuilt value, not the stale one. + await cli.waitForStdout('Duration'); + expect(cli.stdout).toMatch('SHARED_MARKER=UPDATED'); + expect(cli.stdout).not.toMatch('SHARED_MARKER=ORIGINAL'); + }); + }, +); diff --git a/packages/browser/src/client/entry.ts b/packages/browser/src/client/entry.ts index 5031bafef..d37c01f35 100644 --- a/packages/browser/src/client/entry.ts +++ b/packages/browser/src/client/entry.ts @@ -482,6 +482,10 @@ const run = async () => { rootPath: options.rootPath, runtimeConfig, taskId: 0, + // The kept-module-cache flush keyed on `buildId` is node worker-pool + // only (#1373); browser runners never reuse a node worker, so a constant + // inert id is correct here. + buildId: 0, outputModule: false, environment: 'browser', testPath, @@ -578,6 +582,8 @@ const run = async () => { rootPath: options.rootPath, runtimeConfig, taskId: 0, + // See the `buildId` note above: inert in browser mode. + buildId: 0, outputModule: false, environment: 'browser', currentTask: taskStack[0], diff --git a/packages/core/src/core/plugins/moduleCacheControl.ts b/packages/core/src/core/plugins/moduleCacheControl.ts index e9e77f1bd..b84d401e7 100644 --- a/packages/core/src/core/plugins/moduleCacheControl.ts +++ b/packages/core/src/core/plugins/moduleCacheControl.ts @@ -1,4 +1,5 @@ import type { RsbuildPlugin, Rspack } from '@rsbuild/core'; +import path from 'pathe'; class RstestCacheControlPlugin { apply(compiler: Rspack.Compiler) { @@ -56,16 +57,30 @@ export const pluginCacheControl: (setupFiles: string[]) => RsbuildPlugin = ( name: 'rstest:cache-control', setup: (api) => { if (setupFiles.length) { - api.transform({ test: setupFiles }, ({ code }) => { - // register setup's moduleId - return { - code: `${code} + // `setupFiles` are posix-style paths (pathe), but rspack matches `test` + // against the native resource path, which uses `\` on Windows — a raw + // string/array `test` would never match there, so the `setupIds` marker + // below would not be injected and setup files would stop re-running per + // file under `isolate: false`. Compare paths normalized to posix instead. + const setupFileSet = new Set( + setupFiles.map((file) => path.normalize(file)), + ); + api.transform( + { + test: (resourcePath) => + setupFileSet.has(path.normalize(resourcePath)), + }, + ({ code }) => { + // register setup's moduleId + return { + code: `${code} if (global.setupIds && __webpack_module__.id) { global.setupIds.push(__webpack_module__.id); } `, - }; - }); + }; + }, + ); } api.modifyRspackConfig((config) => { diff --git a/packages/core/src/core/rsbuild.ts b/packages/core/src/core/rsbuild.ts index fdaa8d0c6..26fa1dd9a 100644 --- a/packages/core/src/core/rsbuild.ts +++ b/packages/core/src/core/rsbuild.ts @@ -15,6 +15,7 @@ import type { RstestContext, } from '../types'; import { isDebug } from '../utils'; +import { collectSetupPaths } from '../utils/getSetupFiles'; import { isMemorySufficient } from '../utils/memory'; import { pluginBasic } from './plugins/basic'; import { pluginCSSFilter } from './plugins/css-filter'; @@ -162,12 +163,7 @@ export const prepareRsbuild = async ( }), pluginExternal(context), !isolate - ? pluginCacheControl( - Object.values({ - ...setupFiles, - ...globalSetupFiles, - }).flatMap((files) => Object.values(files)), - ) + ? pluginCacheControl(collectSetupPaths(setupFiles, globalSetupFiles)) : null, pluginInspect({ poolExecArgv: pool.execArgv }), ...extraPlugins, @@ -182,10 +178,7 @@ export const prepareRsbuild = async ( context.rootPath, ); coverage.exclude.push( - ...Object.values(setupFiles).flatMap((files) => Object.values(files)), - ...Object.values(globalSetupFiles || {}).flatMap((files) => - Object.values(files), - ), + ...collectSetupPaths(setupFiles, globalSetupFiles || {}), ); rsbuildInstance.addPlugins([pluginCoverage(coverage)]); diff --git a/packages/core/src/core/runTests.ts b/packages/core/src/core/runTests.ts index 2ac1d398e..26a707057 100644 --- a/packages/core/src/core/runTests.ts +++ b/packages/core/src/core/runTests.ts @@ -539,6 +539,11 @@ export async function runTests(context: Rstest): Promise { type Mode = 'all' | 'on-demand'; + // Per-compile id, bumped on every `run()` (initial build + each watch + // rebuild) and threaded to the worker so it can flush its kept cache on a + // rebuild boundary (#1373). + let buildId = 0; + const run = async ({ fileFilters, mode = 'all', @@ -548,6 +553,8 @@ export async function runTests(context: Rstest): Promise { mode?: Mode; buildStart?: number; } = {}) => { + buildId += 1; + for (const reporter of reporters) { await reporter.onTestRunStart?.(); } @@ -676,6 +683,7 @@ export async function runTests(context: Rstest): Promise { setupEntries, getAssetFiles, project: p, + buildId, updateSnapshot: context.snapshotManager.options.updateSnapshot, onCoverageResult: (coverage) => mergedCoverageMap?.merge(coverage), onTraceEvents: traceRun.onEvents, diff --git a/packages/core/src/pool/index.ts b/packages/core/src/pool/index.ts index d797238b9..e09787c44 100644 --- a/packages/core/src/pool/index.ts +++ b/packages/core/src/pool/index.ts @@ -135,6 +135,8 @@ type PoolDispatchParams = { setupEntries: EntryInfo[]; updateSnapshot: SnapshotUpdateState; project: ProjectContext; + /** Per-compile id threaded to the worker for rebuild-boundary cache flushing (#1373). Defaults to `0`. */ + buildId?: number; }; /** @@ -156,6 +158,7 @@ const buildTask = async ({ getSourceMaps, rpcMethods, traceSpan, + buildId = 0, }: { type: 'run' | 'collect'; workerKind: PoolWorkerKind; @@ -171,6 +174,7 @@ const buildTask = async ({ getSourceMaps: PoolDispatchParams['getSourceMaps']; rpcMethods: Omit; traceSpan: TraceSpan; + buildId?: number; }) => { const getAssets = () => filterAssetsByEntry(entryInfo, getAssetFiles, getSourceMaps, setupAssets); @@ -188,6 +192,7 @@ const buildTask = async ({ context: { outputModule: project.outputModule, taskId: index + 1, + buildId, project: project.name, rootPath: context.rootPath, projectRoot: project.rootPath, @@ -275,6 +280,8 @@ export const createPool = async ({ setupEntries: EntryInfo[]; updateSnapshot: SnapshotUpdateState; project: ProjectContext; + /** Per-compile id; bumped on each watch rebuild so reused workers flush their kept module cache. */ + buildId?: number; /** When provided, coverage data is passed to this callback immediately for caller-owned merging. */ onCoverageResult?: (coverage: CoverageMapData) => void; /** Perfetto trace events forwarded for caller-owned dumping. */ @@ -476,6 +483,7 @@ export const createPool = async ({ setupEntries, project, updateSnapshot, + buildId, onCoverageResult, onTraceEvents, traceSpan, @@ -513,6 +521,7 @@ export const createPool = async ({ getSourceMaps, rpcMethods, traceSpan, + buildId, }), traceArgs, ); diff --git a/packages/core/src/runtime/api/expect.ts b/packages/core/src/runtime/api/expect.ts index 034741437..283902e6c 100644 --- a/packages/core/src/runtime/api/expect.ts +++ b/packages/core/src/runtime/api/expect.ts @@ -44,21 +44,45 @@ import type { TestCase, WorkerState, } from '../../types'; +import { fileContext } from '../fileContext'; import { createExpectPoll } from './poll'; export { assert } from 'chai'; -export { GLOBAL_EXPECT }; export function setupChaiConfig(config: ChaiConfig): void { Object.assign(chaiConfig, config); } +/** + * The per-file slate of `expect` state: assertion bookkeeping cleared and + * `testPath` (re-)established as a live getter — the runner pins `testPath` to + * a plain value per test, so each file must restore the getter. + */ +const freshExpectState = ( + getWorkerState: () => WorkerState, +): Partial => ({ + assertionCalls: 0, + isExpectingAssertions: false, + isExpectingAssertionsError: null, + expectedAssertionsNumber: null, + expectedAssertionsNumberErrorGen: null, + get testPath() { + return getWorkerState().testPath; + }, +}); + export function createExpect({ getCurrentTest, - workerState, + getWorkerState, snapshotPlugin, }: { - workerState: WorkerState; + /** + * Resolved at call time, never captured: the file-level singleton passes a + * context-resolving accessor so a reference shared across files under + * `isolate: false` always reads the running file's state; a per-test local + * expect passes its own pinned state. + */ + getWorkerState: () => WorkerState; getCurrentTest: () => TestCase | undefined; snapshotPlugin?: ChaiPlugin; }): RstestExpect { @@ -88,20 +112,10 @@ export function createExpect({ const globalState = getState((globalThis as any)[GLOBAL_EXPECT]) || {}; - setState( - { - ...globalState, - assertionCalls: 0, - isExpectingAssertions: false, - isExpectingAssertionsError: null, - expectedAssertionsNumber: null, - expectedAssertionsNumberErrorGen: null, - get testPath() { - return workerState.testPath; - }, - }, - expect, - ); + setState({ ...globalState }, expect); + // Separate call: `setState` merges property DESCRIPTORS, which keeps the + // `testPath` getter that a spread literal would have eagerly evaluated. + setState(freshExpectState(getWorkerState), expect); // @ts-expect-error chai.expect.extend untyped expect.extend = (matchers) => chaiExpect.extend(expect, matchers); @@ -162,3 +176,40 @@ export function createExpect({ return expect; } + +let fileExpect: RstestExpect | undefined; + +const getContextWorkerState = (): WorkerState => fileContext().workerState; + +/** + * The file-level `expect` is a build-once singleton with a STABLE identity + * across files (the live-binding contract, see `../api`): it resolves the + * running file's worker state and current test through `fileContext()` at call + * time, so any value-copied reference (`expect.poll`, `.soft`, `{ ...api }`) + * captured in a module shared under `isolate: false` stays live — no + * delegation needed, there is only one instance. Per-file state is RESET, not + * rebuilt. The per-test local expect (`context.expect`, created via + * `createExpect` in the runner) intentionally stays a pinned per-test instance + * to keep `test.concurrent` isolation. + */ +export const createFileExpect = (snapshotPlugin: ChaiPlugin): RstestExpect => { + if (!fileExpect) { + fileExpect = createExpect({ + getWorkerState: getContextWorkerState, + getCurrentTest: () => fileContext().testRunner.getCurrentTest(), + snapshotPlugin, + }); + // The slot the runner and `@vitest/expect` internals read; assigned once — + // the singleton never changes identity. + Object.defineProperty(globalThis, GLOBAL_EXPECT, { + value: fileExpect, + writable: true, + configurable: true, + }); + return fileExpect; + } + // Later files reuse the singleton on a clean slate, mirroring the previous + // per-file rebuild (which also carried non-bookkeeping state forward). + setState(freshExpectState(getContextWorkerState), fileExpect); + return fileExpect; +}; diff --git a/packages/core/src/runtime/api/index.ts b/packages/core/src/runtime/api/index.ts index a99a1f506..d25f505ce 100644 --- a/packages/core/src/runtime/api/index.ts +++ b/packages/core/src/runtime/api/index.ts @@ -7,11 +7,36 @@ import type { TestInfo, WorkerState, } from '../../types'; -import { createRunner } from '../runner'; +import { createRunner, runnerAPI } from '../runner'; import type { TaskContext } from '../worker/taskContext'; -import { assert, createExpect, GLOBAL_EXPECT, setupChaiConfig } from './expect'; +import { assert, createFileExpect, setupChaiConfig } from './expect'; import { createRstestUtilities } from './utilities'; +/** + * Live per-file API binding under `isolate: false` (the canonical contract). + * + * One worker runs many files: `@rstest/core`'s runtime is re-prepared per file + * (#1373) but user modules persist, so any context-bound API value-copied into + * a shared helper (`export const test = base.extend({})`, `{ ...rstest }`, a + * snapshotted `expect.poll`) must not freeze to the first file's torn-down + * context. ONE mechanism covers the whole surface: every injected member is + * built once per worker with a stable identity and resolves the running file's + * `FileContext` (`../fileContext`) at call time — never closing over a + * per-file instance. `createRunner` republishes the context per file, and + * per-file state is reset, not rebuilt: + * + * - test / it / describe / hooks → `runtimeAPI` (runner/runtime.ts) + * - onTestFinished / onTestFailed → `runnerAPI` (runner/index.ts) + * - expect (incl. `.poll`/`.soft`) → `createFileExpect` (./expect) + * - rstest / rs → `createRstestUtilities` (./utilities) + * + * The one intentional exception is the per-test local expect + * (`context.expect`): created inside the running test, it can never be stale + * and stays pinned to keep `test.concurrent` isolation. + * + * See https://github.com/web-infra-dev/rstest/issues/1376. + */ + export const createRstestRuntime = async ( workerState: WorkerState, { taskContext }: { taskContext: TaskContext }, @@ -27,29 +52,25 @@ export const createRstestRuntime = async ( }; api: Rstest; }> => { - const [{ runner, api: runnerAPI }, { SnapshotPlugin }] = await Promise.all([ - Promise.resolve(createRunner({ workerState, taskContext })), - import(/* webpackChunkName: "snapshot" */ './snapshot'), - ]); + const [{ runner }, { SnapshotPlugin, ensureSnapshotClient }] = + await Promise.all([ + Promise.resolve(createRunner({ workerState, taskContext })), + import(/* webpackChunkName: "snapshot" */ './snapshot'), + ]); if (workerState.runtimeConfig.chaiConfig) { setupChaiConfig(workerState.runtimeConfig.chaiConfig); } - const expect: RstestExpect = createExpect({ - workerState, - getCurrentTest: () => runner.getCurrentTest(), - snapshotPlugin: SnapshotPlugin(workerState), - }); + // The runner consumes this file's snapshot client for `setup`/`finish`; the + // build-once snapshot plugin resolves it through the context at assert time. + ensureSnapshotClient(workerState); - Object.defineProperty(globalThis, GLOBAL_EXPECT, { - value: expect, - writable: true, - configurable: true, - }); + const expect: RstestExpect = createFileExpect(SnapshotPlugin()); - const rstest = await createRstestUtilities(workerState); + const rstest = await createRstestUtilities(); + // Injected surface: build-once members only (see the contract above). const runtime = { runner, api: { @@ -61,6 +82,7 @@ export const createRstestRuntime = async ( }, }; + // Published live for real-module importers (`public.ts` reads `RSTEST_API`). globalThis.RSTEST_API = runtime.api; return runtime; diff --git a/packages/core/src/runtime/api/snapshot.ts b/packages/core/src/runtime/api/snapshot.ts index 5dcf79df3..cc16a7bca 100644 --- a/packages/core/src/runtime/api/snapshot.ts +++ b/packages/core/src/runtime/api/snapshot.ts @@ -25,6 +25,7 @@ import { } from '@vitest/snapshot'; import type { Assertion, TestCase, WorkerState } from '../../types'; import { getTaskNameWithPrefix } from '../../utils/helper'; +import { fileContext } from '../fileContext'; function recordAsyncExpect( _test: any, @@ -129,21 +130,40 @@ function getTestMeta(test: TestCase) { testId: test.testId, }; } -export const SnapshotPlugin: (workerState: WorkerState) => ChaiPlugin = ( - workerState, -) => { - if (!workerState.snapshotClient) { - workerState.snapshotClient = new SnapshotClient({ - isEqual: (received, expected) => { - return equals(received, expected, [iterableEquality, subsetEquality]); - }, - }); - } - const client = workerState.snapshotClient; +/** Snapshot metadata for inline assertions reached outside a test context. */ +function getFileMeta(name: string) { + const { testPath, taskId } = fileContext().workerState; + return { + filepath: testPath, + name, + testId: String(taskId), + }; +} +/** + * Attach this file's `SnapshotClient` to its worker state. Called once per + * file by `createRstestRuntime` — the runner consumes it for `setup`/`finish`, + * and the (build-once) snapshot chai plugin below resolves it at assert time. + */ +export const ensureSnapshotClient = ( + workerState: WorkerState, +): SnapshotClient => { + workerState.snapshotClient ??= new SnapshotClient({ + isEqual: (received, expected) => { + return equals(received, expected, [iterableEquality, subsetEquality]); + }, + }); + return workerState.snapshotClient; +}; +/** + * Build the snapshot chai plugin. Installed once on the file-level `expect` + * singleton; every assertion resolves the running file's worker state through + * `fileContext()` at call time (the live-binding contract, see `../api`). + */ +export const SnapshotPlugin: () => ChaiPlugin = () => { function getSnapshotClient(): SnapshotClient { - return client; + return ensureSnapshotClient(fileContext().workerState); } return (chai, utils) => { @@ -268,13 +288,7 @@ export const SnapshotPlugin: (workerState: WorkerState) => ChaiPlugin = ( inlineSnapshot, error, errorMessage, - ...(test - ? getTestMeta(test) - : { - filepath: workerState.testPath, - name: 'toMatchInlineSnapshot', - testId: String(workerState.taskId), - }), + ...(test ? getTestMeta(test) : getFileMeta('toMatchInlineSnapshot')), }); }, ); @@ -335,11 +349,7 @@ export const SnapshotPlugin: (workerState: WorkerState) => ChaiPlugin = ( errorMessage, ...(test ? getTestMeta(test) - : { - name: 'toThrowErrorMatchingInlineSnapshot', - filepath: workerState.testPath, - testId: String(workerState.taskId), - }), + : getFileMeta('toThrowErrorMatchingInlineSnapshot')), }); }, ); diff --git a/packages/core/src/runtime/api/spy.ts b/packages/core/src/runtime/api/spy.ts index 1a67be278..4ac50291e 100644 --- a/packages/core/src/runtime/api/spy.ts +++ b/packages/core/src/runtime/api/spy.ts @@ -19,6 +19,12 @@ export const initSpy = (): Pick< > & { mocks: Set; createMockInstance: CreateMockInstanceFn; + /** + * Restart `invocationCallOrder` numbering. The spy state lives for the whole + * worker (the `rstest` singleton), so under `isolate: false` the per-file + * reset must rewind this counter to mirror the previous per-file rebuild. + */ + resetCallOrder: () => void; } => { let callOrder = 0; const mocks: Set = new Set(); @@ -369,5 +375,8 @@ export const initSpy = (): Pick< fn, mocks, createMockInstance, + resetCallOrder: () => { + callOrder = 0; + }, }; }; diff --git a/packages/core/src/runtime/api/utilities.ts b/packages/core/src/runtime/api/utilities.ts index 71b8508b3..8a95bf2ef 100644 --- a/packages/core/src/runtime/api/utilities.ts +++ b/packages/core/src/runtime/api/utilities.ts @@ -5,9 +5,9 @@ import type { RuntimeConfig, WaitForOptions, WaitUntilOptions, - WorkerState, } from '../../types'; import { RSTEST_ENV_SYMBOL_KEY } from '../../utils/constants'; +import { fileContext } from '../fileContext'; import { getRealTimers } from '../util'; import type { FakeTimerInstallOpts, FakeTimersSnapshot } from './fakeTimers'; import { mockObject as mockObjectImpl } from './mockObject'; @@ -98,9 +98,36 @@ export const restoreScopedEntry = ( } }; -export const createRstestUtilities: ( - workerState: WorkerState, -) => Promise = async (workerState) => { +let utilitiesPromise: + | Promise<{ rstest: RstestUtilities; resetForFile: () => void }> + | undefined; + +/** + * `rstest`/`rs` is a build-once singleton with a STABLE identity across files, + * so a reference captured in a module shared under `isolate: false` stays live + * without any forwarder/Proxy (the live-binding contract, see `./index`). + * Per-file state is RESET, not rebuilt: the config methods resolve the running + * file's worker state through `fileContext()` at call time, and `resetForFile` + * drops the env/global/timer stub bookkeeping and the mock registry between + * files, mirroring the previous per-file object rebuild exactly. The actual + * globalThis side-effects are still unwound by the runner's config-gated + * `unstubAll*`/`*AllMocks` and the per-file `useRealTimers`, unchanged. + * See https://github.com/web-infra-dev/rstest/issues/1376. + */ +export const createRstestUtilities = async (): Promise => { + utilitiesPromise ??= buildRstestUtilities(); + const bound = await utilitiesPromise; + // On the first file this is a no-op (the fresh singleton already has empty + // maps/registry); every later file returns it to a clean slate, mirroring the + // previous per-file rebuild. + bound.resetForFile(); + return bound.rstest; +}; + +const buildRstestUtilities = async (): Promise<{ + rstest: RstestUtilities; + resetForFile: () => void; +}> => { type RuntimeEnvStore = Record; const RSTEST_ENV_SYMBOL = Symbol.for(RSTEST_ENV_SYMBOL_KEY); type GlobalWithRuntimeEnv = typeof globalThis & Record; @@ -246,7 +273,14 @@ export const createRstestUtilities: ( }); }; - const { fn, spyOn, isMockFunction, mocks, createMockInstance } = initSpy(); + const { + fn, + spyOn, + isMockFunction, + mocks, + createMockInstance, + resetCallOrder, + } = initSpy(); const rstest: RstestUtilities = { fn, @@ -329,10 +363,11 @@ export const createRstestUtilities: ( }, setConfig: (config) => { + const { runtimeConfig } = fileContext().workerState; if (!originalConfig) { - originalConfig = { ...workerState.runtimeConfig }; + originalConfig = { ...runtimeConfig }; } - Object.assign(workerState.runtimeConfig, config); + Object.assign(runtimeConfig, config); }, getConfig: () => { @@ -344,7 +379,7 @@ export const createRstestUtilities: ( restoreMocks, maxConcurrency, retry, - } = workerState.runtimeConfig; + } = fileContext().workerState.runtimeConfig; return { testTimeout, hookTimeout, @@ -358,7 +393,7 @@ export const createRstestUtilities: ( resetConfig: () => { if (originalConfig) { - Object.assign(workerState.runtimeConfig, originalConfig); + Object.assign(fileContext().workerState.runtimeConfig, originalConfig); } }, @@ -578,5 +613,20 @@ export const createRstestUtilities: ( }, }; - return rstest; + // Drop the per-file bookkeeping so the next file starts clean — this mirrors + // the previous "rebuild a fresh utilities object per file" exactly. The actual + // globalThis side-effects (env/global stubs, fake timers, installed spies) are + // unwound elsewhere (the runner's config-gated `unstubAll*`/`*AllMocks` and the + // per-file `useRealTimers`), so only the tracking maps/registry are cleared. + const resetForFile = (): void => { + mocks.clear(); + resetCallOrder(); + originalEnvValues.clear(); + originalGlobalValues.clear(); + timerStack.length = 0; + originalConfig = undefined; + currentFakeTimersConfig = undefined; + }; + + return { rstest, resetForFile }; }; diff --git a/packages/core/src/runtime/fileContext.ts b/packages/core/src/runtime/fileContext.ts new file mode 100644 index 000000000..0ac77e848 --- /dev/null +++ b/packages/core/src/runtime/fileContext.ts @@ -0,0 +1,43 @@ +import type { WorkerState } from '../types'; +import type { TestRunner } from './runner/runner'; +import type { RunnerRuntime } from './runner/runtime'; + +/** + * The running test file's per-file state, published as ONE unit. + * + * Under `isolate: false` one worker runs many files while user modules persist + * across them, so every context-bound `@rstest/core` API is a build-once stable + * value that resolves this context at call time instead of closing over a + * per-file instance (the live-binding contract, see `./api`). This module is + * the single rebinding point: `createRunner` constructs all three members per + * file and publishes them together. + */ +export interface FileContext { + workerState: WorkerState; + /** Collection-phase registrar (`describe`/`it`/hooks land here). */ + runnerRuntime: RunnerRuntime; + /** Execution-phase runner (current test, `onTestFinished`/`onTestFailed`). */ + testRunner: TestRunner; +} + +// A module-level binding (not a globalThis slot) is sufficient: this module is +// instantiated once per worker process (or per browser iframe) and is never +// part of the per-file user-bundle cache eviction. +let current: FileContext | undefined; + +export const setFileContext = (context: FileContext): void => { + current = context; +}; + +/** + * Resolve the running file's context. Throws when called outside a prepared + * rstest runtime (e.g. importing `@rstest/core` APIs in a plain node script). + */ +export const fileContext = (): FileContext => { + if (!current) { + throw new Error( + 'Rstest runtime is not registered yet, please make sure you are running in a rstest environment.', + ); + } + return current; +}; diff --git a/packages/core/src/runtime/runner/index.ts b/packages/core/src/runtime/runner/index.ts index 4fb5458a4..83a26937a 100644 --- a/packages/core/src/runtime/runner/index.ts +++ b/packages/core/src/runtime/runner/index.ts @@ -8,11 +8,37 @@ import type { WorkerState, } from '../../types'; import { getFileTaskId } from '../../utils/helper'; +import { fileContext, setFileContext } from '../fileContext'; import type { TaskContext } from '../worker/taskContext'; import { TestRunner } from './runner'; -import { createRuntimeAPI } from './runtime'; +import { createRuntimeAPI, runtimeAPI } from './runtime'; import { traverseUpdateTest } from './task'; +// The running file's execution-phase runner (see the live-binding contract in +// `../api`; `createRunner` publishes the context per file). +const currentRunner = (): TestRunner => fileContext().testRunner; + +const onTestFinished: RunnerAPI['onTestFinished'] = (...args) => { + const runner = currentRunner(); + runner.onTestFinished(runner.getCurrentTest(), ...args); +}; + +const onTestFailed: RunnerAPI['onTestFailed'] = (...args) => { + const runner = currentRunner(); + runner.onTestFailed(runner.getCurrentTest(), ...args); +}; + +/** + * The full stable `@rstest/core` runner surface, built once: the collection-phase + * `runtimeAPI` plus the execution-phase `onTestFinished`/`onTestFailed` + * forwarders. Spread into the injected api by `createRstestRuntime` (`../api`). + */ +export const runnerAPI: RunnerAPI = { + ...runtimeAPI, + onTestFinished, + onTestFailed, +}; + export function createRunner({ workerState, taskContext, @@ -20,7 +46,6 @@ export function createRunner({ workerState: WorkerState; taskContext: TaskContext; }): { - api: RunnerAPI; runner: { runTests: ( testFilePath: string, @@ -36,37 +61,31 @@ export function createRunner({ project, runtimeConfig: { testNamePattern }, } = workerState; - const runtime = createRuntimeAPI({ + const runtimeInstance = createRuntimeAPI({ project, testPath, runtimeConfig: workerState.runtimeConfig, }); const testRunner: TestRunner = new TestRunner(taskContext); + // Publish this file's context as one unit; every stable forwarder (runner + // surface, `expect`, `rstest` config methods) resolves it at call time. + setFileContext({ workerState, runnerRuntime: runtimeInstance, testRunner }); return { - api: { - ...runtime.api, - onTestFinished: (fn, timeout) => { - testRunner.onTestFinished(testRunner.getCurrentTest(), fn, timeout); - }, - onTestFailed: (fn, timeout) => { - testRunner.onTestFailed(testRunner.getCurrentTest(), fn, timeout); - }, - }, runner: { runTests: async (testPath: string, hooks: RunnerHooks, api: Rstest) => { const snapshotClient = workerState.snapshotClient!; await snapshotClient.setup(testPath, workerState.snapshotOptions); - const tests = await runtime.instance.getTests(); + const tests = await runtimeInstance.getTests(); traverseUpdateTest(tests, testNamePattern); hooks.onTestFileReady?.({ testId: getFileTaskId(testPath), testPath, tests: tests.map(toTestInfo), }); - runtime.instance.updateStatus('running'); + runtimeInstance.updateStatus('running'); const results = await testRunner.runTests({ tests, @@ -80,7 +99,7 @@ export function createRunner({ return results; }, collectTests: async () => { - const tests = await runtime.instance.getTests(); + const tests = await runtimeInstance.getTests(); traverseUpdateTest(tests, testNamePattern); return tests.map(toTestInfo); diff --git a/packages/core/src/runtime/runner/runner.ts b/packages/core/src/runtime/runner/runner.ts index 1ddb72736..5b1066a9e 100644 --- a/packages/core/src/runtime/runner/runner.ts +++ b/packages/core/src/runtime/runner/runner.ts @@ -619,7 +619,7 @@ export class TestRunner { get: () => { if (!_expect) { _expect = createExpect({ - workerState: this.workerState!, + getWorkerState: () => this.workerState!, getCurrentTest: () => current, }); } diff --git a/packages/core/src/runtime/runner/runtime.ts b/packages/core/src/runtime/runner/runtime.ts index 75a1ccce6..461d9be70 100644 --- a/packages/core/src/runtime/runner/runtime.ts +++ b/packages/core/src/runtime/runner/runtime.ts @@ -31,6 +31,7 @@ import { SYNTHETIC_STACK_ERROR_MESSAGE, } from '../../utils/constants'; import { castArray, generateFilePathHash } from '../../utils/helper'; +import { fileContext } from '../fileContext'; import { formatName, isTemplateStringsArray, @@ -58,7 +59,7 @@ const SHARED_RUN_MODIFIERS = [ { name: 'sequential', overrides: { sequential: true } }, ] as const; -class RunnerRuntime { +export class RunnerRuntime { /** all test cases */ private readonly tests: Test[] = []; /** a calling stack of the current test suites and case */ @@ -96,6 +97,31 @@ class RunnerRuntime { this.status = status; } + /** + * Resolve the source location of the current registration call within this + * file. Lives on the runner (not a per-file closure) so a late-bound test API + * computes the location against the current file's `testPath`. + */ + getLocation(): Location | undefined { + if (!this.runtimeConfig.includeTaskLocation) return undefined; + const stack = new Error().stack; + if (stack) { + const frames = stackTraceParse(stack); + for (const frame of frames) { + let filename = frame.file ?? ''; + if (filename.startsWith('file://')) filename = fileURLToPath(filename); + // testPath is always unix path style, so convert filename with same way + filename = normalize(filename); + if (filename === this.testPath) { + const line = frame.lineNumber; + const column = frame.column; + if (line != null && column != null) return { line, column }; + } + } + } + return undefined; + } + private checkStatus(name: string, type: 'case' | 'suite'): void { if (this.status === 'running') { const error = new TestRegisterError( @@ -510,44 +536,21 @@ class RunnerRuntime { } } -export const createRuntimeAPI = ({ - testPath, - runtimeConfig, - project, -}: { - testPath: string; - runtimeConfig: RuntimeConfig; - project: string; -}): { - api: Omit; - instance: RunnerRuntime; -} => { - const runtimeInstance: RunnerRuntime = new RunnerRuntime({ - project, - testPath, - runtimeConfig, - }); +// The running file's collection-phase registrar (see the live-binding +// contract in `../api`; `createRunner` publishes the context per file). +const currentRuntime = (): RunnerRuntime => fileContext().runnerRuntime; - const getLocation = (): Location | undefined => { - if (!runtimeConfig.includeTaskLocation) return undefined; - const stack = new Error().stack; - if (stack) { - const frames = stackTraceParse(stack); - for (const frame of frames) { - let filename = frame.file ?? ''; - if (filename.startsWith('file://')) filename = fileURLToPath(filename); - // testPath is always unix path style, so convert filename with same way - filename = normalize(filename); - if (filename === testPath) { - const line = frame.lineNumber; - const column = frame.column; - if (line != null && column != null) return { line, column }; - } - } - } - return undefined; - }; +/** + * The collection-phase subset of the runner API — everything except the + * execution-phase `onTestFinished`/`onTestFailed`, which are added in + * runner/index.ts to form the full `runnerAPI`. + */ +type CollectionAPI = Omit; +// Build the collection-phase surface ONCE at module load (`runtimeAPI` below). +// Every leaf registration resolves `currentRuntime()` at call time and closes +// over nothing per-file (see the live-binding contract in `../api`). +const buildRuntimeAPI = (): CollectionAPI => { const createTestAPI = ( options: { concurrent?: boolean; @@ -560,14 +563,15 @@ export const createRuntimeAPI = ({ ): TestAPI => { const testFn = ((name, fn, testOptions) => { const { timeout, retry, repeats } = normalizeTestOptions(testOptions); - runtimeInstance.it({ + const rt = currentRuntime(); + rt.it({ name, fn, timeout, retry, repeats, ...options, - location: options.location ?? getLocation(), + location: options.location ?? rt.getLocation(), }); }) as TestAPI; @@ -586,31 +590,33 @@ export const createRuntimeAPI = ({ testFn.runIf = (condition: boolean) => createTestAPI({ ...options, - location: getLocation(), + location: currentRuntime().getLocation(), runMode: condition ? options.runMode : 'skip', }); testFn.skipIf = (condition: boolean) => createTestAPI({ ...options, - location: getLocation(), + location: currentRuntime().getLocation(), runMode: condition ? 'skip' : options.runMode, }); testFn.each = ((...args: any[]) => { - const location = getLocation(); + const rt = currentRuntime(); + const location = rt.getLocation(); const cases = isTemplateStringsArray(args[0]) ? parseTemplateTable(args[0], ...args.slice(1)) : args[0]; - return runtimeInstance.each({ cases, ...options, location }); + return rt.each({ cases, ...options, location }); }) as TestEachFn; testFn.for = ((...args: any[]) => { - const location = getLocation(); + const rt = currentRuntime(); + const location = rt.getLocation(); const cases = isTemplateStringsArray(args[0]) ? parseTemplateTable(args[0], ...args.slice(1)) : args[0]; - return runtimeInstance.for({ cases, ...options, location }); + return rt.for({ cases, ...options, location }); }) as TestForFn; return testFn; @@ -642,13 +648,15 @@ export const createRuntimeAPI = ({ location?: Location; } = {}, ): DescribeAPI => { - const describeFn = ((name, fn) => - runtimeInstance.describe({ + const describeFn = ((name, fn) => { + const rt = currentRuntime(); + rt.describe({ name, fn, ...options, - location: options.location ?? getLocation(), - })) as DescribeAPI; + location: options.location ?? rt.getLocation(), + }); + }) as DescribeAPI; for (const { name, overrides } of SHARED_RUN_MODIFIERS) { Object.defineProperty(describeFn, name, { @@ -662,30 +670,32 @@ export const createRuntimeAPI = ({ describeFn.skipIf = (condition: boolean) => createDescribeAPI({ ...options, - location: getLocation(), + location: currentRuntime().getLocation(), runMode: condition ? 'skip' : options.runMode, }); describeFn.runIf = (condition: boolean) => createDescribeAPI({ ...options, - location: getLocation(), + location: currentRuntime().getLocation(), runMode: condition ? options.runMode : 'skip', }); describeFn.each = ((...args: any[]) => { - const location = getLocation(); + const rt = currentRuntime(); + const location = rt.getLocation(); const cases = isTemplateStringsArray(args[0]) ? parseTemplateTable(args[0], ...args.slice(1)) : args[0]; - return runtimeInstance.describeEach({ cases, ...options, location }); + return rt.describeEach({ cases, ...options, location }); }) as DescribeEachFn; describeFn.for = ((...args: any[]) => { - const location = getLocation(); + const rt = currentRuntime(); + const location = rt.getLocation(); const cases = isTemplateStringsArray(args[0]) ? parseTemplateTable(args[0], ...args.slice(1)) : args[0]; - return runtimeInstance.describeFor({ cases, ...options, location }); + return rt.describeFor({ cases, ...options, location }); }) as DescribeForFn; return describeFn; @@ -694,15 +704,32 @@ export const createRuntimeAPI = ({ const describe = createDescribeAPI(); return { - api: { - describe, - it, - test: it, - afterAll: runtimeInstance.afterAll, - beforeAll: runtimeInstance.beforeAll, - afterEach: runtimeInstance.afterEach, - beforeEach: runtimeInstance.beforeEach, - }, - instance: runtimeInstance, + describe, + it, + test: it, + afterAll: (...args) => currentRuntime().afterAll(...args), + beforeAll: (...args) => currentRuntime().beforeAll(...args), + afterEach: (...args) => currentRuntime().afterEach(...args), + beforeEach: (...args) => currentRuntime().beforeEach(...args), }; }; + +/** The stable collection-phase surface. Built once; see `buildRuntimeAPI`. */ +export const runtimeAPI: CollectionAPI = buildRuntimeAPI(); + +// Construct this file's `RunnerRuntime`; the caller (`createRunner`) publishes +// it as part of the file's `FileContext`. +export const createRuntimeAPI = ({ + testPath, + runtimeConfig, + project, +}: { + testPath: string; + runtimeConfig: RuntimeConfig; + project: string; +}): RunnerRuntime => + new RunnerRuntime({ + project, + testPath, + runtimeConfig, + }); diff --git a/packages/core/src/runtime/worker/loadEsModule.ts b/packages/core/src/runtime/worker/loadEsModule.ts index 250fa0045..5d82cb12b 100644 --- a/packages/core/src/runtime/worker/loadEsModule.ts +++ b/packages/core/src/runtime/worker/loadEsModule.ts @@ -148,12 +148,21 @@ const defineRstestDynamicImport = const esmCache = new Map(); +// With `isolate: false` the kept runtime chunk's `import.meta` hooks (wasm / +// dynamic-import resolution) capture this asset map BY REFERENCE at creation +// time. Folding every file's assets into one persistent map — the same +// reference those hooks closed over — keeps a later file's chunks resolvable. +// Paths are globally unique per build, so merging never collides; the map is +// reset only on a full `clearModuleCache`. +// See https://github.com/web-infra-dev/rstest/issues/1373. +const accumulatedAssetFiles: Record = {}; + // setup and rstest module should not be cached export const loadModule = async ({ codeContent, distPath, testPath, - assetFiles, + assetFiles: assetFilesArg, interopDefault, esmMode = EsmMode.Unknown, runtimeDistPath, @@ -167,6 +176,12 @@ export const loadModule = async ({ rstestContext: Record; assetFiles: Record; }): Promise => { + // Fold this file's assets into the persistent map. Recursive loads (dynamic + // imports) re-pass that same map, so skip the no-op self-merge. + if (assetFilesArg !== accumulatedAssetFiles) { + Object.assign(accumulatedAssetFiles, assetFilesArg); + } + const assetFiles = accumulatedAssetFiles; const code = shouldInjectSourceURL() ? appendSourceURL(codeContent, distPath) : codeContent; @@ -262,7 +277,30 @@ export const loadModule = async ({ return ns.default && ns.default instanceof Promise ? ns.default : ns; }; -export const clearModuleCache = (): void => { - esmCache.clear(); +/** + * Reset the per-worker module cache between test files. + * + * Under `isolate: false`, `keep` is the shared runtime chunk id that owns the + * only `__webpack_module_cache__`; preserving it keeps the module-scope state + * of every already-evaluated non-entry module across files (evaluated once per + * worker, not per file). Test-entry and setup modules are still evicted so + * their bodies re-run per file. Workers are environment-homogeneous, so one id + * covers the shared runtime chunk. + * See https://github.com/web-infra-dev/rstest/issues/1373. + */ +export const clearModuleCache = (keep?: string): void => { + if (keep) { + for (const key of esmCache.keys()) { + if (key !== keep) { + esmCache.delete(key); + } + } + } else { + esmCache.clear(); + // Nothing is kept, so no hook holds a reference to the accumulated assets. + for (const key of Object.keys(accumulatedAssetFiles)) { + delete accumulatedAssetFiles[key]; + } + } clearSyntheticModuleCache(); }; diff --git a/packages/core/src/runtime/worker/loadModule.ts b/packages/core/src/runtime/worker/loadModule.ts index 34b14a113..5ab0be85d 100644 --- a/packages/core/src/runtime/worker/loadModule.ts +++ b/packages/core/src/runtime/worker/loadModule.ts @@ -153,13 +153,18 @@ const defineRstestDynamicImport = }); }; +// Persistent asset map for the kept runtime chunk under `isolate: false` (the +// per-module hooks closed over this reference). Mirrors the ESM loader — see +// `loadEsModule.ts` for the full rationale. +const accumulatedAssetFiles: Record = {}; + // setup and rstest module should not be cached export const loadModule = ({ codeContent, distPath, testPath, rstestContext, - assetFiles, + assetFiles: assetFilesArg, interopDefault, }: { interopDefault: boolean; @@ -169,6 +174,12 @@ export const loadModule = ({ rstestContext: Record; assetFiles: Record; }): any => { + // Fold this file's assets into the persistent map. Recursive loads (require / + // dynamic imports) re-pass that same map, so skip the no-op self-merge. + if (assetFilesArg !== accumulatedAssetFiles) { + Object.assign(accumulatedAssetFiles, assetFilesArg); + } + const assetFiles = accumulatedAssetFiles; const fileDir = path.dirname(testPath); const localModule = { @@ -277,7 +288,27 @@ export const cacheableLoadModule = ({ return mod; }; -export const clearModuleCache = (): void => { - moduleCache.clear(); +/** + * Reset the per-worker module cache between test files. + * + * Mirrors the ESM loader: with `isolate: false` the shared runtime chunk owns + * the only `__webpack_module_cache__`, so keeping it (via `keep`) preserves the + * module-scope state of every already-evaluated non-entry module across files. + * See https://github.com/web-infra-dev/rstest/issues/1373. + */ +export const clearModuleCache = (keep?: string): void => { + if (keep) { + for (const key of moduleCache.keys()) { + if (key !== keep) { + moduleCache.delete(key); + } + } + } else { + moduleCache.clear(); + // Nothing is kept, so no hook holds a reference to the accumulated assets. + for (const key of Object.keys(accumulatedAssetFiles)) { + delete accumulatedAssetFiles[key]; + } + } clearSyntheticModuleCache(); }; diff --git a/packages/core/src/runtime/worker/runInPool.ts b/packages/core/src/runtime/worker/runInPool.ts index d4d1dc7f0..b4901dd2a 100644 --- a/packages/core/src/runtime/worker/runInPool.ts +++ b/packages/core/src/runtime/worker/runInPool.ts @@ -59,6 +59,11 @@ const registerGlobalApi = (api: Rstest) => { const globalCleanups: (() => void)[] = []; let isTeardown = false; +/** + * Last per-compile `buildId` this (possibly reused) worker loaded; a change + * means a watch rebuild and triggers a full cache flush below (#1373). + */ +let lastBuildId: number | undefined; const setErrorName = (error: Error, type: string): Error => { try { @@ -387,10 +392,25 @@ export const runInPool = async ( type, context: { project, + buildId, runtimeConfig: { isolate, bail, detectAsyncLeaks }, }, } = options; + const importLoader = () => + options.context.outputModule + ? import('./loadEsModule') + : import('./loadModule'); + + // Keeping the runtime chunk is correct within one compile, but a watch rebuild + // (bumped `buildId`) would serve a changed shared module from the previous + // build's cache. Fully flush on the rebuild boundary before loading. + if (!isolate && lastBuildId !== undefined && lastBuildId !== buildId) { + const { clearModuleCache } = await importLoader(); + clearModuleCache(); + } + lastBuildId = buildId; + const cleanups: (() => MaybePromise)[] = []; const exit = process.exit.bind(process); @@ -420,10 +440,10 @@ export const runInPool = async ( await Promise.all(cleanups.map((fn) => fn())); if (!isolate) { - const { clearModuleCache } = options.context.outputModule - ? await import('./loadEsModule') - : await import('./loadModule'); - clearModuleCache(); + const { clearModuleCache } = await importLoader(); + // Keep the shared runtime chunk so imported module state survives across + // files; test-entry and setup modules are still evicted (see clearModuleCache). + clearModuleCache(runtimeDistPath); } isTeardown = true; diff --git a/packages/core/src/types/worker.ts b/packages/core/src/types/worker.ts index 2fecbec67..6c03dac1c 100644 --- a/packages/core/src/types/worker.ts +++ b/packages/core/src/types/worker.ts @@ -78,6 +78,12 @@ export type WorkerContext = { project: string; runtimeConfig: RuntimeConfig; taskId: number; + /** + * Monotonically increasing per-compile id: stable across all files of one + * run, bumped on every watch rebuild. A change tells a reused worker to flush + * its kept module cache before loading (#1373). + */ + buildId: number; outputModule: boolean; /** When true, the worker emits Perfetto trace events alongside phase totals. */ trace?: boolean; diff --git a/packages/core/src/utils/getSetupFiles.ts b/packages/core/src/utils/getSetupFiles.ts index 9036e992f..c17d4c5ef 100644 --- a/packages/core/src/utils/getSetupFiles.ts +++ b/packages/core/src/utils/getSetupFiles.ts @@ -15,6 +15,21 @@ const tryResolve = (request: string, rootPath: string) => { return resolvedPath; }; +/** + * Flatten one or more `{ [env]: { [entry]: path } }` setup maps into a flat + * list of setup file paths. + * + * Iterates the groups separately on purpose: a `{ ...a, ...b }` spread merges + * by environment name, so an empty map would clobber a populated entry for the + * same environment and drop its paths. + */ +export const collectSetupPaths = ( + ...groups: Record>[] +): string[] => + groups.flatMap((group) => + Object.values(group).flatMap((files) => Object.values(files)), + ); + export const getSetupFiles = ( setups: string[], rootPath: string, diff --git a/packages/core/tests/runner/runtime.test.ts b/packages/core/tests/runner/runtime.test.ts index d6646e30d..f1bb914ec 100644 --- a/packages/core/tests/runner/runtime.test.ts +++ b/packages/core/tests/runner/runtime.test.ts @@ -1,29 +1,44 @@ -import { createRuntimeAPI } from '../../src/runtime/runner/runtime'; +import { + type FileContext, + setFileContext, +} from '../../src/runtime/fileContext'; +import { createRuntimeAPI, runtimeAPI } from '../../src/runtime/runner/runtime'; import type { RuntimeConfig, TestCase, TestSuite } from '../../src/types'; import { generateFilePathHash } from '../../src/utils/helper'; +// `createRuntimeAPI` is a pure factory; production code publishes the instance +// as the file context via `createRunner`. Publish it here so the stable +// `runtimeAPI` forwarders resolve it. +const createPublishedRuntimeAPI = ( + options: Parameters[0], +) => { + const instance = createRuntimeAPI(options); + setFileContext({ runnerRuntime: instance } as FileContext); + return instance; +}; + describe('RunnerRuntime', () => { it('should add test correctly', async () => { - const { api: runtime, instance } = createRuntimeAPI({ + const instance = createPublishedRuntimeAPI({ testPath: __filename, runtimeConfig: { testTimeout: 100 } as RuntimeConfig, project: 'rstest', }); - runtime.describe('suite - 0', () => { - runtime.it('test - 0', () => {}); - runtime.describe('test - 1', async () => { + runtimeAPI.describe('suite - 0', () => { + runtimeAPI.it('test - 0', () => {}); + runtimeAPI.describe('test - 1', async () => { await new Promise((resolve) => { setTimeout(() => { resolve(); }, 100); }); - runtime.it('test - 1 - 1', () => {}); + runtimeAPI.it('test - 1 - 1', () => {}); }); }); - runtime.describe('suite - 1', () => {}); - runtime.it('test - 2', () => {}); + runtimeAPI.describe('suite - 1', () => {}); + runtimeAPI.it('test - 2', () => {}); const tests = await instance.getTests(); @@ -65,26 +80,26 @@ describe('RunnerRuntime', () => { }); it('should add test correctly when describe fn undefined', async () => { - const { api: runtime, instance } = createRuntimeAPI({ + const instance = createPublishedRuntimeAPI({ testPath: __filename, runtimeConfig: { testTimeout: 100 } as RuntimeConfig, project: 'rstest', }); - runtime.describe('suite - 0'); + runtimeAPI.describe('suite - 0'); - runtime.describe('suite - 1', () => { - runtime.it('test - 0', () => {}); - runtime.describe('test - 1', async () => { + runtimeAPI.describe('suite - 1', () => { + runtimeAPI.it('test - 0', () => {}); + runtimeAPI.describe('test - 1', async () => { await new Promise((resolve) => { setTimeout(() => { resolve(); }, 100); }); - runtime.it('test - 1 - 1', () => {}); + runtimeAPI.it('test - 1 - 1', () => {}); }); }); - runtime.it('test - 2', () => {}); + runtimeAPI.it('test - 2', () => {}); const tests = await instance.getTests(); @@ -110,15 +125,15 @@ describe('RunnerRuntime', () => { describe('TestOptions third argument', () => { const createApi = (testTimeout = 5000) => - createRuntimeAPI({ + createPublishedRuntimeAPI({ testPath: __filename, runtimeConfig: { testTimeout } as RuntimeConfig, project: 'rstest', }); it('treats a numeric third arg as timeout shorthand', async () => { - const { api, instance } = createApi(); - api.it('case', () => {}, 250); + const instance = createApi(); + runtimeAPI.it('case', () => {}, 250); const [first] = await instance.getTests(); const testCase = first as TestCase; @@ -129,8 +144,8 @@ describe('RunnerRuntime', () => { }); it('reads timeout/retry/repeats from a TestOptions object', async () => { - const { api, instance } = createApi(); - api.it('case', () => {}, { timeout: 250, retry: 2, repeats: 3 }); + const instance = createApi(); + runtimeAPI.it('case', () => {}, { timeout: 250, retry: 2, repeats: 3 }); const testCase = (await instance.getTests())[0] as TestCase; expect(testCase.timeout).toBe(250); @@ -139,8 +154,8 @@ describe('RunnerRuntime', () => { }); it('falls back to config.testTimeout when timeout omitted', async () => { - const { api, instance } = createApi(123); - api.it('case', () => {}, { retry: 1 }); + const instance = createApi(123); + runtimeAPI.it('case', () => {}, { retry: 1 }); const testCase = (await instance.getTests())[0] as TestCase; expect(testCase.timeout).toBe(123); @@ -148,8 +163,11 @@ describe('RunnerRuntime', () => { }); it('propagates options through test.each', async () => { - const { api, instance } = createApi(); - api.it.each([1, 2])('case %s', () => {}, { timeout: 50, retry: 1 }); + const instance = createApi(); + runtimeAPI.it.each([1, 2])('case %s', () => {}, { + timeout: 50, + retry: 1, + }); const cases = (await instance.getTests()) as TestCase[]; expect(cases).toHaveLength(2); @@ -160,8 +178,11 @@ describe('RunnerRuntime', () => { }); it('propagates options through test.for', async () => { - const { api, instance } = createApi(); - api.it.for([1, 2])('case %s', () => {}, { timeout: 50, repeats: 2 }); + const instance = createApi(); + runtimeAPI.it.for([1, 2])('case %s', () => {}, { + timeout: 50, + repeats: 2, + }); const cases = (await instance.getTests()) as TestCase[]; expect(cases).toHaveLength(2); @@ -172,9 +193,9 @@ describe('RunnerRuntime', () => { }); it('still accepts numeric shorthand on test.each / test.for', async () => { - const { api, instance } = createApi(); - api.it.each([1])('a %s', () => {}, 99); - api.it.for([2])('b %s', () => {}, 88); + const instance = createApi(); + runtimeAPI.it.each([1])('a %s', () => {}, 99); + runtimeAPI.it.for([2])('b %s', () => {}, 88); const [a, b] = (await instance.getTests()) as TestCase[]; expect(a!.timeout).toBe(99); diff --git a/packages/core/tests/runtime/api/expect.test.ts b/packages/core/tests/runtime/api/expect.test.ts new file mode 100644 index 000000000..e1d46c8e6 --- /dev/null +++ b/packages/core/tests/runtime/api/expect.test.ts @@ -0,0 +1,111 @@ +import { GLOBAL_EXPECT } from '@vitest/expect'; +import { util } from 'chai'; +import { + createExpect, + createFileExpect, +} from '../../../src/runtime/api/expect'; +import { + type FileContext, + setFileContext, +} from '../../../src/runtime/fileContext'; +import type { TestCase, WorkerState } from '../../../src/types'; + +const fakeTest = (name: string) => ({ name }) as unknown as TestCase; + +// Publish a fake running file: the singleton resolves the current test and +// worker state through this context at call time, as production does. +const publishFile = (testPath: string, currentTestName: string) => { + setFileContext({ + workerState: { testPath, runtimeConfig: {} } as WorkerState, + testRunner: { getCurrentTest: () => fakeTest(currentTestName) }, + } as FileContext); +}; + +// `createFileExpect` assigns `globalThis[GLOBAL_EXPECT]` — the slot the OUTER +// rstest runtime (running this test file) also owns. Restore it after each +// test so the framework's own per-test expect state handling keeps working. +// @ts-expect-error symbol index +const frameworkExpect = globalThis[GLOBAL_EXPECT]; +afterEach(() => { + // @ts-expect-error symbol index + globalThis[GLOBAL_EXPECT] = frameworkExpect; +}); + +/** + * Regression for https://github.com/web-infra-dev/rstest/issues/1376: the + * file-level `expect` is a build-once singleton, so a reference (or a + * value-copied `expect.poll`/`.soft`) captured in a module shared under + * `isolate: false` always tracks the running file — while the per-test local + * expect stays a pinned per-test instance. + */ +describe('file-level expect singleton (isolate: false)', () => { + it('keeps a stable identity across files', () => { + publishFile('/f1', 't1'); + const first = createFileExpect(() => {}); + publishFile('/f2', 't2'); + const second = createFileExpect(() => {}); + + expect(second).toBe(first); + }); + + it('attributes a captured reference to the running file', () => { + publishFile('/f1', 't1'); + const captured = createFileExpect(() => {}); + + // File 2 becomes the running file; the same captured reference must + // resolve file 2's current test and testPath. + publishFile('/f2', 't2'); + createFileExpect(() => {}); + + const attributed = util.flag( + captured(1) as unknown as object, + 'vitest-test', + ) as TestCase; + expect(attributed.name).toBe('t2'); + expect(captured.getState().testPath).toBe('/f2'); + }); + + it('resets per-file bookkeeping between files', () => { + publishFile('/f1', 't1'); + const fileExpect = createFileExpect(() => {}); + fileExpect.setState({ assertionCalls: 7, isExpectingAssertions: true }); + + publishFile('/f2', 't2'); + createFileExpect(() => {}); + + expect(fileExpect.getState().assertionCalls).toBe(0); + expect(fileExpect.getState().isExpectingAssertions).toBe(false); + }); + + it('restores the live testPath getter pinned by a previous test', () => { + publishFile('/f1', 't1'); + const fileExpect = createFileExpect(() => {}); + // The runner pins `testPath` to a plain value per test (beforeRunTest). + fileExpect.setState({ testPath: '/f1' }); + + publishFile('/f2', 't2'); + createFileExpect(() => {}); + + expect(fileExpect.getState().testPath).toBe('/f2'); + }); + + it('keeps the per-test local expect pinned (concurrent isolation)', () => { + publishFile('/f2', 't2'); + const fileExpect = createFileExpect(() => {}); + const localExpect = createExpect({ + getWorkerState: () => ({ testPath: '/f2' }) as WorkerState, + getCurrentTest: () => fakeTest('local'), + }); + + const attributed = util.flag( + localExpect(1) as unknown as object, + 'vitest-test', + ) as TestCase; + localExpect.setState({ assertionCalls: 3 }); + + expect(attributed.name).toBe('local'); + expect(localExpect.getState().assertionCalls).toBe(3); + // The file singleton's state is untouched by the local expect. + expect(fileExpect.getState().assertionCalls).toBe(0); + }); +}); diff --git a/packages/core/tests/runtime/api/fakeTimers.test.ts b/packages/core/tests/runtime/api/fakeTimers.test.ts index ba8601023..2c8f4c597 100644 --- a/packages/core/tests/runtime/api/fakeTimers.test.ts +++ b/packages/core/tests/runtime/api/fakeTimers.test.ts @@ -1,20 +1,5 @@ -import { createRstestUtilities } from '../../../src/runtime/api/utilities'; import { setRealTimers } from '../../../src/runtime/util'; -import type { WorkerState } from '../../../src/types'; - -function createWorkerState(): WorkerState { - return { - runtimeConfig: { - testTimeout: 1_000, - hookTimeout: 1_000, - clearMocks: false, - resetMocks: false, - restoreMocks: false, - maxConcurrency: 5, - retry: 0, - }, - } as WorkerState; -} +import { createUtilities } from './helpers'; describe('fake timers API', () => { beforeEach(() => { @@ -22,7 +7,7 @@ describe('fake timers API', () => { }); it('useFakeTimers not throws when specifies `toNotFake`', async () => { - const rs = await createRstestUtilities(createWorkerState()); + const rs = await createUtilities(); expect(() => rs.useFakeTimers({ toNotFake: ['setImmediate'] }), @@ -32,7 +17,7 @@ describe('fake timers API', () => { }); it('useFakeTimers filters out timers in toNotFake', async () => { - const rs = await createRstestUtilities(createWorkerState()); + const rs = await createUtilities(); rs.useFakeTimers({ toNotFake: ['setTimeout'] }); diff --git a/packages/core/tests/runtime/api/helpers.ts b/packages/core/tests/runtime/api/helpers.ts new file mode 100644 index 000000000..c1024e8b6 --- /dev/null +++ b/packages/core/tests/runtime/api/helpers.ts @@ -0,0 +1,30 @@ +import { createRstestUtilities } from '../../../src/runtime/api/utilities'; +import { + type FileContext, + setFileContext, +} from '../../../src/runtime/fileContext'; +import type { WorkerState } from '../../../src/types'; + +export function createWorkerState(): WorkerState { + return { + runtimeConfig: { + testTimeout: 1_000, + hookTimeout: 1_000, + clearMocks: false, + resetMocks: false, + restoreMocks: false, + maxConcurrency: 5, + retry: 0, + }, + } as WorkerState; +} + +/** + * `createRstestUtilities` resolves the running file's worker state through the + * file context at call time; publish a fresh one per construction, as + * `createRunner` does in production. + */ +export const createUtilities = async () => { + setFileContext({ workerState: createWorkerState() } as FileContext); + return createRstestUtilities(); +}; diff --git a/packages/core/tests/runtime/api/utilities.test.ts b/packages/core/tests/runtime/api/utilities.test.ts index d76cc5e04..1d7f26d45 100644 --- a/packages/core/tests/runtime/api/utilities.test.ts +++ b/packages/core/tests/runtime/api/utilities.test.ts @@ -1,26 +1,26 @@ -import { - createRstestUtilities, - restoreScopedEntry, -} from '../../../src/runtime/api/utilities'; +import { restoreScopedEntry } from '../../../src/runtime/api/utilities'; import { setRealTimers } from '../../../src/runtime/util'; -import type { WorkerState } from '../../../src/types'; +import { createUtilities } from './helpers'; const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); -function createWorkerState(): WorkerState { - return { - runtimeConfig: { - testTimeout: 1_000, - hookTimeout: 1_000, - clearMocks: false, - resetMocks: false, - restoreMocks: false, - maxConcurrency: 5, - retry: 0, - }, - } as WorkerState; -} +describe('rstest utilities per-file reset', () => { + it('restarts invocationCallOrder numbering for the next file', async () => { + const rs1 = await createUtilities(); + const first = rs1.fn(); + first(); + first(); + expect(first.mock.invocationCallOrder).toEqual([1, 2]); + + // Next file reuses the singleton; the reset must rewind the shared + // counter, mirroring the previous per-file utilities rebuild. + const rs2 = await createUtilities(); + const second = rs2.fn(); + second(); + expect(second.mock.invocationCallOrder).toEqual([1]); + }); +}); describe('rstest utilities wait APIs', () => { beforeEach(() => { @@ -28,7 +28,7 @@ describe('rstest utilities wait APIs', () => { }); it('waitFor retries until callback stops throwing', async () => { - const rs = await createRstestUtilities(createWorkerState()); + const rs = await createUtilities(); let attempts = 0; const result = await rs.waitFor( @@ -47,7 +47,7 @@ describe('rstest utilities wait APIs', () => { }); it('waitFor throws the latest callback error after timeout', async () => { - const rs = await createRstestUtilities(createWorkerState()); + const rs = await createUtilities(); let attempts = 0; await rs @@ -70,7 +70,7 @@ describe('rstest utilities wait APIs', () => { }); it('waitFor rejects when callback succeeds after timeout', async () => { - const rs = await createRstestUtilities(createWorkerState()); + const rs = await createUtilities(); await expect( rs.waitFor(async () => { @@ -81,7 +81,7 @@ describe('rstest utilities wait APIs', () => { }); it('waitUntil retries until callback returns a truthy value', async () => { - const rs = await createRstestUtilities(createWorkerState()); + const rs = await createUtilities(); let attempts = 0; const result = await rs.waitUntil( @@ -97,7 +97,7 @@ describe('rstest utilities wait APIs', () => { }); it('waitUntil throws on timeout and accepts number options', async () => { - const rs = await createRstestUtilities(createWorkerState()); + const rs = await createUtilities(); await expect(rs.waitUntil(() => false, 20)).rejects.toThrow( 'waitUntil timed out in 20ms', @@ -105,7 +105,7 @@ describe('rstest utilities wait APIs', () => { }); it('waitUntil rejects truthy values returned after timeout', async () => { - const rs = await createRstestUtilities(createWorkerState()); + const rs = await createUtilities(); await expect( rs.waitUntil(async () => { @@ -116,7 +116,7 @@ describe('rstest utilities wait APIs', () => { }); it('wait APIs still work when fake timers are enabled', async () => { - const rs = await createRstestUtilities(createWorkerState()); + const rs = await createUtilities(); rs.useFakeTimers(); @@ -152,7 +152,7 @@ describe('rstest utility scoped cleanup', () => { }); it('tracks chained scoped utility disposals', async () => { - const rs = await createRstestUtilities(createWorkerState()); + const rs = await createUtilities(); const disposable = rs.stubEnv(envName, 'first').stubEnv(envName, 'second'); @@ -164,7 +164,7 @@ describe('rstest utility scoped cleanup', () => { }); it('ignores stale scoped env disposables after unstubAllEnvs', async () => { - const rs = await createRstestUtilities(createWorkerState()); + const rs = await createUtilities(); const disposable = rs.stubEnv(envName, 'scoped'); rs.unstubAllEnvs(); @@ -178,7 +178,7 @@ describe('rstest utility scoped cleanup', () => { }); it('ignores stale scoped global disposables after unstubAllGlobals', async () => { - const rs = await createRstestUtilities(createWorkerState()); + const rs = await createUtilities(); const disposable = rs.stubGlobal(envName, 'scoped'); rs.unstubAllGlobals(); @@ -192,7 +192,7 @@ describe('rstest utility scoped cleanup', () => { }); it('restores previous fake timer state on scoped disposal', async () => { - const rs = await createRstestUtilities(createWorkerState()); + const rs = await createUtilities(); rs.useFakeTimers({ now: 100 }); const disposable = rs.useFakeTimers({ now: 200 }); @@ -208,7 +208,7 @@ describe('rstest utility scoped cleanup', () => { }); it('preserves pending timers after nested fake timer scoped disposal', async () => { - const rs = await createRstestUtilities(createWorkerState()); + const rs = await createUtilities(); rs.useFakeTimers({ now: 100 }); const callback = rs.fn(); diff --git a/website/docs/en/config/test/isolate.mdx b/website/docs/en/config/test/isolate.mdx index fa19d693b..87696901a 100644 --- a/website/docs/en/config/test/isolate.mdx +++ b/website/docs/en/config/test/isolate.mdx @@ -14,6 +14,10 @@ By default, Rstest runs each test in an isolated environment, which avoids the i If your code has no side effects, turning off this option will help improve performance because module caches can be reused between different test files. +:::warning Module top-level code runs once per worker +When `isolate` is `false`, a module imported by multiple test files is evaluated **once per worker**, not once per file. Any code at that shared module's top level — including hooks registered there (e.g. a helper that calls `beforeEach` at module scope) — therefore runs only for the first file that loads it, not for every file. For setup that must run per file, use [`setupFiles`](/config/test/setup-files), which Rstest re-runs for each test file even without isolation. +::: + import { Tab, Tabs } from '@theme'; diff --git a/website/docs/zh/config/test/isolate.mdx b/website/docs/zh/config/test/isolate.mdx index 3f10b320a..421e3f46b 100644 --- a/website/docs/zh/config/test/isolate.mdx +++ b/website/docs/zh/config/test/isolate.mdx @@ -14,6 +14,10 @@ description: 默认情况下,Rstest 会运行每个测试在一个独立的环 如果你的代码没有副作用影响,关闭这个选项将有助于提升性能因为可以在不同的测试文件间复用模块缓存。 +:::warning 模块顶层代码每个 worker 只执行一次 +当 `isolate` 为 `false` 时,被多个测试文件引入的模块在每个 worker 中**只会被求值一次**,而非每个文件求值一次。因此该共享模块顶层的所有代码——包括在其模块作用域注册的 hooks(例如某个 helper 在模块作用域调用 `beforeEach`)——只会对第一个加载它的文件生效,而不会对每个文件生效。如果某段 setup 逻辑需要按文件执行,请使用 [`setupFiles`](/config/test/setup-files),即使关闭隔离,Rstest 也会为每个测试文件重新执行它。 +::: + import { Tab, Tabs } from '@theme';