From 7578a38aa31a7834a87af479a3beadd21ab0e639 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Mon, 31 Aug 2026 20:50:51 +0000 Subject: [PATCH] perf(test): drop rstest maxWorkers pins and isolate shared roots per RSTEST_WORKER_ID --- .../tests/dev-workbench-packaging.test.ts | 47 ++++++++++--------- .../tests/helpers/project-fixture.ts | 5 +- .../tests/public-api-packed.test.ts | 13 ++--- .../agent-bundle/tests/release-audit.test.ts | 3 +- .../rsc-runtime-optional-packaging.test.ts | 11 +++-- .../agent-bundle/tests/support/shared-pack.ts | 12 +++-- .../agent-bundle/tests/support/time-scale.ts | 5 +- rstest.config.ts | 4 +- rstest.integration-tests.ts | 4 +- rstest.integration.config.ts | 44 +++++++---------- rstest.packed.config.ts | 9 ++-- rstest.runtime-playground.browser.config.ts | 2 +- rstest.runtime-playground.config.ts | 2 +- rstest.setup.ts | 3 ++ rstest.unit.config.ts | 3 ++ rstest.worker-isolation.ts | 46 ++++++++++++++++++ 16 files changed, 133 insertions(+), 80 deletions(-) create mode 100644 rstest.setup.ts create mode 100644 rstest.worker-isolation.ts diff --git a/packages/agent-bundle/tests/dev-workbench-packaging.test.ts b/packages/agent-bundle/tests/dev-workbench-packaging.test.ts index 326ffde08..e82454bac 100644 --- a/packages/agent-bundle/tests/dev-workbench-packaging.test.ts +++ b/packages/agent-bundle/tests/dev-workbench-packaging.test.ts @@ -1,5 +1,5 @@ import { execFile as executeFile } from 'node:child_process'; -import { access, mkdtemp, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises'; +import { access, cp, mkdtemp, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises'; import { createServer } from 'node:net'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -16,13 +16,7 @@ const workbenchRoot = join(workspaceRoot, 'packages', 'workbench'); const appRendererLicense = join('src', 'mcp', 'APP-RENDERER-LICENSE'); let built: Promise | undefined; -const buildPackage = async (force = false): Promise => { - if (force) { - // The stale-asset pruning test rebuilds on purpose; the prebuilt seam - // never skips it because the rebuild itself is the behavior under test. - await execFile('pnpm', ['build'], { cwd: workspaceRoot }); - return; - } +const buildPackage = async (): Promise => { if (process.env['AGENT_BUNDLE_PACKAGE_PREBUILT'] === '1') return; built ??= execFile('pnpm', ['build'], { cwd: workspaceRoot }).then(() => undefined); await built; @@ -57,17 +51,26 @@ it('copies stable prebuilt workbench assets and the exact app-renderer license i it('prunes stale copied workbench assets without removing the package library output', async () => { await buildPackage(); - const workbench = join(packageRoot, 'dist', 'workbench'); - const stale = join(workbench, 'static', 'js', 'async', 'stale-nested.js'); - await mkdir(join(workbench, 'static', 'js', 'async'), { recursive: true }); - await writeFile(stale, 'obsolete workbench output\n'); - await expect(access(stale)).resolves.toBeUndefined(); - - await buildPackage(true); - - await expect(access(stale)).rejects.toThrow(); - await expect(access(join(packageRoot, 'dist', 'cli.js'))).resolves.toBeUndefined(); - expect(await readdir(workbench, { recursive: true })).not.toContain('index.js.map'); + const isolatedRoot = await mkdtemp(join(tmpdir(), 'agent-bundle-workbench-prune-')); + const isolatedDist = join(isolatedRoot, 'dist'); + try { + await cp(join(packageRoot, 'dist'), isolatedDist, { recursive: true }); + const workbench = join(isolatedDist, 'workbench'); + const stale = join(workbench, 'static', 'js', 'async', 'stale-nested.js'); + await mkdir(join(workbench, 'static', 'js', 'async'), { recursive: true }); + await writeFile(stale, 'obsolete workbench output\n'); + await expect(access(stale)).resolves.toBeUndefined(); + await execFile(join(workspaceRoot, 'node_modules', '.bin', 'rslib'), [ + 'build', + '--config', join(packageRoot, 'rslib.config.ts'), + '--dist-path', isolatedDist, + ], { cwd: workspaceRoot }); + await expect(access(stale)).rejects.toThrow(); + await expect(access(join(isolatedDist, 'cli.js'))).resolves.toBeUndefined(); + expect(await readdir(workbench, { recursive: true })).not.toContain('index.js.map'); + } finally { + await rm(isolatedRoot, { force: true, recursive: true }); + } }, 60_000); it('serves prebuilt workbench assets from an installed tarball without the repository source tree', async () => { @@ -83,7 +86,7 @@ it('serves prebuilt workbench assets from an installed tarball without the repos expect(listing.stdout).not.toMatch(/package\/dist\/workbench\/.*-[a-f0-9]{8,}/iu); await writeFile(join(consumer, 'package.json'), '{"type":"module"}\n'); - await execFile('npm', ['install', ...npmInstallArguments, tarball], { cwd: consumer }); + await execFile('npm', ['install', ...npmInstallArguments, tarball], { cwd: consumer, env: installedEnvironment() }); await mkdir(join(project, 'skills', 'review'), { recursive: true }); await Promise.all([ writeFile(join(project, 'package.json'), '{"type":"module"}\n'), @@ -99,7 +102,7 @@ it('serves prebuilt workbench assets from an installed tarball without the repos ' console.log(JSON.stringify({ body: await response.text(), status: response.status }));', '} finally { await session.close(); }', ].join('\n'); - const served = await execFile(process.execPath, ['--input-type=module', '--eval', script], { cwd: consumer }); + const served = await execFile(process.execPath, ['--input-type=module', '--eval', script], { cwd: consumer, env: installedEnvironment() }); expect(JSON.parse(served.stdout)).toMatchObject({ body: expect.stringContaining('Agent Bundle workbench'), status: 200, @@ -115,7 +118,7 @@ it('runs the Agent API from an omit-dev installed tarball with its runtime MCP d const project = join(consumer, 'project'); try { await writeFile(join(consumer, 'package.json'), '{"type":"module"}\n'); - await execFile('npm', ['install', '--omit=dev', ...npmInstallArguments, tarball], { cwd: consumer }); + await execFile('npm', ['install', '--omit=dev', ...npmInstallArguments, tarball], { cwd: consumer, env: installedEnvironment() }); await mkdir(join(project, 'skills', 'review'), { recursive: true }); await Promise.all([ writeFile(join(project, 'package.json'), '{"type":"module"}\n'), diff --git a/packages/agent-bundle/tests/helpers/project-fixture.ts b/packages/agent-bundle/tests/helpers/project-fixture.ts index 6393a6fcf..4452fdb09 100644 --- a/packages/agent-bundle/tests/helpers/project-fixture.ts +++ b/packages/agent-bundle/tests/helpers/project-fixture.ts @@ -1,7 +1,8 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; +import { rstestWorkerRoot } from '../../../../rstest.worker-isolation.ts'; + export interface ProjectFixture { configPath: string; imagePath: string; @@ -33,7 +34,7 @@ const sourceEntryPoint = resolve( export const createProjectFixture = async ( options: ProjectFixtureOptions = {}, ): Promise => { - const root = await mkdtemp(join(tmpdir(), options.prefix ?? 'agent-bundle-config-')); + const root = await mkdtemp(join(rstestWorkerRoot(), options.prefix ?? 'agent-bundle-config-')); const skillDir = join(root, 'skills/review'); const skillSource = join(skillDir, 'SKILL.md'); const imagePath = join(skillDir, 'assets/diagram.png'); diff --git a/packages/agent-bundle/tests/public-api-packed.test.ts b/packages/agent-bundle/tests/public-api-packed.test.ts index 5db4ae6bf..e1764b404 100644 --- a/packages/agent-bundle/tests/public-api-packed.test.ts +++ b/packages/agent-bundle/tests/public-api-packed.test.ts @@ -6,6 +6,7 @@ import { promisify } from 'node:util'; import { expect, it } from '@rstest/core'; +import { isolatedCommandEnvironment } from '../../../rstest.worker-isolation.ts'; import { writeFixtureManifest } from './support/manifest.ts'; import { npmInstallArguments, sharedPackedTarball } from './support/shared-pack.ts'; @@ -59,7 +60,7 @@ it('writes the package version as the producer of a packed CLI manifest', async await writeFile(join(consumerRoot, 'package.json'), '{"type":"module"}\n'); await execFile( 'npm', ['install', ...npmInstallArguments, tarball], - { cwd: consumerRoot }, + { cwd: consumerRoot, env: isolatedCommandEnvironment() }, ); const project = await createBuildProject(consumerRoot); @@ -91,7 +92,7 @@ it('imports the externalized config entry from a packed npm consumer', async () await execFile( 'npm', ['install', ...npmInstallArguments, tarball], - { cwd: consumerRoot }, + { cwd: consumerRoot, env: isolatedCommandEnvironment() }, ); expect((await stat(join(packageRoot, 'dist/config.js'))).size).toBeLessThan( @@ -106,7 +107,7 @@ it('imports the externalized config entry from a packed npm consumer', async () "import { defineConfig } from 'agent-bundle/config';", 'if (defineConfig !== rootDefineConfig) throw new Error(\'config factory identity mismatch\');', ].join('\n'), - ], { cwd: consumerRoot }), + ], { cwd: consumerRoot, env: isolatedCommandEnvironment() }), ).resolves.toMatchObject({ stderr: '', stdout: '' }); await symlink( join(workspaceRoot, 'node_modules', '@types'), @@ -138,7 +139,7 @@ it('imports the externalized config entry from a packed npm consumer', async () '--target', 'es2022', '--types', 'node', 'config.mts', - ], { cwd: consumerRoot })).resolves.toMatchObject({ stderr: '', stdout: '' }); + ], { cwd: consumerRoot, env: isolatedCommandEnvironment() })).resolves.toMatchObject({ stderr: '', stdout: '' }); } finally { await rm(consumerRoot, { force: true, recursive: true }); } @@ -199,7 +200,7 @@ it('invokes a prebuilt MCP server from a clean packed consumer', async () => { await execFile( 'npm', ['install', ...npmInstallArguments, tarball], - { cwd: consumerRoot }, + { cwd: consumerRoot, env: isolatedCommandEnvironment() }, ); const { stdout } = await execFile(process.execPath, [ '--input-type=module', @@ -209,7 +210,7 @@ it('invokes a prebuilt MCP server from a clean packed consumer', async () => { "const result = await new McpService().invoke({ artifact: './artifact', input: {}, server: 'fixture', target: 'portable', tool: 'inspect' });", 'console.log(JSON.stringify(result));', ].join('\n'), - ], { cwd: consumerRoot }); + ], { cwd: consumerRoot, env: isolatedCommandEnvironment() }); expect(JSON.parse(stdout)).toMatchObject({ result: { content: [{ text: 'packed result', type: 'text' }], diff --git a/packages/agent-bundle/tests/release-audit.test.ts b/packages/agent-bundle/tests/release-audit.test.ts index d8d8a70ba..e57c5420f 100644 --- a/packages/agent-bundle/tests/release-audit.test.ts +++ b/packages/agent-bundle/tests/release-audit.test.ts @@ -6,13 +6,14 @@ import { promisify } from 'node:util'; import { expect, it } from '@rstest/core'; +import { isolatedCommandEnvironment } from '../../../rstest.worker-isolation.ts'; import { npmInstallArguments, sharedPackedTarball } from './support/shared-pack.ts'; const execFile = promisify(executeFile); const workspaceRoot = process.cwd(); const packageRoot = join(workspaceRoot, 'packages', 'agent-bundle'); -const releaseEnvironment = (): NodeJS.ProcessEnv => ({ ...process.env, NODE_ENV: 'production' }); +const releaseEnvironment = (): NodeJS.ProcessEnv => isolatedCommandEnvironment({ ...process.env, NODE_ENV: 'production' }); it('audits an externally installed production tarball and generates its CycloneDX SBOM', async () => { const { stdout } = await execFile(process.execPath, ['scripts/audit-packed-release.mjs'], { diff --git a/packages/agent-bundle/tests/rsc-runtime-optional-packaging.test.ts b/packages/agent-bundle/tests/rsc-runtime-optional-packaging.test.ts index ee8477592..e8fd26cb7 100644 --- a/packages/agent-bundle/tests/rsc-runtime-optional-packaging.test.ts +++ b/packages/agent-bundle/tests/rsc-runtime-optional-packaging.test.ts @@ -1,12 +1,12 @@ import { execFile as executeFile } from 'node:child_process'; -import { cp, mkdtemp, readdir, rm } from 'node:fs/promises'; +import { cp, mkdtemp, readdir, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { promisify } from 'node:util'; import { describe, expect, it } from '@rstest/core'; -import { npmInstallArguments, sharedPackedTarball } from './support/shared-pack.ts'; +import { installedEnvironment, npmInstallArguments, sharedPackedTarball } from './support/shared-pack.ts'; const execFile = promisify(executeFile); const workspaceRoot = process.cwd(); @@ -42,11 +42,12 @@ describe.sequential('optional RSC runtime package boundary', () => { const project = join(consumer, 'project'); const artifact = join(project, '.agent-bundle', 'artifact'); try { + await writeFile(join(consumer, 'package.json'), '{"name":"rsc-optional-consumer","type":"module"}\n'); const tarListing = (await execFile('tar', ['-tf', tarball])).stdout; expect(tarListing).not.toMatch(/examples\/rsc-agent-runtime|react-server-dom-rspack|rsbuild-plugin-rsc/u); - await execFile('npm', ['install', ...npmInstallArguments, tarball], { cwd: consumer }); - const dependencyTree = JSON.parse((await execFile('npm', ['ls', '--all', '--json'], { cwd: consumer })).stdout) as InstalledDependencyTree; + await execFile('npm', ['install', ...npmInstallArguments, tarball], { cwd: consumer, env: installedEnvironment() }); + const dependencyTree = JSON.parse((await execFile('npm', ['ls', '--all', '--json'], { cwd: consumer, env: installedEnvironment() })).stdout) as InstalledDependencyTree; const installedNames = installedDependencyNames(dependencyTree); for (const name of ['react', 'react-dom', 'react-server-dom-rspack', 'rsbuild-plugin-rsc']) { expect(installedNames).not.toContain(name); @@ -69,7 +70,7 @@ describe.sequential('optional RSC runtime package boundary', () => { " process.stdout.write(JSON.stringify({ diagnostics: validated.diagnostics, runtimeBody: await runtimeResponse.json(), runtimeStatus: runtimeResponse.status, status: session.status(), surfacesBody: await surfacesResponse.json(), surfacesStatus: surfacesResponse.status, targets: inspected.model.targets.map(({ name }) => name) }));", '} finally { await session.close(); }', ].join('\n'); - const result = JSON.parse((await execFile(process.execPath, ['--input-type=module', '--eval', script], { cwd: consumer })).stdout) as Readonly<{ + const result = JSON.parse((await execFile(process.execPath, ['--input-type=module', '--eval', script], { cwd: consumer, env: installedEnvironment() })).stdout) as Readonly<{ readonly diagnostics: unknown; readonly runtimeBody: unknown; readonly runtimeStatus: number; diff --git a/packages/agent-bundle/tests/support/shared-pack.ts b/packages/agent-bundle/tests/support/shared-pack.ts index 124584daf..a434e143e 100644 --- a/packages/agent-bundle/tests/support/shared-pack.ts +++ b/packages/agent-bundle/tests/support/shared-pack.ts @@ -5,6 +5,8 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { promisify } from 'node:util'; +import { isolatedCommandEnvironment } from '../../../../rstest.worker-isolation.ts'; + const execFile = promisify(executeFile); const workspaceRoot = process.cwd(); @@ -21,10 +23,12 @@ export interface SharedPack { export type SharedPackPackage = 'agent-bundle' | 'create-agent-bundle'; -export const installedEnvironment = (): NodeJS.ProcessEnv => { - const { NODE_PATH: _nodePath, ...environment } = process.env; - return environment; -}; +/** + * NODE_PATH-free environment with per-command npm cache and tmp roots under + * the worker's RSTEST_WORKER_ID directory (see rstest.worker-isolation.ts), + * so concurrent workers never contend on shared npm or tmp state. + */ +export const installedEnvironment = (): NodeJS.ProcessEnv => isolatedCommandEnvironment(); /** Canonical flags for installing a packed tarball into a consumer fixture. */ export const npmInstallArguments = ['--ignore-scripts', '--no-audit', '--no-fund'] as const; diff --git a/packages/agent-bundle/tests/support/time-scale.ts b/packages/agent-bundle/tests/support/time-scale.ts index 7ba558ada..2279cc5b5 100644 --- a/packages/agent-bundle/tests/support/time-scale.ts +++ b/packages/agent-bundle/tests/support/time-scale.ts @@ -5,9 +5,10 @@ * costs nothing on green runs - polling assertions return on success - and * the workflow-level timeout-minutes still bounds real hangs. * - * AGENT_BUNDLE_TEST_TIME_SCALE (set by rstest.integration.config.ts when the - * pool runs multiple workers) covers the same contention on development + * AGENT_BUNDLE_TEST_TIME_SCALE covers the same contention on development * machines, where concurrent Chrome + dev-server + rsbuild pairs share cores. + * rstest.integration.config.ts sets it locally from core count without pinning + * workers. CI always uses 4, independent of pool size. */ const localScale = Number(process.env['AGENT_BUNDLE_TEST_TIME_SCALE'] ?? ''); export const timeScale = process.env['CI'] !== undefined diff --git a/rstest.config.ts b/rstest.config.ts index c295aef3d..9bedf50a8 100644 --- a/rstest.config.ts +++ b/rstest.config.ts @@ -9,9 +9,7 @@ export default defineConfig({ 'packages/**/tests/**/*.test.ts', ], exclude: [...templateTestFiles], - // Several integration tests run Rslib, whose build cache and configured - // output paths are process-shared. Keep those builds from racing each other. - pool: { maxWorkers: 1 }, + setupFiles: ['./rstest.setup.ts'], // isolate: false would cut Playwright startup cost, but the log pipeline // suites rely on per-file module isolation (verified: logs-real.e2e fails // when sharing a worker with the other log suites). diff --git a/rstest.integration-tests.ts b/rstest.integration-tests.ts index ab2a6dacb..11e96cea6 100644 --- a/rstest.integration-tests.ts +++ b/rstest.integration-tests.ts @@ -73,8 +73,8 @@ export const integrationTestFiles: readonly string[] = [ * native-host-smoke workflow keep them covered — and stay excluded from the * parallel unit pool. packed-release.e2e lives here (not in the integration * pool) so `pnpm test` and the release gates don't each run the same long - * packed-browser suite; `rstest.packed.config.ts` keeps `test:packed` on one - * worker. + * packed-browser suite. `rstest.packed.config.ts` does not cap `test:packed` + * workers; pack destinations and tmp roots are per RSTEST_WORKER_ID. */ export const packedTestFiles: readonly string[] = [ 'packages/agent-bundle/tests/dev-workbench-packaging.test.ts', diff --git a/rstest.integration.config.ts b/rstest.integration.config.ts index d5b3d89a2..c01fa1877 100644 --- a/rstest.integration.config.ts +++ b/rstest.integration.config.ts @@ -6,33 +6,22 @@ import { integrationTestFiles } from './rstest.integration-tests.ts'; import { withAgentBundleRslibConfig } from './rstest.rslib.ts'; /** - * Worker count for the parallel integration pool. Half the cores keeps - * browser + dev-server pairs from starving each other and the cap of 4 bounds - * memory on large machines. CI pins one worker explicitly: hosted runners - * report 4 cores (which would compute 2 workers), but each Chrome + - * dev-server + rsbuild pair already saturates them, and 2-worker matrix runs - * flaked on a rotating test per leg even at timeScale 4. Parallelism is a - * development-machine speedup; CI keeps the serialized shape it was tuned - * for. AGENT_BUNDLE_INTEGRATION_MAX_WORKERS overrides the computed value - * (e.g. to measure a parallel CI run or bisect locally in serial). + * Rstest computes worker count from CPU and command mode when pool.maxWorkers + * is omitted. Shared cache, tmp, and pack roots are isolated per worker via + * RSTEST_WORKER_ID (see rstest.setup.ts). */ -const overrideWorkers = Number(process.env['AGENT_BUNDLE_INTEGRATION_MAX_WORKERS'] ?? ''); -const maxWorkers = Number.isSafeInteger(overrideWorkers) && overrideWorkers >= 1 - ? overrideWorkers - : process.env['CI'] !== undefined - ? 1 - : Math.max(1, Math.min(4, Math.floor(availableParallelism() / 2))); /** - * Polling budgets scale with contention. A multi-worker pool needs at least - * 2 (see the env comment below); an externally set - * AGENT_BUNDLE_TEST_TIME_SCALE raises it further when the machine is shared — - * scripts/local-ci.mjs passes 4 (hosted CI's own scale) because it runs - * three Node legs plus the release gates concurrently. The external value - * never lowers the scale below what the pool shape requires. + * Polling budgets scale with contention. The auto-sized pool runs multiple + * workers on any multi-core machine, which needs at least 2 (see the env + * comment below); an externally set AGENT_BUNDLE_TEST_TIME_SCALE raises it + * further when the machine is shared — scripts/local-ci.mjs passes 4 (hosted + * CI's own scale) because it runs three Node legs plus the release gates + * concurrently. The external value never lowers the scale below what the + * pool shape requires. */ const externalTimeScale = Number(process.env['AGENT_BUNDLE_TEST_TIME_SCALE'] ?? ''); -const poolTimeScale = maxWorkers > 1 ? 2 : 1; +const poolTimeScale = availableParallelism() > 1 ? 2 : 1; const timeScale = Number.isSafeInteger(externalTimeScale) && externalTimeScale >= 1 ? Math.max(externalTimeScale, poolTimeScale) : poolTimeScale; @@ -40,19 +29,20 @@ const timeScale = Number.isSafeInteger(externalTimeScale) && externalTimeScale > /** * Build- and process-running tests that only read workspace-shared artifacts; * files that WRITE shared locations (root builds, `npm pack`) run through the - * single-worker `test:packed` script instead (see rstest.integration-tests.ts). + * `test:packed` script instead (see rstest.integration-tests.ts). */ export default defineConfig({ extends: withAgentBundleRslibConfig(), include: [...integrationTestFiles], - pool: { maxWorkers }, + setupFiles: ['./rstest.setup.ts'], + // isolate: false would cut Playwright startup cost, but the log pipeline + // suites rely on per-file module isolation (verified: logs-real.e2e fails + // when sharing a worker with the other log suites). + isolate: true, // Concurrent Chrome + dev-server + rsbuild pairs contend for cores, so // parallel runs double the polling budgets (see tests/support/time-scale.ts) // and raise the 5s default test timeout, which real in-process builds can // exceed when workers share the machine. Explicit per-test timeouts win. env: { AGENT_BUNDLE_TEST_TIME_SCALE: String(timeScale) }, testTimeout: 30_000, - // isolate: false would cut Playwright startup cost, but the log pipeline - // suites rely on per-file module isolation (verified: logs-real.e2e fails - // when sharing a worker with the other log suites). }); diff --git a/rstest.packed.config.ts b/rstest.packed.config.ts index ad15c6c0b..696f8570d 100644 --- a/rstest.packed.config.ts +++ b/rstest.packed.config.ts @@ -8,9 +8,10 @@ import { withAgentBundleRslibConfig } from './rstest.rslib.ts'; * `scripts/run-packed-tests.mjs` so every file consumes one shared tarball * per public package. `--release` (AGENT_BUNDLE_PACKED_RELEASE=1) adds the * release-boundary-only files — the scaffolder template matrix — on top of - * the per-PR set. The pool stays on one worker: dev-workbench-packaging - * rebuilds the workspace `dist` in place while release-audit's audit script - * packs it, so the files still contend on workspace-shared writes. + * the per-PR set. Rstest sizes the pool itself: no file writes the workspace + * `dist` in place anymore (dev-workbench-packaging's prune test rebuilds + * into an isolated copy), and shared tmp/npm/cache roots are per + * RSTEST_WORKER_ID (see rstest.setup.ts). */ export default defineConfig({ extends: withAgentBundleRslibConfig(), @@ -18,5 +19,5 @@ export default defineConfig({ ...packedTestFiles, ...(process.env['AGENT_BUNDLE_PACKED_RELEASE'] === '1' ? packedReleaseOnlyTestFiles : []), ], - pool: { maxWorkers: 1 }, + setupFiles: ['./rstest.setup.ts'], }); diff --git a/rstest.runtime-playground.browser.config.ts b/rstest.runtime-playground.browser.config.ts index 6cb104f61..8127c0d5d 100644 --- a/rstest.runtime-playground.browser.config.ts +++ b/rstest.runtime-playground.browser.config.ts @@ -19,7 +19,7 @@ export default defineConfig({ extends: withRslibConfig(), include: ['packages/workbench/tests/runtime-playground.browser.test.tsx'], plugins: [pluginReact()], - pool: { maxWorkers: 1 }, + setupFiles: ['./rstest.setup.ts'], resolve: { alias: { react: browserReactRoot, diff --git a/rstest.runtime-playground.config.ts b/rstest.runtime-playground.config.ts index 426d018b8..0e9d3d3eb 100644 --- a/rstest.runtime-playground.config.ts +++ b/rstest.runtime-playground.config.ts @@ -16,7 +16,7 @@ export default defineConfig({ reporters: ['text', 'json'], thresholds: { branches: 85, functions: 90, lines: 90, statements: 90 }, }, - pool: { maxWorkers: 1 }, + setupFiles: ['./rstest.setup.ts'], projects: [ defineInlineProject({ extends: withRslibConfig(), diff --git a/rstest.setup.ts b/rstest.setup.ts new file mode 100644 index 000000000..d5d552c8d --- /dev/null +++ b/rstest.setup.ts @@ -0,0 +1,3 @@ +import { isolateWorkerEnvironment } from './rstest.worker-isolation.ts'; + +isolateWorkerEnvironment(); diff --git a/rstest.unit.config.ts b/rstest.unit.config.ts index f8b8c9f17..c5a4ad9d4 100644 --- a/rstest.unit.config.ts +++ b/rstest.unit.config.ts @@ -10,4 +10,7 @@ export default defineConfig({ 'packages/**/tests/**/*.test.ts', ], exclude: [...integrationTestFiles, ...packedTestFiles, ...templateTestFiles], + setupFiles: ['./rstest.setup.ts'], + // Unit files construct per-test services; logs-real.e2e is not in this pool. + isolate: false, }); diff --git a/rstest.worker-isolation.ts b/rstest.worker-isolation.ts new file mode 100644 index 000000000..6d7372d51 --- /dev/null +++ b/rstest.worker-isolation.ts @@ -0,0 +1,46 @@ +import { mkdirSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +export const rstestWorkerId = (): string => process.env['RSTEST_WORKER_ID'] ?? '0'; + +const hostTemporaryRoot = tmpdir(); + +export const rstestWorkerRoot = (): string => { + const root = join(hostTemporaryRoot, 'agent-bundle-rstest-w' + rstestWorkerId()); + mkdirSync(root, { recursive: true }); + return root; +}; + +export const rstestWorkerCacheDirectory = (name: string): string => { + const directory = join(rstestWorkerRoot(), 'cache', name); + mkdirSync(directory, { recursive: true }); + return directory; +}; + +export const isolateWorkerEnvironment = (): void => { + const root = rstestWorkerRoot(); + const cache = rstestWorkerCacheDirectory('xdg'); + const env = process['env']; + env['TMPDIR'] = root; + env['TMP'] = root; + env['TEMP'] = root; + env['XDG_CACHE_HOME'] = cache; +}; + +let commandSerial = 0; + +export const isolatedCommandEnvironment = (base: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv => { + commandSerial += 1; + const stamp = String(process.pid) + '-' + String(commandSerial); + const cache = rstestWorkerCacheDirectory('cmd-' + stamp); + const tmp = join(rstestWorkerRoot(), 'cmd-tmp-' + stamp); + mkdirSync(tmp, { recursive: true }); + const { NODE_PATH: _nodePath, ...rest } = base; + const environment: NodeJS.ProcessEnv = { ...rest }; + environment['npm_config_cache'] = cache; + environment['TMPDIR'] = tmp; + environment['TMP'] = tmp; + environment['TEMP'] = tmp; + return environment; +};