diff --git a/e2e/soft-mode-happy-dom/fixtures/rstest.config.mts b/e2e/soft-mode-happy-dom/fixtures/rstest.config.mts new file mode 100644 index 000000000..bda670482 --- /dev/null +++ b/e2e/soft-mode-happy-dom/fixtures/rstest.config.mts @@ -0,0 +1,14 @@ +import { defineConfig } from '@rstest/core'; + +export default defineConfig({ + experiments: { + softMode: true, + }, + testEnvironment: 'happy-dom', + // Force single worker so file-a always precedes file-b — assertions + // about cross-file state reset are deterministic. + pool: { + maxWorkers: 1, + }, + setupFiles: ['./test/setup.ts'], +}); diff --git a/e2e/soft-mode-happy-dom/fixtures/test/a-init.test.ts b/e2e/soft-mode-happy-dom/fixtures/test/a-init.test.ts new file mode 100644 index 000000000..5c582ab14 --- /dev/null +++ b/e2e/soft-mode-happy-dom/fixtures/test/a-init.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from '@rstest/core'; + +declare global { + // eslint-disable-next-line no-var + var __soft_worker_pid__: number | undefined; +} + +globalThis.__soft_worker_pid__ ??= process.pid; + +describe('soft mode happy-dom — file A', () => { + it('owns a fresh DOM body', () => { + expect(document.body.innerHTML).toBe(''); + document.body.innerHTML = '
x
'; + }); + + it('mutates storage', () => { + expect(localStorage.length).toBe(0); + localStorage.setItem('from-a', 'leaked-if-soft-reset-broken'); + }); +}); diff --git a/e2e/soft-mode-happy-dom/fixtures/test/b-verify.test.ts b/e2e/soft-mode-happy-dom/fixtures/test/b-verify.test.ts new file mode 100644 index 000000000..1c0fb91ee --- /dev/null +++ b/e2e/soft-mode-happy-dom/fixtures/test/b-verify.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from '@rstest/core'; + +declare global { + // eslint-disable-next-line no-var + var __soft_worker_pid__: number | undefined; +} + +describe('soft mode happy-dom — file B', () => { + it('runs in the same worker as file A', () => { + expect(globalThis.__soft_worker_pid__).toBe(process.pid); + }); + + it('starts with a clean DOM body', () => { + expect(document.body.innerHTML).toBe(''); + expect(document.querySelector('#from-a')).toBeNull(); + }); + + it('starts with clean storage', () => { + expect(localStorage.length).toBe(0); + expect(localStorage.getItem('from-a')).toBeNull(); + }); + + it('HTMLElement.prototype.focus descriptor restored across files', () => { + // setup.ts records the descriptor SHAPE on each module re-eval BEFORE + // re-patching. With softMode handling happy-dom, file-a saw 'value' + // (fresh DOM) and file-b also saw 'value' because softResetEnv's + // protoSnapshot restore wiped file-a's getter-only patch. + const history = ( + globalThis as { __soft_focus_descriptor_history__?: Array } + ).__soft_focus_descriptor_history__; + expect(history).toEqual(['value', 'value']); + }); +}); diff --git a/e2e/soft-mode-happy-dom/fixtures/test/setup.ts b/e2e/soft-mode-happy-dom/fixtures/test/setup.ts new file mode 100644 index 000000000..b31085957 --- /dev/null +++ b/e2e/soft-mode-happy-dom/fixtures/test/setup.ts @@ -0,0 +1,38 @@ +/** + * Mirrors the jsdom fixture's setup-spy.ts but for happy-dom. happy-dom + * exposes the same Element/HTMLElement/Node globals jsdom does, so the + * vendor-style focus monkey-patch + descriptor-history recording is + * portable. softResetEnv's protoSnapshot path handles both env names. + */ +if (typeof HTMLElement !== 'undefined') { + const preDescriptor = Object.getOwnPropertyDescriptor( + HTMLElement.prototype, + 'focus', + ); + ( + globalThis as { __soft_focus_descriptor_history__?: Array } + ).__soft_focus_descriptor_history__ ??= []; + ( + globalThis as { __soft_focus_descriptor_history__?: Array } + ).__soft_focus_descriptor_history__!.push( + !preDescriptor + ? 'missing' + : 'value' in preDescriptor && typeof preDescriptor.value === 'function' + ? 'value' + : preDescriptor.get && !preDescriptor.set + ? 'get-only' + : 'other', + ); + + const originalFocus = HTMLElement.prototype.focus; + Object.defineProperty(HTMLElement.prototype, 'focus', { + configurable: true, + get: () => + function patchedFocus(this: HTMLElement, options?: FocusOptions) { + (globalThis as { __soft_focus_calls__?: number }).__soft_focus_calls__ = + ((globalThis as { __soft_focus_calls__?: number }) + .__soft_focus_calls__ ?? 0) + 1; + return originalFocus.call(this, options); + }, + }); +} diff --git a/e2e/soft-mode-happy-dom/index.test.ts b/e2e/soft-mode-happy-dom/index.test.ts new file mode 100644 index 000000000..b4d068fbc --- /dev/null +++ b/e2e/soft-mode-happy-dom/index.test.ts @@ -0,0 +1,29 @@ +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('experiments.softMode — happy-dom', () => { + it('resets DOM/storage/prototype state between files (happy-dom env)', async ({ + onTestFinished, + }) => { + // Mirrors the jsdom soft-mode fixture for happy-dom. Verifies that + // `softResetEnv` and the prototype-snapshot path handle the + // `happy-dom` env-name branch the same way they handle `jsdom`. + const { expectExecSuccess } = await runRstestCli({ + command: 'rstest', + args: ['run'], + onTestFinished, + options: { + nodeOptions: { + cwd: join(__dirname, './fixtures'), + }, + }, + }); + + await expectExecSuccess(); + }); +}); diff --git a/e2e/soft-mode-recycle/fixtures/rstest.config.mts b/e2e/soft-mode-recycle/fixtures/rstest.config.mts new file mode 100644 index 000000000..6777c5937 --- /dev/null +++ b/e2e/soft-mode-recycle/fixtures/rstest.config.mts @@ -0,0 +1,15 @@ +import { defineConfig } from '@rstest/core'; + +export default defineConfig({ + // maxFilesPerWorker: 1 disposes the runner after every task, so each + // file gets a fresh worker process. With pool.maxWorkers: 2 and 4 files, + // every file's `process.pid` must be unique — the driver asserts that + // pidcount === filecount. Default soft-mode reuse would yield fewer + // pids than files (covered by e2e/soft-mode-reuse). + experiments: { + softMode: { maxFilesPerWorker: 1 }, + }, + pool: { + maxWorkers: 2, + }, +}); diff --git a/e2e/soft-mode-recycle/fixtures/test/f1.test.ts b/e2e/soft-mode-recycle/fixtures/test/f1.test.ts new file mode 100644 index 000000000..00cad24c1 --- /dev/null +++ b/e2e/soft-mode-recycle/fixtures/test/f1.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from '@rstest/core'; +import { recordPid } from './record-pid'; + +recordPid('f1'); + +describe('soft mode recycle — file 1', () => { + it('runs and records its pid', () => { + expect(true).toBe(true); + }); +}); diff --git a/e2e/soft-mode-recycle/fixtures/test/f2.test.ts b/e2e/soft-mode-recycle/fixtures/test/f2.test.ts new file mode 100644 index 000000000..e5dde009f --- /dev/null +++ b/e2e/soft-mode-recycle/fixtures/test/f2.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from '@rstest/core'; +import { recordPid } from './record-pid'; + +recordPid('f2'); + +describe('soft mode recycle — file 2', () => { + it('runs and records its pid', () => { + expect(true).toBe(true); + }); +}); diff --git a/e2e/soft-mode-recycle/fixtures/test/f3.test.ts b/e2e/soft-mode-recycle/fixtures/test/f3.test.ts new file mode 100644 index 000000000..593c31cc0 --- /dev/null +++ b/e2e/soft-mode-recycle/fixtures/test/f3.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from '@rstest/core'; +import { recordPid } from './record-pid'; + +recordPid('f3'); + +describe('soft mode recycle — file 3', () => { + it('runs and records its pid', () => { + expect(true).toBe(true); + }); +}); diff --git a/e2e/soft-mode-recycle/fixtures/test/f4.test.ts b/e2e/soft-mode-recycle/fixtures/test/f4.test.ts new file mode 100644 index 000000000..2d75c3e92 --- /dev/null +++ b/e2e/soft-mode-recycle/fixtures/test/f4.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from '@rstest/core'; +import { recordPid } from './record-pid'; + +recordPid('f4'); + +describe('soft mode recycle — file 4', () => { + it('runs and records its pid', () => { + expect(true).toBe(true); + }); +}); diff --git a/e2e/soft-mode-recycle/fixtures/test/record-pid.ts b/e2e/soft-mode-recycle/fixtures/test/record-pid.ts new file mode 100644 index 000000000..5d9ff07a0 --- /dev/null +++ b/e2e/soft-mode-recycle/fixtures/test/record-pid.ts @@ -0,0 +1,17 @@ +import { appendFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +/** + * Record this file's `process.pid` + the file's tag into a shared log. + * The driver reads this back after the fixture run and asserts the pid + * count matches the file count — proving the runner was disposed after + * each task (the configured `maxFilesPerWorker: 1` cap). + */ +export const PID_LOG_PATH = + process.env.RSTEST_SOFT_RECYCLE_LOG ?? + join(tmpdir(), 'rstest-soft-mode-recycle.log'); + +export const recordPid = (fileTag: string): void => { + appendFileSync(PID_LOG_PATH, `${process.pid}\t${fileTag}\n`); +}; diff --git a/e2e/soft-mode-recycle/index.test.ts b/e2e/soft-mode-recycle/index.test.ts new file mode 100644 index 000000000..4358558f2 --- /dev/null +++ b/e2e/soft-mode-recycle/index.test.ts @@ -0,0 +1,50 @@ +import { existsSync, readFileSync, unlinkSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from '@rstest/core'; +import { runRstestCli } from '../scripts/'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +describe('experiments.softMode — maxFilesPerWorker recycle', () => { + it('disposes the runner after each task when maxFilesPerWorker=1', async ({ + onTestFinished, + }) => { + const logPath = join( + tmpdir(), + `rstest-soft-mode-recycle-${process.pid}-${Date.now()}.log`, + ); + onTestFinished(() => { + if (existsSync(logPath)) unlinkSync(logPath); + }); + + const { expectExecSuccess } = await runRstestCli({ + command: 'rstest', + args: ['run'], + onTestFinished, + options: { + nodeOptions: { + cwd: join(__dirname, './fixtures'), + env: { + RSTEST_SOFT_RECYCLE_LOG: logPath, + }, + }, + }, + }); + await expectExecSuccess(); + + // 4 fixture files × maxFilesPerWorker=1 → every runner is disposed + // after a single task, so every file MUST observe a unique pid. If + // recycling were broken (cap ignored or off-by-one), some pair of + // files would share a pid and this assertion would catch it. + const lines = readFileSync(logPath, 'utf8') + .split('\n') + .filter((l) => l.trim().length > 0); + expect(lines.length).toBe(4); + + const pids = new Set(lines.map((line) => line.split('\t')[0])); + expect(pids.size).toBe(4); + }); +}); diff --git a/e2e/soft-mode-reuse/fixtures/rstest.config.mts b/e2e/soft-mode-reuse/fixtures/rstest.config.mts new file mode 100644 index 000000000..002fa6f25 --- /dev/null +++ b/e2e/soft-mode-reuse/fixtures/rstest.config.mts @@ -0,0 +1,14 @@ +import { defineConfig } from '@rstest/core'; + +export default defineConfig({ + experiments: { + softMode: true, + }, + // 4 test files + 2 workers → at least one worker handles ≥2 files, + // so at least one pair of files must share `process.pid`. The driver + // (e2e/soft-mode-reuse/index.test.ts) asserts the worker-reuse + // invariant after the fixture run by reading the recorded pids back. + pool: { + maxWorkers: 2, + }, +}); diff --git a/e2e/soft-mode-reuse/fixtures/test/f1.test.ts b/e2e/soft-mode-reuse/fixtures/test/f1.test.ts new file mode 100644 index 000000000..7e76da6a8 --- /dev/null +++ b/e2e/soft-mode-reuse/fixtures/test/f1.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from '@rstest/core'; +import { recordPid } from './record-pid'; + +recordPid('f1'); + +describe('soft mode reuse — file 1', () => { + it('runs and records its pid', () => { + expect(true).toBe(true); + }); +}); diff --git a/e2e/soft-mode-reuse/fixtures/test/f2.test.ts b/e2e/soft-mode-reuse/fixtures/test/f2.test.ts new file mode 100644 index 000000000..fda971b2f --- /dev/null +++ b/e2e/soft-mode-reuse/fixtures/test/f2.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from '@rstest/core'; +import { recordPid } from './record-pid'; + +recordPid('f2'); + +describe('soft mode reuse — file 2', () => { + it('runs and records its pid', () => { + expect(true).toBe(true); + }); +}); diff --git a/e2e/soft-mode-reuse/fixtures/test/f3.test.ts b/e2e/soft-mode-reuse/fixtures/test/f3.test.ts new file mode 100644 index 000000000..e7bbe9997 --- /dev/null +++ b/e2e/soft-mode-reuse/fixtures/test/f3.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from '@rstest/core'; +import { recordPid } from './record-pid'; + +recordPid('f3'); + +describe('soft mode reuse — file 3', () => { + it('runs and records its pid', () => { + expect(true).toBe(true); + }); +}); diff --git a/e2e/soft-mode-reuse/fixtures/test/f4.test.ts b/e2e/soft-mode-reuse/fixtures/test/f4.test.ts new file mode 100644 index 000000000..f43c6ebeb --- /dev/null +++ b/e2e/soft-mode-reuse/fixtures/test/f4.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from '@rstest/core'; +import { recordPid } from './record-pid'; + +recordPid('f4'); + +describe('soft mode reuse — file 4', () => { + it('runs and records its pid', () => { + expect(true).toBe(true); + }); +}); diff --git a/e2e/soft-mode-reuse/fixtures/test/record-pid.ts b/e2e/soft-mode-reuse/fixtures/test/record-pid.ts new file mode 100644 index 000000000..989fa2892 --- /dev/null +++ b/e2e/soft-mode-reuse/fixtures/test/record-pid.ts @@ -0,0 +1,20 @@ +import { appendFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +/** + * Record this file's process.pid + the file's name into a shared log + * file. The driver test reads the log after the fixture finishes and + * asserts that fewer unique pids exist than files — proving the worker + * pool reused workers across files. + * + * Cannot use `globalThis` because each worker process has its own + * global, so a counter there is per-worker not workspace-wide. + */ +export const PID_LOG_PATH = + process.env.RSTEST_SOFT_REUSE_LOG ?? + join(tmpdir(), 'rstest-soft-mode-reuse.log'); + +export const recordPid = (fileTag: string): void => { + appendFileSync(PID_LOG_PATH, `${process.pid}\t${fileTag}\n`); +}; diff --git a/e2e/soft-mode-reuse/index.test.ts b/e2e/soft-mode-reuse/index.test.ts new file mode 100644 index 000000000..114d161e6 --- /dev/null +++ b/e2e/soft-mode-reuse/index.test.ts @@ -0,0 +1,54 @@ +import { existsSync, readFileSync, unlinkSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from '@rstest/core'; +import { runRstestCli } from '../scripts/'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +describe('experiments.softMode — multi-worker reuse', () => { + it('reuses workers across files when files > workers', async ({ + onTestFinished, + }) => { + const logPath = join( + tmpdir(), + `rstest-soft-mode-reuse-${process.pid}-${Date.now()}.log`, + ); + onTestFinished(() => { + if (existsSync(logPath)) unlinkSync(logPath); + }); + + const { expectExecSuccess } = await runRstestCli({ + command: 'rstest', + args: ['run'], + onTestFinished, + options: { + nodeOptions: { + cwd: join(__dirname, './fixtures'), + env: { + RSTEST_SOFT_REUSE_LOG: logPath, + }, + }, + }, + }); + await expectExecSuccess(); + + // 4 fixture files × pool.maxWorkers=2 → by pigeon-hole, at least + // one pid must appear on ≥2 lines. If softMode silently fell back + // to `isolate: true`, every file would have its own pid and the + // unique-pid set would have 4 elements — the assertion below + // catches that regression. + const lines = readFileSync(logPath, 'utf8') + .split('\n') + .filter((l) => l.trim().length > 0); + expect(lines.length).toBe(4); + + const pids = new Set(lines.map((line) => line.split('\t')[0])); + expect(pids.size).toBeLessThan(4); + // And at least one worker really was reused (not just one worker + // for all 4 — that would be a single-worker fallback regression). + expect(pids.size).toBeGreaterThanOrEqual(1); + }); +}); diff --git a/e2e/soft-mode/fixtures/rstest.config.mts b/e2e/soft-mode/fixtures/rstest.config.mts new file mode 100644 index 000000000..6a92a3f94 --- /dev/null +++ b/e2e/soft-mode/fixtures/rstest.config.mts @@ -0,0 +1,15 @@ +import { defineConfig } from '@rstest/core'; + +export default defineConfig({ + experiments: { + softMode: true, + }, + testEnvironment: 'jsdom', + // Force a single worker so file-a runs before file-b deterministically. + // The fixture asserts cross-file state-reset semantics that only + // exercise the soft-mode code path when both files share one worker. + pool: { + maxWorkers: 1, + }, + setupFiles: ['./test/setup-spy.ts'], +}); diff --git a/e2e/soft-mode/fixtures/test/a-init.test.ts b/e2e/soft-mode/fixtures/test/a-init.test.ts new file mode 100644 index 000000000..868a1a56a --- /dev/null +++ b/e2e/soft-mode/fixtures/test/a-init.test.ts @@ -0,0 +1,53 @@ +/** + * @rstest-environment jsdom + * + * First file in a soft-mode worker. Establishes state (mutates DOM, sets + * storage, installs fake timers, exercises the focus patch). The next + * files assert all of this is reset between them. + */ +import { describe, expect, it, rstest } from '@rstest/core'; + +declare global { + // eslint-disable-next-line no-var + var __soft_worker_pid__: number | undefined; + // eslint-disable-next-line no-var + var __soft_file_seq__: string[] | undefined; +} + +// Capture the worker's pid; later files assert the same pid (worker reuse). +globalThis.__soft_worker_pid__ ??= process.pid; +globalThis.__soft_file_seq__ ??= []; +globalThis.__soft_file_seq__.push('file-a'); + +describe('soft mode — file A (state setter)', () => { + it('owns a fresh DOM body', () => { + expect(document.body.innerHTML).toBe(''); + document.body.innerHTML = '
x
'; + expect(document.querySelector('#from-file-a')).not.toBeNull(); + }); + + it('owns fresh storage', () => { + expect(localStorage.length).toBe(0); + localStorage.setItem('from-file-a', 'leaked-if-soft-reset-broken'); + sessionStorage.setItem('from-file-a-s', 'leaked-if-soft-reset-broken'); + }); + + it('installs fake timers without throwing', () => { + rstest.useFakeTimers(); + rstest.setSystemTime(new Date('2020-01-01T00:00:00Z')); + expect(new Date().toISOString()).toBe('2020-01-01T00:00:00.000Z'); + // Deliberately DO NOT call useRealTimers() — soft mode must restore + // real timers between files, otherwise a downstream file's + // useFakeTimers() throws "Can't install fake timers twice on the + // same global object". + }); + + it('exercises HTMLElement.prototype.focus patch', () => { + const el = document.createElement('button'); + document.body.appendChild(el); + el.focus(); + expect( + (globalThis as { __soft_focus_calls__?: number }).__soft_focus_calls__, + ).toBeGreaterThan(0); + }); +}); diff --git a/e2e/soft-mode/fixtures/test/b-leak.test.ts b/e2e/soft-mode/fixtures/test/b-leak.test.ts new file mode 100644 index 000000000..ae6e45ad6 --- /dev/null +++ b/e2e/soft-mode/fixtures/test/b-leak.test.ts @@ -0,0 +1,45 @@ +/** + * @rstest-environment jsdom + * + * Deliberately leaks unawaited async work so the next file's + * `preparePool` exercises the `drainPendingAsyncFromPriorFile` absorber. + * + * If the absorber regresses (removed, drain count too low, listener + * install/remove order wrong), this file's leak surfaces as an + * `unhandledRejection` attributed to file-b or file-c → suite fails. + * With the absorber working, the leak is silently absorbed and + * subsequent files run cleanly. + */ +import { describe, expect, it } from '@rstest/core'; + +declare global { + // eslint-disable-next-line no-var + var __soft_file_seq__: string[] | undefined; +} + +globalThis.__soft_file_seq__ ??= []; +globalThis.__soft_file_seq__.push('file-leaker'); + +describe('soft mode — leaker (exercises the absorber)', () => { + it('finishes without awaiting a deferred rejection', () => { + // Schedule a rejection that will fire AFTER this test's microtask + // queue has drained — i.e. after the file's slot has closed. The + // worker survives because soft mode drains + absorbs before + // installing the next file's per-file `unhandledRejection` handler. + setTimeout(() => { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + Promise.reject( + new Error('intentional leak — should be absorbed by drain'), + ); + }, 0); + + // Also schedule a deferred uncaughtException-style throw. Same idea: + // it must not surface in the next file's slot. + setTimeout(() => { + throw new Error('intentional leak — should be absorbed by drain'); + }, 0); + + // Assert something trivial so this counts as a passing test. + expect(true).toBe(true); + }); +}); diff --git a/e2e/soft-mode/fixtures/test/c-verify.test.ts b/e2e/soft-mode/fixtures/test/c-verify.test.ts new file mode 100644 index 000000000..14499e89c --- /dev/null +++ b/e2e/soft-mode/fixtures/test/c-verify.test.ts @@ -0,0 +1,97 @@ +/** + * @rstest-environment jsdom + * + * Runs after `a-init` and `b-leak` in the same worker. Asserts every + * kind of state soft mode is supposed to reset. + */ +import { describe, expect, it, rstest } from '@rstest/core'; + +declare global { + // eslint-disable-next-line no-var + var __soft_worker_pid__: number | undefined; + // eslint-disable-next-line no-var + var __soft_file_seq__: string[] | undefined; +} + +globalThis.__soft_file_seq__ ??= []; +globalThis.__soft_file_seq__.push('file-b'); + +describe('soft mode — file B (state asserter)', () => { + it('runs in the same worker as a-init and b-leak', () => { + expect(globalThis.__soft_worker_pid__).toBe(process.pid); + // Each file pushed its tag; exact sequence pins file ordering AND + // proves the global survived softReset (which only clears env-side + // state, not arbitrary globals). `file-leaker` being present here + // also proves the leaker file completed successfully — its deferred + // unhandled rejection + throw were absorbed by + // `drainPendingAsyncFromPriorFile` before this file's `preparePool` + // installed per-file handlers. If the absorber regresses, those + // leaks would bubble into this file's slot and fail the suite. + expect(globalThis.__soft_file_seq__).toEqual([ + 'file-a', + 'file-leaker', + 'file-b', + ]); + }); + + it('starts with a clean DOM body (file-a leaks reset)', () => { + expect(document.body.innerHTML).toBe(''); + expect(document.querySelector('#from-file-a')).toBeNull(); + }); + + it('starts with clean storage', () => { + expect(localStorage.length).toBe(0); + expect(sessionStorage.length).toBe(0); + expect(localStorage.getItem('from-file-a')).toBeNull(); + expect(sessionStorage.getItem('from-file-a-s')).toBeNull(); + }); + + it('useFakeTimers() does NOT throw after file-a installed them', () => { + // file-a installed fake timers and intentionally never uninstalled. + // Soft mode's between-file teardown should have called useRealTimers() + // via the captured api. If not, sinon's `install()` throws + // "Can't install fake timers twice on the same global object". + expect(() => { + rstest.useFakeTimers(); + rstest.setSystemTime(new Date('2030-06-15T00:00:00Z')); + }).not.toThrow(); + expect(new Date().toISOString()).toBe('2030-06-15T00:00:00.000Z'); + rstest.useRealTimers(); + }); + + it('HTMLElement.prototype.focus was restored to value-descriptor between files', () => { + // setup-spy.ts snapshots the descriptor shape on every module + // re-evaluation BEFORE re-patching. With soft mode working, every + // file's setupFile sees a freshly-restored `value` descriptor (the + // prior file's getter-only patch was wiped by softResetEnv's + // protoSnapshot restore). Without soft mode, file 2+ would see + // `'get-only'` — the prior file's patch lingers. + // + // 3 files in this fixture (a-init, b-leak, c-verify) → 3 captures, + // all should be `'value'`. + const history = ( + globalThis as { __soft_focus_descriptor_history__?: Array } + ).__soft_focus_descriptor_history__; + expect(history).toBeDefined(); + expect(history).toEqual(['value', 'value', 'value']); + }); + + it('Element.prototype.getBoundingClientRect spy is fresh', () => { + // setup-spy.ts re-installed the spy for this file. If file-a's spy + // wasn't restored across the file boundary, the new spy would wrap + // the OLD spy and `.mock.calls` would already be non-empty. + const div = document.createElement('div'); + const mock = ( + div.getBoundingClientRect as unknown as { + mock?: { calls: unknown[] }; + } + ).mock; + // Assert it IS a mock — proves setup-spy.ts re-ran for file-b. + // Without this assertion the spy-restored check would silently pass + // when the spy didn't install at all (regression in setupFile re-eval). + expect(mock).toBeDefined(); + expect(mock!.calls.length).toBe(0); + div.getBoundingClientRect(); + expect(div.getBoundingClientRect().width).toBe(100); + }); +}); diff --git a/e2e/soft-mode/fixtures/test/setup-spy.ts b/e2e/soft-mode/fixtures/test/setup-spy.ts new file mode 100644 index 000000000..26a80891b --- /dev/null +++ b/e2e/soft-mode/fixtures/test/setup-spy.ts @@ -0,0 +1,72 @@ +// Shared setupFile: every test file in this fixture re-evaluates this +// module (rstest re-runs setupFiles per file). The spy and the +// HTMLElement.prototype.focus assignment are deliberate cross-file leaks +// that soft mode must clean up between files. +import { rstest } from '@rstest/core'; + +// Spy on Element.prototype.getBoundingClientRect — registered with +// tinyspy's worker-scope `spies` Set; soft mode's `restoreAll()` between +// files restores the original method so file N+1's setupFile creates a +// fresh spy on the original (not a spy-of-a-spy). +if (typeof Element !== 'undefined') { + rstest.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation( + () => + ({ + x: 0, + y: 0, + width: 100, + height: 100, + top: 0, + left: 0, + right: 100, + bottom: 100, + toJSON: () => ({}), + }) as DOMRect, + ); +} + +// Vendor-style monkey-patch on HTMLElement.prototype.focus — mirrors what +// `@testing-library/user-event`'s `patchFocus` does (replaces the value +// descriptor with a getter-only one via `Object.defineProperty`). Without +// soft mode's prototype snapshot+restore, file N+1's vendor code that +// re-assigns via `prototype.focus = fn` would throw "has only a getter". +// +// Before re-patching, capture the current descriptor's *shape* so tests +// can assert that soft mode reset it back to the original `value+writable` +// form between files. After re-patching, the descriptor is getter-only +// again, so we can't observe the reset by inspecting the descriptor at +// test time — we have to look BEFORE the patch runs. +if (typeof HTMLElement !== 'undefined') { + const preDescriptor = Object.getOwnPropertyDescriptor( + HTMLElement.prototype, + 'focus', + ); + ( + globalThis as { __soft_focus_descriptor_history__?: Array } + ).__soft_focus_descriptor_history__ ??= []; + // 'value' = original / restored; 'get-only' = a prior file's patch is + // still in place; 'missing' = something destroyed the property. + ( + globalThis as { __soft_focus_descriptor_history__?: Array } + ).__soft_focus_descriptor_history__!.push( + !preDescriptor + ? 'missing' + : 'value' in preDescriptor && typeof preDescriptor.value === 'function' + ? 'value' + : preDescriptor.get && !preDescriptor.set + ? 'get-only' + : 'other', + ); + + const originalFocus = HTMLElement.prototype.focus; + Object.defineProperty(HTMLElement.prototype, 'focus', { + configurable: true, + get: () => + function patchedFocus(this: HTMLElement, options?: FocusOptions) { + (globalThis as { __soft_focus_calls__?: number }).__soft_focus_calls__ = + ((globalThis as { __soft_focus_calls__?: number }) + .__soft_focus_calls__ ?? 0) + 1; + return originalFocus.call(this, options); + }, + }); +} diff --git a/e2e/soft-mode/index.test.ts b/e2e/soft-mode/index.test.ts new file mode 100644 index 000000000..4befb0f52 --- /dev/null +++ b/e2e/soft-mode/index.test.ts @@ -0,0 +1,33 @@ +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('experiments.softMode', () => { + it('reuses workers across files while resetting per-file env state', async ({ + onTestFinished, + }) => { + // The fixture asserts: + // - same worker pid for file-a and file-b (worker reuse) + // - DOM body, localStorage, sessionStorage cleared between files + // - HTMLElement.prototype mutations from file-a are reverted + // - `useFakeTimers()` in file-b doesn't throw "twice on the same + // global" after file-a installed them and never uninstalled + // - tinyspy-registered spies restored between files + const { expectExecSuccess } = await runRstestCli({ + command: 'rstest', + args: ['run'], + onTestFinished, + options: { + nodeOptions: { + cwd: join(__dirname, './fixtures'), + }, + }, + }); + + await expectExecSuccess(); + }); +}); diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index 5e69665c1..a3907330e 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -284,13 +284,44 @@ const createDefaultConfig = (): NormalizedConfig => ({ export const withDefaultConfig = (config: RstestConfig): NormalizedConfig => { const merged = mergeRstestConfig( - createDefaultConfig(), + // `createDefaultConfig()` returns `NormalizedConfig`, whose internal + // `isolate` is wider (`boolean | 'soft'`) than the public + // `RstestConfig.isolate`. The cast narrows it for the merge step; + // the `as NormalizedConfig` below re-widens to internal. + createDefaultConfig() as unknown as RstestConfig, config, ) as NormalizedConfig; merged.setupFiles = castArray(merged.setupFiles); merged.globalSetup = castArray(merged.globalSetup); + // Normalize `experiments.softMode` to the internal `isolate: 'soft'` + // representation. The internal `IsolateMode` (`pool/types.ts`) keeps the + // three-way distinction (`true | false | 'soft'`) so the pool and worker + // can encode strict / reuse / reuse-with-reset without adding a fourth + // axis. The public surface stays `boolean | undefined` for `isolate` and + // gates soft on the experiments flag so the contract is clear: this + // mode is experimental and may change shape before stabilising. + // + // `softMode` accepts `true` or an options object (`{ maxFilesPerWorker }`); + // any truthy value enables the mode. + if (merged.experiments?.softMode) { + // Surface the conflict when a user has BOTH `isolate: false` (explicit + // reuse-without-reset) and `experiments.softMode` enabled (reuse-WITH- + // reset). softMode wins because it's the strictly-stronger semantic, + // but silent override is surprising; one warning per config-load is + // cheap and self-documenting. + if (merged.isolate === false) { + logger.warn( + '[rstest] `experiments.softMode` overrides `isolate: false`. ' + + 'Soft mode reuses workers AND resets per-file env state; ' + + '`isolate: false` is reuse without reset. Remove one to silence ' + + 'this warning.', + ); + } + (merged as unknown as { isolate: 'soft' }).isolate = 'soft'; + } + const outputDistPathRoot = getOutputDistPathRoot(merged.output?.distPath); merged.output.distPath = { root: formatRootStr(outputDistPathRoot, merged.root), diff --git a/packages/core/src/pool/index.ts b/packages/core/src/pool/index.ts index 5d4fc66cc..c8c671250 100644 --- a/packages/core/src/pool/index.ts +++ b/packages/core/src/pool/index.ts @@ -338,10 +338,17 @@ export const createPool = async ({ const numCpus = getNumCpus(); const { - normalizedConfig: { pool: poolOptions, isolate }, + normalizedConfig: { pool: poolOptions, isolate, experiments }, reporters, } = context; + // `softMode` accepts `true` or `{ maxFilesPerWorker }`. The truthy check + // is mirrored in `withDefaultConfig` where the public flag is collapsed + // into the internal `isolate: 'soft'`; here we only need the tuning value. + const softModeOptions = + typeof experiments?.softMode === 'object' ? experiments.softMode : null; + const softModeMaxFilesPerWorker = softModeOptions?.maxFilesPerWorker; + const workerKind: PoolWorkerKind = poolOptions.type ?? 'forks'; const threadsCount = @@ -374,6 +381,7 @@ export const createPool = async ({ isolate, maxWorkers, minWorkers, + softModeMaxFilesPerWorker, execArgv: [ ...(poolOptions?.execArgv ?? []), ...execArgv, diff --git a/packages/core/src/pool/pool.ts b/packages/core/src/pool/pool.ts index 731501e88..e7181bf6b 100644 --- a/packages/core/src/pool/pool.ts +++ b/packages/core/src/pool/pool.ts @@ -9,8 +9,21 @@ import { createPoolWorker } from './workers'; * - one task per worker at a time (concurrentTasksPerWorker=1) * - parallel dispatch up to maxWorkers, slot-waiter blocks excess callers * - isolate=true: fresh runner per task, stopped in the background - * - isolate=false: idle runners reused, lazy-spawned on demand + * - isolate='soft': idle runners reused; worker resets test env per task + * - isolate=false: idle runners reused, no per-task reset */ +/** + * Default cap for tasks handled by a single worker in `experiments.softMode` + * (or `isolate: false`). Empirically chosen from a 74-file jsdom-heavy lib + * where heap grew from 71MB at file 1 to 4GB at file 65 in a single + * worker; recycling at 20 keeps the peak heap under ~1GB and prevents the + * 2-3× per-test slowdown that GC pressure inflicted at the top of that + * range. Strict isolate runners are single-use so this doesn't apply. + * + * Override via `experiments.softMode.maxFilesPerWorker`. + */ +const DEFAULT_SOFT_MODE_MAX_TASKS = 20; + export class Pool { private readonly options: PoolOptions; private readonly idleRunners: PoolRunner[] = []; @@ -65,6 +78,10 @@ export class Pool { return await runner.collectTests(task); } finally { this.options.memoryGate?.recordResolve(heapBaseline); + // Increment the runner's task counter BEFORE release so `isUsable()` + // can flip false when the cap is reached. `releaseRunner` then + // disposes instead of returning to the idle pool. + runner.recordTaskCompleted(); this.releaseRunner(runner); } } @@ -113,7 +130,21 @@ export class Pool { const workerId = this.acquireWorkerId(); const worker = createPoolWorker(task, this.options, workerId); gate?.attachWorker(worker); - const runner = new PoolRunner(worker, { workerId }); + const runner = new PoolRunner(worker, { + workerId, + // Cap soft-mode reuse — heap accumulates monotonically across + // file evaluations (vendor modules + React fiber trees + JSDOM + // nodes), and by ~50 files on a jsdom-heavy lib the worker is + // GC-thrashing at multi-GB heap, slowing each test 2-3×. Default + // applies only to soft mode; strict-isolate runners are + // single-use anyway. `isolate: false` (reuse without reset) also + // benefits because the same heap pressure applies. + maxTasks: + this.options.isolate === true + ? 0 + : (this.options.softModeMaxFilesPerWorker ?? + DEFAULT_SOFT_MODE_MAX_TASKS), + }); this.activeRunners.add(runner); try { await runner.start(); @@ -175,12 +206,11 @@ export class Pool { this.activeRunners.delete(runner); // `isolate: true`, closing, or unusable — never reuse. - if ( - this.options.isolate !== false || - this.isClosing || - this.isClosed || - !runner.isUsable() - ) { + // `isolate: false` and `isolate: 'soft'` both reuse runners; only the + // worker-side per-file reset semantics differ. + const canReuse = + this.options.isolate === false || this.options.isolate === 'soft'; + if (!canReuse || this.isClosing || this.isClosed || !runner.isUsable()) { // Background dispose. The slot stays accounted for in `stoppingRunners` // until the child actually exits, so `isolate: true` cannot transiently // exceed `maxWorkers` and `close()` can drain in-flight stops. diff --git a/packages/core/src/pool/poolRunner.ts b/packages/core/src/pool/poolRunner.ts index 497ab8c33..92f8fe665 100644 --- a/packages/core/src/pool/poolRunner.ts +++ b/packages/core/src/pool/poolRunner.ts @@ -64,6 +64,20 @@ let nextTaskSeq = 0; type PoolRunnerOptions = { workerId: number; + /** + * For `experiments.softMode` (or `isolate: false`) where the worker is + * reused across tasks, this caps how many tasks a single worker handles + * before `isUsable()` reports false and the pool disposes it for a + * fresh one. Unbounded reuse leaks heap (vendor modules, React fiber + * trees, JSDOM nodes, accumulated closures) — by file 50+ on a + * jsdom-heavy lib we observed 4GB+ heap and 2-3× per-test slowdown + * from GC pressure. + * + * Default `0` = unbounded (legacy behavior). Recommended for soft + * mode: 20-30. Strict isolate (`isolate: true`) never reuses runners + * so this cap is irrelevant there. + */ + maxTasks?: number; }; /** @@ -99,8 +113,18 @@ export class PoolRunner { */ private crashed = false; + /** + * Tasks completed on this runner. When `maxTasks > 0`, `isUsable()` + * starts returning false once the count reaches the cap, prompting the + * pool to dispose this runner and spawn a fresh one. See + * `PoolRunnerOptions.maxTasks` for rationale. + */ + private tasksRun = 0; + private readonly maxTasks: number; + constructor(worker: PoolWorker, options: PoolRunnerOptions) { this.workerId = options.workerId; + this.maxTasks = Math.max(0, options.maxTasks ?? 0); this.worker = worker; this.handleMessage = this.handleMessage.bind(this); @@ -113,7 +137,19 @@ export class PoolRunner { } isUsable(): boolean { - return this.state === 'STARTED' && !this.crashed; + if (this.state !== 'STARTED' || this.crashed) return false; + if (this.maxTasks > 0 && this.tasksRun >= this.maxTasks) return false; + return true; + } + + /** + * Called by the pool after each completed task. Drives the soft-mode + * heap-pressure relief: once `tasksRun >= maxTasks`, `isUsable()` flips + * to false and the pool's `releaseRunner` path disposes us instead of + * returning us to the idle pool. + */ + recordTaskCompleted(): void { + this.tasksRun++; } start(): Promise { diff --git a/packages/core/src/pool/types.ts b/packages/core/src/pool/types.ts index 1f0fde24c..2084b152c 100644 --- a/packages/core/src/pool/types.ts +++ b/packages/core/src/pool/types.ts @@ -10,11 +10,26 @@ export type PoolTask = { rpcMethods: RuntimeRPC; }; +/** + * Isolation strategy for the pool. See `RstestConfig.isolate` for full docs. + * - `true`: fresh runner per task (process-per-file isolation) + * - `'soft'`: reuse runner across tasks; worker resets test env per task + * - `false`: reuse runner across tasks with no per-task reset + */ +export type IsolateMode = boolean | 'soft'; + export type PoolOptions = { workerEntry: string; maxWorkers: number; minWorkers: number; - isolate: boolean; + isolate: IsolateMode; + /** + * Cap on tasks dispatched to a single runner before it is disposed and + * a fresh one spawns. Only consulted when `isolate === 'soft'` (or + * `false`, where reuse also accrues heap). `0` or omitted means + * unbounded reuse. + */ + softModeMaxFilesPerWorker?: number; env?: Record; execArgv?: string[]; /** diff --git a/packages/core/src/runtime/worker/runInPool.ts b/packages/core/src/runtime/worker/runInPool.ts index f6452a519..01a084bfd 100644 --- a/packages/core/src/runtime/worker/runInPool.ts +++ b/packages/core/src/runtime/worker/runInPool.ts @@ -1,3 +1,4 @@ +import { appendFileSync } from 'node:fs'; import type { FileCoverageData } from 'istanbul-lib-coverage'; import { isMainThread, threadId } from 'node:worker_threads'; import { install } from 'source-map-support'; @@ -58,6 +59,305 @@ const registerGlobalApi = (api: Rstest) => { const globalCleanups: (() => void)[] = []; let isTeardown = false; +/** + * Worker-scope test-environment cache. In `isolate: 'soft' | false` the + * worker is reused across files, so paying the jsdom/happyDom setup cost + * per file is wasteful — and tearing the env down between files races with + * any async work the previous file scheduled (e.g. React commit phase + * accessing `window` after `dom.window.close()` already ran). + * + * The cached entry is reset between files via `softResetEnv` (clear DOM, + * reset URL) so leaked DOM state from file N doesn't contaminate file N+1. + * + * `protoSnapshot` captures the original property descriptors of well-known + * DOM prototypes so we can revert in-place mutations vendor packages make + * during a file run (e.g. `@testing-library/user-event`'s `patchFocus` + * which replaces `HTMLElement.prototype.focus` with a getter-only + * descriptor; the next file's bundle re-evaluation does + * `prototype.focus = newFn` and dies on "has only a getter"). + */ +let cachedEnv: + | { + name: string; + teardown: (global: any) => MaybePromise; + protoSnapshot: ProtoEntry[]; + } + | undefined; + +// In soft mode the per-file `cleanupFns` does NOT include the env teardown +// (the env persists across files). Without this hook, a worker that exits +// cleanly (no fatal error, pool drained) leaks the JSDOM window — virtual +// console handlers, scheduled timers, the cookie jar, the global Proxy. +// On `beforeExit` we still have a chance to flush; on `exit` (synchronous) +// we just attempt the call and ignore. +// +// `process.exit` short-circuits `beforeExit`, but rstest's pool sends +// graceful shutdown messages and the worker returns from the message loop, +// so beforeExit fires in the common case. +const teardownCachedEnvOnExit = (): void => { + if (!cachedEnv) return; + try { + // Fire-and-forget; the event loop is already winding down so awaiting + // a promise here is best-effort. JSDOM's teardown is synchronous as of + // jsdom 26.x — this stays meaningful even without await. + void cachedEnv.teardown(global); + } catch { + // best-effort + } finally { + cachedEnv = undefined; + } +}; +process.on('beforeExit', teardownCachedEnvOnExit); +process.on('exit', teardownCachedEnvOnExit); + +/** + * Snapshot every own-property descriptor on the well-known DOM prototypes + * the worker exposes via globals. This is the "all-keys" form: we capture + * each descriptor at env-setup time and re-apply it between files when its + * shape has drifted (e.g. `@testing-library/user-event`'s `patchFocus` + * replaces `HTMLElement.prototype.focus` with a getter-only descriptor on + * the first `userEvent.click()`; without restore, file N+1's vendor code + * that re-assigns via `prototype.focus = fn` throws "has only a getter"). + * + * Why "all keys" and not a curated allow-list: vendor monkey-patching + * targets are open-ended (focus, blur, addEventListener, dispatchEvent, + * scrollIntoView, getBoundingClientRect, animate, matches, closest, …). + * A curated list misses one and you get confusing failures only on + * specific libs. The cost is small — ~3 prototypes × ~50-100 own keys + * each, descriptor lookups are O(1). + * + * `constructor` is excluded: rewriting it can break `instanceof` checks + * in any code that has a stale constructor reference. + */ +type ProtoEntry = { + proto: object; + descriptors: Record; +}; + +const SKIP_PROTO_KEYS = new Set(['constructor']); + +const captureProtoDescriptors = ( + proto: object, +): Record => { + const descriptors: Record = {}; + const keys: PropertyKey[] = [ + ...Object.getOwnPropertyNames(proto), + ...Object.getOwnPropertySymbols(proto), + ]; + for (const key of keys) { + if (SKIP_PROTO_KEYS.has(key)) continue; + const d = Object.getOwnPropertyDescriptor(proto, key); + if (d) descriptors[key as string] = d; + } + return descriptors; +}; + +const captureProtoSnapshot = (win: any): ProtoEntry[] => { + const protos = [ + win.HTMLElement?.prototype, + win.Element?.prototype, + win.Node?.prototype, + ].filter(Boolean) as object[]; + return protos.map((proto) => ({ + proto, + descriptors: captureProtoDescriptors(proto), + })); +}; + +const descriptorEquals = ( + a: PropertyDescriptor, + b: PropertyDescriptor, +): boolean => + a.value === b.value && + a.get === b.get && + a.set === b.set && + a.writable === b.writable && + a.enumerable === b.enumerable && + a.configurable === b.configurable; + +const restoreProtoSnapshot = (snapshot: ProtoEntry[]): void => { + for (const { proto, descriptors } of snapshot) { + const snapshotKeys: PropertyKey[] = [ + ...Object.getOwnPropertyNames(descriptors), + ...Object.getOwnPropertySymbols(descriptors), + ]; + + // Step 1: restore mutated descriptors to their original shape. + for (const key of snapshotKeys) { + const original = (descriptors as any)[key] as PropertyDescriptor; + const current = Object.getOwnPropertyDescriptor(proto, key); + if (current && descriptorEquals(current, original)) continue; + try { + Object.defineProperty(proto, key, original); + } catch { + // Property is non-configurable (some Web IDL bindings) and was + // mutated in-place — we can't undo. Falls through; the caller + // accepts that some leaks may persist. + } + } + + // Step 2: drop own-keys that didn't exist at snapshot time. A prior + // file could have added a brand-new method to the prototype (e.g. + // `HTMLElement.prototype.myCustomHelper = fn`); without symmetric + // removal it would persist into the next file. Symbols are included + // — vendor packages occasionally stash markers behind Symbols (e.g. + // user-event's `Symbol('patched...')` marker) that we want to clear. + const snapshotKeySet = new Set(snapshotKeys); + const currentKeys: PropertyKey[] = [ + ...Object.getOwnPropertyNames(proto), + ...Object.getOwnPropertySymbols(proto), + ]; + for (const key of currentKeys) { + if (SKIP_PROTO_KEYS.has(key)) continue; + if (snapshotKeySet.has(key)) continue; + try { + delete (proto as any)[key]; + } catch { + // Same reason as above — non-configurable. Best-effort. + } + } + } +}; + +/** + * Per-step reset wrapper. Each step is independent — if one throws the + * others should still run. We log to stderr in `DEBUG=rstest:soft-mode` + * so silent failures can surface without forcing every consumer to opt + * into noisy logs. + * + * Returning `void` keeps the call sites readable; the caller doesn't + * need to know which step failed, just that the env wound up as clean + * as best-effort can make it. + */ +const softResetStep = (label: string, fn: () => void): void => { + try { + fn(); + } catch (e) { + if (process.env.DEBUG?.includes('rstest:soft-mode')) { + const msg = e instanceof Error ? e.message : String(e); + process.stderr.write( + `[rstest:soft-mode] reset step "${label}" failed: ${msg}\n`, + ); + } + } +}; + +/** + * Drain pending microtasks and a few macrotask cycles, absorbing any + * `unhandledRejection` / `uncaughtException` that fires during the drain. + * + * Why: under `experiments.softMode` (or `isolate: false`), a worker + * survives across files. If the previous file's tests started an XHR + * (e.g. via a `useEffect` that wasn't awaited in the test body), the + * XHR is still in flight when the file ends. Once the previous file's + * mock-server handlers have been reset and the file slot has closed, the + * XHR resolves — its `onUnhandledRequest` error bubbles up as an + * unhandled rejection and lands in the NEXT file's slot, wrongly + * attributing the failure. + * + * In `isolate: true`, the worker process dies between files, so the + * pending XHR dies with it. In Jest, the per-file `vm.Context` is torn + * down — same effect. Worker-reuse modes don't get that for free, so + * we recreate the moral equivalent: give pending async ~5 macrotask + * cycles to finish and absorb any errors that surface during the drain. + * + * Errors absorbed here are NOT attributable to the next file in any + * useful way — they originate in the previous file and only manifest + * now. Surfacing them as the next file's failure is worse than dropping + * them (the previous file already passed its assertions; if it had a + * latent leak, that's a test-code hygiene issue users should address + * separately). Set `DEBUG=rstest:soft-mode` to log the absorbed count. + */ +const drainPendingAsyncFromPriorFile = async (): Promise => { + const absorbed: unknown[] = []; + const swallow = (e: unknown) => { + absorbed.push(e); + }; + process.on('unhandledRejection', swallow); + process.on('uncaughtException', swallow); + try { + // Each iteration awaits one `setImmediate` cycle: that drains all + // currently-queued microtasks (Promise jobs run before the next macro) + // plus one macrotask batch. 5 cycles is enough to settle typical + // fetch → response → React commit chains; more is wasted budget. + for (let i = 0; i < 5; i++) { + await new Promise((resolve) => + getRealTimers().setImmediate + ? getRealTimers().setImmediate!(resolve) + : getRealTimers().setTimeout!(resolve, 0), + ); + } + } finally { + process.removeListener('unhandledRejection', swallow); + process.removeListener('uncaughtException', swallow); + } + if (absorbed.length > 0 && process.env.DEBUG?.includes('rstest:soft-mode')) { + process.stderr.write( + `[rstest:soft-mode] absorbed ${absorbed.length} async error(s) from prior file\n`, + ); + } +}; + +const softResetEnv = (envName: string, protoSnapshot?: ProtoEntry[]): void => { + if (envName !== 'jsdom' && envName !== 'happy-dom') return; + const g = global as unknown as { + document?: { + body?: { innerHTML: string }; + head?: { innerHTML: string }; + cookie?: string; + }; + window?: { + history?: { replaceState?: Function }; + scrollTo?: Function; + localStorage?: { clear?: () => void }; + sessionStorage?: { clear?: () => void }; + }; + }; + + softResetStep('body.innerHTML', () => { + if (g.document?.body) g.document.body.innerHTML = ''; + }); + softResetStep('head.innerHTML', () => { + if (g.document?.head) g.document.head.innerHTML = ''; + }); + softResetStep('history.replaceState', () => { + g.window?.history?.replaceState?.(null, '', '/'); + }); + softResetStep('scrollTo', () => { + g.window?.scrollTo?.(0, 0); + }); + softResetStep('localStorage.clear', () => { + g.window?.localStorage?.clear?.(); + // Also clear via globalThis in case test code references the global + // shortcut rather than `window.localStorage` (some helpers do). + (globalThis as any).localStorage?.clear?.(); + }); + softResetStep('sessionStorage.clear', () => { + g.window?.sessionStorage?.clear?.(); + (globalThis as any).sessionStorage?.clear?.(); + }); + softResetStep('cookies', () => { + // Setting `cookie` to an expired version of each existing pair drops + // it. Note: cookies set with a non-root `path` won't be wiped by this + // — tests that set such cookies need to clear them in afterEach. + if (g.document && typeof g.document.cookie === 'string') { + const cookies = g.document.cookie.split(';'); + for (const c of cookies) { + const eqIx = c.indexOf('='); + const name = (eqIx > -1 ? c.slice(0, eqIx) : c).trim(); + if (name) { + g.document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/`; + } + } + } + }); + if (protoSnapshot) { + softResetStep('protoSnapshot.restore', () => { + restoreProtoSnapshot(protoSnapshot); + }); + } +}; + const setErrorName = (error: Error, type: string): Error => { try { error.name = type; @@ -128,6 +428,38 @@ const preparePool = async ( }); globalCleanups.length = 0; + // If a cachedEnv exists, the worker is being reused for another file. + // Drain any pending async work scheduled by the previous file BEFORE we + // install this file's `unhandledRejection` listener — otherwise leftover + // XHRs / promise chains from the previous file would resolve into this + // file's slot and surface as misattributed errors. This is the moral + // equivalent of the per-file vm.Context teardown Jest gets for free. + if (cachedEnv && context.runtimeConfig.isolate !== true) { + await drainPendingAsyncFromPriorFile(); + } + + // DIAGNOSTIC: heap usage at file boundary, gated on env var so it doesn't + // pollute normal runs. Tracks across the worker's file sequence so we can + // see heap growth + GC behaviour. + // Optional diagnostic: heap usage at file boundary. Set + // `RSTEST_HEAP_TRACE=1` and read the per-pid log files under /private/tmp + // to observe heap growth across a worker's file sequence — useful when + // tuning soft-mode worker recycle cap. + if (process.env.RSTEST_HEAP_TRACE === '1') { + try { + const m = process.memoryUsage(); + const g = globalThis as { __rstest_file_seq__?: number }; + g.__rstest_file_seq__ = (g.__rstest_file_seq__ ?? 0) + 1; + const seq = g.__rstest_file_seq__; + appendFileSync( + `/private/tmp/rstest-heap-${process.pid}.log`, + `${seq}\t${context.runtimeConfig.isolate}\t${(m.heapUsed / 1024 / 1024).toFixed(1)}\t${(m.heapTotal / 1024 / 1024).toFixed(1)}\t${(m.rss / 1024 / 1024).toFixed(1)}\t${(m.external / 1024 / 1024).toFixed(1)}\t${testPath.split('/').slice(-2).join('/')}\n`, + ); + } catch { + // best-effort diagnostic; don't fail the file + } + } + const taskContext = createNodeTaskContext(); setRealTimers(); @@ -168,7 +500,13 @@ const preparePool = async ( silent, }, emitInterceptedLog: async (log) => { - await rpc.onConsoleLog(log); + try { + await rpc.onConsoleLog(log); + } catch { + // RPC may already be closed if a pending async log (e.g. React + // commit or microtask) fires after teardown. Drop it; the log is + // best-effort and we don't want to spam unhandled rejections. + } }, writeOriginalLog: createOriginalLogWriter(), }); @@ -231,43 +569,91 @@ const preparePool = async ( const uncaughtException = (e: Error) => handleError(e, 'uncaughtException'); const unhandledRejection = (e: Error) => handleError(e, 'unhandledRejection'); - process.on('uncaughtException', uncaughtException); - process.on('unhandledRejection', unhandledRejection); - + // Register the cleanup BEFORE the listener install. Without this order + // an early throw between `process.on(...)` and the matching + // `globalCleanups.push(...)` would leak the listener — across many such + // throws in soft mode the worker accumulates duplicate handlers and any + // `unhandledRejection` fires N times, attributing the same error to N + // earlier file slots. globalCleanups.push(() => { process.off('uncaughtException', uncaughtException); process.off('unhandledRejection', unhandledRejection); }); + process.on('uncaughtException', uncaughtException); + process.on('unhandledRejection', unhandledRejection); const { api, runner } = await createRstestRuntime(workerState, { taskContext, }); tracker?.transition('envSetup'); - switch (testEnvironment.name) { - case 'node': - break; - case 'jsdom': { - const { environment } = await import('./env/jsdom'); - const { teardown } = await environment.setup( - global, - testEnvironment.options || {}, - ); - cleanupFns.push(() => teardown(global)); - break; + const isolateMode = context.runtimeConfig.isolate; + const canReuseEnv = + isolateMode !== true && + cachedEnv !== undefined && + cachedEnv.name === testEnvironment.name; + + if (canReuseEnv) { + // Worker is being reused for another file; soft-reset the env in place + // (clear DOM + restore mutated DOM prototype descriptors) instead of + // paying the full setup cost again. Pending-async drain already ran + // at the top of preparePool (before per-file listeners installed). + softResetEnv(cachedEnv!.name, cachedEnv!.protoSnapshot); + } else { + // Tear down any prior env of the wrong type before installing a fresh one. + if (cachedEnv) { + try { + await cachedEnv.teardown(global); + } catch { + // ignore — installing the new env will overwrite globals anyway. + } + cachedEnv = undefined; } - case 'happy-dom': { - const { environment } = await import('./env/happyDom'); - const { teardown } = await environment.setup( - global, - testEnvironment.options || {}, - ); - cleanupFns.push(async () => teardown(global)); - break; + switch (testEnvironment.name) { + case 'node': + break; + case 'jsdom': { + const { environment } = await import('./env/jsdom'); + const { teardown } = await environment.setup( + global, + testEnvironment.options || {}, + ); + cachedEnv = { + name: 'jsdom', + teardown, + protoSnapshot: captureProtoSnapshot(global as any), + }; + break; + } + case 'happy-dom': { + const { environment } = await import('./env/happyDom'); + const { teardown } = await environment.setup( + global, + testEnvironment.options || {}, + ); + cachedEnv = { + name: 'happy-dom', + teardown, + protoSnapshot: captureProtoSnapshot(global as any), + }; + break; + } + default: + throw new Error(`Unknown test environment: ${testEnvironment.name}`); } - default: - throw new Error(`Unknown test environment: ${testEnvironment.name}`); } + + // In strict isolation, the env is torn down after the file via `cleanupFns`. + // In soft mode, the cached env persists; teardown only fires when the + // worker exits (handled by the pool, not this function). + if (isolateMode === true && cachedEnv) { + const env = cachedEnv; + cleanupFns.push(async () => { + await env.teardown(global); + cachedEnv = undefined; + }); + } + tracker?.transition('prepare'); if (globals) { @@ -317,7 +703,7 @@ const loadFiles = async ({ runtimeDistPath?: string; testPath: string; interopDefault: boolean; - isolate: boolean; + isolate: boolean | 'soft'; outputModule: boolean; tracker?: PhaseTracker; }): Promise => { @@ -326,7 +712,10 @@ const loadFiles = async ({ : await import('./loadModule'); // clean rstest core cache manually - if (!isolate) { + // Runs for any non-strict isolate (`false` or `'soft'`): the worker + // process is reused across files, so rstest's internal state needs to + // start clean for each new file. + if (isolate !== true) { await loadModule({ codeContent: `if (global && typeof global.__rstest_clean_core_cache__ === 'function') { global.__rstest_clean_core_cache__(); @@ -410,13 +799,50 @@ export const runInPool = async ( process.exit = exit; }); + // Captured by preparePool — used by teardown to perform per-file resets + // when the worker is reused (`isolate !== true`). + let perFileApi: Rstest | undefined; + const teardown = async () => { await new Promise((resolve) => getRealTimers().setTimeout!(resolve)); + // Soft/non-strict isolate: process is reused for the next file, so we + // must reset per-file global state that would otherwise leak. + // + // Per-api rstest.restoreAllMocks() only reaches spies registered to the + // CURRENT file's `mocks` Set — spies from prior files are orphaned when + // their api is GC'd, leaving the property descriptors patched. Use + // tinyspy's worker-scope `restoreAll()` instead: it walks the same + // module-level `spies` Set that `internalSpyOn` registers into, so it + // restores every spy in the worker regardless of which api created it. + // + // The fake-timers reset is also critical: sinon's `install()` rejects a + // second install on the same global, so the next file's + // `useFakeTimers()` would throw "Can't install fake timers twice on the + // same global object" without this. + if (isolate !== true) { + if (perFileApi) { + try { + if (perFileApi.rstest.isFakeTimers()) { + perFileApi.rstest.useRealTimers(); + } + } catch { + // api may already be in a torn-down state; the next file's + // preparePool will create a fresh one. + } + } + try { + const { restoreAll } = await import('tinyspy'); + restoreAll(); + } catch { + // tinyspy not available or registry already empty — nothing to do. + } + } + // Run teardown await Promise.all(cleanups.map((fn) => fn())); - if (!isolate) { + if (isolate !== true) { const { clearModuleCache } = options.context.outputModule ? await import('./loadEsModule') : await import('./loadModule'); @@ -435,7 +861,9 @@ export const runInPool = async ( cleanup, unhandledErrors, interopDefault, + api, } = await preparePool(options); + perFileApi = api; const { assetFiles, sourceMaps: sourceMapsFromAssets } = assets || (await rpc.getAssetsByEntry()); sourceMaps = sourceMapsFromAssets; @@ -500,6 +928,7 @@ export const runInPool = async ( interopDefault, taskContext: preparedTaskContext, } = await preparePool(options, tracker); + perFileApi = api; taskContext = preparedTaskContext; if (detectAsyncLeaks) { asyncLeakDetector = createAsyncLeakDetector(taskContext); diff --git a/packages/core/src/types/config.ts b/packages/core/src/types/config.ts index deb3a7a39..bf4bb5711 100644 --- a/packages/core/src/types/config.ts +++ b/packages/core/src/types/config.ts @@ -242,6 +242,26 @@ export type EnvironmentWithOptions = { options?: Record; }; +/** + * Tuning knobs for `experiments.softMode`. Reserved for soft-mode-specific + * concerns; keep general pool options on `RstestPoolOptions`. + */ +export interface SoftModeOptions { + /** + * Cap on the number of test files a single worker handles before it is + * disposed and a fresh one spawns. Bounds the heap growth that accumulates + * from per-file module state (vendor caches, React fiber trees, JSDOM + * nodes) across a reused worker. + * + * Set higher for libs with light tests, lower for heavy React/JSDOM + * workloads where heap pressure dominates wall time. Has no effect when + * `softMode` is disabled. + * + * @default 20 + */ + maxFilesPerWorker?: number; +} + export interface RstestConfig { /** * Extend configuration from adapters @@ -326,11 +346,68 @@ export interface RstestConfig { */ pool?: RstestPoolType | RstestPoolOptions; /** - * Run tests in an isolated environment + * Isolation strategy between test files. + * + * - `true` (default): each test file runs in a fresh worker process. Strongest + * isolation but pays the cost of process fork + bundle load + env setup + * on every file. + * - `false`: workers persist with no per-file env reset. Fastest but + * leaks every kind of state between files. + * + * For an in-between option that reuses workers but resets per-file env + * state (DOM, timers, spies, storage, prototype mutations), opt into + * the experimental `experiments.softMode` flag instead — most tests are + * idempotent enough to benefit without the per-file fork cost. * * @default true */ isolate?: boolean; + /** + * Experimental features. Shape may change before stabilising; opt in + * deliberately and pin your `@rstest/core` version if you depend on + * the exact semantics. + */ + experiments?: { + /** + * Reuse worker processes across test files while still resetting + * per-file env state between them. + * + * What's reset between files: + * - DOM contents (`document.body/head` innerHTML, URL, scroll) + * - Web storage (`localStorage`, `sessionStorage`, document cookies) + * - DOM prototype descriptors mutated by vendor packages (e.g. + * `HTMLElement.prototype.focus` patched by + * `@testing-library/user-event`) + * - Sinon fake timers (`useRealTimers()` on the prior file's api) + * - Tinyspy spies via the worker-scope `restoreAll()` + * + * What survives across files (this is the speedup): + * - The worker process itself (no fork cost) + * - Node's module cache for vendor deps (no `import('jsdom')` cold load) + * - JSDOM window instance (no `new JSDOM()` per file) + * + * Trade-offs vs `isolate: true`: + * - Tests that depend on per-file `rstest.mock(...)` module factory + * re-application may need to stay on `isolate: true`. The module + * mock registry is per-bundle (one bundle per file in rstest), but + * tests that interact with module-level singletons inside vendors + * (final-form's `keysCache`, msw's interceptor state) can leak. + * - Heap grows monotonically across files in a reused worker; the + * `maxFilesPerWorker` cap (default 20) recycles workers + * periodically to prevent GC pressure. Tune higher for libs with + * light tests, lower for jsdom-heavy React workloads. + * - Has precedence over `isolate`: if both are set, soft semantics win. + * + * Pass `true` to enable with defaults, or an object to customise: + * ```ts + * experiments: { softMode: { maxFilesPerWorker: 10 } } + * ``` + * + * @default false + * @experimental + */ + softMode?: boolean | SoftModeOptions; + }; /** * Provide global APIs * @@ -571,7 +648,8 @@ type OptionalKeys = | 'hideSkippedTestFiles' | 'resolveSnapshotPath' | 'extends' - | 'shard'; + | 'shard' + | 'experiments'; export type NormalizedBrowserModeConfig = { enabled: boolean; @@ -597,6 +675,7 @@ export type NormalizedConfig = Required< | 'testEnvironment' | 'browser' | 'output' + | 'isolate' > > & Partial> & { @@ -611,6 +690,13 @@ export type NormalizedConfig = Required< override?: boolean; }; output: NormalizedOutputConfig; + /** + * Internal three-way isolation mode. The public `RstestConfig.isolate` + * is `boolean`; `'soft'` is set internally when + * `experiments.softMode === true`. Pool/worker code switches on this + * to encode strict / reuse / reuse-with-reset behavior. + */ + isolate: boolean | 'soft'; }; export type NormalizedProjectConfig = Required<