Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 13 additions & 4 deletions examples/rsc-agent-runtime/src/dev/rsbuild-runtime-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -603,6 +604,12 @@ export interface RsbuildRuntimeSessionStartTesting {
}>) => Promise<void> | 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;
}

/**
Expand Down Expand Up @@ -633,6 +640,7 @@ export class RsbuildRuntimeSession implements DevRuntimeSession {
readonly #surfaceAssetApps = new Map<string, DevRuntimePreparedProject['apps'][number]>();
readonly #surfaces = new Map<string, DevRuntimeSurface>();
readonly #testing: RsbuildRuntimeSessionStartTesting;
readonly #maximumRunHistory: number;
readonly #attempts = new Map<string, AttemptBarrier>();
readonly #workers = new Map<string, InvocationWorker>();
readonly #failedAttempts = new Set<string>();
Expand Down Expand Up @@ -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`);
Expand Down Expand Up @@ -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));
}
Expand Down Expand Up @@ -1333,7 +1342,7 @@ export class RsbuildRuntimeSession implements DevRuntimeSession {
}

async #evictTerminalRuns(): Promise<void> {
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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Buffer> =>
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions packages/agent-bundle/tests/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const cliPath = join(packageRoot, 'dist/cli.js');
let buildPackage: Promise<void> | undefined;

const buildCliPackage = async (): Promise<void> => {
if (process.env['AGENT_BUNDLE_PACKAGE_PREBUILT'] === '1') return;
buildPackage ??= execFile('pnpm', ['build'], { cwd: workspaceRoot }).then(() => undefined);
await buildPackage;
};
Expand Down
9 changes: 8 additions & 1 deletion packages/agent-bundle/tests/support/time-scale.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
3 changes: 2 additions & 1 deletion packages/workbench/tests/evals-real.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> => {
Expand Down
3 changes: 2 additions & 1 deletion packages/workbench/tests/examples-real.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <Value>(
page: Parameters<typeof captureExampleState>[0],
Expand Down
3 changes: 2 additions & 1 deletion packages/workbench/tests/logs-real.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
13 changes: 1 addition & 12 deletions packages/workbench/tests/mcp-app-real.e2e.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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: {
Expand All @@ -26,14 +23,6 @@ const e2e = test.extend({
} satisfies PlaywrightOptions,
});

const buildWorkbench = async (): Promise<void> => {
const { RSTEST: _rstest, ...environment } = process.env;
await execFile('pnpm', ['--filter', 'agent-bundle-workbench', 'build'], {
cwd: workspaceRoot,
env: { ...environment, NODE_ENV: 'production' },
});
};

const appFixtureHtml = [
'<!doctype html><html><body><main data-testid="app-state">waiting</main>',
'<script>',
Expand Down
15 changes: 3 additions & 12 deletions packages/workbench/tests/overview.e2e.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
import { execFile as executeFile } from 'node:child_process';
import { mkdir, readFile, rename, 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 { Locator, Page } from 'playwright';
Expand All @@ -20,11 +18,12 @@ import { createWorkbenchAssetSource } from '../../agent-bundle/src/dev/workbench
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 { buildWorkbench } from './support/workbench-e2e.ts';

const execFile = promisify(executeFile);
const workspaceRoot = process.cwd();
const workbenchAssets = join(workspaceRoot, 'packages', 'workbench', 'dist');
const browserTimeout = 15_000;
const browserTimeout = 15_000 * timeScale;

interface RuntimeAppOperation {
readonly body: unknown;
Expand Down Expand Up @@ -54,14 +53,6 @@ const e2e = test.extend({
} satisfies PlaywrightOptions,
});

const buildWorkbench = async (): Promise<void> => {
const { RSTEST: _rstest, ...environment } = process.env;
await execFile('pnpm', ['--filter', 'agent-bundle-workbench', 'build'], {
cwd: workspaceRoot,
env: { ...environment, NODE_ENV: 'production' },
});
};

const startFrozenEpochServer = async (root: string) => {
const registry = createDefaultRegistry();
const epochStore = new EpochStore({ projectRoot: root });
Expand Down
18 changes: 3 additions & 15 deletions packages/workbench/tests/playground-real.e2e.test.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,18 @@
import { execFile as executeFile } from 'node:child_process';
import { chmod, mkdir, symlink, writeFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { promisify } from 'node:util';

import { expect, test, type PlaywrightOptions } from '@rstest/playwright';

import { agentBundleNodeModules, workbenchNodeModules } from '../../agent-bundle/tests/helpers/workspace-paths.ts';
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 { workbenchUrl } from './support/workbench-e2e.ts';
import { timeScale } from '../../agent-bundle/tests/support/time-scale.ts';
import { buildWorkbench, workbenchUrl } from './support/workbench-e2e.ts';

const execFile = promisify(executeFile);
const workspaceRoot = process.cwd();
const workbenchAssets = join(workspaceRoot, 'packages', 'workbench', 'dist');
const browserTimeout = 8_000;
const browserTimeout = 8_000 * timeScale;
const nativePathFallback = `${dirname(process.execPath)}:/usr/bin:/bin`;

const e2e = test.extend({
Expand All @@ -24,16 +22,6 @@ const e2e = test.extend({
} satisfies PlaywrightOptions,
});

let workbenchBuild: Promise<void> | undefined;

const buildWorkbench = (): Promise<void> => workbenchBuild ??= (async (): Promise<void> => {
const { RSTEST: _rstest, ...environment } = process.env;
await execFile('pnpm', ['--filter', 'agent-bundle-workbench', 'build'], {
cwd: workspaceRoot,
env: { ...environment, NODE_ENV: 'production' },
});
})();

const writeFakeClaude = async (directory: string): Promise<void> => {
const executable = join(directory, 'claude');
const implementation = join(directory, 'claude.mjs');
Expand Down
3 changes: 2 additions & 1 deletion packages/workbench/tests/runtime-playground.e2e.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { expect, test, type PlaywrightOptions } from '@rstest/playwright';

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';

const browserTimeout = 12_000;
const browserTimeout = 12_000 * timeScale;

const e2e = test.extend({
playwright: {
Expand Down
1 change: 1 addition & 0 deletions packages/workbench/tests/support/packed-release-harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export const availablePort = async (): Promise<number> => {
};

export const buildPackage = (): Promise<void> => builtPackage ??= (async (): Promise<void> => {
if (process.env['AGENT_BUNDLE_PACKAGE_PREBUILT'] === '1') return;
const { RSTEST: _rstest, ...environment } = process.env;
await execFile('pnpm', ['build'], {
cwd: workspaceRoot,
Expand Down
16 changes: 16 additions & 0 deletions rstest.integration-serial.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { defineConfig } from '@rstest/core';

import { serialIntegrationTestFiles } from './rstest.integration-tests.ts';
import { withAgentBundleRslibConfig } from './rstest.rslib.ts';

/**
* Integration files that rewrite workspace-shared artifacts (see the
* serialIntegrationTestFiles doc in rstest.integration-tests.ts). One worker
* only: they rebuild or repack shared package dist directories that every
* other file in this group also reads.
*/
export default defineConfig({
extends: withAgentBundleRslibConfig(),
include: [...serialIntegrationTestFiles],
pool: { maxWorkers: 1 },
});
30 changes: 30 additions & 0 deletions rstest.integration-tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,36 @@ export const integrationTestFiles: readonly string[] = [
'packages/workbench/tests/workbench-dev-command.test.ts',
];

/**
* Integration files that WRITE to workspace-shared locations and therefore
* cannot run alongside other integration files:
*
* - inspector-shell.e2e rewrites `packages/workbench/dist` with an explicit
* development-mode artifact (the build itself is under test).
* - packed-release.e2e can run a root `pnpm build` (rewriting
* `packages/{agent-bundle,rsc-runtime,workbench}/dist`) when
* AGENT_BUNDLE_PACKAGE_PREBUILT is unset, and always runs `npm pack`
* plus a packed dev server on a pre-reserved (not ephemeral) port.
*
* They run on one worker via rstest.integration-serial.config.ts after the
* parallel pool finishes (rstest orders files alphabetically, so
* packed-release packs the agent-bundle dist copy that is unaffected by
* inspector-shell's workbench dist rewrite).
*/
export const serialIntegrationTestFiles: readonly string[] = [
'packages/workbench/tests/inspector-shell.e2e.test.ts',
'packages/workbench/tests/packed-release.e2e.test.ts',
];

/**
* Integration files safe on parallel workers: they create per-test fixtures
* with `mkdtemp`, bind servers on ephemeral ports (`port: 0` or rsbuild's
* silent free-port fallback), and only READ the prebuilt shared artifacts
* (`packages/workbench/dist`, `packages/agent-bundle/dist`).
*/
export const parallelIntegrationTestFiles: readonly string[] =
integrationTestFiles.filter((file) => !serialIntegrationTestFiles.includes(file));
Comment thread
ScriptedAlchemy marked this conversation as resolved.

/**
* Pack-and-install tests: each one runs `npm pack` (and usually a clean
* `npm install` of the tarball), which dominates the serialized integration
Expand Down
Loading
Loading