diff --git a/examples/rsc-agent-runtime/src/dev/rsbuild-runtime-session.ts b/examples/rsc-agent-runtime/src/dev/rsbuild-runtime-session.ts index e7cc886c2..d447d9744 100644 --- a/examples/rsc-agent-runtime/src/dev/rsbuild-runtime-session.ts +++ b/examples/rsc-agent-runtime/src/dev/rsbuild-runtime-session.ts @@ -93,7 +93,8 @@ const maximumInvocationWorkers = 4; const maximumInvocationStdoutBytes = 4 * 1024 * 1024; const maximumInvocationFlightBytes = 4 * 1024 * 1024; const maximumInvocationStderrBytes = 256 * 1024; -const maximumRunHistory = 50; +/** Production terminal-run retention window; tests may shrink it through the start testing seam. */ +export const defaultMaximumRunHistory = 50; const invocationTimeoutMs = 10_000; const invocationTerminationGraceMs = 100; const flightPreviewBytes = 32 * 1024; @@ -603,6 +604,12 @@ export interface RsbuildRuntimeSessionStartTesting { }>) => Promise | void; /** Windows-only Job owner fault injection; never used by the public provider. */ readonly windowsJobOwnerMode?: 'close-control' | 'hang-ready' | 'ignore-stop' | 'nonzero-after-drain' | 'normal'; + /** + * Test-only terminal-run retention override so eviction suites do not need + * fifty real invocations; the public provider always keeps + * `defaultMaximumRunHistory` runs. + */ + readonly maximumRunHistory?: number; } /** @@ -633,6 +640,7 @@ export class RsbuildRuntimeSession implements DevRuntimeSession { readonly #surfaceAssetApps = new Map(); readonly #surfaces = new Map(); readonly #testing: RsbuildRuntimeSessionStartTesting; + readonly #maximumRunHistory: number; readonly #attempts = new Map(); readonly #workers = new Map(); readonly #failedAttempts = new Set(); @@ -669,6 +677,7 @@ export class RsbuildRuntimeSession implements DevRuntimeSession { this.#mcpRegistry = input.mcpRegistry; this.#latestPreparedRuntime = input.preparedRuntime; this.#testing = input.testing; + this.#maximumRunHistory = input.testing.maximumRunHistory ?? defaultMaximumRunHistory; this.#ownedRunsRoot = input.ownedRunsRoot; this.#runRoot = input.ownedRunsRoot.root; this.#stateFile = join(resolve(input.context.storageRoot), 'state', `${stateStoreId}.jsonl`); @@ -1035,8 +1044,8 @@ export class RsbuildRuntimeSession implements DevRuntimeSession { runs(limit: number): readonly DevRuntimeRun[] { if (this.#closed) return Object.freeze([]); - if (!Number.isSafeInteger(limit) || limit < 1 || limit > maximumRunHistory) { - throw new RangeError(`Runtime run history limit must be an integer from 1 through ${maximumRunHistory}.`); + if (!Number.isSafeInteger(limit) || limit < 1 || limit > this.#maximumRunHistory) { + throw new RangeError(`Runtime run history limit must be an integer from 1 through ${String(this.#maximumRunHistory)}.`); } return Object.freeze([...this.#terminalRuns.values()].reverse().slice(0, limit)); } @@ -1333,7 +1342,7 @@ export class RsbuildRuntimeSession implements DevRuntimeSession { } async #evictTerminalRuns(): Promise { - while (this.#terminalRuns.size > maximumRunHistory) { + while (this.#terminalRuns.size > this.#maximumRunHistory) { const oldestId = this.#terminalRuns.keys().next().value as string | undefined; if (oldestId === undefined) return; this.#evictingTerminalRuns.add(oldestId); diff --git a/examples/rsc-agent-runtime/tests/dev-invocation.integration.test.ts b/examples/rsc-agent-runtime/tests/dev-invocation.integration.test.ts index 6eafe5441..a13490dc8 100644 --- a/examples/rsc-agent-runtime/tests/dev-invocation.integration.test.ts +++ b/examples/rsc-agent-runtime/tests/dev-invocation.integration.test.ts @@ -13,7 +13,7 @@ import { createElement, type ReactNode } from 'react'; import { ProjectService } from '../../../packages/agent-bundle/src/dev/index.ts'; import { createRscRuntimeRsbuildConfig } from '../rsbuild.config.js'; import { createDevRuntimeProvider } from '../src/dev/provider.js'; -import { RsbuildRuntimeSession } from '../src/dev/rsbuild-runtime-session.js'; +import { defaultMaximumRunHistory, RsbuildRuntimeSession } from '../src/dev/rsbuild-runtime-session.js'; import { serializeInspection } from '../src/dev/serialize-inspection.js'; const readChildOutput = (stream: NodeJS.ReadableStream): Promise => @@ -1703,58 +1703,153 @@ process.stdout.end(JSON.stringify({ } }, 30_000); -test('keeps the newest fifty immutable run artifacts and evicts the oldest completed Flight', async () => { - const storageRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-session-history-')); +test('drives the run-eviction window through its happy, held-reader, failed-removal, and failed-release paths', async () => { + // The retention window is injected small so the suite does not need fifty + // real invocations; production keeps the fifty-run default. + expect(defaultMaximumRunHistory).toBe(50); + const retentionWindow = 5; + const storageRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-session-eviction-')); const projectRoot = process.cwd(); const prepared = await new ProjectService({ includeDevRuntime: true, mode: 'development', root: projectRoot }).prepare('dev'); - const session = await createDevRuntimeProvider().start({ + const readerEntered = deferred(); + const releaseReader = deferred(); + const evictionReserved = deferred(); + let heldRunId: string | undefined; + let heldReaderAdmissions = 0; + let reservedRunId: string | undefined; + let failReleaseRunId: string | undefined; + let failedReleaseAttempts = 0; + let removalFailureRunId: string | undefined; + let failRemovalOnce = false; + let removalVictimReleaseAttempts = 0; + let removalVictimRemovalAttempts = 0; + const session = await RsbuildRuntimeSession.start({ artifactStatus: () => Object.freeze({ state: 'missing' as const }), emit: () => undefined, environment: Object.freeze({}), projectRoot, preparedRuntime: prepared.devRuntime!, - providerSessionId: 'session-history-test', + providerSessionId: 'session-eviction-test', signal: new AbortController().signal, storageRoot, + }, { + afterRunArtifactEvictionReserved: ({ runId }: Readonly<{ readonly runId: string }>) => { + if (runId === reservedRunId) evictionReserved.resolve(); + }, + beforeRunArtifactRelease: ({ runId }: Readonly<{ readonly runId: string }>) => { + if (runId === removalFailureRunId) removalVictimReleaseAttempts += 1; + if (runId !== failReleaseRunId) return; + failedReleaseAttempts += 1; + throw new Error('do-not-expose-eviction-release-secret'); + }, + beforeRunDirectoryRemoval: ({ runId }: Readonly<{ readonly runId: string }>) => { + if (runId === removalFailureRunId) removalVictimRemovalAttempts += 1; + if (failRemovalOnce && runId === removalFailureRunId) { + failRemovalOnce = false; + throw new Error('do-not-expose-evicted-run-directory-removal-secret'); + } + }, + beforeRunFlightRead: async ({ runId }: Readonly<{ readonly runId: string }>) => { + if (runId !== heldRunId) return; + heldReaderAdmissions += 1; + if (heldReaderAdmissions !== 1) return; + readerEntered.resolve(); + await releaseReader.promise; + }, + maximumRunHistory: retentionWindow, }); try { await waitFor(() => session.status().activeVector !== undefined, 'Timed out waiting for an active runtime generation', 15_000); const generationId = session.status().activeVector!.runtimeGenerationId; const target = session.surfaces().find((surface) => surface.id === 'mcp.runtime_status')!.targets[0]!; - const first = await session.invoke({ + const request = { expectedGenerationId: generationId, input: {}, surfaceId: 'mcp.runtime_status', target, - }); - if (first.status !== 'succeeded') throw new Error(JSON.stringify(first.diagnostics)); - const firstFlight = await session.readRunFlight(first.id); - expect(firstFlight?.body.byteLength).toBeGreaterThan(0); + } as const; + const invokeSucceeded = async () => { + const run = await session.invoke(request); + if (run.status !== 'succeeded') throw new Error(JSON.stringify(run.status === 'failed' ? run.diagnostics : run)); + return run; + }; + + // The four oldest runs become, in eviction order, the victims of each exercised path. + const happyVictim = await invokeSucceeded(); + const readerVictim = await invokeSucceeded(); + const removalVictim = await invokeSucceeded(); + const releaseVictim = await invokeSucceeded(); + const happyFlight = await session.readRunFlight(happyVictim.id); + expect(happyFlight?.body.byteLength).toBeGreaterThan(0); await session.resetState({ expectedGenerationId: generationId, stateStoreId: 'playground' }); - expect(session.run(first.id)).toEqual(first); - - for (let index = 0; index < 50; index += 1) { - const run = await session.invoke({ - expectedGenerationId: generationId, - input: {}, - surfaceId: 'mcp.runtime_status', - target, - }); - expect(run.status).toBe('succeeded'); - } + expect(session.run(happyVictim.id)).toEqual(happyVictim); + + for (let index = 0; index < retentionWindow - 4; index += 1) await invokeSucceeded(); + expect(session.runs(retentionWindow)).toHaveLength(retentionWindow); - expect(session.run(first.id)).toBeUndefined(); - await expect(session.readRunFlight(first.id)).resolves.toBeUndefined(); + // Happy path: the run beyond the window evicts the oldest completed Flight. + await invokeSucceeded(); + expect(session.run(happyVictim.id)).toBeUndefined(); + await expect(session.readRunFlight(happyVictim.id)).resolves.toBeUndefined(); await expect(session.readRunFlight('../flight.bin')).resolves.toBeUndefined(); - expect(session.runs(50)).toHaveLength(50); - expect(session.runs(50)[0]!.id).not.toBe(first.id); - expect((await readdir(join(storageRoot, 'runs'))).filter((entry) => entry !== '.agent-bundle-runtime-owner')).toHaveLength(50); + expect(session.runs(retentionWindow)).toHaveLength(retentionWindow); + expect(session.runs(retentionWindow)[0]!.id).not.toBe(happyVictim.id); + expect((await readdir(join(storageRoot, 'runs'))).filter((entry) => entry !== '.agent-bundle-runtime-owner')).toHaveLength(retentionWindow); + + // Held reader: eviction reserves the terminal run before draining its admitted Flight reader. + heldRunId = readerVictim.id; + reservedRunId = readerVictim.id; + const admittedReader = session.readRunFlight(readerVictim.id); + await readerEntered.promise; + const evicting = session.invoke(request); + await evictionReserved.promise; + await expect(session.readRunFlight(readerVictim.id)).resolves.toBeUndefined(); + expect(heldReaderAdmissions).toBe(1); + releaseReader.resolve(); + await expect(admittedReader).resolves.toMatchObject({ body: expect.any(Buffer) }); + await expect(evicting).resolves.toMatchObject({ status: 'succeeded' }); + await expect(session.readRunFlight(readerVictim.id)).resolves.toBeUndefined(); + + // Failed run-directory removal: successful history finalizes before the removal failure surfaces. + removalFailureRunId = removalVictim.id; + failRemovalOnce = true; + const removalFailure = await session.invoke(request); + expect(removalFailure).toMatchObject({ + diagnostics: [expect.objectContaining({ message: 'RSC runtime run artifact cleanup failed; cleanup failures: run-artifact.' })], + status: 'failed', + }); + expect(removalFailure.status === 'failed' && removalFailure.diagnostics[0]!.message) + .not.toContain('do-not-expose-evicted-run-directory-removal-secret'); + expect(session.run(removalVictim.id)).toBeUndefined(); + await expect(session.readRunFlight(removalVictim.id)).resolves.toBeUndefined(); + expect(await readdir(join(storageRoot, 'runs'))).toEqual(expect.arrayContaining([removalVictim.id])); + expect(removalVictimReleaseAttempts).toBe(1); + + // Failed artifact release: the oldest artifact and its terminal history stay owned. + failReleaseRunId = releaseVictim.id; + await expect(session.invoke(request)).rejects.toThrow('RSC runtime run artifact cleanup failed; cleanup failures: run-artifact.'); + expect(failedReleaseAttempts).toBeGreaterThan(0); + expect(session.run(releaseVictim.id)).toEqual(releaseVictim); + await expect(session.readRunFlight(releaseVictim.id)).resolves.toMatchObject({ body: expect.any(Buffer) }); + expect(await readdir(join(storageRoot, 'runs'))).toEqual(expect.arrayContaining([releaseVictim.id])); + + // Close retries the failed directory removal exactly once while the still-failing release keeps close owned. + const closing = session.close(); + expect(session.close()).toBe(closing); + await expect(closing).rejects.toMatchObject({ + message: 'RSC runtime session close failed; cleanup failures: run-artifact.', + }); + await expect(closing).rejects.not.toThrow('do-not-expose-eviction-release-secret'); + expect(failedReleaseAttempts).toBeGreaterThan(1); + expect(removalVictimReleaseAttempts).toBe(1); + expect(removalVictimRemovalAttempts).toBe(2); } finally { + releaseReader.resolve(); await session.close().catch(() => undefined); await rm(storageRoot, { force: true, recursive: true }); } -}, 45_000); +}, 240_000); test('retains a failed invocation Flight artifact until its explicit session-close release succeeds', async () => { const storageRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-session-artifact-release-')); @@ -1811,200 +1906,6 @@ test('retains a failed invocation Flight artifact until its explicit session-clo } }, 45_000); -test('keeps the oldest artifact and terminal history owned when eviction release fails', async () => { - const storageRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-session-eviction-release-')); - const projectRoot = process.cwd(); - const prepared = await new ProjectService({ includeDevRuntime: true, mode: 'development', root: projectRoot }).prepare('dev'); - let firstRunId: string | undefined; - let failedReleaseAttempts = 0; - const session = await RsbuildRuntimeSession.start({ - artifactStatus: () => Object.freeze({ state: 'missing' as const }), - emit: () => undefined, - environment: Object.freeze({}), - projectRoot, - preparedRuntime: prepared.devRuntime!, - providerSessionId: 'session-eviction-release-test', - signal: new AbortController().signal, - storageRoot, - }, { - beforeRunArtifactRelease: ({ runId }: Readonly<{ readonly runId: string }>) => { - if (runId !== firstRunId) return; - failedReleaseAttempts += 1; - throw new Error('do-not-expose-eviction-release-secret'); - }, - }); - - try { - await waitFor(() => session.status().activeVector !== undefined, 'Timed out waiting for an active runtime generation', 15_000); - const generationId = session.status().activeVector!.runtimeGenerationId; - const target = session.surfaces().find((surface) => surface.id === 'mcp.runtime_status')!.targets[0]!; - const request = { - expectedGenerationId: generationId, - input: {}, - surfaceId: 'mcp.runtime_status', - target, - } as const; - const first = await session.invoke(request); - if (first.status !== 'succeeded') throw new Error(JSON.stringify(first.diagnostics)); - firstRunId = first.id; - - for (let index = 0; index < 49; index += 1) { - await expect(session.invoke(request)).resolves.toMatchObject({ status: 'succeeded' }); - } - await expect(session.invoke(request)).rejects.toThrow('RSC runtime run artifact cleanup failed; cleanup failures: run-artifact.'); - - expect(failedReleaseAttempts).toBeGreaterThan(0); - expect(session.run(first.id)).toEqual(first); - await expect(session.readRunFlight(first.id)).resolves.toMatchObject({ body: expect.any(Buffer) }); - expect(await readdir(join(storageRoot, 'runs'))).toEqual(expect.arrayContaining([first.id])); - - const closing = session.close(); - expect(session.close()).toBe(closing); - await expect(closing).rejects.toMatchObject({ - message: 'RSC runtime session close failed; cleanup failures: run-artifact.', - }); - await expect(closing).rejects.not.toThrow('do-not-expose-eviction-release-secret'); - expect(failedReleaseAttempts).toBeGreaterThan(1); - } finally { - await session.close().catch(() => undefined); - await rm(storageRoot, { force: true, recursive: true }); - } -}, 60_000); - -test('reserves an evicting terminal run before draining its admitted Flight readers', async () => { - const storageRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-session-eviction-reader-')); - const projectRoot = process.cwd(); - const prepared = await new ProjectService({ includeDevRuntime: true, mode: 'development', root: projectRoot }).prepare('dev'); - const readerEntered = deferred(); - const releaseReader = deferred(); - const evictionReserved = deferred(); - let firstRunId: string | undefined; - let holdFirstReader = false; - let firstReaderAdmissions = 0; - const session = await RsbuildRuntimeSession.start({ - artifactStatus: () => Object.freeze({ state: 'missing' as const }), - emit: () => undefined, - environment: Object.freeze({}), - projectRoot, - preparedRuntime: prepared.devRuntime!, - providerSessionId: 'session-eviction-reader-test', - signal: new AbortController().signal, - storageRoot, - }, { - afterRunArtifactEvictionReserved: ({ runId }: Readonly<{ readonly runId: string }>) => { - if (runId === firstRunId) evictionReserved.resolve(); - }, - beforeRunFlightRead: async ({ runId }: Readonly<{ readonly runId: string }>) => { - if (!holdFirstReader || runId !== firstRunId) return; - firstReaderAdmissions += 1; - if (firstReaderAdmissions !== 1) return; - readerEntered.resolve(); - await releaseReader.promise; - }, - }); - - try { - await waitFor(() => session.status().activeVector !== undefined, 'Timed out waiting for an active runtime generation', 15_000); - const generationId = session.status().activeVector!.runtimeGenerationId; - const target = session.surfaces().find((surface) => surface.id === 'mcp.runtime_status')!.targets[0]!; - const request = { - expectedGenerationId: generationId, - input: {}, - surfaceId: 'mcp.runtime_status', - target, - } as const; - const first = await session.invoke(request); - if (first.status !== 'succeeded') throw new Error(JSON.stringify(first.diagnostics)); - firstRunId = first.id; - - holdFirstReader = true; - const admittedReader = session.readRunFlight(first.id); - await readerEntered.promise; - for (let index = 0; index < 49; index += 1) await expect(session.invoke(request)).resolves.toMatchObject({ status: 'succeeded' }); - - const evicting = session.invoke(request); - await evictionReserved.promise; - await expect(session.readRunFlight(first.id)).resolves.toBeUndefined(); - expect(firstReaderAdmissions).toBe(1); - - releaseReader.resolve(); - await expect(admittedReader).resolves.toMatchObject({ body: expect.any(Buffer) }); - await expect(evicting).resolves.toMatchObject({ status: 'succeeded' }); - await expect(session.readRunFlight(first.id)).resolves.toBeUndefined(); - } finally { - releaseReader.resolve(); - await session.close().catch(() => undefined); - await rm(storageRoot, { force: true, recursive: true }); - } -}, 90_000); - -test('finalizes successful history before a failed evicted run-directory removal', async () => { - const storageRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-session-eviction-directory-')); - const projectRoot = process.cwd(); - const prepared = await new ProjectService({ includeDevRuntime: true, mode: 'development', root: projectRoot }).prepare('dev'); - let firstRunId: string | undefined; - let failFirstDirectoryRemoval = true; - let firstArtifactReleaseAttempts = 0; - let firstDirectoryRemovalAttempts = 0; - const session = await RsbuildRuntimeSession.start({ - artifactStatus: () => Object.freeze({ state: 'missing' as const }), - emit: () => undefined, - environment: Object.freeze({}), - projectRoot, - preparedRuntime: prepared.devRuntime!, - providerSessionId: 'session-eviction-directory-test', - signal: new AbortController().signal, - storageRoot, - }, { - beforeRunArtifactRelease: ({ runId }: Readonly<{ readonly runId: string }>) => { - if (runId === firstRunId) firstArtifactReleaseAttempts += 1; - }, - beforeRunDirectoryRemoval: ({ runId }: Readonly<{ readonly runId: string }>) => { - if (runId === firstRunId) firstDirectoryRemovalAttempts += 1; - if (failFirstDirectoryRemoval && runId === firstRunId) { - failFirstDirectoryRemoval = false; - throw new Error('do-not-expose-evicted-run-directory-removal-secret'); - } - }, - }); - - try { - await waitFor(() => session.status().activeVector !== undefined, 'Timed out waiting for an active runtime generation', 15_000); - const generationId = session.status().activeVector!.runtimeGenerationId; - const target = session.surfaces().find((surface) => surface.id === 'mcp.runtime_status')!.targets[0]!; - const request = { - expectedGenerationId: generationId, - input: {}, - surfaceId: 'mcp.runtime_status', - target, - } as const; - const first = await session.invoke(request); - if (first.status !== 'succeeded') throw new Error(JSON.stringify(first.diagnostics)); - firstRunId = first.id; - - for (let index = 0; index < 49; index += 1) await expect(session.invoke(request)).resolves.toMatchObject({ status: 'succeeded' }); - const evictionFailure = await session.invoke(request); - - expect(evictionFailure).toMatchObject({ - diagnostics: [expect.objectContaining({ message: 'RSC runtime run artifact cleanup failed; cleanup failures: run-artifact.' })], - status: 'failed', - }); - expect(evictionFailure.status === 'failed' && evictionFailure.diagnostics[0]!.message) - .not.toContain('do-not-expose-evicted-run-directory-removal-secret'); - expect(session.run(first.id)).toBeUndefined(); - await expect(session.readRunFlight(first.id)).resolves.toBeUndefined(); - expect(await readdir(join(storageRoot, 'runs'))).toEqual(expect.arrayContaining([first.id])); - expect(firstArtifactReleaseAttempts).toBe(1); - - await expect(session.close()).resolves.toBeUndefined(); - expect(firstArtifactReleaseAttempts).toBe(1); - expect(firstDirectoryRemovalAttempts).toBe(2); - } finally { - await session.close().catch(() => undefined); - await rm(storageRoot, { force: true, recursive: true }); - } -}, 60_000); - test('rejects a fifth blocked generation worker and settles every leased worker on close', async () => { const storageRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-session-bound-')); const projectRoot = process.cwd(); diff --git a/package.json b/package.json index fb5b80d3d..affa5416a 100644 --- a/package.json +++ b/package.json @@ -12,8 +12,8 @@ "lint:package": "publint packages/agent-bundle", "test": "pnpm test:unit && pnpm test:integration", "test:unit": "rstest --config rstest.unit.config.ts", - "test:integration": "pnpm --filter agent-bundle-workbench build && pnpm test:integration:run", - "test:integration:run": "AGENT_BUNDLE_WORKBENCH_PREBUILT=1 rstest --config rstest.integration.config.ts --pool.maxWorkers 1", + "test:integration": "pnpm build && pnpm test:integration:run", + "test:integration:run": "AGENT_BUNDLE_WORKBENCH_PREBUILT=1 AGENT_BUNDLE_PACKAGE_PREBUILT=1 rstest --config rstest.integration.config.ts && AGENT_BUNDLE_WORKBENCH_PREBUILT=1 AGENT_BUNDLE_PACKAGE_PREBUILT=1 rstest --config rstest.integration-serial.config.ts", "test:watch": "rstest --config rstest.config.ts --watch", "lint": "rslint .", "typecheck": "tsc --noEmit && tsc --project packages/workbench/tsconfig.json", diff --git a/packages/agent-bundle/tests/cli.test.ts b/packages/agent-bundle/tests/cli.test.ts index 9fe2f5436..a858d0b64 100644 --- a/packages/agent-bundle/tests/cli.test.ts +++ b/packages/agent-bundle/tests/cli.test.ts @@ -15,6 +15,7 @@ const cliPath = join(packageRoot, 'dist/cli.js'); let buildPackage: Promise | undefined; const buildCliPackage = async (): Promise => { + if (process.env['AGENT_BUNDLE_PACKAGE_PREBUILT'] === '1') return; buildPackage ??= execFile('pnpm', ['build'], { cwd: workspaceRoot }).then(() => undefined); await buildPackage; }; diff --git a/packages/agent-bundle/tests/support/time-scale.ts b/packages/agent-bundle/tests/support/time-scale.ts index cf6e3d663..7ba558ada 100644 --- a/packages/agent-bundle/tests/support/time-scale.ts +++ b/packages/agent-bundle/tests/support/time-scale.ts @@ -4,5 +4,12 @@ * processes, and rsbuild compiles inside a single test. Scaling the budgets * 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 + * machines, where concurrent Chrome + dev-server + rsbuild pairs share cores. */ -export const timeScale = process.env['CI'] === undefined ? 1 : 4; +const localScale = Number(process.env['AGENT_BUNDLE_TEST_TIME_SCALE'] ?? ''); +export const timeScale = process.env['CI'] !== undefined + ? 4 + : Number.isSafeInteger(localScale) && localScale >= 1 ? localScale : 1; diff --git a/packages/workbench/tests/evals-real.e2e.test.ts b/packages/workbench/tests/evals-real.e2e.test.ts index 2634082c0..9ac8c2460 100644 --- a/packages/workbench/tests/evals-real.e2e.test.ts +++ b/packages/workbench/tests/evals-real.e2e.test.ts @@ -14,11 +14,12 @@ import { createProjectFixture, removeProjectFixture } from '../../agent-bundle/t import { seedEvalProject, writeEvalSuite } from '../../agent-bundle/tests/support/eval-project.ts'; import { readFinalizedEvalRun } from '../src/evals/evals-page.tsx'; import { closeServer } from './support/http.ts'; +import { timeScale } from '../../agent-bundle/tests/support/time-scale.ts'; import { workbenchBrowserAliases } from './support/workbench-browser-modules.ts'; import { buildWorkbench, e2e, workbenchAssets, workspaceRoot, workbenchUrl } from './support/workbench-e2e.ts'; const evalsPage = join(workspaceRoot, 'packages', 'workbench', 'src', 'evals', 'evals-page.tsx'); -const browserTimeout = 12_000; +const browserTimeout = 12_000 * timeScale; const runCompletionTimeout = 60_000; e2e('retries a terminal canonical read until the durable run finalization is visible', async () => { diff --git a/packages/workbench/tests/examples-real.e2e.test.ts b/packages/workbench/tests/examples-real.e2e.test.ts index 7699d2451..44c13dd80 100644 --- a/packages/workbench/tests/examples-real.e2e.test.ts +++ b/packages/workbench/tests/examples-real.e2e.test.ts @@ -14,9 +14,10 @@ import { waitForSettledWorkbench, writeExampleReport, } from './support/example-acceptance.ts'; +import { timeScale } from '../../agent-bundle/tests/support/time-scale.ts'; import { buildWorkbench, e2e, workbenchAssets, workbenchUrl } from './support/workbench-e2e.ts'; -const browserTimeout = 15_000; +const browserTimeout = 15_000 * timeScale; const waitForExampleValue = async ( page: Parameters[0], diff --git a/packages/workbench/tests/logs-real.e2e.test.ts b/packages/workbench/tests/logs-real.e2e.test.ts index 08f2ebcae..351025cc0 100644 --- a/packages/workbench/tests/logs-real.e2e.test.ts +++ b/packages/workbench/tests/logs-real.e2e.test.ts @@ -5,9 +5,10 @@ import { expect } from '@rstest/playwright'; import { createWorkbenchAssetSource } from '../../agent-bundle/src/dev/workbench-assets.ts'; import { startDevServer } from '../../agent-bundle/src/dev/workbench-server.ts'; import { createProjectFixture, removeProjectFixture } from '../../agent-bundle/tests/helpers/project-fixture.ts'; +import { timeScale } from '../../agent-bundle/tests/support/time-scale.ts'; import { buildWorkbench, e2e, workbenchAssets, workbenchUrl } from './support/workbench-e2e.ts'; -const browserTimeout = 12_000; +const browserTimeout = 12_000 * timeScale; e2e('shows real producer logs with replay, filters, redaction, responsive layout, and no browser errors', { timeout: 90_000 }, async ({ page }) => { await buildWorkbench(); diff --git a/packages/workbench/tests/mcp-app-real.e2e.test.ts b/packages/workbench/tests/mcp-app-real.e2e.test.ts index e4611ecd9..8c4d783c8 100644 --- a/packages/workbench/tests/mcp-app-real.e2e.test.ts +++ b/packages/workbench/tests/mcp-app-real.e2e.test.ts @@ -1,7 +1,5 @@ -import { execFile as executeFile } from 'node:child_process'; import { access, mkdir, readFile, symlink, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; -import { promisify } from 'node:util'; import { expect, test, type PlaywrightOptions } from '@rstest/playwright'; import type { Page, WebSocketRoute } from 'playwright'; @@ -12,12 +10,11 @@ import { startDevServer } from '../../agent-bundle/src/dev/workbench-server.ts'; import { createProjectFixture, removeProjectFixture } from '../../agent-bundle/tests/helpers/project-fixture.ts'; import { startRuntimePlaygroundFixture } from './helpers/runtime-playground-fixture.ts'; import { timeScale } from '../../agent-bundle/tests/support/time-scale.ts'; -import { workbenchUrl } from './support/workbench-e2e.ts'; +import { buildWorkbench, workbenchUrl } from './support/workbench-e2e.ts'; const workspaceRoot = process.cwd(); const workbenchAssets = join(workspaceRoot, 'packages', 'workbench', 'dist'); const browserTimeout = 8_000 * timeScale; -const execFile = promisify(executeFile); const e2e = test.extend({ playwright: { @@ -26,14 +23,6 @@ const e2e = test.extend({ } satisfies PlaywrightOptions, }); -const buildWorkbench = async (): Promise => { - const { RSTEST: _rstest, ...environment } = process.env; - await execFile('pnpm', ['--filter', 'agent-bundle-workbench', 'build'], { - cwd: workspaceRoot, - env: { ...environment, NODE_ENV: 'production' }, - }); -}; - const appFixtureHtml = [ '
waiting
', '