Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions e2e/no-isolate/fixtures/sharing/a.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, any>).__rstestSetupFor).toContain(
'a.test',
);
});
17 changes: 17 additions & 0 deletions e2e/no-isolate/fixtures/sharing/b.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, any>).__rstestSetupFor).toContain(
'b.test',
);
});
7 changes: 7 additions & 0 deletions e2e/no-isolate/fixtures/sharing/dynShared.ts
Original file line number Diff line number Diff line change
@@ -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<string, any>;
g.__rstestDynEvalCount = (g.__rstestDynEvalCount ?? 0) + 1;
const evalIdAtLoad: number = g.__rstestDynEvalCount;

export const getDynEvalId = (): number => evalIdAtLoad;
11 changes: 11 additions & 0 deletions e2e/no-isolate/fixtures/sharing/rstest.config.ts
Original file line number Diff line number Diff line change
@@ -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 },
});
7 changes: 7 additions & 0 deletions e2e/no-isolate/fixtures/sharing/setup.ts
Original file line number Diff line number Diff line change
@@ -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<string, any>).__rstestSetupFor = ctx.filepath;
});
8 changes: 8 additions & 0 deletions e2e/no-isolate/fixtures/sharing/shared.ts
Original file line number Diff line number Diff line change
@@ -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<string, any>;
g.__rstestSharedEvalCount = (g.__rstestSharedEvalCount ?? 0) + 1;
const evalIdAtLoad: number = g.__rstestSharedEvalCount;

export const getSharedEvalId = (): number => evalIdAtLoad;
6 changes: 6 additions & 0 deletions e2e/no-isolate/fixtures/sharing/surfaceFirst.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
26 changes: 26 additions & 0 deletions e2e/no-isolate/fixtures/sharing/surfaceHelper.ts
Original file line number Diff line number Diff line change
@@ -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 };
68 changes: 68 additions & 0 deletions e2e/no-isolate/fixtures/sharing/surfaceSecond.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, any>).__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);
});
});
9 changes: 9 additions & 0 deletions e2e/no-isolate/fixtures/sharing/surfaceThird.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, any>).__rstestSurfaceAfterAll).toBe(
true,
);
});
8 changes: 8 additions & 0 deletions e2e/no-isolate/fixtures/watch-sharing/basic.test.ts
Original file line number Diff line number Diff line change
@@ -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}`);
});
8 changes: 8 additions & 0 deletions e2e/no-isolate/fixtures/watch-sharing/rstest.config.ts
Original file line number Diff line number Diff line change
@@ -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 },
});
4 changes: 4 additions & 0 deletions e2e/no-isolate/fixtures/watch-sharing/shared.ts
Original file line number Diff line number Diff line change
@@ -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';
37 changes: 37 additions & 0 deletions e2e/no-isolate/moduleSharing.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
60 changes: 60 additions & 0 deletions e2e/no-isolate/watchSharing.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
},
);
6 changes: 6 additions & 0 deletions packages/browser/src/client/entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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],
Expand Down
27 changes: 21 additions & 6 deletions packages/core/src/core/plugins/moduleCacheControl.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { RsbuildPlugin, Rspack } from '@rsbuild/core';
import path from 'pathe';

class RstestCacheControlPlugin {
apply(compiler: Rspack.Compiler) {
Expand Down Expand Up @@ -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) => {
Expand Down
Loading
Loading