From 65b9de9e38572ce7c048e8f0605ec1fdcb056637 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 03:29:33 +0000 Subject: [PATCH 1/3] =?UTF-8?q?test(rstest):=20shared=20pool=20policy=20?= =?UTF-8?q?=E2=80=94=20worker-root=20teardown,=20stale-dist=20guard,=20iso?= =?UTF-8?q?lation=20setup=20in=20every=20pool,=20explicit=20timeouts,=20ad?= =?UTF-8?q?apter=20libId=20(#576)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - rstest.global-setup.ts: tag each invocation with AGENT_BUNDLE_RSTEST_RUN_ID (relayed to workers), record it in the worker-root owner marker, and remove exactly that run's /tmp/ab-rstest-* roots in teardown. - scripts/dist-freshness.mjs + rstest.dist-freshness.setup.ts + scripts/check-dist-fresh.mjs: newest-mtime src-vs-dist guard for every `pnpm build` output, run in every pool's globalSetup and ahead of `pnpm typecheck`; ensureRuntimeExamplePayload rebuilds a stale payload. - rstest.pools.ts: shared globalSetup chains, setup file, poolTimeouts (hookTimeout = testTimeout), process-pool worker cap and time scale; route-unit/projection load rstest.setup.ts before the generated registry; rstest.config.ts gets the process-pool cap and 30 s floors. - rstest.rslib.ts passes the lib's id as libId; mapped vs ignored adapter fields documented; rstest-rslib-adapter.test.ts and rstest-pool-configs.test.ts pin the resolved configs. - projection/mcp-lineage.test.ts imports agent-bundle/test from src. --- package.json | 3 +- packages/agent-bundle/rslib.config.ts | 13 + .../agent-bundle/tests/dist-freshness.test.ts | 275 +++++++++++++++ .../tests/projection/mcp-lineage.test.ts | 2 +- .../tests/rstest-pool-configs.test.ts | 177 ++++++++++ .../tests/rstest-rslib-adapter.test.ts | 101 ++++++ .../tests/rstest-worker-root-teardown.test.ts | 145 ++++++++ .../tests/helpers/runtime-example-payload.ts | 21 +- rstest.config.ts | 30 +- rstest.dist-freshness.setup.ts | 15 + rstest.evidence.config.ts | 7 +- rstest.global-setup.ts | 71 ++++ rstest.integration.config.ts | 65 ++-- rstest.mcp-conformance.config.ts | 6 +- rstest.packed.config.ts | 8 +- rstest.pools.ts | 86 +++++ rstest.projection.config.ts | 20 +- rstest.route-unit.config.ts | 22 +- rstest.rslib.ts | 133 ++++++-- rstest.unit.config.ts | 20 +- rstest.worker-isolation.ts | 14 +- scripts/check-dist-fresh.mjs | 19 ++ scripts/dist-freshness.d.mts | 63 ++++ scripts/dist-freshness.mjs | 317 ++++++++++++++++++ scripts/rstest-worker-roots.d.mts | 22 +- scripts/rstest-worker-roots.mjs | 101 ++++-- 26 files changed, 1622 insertions(+), 134 deletions(-) create mode 100644 packages/agent-bundle/tests/dist-freshness.test.ts create mode 100644 packages/agent-bundle/tests/rstest-pool-configs.test.ts create mode 100644 packages/agent-bundle/tests/rstest-rslib-adapter.test.ts create mode 100644 packages/agent-bundle/tests/rstest-worker-root-teardown.test.ts create mode 100644 rstest.dist-freshness.setup.ts create mode 100644 rstest.global-setup.ts create mode 100644 rstest.pools.ts create mode 100644 scripts/check-dist-fresh.mjs create mode 100644 scripts/dist-freshness.d.mts create mode 100644 scripts/dist-freshness.mjs diff --git a/package.json b/package.json index be3c49625..4c4813fac 100644 --- a/package.json +++ b/package.json @@ -18,10 +18,11 @@ "test:integration:run": "AGENT_BUNDLE_WORKBENCH_PREBUILT=1 AGENT_BUNDLE_PACKAGE_PREBUILT=1 rstest --config rstest.integration.config.ts", "test:evidence": "pnpm build && AGENT_BUNDLE_WORKBENCH_PREBUILT=1 AGENT_BUNDLE_PACKAGE_PREBUILT=1 rstest --config rstest.evidence.config.ts", "test:mcp-conformance": "pnpm build && AGENT_BUNDLE_MCP_CONFORMANCE=1 rstest --config rstest.mcp-conformance.config.ts", + "test:native-host": "pnpm build && AGENT_BUNDLE_NATIVE_HOST_CONTRACTS=1 rstest --config rstest.native-host.config.ts", "test:watch": "rstest --config rstest.config.ts --watch", "lint": "rslint .", "bench:hook-cold-start": "node scripts/measure-hook-cold-start.mjs", - "typecheck": "tsc --noEmit && tsc --project packages/workbench/tsconfig.json && tsc --project packages/create-agent-bundle/tsconfig.json && tsc --project packages/rsc-markdown-stream/tsconfig.json", + "typecheck": "node scripts/check-dist-fresh.mjs && tsc --noEmit && tsc --project packages/workbench/tsconfig.json && tsc --project packages/create-agent-bundle/tsconfig.json && tsc --project packages/rsc-markdown-stream/tsconfig.json", "check": "pnpm build && pnpm test:unit && pnpm test:route-unit && pnpm test:projection && pnpm test:integration:run && pnpm lint && pnpm typecheck", "check:local-ci": "node scripts/local-ci.mjs", "check:host-cli": "node scripts/host-cli-pins.mjs verify", diff --git a/packages/agent-bundle/rslib.config.ts b/packages/agent-bundle/rslib.config.ts index d0a7649c4..20b365b2f 100644 --- a/packages/agent-bundle/rslib.config.ts +++ b/packages/agent-bundle/rslib.config.ts @@ -43,9 +43,22 @@ const esmNodeGlobalsPlugin: RsbuildPlugin = { */ const buildCacheDirectory = process.env['AGENT_BUNDLE_RSLIB_CACHE_DIRECTORY']; +/** + * The `id` of the single lib entry. To Rslib an id is a name: it labels the + * Rsbuild environment the entry becomes (`esm` when unset — + * `composeRsbuildEnvironments` in @rslib/core), so it shows in build logs, + * selects the entry for `rslib build --lib`, and keys the persistent build + * cache's version (`-`, Rsbuild's cache plugin); it + * changes no emitted file. It exists so `rstest.rslib.ts` can pass it as the + * adapter's `libId`: without one, `@rstest/adapter-rslib` reads none of this + * entry's fields into the pools' test build. + */ +export const agentBundleLibId = 'esm-node'; + export default defineConfig({ lib: [ { + id: agentBundleLibId, bundle: true, // One `.d.ts` per source module. Bundling them per entry // (`dts: { bundle: true }`, API Extractor) was measured and rejected: diff --git a/packages/agent-bundle/tests/dist-freshness.test.ts b/packages/agent-bundle/tests/dist-freshness.test.ts new file mode 100644 index 000000000..8cbddc10f --- /dev/null +++ b/packages/agent-bundle/tests/dist-freshness.test.ts @@ -0,0 +1,275 @@ +import { existsSync } from 'node:fs'; +import { lstat, mkdir, mkdtemp, readdir, rm, utimes, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; + +import { afterEach, describe, expect, it } from '@rstest/core'; + +import { + assertFreshDist, + checkDistFreshness, + distFreshness, + formatDistFreshnessFailure, + isSkippedInputDirectory, + newestEntry, + runtimeExampleBuildOutputs, + workspaceBuildOutputs, + type DistDescriptor, + type DistFreshness, +} from '../../../scripts/dist-freshness.mjs'; + +const workspaceRoot = process.cwd(); + +/** Fixture clock: sources written first, dist built after, then one later edit. */ +const sourceTime = new Date('2026-01-01T00:00:00Z'); +const buildTime = new Date('2026-01-02T00:00:00Z'); +const editTime = new Date('2026-01-03T00:00:00Z'); + +const temporaryRoots: string[] = []; + +afterEach(async () => { + await Promise.all(temporaryRoots.splice(0).map((root) => rm(root, { force: true, recursive: true }))); +}); + +/** Writes empty files at `paths` (creating directories) under `root`. */ +const writeFiles = async (root: string, paths: readonly string[]): Promise => { + for (const path of paths) { + await mkdir(dirname(join(root, path)), { recursive: true }); + await writeFile(join(root, path), ''); + } +}; + +/** Sets `mtime` on `path` and, for a directory, everything beneath it — children first, since creating entries had left the directories at "now". */ +const stampTree = async (path: string, mtime: Date): Promise => { + if ((await lstat(path)).isDirectory()) { + for (const entry of await readdir(path)) await stampTree(join(path, entry), mtime); + } + await utimes(path, mtime, mtime); +}; + +interface PackageFixture { + readonly descriptor: DistDescriptor; + readonly root: string; +} + +/** + * A package whose sources predate its dist: `src` (two levels), the Rslib + * config, the manifest, a declared-but-absent `tsconfig.json`, and a dist + * with a nested chunk directory. It sits one level below the temporary + * root so a test can add a sibling package (`../shared-lib`). + */ +const createPackageFixture = async (): Promise => { + const base = await mkdtemp(join(tmpdir(), 'dist-freshness-')); + temporaryRoots.push(base); + const root = join(base, 'pkg'); + await writeFiles(root, ['src/index.ts', 'src/nested/util.ts', 'rslib.config.ts', 'package.json', 'dist/index.js', 'dist/chunks/shared.js']); + await stampTree(root, sourceTime); + await stampTree(join(root, 'dist'), buildTime); + return { + descriptor: { + name: 'fixture', + root, + inputs: ['src', 'rslib.config.ts', 'package.json', 'tsconfig.json'], + output: 'dist', + }, + root, + }; +}; + +const touch = (path: string, mtime: Date): Promise => utimes(path, mtime, mtime); + +describe('distFreshness', () => { + it('is fresh when every input predates the newest built file, ignoring declared inputs that do not exist', async () => { + const { descriptor, root } = await createPackageFixture(); + const result = distFreshness(descriptor); + expect(result.status).toBe('fresh'); + expect(result.output).toBe(join(root, 'dist')); + expect(new Date(result.newestInput.mtimeMs)).toEqual(sourceTime); + expect(new Date(result.newestOutput?.mtimeMs ?? 0)).toEqual(buildTime); + expect(result.newestOutput?.path.startsWith(join(root, 'dist'))).toBe(true); + }); + + it('is stale when a nested source file is newer than the dist, and names that file', async () => { + const { descriptor, root } = await createPackageFixture(); + await touch(join(root, 'src/nested/util.ts'), editTime); + const result = distFreshness(descriptor); + expect(result.status).toBe('stale'); + expect(result.newestInput).toEqual({ mtimeMs: editTime.getTime(), path: join(root, 'src/nested/util.ts') }); + }); + + it('is stale when a build config file input is newer than the dist', async () => { + const { descriptor, root } = await createPackageFixture(); + await touch(join(root, 'rslib.config.ts'), editTime); + expect(distFreshness(descriptor).status).toBe('stale'); + }); + + it('counts input directory mtimes, so a deleted or renamed source is noticed', async () => { + const { descriptor, root } = await createPackageFixture(); + await touch(join(root, 'src'), editTime); + const result = distFreshness(descriptor); + expect(result.status).toBe('stale'); + expect(result.newestInput.path).toBe(join(root, 'src')); + }); + + it('is missing when the dist is absent, empty, or holds only empty directories', async () => { + const { descriptor, root } = await createPackageFixture(); + await rm(join(root, 'dist'), { recursive: true }); + expect(distFreshness(descriptor)).toMatchObject({ newestOutput: undefined, status: 'missing' }); + await mkdir(join(root, 'dist')); + expect(distFreshness(descriptor).status).toBe('missing'); + await mkdir(join(root, 'dist/chunks')); + expect(distFreshness(descriptor).status).toBe('missing'); + }); + + it('skips node_modules, dist, .rstest-temp and dot-directories inside inputs', async () => { + const { descriptor, root } = await createPackageFixture(); + const skipped = ['src/node_modules/dep/index.js', 'src/dist/out.js', 'src/.rstest-temp/chunk.js', 'src/.cache/entry.js']; + await writeFiles(root, skipped); + for (const path of skipped) await stampTree(join(root, path, '..'), editTime); + // Creating the directories moved `src` itself to "now"; put it back so + // only the skipped trees are newer than the dist. + await touch(join(root, 'src'), sourceTime); + const result = distFreshness(descriptor); + expect(result.status).toBe('fresh'); + expect(new Date(result.newestInput.mtimeMs)).toEqual(sourceTime); + for (const name of ['node_modules', 'dist', '.rstest-temp', '.git', '.agent-bundle']) { + expect(isSkippedInputDirectory(name), name).toBe(true); + } + for (const name of ['src', 'lib', 'chunks', 'dist-tools']) { + expect(isSkippedInputDirectory(name), name).toBe(false); + } + }); + + it('walks an input root that is itself a dist, so a rebuilt workspace dependency flags a bundle that embeds it', async () => { + const { descriptor, root } = await createPackageFixture(); + await writeFiles(root, ['../shared-lib/dist/index.js']); + const sharedDist = resolve(root, '../shared-lib/dist'); + await stampTree(sharedDist, editTime); + const result = distFreshness({ ...descriptor, inputs: [...descriptor.inputs, '../shared-lib/dist'] }); + expect(result.status).toBe('stale'); + // The directory and its file carry the same stamp; either is the evidence. + expect(result.newestInput.path.startsWith(sharedDist)).toBe(true); + expect(new Date(result.newestInput.mtimeMs)).toEqual(editTime); + }); + + it('ignores dot-directories inside the dist, so a temp writer there cannot mask staleness', async () => { + const { descriptor, root } = await createPackageFixture(); + await touch(join(root, 'src/index.ts'), editTime); + await writeFiles(root, ['dist/.rstest-temp/scratch.js']); + await stampTree(join(root, 'dist/.rstest-temp'), new Date('2027-01-01T00:00:00Z')); + const result = distFreshness(descriptor); + expect(result.status).toBe('stale'); + expect(new Date(result.newestOutput?.mtimeMs ?? 0)).toEqual(buildTime); + }); + + it('rejects a descriptor none of whose inputs exist', async () => { + const { descriptor } = await createPackageFixture(); + expect(() => distFreshness({ ...descriptor, inputs: ['nope', 'also-nope.ts'] })).toThrow(/none of the 2 declared inputs of fixture exist/u); + }); +}); + +describe('newestEntry', () => { + it('returns the file itself for a file, undefined for a missing path, and the newest file of a tree', async () => { + const { root } = await createPackageFixture(); + expect(newestEntry(join(root, 'package.json'))).toEqual({ mtimeMs: sourceTime.getTime(), path: join(root, 'package.json') }); + expect(newestEntry(join(root, 'missing'))).toBeUndefined(); + await touch(join(root, 'dist/chunks/shared.js'), editTime); + expect(newestEntry(join(root, 'dist'))).toEqual({ mtimeMs: editTime.getTime(), path: join(root, 'dist/chunks/shared.js') }); + }); +}); + +describe('formatDistFreshnessFailure and assertFreshDist', () => { + const fresh: DistFreshness = { + name: 'fresh-package', + newestInput: { mtimeMs: sourceTime.getTime(), path: '/repo/packages/fresh/src/index.ts' }, + newestOutput: { mtimeMs: buildTime.getTime(), path: '/repo/packages/fresh/dist/index.js' }, + output: '/repo/packages/fresh/dist', + status: 'fresh', + }; + const stale: DistFreshness = { + name: '@agent-bundle/runtime', + newestInput: { mtimeMs: editTime.getTime(), path: '/repo/packages/rsc-runtime/src/index.ts' }, + newestOutput: { mtimeMs: buildTime.getTime(), path: '/repo/packages/rsc-runtime/dist/index.js' }, + output: '/repo/packages/rsc-runtime/dist', + status: 'stale', + }; + const missing: DistFreshness = { + name: 'agent-bundle-workbench', + newestInput: { mtimeMs: sourceTime.getTime(), path: '/repo/packages/workbench/src/main.tsx' }, + newestOutput: undefined, + output: '/repo/packages/workbench/dist', + status: 'missing', + }; + + it('names every stale or missing output with its evidence and ends with the rebuild instruction', () => { + const message = formatDistFreshnessFailure([fresh, stale, missing], { relativeTo: '/repo' }); + expect(message).not.toContain('fresh-package'); + expect(message).toContain( + ' @agent-bundle/runtime: stale — packages/rsc-runtime/src/index.ts (2026-01-03T00:00:00.000Z)' + + ' is newer than packages/rsc-runtime/dist/index.js (2026-01-02T00:00:00.000Z)', + ); + expect(message).toContain(' agent-bundle-workbench: missing — packages/workbench/dist has no built files'); + expect(message.endsWith('run `pnpm build`.')).toBe(true); + }); + + it('prints paths outside relativeTo as they are', () => { + expect(formatDistFreshnessFailure([stale], { relativeTo: '/elsewhere' })).toContain('/repo/packages/rsc-runtime/src/index.ts'); + }); + + it('formats an all-fresh result set to the empty string', () => { + expect(formatDistFreshnessFailure([fresh])).toBe(''); + }); + + it('assertFreshDist throws the message for a stale fixture and returns for a fresh one', async () => { + const { descriptor, root } = await createPackageFixture(); + expect(() => assertFreshDist([descriptor])).not.toThrow(); + await touch(join(root, 'src/index.ts'), editTime); + expect(() => assertFreshDist([descriptor], { relativeTo: root })).toThrow( + /^Built output is stale or missing[\s\S]*fixture: stale — src\/index\.ts[\s\S]*run `pnpm build`\.$/u, + ); + expect(checkDistFreshness([descriptor]).map((result) => result.status)).toEqual(['stale']); + }); +}); + +describe('workspace descriptors', () => { + it('lists every dist `pnpm build` produces, in build order, and every declared input exists in the repository', () => { + const outputs = workspaceBuildOutputs(workspaceRoot); + expect(outputs.map((output) => output.name)).toEqual([ + 'rsc-markdown-stream', + '@agent-bundle/runtime', + 'agent-bundle-workbench', + 'agent-bundle', + 'create-agent-bundle', + ]); + for (const output of outputs) { + expect(output.output).toBe('dist'); + expect(output.root.startsWith(join(workspaceRoot, 'packages'))).toBe(true); + for (const input of output.inputs) { + expect(existsSync(resolve(output.root, input)), `${output.name}: ${input}`).toBe(true); + } + expect(output.inputs).toContain('src'); + expect(output.inputs).toContain('package.json'); + expect(output.inputs).toContain('../../pnpm-lock.yaml'); + } + }); + + it('gives agent-bundle the Workbench build inputs its Rslib build copies from', () => { + const agentBundle = workspaceBuildOutputs(workspaceRoot).find((output) => output.name === 'agent-bundle'); + expect(agentBundle?.inputs).toEqual(expect.arrayContaining(['../workbench/src', '../workbench/index.html', '../workbench/rsbuild.config.ts', '../workbench/THIRD_PARTY_NOTICES'])); + expect(agentBundle?.inputs).not.toContain('../workbench/dist'); + }); + + it('describes the runtime example per payload tree, bundling the workspace runtime dists as inputs', () => { + const outputs = runtimeExampleBuildOutputs(workspaceRoot, ['app', 'runtime']); + expect(outputs.map((output) => output.output)).toEqual(['dist/app', 'dist/runtime']); + for (const output of outputs) { + expect(output.root).toBe(join(workspaceRoot, 'examples/rsc-agent-runtime')); + expect(output.inputs).toEqual(expect.arrayContaining(['src', 'rsbuild.config.ts', '../../packages/rsc-runtime/dist', '../../packages/rsc-markdown-stream/dist'])); + // The dist inputs may be absent on a cold tree; every tracked input exists. + for (const input of output.inputs.filter((path) => !path.endsWith('/dist'))) { + expect(existsSync(resolve(output.root, input)), input).toBe(true); + } + } + expect(runtimeExampleBuildOutputs(workspaceRoot)).toHaveLength(2); + }); +}); diff --git a/packages/agent-bundle/tests/projection/mcp-lineage.test.ts b/packages/agent-bundle/tests/projection/mcp-lineage.test.ts index f27aefe4b..4bae5c00c 100644 --- a/packages/agent-bundle/tests/projection/mcp-lineage.test.ts +++ b/packages/agent-bundle/tests/projection/mcp-lineage.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from '@rstest/core'; import { createAgentLineageRegistry } from '@agent-bundle/runtime/lineage'; -import { openInMemoryMcpServer } from 'agent-bundle/test'; +import { openInMemoryMcpServer } from '../../src/test/mcp.ts'; const root = 'session-root'; const child = 'agent-child'; diff --git a/packages/agent-bundle/tests/rstest-pool-configs.test.ts b/packages/agent-bundle/tests/rstest-pool-configs.test.ts new file mode 100644 index 000000000..961c2a522 --- /dev/null +++ b/packages/agent-bundle/tests/rstest-pool-configs.test.ts @@ -0,0 +1,177 @@ +import { execFile as executeFile } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { promisify } from 'node:util'; + +import { beforeAll, describe, expect, it } from '@rstest/core'; + +import { + examplePayloadGlobalSetup, + poolTimeouts, + processPoolMaxWorkers, + processPoolTimeScale, + workspaceGlobalSetup, + workspaceSetupFiles, +} from '../../../rstest.pools.ts'; +import { rstestHygiene } from '../../../rstest.rslib.ts'; + +/** + * Pool-level policy the #576 audit found missing or uneven: every pool runs + * the orchestrator hooks (stale-dist refusal, worker-root teardown), loads + * the per-worker isolation setup before any other setup file, and carries an + * explicit test/hook timeout floor. The adapter-level contract (libId, + * plugin filtering, hygiene through `extends`) is + * rstest-rslib-adapter.test.ts's; this file pins what each config spells out + * itself. + * + * Inside a pool `@rstest/core` is the runtime API, without `defineConfig`, + * so the configs cannot be imported here. A child Node process (type + * stripping is on by default from Node 22.18) evaluates them the way the + * `rstest` CLI does and reports the asserted fields. + */ + +const execFile = promisify(executeFile); +const workspaceRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..'); + +const poolConfigs = [ + 'rstest.config.ts', + 'rstest.unit.config.ts', + 'rstest.route-unit.config.ts', + 'rstest.projection.config.ts', + 'rstest.integration.config.ts', + 'rstest.packed.config.ts', + 'rstest.evidence.config.ts', + 'rstest.mcp-conformance.config.ts', + 'rstest.native-host.config.ts', +] as const; + +type PoolConfigName = (typeof poolConfigs)[number]; + +interface ResolvedPoolConfig { + readonly clearMocks?: boolean; + readonly env?: Readonly>; + readonly globalSetup?: string | readonly string[]; + readonly hookTimeout?: number; + readonly maxWorkers?: number | string; + readonly restoreMocks?: boolean; + readonly setupFiles?: string | readonly string[]; + readonly testTimeout?: number; + readonly unstubEnvs?: boolean; + readonly unstubGlobals?: boolean; +} + +const resolverSource = ` +const names = ${JSON.stringify(poolConfigs)}; +const out = {}; +for (const name of names) { + const { default: c } = await import('./' + name); + out[name] = { + clearMocks: c.clearMocks, env: c.env, globalSetup: c.globalSetup, hookTimeout: c.hookTimeout, + maxWorkers: typeof c.pool === 'object' ? c.pool.maxWorkers : undefined, restoreMocks: c.restoreMocks, + setupFiles: c.setupFiles, testTimeout: c.testTimeout, unstubEnvs: c.unstubEnvs, unstubGlobals: c.unstubGlobals, + }; +} +process.stdout.write(JSON.stringify(out)); +`; + +const list = (value: string | readonly string[] | undefined): readonly string[] => + value === undefined ? [] : typeof value === 'string' ? [value] : value; + +interface PoolExpectation { + /** Setup files the pool appends after the shared isolation setup. */ + readonly extraSetupFiles: number; + readonly globalSetup: readonly string[]; + readonly testTimeout: number; +} + +const expectations: Readonly> = { + 'rstest.config.ts': { extraSetupFiles: 0, globalSetup: examplePayloadGlobalSetup, testTimeout: 30_000 }, + 'rstest.evidence.config.ts': { extraSetupFiles: 0, globalSetup: examplePayloadGlobalSetup, testTimeout: 30_000 }, + 'rstest.integration.config.ts': { extraSetupFiles: 0, globalSetup: examplePayloadGlobalSetup, testTimeout: 30_000 }, + 'rstest.mcp-conformance.config.ts': { extraSetupFiles: 0, globalSetup: workspaceGlobalSetup, testTimeout: 180_000 }, + 'rstest.native-host.config.ts': { extraSetupFiles: 0, globalSetup: workspaceGlobalSetup, testTimeout: 60_000 }, + 'rstest.packed.config.ts': { extraSetupFiles: 0, globalSetup: workspaceGlobalSetup, testTimeout: 120_000 }, + 'rstest.projection.config.ts': { extraSetupFiles: 1, globalSetup: workspaceGlobalSetup, testTimeout: 30_000 }, + 'rstest.route-unit.config.ts': { extraSetupFiles: 1, globalSetup: workspaceGlobalSetup, testTimeout: 30_000 }, + 'rstest.unit.config.ts': { extraSetupFiles: 0, globalSetup: workspaceGlobalSetup, testTimeout: 15_000 }, +}; + +let resolved: Readonly>; + +beforeAll(async () => { + const { stdout } = await execFile( + process.execPath, + ['--no-warnings=ExperimentalWarning', '--input-type=module', '--eval', resolverSource], + { cwd: workspaceRoot, maxBuffer: 8 * 1024 * 1024 }, + ); + resolved = JSON.parse(stdout) as Readonly>; +}, 60_000); + +describe('shared pool policy', () => { + it('names orchestrator hooks and a setup file that exist on disk', () => { + for (const path of [...examplePayloadGlobalSetup, ...workspaceSetupFiles]) { + expect(existsSync(resolve(workspaceRoot, path)), `${path} is missing`).toBe(true); + } + // Fail fast on a stale dist before a run id is handed out; the payload + // build comes after both. + expect(examplePayloadGlobalSetup.slice(0, workspaceGlobalSetup.length)).toEqual(workspaceGlobalSetup); + expect(workspaceGlobalSetup[0]).toBe('./rstest.dist-freshness.setup.ts'); + expect(workspaceGlobalSetup).toContain('./rstest.global-setup.ts'); + expect(examplePayloadGlobalSetup.at(-1)).toBe('./rstest.integration.setup.ts'); + }); + + it('gives hooks the same budget as tests', () => { + expect(poolTimeouts(7_000)).toEqual({ hookTimeout: 7_000, testTimeout: 7_000 }); + }); + + it('caps process pools at 1..4 workers and scales polling with the shape', () => { + const workers = processPoolMaxWorkers(); + expect(workers).toBeGreaterThanOrEqual(1); + if (process.env['AGENT_BUNDLE_INTEGRATION_MAX_WORKERS'] === undefined) expect(workers).toBeLessThanOrEqual(4); + expect(processPoolTimeScale(1)).toBeGreaterThanOrEqual(1); + expect(processPoolTimeScale(2)).toBeGreaterThanOrEqual(2); + }); +}); + +describe.each(poolConfigs.map((name) => [name, expectations[name]] as const))('%s', (name, expected) => { + it('runs the orchestrator hooks', () => { + expect(list(resolved[name].globalSetup), `${name}: globalSetup`).toEqual([...expected.globalSetup]); + }); + + it('loads the per-worker isolation setup first', () => { + const setupFiles = list(resolved[name].setupFiles); + expect(setupFiles.slice(0, workspaceSetupFiles.length), `${name}: setupFiles`).toEqual([...workspaceSetupFiles]); + expect(setupFiles.length - workspaceSetupFiles.length, `${name}: extra setup files`).toBe(expected.extraSetupFiles); + }); + + it('sets an explicit test timeout and matches the hook timeout to it', () => { + expect(resolved[name].testTimeout, `${name}: testTimeout`).toBe(expected.testTimeout); + expect(resolved[name].hookTimeout, `${name}: hookTimeout`).toBe(expected.testTimeout); + }); +}); + +describe('helper-built pools', () => { + it.each(['rstest.route-unit.config.ts', 'rstest.projection.config.ts'] as const)( + '%s appends the generated route registry after the isolation setup and restores between tests', + (name) => { + const setupFiles = list(resolved[name].setupFiles); + expect(setupFiles[1]).toMatch(/[\\/]\.agent-bundle[\\/]test[\\/]route-setup\.mjs$/u); + expect(resolved[name]).toMatchObject(rstestHygiene); + }, + ); +}); + +describe('process pools', () => { + it.each(['rstest.config.ts', 'rstest.integration.config.ts'] as const)('%s shares the worker cap and the polling scale', (name) => { + const workers = processPoolMaxWorkers(); + expect(resolved[name].maxWorkers).toBe(workers); + expect(resolved[name].env?.['AGENT_BUNDLE_TEST_TIME_SCALE']).toBe(String(processPoolTimeScale(workers))); + }); + + it('runs the evidence, conformance and native-host journeys on one worker', () => { + expect(resolved['rstest.evidence.config.ts'].maxWorkers).toBe(1); + expect(resolved['rstest.mcp-conformance.config.ts'].maxWorkers).toBe(1); + expect(resolved['rstest.native-host.config.ts'].maxWorkers).toBe(1); + }); +}); diff --git a/packages/agent-bundle/tests/rstest-rslib-adapter.test.ts b/packages/agent-bundle/tests/rstest-rslib-adapter.test.ts new file mode 100644 index 000000000..df27e6e7d --- /dev/null +++ b/packages/agent-bundle/tests/rstest-rslib-adapter.test.ts @@ -0,0 +1,101 @@ +import { join, resolve } from 'node:path'; + +import type { RslibConfig } from '@rslib/core'; +import { withRslibConfig } from '@rstest/adapter-rslib'; +import { describe, expect, it, type ExtendConfig } from '@rstest/core'; + +import agentBundleRslibConfig from '../rslib.config.ts'; +import packageManifest from '../package.json' with { type: 'json' }; +import { agentBundleRslibAdapterOptions, rstestHygiene, withAgentBundleRslibConfig } from '../../../rstest.rslib.ts'; +import { agentBundlePackageRoot } from './helpers/workspace-paths.ts'; + +/** + * Every adapter-based pool extends `withAgentBundleRslibConfig()`, which hands + * `packages/agent-bundle/rslib.config.ts` to `@rstest/adapter-rslib` and trims + * the result. The adapter reads the lib entry only when told its `id`, and + * reads the entry silently as `{}` otherwise, so this suite pins both the id + * wiring and the resolved pool config the pools compile against. + */ +const workspaceRoot = resolve(agentBundlePackageRoot, '../..'); + +/** Names of the plugin objects in an Rsbuild `plugins` list; nested lists, promises, and falsy entries have none. */ +const pluginNames = (plugins: ExtendConfig['plugins']): readonly string[] => (plugins ?? []).flatMap((plugin) => ( + typeof plugin === 'object' && plugin !== null && 'name' in plugin && typeof plugin.name === 'string' ? [plugin.name] : [] +)); + +const publishOnlyPlugins = ['plugin-publint', 'agent-bundle:esm-node-globals']; + +/** The pool config as a pool with no overrides of its own receives it, resolved once. */ +let poolConfig: Promise | undefined; +const resolvedPoolConfig = (): Promise => (poolConfig ??= Promise.resolve(withAgentBundleRslibConfig()({}))); + +describe('rstest.rslib.ts', () => { + it('passes as libId the id of the single lib entry', () => { + expect(agentBundleRslibAdapterOptions.cwd).toBe(agentBundlePackageRoot); + expect((agentBundleRslibConfig.lib ?? []).map((lib) => lib.id)).toEqual([agentBundleRslibAdapterOptions.libId]); + }); + + it('reads the lib entry through libId only — the adapter falls back to an empty entry without a diagnostic', async () => { + const config = { + lib: [{ id: 'browser-cjs', format: 'cjs', output: { target: 'web' } }], + output: { target: 'node' }, + // Set so the adapter does not probe a tsconfig for `experimentalDecorators`. + source: { decorators: { version: '2022-03' } }, + } satisfies RslibConfig; + const [unnamed, unmatched, named] = await Promise.all([ + withRslibConfig({ config })({}), + withRslibConfig({ config, libId: 'no-such-lib' })({}), + withRslibConfig({ config, libId: 'browser-cjs' })({}), + ]); + expect(unnamed).toMatchObject({ output: { module: true }, testEnvironment: 'node' }); + expect(unmatched).toMatchObject({ output: { module: true }, testEnvironment: 'node' }); + expect(named).toMatchObject({ output: { module: false }, testEnvironment: 'happy-dom' }); + }); + + it('resolves the package rslib config, rooted at the workspace', async () => { + const config = await resolvedPoolConfig(); + expect(config.root).toBe(workspaceRoot); + expect(config.forceRerunTriggers).toEqual([join(agentBundlePackageRoot, 'rslib.config.ts')]); + }); + + it('compiles tests as ESM for node', async () => { + const config = await resolvedPoolConfig(); + expect(config.testEnvironment).toBe('node'); + expect(config.output?.module).toBe(true); + }); + + it('keeps the compile-time version define as a JSON string literal', async () => { + const config = await resolvedPoolConfig(); + const version: unknown = config.source?.define?.['__AGENT_BUNDLE_VERSION__']; + expect(version).toBe(JSON.stringify(packageManifest.version)); + expect(JSON.parse(version as string)).toBe(packageManifest.version); + }); + + it('repoints tsconfigPath at the workspace tsconfig', async () => { + const config = await resolvedPoolConfig(); + expect(config.source?.tsconfigPath).toBe(join(workspaceRoot, 'tsconfig.json')); + }); + + it('drops the publish-only plugins the package build registers', async () => { + const config = await resolvedPoolConfig(); + expect(pluginNames(agentBundleRslibConfig.plugins)).toEqual(expect.arrayContaining(publishOnlyPlugins)); + for (const name of publishOnlyPlugins) expect(pluginNames(config.plugins)).not.toContain(name); + }); + + it('drops tools.rspack', async () => { + const config = await resolvedPoolConfig(); + expect(agentBundleRslibConfig.tools?.rspack).toBeDefined(); + expect(config.tools?.rspack).toBeUndefined(); + }); + + it('drops the project name the adapter derives from libId, so pools keep the default', async () => { + const config = await resolvedPoolConfig(); + expect(config.name).toBeUndefined(); + }); + + it('applies rstestHygiene', async () => { + const config = await resolvedPoolConfig(); + expect(rstestHygiene).toEqual({ clearMocks: true, restoreMocks: true, unstubEnvs: true, unstubGlobals: true }); + expect(config).toMatchObject(rstestHygiene); + }); +}); diff --git a/packages/agent-bundle/tests/rstest-worker-root-teardown.test.ts b/packages/agent-bundle/tests/rstest-worker-root-teardown.test.ts new file mode 100644 index 000000000..3f5f63982 --- /dev/null +++ b/packages/agent-bundle/tests/rstest-worker-root-teardown.test.ts @@ -0,0 +1,145 @@ +import { randomUUID } from 'node:crypto'; +import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { expect, it, rs } from '@rstest/core'; + +import { setup as tagInvocation } from '../../../rstest.global-setup.ts'; +import { + removeRunRstestWorkerRoots, + rstestRunIdVariable, + rstestWorkerRootOwnerFile, + rstestWorkerRootPrefix, +} from '../../../scripts/rstest-worker-roots.mjs'; +import { rstestWorkerRoot, rstestWorkerRootOwner } from '../../../rstest.worker-isolation.ts'; + +/** + * The pool teardown (rstest.global-setup.ts) removes the worker roots of one + * Rstest invocation by the run id their owner markers carry. These tests + * cover the run-id key end to end: the marker records the id the worker + * inherited, `setup` decides that id, and the sweep removes exactly the + * finished roots that carry it. rstest-worker-isolation.test.ts covers the + * `temporaryRoot` key scripts/local-ci.mjs sweeps by. + */ + +const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u; + +it('records the run id the worker inherited in its owner marker', async () => { + const root = rstestWorkerRoot(); + const markerPath = join(root, rstestWorkerRootOwnerFile); + const original = await readFile(markerPath, 'utf8'); + // rstest.setup.ts wrote the marker at worker start, after Rstest applied the + // orchestrator's environment — including the id the pool's global setup + // tagged this invocation with, when the pool runs one. + expect(rstestWorkerRootOwner(root)?.runId).toBe(process.env[rstestRunIdVariable]); + + const runId = randomUUID(); + try { + // The marker is written once per root, so re-create it under a stubbed id. + rs.stubEnv(rstestRunIdVariable, runId); + await rm(markerPath); + expect(rstestWorkerRoot()).toBe(root); + expect(rstestWorkerRootOwner(root)).toEqual({ ...(JSON.parse(original) as object), runId }); + + // Without the variable the marker names no run, so no teardown can claim it. + rs.stubEnv(rstestRunIdVariable, undefined); + await rm(markerPath); + rstestWorkerRoot(); + expect(rstestWorkerRootOwner(root)).not.toHaveProperty('runId'); + } finally { + await writeFile(markerPath, original); + } +}); + +it('tags the invocation with a fresh run id unless an outer runner already did', () => { + expect(rstestRunIdVariable).toBe('AGENT_BUNDLE_RSTEST_RUN_ID'); + + rs.stubEnv(rstestRunIdVariable, 'outer-runner-leg-1'); + tagInvocation(); + expect(process.env[rstestRunIdVariable]).toBe('outer-runner-leg-1'); + + rs.stubEnv(rstestRunIdVariable, undefined); + tagInvocation(); + const generated = process.env[rstestRunIdVariable]; + expect(generated).toMatch(uuidPattern); + + // An empty value is not an id an outer runner owns. + rs.stubEnv(rstestRunIdVariable, ''); + tagInvocation(); + expect(process.env[rstestRunIdVariable]).toMatch(uuidPattern); + expect(process.env[rstestRunIdVariable]).not.toBe(generated); +}); + +it('removes only the finished roots that carry one run id', async () => { + const parent = await mkdtemp(join(tmpdir(), 'ab-rstest-roots-parent-')); + const runId = randomUUID(); + const otherRunId = randomUUID(); + const writeRoot = async (name: string, owner: Readonly> | undefined): Promise => { + const root = join(parent, name); + await mkdir(join(root, 'cache', 'cmd-1-1'), { recursive: true }); + await writeFile(join(root, 'cache', 'cmd-1-1', 'leftover'), 'x'); + if (owner !== undefined) await writeFile(join(root, rstestWorkerRootOwnerFile), `${JSON.stringify(owner)}\n`); + return root; + }; + const marker = (workerId: string, pid: number, id?: string): Readonly> => ({ + cwd: '/w', + pid, + ...(id === undefined ? {} : { runId: id }), + temporaryRoot: '/tmp', + workerId, + }); + try { + const finished = await writeRoot(`${rstestWorkerRootPrefix}0000000000000001`, marker('1', 4_000_001, runId)); + const interrupted = await writeRoot(`${rstestWorkerRootPrefix}0000000000000002`, marker('2', 4_000_002, runId)); + const live = await writeRoot(`${rstestWorkerRootPrefix}0000000000000003`, marker('3', 4_000_003, runId)); + const otherRun = await writeRoot(`${rstestWorkerRootPrefix}0000000000000004`, marker('1', 4_000_004, otherRunId)); + // A pool that ran without rstest.global-setup.ts: marker, but no run id. + const untagged = await writeRoot(`${rstestWorkerRootPrefix}0000000000000005`, marker('1', 4_000_005)); + const unmarked = await writeRoot(`${rstestWorkerRootPrefix}0000000000000006`, undefined); + const corrupt = await writeRoot(`${rstestWorkerRootPrefix}0000000000000007`, undefined); + await writeFile(join(corrupt, rstestWorkerRootOwnerFile), '{not json'); + const unrelated = await writeRoot('agent-bundle-artifact-000001', marker('1', 4_000_008, runId)); + const everything = [finished, interrupted, live, otherRun, untagged, unmarked, corrupt, unrelated] + .map((root) => root.slice(parent.length + 1)) + .sort(); + + // An empty id matches nothing, so an unset variable can never widen the sweep. + await expect(removeRunRstestWorkerRoots({ isAlive: () => false, parent, runId: '' })) + .resolves.toEqual({ removed: [], retained: [] }); + expect((await readdir(parent)).sort()).toEqual(everything); + + const result = await removeRunRstestWorkerRoots({ + isAlive: (pid) => pid === 4_000_003, + parent, + runId, + }); + + expect(result).toEqual({ removed: [finished, interrupted], retained: [live] }); + expect((await readdir(parent)).sort()).toEqual([ + live, otherRun, untagged, unmarked, corrupt, unrelated, + ].map((root) => root.slice(parent.length + 1)).sort()); + await expect(readdir(join(live, 'cache', 'cmd-1-1'))).resolves.toEqual(['leftover']); + + // Once its owner has exited the retained root goes on the next pass; a pass + // with nothing to do is not an error, and neither is a missing parent. + await expect(removeRunRstestWorkerRoots({ isAlive: () => false, parent, runId })) + .resolves.toEqual({ removed: [live], retained: [] }); + // Untagged roots are reclaimed only for the checkout that asks, and only + // once their worker has exited; another run's tagged roots never are. + await expect(removeRunRstestWorkerRoots({ isAlive: () => false, parent, reclaimUntaggedFrom: '/elsewhere', runId })) + .resolves.toEqual({ removed: [], retained: [] }); + await expect(removeRunRstestWorkerRoots({ isAlive: () => true, parent, reclaimUntaggedFrom: '/w', runId })) + .resolves.toEqual({ removed: [], retained: [untagged] }); + await expect(removeRunRstestWorkerRoots({ isAlive: () => false, parent, reclaimUntaggedFrom: '/w', runId })) + .resolves.toEqual({ removed: [untagged], retained: [] }); + expect((await readdir(parent)).sort()).toEqual([otherRun, unmarked, corrupt, unrelated] + .map((root) => root.slice(parent.length + 1)).sort()); + await expect(removeRunRstestWorkerRoots({ isAlive: () => false, parent, runId })) + .resolves.toEqual({ removed: [], retained: [] }); + await expect(removeRunRstestWorkerRoots({ parent: join(parent, 'missing'), runId })) + .resolves.toEqual({ removed: [], retained: [] }); + } finally { + await rm(parent, { force: true, recursive: true }); + } +}); diff --git a/packages/workbench/tests/helpers/runtime-example-payload.ts b/packages/workbench/tests/helpers/runtime-example-payload.ts index 1fc9be484..bb0651298 100644 --- a/packages/workbench/tests/helpers/runtime-example-payload.ts +++ b/packages/workbench/tests/helpers/runtime-example-payload.ts @@ -1,11 +1,10 @@ import { execFile as executeFile } from 'node:child_process'; -import { access } from 'node:fs/promises'; -import { join } from 'node:path'; import { promisify } from 'node:util'; +import { checkDistFreshness, runtimeExampleBuildOutputs } from '../../../../scripts/dist-freshness.mjs'; + const execFile = promisify(executeFile); const workspaceRoot = process.cwd(); -const runtimeExample = join(workspaceRoot, 'examples', 'rsc-agent-runtime'); /** The example's prebuilt payload directories its declared artifacts package. */ export const runtimeExamplePayloads = ['app', 'runtime'] as const; @@ -13,8 +12,13 @@ export const runtimeExamplePayloads = ['app', 'runtime'] as const; /** * The rsc-agent-runtime example declares its Rsbuild output trees as prebuilt * payloads, so the workbench dev artifact epoch needs them to exist. Build - * them once when absent (Rsbuild only — the framework packaging step is what - * the fixtures exercise live). + * them when absent or stale (Rsbuild only — the framework packaging step is + * what the fixtures exercise live). Presence alone is not enough: the + * payload bundles `packages/rsc-runtime/dist`, so a `pnpm build` that + * rewrote the runtime, or an edit under the example's `src`, leaves a tree + * that exists and tests old code (#576). The short-circuit therefore + * requires every payload tree to be newer than every build input + * (scripts/dist-freshness.mjs lists them). * * This must not run concurrently with itself: two racing builds write the * same `examples/rsc-agent-runtime/dist` tree, and a fixture copying that @@ -22,12 +26,11 @@ export const runtimeExamplePayloads = ['app', 'runtime'] as const; * once in the orchestrator via `globalSetup` (rstest.integration.setup.ts) * before any worker starts; the per-fixture call in * runtime-playground-fixture.ts is then a warm no-op and only builds when a - * file is run through a single-worker config with a cold tree. + * file is run through a single-worker config with a cold or stale tree. */ export const ensureRuntimeExamplePayload = async (): Promise => { - const probes = await Promise.allSettled(runtimeExamplePayloads.map(async (payload) => - access(join(runtimeExample, 'dist', payload)))); - if (probes.every((probe) => probe.status === 'fulfilled')) return; + const payloads = checkDistFreshness(runtimeExampleBuildOutputs(workspaceRoot, runtimeExamplePayloads)); + if (payloads.every((payload) => payload.status === 'fresh')) return; const { RSTEST: _rstest, ...environment } = process.env; await execFile('pnpm', ['--filter', '@agent-bundle/rsc-agent-runtime-demo', 'exec', 'rsbuild', 'build', '--mode', 'production'], { cwd: workspaceRoot, diff --git a/rstest.config.ts b/rstest.config.ts index 789d74f94..48aec29a7 100644 --- a/rstest.config.ts +++ b/rstest.config.ts @@ -3,28 +3,54 @@ import { defineConfig } from '@rstest/core'; import { fixtureProjectTestFiles, mcpConformanceTestFiles, + nativeHostTestFiles, projectionTestFiles, routeUnitTestFiles, templateTestFiles, workspaceTestFileGlob, } from './rstest.integration-tests.ts'; +import { + examplePayloadGlobalSetup, + poolTimeouts, + processPoolMaxWorkers, + processPoolTimeScale, + workspaceSetupFiles, +} from './rstest.pools.ts'; import { withAgentBundleRslibConfig } from './rstest.rslib.ts'; +/** + * The default config: what `rstest ` runs when a script names files + * without choosing a pool (`test:host-install`, `test:packed:native`, + * `test:session`, `test:examples:browser`) and what `test:watch` discovers. + * Its scope is every test the adapter-less pools do not own, so integration, + * packed and evidence files land here alongside the unit files. It therefore + * carries the process pool's shape — the worker cap, the polling scale and + * the 30 s floor that Chrome + dev-server + rsbuild files need — rather than + * Rstest's cores - 1 workers and 5 s default, which the CI + * `host-install-proofs` job (dev-host-install.test.ts, 30 s per host) would + * otherwise run under. + */ +const maxWorkers = processPoolMaxWorkers(); + export default defineConfig({ extends: withAgentBundleRslibConfig(), include: [workspaceTestFileGlob], exclude: [ ...fixtureProjectTestFiles, ...mcpConformanceTestFiles, + ...nativeHostTestFiles, ...projectionTestFiles, ...routeUnitTestFiles, ...templateTestFiles, ], // The e2e fixtures copy the shared rsc-agent-runtime example dist; build it // once in the orchestrator so parallel workers never race the ensure-build. - globalSetup: ['./rstest.integration.setup.ts'], - setupFiles: ['./rstest.setup.ts'], + globalSetup: [...examplePayloadGlobalSetup], + pool: { maxWorkers }, + setupFiles: [...workspaceSetupFiles], // 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). + env: { AGENT_BUNDLE_TEST_TIME_SCALE: String(processPoolTimeScale(maxWorkers)) }, + ...poolTimeouts(30_000), }); diff --git a/rstest.dist-freshness.setup.ts b/rstest.dist-freshness.setup.ts new file mode 100644 index 000000000..64376fcc2 --- /dev/null +++ b/rstest.dist-freshness.setup.ts @@ -0,0 +1,15 @@ +import { assertFreshDist, workspaceBuildOutputs } from './scripts/dist-freshness.mjs'; + +/** + * Refuses to start a pool over a stale or missing `dist` (#576). Unit, + * route-unit and projection files import `@agent-bundle/runtime` and + * `agent-bundle` from dist, and the process pools read + * `packages/{agent-bundle,workbench}/dist`, so a green run over yesterday's + * build proves nothing about today's sources. Runs once in the orchestrator + * before any worker starts; the thrown Error fails the pool fast with the + * rebuild instruction. The mtime rule and the per-package input lists live + * in scripts/dist-freshness.mjs. + */ +export const setup = (): void => { + assertFreshDist(workspaceBuildOutputs(import.meta.dirname), { relativeTo: import.meta.dirname }); +}; diff --git a/rstest.evidence.config.ts b/rstest.evidence.config.ts index 864d024cc..6df215c88 100644 --- a/rstest.evidence.config.ts +++ b/rstest.evidence.config.ts @@ -1,6 +1,7 @@ import { defineConfig } from '@rstest/core'; import { nightlyEvidenceTestFiles } from './rstest.integration-tests.ts'; +import { examplePayloadGlobalSetup, poolTimeouts, workspaceSetupFiles } from './rstest.pools.ts'; import { withAgentBundleRslibConfig } from './rstest.rslib.ts'; /** @@ -15,9 +16,9 @@ import { withAgentBundleRslibConfig } from './rstest.rslib.ts'; export default defineConfig({ extends: withAgentBundleRslibConfig(), include: [...nightlyEvidenceTestFiles], - globalSetup: ['./rstest.integration.setup.ts'], + globalSetup: [...examplePayloadGlobalSetup], pool: { maxWorkers: 1 }, - setupFiles: ['./rstest.setup.ts'], + setupFiles: [...workspaceSetupFiles], isolate: true, - testTimeout: 30_000, + ...poolTimeouts(30_000), }); diff --git a/rstest.global-setup.ts b/rstest.global-setup.ts new file mode 100644 index 000000000..f6a8aa18f --- /dev/null +++ b/rstest.global-setup.ts @@ -0,0 +1,71 @@ +import { randomUUID } from 'node:crypto'; + +import { removeRunRstestWorkerRoots, rstestRunIdVariable } from './scripts/rstest-worker-roots.mjs'; + +/** + * Rstest `globalSetup` module: tags one Rstest invocation with a run id and, + * once every test file has finished, removes exactly the worker roots that + * invocation created — plus, as a one-time repair, the untagged roots this + * checkout's pools left behind before the teardown existed. + * + * Every pool worker derives a private temp root, `/tmp/ab-rstest-` + * (rstest.worker-isolation.ts), and stamps it with an owner marker. Nothing on + * the `pnpm test*` path used to remove those roots — only scripts/local-ci.mjs + * swept the ones derived from its private TMPDIR — so a developer machine + * accumulated one root per worker per run, tens of thousands over time. + * + * Mechanism. Rstest 0.11 loads `globalSetup` modules in a dedicated forked + * process, before any test worker starts, and keeps that process alive until + * teardown. `setup` puts the run id in `process.env`; Rstest diffs the env + * around `setup`, relays the change to the orchestrator, and the orchestrator + * merges its own `process.env` into each worker's runtime config, which the + * worker applies (`setupEnv`) before its setup files run. So by the time + * rstest.setup.ts isolates a worker and writes its owner marker, the variable + * is set and the marker records it as `runId` (verified empirically for both + * `isolate: true` and `isolate: false`; the pool's forked-env snapshot alone + * would NOT have carried it, since the pool is created before global setup). + * `teardown` runs in the same forked process after the pool has closed and + * every worker has exited, also when a test failed, so the module-level id is + * still at hand and no live owner is left to retain. + * + * Why the run id is the ownership key and not the pid or the cwd: each root's + * hash includes the *worker's* pid, so the orchestrator cannot predict the + * paths and pids are recycled; the cwd is shared by every invocation of the + * same checkout — the four pools `pnpm test` runs back to back, or the three + * local-CI legs running at once. A fresh UUID per invocation is unique across + * concurrent runs and common to all of that run's workers, which is exactly + * the set the teardown must remove and nothing more. An outer runner may set + * `AGENT_BUNDLE_RSTEST_RUN_ID` itself to own the id; it then must give + * concurrently running pools distinct ids, because each pool's teardown + * removes every finished root carrying its id. + * + * Set `AGENT_BUNDLE_RSTEST_DEBUG_ROOTS` (any non-empty value) to have the + * teardown report the roots it removed and retained on stderr. + */ + +/** Debug switch: report the teardown's removed and retained roots on stderr. */ +const debugRootsVariable = 'AGENT_BUNDLE_RSTEST_DEBUG_ROOTS'; + +/** The id `setup` tagged this invocation with; `teardown` sweeps by it. */ +let runId: string | undefined; + +export const setup = (): void => { + const inherited = process.env[rstestRunIdVariable]; + runId = inherited === undefined || inherited === '' ? randomUUID() : inherited; + process.env[rstestRunIdVariable] = runId; +}; + +export const teardown = async (): Promise => { + if (runId === undefined) return; + // Untagged roots this checkout left before the teardown existed go too: + // nothing else reclaims them, and each one is a marker every later sweep + // would read again. + const { removed, retained } = await removeRunRstestWorkerRoots({ reclaimUntaggedFrom: process.cwd(), runId }); + const debug = process.env[debugRootsVariable]; + if (debug === undefined || debug === '') return; + const listed = (roots: readonly string[]): string => (roots.length === 0 ? '' : `\n ${roots.join('\n ')}`); + console.error( + `[rstest.global-setup] run ${runId}: removed ${String(removed.length)} worker root(s)${listed(removed)}` + + `\n[rstest.global-setup] run ${runId}: retained ${String(retained.length)} live worker root(s)${listed(retained)}`, + ); +}; diff --git a/rstest.integration.config.ts b/rstest.integration.config.ts index 783ef2fa4..0fd50d94f 100644 --- a/rstest.integration.config.ts +++ b/rstest.integration.config.ts @@ -1,50 +1,22 @@ -import { availableParallelism } from 'node:os'; - import { defineConfig } from '@rstest/core'; import { integrationTestFiles } from './rstest.integration-tests.ts'; +import { + examplePayloadGlobalSetup, + poolTimeouts, + processPoolMaxWorkers, + processPoolTimeScale, + workspaceSetupFiles, +} from './rstest.pools.ts'; import { withAgentBundleRslibConfig } from './rstest.rslib.ts'; /** - * Worker count for the parallel integration pool, CI and local alike: half - * the cores (hosted runners report 4, so CI runs 2 workers), clamped to at - * least 1 and at most 4. Rstest's own auto-sizing would run cores - 1 (3 on - * hosted runners), but every worker here drives a Chrome + dev-server + - * rsbuild pair, so halving keeps the pairs from starving each other and the - * cap bounds memory on large machines; the 2-worker shape is also the one - * the burn-in evidence covers. Shared cache, tmp, and pack roots are - * isolated per worker via RSTEST_WORKER_ID (see rstest.setup.ts). - * - * History: CI briefly pinned 1 worker because early 2-worker matrix runs - * flaked on a rotating test per leg. The causes were since fixed at the - * source rather than by keeping the serial shape: contention-sensitive tests - * now sequence readiness instead of racing fixed timers (e.g. the - * script-playground descendant-drain suites), shared cold artifacts are - * built once in the orchestrator (see globalSetup below), watched-file and - * pid publications use staged renames, dev ports are ephemeral, and polling - * budgets follow AGENT_BUNDLE_TEST_TIME_SCALE. Burn-ins of the 2-worker, - * 4-core CI shape back the unpin; if a new contention flake appears, fix its - * race — do not re-pin. AGENT_BUNDLE_INTEGRATION_MAX_WORKERS overrides the - * computed value (e.g. to bisect locally in serial). - */ -const overrideWorkers = Number(process.env['AGENT_BUNDLE_INTEGRATION_MAX_WORKERS'] ?? ''); -const maxWorkers = Number.isSafeInteger(overrideWorkers) && overrideWorkers >= 1 - ? overrideWorkers - : 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. + * Worker count and polling scale come from rstest.pools.ts (half the cores, + * clamped to 1..4; scale 2 whenever more than one worker shares the + * machine). Shared cache, tmp, and pack roots are isolated per worker via + * RSTEST_WORKER_ID (see rstest.setup.ts). */ -const externalTimeScale = Number(process.env['AGENT_BUNDLE_TEST_TIME_SCALE'] ?? ''); -const poolTimeScale = maxWorkers > 1 ? 2 : 1; -const timeScale = Number.isSafeInteger(externalTimeScale) && externalTimeScale >= 1 - ? Math.max(externalTimeScale, poolTimeScale) - : poolTimeScale; +const maxWorkers = processPoolMaxWorkers(); /** * Build- and process-running tests that only read workspace-shared artifacts; @@ -54,12 +26,13 @@ const timeScale = Number.isSafeInteger(externalTimeScale) && externalTimeScale > export default defineConfig({ extends: withAgentBundleRslibConfig(), include: [...integrationTestFiles], - // Builds the rsc-agent-runtime example payload once before workers start; - // parallel workers must never race that shared ensure-build (see + // Refuses a stale dist, tags the run for worker-root teardown, and builds + // the rsc-agent-runtime example payload once before workers start; parallel + // workers must never race that shared ensure-build (see // rstest.integration.setup.ts). - globalSetup: ['./rstest.integration.setup.ts'], + globalSetup: [...examplePayloadGlobalSetup], pool: { maxWorkers }, - setupFiles: ['./rstest.setup.ts'], + setupFiles: [...workspaceSetupFiles], // 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). @@ -68,6 +41,6 @@ export default defineConfig({ // 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, + env: { AGENT_BUNDLE_TEST_TIME_SCALE: String(processPoolTimeScale(maxWorkers)) }, + ...poolTimeouts(30_000), }); diff --git a/rstest.mcp-conformance.config.ts b/rstest.mcp-conformance.config.ts index 9c0755858..4b0bab64c 100644 --- a/rstest.mcp-conformance.config.ts +++ b/rstest.mcp-conformance.config.ts @@ -1,12 +1,14 @@ import { defineConfig } from '@rstest/core'; import { mcpConformanceTestFiles } from './rstest.integration-tests.ts'; +import { poolTimeouts, workspaceGlobalSetup, workspaceSetupFiles } from './rstest.pools.ts'; import { withAgentBundleRslibConfig } from './rstest.rslib.ts'; export default defineConfig({ extends: withAgentBundleRslibConfig(), include: [...mcpConformanceTestFiles], + globalSetup: [...workspaceGlobalSetup], pool: { maxWorkers: 1 }, - setupFiles: ['./rstest.setup.ts'], - testTimeout: 180_000, + setupFiles: [...workspaceSetupFiles], + ...poolTimeouts(180_000), }); diff --git a/rstest.packed.config.ts b/rstest.packed.config.ts index 696f8570d..208a823c0 100644 --- a/rstest.packed.config.ts +++ b/rstest.packed.config.ts @@ -1,6 +1,7 @@ import { defineConfig } from '@rstest/core'; import { packedReleaseOnlyTestFiles, packedTestFiles } from './rstest.integration-tests.ts'; +import { poolTimeouts, workspaceGlobalSetup, workspaceSetupFiles } from './rstest.pools.ts'; import { withAgentBundleRslibConfig } from './rstest.rslib.ts'; /** @@ -19,5 +20,10 @@ export default defineConfig({ ...packedTestFiles, ...(process.env['AGENT_BUNDLE_PACKED_RELEASE'] === '1' ? packedReleaseOnlyTestFiles : []), ], - setupFiles: ['./rstest.setup.ts'], + globalSetup: [...workspaceGlobalSetup], + setupFiles: [...workspaceSetupFiles], + // A pack + consumer install + host run per case: the files set their own + // per-test budgets (up to 300 s), and this floor keeps a case that forgets + // one from dying at Rstest's 5 s default (#576). + ...poolTimeouts(120_000), }); diff --git a/rstest.pools.ts b/rstest.pools.ts new file mode 100644 index 000000000..45f22266e --- /dev/null +++ b/rstest.pools.ts @@ -0,0 +1,86 @@ +import { availableParallelism } from 'node:os'; + +/** + * Pool-level policy shared by every workspace Rstest configuration: the + * orchestrator hooks each pool runs, the per-worker setup file, and the + * timeout floors. Per-test restoration lives in `rstestHygiene` + * (rstest.rslib.ts) because the adapter-based pools inherit it through + * `extends`; the values here are the ones every pool spells out itself. + */ + +/** + * Orchestrator hooks every pool runs, in order: refuse a stale `dist` before + * anything else happens (rstest.dist-freshness.setup.ts), then tag this + * invocation so its teardown can remove exactly the worker roots it created + * (rstest.global-setup.ts). Both run once, in Rstest's global-setup process, + * before any worker starts; the run id reaches the workers through Rstest's + * env relay (see rstest.global-setup.ts). + */ +export const workspaceGlobalSetup: readonly string[] = [ + './rstest.dist-freshness.setup.ts', + './rstest.global-setup.ts', +]; + +/** + * The hooks above plus the shared rsc-agent-runtime example payload build + * (rstest.integration.setup.ts), for pools whose fixtures copy + * `examples/rsc-agent-runtime/dist`. + */ +export const examplePayloadGlobalSetup: readonly string[] = [ + ...workspaceGlobalSetup, + './rstest.integration.setup.ts', +]; + +/** Per-worker TMPDIR / XDG isolation; must precede any test module. */ +export const workspaceSetupFiles: readonly string[] = ['./rstest.setup.ts']; + +/** + * A pool's timeout floors. `hookTimeout` matches `testTimeout` so a + * `beforeAll` that prepares what a test is allowed to spend `testTimeout` on + * does not die at Rstest's 10 s hook default first. Explicit per-test + * timeouts still win. + */ +export const poolTimeouts = (testTimeout: number): Readonly<{ hookTimeout: number; testTimeout: number }> => + ({ hookTimeout: testTimeout, testTimeout }); + +/** + * Worker count for pools whose files each drive a Chrome + dev-server + + * rsbuild pair, CI and local alike: half the cores (hosted runners report 4, + * so CI runs 2 workers), clamped to at least 1 and at most 4. Rstest's own + * auto-sizing would run cores - 1 (3 on hosted runners); halving keeps the + * pairs from starving each other, the cap bounds memory on large machines, + * and the 2-worker shape is the one the burn-in evidence covers. + * AGENT_BUNDLE_INTEGRATION_MAX_WORKERS overrides the computed value (e.g. to + * bisect locally in serial). + * + * History: CI briefly pinned 1 worker because early 2-worker matrix runs + * flaked on a rotating test per leg. The causes were since fixed at the + * source rather than by keeping the serial shape: contention-sensitive tests + * now sequence readiness instead of racing fixed timers (e.g. the + * script-playground descendant-drain suites), shared cold artifacts are + * built once in the orchestrator (`examplePayloadGlobalSetup`), watched-file + * and pid publications use staged renames, dev ports are ephemeral, and + * polling budgets follow AGENT_BUNDLE_TEST_TIME_SCALE. Burn-ins of the + * 2-worker, 4-core CI shape back the unpin; if a new contention flake + * appears, fix its race — do not re-pin. + */ +export const processPoolMaxWorkers = (): number => { + const override = Number(process.env['AGENT_BUNDLE_INTEGRATION_MAX_WORKERS'] ?? ''); + return Number.isSafeInteger(override) && override >= 1 + ? override + : 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 in rstest.integration.config.ts); 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. + */ +export const processPoolTimeScale = (maxWorkers: number): number => { + const external = Number(process.env['AGENT_BUNDLE_TEST_TIME_SCALE'] ?? ''); + const fromShape = maxWorkers > 1 ? 2 : 1; + return Number.isSafeInteger(external) && external >= 1 ? Math.max(external, fromShape) : fromShape; +}; diff --git a/rstest.projection.config.ts b/rstest.projection.config.ts index aa0a2efbb..e4bd02546 100644 --- a/rstest.projection.config.ts +++ b/rstest.projection.config.ts @@ -3,6 +3,7 @@ import { resolve } from 'node:path'; import { defineConfig } from '@rstest/core'; import { agentBundleRstest } from './packages/agent-bundle/src/rstest/index.ts'; +import { poolTimeouts, workspaceGlobalSetup, workspaceSetupFiles } from './rstest.pools.ts'; import { rstestHygiene } from './rstest.rslib.ts'; /** @@ -14,13 +15,20 @@ import { rstestHygiene } from './rstest.rslib.ts'; * * Neither level opens a process. The `packed-stdio` level lives in the packed * pool (`pnpm test:packed`), which owns the run's single build and pack. - * Per-test restoration comes from the workspace's shared policy, as in the - * route-unit pool. + * Per-test restoration, per-worker temp isolation and the orchestrator hooks + * come from the workspace's shared policy, as in the route-unit pool. */ +const helper = await agentBundleRstest({ + include: ['packages/agent-bundle/tests/projection/**/*.test.ts'], + root: resolve(import.meta.dirname, 'packages/agent-bundle/fixtures/route-harness'), +}); + export default defineConfig({ - ...(await agentBundleRstest({ - include: ['packages/agent-bundle/tests/projection/**/*.test.ts'], - root: resolve(import.meta.dirname, 'packages/agent-bundle/fixtures/route-harness'), - })), + ...helper, + globalSetup: [...workspaceGlobalSetup], + setupFiles: [...workspaceSetupFiles, ...helper.setupFiles], ...rstestHygiene, + // contract-matrix.test.ts runs a full runContractMatrix per case (≈5.5 s, + // slowest 8.3 s in the #576 audit); 30 s per case is >3× that. + ...poolTimeouts(30_000), }); diff --git a/rstest.route-unit.config.ts b/rstest.route-unit.config.ts index 734f011fe..79ee94f66 100644 --- a/rstest.route-unit.config.ts +++ b/rstest.route-unit.config.ts @@ -3,6 +3,7 @@ import { resolve } from 'node:path'; import { defineConfig } from '@rstest/core'; import { agentBundleRstest } from './packages/agent-bundle/src/rstest/index.ts'; +import { poolTimeouts, workspaceGlobalSetup, workspaceSetupFiles } from './rstest.pools.ts'; import { rstestHygiene } from './rstest.rslib.ts'; /** @@ -11,12 +12,23 @@ import { rstestHygiene } from './rstest.rslib.ts'; * needs the `react-server` Node condition, which is a pool-level process * flag — that is why it is a separate run from `rstest.unit.config.ts` and not * a project inside it. The shipped helper carries no per-test restoration - * policy (that is the consumer's call), so the workspace pool adds its own. + * policy, no per-worker temp isolation and no orchestrator hooks (those are + * the consumer's call), so the workspace pool adds its own: the isolation + * setup runs before the helper's generated route registry, as in every other + * pool, so `mkdtemp(tmpdir())` and spawned children land in the worker root. */ +const helper = await agentBundleRstest({ + include: ['packages/agent-bundle/tests/route-unit/**/*.test.ts'], + root: resolve(import.meta.dirname, 'packages/agent-bundle/fixtures/route-harness'), +}); + export default defineConfig({ - ...(await agentBundleRstest({ - include: ['packages/agent-bundle/tests/route-unit/**/*.test.ts'], - root: resolve(import.meta.dirname, 'packages/agent-bundle/fixtures/route-harness'), - })), + ...helper, + globalSetup: [...workspaceGlobalSetup], + setupFiles: [...workspaceSetupFiles, ...helper.setupFiles], ...rstestHygiene, + // lifecycle-replay.test.ts is the pool's long file (12.3 s of tests across + // its cases in the #576 audit); 30 s per case keeps >2× headroom over the + // slowest of them without hiding a hang. + ...poolTimeouts(30_000), }); diff --git a/rstest.rslib.ts b/rstest.rslib.ts index e8da54a3b..a7a35fae5 100644 --- a/rstest.rslib.ts +++ b/rstest.rslib.ts @@ -1,8 +1,10 @@ import { resolve } from 'node:path'; -import { withRslibConfig } from '@rstest/adapter-rslib'; +import { withRslibConfig, type WithRslibConfigOptions } from '@rstest/adapter-rslib'; import type { ExtendConfig, ExtendConfigFn } from '@rstest/core'; +import { agentBundleLibId } from './packages/agent-bundle/rslib.config.ts'; + const workspaceRoot = import.meta.dirname; const packageRoot = resolve(workspaceRoot, 'packages/agent-bundle'); @@ -53,36 +55,107 @@ export const rstestHygiene = { } as const satisfies ExtendConfig; /** - * The shared pool configuration: the package's Rslib build config, reduced to - * what compiling tests needs, plus `rstestHygiene`. + * What `packages/agent-bundle/rslib.config.ts` becomes under the adapter — + * `withRslibConfig` in @rstest/adapter-rslib 0.11.12 (dist/index.js), + * verified against its source, since the adapter documents none of it. + * + * The lib entry is found by `libId` (line 53: `lib.find((l) => l.id === + * libId) || {}`); without a `libId`, or with one no entry carries, the entry + * is silently `{}` and only the top-level fields count. That is why the entry + * has an `id` and these options pass it: the top-level fields happen to carry + * everything the pools need, so the result was right by accident, and a field + * moved into the entry — `output.target`, `source.define` — would have + * vanished from every pool without a diagnostic. Of the entry, only `source`, + * `output`, `tools`, `plugins`, and `resolve` are merged over the top-level + * config (lines 54-61); `format` is read once more, directly, as the fallback + * for `output.module` (line 105). + * + * Mapped into the pool config (lines 69-120), after `modifyLibConfig`: + * + * | Rslib config | Rstest config | + * | -------------------------------------------- | -------------------------------------- | + * | `root` | `root` | + * | `plugins` | `plugins`, verbatim, plus the | + * | | adapter's own entry that removes | + * | | `rsbuild:dts` and `rsbuild:type-check` | + * | `source.define`, `source.tsconfigPath` | the same keys (also `assetsInclude`, | + * | | `decorators`, `include`, `exclude`, | + * | | `transformImport`) | + * | `resolve` | `resolve`, verbatim | + * | `output.module`, else lib `format !== 'cjs'` | `output.module` (`true` is ESM) | + * | `output.target` | `testEnvironment`: `web` becomes | + * | | `happy-dom`, anything else `node` | + * | | (line 119) | + * | `output.cssModules` | `output.cssModules` | + * | `performance.buildCache` | `performance.buildCache`, its | + * | | `buildDependencies` resolved against | + * | | the config file, which is appended | + * | `tools.rspack`, `tools.swc`, | the same keys | + * | `tools.bundlerChain` | | + * | the config file's path | `forceRerunTriggers` | + * | the `libId` option | `name` (line 75; dropped below) | + * + * Never read: every other lib-entry field — `bundle`, `dts`, `syntax`, and + * would-be `autoExternal`, `autoExtension`, `redirect`, `shims`, `banner`, + * `footer`, `umdName`, `outBase`, `experiments` — and, top-level, + * `source.entry`, `output.{cleanDistPath,copy,filenameHash,legalComments}` + * (likewise `output.externals`, `distPath`, `minify`, `sourceMap`), any + * `tools.*` beyond the three above, any `performance.*` beyond `buildCache`, + * `mode`, `logLevel`, `dev`, `server`. Pools therefore leave every dependency + * external — Rstest's default for `testEnvironment: 'node'` — where the + * package build's `autoExternal` bundles devDependencies (the TypeScript + * parser, #381). One probe escapes `modifyLibConfig`: when the pool sets + * neither `source.tsconfigPath` nor `source.decorators.version` and the lib + * sets no `source.decorators.version`, the adapter reads the tsconfig the lib + * names (`tsconfig.build.json`, before the repointing below) for + * `experimentalDecorators` (lines 62-67); it feeds nothing else. * - * Kept from the package build: `source.define` — `__AGENT_BUNDLE_VERSION__` - * in src/cli.ts is a compile-time identifier and resolves here exactly as in - * the published build — and `source.tsconfigPath`, repointed at the workspace - * tsconfig so test files resolve beside the sources. Dropped: the publish-only - * plugins above and `tools.rspack`, whose `ignoreWarnings` entry and - * `node.__dirname = false` exist for the inlined TypeScript parser (external - * in pools; Rstest sets its own `node` options). + * `modifyLibConfig` keeps `source.define` — `__AGENT_BUNDLE_VERSION__` in + * src/cli.ts is a compile-time identifier and resolves here exactly as in the + * published build — and `source.tsconfigPath`, repointed at the workspace + * tsconfig so test files resolve beside the sources. It drops the + * publish-only plugins above and `tools.rspack`, whose `ignoreWarnings` entry + * and `node.__dirname = false` exist for the inlined TypeScript parser + * (external in pools; Rstest sets its own `node` options). + * + * Exported so rstest-rslib-adapter.test.ts can hold `libId` against the + * config's lib entries: the resolved config cannot show whether the lookup + * matched, because a miss yields `{}` and this config's entry adds nothing + * the top level lacks. + */ +export const agentBundleRslibAdapterOptions = { + cwd: packageRoot, + libId: agentBundleLibId, + modifyLibConfig: ({ plugins, tools, ...config }) => { + const { rspack: _publishOnlyRspack, ...testTools } = tools ?? {}; + return { + ...config, + root: workspaceRoot, + plugins: plugins?.filter((plugin) => { + const name = pluginName(plugin); + return name === undefined || !publishOnlyPlugins.has(name); + }), + source: { + ...config.source, + tsconfigPath: resolve(workspaceRoot, 'tsconfig.json'), + }, + tools: testTools, + }; + }, +} satisfies WithRslibConfigOptions; + +/** + * The shared pool configuration: the package's Rslib build config, reduced to + * what compiling tests needs (`agentBundleRslibAdapterOptions`), plus + * `rstestHygiene`, minus the `name` the adapter derives from `libId` — a + * project name labels GitHub step summaries and answers `--project`, and + * `esm-node` describes the build, not a pool, so the pools keep Rstest's + * default (`rstest`). */ export const withAgentBundleRslibConfig = (): ExtendConfigFn => { - const rslib = withRslibConfig({ - cwd: packageRoot, - modifyLibConfig: ({ plugins, tools, ...config }) => { - const { rspack: _publishOnlyRspack, ...testTools } = tools ?? {}; - return { - ...config, - root: workspaceRoot, - plugins: plugins?.filter((plugin) => { - const name = pluginName(plugin); - return name === undefined || !publishOnlyPlugins.has(name); - }), - source: { - ...config.source, - tsconfigPath: resolve(workspaceRoot, 'tsconfig.json'), - }, - tools: testTools, - }; - }, - }); - return async (userConfig) => ({ ...(await rslib(userConfig)), ...rstestHygiene }); + const rslib = withRslibConfig(agentBundleRslibAdapterOptions); + return async (userConfig) => { + const { name: _libId, ...config } = await rslib(userConfig); + return { ...config, ...rstestHygiene }; + }; }; diff --git a/rstest.unit.config.ts b/rstest.unit.config.ts index e966da3a4..e14793427 100644 --- a/rstest.unit.config.ts +++ b/rstest.unit.config.ts @@ -4,6 +4,7 @@ import { fixtureProjectTestFiles, integrationTestFiles, mcpConformanceTestFiles, + nativeHostTestFiles, nightlyEvidenceTestFiles, packedReleaseOnlyTestFiles, packedTestFiles, @@ -12,16 +13,25 @@ import { templateTestFiles, workspaceTestFileGlob, } from './rstest.integration-tests.ts'; +import { poolTimeouts, workspaceGlobalSetup, workspaceSetupFiles } from './rstest.pools.ts'; import { withAgentBundleRslibConfig } from './rstest.rslib.ts'; -/** Build-free, process-free tests only; safe on parallel workers. `pnpm test` runs this before the integration config. */ +/** + * No builds, no long-lived processes: files that run the compiler (`build()` + * from src/api, `compileMcpApps`) belong in integrationTestFiles; short-lived + * children a test spawns and reaps itself (a `node -e`, `git --version`, a + * one-shot Flight render worker) are fine here. Safe on parallel workers with + * a shared module cache. `pnpm test` runs this before the integration config. + */ export default defineConfig({ extends: withAgentBundleRslibConfig(), + globalSetup: [...workspaceGlobalSetup], include: [workspaceTestFileGlob], exclude: [ ...fixtureProjectTestFiles, ...integrationTestFiles, ...mcpConformanceTestFiles, + ...nativeHostTestFiles, ...nightlyEvidenceTestFiles, // Packs and installs like packedTestFiles, and is release-boundary-only: // `test:packed:release` owns it, not the build-free per-PR pool. @@ -31,7 +41,13 @@ export default defineConfig({ ...routeUnitTestFiles, ...templateTestFiles, ], - setupFiles: ['./rstest.setup.ts'], + setupFiles: [...workspaceSetupFiles], // Unit files construct per-test services; logs-real.e2e is not in this pool. isolate: false, + // The slowest unit cases build a TypeScript program in-process + // (inspect-state.test.ts: 2.3–4.5 s per case under load, and a 5 s-default + // timeout in 1 of 3 audited runs — #576). 15 s is >3× that; the runCli + // journeys that legitimately take longer (runtime-client-surface-proxy, + // eval-native-mount) carry their own per-test timeouts, which win. + ...poolTimeouts(15_000), }); diff --git a/rstest.worker-isolation.ts b/rstest.worker-isolation.ts index 5fa644104..684ed9ad3 100644 --- a/rstest.worker-isolation.ts +++ b/rstest.worker-isolation.ts @@ -3,7 +3,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { homedir, tmpdir } from 'node:os'; import { join } from 'node:path'; -import { rstestWorkerRootOwnerFile } from './scripts/rstest-worker-roots.mjs'; +import { rstestRunIdVariable, rstestWorkerRootOwnerFile } from './scripts/rstest-worker-roots.mjs'; export const rstestWorkerId = (): string => process.env['RSTEST_WORKER_ID'] ?? '0'; @@ -12,13 +12,17 @@ const hostTemporaryRoot = tmpdir(); /** * Owner marker for a hashed worker root. The root's name is not predictable * from outside (the hash includes this process id), so the marker is how a - * runner that owns `temporaryRoot` — scripts/local-ci.mjs and its per-leg - * TMPDIR — recognizes and removes the roots a finished run left behind - * without touching another run's live roots. + * sweeper recognizes and removes the roots a finished run left behind without + * touching another run's live roots: the pool's own teardown + * (rstest.global-setup.ts) matches `runId`, the id of the Rstest invocation + * this worker belongs to; scripts/local-ci.mjs matches `temporaryRoot`, its + * per-leg TMPDIR. `runId` is absent when the pool ran without the global + * setup, so no teardown can claim such a root. */ export interface RstestWorkerRootOwner { readonly cwd: string; readonly pid: number; + readonly runId?: string; readonly temporaryRoot: string; readonly workerId: string; } @@ -32,9 +36,11 @@ export const rstestWorkerRootOwner = (root: string): RstestWorkerRootOwner | und const writeOwnerMarker = (root: string, workerId: string): void => { const path = join(root, rstestWorkerRootOwnerFile); if (existsSync(path)) return; + const runId = process.env[rstestRunIdVariable]; const owner: RstestWorkerRootOwner = { cwd: process.cwd(), pid: process.pid, + ...(runId === undefined || runId === '' ? {} : { runId }), temporaryRoot: hostTemporaryRoot, workerId, }; diff --git a/scripts/check-dist-fresh.mjs b/scripts/check-dist-fresh.mjs new file mode 100644 index 000000000..f9638063f --- /dev/null +++ b/scripts/check-dist-fresh.mjs @@ -0,0 +1,19 @@ +/** + * `node scripts/check-dist-fresh.mjs` — exits 1 with the rebuild instruction + * when any dist `pnpm build` produces is older than its inputs or absent; + * silent and 0 otherwise. The root `typecheck` script runs it first, because + * `tsc` types the tests against `dist/*.d.ts` (scripts/dist-freshness.mjs + * explains the descriptors and the mtime rule). + */ +import { resolve } from 'node:path'; + +import { assertFreshDist, workspaceBuildOutputs } from './dist-freshness.mjs'; + +const workspaceRoot = resolve(import.meta.dirname, '..'); + +try { + assertFreshDist(workspaceBuildOutputs(workspaceRoot), { relativeTo: workspaceRoot }); +} catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; +} diff --git a/scripts/dist-freshness.d.mts b/scripts/dist-freshness.d.mts new file mode 100644 index 000000000..713f65acd --- /dev/null +++ b/scripts/dist-freshness.d.mts @@ -0,0 +1,63 @@ +export type DistFreshnessStatus = 'fresh' | 'stale' | 'missing'; + +export interface DistDescriptor { + /** Package name, as printed in the failure message. */ + readonly name: string; + /** Absolute package root; `inputs` and `output` resolve against it. */ + readonly root: string; + /** Files and directories the build reads, relative to `root` (`..` segments allowed). */ + readonly inputs: readonly string[]; + /** The directory the build writes, relative to `root`. */ + readonly output: string; +} + +export interface NewestEntry { + /** Absolute path of the newest file or directory. */ + readonly path: string; + /** Its modification time, in milliseconds since the epoch. */ + readonly mtimeMs: number; +} + +export interface DistFreshness { + readonly name: string; + /** Absolute path of the output directory. */ + readonly output: string; + readonly status: DistFreshnessStatus; + readonly newestInput: NewestEntry; + /** Undefined when `status` is `missing`. */ + readonly newestOutput: NewestEntry | undefined; +} + +export interface NewestEntryOptions { + /** Count directory mtimes as well as file mtimes; defaults to false. */ + readonly countDirectories?: boolean; + /** Directory names not to enter; the walk root is always entered. */ + readonly skip?: (name: string) => boolean; +} + +export interface FormatDistFreshnessOptions { + /** Paths under this directory print relative to it; defaults to `process.cwd()`. */ + readonly relativeTo?: string; +} + +export declare const isSkippedInputDirectory: (name: string) => boolean; + +export declare const newestEntry: (path: string, options?: NewestEntryOptions) => NewestEntry | undefined; + +export declare const distFreshness: (descriptor: DistDescriptor) => DistFreshness; + +export declare const checkDistFreshness: (descriptors: readonly DistDescriptor[]) => readonly DistFreshness[]; + +export declare const formatDistFreshnessFailure: ( + results: readonly DistFreshness[], + options?: FormatDistFreshnessOptions, +) => string; + +export declare const assertFreshDist: (descriptors: readonly DistDescriptor[], options?: FormatDistFreshnessOptions) => void; + +export declare const workspaceBuildOutputs: (workspaceRoot?: string) => readonly DistDescriptor[]; + +export declare const runtimeExampleBuildOutputs: ( + workspaceRoot?: string, + payloads?: readonly string[], +) => readonly DistDescriptor[]; diff --git a/scripts/dist-freshness.mjs b/scripts/dist-freshness.mjs new file mode 100644 index 000000000..2f61af904 --- /dev/null +++ b/scripts/dist-freshness.mjs @@ -0,0 +1,317 @@ +/** + * Freshness of the workspace `dist` trees, judged by newest mtime (#576, + * finding 3-P1 "stale dist"). + * + * The unit, route-unit and projection pools import `@agent-bundle/runtime` + * and `agent-bundle` through their package `exports`, which name `dist` only; + * `pnpm typecheck` types the tests against the same `dist/*.d.ts`; the + * process pools read `packages/{agent-bundle,workbench}/dist` and copy + * `examples/rsc-agent-runtime/dist`. None of those paths knows when the + * sources moved on, so a green run can prove yesterday's build — it did, on + * #570's own gate. This module answers one question per built output: is + * every file the build read older than the newest file it wrote? Three + * callers act on the answer: `scripts/check-dist-fresh.mjs` ahead of + * `pnpm typecheck`, `rstest.dist-freshness.setup.ts` in each pool's + * orchestrator before the first worker starts, and + * `packages/workbench/tests/helpers/runtime-example-payload.ts`, which + * rebuilds the example payload instead of short-circuiting on a stale one. + * + * Comparison. A package is `missing` when its output directory is absent or + * holds no file, `stale` when the newest input mtime is later than the + * newest output mtime, `fresh` otherwise. Inputs count files and + * directories: a directory's mtime moves when an entry is created, renamed + * or deleted, which is how a removed source file is noticed. Outputs count + * files only — the bytes a test loads. While walking, `node_modules`, + * `dist`, `.rstest-temp` and every other dot-directory are skipped inside + * inputs (an input root that is itself a `dist` is still walked: the example + * bundles two workspace dists); outputs skip `node_modules` and + * dot-directories, so a temp writer inside a dist cannot make it look new. + * + * False positives run in the safe direction. A `git checkout`, `git stash` + * or rebase rewrites the touched sources with the current time, so a dist + * whose content would be byte-identical reads as stale and the guard asks + * for `pnpm build` (about 15 s warm). The build is the fix in every case; the + * guard never guesses that unchanged-looking bytes are unchanged. There is + * no bypass variable: every flow in this repository that runs a pool or + * `pnpm typecheck` builds first (ci.yml `verify`: build, typecheck, lint, + * test; the `test:integration`, `test:evidence`, `test:mcp-conformance` and + * `check` scripts; scripts/run-packed-tests.mjs; scripts/local-ci.mjs legs; + * docs.yml runs the website's own typecheck), so a skip switch would only + * serve to reopen the hole this closes. Add one when a flow that must run + * over a deliberately prebuilt dist exists, and name that flow beside it. + * + * Inputs per package (`workspaceBuildOutputs`), read from each build config: + * + * - Every package: `src` (the entries and everything they import), the + * Rslib/Rsbuild config, `package.json` (entry list and `exports`; + * agent-bundle also bakes its `version` in through `source.define`), the + * tsconfig the config names, and the root `tsconfig.json` and + * `tsconfig.base.json` those extend (compiler options shape the `.d.ts` + * emit and SWC settings). `pnpm-lock.yaml` for all of them: agent-bundle + * inlines the TypeScript 5 parser and every devDependency it imports, + * create-agent-bundle inlines `@clack/prompts`, so a dependency bump is a + * build input too. `LICENSE`/`NOTICE` are copied beside dist, not into it, + * and are not inputs; neither are `bin/` and `templates/`, which ship as + * files. + * - rsc-markdown-stream: `dts: false`; `src/index.d.ts` is copied into dist + * and lives under `src`, so it is covered. + * - agent-bundle-workbench: an Rsbuild app — `src`, `index.html` (the + * template), `THIRD_PARTY_NOTICES` (copied into dist), `rsbuild.config.ts`. + * `pnpm build` produces this dist through agent-bundle's + * `build:workbench` step, so it is listed like the publishable four. + * - agent-bundle: its Rslib build copies `../workbench/dist` into + * `dist/workbench`, after its package script has rebuilt the Workbench. The + * descriptor therefore lists the Workbench's build inputs — not its dist — + * so an in-place Workbench rebuild from unchanged sources (workbench-e2e.ts + * and runtime-playground-fixture.ts run one when + * AGENT_BUNDLE_WORKBENCH_PREBUILT is unset) does not demand a full + * `pnpm build` at the next pool start. The Workbench descriptor still flags + * a Workbench dist older than its own sources, so between the two every + * stale copy is caught. + * + * The rsc-agent-runtime example (`runtimeExampleBuildOutputs`) is not part of + * `pnpm build`; runtime-example-payload.ts builds it. Its Rsbuild `node` + * target bundles dependencies, so `packages/rsc-runtime/dist` and the + * `rsc-markdown-stream` dist it imports are inputs beside `src`, + * `rsbuild.config.ts` (which imports `src/build/emit-artifacts.js`), + * `package.json` and the tsconfig chain: a `pnpm build` that rewrote the + * runtime makes the payload stale, and the ensure-build reruns. One + * descriptor per payload tree (`dist/app`, `dist/runtime`) keeps + * "missing" per tree, matching the presence probes it replaces. + */ +import { readdirSync, statSync } from 'node:fs'; +import { relative, resolve } from 'node:path'; + +/** + * @typedef {object} DistDescriptor + * @property {string} name Package name, as printed in the failure message. + * @property {string} root Absolute package root; `inputs` and `output` resolve against it. + * @property {readonly string[]} inputs Files and directories the build reads, relative to `root` (`..` segments allowed). + * @property {string} output The directory the build writes, relative to `root`. + */ + +/** + * @typedef {object} NewestEntry + * @property {string} path Absolute path of the newest file or directory. + * @property {number} mtimeMs Its modification time, in milliseconds since the epoch. + */ + +/** + * @typedef {object} DistFreshness + * @property {string} name + * @property {string} output Absolute path of the output directory. + * @property {'fresh' | 'stale' | 'missing'} status + * @property {NewestEntry} newestInput + * @property {NewestEntry | undefined} newestOutput Undefined when `status` is `missing`. + */ + +const skippedInputDirectoryNames = new Set(['node_modules', 'dist', '.rstest-temp']); + +/** Directory names never entered while walking a package's inputs (the input root itself is always walked). */ +export const isSkippedInputDirectory = (name) => skippedInputDirectoryNames.has(name) || name.startsWith('.'); + +/** Directory names never entered while walking an output: a dist is itself the walk root, so only temp and dependency trees are excluded. */ +const isSkippedOutputDirectory = (name) => name === 'node_modules' || name.startsWith('.'); + +/** The later of two entries; either may be undefined (an absent path). */ +const newer = (best, candidate) => { + if (candidate === undefined) return best; + return best === undefined || candidate.mtimeMs > best.mtimeMs ? candidate : best; +}; + +/** + * Newest mtime under `path` (a file or a directory), or undefined when it + * does not exist. Directories whose name `skip` accepts are not entered. + * With `countDirectories`, directory mtimes join the comparison. Symbolic + * links are followed for their mtime; a link to a directory is not entered, + * so a cycle cannot form. + * + * @param {string} path + * @param {{ readonly countDirectories?: boolean; readonly skip?: (name: string) => boolean }} [options] + * @returns {NewestEntry | undefined} + */ +export const newestEntry = (path, options = {}) => { + const skip = options.skip ?? (() => false); + const countDirectories = options.countDirectories ?? false; + const stat = statSync(path, { throwIfNoEntry: false }); + if (stat === undefined) return undefined; + if (!stat.isDirectory()) return { path, mtimeMs: stat.mtimeMs }; + let newest = countDirectories ? { path, mtimeMs: stat.mtimeMs } : undefined; + const pending = [path]; + while (pending.length > 0) { + const directory = pending.pop(); + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const entryPath = resolve(directory, entry.name); + const entryStat = statSync(entryPath, { throwIfNoEntry: false }); + if (entryStat === undefined) continue; + if (entryStat.isDirectory()) { + if (skip(entry.name)) continue; + if (countDirectories) newest = newer(newest, { path: entryPath, mtimeMs: entryStat.mtimeMs }); + if (!entry.isSymbolicLink()) pending.push(entryPath); + continue; + } + if (entryStat.isFile()) newest = newer(newest, { path: entryPath, mtimeMs: entryStat.mtimeMs }); + } + } + return newest; +}; + +/** + * Freshness of one built output. Declared inputs that do not exist are + * ignored (a package may lack an optional config file); a descriptor none of + * whose inputs exist is a mistake and throws. + * + * @param {DistDescriptor} descriptor + * @returns {DistFreshness} + */ +export const distFreshness = (descriptor) => { + const { name, root } = descriptor; + let newestInput; + for (const input of descriptor.inputs) { + newestInput = newer(newestInput, newestEntry(resolve(root, input), { countDirectories: true, skip: isSkippedInputDirectory })); + } + if (newestInput === undefined) { + throw new Error(`dist-freshness: none of the ${descriptor.inputs.length} declared inputs of ${name} exist under ${root}; the descriptor is wrong.`); + } + const output = resolve(root, descriptor.output); + const newestOutput = newestEntry(output, { skip: isSkippedOutputDirectory }); + const status = newestOutput === undefined ? 'missing' : newestInput.mtimeMs > newestOutput.mtimeMs ? 'stale' : 'fresh'; + return { name, output, status, newestInput, newestOutput }; +}; + +/** + * @param {readonly DistDescriptor[]} descriptors + * @returns {readonly DistFreshness[]} + */ +export const checkDistFreshness = (descriptors) => descriptors.map(distFreshness); + +const timestamp = (mtimeMs) => new Date(mtimeMs).toISOString(); + +/** + * One actionable message naming every stale or missing output and ending + * with the fix, or the empty string when every result is fresh. Paths print + * relative to `relativeTo` (default: the working directory) when they lie + * under it. + * + * @param {readonly DistFreshness[]} results + * @param {{ readonly relativeTo?: string }} [options] + * @returns {string} + */ +export const formatDistFreshnessFailure = (results, options = {}) => { + const relativeTo = options.relativeTo ?? process.cwd(); + const display = (path) => { + const relativePath = relative(relativeTo, path); + return relativePath.length > 0 && !relativePath.startsWith('..') ? relativePath : path; + }; + const lines = []; + for (const result of results) { + if (result.status === 'fresh') continue; + if (result.status === 'missing' || result.newestOutput === undefined) { + lines.push(` ${result.name}: missing — ${display(result.output)} has no built files`); + continue; + } + lines.push( + ` ${result.name}: stale — ${display(result.newestInput.path)} (${timestamp(result.newestInput.mtimeMs)})` + + ` is newer than ${display(result.newestOutput.path)} (${timestamp(result.newestOutput.mtimeMs)})`, + ); + } + if (lines.length === 0) return ''; + return [ + 'Built output is stale or missing; tests and `pnpm typecheck` load it from dist:', + ...lines, + 'A green run over that dist tests old code; run `pnpm build`.', + ].join('\n'); +}; + +/** + * Throws an Error carrying `formatDistFreshnessFailure`'s message when any + * descriptor's output is stale or missing. + * + * @param {readonly DistDescriptor[]} descriptors + * @param {{ readonly relativeTo?: string }} [options] + */ +export const assertFreshDist = (descriptors, options = {}) => { + const message = formatDistFreshnessFailure(checkDistFreshness(descriptors), options); + if (message.length > 0) throw new Error(message); +}; + +/** Read by every package build, relative to a package root: the tsconfig chain the package tsconfigs extend, and the dependency graph. */ +const workspaceConfigInputs = Object.freeze(['../../tsconfig.json', '../../tsconfig.base.json', '../../pnpm-lock.yaml']); + +/** The Workbench Rsbuild build's inputs, relative to packages/workbench. */ +const workbenchInputs = Object.freeze(['src', 'index.html', 'THIRD_PARTY_NOTICES', 'rsbuild.config.ts', 'package.json', 'tsconfig.json']); + +/** An Rslib package's inputs, given the tsconfig its config names. */ +const rslibInputs = (tsconfig) => Object.freeze(['src', 'rslib.config.ts', 'package.json', tsconfig, ...workspaceConfigInputs]); + +/** + * Every dist the root `pnpm build` script produces, in the order it produces + * them. Resolved against `workspaceRoot` (default: the working directory). + * + * @param {string} [workspaceRoot] + * @returns {readonly DistDescriptor[]} + */ +export const workspaceBuildOutputs = (workspaceRoot = process.cwd()) => Object.freeze([ + { + name: 'rsc-markdown-stream', + root: resolve(workspaceRoot, 'packages/rsc-markdown-stream'), + inputs: rslibInputs('tsconfig.json'), + output: 'dist', + }, + { + name: '@agent-bundle/runtime', + root: resolve(workspaceRoot, 'packages/rsc-runtime'), + inputs: rslibInputs('tsconfig.build.json'), + output: 'dist', + }, + { + name: 'agent-bundle-workbench', + root: resolve(workspaceRoot, 'packages/workbench'), + inputs: Object.freeze([...workbenchInputs, ...workspaceConfigInputs]), + output: 'dist', + }, + { + name: 'agent-bundle', + root: resolve(workspaceRoot, 'packages/agent-bundle'), + inputs: Object.freeze([ + ...rslibInputs('tsconfig.build.json'), + ...workbenchInputs.map((input) => `../workbench/${input}`), + ]), + output: 'dist', + }, + { + name: 'create-agent-bundle', + root: resolve(workspaceRoot, 'packages/create-agent-bundle'), + inputs: rslibInputs('tsconfig.build.json'), + output: 'dist', + }, +]); + +/** + * The rsc-agent-runtime example's prebuilt payload trees, one descriptor per + * payload directory under its `dist` (see runtimeExamplePayloads in + * packages/workbench/tests/helpers/runtime-example-payload.ts). Not part of + * `pnpm build`: the ensure-build there is the only production caller. + * + * @param {string} [workspaceRoot] + * @param {readonly string[]} [payloads] + * @returns {readonly DistDescriptor[]} + */ +export const runtimeExampleBuildOutputs = (workspaceRoot = process.cwd(), payloads = ['app', 'runtime']) => { + const root = resolve(workspaceRoot, 'examples/rsc-agent-runtime'); + const inputs = Object.freeze([ + 'src', + 'rsbuild.config.ts', + 'package.json', + 'tsconfig.json', + ...workspaceConfigInputs, + '../../packages/rsc-runtime/dist', + '../../packages/rsc-markdown-stream/dist', + ]); + return Object.freeze(payloads.map((payload) => ({ + name: `@agent-bundle/rsc-agent-runtime-demo dist/${payload}`, + root, + inputs, + output: `dist/${payload}`, + }))); +}; diff --git a/scripts/rstest-worker-roots.d.mts b/scripts/rstest-worker-roots.d.mts index 55676ddd3..aab5b345c 100644 --- a/scripts/rstest-worker-roots.d.mts +++ b/scripts/rstest-worker-roots.d.mts @@ -1,13 +1,23 @@ -interface RemoveOwnedRstestWorkerRootsOptions { +interface RemoveRstestWorkerRootsOptions { /** Liveness probe for the owning process id; defaults to `process.kill(pid, 0)`. */ isAlive?: (pid: number) => boolean; /** Directory scanned for worker roots; defaults to `rstestWorkerRootsParent`. */ parent?: string; +} + +interface RemoveOwnedRstestWorkerRootsOptions extends RemoveRstestWorkerRootsOptions { /** The host `TMPDIR` whose derived worker roots may be removed. */ temporaryRoot: string; } -interface RemoveOwnedRstestWorkerRootsResult { +interface RemoveRunRstestWorkerRootsOptions extends RemoveRstestWorkerRootsOptions { + /** Also remove finished roots with no run id whose marker names this `cwd`. */ + reclaimUntaggedFrom?: string; + /** The Rstest invocation whose worker roots may be removed; empty matches nothing. */ + runId: string; +} + +interface RemoveRstestWorkerRootsResult { removed: string[]; retained: string[]; } @@ -18,6 +28,12 @@ export declare const rstestWorkerRootPrefix: string; export declare const rstestWorkerRootOwnerFile: string; +export declare const rstestRunIdVariable: 'AGENT_BUNDLE_RSTEST_RUN_ID'; + export declare const removeOwnedRstestWorkerRoots: ( options: RemoveOwnedRstestWorkerRootsOptions, -) => Promise; +) => Promise; + +export declare const removeRunRstestWorkerRoots: ( + options: RemoveRunRstestWorkerRootsOptions, +) => Promise; diff --git a/scripts/rstest-worker-roots.mjs b/scripts/rstest-worker-roots.mjs index 2bd1c078c..4e1c1decc 100644 --- a/scripts/rstest-worker-roots.mjs +++ b/scripts/rstest-worker-roots.mjs @@ -5,11 +5,20 @@ * `/tmp/ab-rstest-` — directly under the system temp directory, never * under the host `TMPDIR`, because Chrome and the Doctor socket fixtures create * AF_UNIX sockets inside it and Linux caps socket paths at 108 bytes. The hash - * includes the invoking process id, so a runner such as `scripts/local-ci.mjs` - * cannot predict the paths a finished leg created. Every root therefore carries - * an owner marker naming the host `TMPDIR` it was derived from and the process - * that created it; a runner that owns that `TMPDIR` can remove exactly those - * roots once the run has finished, and nothing else. + * includes the invoking process id, so neither the Rstest orchestrator nor a + * runner such as `scripts/local-ci.mjs` can predict the paths a finished run + * created. Every root therefore carries an owner marker, and a sweeper removes + * exactly the roots whose marker names what it owns, and nothing else: + * + * - `removeRunRstestWorkerRoots` matches the marker's `runId`, the id the + * pool's `globalSetup` (rstest.global-setup.ts) tags an invocation with and + * its workers inherit; the pool's teardown removes its own roots by it. + * - `removeOwnedRstestWorkerRoots` matches the marker's `temporaryRoot`, the + * host `TMPDIR` the root was derived from; a runner that owns a private + * `TMPDIR` (each local-CI leg) sweeps by it, catching roots whose pool never + * reached teardown. + * + * Both retain a matching root whose creating process is still alive. */ import { readdir, readFile, rm } from 'node:fs/promises'; import { join } from 'node:path'; @@ -20,6 +29,12 @@ export const rstestWorkerRootsParent = '/tmp'; export const rstestWorkerRootPrefix = 'ab-rstest-'; /** Owner marker written into each worker root by `rstestWorkerRoot()`. */ export const rstestWorkerRootOwnerFile = '.ab-rstest-owner.json'; +/** + * Environment variable carrying the run id of the current Rstest invocation. + * rstest.global-setup.ts sets it (unless an outer runner already did) and every + * worker's owner marker records it as `runId`. + */ +export const rstestRunIdVariable = 'AGENT_BUNDLE_RSTEST_RUN_ID'; const processIsAlive = (pid) => { try { @@ -32,15 +47,19 @@ const processIsAlive = (pid) => { }; /** - * Remove the worker roots owned by a finished run: those whose owner marker - * names `temporaryRoot` as the host `TMPDIR` they were derived from and whose - * creating process has exited. Roots without a readable marker, roots owned by - * another `TMPDIR`, and roots whose owning process is still alive are never - * touched. Returns the roots removed and the live roots retained. + * Markers read at once. A host that accumulated tens of thousands of roots + * before the pool teardown existed makes a sequential sweep take >10 s per + * run; sixteen concurrent reads bring it under 4 s, and more gains nothing. + */ +const sweepConcurrency = 16; + +/** + * Walk `parent` for worker roots and remove those whose owner marker `owns` + * accepts and whose creating process has exited. Roots without a readable + * marker, roots `owns` rejects, and roots whose owning process is still alive + * are never touched. Returns the roots removed and the live roots retained. */ -export const removeOwnedRstestWorkerRoots = async (options) => { - const parent = options.parent ?? rstestWorkerRootsParent; - const isAlive = options.isAlive ?? processIsAlive; +const sweepRstestWorkerRoots = async ({ parent, isAlive, owns }) => { const removed = []; const retained = []; let entries; @@ -49,24 +68,68 @@ export const removeOwnedRstestWorkerRoots = async (options) => { } catch { return { removed, retained }; } - for (const entry of entries) { - if (!entry.isDirectory() || !entry.name.startsWith(rstestWorkerRootPrefix)) continue; - const root = join(parent, entry.name); + const roots = entries + .filter((entry) => entry.isDirectory() && entry.name.startsWith(rstestWorkerRootPrefix)) + .map((entry) => join(parent, entry.name)); + const visit = async (root) => { let owner; try { owner = JSON.parse(await readFile(join(root, rstestWorkerRootOwnerFile), 'utf8')); } catch { - continue; + return; } - if (owner === null || typeof owner !== 'object' || owner.temporaryRoot !== options.temporaryRoot) continue; + if (owner === null || typeof owner !== 'object' || !owns(owner)) return; if (Number.isSafeInteger(owner.pid) && owner.pid > 0 && isAlive(owner.pid)) { retained.push(root); - continue; + return; } await rm(root, { recursive: true, force: true }); removed.push(root); + }; + for (let index = 0; index < roots.length; index += sweepConcurrency) { + await Promise.all(roots.slice(index, index + sweepConcurrency).map(visit)); } removed.sort(); retained.sort(); return { removed, retained }; }; + +/** + * Remove the worker roots owned by a finished run: those whose owner marker + * names `temporaryRoot` as the host `TMPDIR` they were derived from and whose + * creating process has exited. Roots without a readable marker, roots owned by + * another `TMPDIR`, and roots whose owning process is still alive are never + * touched. Returns the roots removed and the live roots retained. + */ +export const removeOwnedRstestWorkerRoots = async (options) => + sweepRstestWorkerRoots({ + parent: options.parent ?? rstestWorkerRootsParent, + isAlive: options.isAlive ?? processIsAlive, + owns: (owner) => owner.temporaryRoot === options.temporaryRoot, + }); + +/** + * Remove the worker roots one Rstest invocation created: those whose owner + * marker records `runId` and whose creating process has exited. Roots without + * a readable marker, roots whose marker carries another run id, and roots + * whose owning process is still alive are never touched; an empty `runId` + * matches nothing, so an unset variable can never widen the sweep. + * + * `reclaimUntaggedFrom`, when given, also removes the roots whose marker + * carries no run id at all and names that directory as `cwd`: roots left by + * this checkout's pools before the teardown existed (or by a pool run without + * rstest.global-setup.ts), which nothing else ever removes and which every + * later sweep would otherwise keep reading. The liveness check applies to + * them as well. Returns the roots removed and the live roots retained. + */ +export const removeRunRstestWorkerRoots = async (options) => { + const { runId, reclaimUntaggedFrom } = options; + if (typeof runId !== 'string' || runId === '') return { removed: [], retained: [] }; + const reclaimsUntagged = typeof reclaimUntaggedFrom === 'string' && reclaimUntaggedFrom !== ''; + return sweepRstestWorkerRoots({ + parent: options.parent ?? rstestWorkerRootsParent, + isAlive: options.isAlive ?? processIsAlive, + owns: (owner) => owner.runId === runId + || (reclaimsUntagged && owner.runId === undefined && owner.cwd === reclaimUntaggedFrom), + }); +}; From ed0b7fa220f2342e52109a59c56d651e65a93b30 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 03:29:33 +0000 Subject: [PATCH 2/3] test(rstest): move the two compiler-running files to the integration pool and the installed-host contract proofs to their own pool (#576) prebuilt-payload.test.ts and test-browser-rstest.test.ts run real builds; host-adapters.native.test.ts asserts nothing without AGENT_BUNDLE_NATIVE_HOST_CONTRACTS=1 and now runs on one worker through rstest.native-host.config.ts (`pnpm test:native-host`) instead of as 29 skips per integration run. Shards still partition the pool (46/46). --- rstest.integration-tests.ts | 15 ++++++++++++++- rstest.native-host.config.ts | 24 ++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) create mode 100644 rstest.native-host.config.ts diff --git a/rstest.integration-tests.ts b/rstest.integration-tests.ts index de6c96f19..05e62747b 100644 --- a/rstest.integration-tests.ts +++ b/rstest.integration-tests.ts @@ -49,7 +49,6 @@ export const integrationTestFiles: readonly string[] = [ 'packages/agent-bundle/tests/generated-route-server.test.ts', 'packages/agent-bundle/tests/hook-playground-service.test.ts', 'packages/agent-bundle/tests/hooks.test.ts', - 'packages/agent-bundle/tests/host-adapters.native.test.ts', 'packages/agent-bundle/tests/host-adapters.test.ts', 'packages/agent-bundle/tests/host-discovery-dev-server.test.ts', 'packages/agent-bundle/tests/host-mcp-proxy.test.ts', @@ -65,6 +64,7 @@ export const integrationTestFiles: readonly string[] = [ 'packages/agent-bundle/tests/package-build.test.ts', 'packages/agent-bundle/tests/path-token-resolver.test.ts', 'packages/agent-bundle/tests/plugin-bundle.test.ts', + 'packages/agent-bundle/tests/prebuilt-payload.test.ts', 'packages/agent-bundle/tests/prepack.test.ts', 'packages/agent-bundle/tests/provider-typegen.test.ts', 'packages/agent-bundle/tests/public-api.test.ts', @@ -77,6 +77,7 @@ export const integrationTestFiles: readonly string[] = [ 'packages/agent-bundle/tests/serve-app.test.ts', 'packages/agent-bundle/tests/target-hook-contract.test.ts', 'packages/agent-bundle/tests/target-mcp-runtime.test.ts', + 'packages/agent-bundle/tests/test-browser-rstest.test.ts', 'packages/agent-bundle/tests/workbench-surface-dev-server.test.ts', 'packages/agent-bundle/tests/worktree-proximity-journeys.test.ts', 'packages/rsc-markdown-stream/tests/react-server.test.ts', @@ -129,6 +130,18 @@ export const nightlyEvidenceTestFiles: readonly string[] = [ 'packages/workbench/tests/runtime-playground-capture.test.ts', ]; +/** + * Installed-host contract proofs: every test spawns the real `claude` / + * `codex` CLI and the whole file skips unless + * AGENT_BUNDLE_NATIVE_HOST_CONTRACTS=1, so no per-PR pool collects it. It + * runs through rstest.native-host.config.ts (`pnpm test:native-host`) on one + * worker, the shape a real host session needs — not next to 3,600 other + * tests in the unit pool, and not as 29 skips per integration run (#576). + */ +export const nativeHostTestFiles: readonly string[] = [ + 'packages/agent-bundle/tests/host-adapters.native.test.ts', +]; + /** * Official MCP server conformance runs only through the manually dispatched * lane. It builds one generated fixture, opens loopback HTTP, and invokes the diff --git a/rstest.native-host.config.ts b/rstest.native-host.config.ts new file mode 100644 index 000000000..97ff7421e --- /dev/null +++ b/rstest.native-host.config.ts @@ -0,0 +1,24 @@ +import { defineConfig } from '@rstest/core'; + +import { nativeHostTestFiles } from './rstest.integration-tests.ts'; +import { poolTimeouts, workspaceGlobalSetup, workspaceSetupFiles } from './rstest.pools.ts'; +import { withAgentBundleRslibConfig } from './rstest.rslib.ts'; + +/** + * The installed-host contract lane (`pnpm test:native-host`): the files in + * nativeHostTestFiles drive the real `claude` / `codex` CLIs and skip + * themselves unless AGENT_BUNDLE_NATIVE_HOST_CONTRACTS=1, which the script + * sets. One worker, because each case is a real host session against the + * developer's installed CLI. The files set no per-case timeouts, so the + * pool floor is the budget: 60 s, twice the 30–32 s the #576 audit measured + * for the comparable real-host install variants in the integration pool. + */ +export default defineConfig({ + extends: withAgentBundleRslibConfig(), + include: [...nativeHostTestFiles], + globalSetup: [...workspaceGlobalSetup], + pool: { maxWorkers: 1 }, + setupFiles: [...workspaceSetupFiles], + isolate: true, + ...poolTimeouts(60_000), +}); From 2247394f995fdb7bd2e07175a5894075029adee0 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 03:55:35 +0000 Subject: [PATCH 3/3] test(rstest): build before every pool in the test chain and the native smoke; document the freshness bound (#576 self-review) Every pool now refuses a missing or stale dist, so `pnpm test` builds once up front and runs the integration pool prebuilt, native-host-smoke.yml builds before its unit-pool smoke, and the AGENTS.md gate starts with `pnpm build`. dist-freshness.mjs names the one false negative the newest-mtime comparison admits. --- .github/workflows/native-host-smoke.yml | 18 +++++++++--------- AGENTS.md | 6 ++++-- package.json | 2 +- scripts/dist-freshness.mjs | 7 +++++++ 4 files changed, 21 insertions(+), 12 deletions(-) diff --git a/.github/workflows/native-host-smoke.yml b/.github/workflows/native-host-smoke.yml index 0209c8677..41ea1078a 100644 --- a/.github/workflows/native-host-smoke.yml +++ b/.github/workflows/native-host-smoke.yml @@ -50,10 +50,17 @@ jobs: if: inputs.host == 'both' || inputs.host == matrix.host with: node-version: 22.19.0 + # One build for every pool below, on a runner that starts from a fresh + # checkout: every pool refuses to start over a missing or stale dist + # (rstest.dist-freshness.setup.ts), `test:integration:run` reads the + # prebuilt dist (AGENT_BUNDLE_*_PREBUILT=1), and the host install proofs + # spawn packages/agent-bundle/dist/cli.js (tests/support/host-install.ts). + - run: pnpm build + if: inputs.host == 'both' || inputs.host == matrix.host # The source smokes run each pool's script with that pool's files. A single # `pnpm test -- ` cannot target them: pnpm appends the arguments to - # the end of the `test` chain (`test:unit && test:route-unit && - # test:projection && test:integration`), so only the last command receives + # the end of the `test` chain (`build && test:unit && test:route-unit && + # test:projection && test:integration:run`), so only the last command receives # them and unit, route-unit and projection run unfiltered (~3.5 min of # unrelated tests); and rstest's CLI parser (cac) keeps everything after # `--` out of the positional filters, so the integration pool ran @@ -67,13 +74,6 @@ jobs: AGENT_BUNDLE_NATIVE_CLAUDE_SMOKE: ${{ matrix.host == 'claude' && '1' || '' }} AGENT_BUNDLE_NATIVE_CODEX_SMOKE: ${{ matrix.host == 'codex' && '1' || '' }} run: pnpm test:unit ${{ matrix.unit_tests }} - # One build for every dist consumer below, on a runner that starts from a - # fresh checkout: `test:integration:run` reads the prebuilt dist - # (AGENT_BUNDLE_*_PREBUILT=1), and the host install proofs spawn - # packages/agent-bundle/dist/cli.js (tests/support/host-install.ts). The - # unit pool above is build-free, so it runs first and fails fast. - - run: pnpm build - if: inputs.host == 'both' || inputs.host == matrix.host - name: ${{ matrix.host }} source signed-in smoke (integration pool) if: matrix.integration_tests != '' && (inputs.host == 'both' || inputs.host == matrix.host) env: diff --git a/AGENTS.md b/AGENTS.md index 97603ca35..a5e864ecc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,8 +44,10 @@ for false positives from prose in `docs/**` and from strings that merely contain the name: `Symbol('epoch-staging')` in `dev/epoch-store.ts` was the only match for a 343-line dead file, which is why it read as reachable. -- Gate before pushing: `pnpm typecheck && pnpm lint && pnpm test:unit`, plus - `pnpm build` first if the change touches `packages/rsc-runtime`. +- Gate before pushing: `pnpm build && pnpm typecheck && pnpm lint && pnpm + test:unit`. Every Rstest pool and `pnpm typecheck` refuse to start over a + `dist` older than its sources (`scripts/dist-freshness.mjs`), so the build + comes first whatever the change touched. ## Workbench platform scope diff --git a/package.json b/package.json index 4c4813fac..018f035b6 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "packageManager": "pnpm@11.23.0", "scripts": { "build": "pnpm --filter rsc-markdown-stream build && pnpm --filter @agent-bundle/runtime build && pnpm --filter agent-bundle build && pnpm --filter create-agent-bundle build", - "test": "pnpm test:unit && pnpm test:route-unit && pnpm test:projection && pnpm test:integration", + "test": "pnpm build && pnpm test:unit && pnpm test:route-unit && pnpm test:projection && pnpm test:integration:run", "test:unit": "rstest --config rstest.unit.config.ts", "test:route-unit": "rstest --config rstest.route-unit.config.ts", "test:projection": "rstest --config rstest.projection.config.ts", diff --git a/scripts/dist-freshness.mjs b/scripts/dist-freshness.mjs index 2f61af904..abea82cfe 100644 --- a/scripts/dist-freshness.mjs +++ b/scripts/dist-freshness.mjs @@ -40,6 +40,13 @@ * serve to reopen the hole this closes. Add one when a flow that must run * over a deliberately prebuilt dist exists, and name that flow beside it. * + * The one false negative the comparison admits: a dist whose newest file + * postdates the inputs while other files in it are older — a build that + * failed after writing some outputs, or a hand-touched file. `pnpm build` + * always rewrites a package's dist whole, so the guard accepts that bound + * rather than a per-file manifest; a build that fails is reported by the + * build, and the next successful one restores the invariant. + * * Inputs per package (`workspaceBuildOutputs`), read from each build config: * * - Every package: `src` (the entries and everything they import), the