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
5 changes: 5 additions & 0 deletions .changeset/support-rstest-012.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"agent-bundle": patch
---

Support Rstest 0.12 in `agent-bundle/rstest` and align the MCP App example's browser and core packages. (#868)
5 changes: 2 additions & 3 deletions examples/mcp-app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,8 @@
},
"devDependencies": {
"@agent-bundle/runtime": "workspace:*",
"@rstest/browser": "0.11.12",
"@rstest/core": "0.11.12",
"@rstest/playwright": "0.11.12",
"@rstest/browser": "0.12.2",
"@rstest/core": "0.12.2",
"@types/react": "19.3.0",
"agent-bundle": "workspace:*",
"playwright": "1.63.0",
Expand Down
2 changes: 1 addition & 1 deletion packages/agent-bundle/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@
},
"peerDependencies": {
"@agent-bundle/runtime": ">=0.0.0 <1",
"@rstest/core": "^0.11.10",
"@rstest/core": "^0.11.10 || ^0.12.0",
"react": "^19.2.0"
},
"peerDependenciesMeta": {
Expand Down
26 changes: 20 additions & 6 deletions packages/workbench/tests/packed-release.e2e.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { spawn, type ChildProcess } from 'node:child_process';
import { once } from 'node:events';
import { access, chmod, cp, mkdir, mkdtemp, readFile, readdir, realpath, writeFile } from 'node:fs/promises';
import { createServer } from 'node:net';
import { tmpdir } from 'node:os';
Expand Down Expand Up @@ -70,6 +71,17 @@ const e2e = test.extend({
const isAppRoute = (url: URL): boolean =>
url.pathname.startsWith('/api/mcp/apps/') || /^\/api\/mcp\/sessions\/[^/]+\/apps$/u.test(url.pathname);

test('treats a signal-terminated packed server as already closed', async () => {
const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1_000)'], { stdio: 'ignore' });
await once(child, 'spawn');
const exited = once(child, 'exit');
child.kill('SIGTERM');
await exited;
expect(child.signalCode).toBe('SIGTERM');
await expect(closeChild(child)).resolves.toBeUndefined();
await expect(awaitReady('http://127.0.0.1:0', child, () => '')).rejects.toThrow('exited before readiness');
});

e2e('runs every Agent API tool from the installed tarball', { timeout: 360_000 * timeScale }, async ({ page }) => {
const { packOutput, tarball } = await sharedPackedTarball('agent-bundle');
const packedAgentBundleIdentity = `${packOutput.name}@${packOutput.version}`;
Expand Down Expand Up @@ -722,18 +734,20 @@ e2e('runs every Agent API tool from the installed tarball', { timeout: 360_000 *
for (const processId of await descendantProcessIds(stoppedChild.pid)) trackedProcessIds.add(processId);
}
const outageStartedAt = Date.now();
const recoveredBrowserSession = page.waitForResponse((response) =>
response.url() === `${origin}/api/project/session` && response.request().method() === 'GET' && response.ok(),
);
await closeChild(stoppedChild);
phase = 'foreground restart/reconnect disconnected state';
await expectHeading(page, 'Foreground connection unavailable');
await expect(page.getByText('Waiting for the foreground server to recover.')).toBeVisible({ timeout: browserTimeout });
phase = 'foreground restart/reconnect browser recovery';
const recoveredBrowserSession = page.waitForResponse((response) =>
response.url() === `${origin}/api/project/session` && response.request().method() === 'GET' && response.ok(),
);
child = startInstalledServer(port);
await awaitReady(origin, child, () => commandOutput);
await expect(page.getByTestId('shell-connection')).toContainText('Foreground server connected', { timeout: browserTimeout });
const recoveredBrowserSessionResponse = await recoveredBrowserSession;
const [recoveredBrowserSessionResponse] = await Promise.all([
recoveredBrowserSession,
awaitReady(origin, child, () => commandOutput),
expect(page.getByTestId('shell-connection')).toContainText('Foreground server connected', { timeout: browserTimeout }),
]);
const recoveredBrowserSessionPayload = record(await recoveredBrowserSessionResponse.json(), 'recovered browser session');
const browserGenerationBToken = string(recoveredBrowserSessionPayload.token, 'recovered browser session token');
const recoveredBrowserSessionRequest = browserRequestByPlaywrightRequest.get(recoveredBrowserSessionResponse.request());
Expand Down
17 changes: 10 additions & 7 deletions packages/workbench/tests/support/packed-release-harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,21 @@ import { chmod, mkdir, writeFile } from 'node:fs/promises';
import { relative, isAbsolute, join } from 'node:path';
import { promisify } from 'node:util';

import { timeScale } from '../../../agent-bundle/tests/support/time-scale.ts';

export { installedEnvironment } from '../../../agent-bundle/tests/support/shared-pack.ts';

export const execFile = promisify(executeFile);
export const workspaceRoot = process.cwd();
const packedServerStartupBudget = 45_000;
const hasExited = (child: ChildProcess): boolean => child.exitCode !== null || child.signalCode !== null;

export const awaitReady = async (origin: string, child: ChildProcess, output: () => string): Promise<void> => {
const startedAt = Date.now();
const diagnostics = (): string =>
`after ${String(Date.now() - startedAt)}ms (PID ${String(child.pid ?? 'unknown')}): ${output()}`;
while (Date.now() - startedAt < packedServerStartupBudget) {
if (child.exitCode !== null) throw new Error(`The packed dev server exited before readiness ${diagnostics()}`);
if (hasExited(child)) throw new Error(`The packed dev server exited before readiness ${diagnostics()}`);
try {
if ((await fetch(origin)).ok) return;
} catch {
Expand All @@ -26,7 +29,7 @@ export const awaitReady = async (origin: string, child: ChildProcess, output: ()
};

const childExitedWithin = (child: ChildProcess, timeoutMs: number): Promise<boolean> => {
if (child.exitCode !== null) return Promise.resolve(true);
if (hasExited(child)) return Promise.resolve(true);
return new Promise((resolvePromise, rejectPromise) => {
const finish = (exited: boolean): void => {
clearTimeout(timeout);
Expand All @@ -43,19 +46,19 @@ const childExitedWithin = (child: ChildProcess, timeoutMs: number): Promise<bool
child.once('exit', onExit);
child.once('error', onError);
const timeout = setTimeout(() => { finish(false); }, timeoutMs);
if (child.exitCode !== null) finish(true);
if (hasExited(child)) finish(true);
});
};

export const closeChild = async (child: ChildProcess): Promise<void> => {
if (child.exitCode !== null) return;
if (hasExited(child)) return;
const signalAndWait = async (signal: NodeJS.Signals): Promise<boolean> => {
if (child.exitCode !== null) return true;
if (hasExited(child)) return true;
if (!child.kill(signal)) {
if (child.exitCode !== null) return true;
if (hasExited(child)) return true;
throw new Error(`The packed dev server could not receive ${signal}.`);
}
return childExitedWithin(child, 5_000);
return childExitedWithin(child, 5_000 * timeScale);
};
const closeFailures: unknown[] = [];
try {
Expand Down
Loading
Loading