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 be1c11c07..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,7 +1703,11 @@ process.stdout.end(JSON.stringify({ } }, 30_000); -test('drives the fifty-run eviction window through its happy, held-reader, failed-removal, and failed-release paths', async () => { +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'); @@ -1752,6 +1756,7 @@ test('drives the fifty-run eviction window through its happy, held-reader, faile readerEntered.resolve(); await releaseReader.promise; }, + maximumRunHistory: retentionWindow, }); try { @@ -1780,17 +1785,17 @@ test('drives the fifty-run eviction window through its happy, held-reader, faile await session.resetState({ expectedGenerationId: generationId, stateStoreId: 'playground' }); expect(session.run(happyVictim.id)).toEqual(happyVictim); - for (let index = 0; index < 46; index += 1) await invokeSucceeded(); - expect(session.runs(50)).toHaveLength(50); + for (let index = 0; index < retentionWindow - 4; index += 1) await invokeSucceeded(); + expect(session.runs(retentionWindow)).toHaveLength(retentionWindow); - // Happy path: the fifty-first run evicts the oldest completed Flight. + // 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(happyVictim.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; diff --git a/package.json b/package.json index 7b3db36df..04a871f2a 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 e26107228..55b2c6951 100644 --- a/packages/workbench/tests/evals-real.e2e.test.ts +++ b/packages/workbench/tests/evals-real.e2e.test.ts @@ -13,11 +13,12 @@ import { startDevServer } from '../../agent-bundle/src/dev/workbench-server.ts'; import { createProjectFixture, removeProjectFixture } from '../../agent-bundle/tests/helpers/project-fixture.ts'; import { seedEvalProject, writeEvalSuite } from '../../agent-bundle/tests/support/eval-project.ts'; 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; const listen = async (server: Server): Promise => { 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 43aa86789..8ad816c69 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 f2b5c235e..4ba1a8ec6 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
', '