diff --git a/packages/agent-bundle/src/dev/mcp-apps/mcp-app-routes.ts b/packages/agent-bundle/src/dev/mcp-apps/mcp-app-routes.ts index 09fca2d1d..543712562 100644 --- a/packages/agent-bundle/src/dev/mcp-apps/mcp-app-routes.ts +++ b/packages/agent-bundle/src/dev/mcp-apps/mcp-app-routes.ts @@ -15,7 +15,11 @@ import type { McpAppConsentRequest } from './mcp-app-sandbox.ts'; import { runtimeAppMessageLimits } from '../runtime-app-message-limits.ts'; const bodyLimit = 64 * 1024; -const gracefulCloseReceiptTimeoutMs = 5_000; +// A force-close DELETE that lands after an accepted graceful close must stay +// idempotent (200, not 404), so this window has to dominate the frame relay's +// force-close budget — clients may fall back as late as their closeTimeoutMs, +// which mcp-app-frame.tsx caps at 30s. +const gracefulCloseReceiptTimeoutMs = 35_000; interface RequestDiagnostic { readonly code: string; @@ -84,6 +88,12 @@ export interface McpAppRoutePreviewService { export interface McpAppRoutesOptions { readonly authorize: (request: IncomingMessage) => void; + /** + * Test-only override for the graceful-close receipt window. Production + * callers must leave this unset so the window keeps dominating the frame + * relay's force-close budget. + */ + readonly gracefulCloseReceiptTimeoutMs?: number; readonly service?: McpAppRoutePreviewService; } @@ -445,6 +455,7 @@ const bridgeHostContext = (host: McpAppPreviewHostContext): McpAppBridgeJsonReco /** Authenticated HTTP boundary for already-bound MCP App previews. */ export class McpAppRoutes { readonly #authorize: (request: IncomingMessage) => void; + readonly #gracefulCloseReceiptTimeoutMs: number; readonly #service: McpAppRoutePreviewService | undefined; readonly #tails = new Map>(); readonly #teardowns = new Map>(); @@ -452,6 +463,7 @@ export class McpAppRoutes { constructor(options: McpAppRoutesOptions) { this.#authorize = options.authorize; + this.#gracefulCloseReceiptTimeoutMs = options.gracefulCloseReceiptTimeoutMs ?? gracefulCloseReceiptTimeoutMs; this.#service = options.service; } @@ -644,7 +656,7 @@ export class McpAppRoutes { if (this.#closed) return; const receipt = setTimeout(() => { if (this.#teardowns.get(bindingId) === receipt) this.#teardowns.delete(bindingId); - }, gracefulCloseReceiptTimeoutMs); + }, this.#gracefulCloseReceiptTimeoutMs); this.#teardowns.set(bindingId, receipt); } diff --git a/packages/agent-bundle/src/dev/project-service.ts b/packages/agent-bundle/src/dev/project-service.ts index a2c8cd172..565feda60 100644 --- a/packages/agent-bundle/src/dev/project-service.ts +++ b/packages/agent-bundle/src/dev/project-service.ts @@ -643,7 +643,7 @@ export class ProjectService { } } - async #prepare(command: ProjectCommand): Promise { + async #prepare(command: ProjectCommand, tornRetries = 0): Promise { const requestedRoot = resolve(this.#options.root); const registry = this.#registry; const requestedConfigPath = resolve(requestedRoot, this.#options.configPath ?? 'agent-bundle.config.ts'); @@ -678,6 +678,14 @@ export class ProjectService { return failedPreparation('AB7002', 'Unable to prepare project paths.', requestedConfigPath, 'project.invalid-source'); } const configPath = resolve(root, this.#options.configPath ?? 'agent-bundle.config.ts'); + const configIdentity = async (): Promise => { + try { + return createHash('sha256').update(await readFile(configPath)).digest('hex'); + } catch { + return undefined; + } + }; + const configIdentityBeforeLoad = await configIdentity(); log(this.#options.logger, 'project.load', { command, root }); let loaded; @@ -724,6 +732,24 @@ export class ProjectService { } catch { return failedPreparation('AB7003', 'Unable to snapshot project source.', loaded.configPath, 'project.invalid-source'); } + // loadConfig evaluated the config from one read while the snapshot hashed + // it in another; a config replacement landing between the two reads would + // otherwise produce a torn preparation whose model belongs to the old + // bytes while its revision hashes the new tree. Consumers dedupe prepared + // deliveries by revision, so a torn preparation reconciles a stale model + // under a fresh revision. When the config changed mid-prepare, restart the + // preparation so both reads agree; the retry cap only yields once writes + // outpace prepares for several consecutive rounds, which no real editor + // or test harness sustains. + if (tornRetries < 3) { + const configIdentityAfterSnapshot = await configIdentity(); + if ( + configIdentityBeforeLoad !== undefined && configIdentityAfterSnapshot !== undefined && + configIdentityBeforeLoad !== configIdentityAfterSnapshot + ) { + return this.#prepare(command, tornRetries + 1); + } + } const runtime = runtimeDeclaration(this.#options.includeDevRuntime === true, loaded.config, loaded.configPath); const runtimeMetadata = runtime.declaration === undefined ? Object.freeze({ changed: false, config: loaded.config }) diff --git a/packages/agent-bundle/tests/cli.test.ts b/packages/agent-bundle/tests/cli.test.ts index 1e828be26..8ce3a9d88 100644 --- a/packages/agent-bundle/tests/cli.test.ts +++ b/packages/agent-bundle/tests/cli.test.ts @@ -8,6 +8,7 @@ import { expect, it } from '@rstest/core'; import { runCli as runSourceCli } from '../src/cli.ts'; import { cachedNpmInstallArguments } from './support/shared-pack.ts'; +import { timeScale } from './support/time-scale.ts'; const execFile = promisify(executeFile); const workspaceRoot = process.cwd(); @@ -163,7 +164,7 @@ it('builds a selected target through the built executable from a path containing } finally { await rm(resolve(project.root, '..'), { force: true, recursive: true }); } -}, 30_000); +}, 30_000 * timeScale); it('runs MCP and hook operations from a packed consumer with explicit and temporary artifacts', async () => { await buildCliPackage(); @@ -294,7 +295,7 @@ it('runs MCP and hook operations from a packed consumer with explicit and tempor rm(consumer.root, { force: true, recursive: true }), ]); } -}, 60_000); +}, 60_000 * timeScale); it('keeps inspect JSON stable and validates only the supplied artifact', async () => { await buildCliPackage(); @@ -352,7 +353,7 @@ it('keeps inspect JSON stable and validates only the supplied artifact', async ( } finally { await rm(resolve(project.root, '..'), { force: true, recursive: true }); } -}, 30_000); +}, 30_000 * timeScale); it('prints a complete invalid inspection on JSON and human output', async () => { const project = await createCliProject(); @@ -381,7 +382,7 @@ it('prints a complete invalid inspection on JSON and human output', async () => } finally { await rm(resolve(project.root, '..'), { force: true, recursive: true }); } -}, 30_000); +}, 30_000 * timeScale); it('reports an unselected inspect target on JSON and human output', async () => { const project = await createCliProject(); @@ -415,7 +416,7 @@ it('reports an unselected inspect target on JSON and human output', async () => } finally { await rm(resolve(project.root, '..'), { force: true, recursive: true }); } -}, 30_000); +}, 30_000 * timeScale); it('dumps the synthesized bundler configuration with inspect --bundler', async () => { const project = await createCliProject(); @@ -476,7 +477,7 @@ it('dumps the synthesized bundler configuration with inspect --bundler', async ( } finally { await rm(resolve(project.root, '..'), { force: true, recursive: true }); } -}, 30_000); +}, 30_000 * timeScale); it('reports source validation diagnostics on stderr before staging an artifact', async () => { await buildCliPackage(); @@ -508,4 +509,4 @@ it('reports source validation diagnostics on stderr before staging an artifact', } finally { await rm(resolve(project.root, '..'), { force: true, recursive: true }); } -}, 30_000); +}, 30_000 * timeScale); diff --git a/packages/agent-bundle/tests/dev-workbench-packaging.test.ts b/packages/agent-bundle/tests/dev-workbench-packaging.test.ts index 6d12f3b70..02bf6a1c4 100644 --- a/packages/agent-bundle/tests/dev-workbench-packaging.test.ts +++ b/packages/agent-bundle/tests/dev-workbench-packaging.test.ts @@ -1,5 +1,5 @@ import { execFile as executeFile } from 'node:child_process'; -import { access, mkdtemp, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises'; +import { access, cp, mkdtemp, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises'; import { createServer } from 'node:net'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -16,13 +16,7 @@ const workbenchRoot = join(workspaceRoot, 'packages', 'workbench'); const appRendererLicense = join('src', 'mcp', 'APP-RENDERER-LICENSE'); let built: Promise | undefined; -const buildPackage = async (force = false): Promise => { - if (force) { - // The stale-asset pruning test rebuilds on purpose; the prebuilt seam - // never skips it because the rebuild itself is the behavior under test. - await execFile('pnpm', ['build'], { cwd: workspaceRoot }); - return; - } +const buildPackage = async (): Promise => { if (process.env['AGENT_BUNDLE_PACKAGE_PREBUILT'] === '1') return; built ??= execFile('pnpm', ['build'], { cwd: workspaceRoot }).then(() => undefined); await built; @@ -57,17 +51,26 @@ it('copies stable prebuilt workbench assets and the exact app-renderer license i it('prunes stale copied workbench assets without removing the package library output', async () => { await buildPackage(); - const workbench = join(packageRoot, 'dist', 'workbench'); - const stale = join(workbench, 'static', 'js', 'async', 'stale-nested.js'); - await mkdir(join(workbench, 'static', 'js', 'async'), { recursive: true }); - await writeFile(stale, 'obsolete workbench output\n'); - await expect(access(stale)).resolves.toBeUndefined(); - - await buildPackage(true); - - await expect(access(stale)).rejects.toThrow(); - await expect(access(join(packageRoot, 'dist', 'cli.js'))).resolves.toBeUndefined(); - expect(await readdir(workbench, { recursive: true })).not.toContain('index.js.map'); + const isolatedRoot = await mkdtemp(join(tmpdir(), 'agent-bundle-workbench-prune-')); + const isolatedDist = join(isolatedRoot, 'dist'); + try { + await cp(join(packageRoot, 'dist'), isolatedDist, { recursive: true }); + const workbench = join(isolatedDist, 'workbench'); + const stale = join(workbench, 'static', 'js', 'async', 'stale-nested.js'); + await mkdir(join(workbench, 'static', 'js', 'async'), { recursive: true }); + await writeFile(stale, 'obsolete workbench output\n'); + await expect(access(stale)).resolves.toBeUndefined(); + await execFile(join(workspaceRoot, 'node_modules', '.bin', 'rslib'), [ + 'build', + '--config', join(packageRoot, 'rslib.config.ts'), + '--dist-path', isolatedDist, + ], { cwd: workspaceRoot }); + await expect(access(stale)).rejects.toThrow(); + await expect(access(join(isolatedDist, 'cli.js'))).resolves.toBeUndefined(); + expect(await readdir(workbench, { recursive: true })).not.toContain('index.js.map'); + } finally { + await rm(isolatedRoot, { force: true, recursive: true }); + } }, 60_000); it('serves prebuilt workbench assets from an installed tarball without the repository source tree', async () => { @@ -83,7 +86,7 @@ it('serves prebuilt workbench assets from an installed tarball without the repos expect(listing.stdout).not.toMatch(/package\/dist\/workbench\/.*-[a-f0-9]{8,}/iu); await writeFile(join(consumer, 'package.json'), '{"type":"module"}\n'); - await execFile('npm', ['install', ...cachedNpmInstallArguments, tarball], { cwd: consumer }); + await execFile('npm', ['install', ...cachedNpmInstallArguments, tarball], { cwd: consumer, env: installedEnvironment() }); await mkdir(join(project, 'skills', 'review'), { recursive: true }); await Promise.all([ writeFile(join(project, 'package.json'), '{"type":"module"}\n'), @@ -99,7 +102,7 @@ it('serves prebuilt workbench assets from an installed tarball without the repos ' console.log(JSON.stringify({ body: await response.text(), status: response.status }));', '} finally { await session.close(); }', ].join('\n'); - const served = await execFile(process.execPath, ['--input-type=module', '--eval', script], { cwd: consumer }); + const served = await execFile(process.execPath, ['--input-type=module', '--eval', script], { cwd: consumer, env: installedEnvironment() }); expect(JSON.parse(served.stdout)).toMatchObject({ body: expect.stringContaining('Agent Bundle workbench'), status: 200, @@ -115,7 +118,7 @@ it('runs the Agent API from an omit-dev installed tarball with its runtime MCP d const project = join(consumer, 'project'); try { await writeFile(join(consumer, 'package.json'), '{"type":"module"}\n'); - await execFile('npm', ['install', '--omit=dev', ...cachedNpmInstallArguments, tarball], { cwd: consumer }); + await execFile('npm', ['install', '--omit=dev', ...cachedNpmInstallArguments, tarball], { cwd: consumer, env: installedEnvironment() }); await mkdir(join(project, 'skills', 'review'), { recursive: true }); await Promise.all([ writeFile(join(project, 'package.json'), '{"type":"module"}\n'), diff --git a/packages/agent-bundle/tests/dev-workbench.test.ts b/packages/agent-bundle/tests/dev-workbench.test.ts index 226697ef7..a842c48be 100644 --- a/packages/agent-bundle/tests/dev-workbench.test.ts +++ b/packages/agent-bundle/tests/dev-workbench.test.ts @@ -20,6 +20,8 @@ import { import type { ForegroundCoordinator, ForegroundServerOptions } from '../src/dev/foreground-server.ts'; import { createProjectFixture, removeProjectFixture } from './helpers/project-fixture.ts'; import { agentBundleNodeModules } from './helpers/workspace-paths.ts'; +import { timeScale } from './support/time-scale.ts'; +import { replaceWatchedSource } from './support/watched-files.ts'; const readToEnd = async (reader: ReadableStreamDefaultReader): Promise => { const decoder = new TextDecoder(); @@ -437,7 +439,7 @@ it('latches a runtime declaration added to an ordinary Workbench session as rest if (cookie === null) throw new Error('Expected foreground session bootstrap cookie.'); events = openProjectEventStream(server.url, cookie); await events.opened; - await writeFile(project.configPath, [ + await replaceWatchedSource(project.root, project.configPath, [ "import { defineConfig } from 'agent-bundle';", '', 'export default defineConfig({', @@ -610,7 +612,7 @@ it('retains Runtime App routes through invalid config updates and reconciles onl }); expect(runtimeState.subscribes).toBe(0); - await writeFile(project.configPath, config(['portable'], 'valid-first', '{}')); + await replaceWatchedSource(project.root, project.configPath, config(['portable'], 'valid-first', '{}')); await within((async () => { for (let attempt = 0; attempt < 100; attempt += 1) { const response = await create(); @@ -634,7 +636,7 @@ it('retains Runtime App routes through invalid config updates and reconciles onl unsubscribes: runtimeState.unsubscribes, }; - await writeFile(project.configPath, config(['portable'], 'invalid-nonfinite', 'Number.NaN')); + await replaceWatchedSource(project.root, project.configPath, config(['portable'], 'invalid-nonfinite', 'Number.NaN')); const invalid = await fetch(`${server.url}/api/project/rebuild`, { body: JSON.stringify({ paths: ['agent-bundle.config.ts'] }), headers, @@ -669,7 +671,7 @@ it('retains Runtime App routes through invalid config updates and reconciles onl }).then((response) => response.status)).resolves.toBe(200); expect(runtimeState).toEqual(stableRuntime); - await writeFile(project.configPath, config(['portable'], 'valid-repair', '{}')); + await replaceWatchedSource(project.root, project.configPath, config(['portable'], 'valid-repair', '{}')); await expect(fetch(`${server.url}/api/project/rebuild`, { body: JSON.stringify({ paths: ['agent-bundle.config.ts'] }), headers, @@ -683,7 +685,7 @@ it('retains Runtime App routes through invalid config updates and reconciles onl unsubscribes: stableRuntime.unsubscribes, }); - await writeFile(project.configPath, config(['portable'], 'valid-removal', undefined, false)); + await replaceWatchedSource(project.root, project.configPath, config(['portable'], 'valid-removal', undefined, false)); await expect(fetch(`${server.url}/api/project/rebuild`, { body: JSON.stringify({ paths: ['agent-bundle.config.ts'] }), headers, @@ -788,7 +790,7 @@ it('fences a closing foreground before a held valid runtime reconcile can attach const bootstrap = await fetch(`${server.url}/api/project/session`, { headers: { 'sec-fetch-site': 'same-origin' } }); const { token } = await bootstrap.json() as { readonly token: string }; const headers = { 'content-type': 'application/json', origin: server.url, 'x-agent-bundle-session': token }; - await writeFile(project.configPath, config(['portable'])); + await replaceWatchedSource(project.root, project.configPath, config(['portable'])); const rebuilding = fetch(`${server.url}/api/project/rebuild`, { body: JSON.stringify({ paths: ['agent-bundle.config.ts'] }), headers, @@ -913,7 +915,7 @@ it('does not reconcile a valid preparation released after foreground close begin subscribes: runtimeState.subscribes, }; - await writeFile(project.configPath, config('held-after-close', true)); + await replaceWatchedSource(project.root, project.configPath, config('held-after-close', true)); const rebuilding = fetch(`${server.url}/api/project/rebuild`, { body: JSON.stringify({ paths: ['agent-bundle.config.ts'] }), headers, @@ -976,7 +978,7 @@ it('does not publish a prepared runtime topology after foreground close begins', }, }, }); - await writeFile(project.configPath, [ + await replaceWatchedSource(project.root, project.configPath, [ "import { defineConfig } from 'agent-bundle';", `const state = globalThis[${JSON.stringify(stateKey)}];`, "if (state === undefined) throw new Error('Missing prepared topology close state.');", @@ -1529,7 +1531,9 @@ it('records a durable playground trace and promotes it through the packaged fore expect(run.id).not.toBe(binding.hook); expect(run.session.state).toBe('open'); let terminal: string | undefined; - for (let attempt = 0; attempt < 25; attempt += 1) { + // Finalization settles asynchronously after the run settles, so the poll + // budget follows the suite time scale like every other readiness wait. + for (let attempt = 0; attempt < 250 * timeScale; attempt += 1) { const session = await fetch(`${server.url}/api/playground/sessions/${run.session.id}`, { headers }); const body = await session.json() as { readonly session: { readonly state: string } }; terminal = body.session.state; diff --git a/packages/agent-bundle/tests/helpers/project-fixture.ts b/packages/agent-bundle/tests/helpers/project-fixture.ts index 6393a6fcf..4452fdb09 100644 --- a/packages/agent-bundle/tests/helpers/project-fixture.ts +++ b/packages/agent-bundle/tests/helpers/project-fixture.ts @@ -1,7 +1,8 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; +import { rstestWorkerRoot } from '../../../../rstest.worker-isolation.ts'; + export interface ProjectFixture { configPath: string; imagePath: string; @@ -33,7 +34,7 @@ const sourceEntryPoint = resolve( export const createProjectFixture = async ( options: ProjectFixtureOptions = {}, ): Promise => { - const root = await mkdtemp(join(tmpdir(), options.prefix ?? 'agent-bundle-config-')); + const root = await mkdtemp(join(rstestWorkerRoot(), options.prefix ?? 'agent-bundle-config-')); const skillDir = join(root, 'skills/review'); const skillSource = join(skillDir, 'SKILL.md'); const imagePath = join(skillDir, 'assets/diagram.png'); diff --git a/packages/agent-bundle/tests/mcp-app-routes.test.ts b/packages/agent-bundle/tests/mcp-app-routes.test.ts index 48661fdbc..dafabc933 100644 --- a/packages/agent-bundle/tests/mcp-app-routes.test.ts +++ b/packages/agent-bundle/tests/mcp-app-routes.test.ts @@ -147,8 +147,15 @@ class RecordingPreviewService implements McpAppRoutePreviewService { } } -const startRoutes = async (service = new RecordingPreviewService()): Promise => { - const routes = new McpAppRoutes({ authorize, service }); +const startRoutes = async ( + service = new RecordingPreviewService(), + gracefulCloseReceiptTimeoutMs?: number, +): Promise => { + const routes = new McpAppRoutes({ + authorize, + ...(gracefulCloseReceiptTimeoutMs === undefined ? {} : { gracefulCloseReceiptTimeoutMs }), + service, + }); const server = createServer((request, response) => { void routes.handle(request, response).then((handled) => { if (!handled) response.writeHead(404).end(); @@ -911,7 +918,10 @@ it('serializes a fallback DELETE behind an accepted graceful close', async () => }); it('expires a graceful-close receipt before a later fallback DELETE', async () => { - const started = await startRoutes(); + // The production window is 35s (it must dominate the relay's 30s force-close + // cap); expiry semantics are what matters here, so the window is shortened + // through the injectable seam instead of sleeping for real. + const started = await startRoutes(undefined, 1_000); try { const closing = await fetch(`${started.url}/api/mcp/apps/binding-a/close`, { body: JSON.stringify({ id: 'close-a' }), @@ -921,7 +931,7 @@ it('expires a graceful-close receipt before a later fallback DELETE', async () = expect(closing.status).toBe(200); started.service.forceCloseResult = false; - await new Promise((resolvePromise) => setTimeout(resolvePromise, 5_100)); + await new Promise((resolvePromise) => setTimeout(resolvePromise, 1_100)); const fallback = await fetch(`${started.url}/api/mcp/apps/binding-a`, { headers: headers(), method: 'DELETE', diff --git a/packages/agent-bundle/tests/public-api-packed.test.ts b/packages/agent-bundle/tests/public-api-packed.test.ts index b8a68b99f..7dc02ddb9 100644 --- a/packages/agent-bundle/tests/public-api-packed.test.ts +++ b/packages/agent-bundle/tests/public-api-packed.test.ts @@ -6,6 +6,7 @@ import { promisify } from 'node:util'; import { expect, it } from '@rstest/core'; +import { isolatedCommandEnvironment } from '../../../rstest.worker-isolation.ts'; import { writeFixtureManifest } from './support/manifest.ts'; import { cachedNpmInstallArguments, sharedPackedTarball } from './support/shared-pack.ts'; @@ -59,7 +60,7 @@ it('writes the package version as the producer of a packed CLI manifest', async await writeFile(join(consumerRoot, 'package.json'), '{"type":"module"}\n'); await execFile( 'npm', ['install', ...cachedNpmInstallArguments, tarball], - { cwd: consumerRoot }, + { cwd: consumerRoot, env: isolatedCommandEnvironment() }, ); const project = await createBuildProject(consumerRoot); @@ -91,7 +92,7 @@ it('imports the externalized config entry from a packed npm consumer', async () await execFile( 'npm', ['install', ...cachedNpmInstallArguments, tarball], - { cwd: consumerRoot }, + { cwd: consumerRoot, env: isolatedCommandEnvironment() }, ); expect((await stat(join(packageRoot, 'dist/config.js'))).size).toBeLessThan( @@ -106,7 +107,7 @@ it('imports the externalized config entry from a packed npm consumer', async () "import { defineConfig } from 'agent-bundle/config';", 'if (defineConfig !== rootDefineConfig) throw new Error(\'config factory identity mismatch\');', ].join('\n'), - ], { cwd: consumerRoot }), + ], { cwd: consumerRoot, env: isolatedCommandEnvironment() }), ).resolves.toMatchObject({ stderr: '', stdout: '' }); await symlink( join(workspaceRoot, 'node_modules', '@types'), @@ -138,7 +139,7 @@ it('imports the externalized config entry from a packed npm consumer', async () '--target', 'es2022', '--types', 'node', 'config.mts', - ], { cwd: consumerRoot })).resolves.toMatchObject({ stderr: '', stdout: '' }); + ], { cwd: consumerRoot, env: isolatedCommandEnvironment() })).resolves.toMatchObject({ stderr: '', stdout: '' }); } finally { await rm(consumerRoot, { force: true, recursive: true }); } @@ -199,7 +200,7 @@ it('invokes a prebuilt MCP server from a clean packed consumer', async () => { await execFile( 'npm', ['install', ...cachedNpmInstallArguments, tarball], - { cwd: consumerRoot }, + { cwd: consumerRoot, env: isolatedCommandEnvironment() }, ); const { stdout } = await execFile(process.execPath, [ '--input-type=module', @@ -209,7 +210,7 @@ it('invokes a prebuilt MCP server from a clean packed consumer', async () => { "const result = await new McpService().invoke({ artifact: './artifact', input: {}, server: 'fixture', target: 'portable', tool: 'inspect' });", 'console.log(JSON.stringify(result));', ].join('\n'), - ], { cwd: consumerRoot }); + ], { cwd: consumerRoot, env: isolatedCommandEnvironment() }); expect(JSON.parse(stdout)).toMatchObject({ result: { content: [{ text: 'packed result', type: 'text' }], diff --git a/packages/agent-bundle/tests/release-audit.test.ts b/packages/agent-bundle/tests/release-audit.test.ts index d8d8a70ba..e57c5420f 100644 --- a/packages/agent-bundle/tests/release-audit.test.ts +++ b/packages/agent-bundle/tests/release-audit.test.ts @@ -6,13 +6,14 @@ import { promisify } from 'node:util'; import { expect, it } from '@rstest/core'; +import { isolatedCommandEnvironment } from '../../../rstest.worker-isolation.ts'; import { npmInstallArguments, sharedPackedTarball } from './support/shared-pack.ts'; const execFile = promisify(executeFile); const workspaceRoot = process.cwd(); const packageRoot = join(workspaceRoot, 'packages', 'agent-bundle'); -const releaseEnvironment = (): NodeJS.ProcessEnv => ({ ...process.env, NODE_ENV: 'production' }); +const releaseEnvironment = (): NodeJS.ProcessEnv => isolatedCommandEnvironment({ ...process.env, NODE_ENV: 'production' }); it('audits an externally installed production tarball and generates its CycloneDX SBOM', async () => { const { stdout } = await execFile(process.execPath, ['scripts/audit-packed-release.mjs'], { diff --git a/packages/agent-bundle/tests/rsc-runtime-optional-packaging.test.ts b/packages/agent-bundle/tests/rsc-runtime-optional-packaging.test.ts index d0a996933..d18b05db6 100644 --- a/packages/agent-bundle/tests/rsc-runtime-optional-packaging.test.ts +++ b/packages/agent-bundle/tests/rsc-runtime-optional-packaging.test.ts @@ -1,12 +1,12 @@ import { execFile as executeFile } from 'node:child_process'; -import { cp, mkdtemp, readdir, rm } from 'node:fs/promises'; +import { cp, mkdtemp, readdir, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { promisify } from 'node:util'; import { describe, expect, it } from '@rstest/core'; -import { cachedNpmInstallArguments, sharedPackedTarball } from './support/shared-pack.ts'; +import { cachedNpmInstallArguments, installedEnvironment, sharedPackedTarball } from './support/shared-pack.ts'; const execFile = promisify(executeFile); const workspaceRoot = process.cwd(); @@ -42,11 +42,12 @@ describe.sequential('optional RSC runtime package boundary', () => { const project = join(consumer, 'project'); const artifact = join(project, '.agent-bundle', 'artifact'); try { + await writeFile(join(consumer, 'package.json'), '{"name":"rsc-optional-consumer","type":"module"}\n'); const tarListing = (await execFile('tar', ['-tf', tarball])).stdout; expect(tarListing).not.toMatch(/examples\/rsc-agent-runtime|react-server-dom-rspack|rsbuild-plugin-rsc/u); - await execFile('npm', ['install', ...cachedNpmInstallArguments, tarball], { cwd: consumer }); - const dependencyTree = JSON.parse((await execFile('npm', ['ls', '--all', '--json'], { cwd: consumer })).stdout) as InstalledDependencyTree; + await execFile('npm', ['install', ...cachedNpmInstallArguments, tarball], { cwd: consumer, env: installedEnvironment() }); + const dependencyTree = JSON.parse((await execFile('npm', ['ls', '--all', '--json'], { cwd: consumer, env: installedEnvironment() })).stdout) as InstalledDependencyTree; const installedNames = installedDependencyNames(dependencyTree); for (const name of ['react', 'react-dom', 'react-server-dom-rspack', 'rsbuild-plugin-rsc']) { expect(installedNames).not.toContain(name); @@ -69,7 +70,7 @@ describe.sequential('optional RSC runtime package boundary', () => { " process.stdout.write(JSON.stringify({ diagnostics: validated.diagnostics, runtimeBody: await runtimeResponse.json(), runtimeStatus: runtimeResponse.status, status: session.status(), surfacesBody: await surfacesResponse.json(), surfacesStatus: surfacesResponse.status, targets: inspected.model.targets.map(({ name }) => name) }));", '} finally { await session.close(); }', ].join('\n'); - const result = JSON.parse((await execFile(process.execPath, ['--input-type=module', '--eval', script], { cwd: consumer })).stdout) as Readonly<{ + const result = JSON.parse((await execFile(process.execPath, ['--input-type=module', '--eval', script], { cwd: consumer, env: installedEnvironment() })).stdout) as Readonly<{ readonly diagnostics: unknown; readonly runtimeBody: unknown; readonly runtimeStatus: number; diff --git a/packages/agent-bundle/tests/script-playground-service.test.ts b/packages/agent-bundle/tests/script-playground-service.test.ts index 9ea13c2c1..fb1c247a5 100644 --- a/packages/agent-bundle/tests/script-playground-service.test.ts +++ b/packages/agent-bundle/tests/script-playground-service.test.ts @@ -7,6 +7,7 @@ import { join } from 'node:path'; import { expect, it } from '@rstest/core'; import { ScriptPlaygroundService } from '../src/dev/playground/script-playground-service.ts'; +import { taskkill, terminateProcessTree, waitForProcessTreeExit } from '../src/services/process-tree.ts'; import { timeScale } from './support/time-scale.ts'; const temporaryScript = async (source: string): Promise Promise; readonly path: string }>> => { @@ -18,7 +19,10 @@ const temporaryScript = async (source: string): Promise Promise | void): Promise => { let failure: unknown; - for (let attempt = 0; attempt < 50; attempt += 1) { + // Child-process startup is what these polls usually wait on, and it slows + // roughly with worker contention, so the budget follows the suite's + // time-scale convention. + for (let attempt = 0; attempt < 100 * timeScale; attempt += 1) { try { await assertion(); return; @@ -392,11 +396,17 @@ it('reports a stable interpreter-unavailable failure without exposing a command it('cancels and drains the emitted script process group before its workspace is released', async () => { const root = await mkdtemp(join(tmpdir(), 'agent-bundle-script-playground-tree-')); const pidPath = join(root, 'descendant.pid'); + // The pid file must appear atomically (write staged, then rename): a plain + // writeFile creates the file before its bytes land, and a poll that reads + // the empty window parses Number('') === 0 — and process.kill(0, 0) probes + // this test runner's own process group, which always exists. + const pidStagingPath = `${pidPath}.staging`; const emitted = await temporaryScript([ "import { spawn } from 'node:child_process';", - "import { writeFile } from 'node:fs/promises';", + "import { rename, writeFile } from 'node:fs/promises';", `const descendant = spawn(process.execPath, ['--eval', 'setInterval(() => undefined, 1_000)'], { stdio: 'ignore' });`, - `await writeFile(${JSON.stringify(pidPath)}, String(descendant.pid));`, + `await writeFile(${JSON.stringify(pidStagingPath)}, String(descendant.pid));`, + `await rename(${JSON.stringify(pidStagingPath)}, ${JSON.stringify(pidPath)});`, 'setInterval(() => undefined, 1_000);', '', ].join('\n')); @@ -438,9 +448,11 @@ it('keeps SIGKILL process-group cleanup alive after the direct child closes', as const descendantProgram = "require('node:fs').writeFileSync(" + JSON.stringify(readyPath) + ", 'ready'); process.on('SIGTERM', () => undefined); setInterval(() => undefined, 1_000);"; const emitted = await temporaryScript([ "import { spawn } from 'node:child_process';", - "import { writeFile } from 'node:fs/promises';", + "import { rename, writeFile } from 'node:fs/promises';", 'const descendant = spawn(process.execPath, [\'--eval\', ' + JSON.stringify(descendantProgram) + '], { stdio: \'ignore\' });', - 'await writeFile(' + JSON.stringify(pidPath) + ', String(descendant.pid));', + // Staged rename: the pid file must never be observable empty. + 'await writeFile(' + JSON.stringify(`${pidPath}.staging`) + ', String(descendant.pid));', + 'await rename(' + JSON.stringify(`${pidPath}.staging`) + ', ' + JSON.stringify(pidPath) + ');', 'setInterval(() => undefined, 1_000);', '', ].join('\n')); @@ -480,18 +492,47 @@ const assertStubbornDescendantIsGoneAtSettlement = async ( const descendantProgram = "require('node:fs').writeFileSync(" + JSON.stringify(readyPath) + ", 'ready'); process.on('SIGTERM', () => undefined); setInterval(() => undefined, 1_000);"; const emitted = await temporaryScript([ "import { spawn } from 'node:child_process';", - "import { readFile, writeFile } from 'node:fs/promises';", + "import { readFile, rename, writeFile } from 'node:fs/promises';", 'const descendant = spawn(process.execPath, [\'--eval\', ' + JSON.stringify(descendantProgram) + '], { stdio: \'ignore\' });', - 'await writeFile(' + JSON.stringify(pidPath) + ', String(descendant.pid));', + // Staged rename: the pid file must never be observable empty. + 'await writeFile(' + JSON.stringify(`${pidPath}.staging`) + ', String(descendant.pid));', + 'await rename(' + JSON.stringify(`${pidPath}.staging`) + ', ' + JSON.stringify(pidPath) + ');', 'while (true) { try { await readFile(' + JSON.stringify(readyPath) + '); break; } catch { await new Promise((resolvePromise) => setTimeout(resolvePromise, 1)); } }', trigger === 'output-limit' ? "process.stdout.write('x'.repeat(512));" : 'setInterval(() => undefined, 1_000);', trigger === 'output-limit' ? 'setInterval(() => undefined, 1_000);' : '', '', ].join('\n')); + // The service arms its timeout timer the moment it spawns the wrapper, so a + // fixed timeoutMs races the descendant's startup (two sequential Node + // process launches) under CPU contention: the tree kill can land before the + // descendant installs its SIGTERM handler and writes the ready file, and + // the test then polls for a file that will never exist. Sequence the + // scenario instead of racing it: hold the first termination signal until + // the descendant is observably ready, then delegate to the same + // process-tree cleanup the service uses in production. + const descendantReady = async (): Promise => { + for (;;) { + try { + await readFile(readyPath); + return; + } catch { + await new Promise((resolvePromise) => { setTimeout(resolvePromise, 10); }); + } + } + }; + const readinessGatedProcessTree = Object.freeze({ + terminate: async (child: ChildProcess, signal: NodeJS.Signals): Promise => { + await descendantReady(); + return terminateProcessTree(child, signal, { onTreeTerminationFailure: () => undefined, platform: process.platform, taskkill }); + }, + // Mirrors the service's default exit-settlement parameters. + waitForExit: (child: ChildProcess): Promise => + waitForProcessTreeExit(child, { platform: process.platform, pollMilliseconds: 10, timeoutMilliseconds: 250 }), + }); let descendant: number | undefined; try { const service = new ScriptPlaygroundService({ - ...(trigger === 'output-limit' ? { outputLimit: 128 } : { timeoutMs: 100 }), + ...(trigger === 'output-limit' ? { outputLimit: 128 } : { processTree: readinessGatedProcessTree, timeoutMs: 100 }), resolveScript: async () => Object.freeze({ interpreter: Object.freeze({ args: Object.freeze([]), command: process.execPath }), name: 'review', path: emitted.path, }), diff --git a/packages/agent-bundle/tests/support/shared-pack.ts b/packages/agent-bundle/tests/support/shared-pack.ts index 131a1959f..9099655a6 100644 --- a/packages/agent-bundle/tests/support/shared-pack.ts +++ b/packages/agent-bundle/tests/support/shared-pack.ts @@ -5,6 +5,8 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { promisify } from 'node:util'; +import { isolatedCommandEnvironment } from '../../../../rstest.worker-isolation.ts'; + const execFile = promisify(executeFile); const workspaceRoot = process.cwd(); @@ -21,10 +23,12 @@ export interface SharedPack { export type SharedPackPackage = 'agent-bundle' | 'create-agent-bundle'; -export const installedEnvironment = (): NodeJS.ProcessEnv => { - const { NODE_PATH: _nodePath, ...environment } = process.env; - return environment; -}; +/** + * NODE_PATH-free environment with per-command npm cache and tmp roots under + * the worker's RSTEST_WORKER_ID directory (see rstest.worker-isolation.ts), + * so concurrent workers never contend on shared npm or tmp state. + */ +export const installedEnvironment = (): NodeJS.ProcessEnv => isolatedCommandEnvironment(); /** * Canonical flags for installing a packed tarball into a consumer fixture. diff --git a/packages/agent-bundle/tests/support/time-scale.ts b/packages/agent-bundle/tests/support/time-scale.ts index 7ba558ada..2279cc5b5 100644 --- a/packages/agent-bundle/tests/support/time-scale.ts +++ b/packages/agent-bundle/tests/support/time-scale.ts @@ -5,9 +5,10 @@ * costs nothing on green runs - polling assertions return on success - and * the workflow-level timeout-minutes still bounds real hangs. * - * AGENT_BUNDLE_TEST_TIME_SCALE (set by rstest.integration.config.ts when the - * pool runs multiple workers) covers the same contention on development + * AGENT_BUNDLE_TEST_TIME_SCALE covers the same contention on development * machines, where concurrent Chrome + dev-server + rsbuild pairs share cores. + * rstest.integration.config.ts sets it locally from core count without pinning + * workers. CI always uses 4, independent of pool size. */ const localScale = Number(process.env['AGENT_BUNDLE_TEST_TIME_SCALE'] ?? ''); export const timeScale = process.env['CI'] !== undefined diff --git a/packages/agent-bundle/tests/support/watched-files.ts b/packages/agent-bundle/tests/support/watched-files.ts new file mode 100644 index 000000000..35fe2a1fc --- /dev/null +++ b/packages/agent-bundle/tests/support/watched-files.ts @@ -0,0 +1,17 @@ +import { rename, writeFile } from 'node:fs/promises'; +import { basename, join } from 'node:path'; + +/** + * Replaces a file the dev server may read concurrently (watcher events or an + * in-flight prepare) with one atomic rename, so no reader ever observes a + * truncated or partially written source. A plain writeFile truncates first: + * a watcher can read the empty window, or coalesce the truncate and append + * events within one mtime tick and drop the content. The temp file lives in + * the project's parent (same filesystem, never watched) so the rename into + * place is the only event a watcher observes. + */ +export const replaceWatchedSource = async (projectRoot: string, path: string, content: string): Promise => { + const temporary = join(projectRoot, '..', `.${basename(path)}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`); + await writeFile(temporary, content); + await rename(temporary, path); +}; diff --git a/packages/workbench/src/mcp/mcp-app-frame.tsx b/packages/workbench/src/mcp/mcp-app-frame.tsx index e56e27e78..c84cfe73c 100644 --- a/packages/workbench/src/mcp/mcp-app-frame.tsx +++ b/packages/workbench/src/mcp/mcp-app-frame.tsx @@ -128,7 +128,12 @@ const messageForResource = (frame: McpAppRelayFrame, value: CanonicalResource): }); const positiveTimeout = (value: number | undefined): number => { - const timeout = value ?? 1_000; + // The force-close timer bounds a hung or hostile app's teardown, so its + // budget only needs to be finite, not tight. A tight budget misfires on a + // healthy app when the host is loaded (the teardown handshake is a route + // round trip plus iframe processing): the forced DELETE then supersedes the + // queued graceful close, discarding app-side teardown work. + const timeout = value ?? 5_000; if (!Number.isSafeInteger(timeout) || timeout < 1 || timeout > 30_000) { throw new RangeError('MCP App frame close timeout must be an integer from 1 to 30000 ms.'); } diff --git a/packages/workbench/tests/helpers/runtime-example-payload.ts b/packages/workbench/tests/helpers/runtime-example-payload.ts new file mode 100644 index 000000000..1fc9be484 --- /dev/null +++ b/packages/workbench/tests/helpers/runtime-example-payload.ts @@ -0,0 +1,37 @@ +import { execFile as executeFile } from 'node:child_process'; +import { access } from 'node:fs/promises'; +import { join } from 'node:path'; +import { promisify } from 'node:util'; + +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; + +/** + * 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). + * + * This must not run concurrently with itself: two racing builds write the + * same `examples/rsc-agent-runtime/dist` tree, and a fixture copying that + * tree mid-build ships a torn payload. The integration pool therefore runs it + * 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. + */ +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 { RSTEST: _rstest, ...environment } = process.env; + await execFile('pnpm', ['--filter', '@agent-bundle/rsc-agent-runtime-demo', 'exec', 'rsbuild', 'build', '--mode', 'production'], { + cwd: workspaceRoot, + env: { ...environment, NODE_ENV: 'production' }, + maxBuffer: 64 * 1024 * 1024, + }); +}; diff --git a/packages/workbench/tests/helpers/runtime-playground-fixture.ts b/packages/workbench/tests/helpers/runtime-playground-fixture.ts index d0101844c..54d51e09a 100644 --- a/packages/workbench/tests/helpers/runtime-playground-fixture.ts +++ b/packages/workbench/tests/helpers/runtime-playground-fixture.ts @@ -1,5 +1,5 @@ import { execFile as executeFile } from 'node:child_process'; -import { access, cp, mkdtemp, rm, symlink } from 'node:fs/promises'; +import { cp, mkdtemp, rm, symlink } from 'node:fs/promises'; import { join } from 'node:path'; import { promisify } from 'node:util'; @@ -8,6 +8,7 @@ import type { ProjectEventHub } from '../../../agent-bundle/src/dev/events.ts'; import { startForegroundServer, type ForegroundProjectEventStreamHandle } from '../../../agent-bundle/src/dev/foreground-server.ts'; import type { DevRuntimeClientSurfaceProxyBinding } from '../../../agent-bundle/src/dev/runtime-provider.ts'; import { startDevServer } from '../../../agent-bundle/src/dev/workbench-server.ts'; +import { ensureRuntimeExamplePayload, runtimeExamplePayloads } from './runtime-example-payload.ts'; const execFile = promisify(executeFile); const workspaceRoot = process.cwd(); @@ -44,26 +45,6 @@ const buildWorkbench = async (): Promise => { }); }; -/** The example's prebuilt payload directories its declared artifacts package. */ -const runtimeExamplePayloads = ['app', 'runtime'] as const; - -/** - * The 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 fixture - * exercises live). - */ -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 { RSTEST: _rstest, ...environment } = process.env; - await execFile('pnpm', ['--filter', '@agent-bundle/rsc-agent-runtime-demo', 'exec', 'rsbuild', 'build', '--mode', 'production'], { - cwd: workspaceRoot, - env: { ...environment, NODE_ENV: 'production' }, - maxBuffer: 64 * 1024 * 1024, - }); -}; /** Starts the real RSC example in an isolated workspace-local copy. */ export const startRuntimePlaygroundFixture = async ( diff --git a/packages/workbench/tests/mcp-app-real.e2e.test.ts b/packages/workbench/tests/mcp-app-real.e2e.test.ts index 5dead3b7f..8a3a08dd1 100644 --- a/packages/workbench/tests/mcp-app-real.e2e.test.ts +++ b/packages/workbench/tests/mcp-app-real.e2e.test.ts @@ -252,7 +252,7 @@ e2e('runs a generated SDK-v2 App through the real foreground session and separat await page.locator('#mcp-target').selectOption('portable'); await page.locator('#mcp-server-name').fill('fixture'); const opened = page.waitForResponse((response) => - response.url() === `${foregroundOrigin}/api/mcp/sessions` && response.request().method() === 'POST'); + response.url() === `${foregroundOrigin}/api/mcp/sessions` && response.request().method() === 'POST', { timeout: 30_000 * timeScale }); await page.getByRole('button', { name: 'Open MCP session' }).click(); const openedResponse = await opened; const foregroundToken = await openedResponse.request().headerValue('x-agent-bundle-session'); @@ -278,7 +278,7 @@ e2e('runs a generated SDK-v2 App through the real foreground session and separat expect({ request: invocation.request, result: invocation.result }).toEqual({ request: originalInput, result: originalResult }); const createdPreview = page.waitForRequest((request) => - request.url() === `${foregroundOrigin}/api/mcp/sessions/${openedSession.session.id}/apps` && request.method() === 'POST'); + request.url() === `${foregroundOrigin}/api/mcp/sessions/${openedSession.session.id}/apps` && request.method() === 'POST', { timeout: 30_000 * timeScale }); await page.getByRole('button', { name: 'Open App preview for mcp-page-1' }).click(); const createRequest = requestBody((await createdPreview).postData()) as Readonly<{ readonly input: unknown; @@ -410,7 +410,7 @@ e2e('runs a generated SDK-v2 App through the real foreground session and separat }), { timeout: browserTimeout }).toBe(true); expect(await appFrame.content()).not.toContain(foregroundToken); - const firstClose = page.waitForRequest((request) => request.url().startsWith(`${foregroundOrigin}/api/mcp/apps/`) && request.url().endsWith('/close')); + const firstClose = page.waitForRequest((request) => request.url().startsWith(`${foregroundOrigin}/api/mcp/apps/`) && request.url().endsWith('/close'), { timeout: 30_000 * timeScale }); await page.getByRole('button', { name: 'Close App preview' }).click(); const firstCloseBody = requestBody((await firstClose).postData()) as Readonly<{ readonly id: string }>; await expect.poll(() => appRequests.some((request) => { @@ -424,9 +424,9 @@ e2e('runs a generated SDK-v2 App through the real foreground session and separat await page.getByRole('button', { name: 'Open App preview for mcp-page-1' }).click(); await expect(outerFrame).toBeVisible({ timeout: browserTimeout }); - const secondClose = page.waitForRequest((request) => request.url().startsWith(`${foregroundOrigin}/api/mcp/apps/`) && request.url().endsWith('/close')); + const secondClose = page.waitForRequest((request) => request.url().startsWith(`${foregroundOrigin}/api/mcp/apps/`) && request.url().endsWith('/close'), { timeout: 30_000 * timeScale }); const closedSession = page.waitForRequest((request) => - request.url() === `${foregroundOrigin}/api/mcp/sessions/${openedSession.session.id}` && request.method() === 'DELETE'); + request.url() === `${foregroundOrigin}/api/mcp/sessions/${openedSession.session.id}` && request.method() === 'DELETE', { timeout: 30_000 * timeScale }); await page.getByRole('button', { name: 'Close MCP session' }).click(); await secondClose; await closedSession; @@ -566,7 +566,7 @@ e2e('opens the real RSC runtime timeline App from provider-owned run evidence', const bootstrap = await clientPage.goto(clientSurface.bootstrapUrl, { waitUntil: 'domcontentloaded' }); expect(bootstrap?.status()).toBe(200); try { - await expect.poll(() => runtimeIdentity.getAttribute('data-runtime-hmr-client-count'), { timeout: 3_000 }).toBe('1'); + await expect.poll(() => runtimeIdentity.getAttribute('data-runtime-hmr-client-count'), { timeout: 3_000 * timeScale }).toBe('1'); } catch { throw new Error(`Runtime App HMR proxy did not connect: ${JSON.stringify({ console: clientSurfaceConsole, @@ -576,12 +576,16 @@ e2e('opens the real RSC runtime timeline App from provider-owned run evidence', sockets: clientSurfaceSockets, })}`); } - expect(clientSurfaceSockets).toEqual([`${clientSurface.origin.replace('http:', 'ws:')}${runtimeClientSurfaceReloadChannelPath}`]); + // The hmr-client-count attribute is the server's view, read through the + // main page's CDP session; Playwright's websocket event arrives on the + // client page's session and can lag it, so the socket list must be + // awaited rather than asserted synchronously. + await expect.poll(() => clientSurfaceSockets, { timeout: 3_000 * timeScale }).toEqual([`${clientSurface.origin.replace('http:', 'ws:')}${runtimeClientSurfaceReloadChannelPath}`]); expect(clientSurfaceSockets.every((socket) => new URL(socket).search.length === 0)).toBe(true); expect(clientSurfaceHmrRequests.every((request) => new URL(request.url).search.length === 0)).toBe(true); await clientPage.close(); clientPage = undefined; - await expect.poll(() => runtimeIdentity.getAttribute('data-runtime-hmr-client-count'), { timeout: 3_000 }).toBe('0'); + await expect.poll(() => runtimeIdentity.getAttribute('data-runtime-hmr-client-count'), { timeout: 3_000 * timeScale }).toBe('0'); await page.routeWebSocket((url) => url.pathname === runtimeClientSurfaceReloadChannelPath, (route) => { runtimePreviewHmrRoutes.push(route); route.connectToServer(); @@ -592,9 +596,9 @@ e2e('opens the real RSC runtime timeline App from provider-owned run evidence', await page.getByRole('radio', { name: 'Raw JSON' }).check(); await page.locator('#runtime-input-raw').fill('{}'); const createRequest = page.waitForRequest((request) => - request.url() === `${fixture.url}/api/runtime/apps` && request.method() === 'POST'); + request.url() === `${fixture.url}/api/runtime/apps` && request.method() === 'POST', { timeout: 30_000 * timeScale }); const createResponse = page.waitForResponse((response) => - response.url() === `${fixture.url}/api/runtime/apps` && response.request().method() === 'POST'); + response.url() === `${fixture.url}/api/runtime/apps` && response.request().method() === 'POST', { timeout: 30_000 * timeScale }); await page.getByRole('button', { name: 'Run', exact: true }).click(); const [createdRequest, createdResponse] = await Promise.all([createRequest, createResponse]); const history = page.getByRole('region', { name: 'Runtime run history' }).locator('ol > li'); @@ -638,7 +642,9 @@ e2e('opens the real RSC runtime timeline App from provider-owned run evidence', await expect(outerFrame).toHaveAttribute('sandbox', 'allow-scripts allow-same-origin'); await expect(outerFrame).toHaveAttribute('referrerpolicy', 'no-referrer'); await expect.poll(() => runtimeIdentity.getAttribute('data-runtime-hmr-client-count'), { timeout: 15_000 * timeScale }).toBe('1'); - expect(runtimePreviewSockets).toEqual([`${created.preview.clientSurface.origin.replace('http:', 'ws:')}${runtimeClientSurfaceReloadChannelPath}`]); + // Awaited for the same reason as clientSurfaceSockets above: the + // websocket event can arrive after the server already counts the client. + await expect.poll(() => runtimePreviewSockets, { timeout: 3_000 * timeScale }).toEqual([`${created.preview.clientSurface.origin.replace('http:', 'ws:')}${runtimeClientSurfaceReloadChannelPath}`]); const runtimeAppFrame = async () => { for (const frame of page.frames()) { if (await frame.getByRole('heading', { name: 'Runtime edit timeline' }).count() === 1) return frame; @@ -946,7 +952,7 @@ e2e('opens the real RSC runtime timeline App from provider-owned run evidence', await appFrame.getByRole('button', { name: 'Refresh' }).click(); try { - await expect.poll(() => consentRequests('action'), { timeout: 3_000 }).toHaveLength(1); + await expect.poll(() => consentRequests('action'), { timeout: 3_000 * timeScale }).toHaveLength(1); } catch { throw new Error(`Runtime App call relay did not reach consent: ${JSON.stringify({ console: browserConsole, @@ -975,7 +981,7 @@ e2e('opens the real RSC runtime timeline App from provider-owned run evidence', await expect(denyRuntimeConsent).toBeFocused({ timeout: 15_000 * timeScale }); await page.keyboard.press('Shift+Tab'); await expect(allowRuntimeConsent).toBeFocused({ timeout: 15_000 * timeScale }); - await expect.poll(() => consentResponses('action')).toHaveLength(1); + await expect.poll(() => consentResponses('action'), { timeout: 15_000 * timeScale }).toHaveLength(1); const consentCreated = consentResponses('action')[0]; const challenge = (consentCreated?.response as Readonly<{ readonly challenge?: Readonly<{ readonly id?: unknown }> }> | undefined)?.challenge; if (typeof challenge?.id !== 'string') throw new Error('Runtime App consent create response omitted its challenge id.'); @@ -1029,7 +1035,7 @@ e2e('opens the real RSC runtime timeline App from provider-owned run evidence', kind: 'tools/call', name: 'render_edit_timeline', }); - await expect.poll(() => operationResponses('tools/call')).toHaveLength(1); + await expect.poll(() => operationResponses('tools/call'), { timeout: 15_000 * timeScale }).toHaveLength(1); const operated = operationResponses('tools/call')[0]; const operationResult = (operated?.response as Readonly<{ readonly result?: unknown }> | undefined)?.result; expect(operationResult).toEqual({ @@ -1087,7 +1093,7 @@ e2e('opens the real RSC runtime timeline App from provider-owned run evidence', scope: 'action', summary: 'Call MCP App tool', }); - await expect.poll(() => consentResponses('action')).toHaveLength(2); + await expect.poll(() => consentResponses('action'), { timeout: 15_000 * timeScale }).toHaveLength(2); const deniedConsentCreated = consentResponses('action')[1]; const deniedChallenge = (deniedConsentCreated?.response as Readonly<{ readonly challenge?: Readonly<{ readonly id?: unknown }> }> | undefined)?.challenge; if (typeof deniedChallenge?.id !== 'string') throw new Error('Denied Runtime App consent create response omitted its challenge id.'); @@ -1151,8 +1157,8 @@ e2e('opens the real RSC runtime timeline App from provider-owned run evidence', body: { diagnostic: { code: 'AB8023', message: 'MCP App operation could not be completed.' } }, status: 502, }); - await expect.poll(() => operationRequests('tools/call')).toHaveLength(2); - await expect.poll(() => operationResponses('tools/call')).toHaveLength(2); + await expect.poll(() => operationRequests('tools/call'), { timeout: 15_000 * timeScale }).toHaveLength(2); + await expect.poll(() => operationResponses('tools/call'), { timeout: 15_000 * timeScale }).toHaveLength(2); expect(operationResponses('tools/call').filter((entry) => { const response = entry.response; return response !== null && typeof response === 'object' && Object.hasOwn(response, 'result'); @@ -1691,7 +1697,7 @@ e2e('renders a compiler-bundled App template through the canonical sandbox URL', const request = response.request(); const url = new URL(response.url()); return request.method() === 'DELETE' && url.origin === foregroundOrigin && /^\/api\/mcp\/apps\/[^/]+$/u.test(url.pathname); - }); + }, { timeout: 30_000 * timeScale }); await page.getByRole('button', { name: 'Close App preview' }).click(); expect((await fallbackClosed).status()).toBe(200); await expect(outerFrame).toBeHidden({ timeout: browserTimeout }); diff --git a/packages/workbench/tests/overview.e2e.test.ts b/packages/workbench/tests/overview.e2e.test.ts index cc924b623..6074a6e53 100644 --- a/packages/workbench/tests/overview.e2e.test.ts +++ b/packages/workbench/tests/overview.e2e.test.ts @@ -357,10 +357,6 @@ e2e('offers the host-owned MCP playground handoff only after a selected Runtime ); const editedStyles = `${styles}\n.timeline__header [data-testid="runtime-hmr-marker"] { color: rgb(1, 2, 3); }\n`; expect(editedSource).not.toBe(source); - await Promise.all([ - replaceWatchedSource(fixture.root, fixture.widgetAppSource, editedSource), - replaceWatchedSource(fixture.root, fixture.appStyles, editedStyles), - ]); // The owned reload channel carries provider-authored frames only; a // changed App compile advances the generation past the connect replay. const ownedReloadFrames = (): readonly number[] => runtimePreviewHmrMessages.flatMap((message) => { @@ -371,8 +367,23 @@ e2e('offers the host-owned MCP playground handoff only after a selected Runtime return []; } }); - await expect.poll(() => ownedReloadFrames().some((generation) => generation > 0), { timeout: browserTimeout }) - .toBe(true); + const maxOwnedReloadGeneration = (): number => + ownedReloadFrames().reduce((max, generation) => Math.max(max, generation), 0); + // The two edits are sequenced through the reload channel rather than + // written together: a simultaneous write leaves the compile count to the + // watcher's aggregation window (one coalesced compile or two split ones), + // and a split's second announcement can land after the DOM waits below — + // the dev middleware holds asset requests during a compile, so the + // refreshed frame can already show both edits while the second frame is + // still in flight, poisoning the baseline captured for the reconcile + // assertions. Frames arrive in order on one socket, so barriering each + // write on its own announced generation pins the edit to exactly one + // announcement per write with none outstanding afterwards. + await replaceWatchedSource(fixture.root, fixture.appStyles, editedStyles); + await expect.poll(maxOwnedReloadGeneration, { timeout: browserTimeout }).toBeGreaterThan(0); + const stylesReloadGeneration = maxOwnedReloadGeneration(); + await replaceWatchedSource(fixture.root, fixture.widgetAppSource, editedSource); + await expect.poll(maxOwnedReloadGeneration, { timeout: browserTimeout }).toBeGreaterThan(stylesReloadGeneration); const refreshedWidget = async () => { for (const frame of page.frames()) { if (await frame.getByTestId('runtime-hmr-marker').count() === 1) return frame; diff --git a/packages/workbench/tests/support/watched-files.ts b/packages/workbench/tests/support/watched-files.ts index c8d88e55e..e9b770a62 100644 --- a/packages/workbench/tests/support/watched-files.ts +++ b/packages/workbench/tests/support/watched-files.ts @@ -1,18 +1 @@ -import { rename, writeFile } from 'node:fs/promises'; -import { basename, join } from 'node:path'; - -/** - * Replaces a watched source atomically through a rename staged OUTSIDE the - * watched project. An in-place write is truncate-then-append: the dev - * compiler can start a compile off the truncation event, read incomplete - * content, and then drop the append event because both operations land - * within the same mtime tick, so the final content never compiles and the - * expected revision never activates. The temp file lives in the project's - * parent (same filesystem, never watched) so the rename into place is the - * only event the watcher observes. - */ -export const replaceWatchedSource = async (projectRoot: string, path: string, content: string): Promise => { - const temporary = join(projectRoot, '..', `.${basename(path)}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`); - await writeFile(temporary, content); - await rename(temporary, path); -}; +export { replaceWatchedSource } from '../../../agent-bundle/tests/support/watched-files.ts'; diff --git a/rstest.config.ts b/rstest.config.ts index c295aef3d..dcf8b29a3 100644 --- a/rstest.config.ts +++ b/rstest.config.ts @@ -9,9 +9,10 @@ export default defineConfig({ 'packages/**/tests/**/*.test.ts', ], exclude: [...templateTestFiles], - // Several integration tests run Rslib, whose build cache and configured - // output paths are process-shared. Keep those builds from racing each other. - pool: { maxWorkers: 1 }, + // 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'], // isolate: false would cut Playwright startup cost, but the log pipeline // suites rely on per-file module isolation (verified: logs-real.e2e fails // when sharing a worker with the other log suites). diff --git a/rstest.integration-tests.ts b/rstest.integration-tests.ts index ab2a6dacb..11e96cea6 100644 --- a/rstest.integration-tests.ts +++ b/rstest.integration-tests.ts @@ -73,8 +73,8 @@ export const integrationTestFiles: readonly string[] = [ * native-host-smoke workflow keep them covered — and stay excluded from the * parallel unit pool. packed-release.e2e lives here (not in the integration * pool) so `pnpm test` and the release gates don't each run the same long - * packed-browser suite; `rstest.packed.config.ts` keeps `test:packed` on one - * worker. + * packed-browser suite. `rstest.packed.config.ts` does not cap `test:packed` + * workers; pack destinations and tmp roots are per RSTEST_WORKER_ID. */ export const packedTestFiles: readonly string[] = [ 'packages/agent-bundle/tests/dev-workbench-packaging.test.ts', diff --git a/rstest.integration.config.ts b/rstest.integration.config.ts index d5b3d89a2..783ef2fa4 100644 --- a/rstest.integration.config.ts +++ b/rstest.integration.config.ts @@ -6,22 +6,31 @@ import { integrationTestFiles } from './rstest.integration-tests.ts'; import { withAgentBundleRslibConfig } from './rstest.rslib.ts'; /** - * Worker count for the parallel integration pool. Half the cores keeps - * browser + dev-server pairs from starving each other and the cap of 4 bounds - * memory on large machines. CI pins one worker explicitly: hosted runners - * report 4 cores (which would compute 2 workers), but each Chrome + - * dev-server + rsbuild pair already saturates them, and 2-worker matrix runs - * flaked on a rotating test per leg even at timeScale 4. Parallelism is a - * development-machine speedup; CI keeps the serialized shape it was tuned - * for. AGENT_BUNDLE_INTEGRATION_MAX_WORKERS overrides the computed value - * (e.g. to measure a parallel CI run or bisect locally in serial). + * 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 - : process.env['CI'] !== undefined - ? 1 - : Math.max(1, Math.min(4, Math.floor(availableParallelism() / 2))); + : Math.max(1, Math.min(4, Math.floor(availableParallelism() / 2))); /** * Polling budgets scale with contention. A multi-worker pool needs at least @@ -40,19 +49,25 @@ const timeScale = Number.isSafeInteger(externalTimeScale) && externalTimeScale > /** * Build- and process-running tests that only read workspace-shared artifacts; * files that WRITE shared locations (root builds, `npm pack`) run through the - * single-worker `test:packed` script instead (see rstest.integration-tests.ts). + * `test:packed` script instead (see rstest.integration-tests.ts). */ export default defineConfig({ extends: withAgentBundleRslibConfig(), include: [...integrationTestFiles], + // 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'], pool: { maxWorkers }, + setupFiles: ['./rstest.setup.ts'], + // isolate: false would cut Playwright startup cost, but the log pipeline + // suites rely on per-file module isolation (verified: logs-real.e2e fails + // when sharing a worker with the other log suites). + isolate: true, // Concurrent Chrome + dev-server + rsbuild pairs contend for cores, so // parallel runs double the polling budgets (see tests/support/time-scale.ts) // and raise the 5s default test timeout, which real in-process builds can // exceed when workers share the machine. Explicit per-test timeouts win. env: { AGENT_BUNDLE_TEST_TIME_SCALE: String(timeScale) }, testTimeout: 30_000, - // isolate: false would cut Playwright startup cost, but the log pipeline - // suites rely on per-file module isolation (verified: logs-real.e2e fails - // when sharing a worker with the other log suites). }); diff --git a/rstest.integration.setup.ts b/rstest.integration.setup.ts new file mode 100644 index 000000000..0e04bb2cb --- /dev/null +++ b/rstest.integration.setup.ts @@ -0,0 +1,13 @@ +import { ensureRuntimeExamplePayload } from './packages/workbench/tests/helpers/runtime-example-payload.ts'; + +/** + * Builds the rsc-agent-runtime example's prebuilt payload trees once, in the + * orchestrator, before any pool worker starts. Four e2e files copy that + * shared `examples/rsc-agent-runtime/dist` tree through + * runtime-playground-fixture.ts; on a cold tree (every CI runner) two + * parallel workers would otherwise race the same ensure-build and one of + * them could copy a torn payload. + */ +export const setup = async (): Promise => { + await ensureRuntimeExamplePayload(); +}; diff --git a/rstest.packed.config.ts b/rstest.packed.config.ts index ad15c6c0b..696f8570d 100644 --- a/rstest.packed.config.ts +++ b/rstest.packed.config.ts @@ -8,9 +8,10 @@ import { withAgentBundleRslibConfig } from './rstest.rslib.ts'; * `scripts/run-packed-tests.mjs` so every file consumes one shared tarball * per public package. `--release` (AGENT_BUNDLE_PACKED_RELEASE=1) adds the * release-boundary-only files — the scaffolder template matrix — on top of - * the per-PR set. The pool stays on one worker: dev-workbench-packaging - * rebuilds the workspace `dist` in place while release-audit's audit script - * packs it, so the files still contend on workspace-shared writes. + * the per-PR set. Rstest sizes the pool itself: no file writes the workspace + * `dist` in place anymore (dev-workbench-packaging's prune test rebuilds + * into an isolated copy), and shared tmp/npm/cache roots are per + * RSTEST_WORKER_ID (see rstest.setup.ts). */ export default defineConfig({ extends: withAgentBundleRslibConfig(), @@ -18,5 +19,5 @@ export default defineConfig({ ...packedTestFiles, ...(process.env['AGENT_BUNDLE_PACKED_RELEASE'] === '1' ? packedReleaseOnlyTestFiles : []), ], - pool: { maxWorkers: 1 }, + setupFiles: ['./rstest.setup.ts'], }); diff --git a/rstest.runtime-playground.browser.config.ts b/rstest.runtime-playground.browser.config.ts index 6cb104f61..c066ce0c4 100644 --- a/rstest.runtime-playground.browser.config.ts +++ b/rstest.runtime-playground.browser.config.ts @@ -19,7 +19,7 @@ export default defineConfig({ extends: withRslibConfig(), include: ['packages/workbench/tests/runtime-playground.browser.test.tsx'], plugins: [pluginReact()], - pool: { maxWorkers: 1 }, + setupFiles: ['./rstest.setup.browser.ts'], resolve: { alias: { react: browserReactRoot, diff --git a/rstest.runtime-playground.config.ts b/rstest.runtime-playground.config.ts index 426d018b8..bccb49df8 100644 --- a/rstest.runtime-playground.config.ts +++ b/rstest.runtime-playground.config.ts @@ -16,7 +16,6 @@ export default defineConfig({ reporters: ['text', 'json'], thresholds: { branches: 85, functions: 90, lines: 90, statements: 90 }, }, - pool: { maxWorkers: 1 }, projects: [ defineInlineProject({ extends: withRslibConfig(), @@ -27,6 +26,7 @@ export default defineConfig({ 'packages/workbench/tests/runtime-playground.test.ts', ], name: 'runtime-node', + setupFiles: ['./rstest.setup.ts'], testEnvironment: 'node', }), defineInlineProject({ @@ -40,6 +40,7 @@ export default defineConfig({ extends: withRslibConfig(), include: ['packages/workbench/tests/runtime-playground.browser.test.tsx'], name: 'runtime-browser', + setupFiles: ['./rstest.setup.browser.ts'], plugins: [pluginReact()], resolve: { alias: { diff --git a/rstest.setup.browser.ts b/rstest.setup.browser.ts new file mode 100644 index 000000000..064dae7a4 --- /dev/null +++ b/rstest.setup.browser.ts @@ -0,0 +1,5 @@ +// Browser pools bundle setup files into the page bundle, where node: builtins +// are an unhandled scheme. Worker isolation (rstest.setup.ts) redirects +// TMPDIR/XDG caches for Node test processes and has no browser equivalent, so +// browser projects load this empty setup instead. +export {}; diff --git a/rstest.setup.ts b/rstest.setup.ts new file mode 100644 index 000000000..d5d552c8d --- /dev/null +++ b/rstest.setup.ts @@ -0,0 +1,3 @@ +import { isolateWorkerEnvironment } from './rstest.worker-isolation.ts'; + +isolateWorkerEnvironment(); diff --git a/rstest.unit.config.ts b/rstest.unit.config.ts index f8b8c9f17..c5a4ad9d4 100644 --- a/rstest.unit.config.ts +++ b/rstest.unit.config.ts @@ -10,4 +10,7 @@ export default defineConfig({ 'packages/**/tests/**/*.test.ts', ], exclude: [...integrationTestFiles, ...packedTestFiles, ...templateTestFiles], + setupFiles: ['./rstest.setup.ts'], + // Unit files construct per-test services; logs-real.e2e is not in this pool. + isolate: false, }); diff --git a/rstest.worker-isolation.ts b/rstest.worker-isolation.ts new file mode 100644 index 000000000..6d7372d51 --- /dev/null +++ b/rstest.worker-isolation.ts @@ -0,0 +1,46 @@ +import { mkdirSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +export const rstestWorkerId = (): string => process.env['RSTEST_WORKER_ID'] ?? '0'; + +const hostTemporaryRoot = tmpdir(); + +export const rstestWorkerRoot = (): string => { + const root = join(hostTemporaryRoot, 'agent-bundle-rstest-w' + rstestWorkerId()); + mkdirSync(root, { recursive: true }); + return root; +}; + +export const rstestWorkerCacheDirectory = (name: string): string => { + const directory = join(rstestWorkerRoot(), 'cache', name); + mkdirSync(directory, { recursive: true }); + return directory; +}; + +export const isolateWorkerEnvironment = (): void => { + const root = rstestWorkerRoot(); + const cache = rstestWorkerCacheDirectory('xdg'); + const env = process['env']; + env['TMPDIR'] = root; + env['TMP'] = root; + env['TEMP'] = root; + env['XDG_CACHE_HOME'] = cache; +}; + +let commandSerial = 0; + +export const isolatedCommandEnvironment = (base: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv => { + commandSerial += 1; + const stamp = String(process.pid) + '-' + String(commandSerial); + const cache = rstestWorkerCacheDirectory('cmd-' + stamp); + const tmp = join(rstestWorkerRoot(), 'cmd-tmp-' + stamp); + mkdirSync(tmp, { recursive: true }); + const { NODE_PATH: _nodePath, ...rest } = base; + const environment: NodeJS.ProcessEnv = { ...rest }; + environment['npm_config_cache'] = cache; + environment['TMPDIR'] = tmp; + environment['TMP'] = tmp; + environment['TEMP'] = tmp; + return environment; +};