Files
nexu-io__open-design/e2e/tests/playwright-runtime-lifecycle.test.ts
Marc Chan a1995a186a test(e2e): harden UI flake waits with real completion signals (#6539)
* test(e2e): harden UI flake waits with real completion signals

Replace synthetic /projects routing, blind boot sleeps, and call-site
create retries with observable readiness: real navigation, suite daemon
health warmup, expect.poll, clock fast-forward for coalesce TTL, and
quiescence for AMR refresh traffic.

* test(e2e): raise Playwright fixture timeout for daemon warmup

The suite fixture now polls daemon /api/health for up to 60s, which
consumed the prior 390s budget's bookkeeping margin. Raise the worker
fixture timeout to 450s so start + web/daemon warmup + stop still leave
a full minute for teardown bookkeeping on slow CI workers.

Generated-By: looper 0.11.8 (runner=fixer, agent=grok-build)

* test(e2e): fail closed on health body drain and narrow billing mock

Require warmPlaywrightDaemonRuntime to fully consume ok health bodies
before declaring readiness, drain non-ok probes, and fulfill only the
exact /api/workspace/billing path so nested billing routes fall through.

Generated-By: looper 0.11.8 (runner=fixer, agent=grok-build)

* test(e2e): reopen projects by id in cross-project file-tab restore

Avoid relying on chrome workspace tabs surviving leave+create hard
navigation; assert per-project file-tab restore via project routes.
2026-08-07 10:37:59 +08:00

215 lines
6.9 KiB
TypeScript

// @vitest-environment node
import { describe, expect, test, vi } from 'vitest';
import {
initializePlaywrightRunNamespace,
PLAYWRIGHT_RUN_NAMESPACE_ENV,
resolvePlaywrightSlotNamespace,
} from '@/playwright/runtime-identity';
import {
warmPlaywrightDaemonRuntime,
warmPlaywrightWebRuntime,
} from '@/playwright/runtime-lifecycle';
import type { RunToolsDevJsonOptions } from '@/tools-dev/cli';
import { createToolsDevSuite } from '@/tools-dev/runtime';
import type { ToolsDevRuntimeDependencies } from '@/tools-dev/runtime';
import type { ToolsDevSuiteSpec } from '@/tools-dev/types';
describe('Playwright tools-dev runtime lifecycle', () => {
test('keeps a replacement worker on the same logical parallel slot', () => {
const env: Record<string, string | undefined> = {};
expect(initializePlaywrightRunNamespace(env, () => 'run-token')).toBe('playwright-run-token');
expect(env[PLAYWRIGHT_RUN_NAMESPACE_ENV]).toBe('playwright-run-token');
expect(resolvePlaywrightSlotNamespace(0, env)).toBe('playwright-run-token-w0');
expect(resolvePlaywrightSlotNamespace(0, env)).toBe('playwright-run-token-w0');
expect(resolvePlaywrightSlotNamespace(1, env)).toBe('playwright-run-token-w1');
});
test('uses the explicit namespace as a prefix while keeping concurrent runs isolated', () => {
const env = {
OD_E2E_NAMESPACE: 'focused route stress',
};
expect(initializePlaywrightRunNamespace(env, () => 'run-token')).toBe(
'focused-route-stress-run-token',
);
expect(resolvePlaywrightSlotNamespace(2, env)).toBe('focused-route-stress-run-token-w2');
});
test('preserves an inherited exact run namespace across Playwright processes', () => {
const env = {
OD_E2E_NAMESPACE: 'ignored-prefix',
[PLAYWRIGHT_RUN_NAMESPACE_ENV]: 'generated-run',
};
expect(initializePlaywrightRunNamespace(env, () => 'unused')).toBe('generated-run');
expect(resolvePlaywrightSlotNamespace(2, env)).toBe('generated-run-w2');
});
test('clears the previous slot incarnation before allocating ports and passes its owner pid', async () => {
const calls: Array<{ args: string[]; timeoutMs: number | undefined }> = [];
const order: string[] = [];
const spec: ToolsDevSuiteSpec = {
codexHomeDir: '/tmp/codex-home',
dataDir: '/tmp/data',
namespace: 'playwright-run-w0',
ownerPid: 4242,
root: '/tmp/root',
toolsDevRoot: '/tmp/tools-dev',
};
const runJson: ToolsDevRuntimeDependencies['runJson'] = async <T>(
_workspaceRoot: string,
_suite: ToolsDevSuiteSpec,
args: string[],
_env: Record<string, string | undefined> = {},
options?: RunToolsDevJsonOptions,
): Promise<T> => {
calls.push({ args, timeoutMs: options?.timeoutMs });
order.push(args[0] === 'stop' ? 'stop' : 'start');
if (args[0] === 'start') {
return {
daemon: { status: { url: 'http://127.0.0.1:31001' } },
web: { status: { url: 'http://127.0.0.1:31002' } },
} as T;
}
return {} as T;
};
const suite = createToolsDevSuite(spec, {
allocatePorts: async () => {
order.push('allocate');
return {
daemonPort: 31001,
webPort: 31002,
release: async () => {
order.push('release');
},
};
},
runJson,
});
await suite.startWeb();
expect(order).toEqual(['stop', 'allocate', 'release', 'start']);
expect(calls[0]?.args.slice(0, 2)).toEqual(['stop', 'web']);
expect(calls[0]?.timeoutMs).toBe(30_000);
expect(calls[1]?.args).toContain('--parent-pid');
expect(calls[1]?.args).toContain('4242');
expect(calls[1]?.timeoutMs).toBe(180_000);
});
test('aborts a blocked cold warmup before the fixture teardown budget is consumed', async () => {
const blockedFetch = vi.fn((_url: string, init?: RequestInit) =>
new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener('abort', () => reject(init.signal?.reason), { once: true });
}));
await expect(
warmPlaywrightWebRuntime('http://127.0.0.1:31002/', {
fetch: blockedFetch,
timeoutMs: 10,
}),
).rejects.toThrow('Playwright web warmup timed out after 10ms');
});
test('polls daemon health until the first ok response', async () => {
let calls = 0;
const fetchRuntime = vi.fn(async () => {
calls += 1;
if (calls < 3) {
return new Response('starting', { status: 503, statusText: 'Service Unavailable' });
}
return new Response(JSON.stringify({ ok: true }), {
status: 200,
statusText: 'OK',
headers: { 'content-type': 'application/json' },
});
});
await warmPlaywrightDaemonRuntime('http://127.0.0.1:31001/api/health', {
fetch: fetchRuntime,
intervalMs: 1,
timeoutMs: 1_000,
});
expect(calls).toBe(3);
});
test('times out when daemon health never becomes ready', async () => {
const fetchRuntime = vi.fn(async () => {
throw new Error('connection refused');
});
await expect(
warmPlaywrightDaemonRuntime('http://127.0.0.1:31001/api/health', {
fetch: fetchRuntime,
intervalMs: 1,
timeoutMs: 20,
}),
).rejects.toThrow('Playwright daemon warmup timed out after 20ms');
});
test('retries when an ok health response body cannot be drained', async () => {
let calls = 0;
const fetchRuntime = vi.fn(async () => {
calls += 1;
if (calls === 1) {
return {
ok: true,
status: 200,
statusText: 'OK',
arrayBuffer: async () => {
throw new Error('body truncated');
},
} as unknown as Response;
}
return new Response(JSON.stringify({ ok: true }), {
status: 200,
statusText: 'OK',
headers: { 'content-type': 'application/json' },
});
});
await warmPlaywrightDaemonRuntime('http://127.0.0.1:31001/api/health', {
fetch: fetchRuntime,
intervalMs: 1,
timeoutMs: 1_000,
});
expect(calls).toBe(2);
});
test('drains non-ok health bodies before the next probe', async () => {
let calls = 0;
let drained503 = false;
const fetchRuntime = vi.fn(async () => {
calls += 1;
if (calls === 1) {
return {
ok: false,
status: 503,
statusText: 'Service Unavailable',
arrayBuffer: async () => {
drained503 = true;
return new ArrayBuffer(0);
},
} as unknown as Response;
}
return new Response(JSON.stringify({ ok: true }), {
status: 200,
statusText: 'OK',
headers: { 'content-type': 'application/json' },
});
});
await warmPlaywrightDaemonRuntime('http://127.0.0.1:31001/api/health', {
fetch: fetchRuntime,
intervalMs: 1,
timeoutMs: 1_000,
});
expect(calls).toBe(2);
expect(drained503).toBe(true);
});
});