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
78 changes: 45 additions & 33 deletions e2e/lib/playwright/collab-cluster.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,39 +53,18 @@ export async function createCollabCluster(
);
await mkdir(clusterRoot, { recursive: true });

const started: CollabClusterClient[] = [];
let closed = false;
try {
for (const spec of specs) {
const root = join(clusterRoot, sanitizeSegment(spec.id));
const scratchDir = join(root, 'scratch');
await mkdir(scratchDir, { recursive: true });
const runtime = createToolsDevSuite({
codexHomeDir: join(scratchDir, 'codex-home'),
dataDir: join(scratchDir, 'data'),
namespace: `collab-${process.pid}-${testInfo.workerIndex}-${sanitizeSegment(spec.id)}`,
root,
toolsDevRoot: join(scratchDir, 'tools-dev'),
});
let context: BrowserContext | null = null;
try {
await runtime.startWeb(spec.env);
context = await browser.newContext({ baseURL: runtime.url.web() });
const page = await context.newPage();
started.push({ ...spec, context, page, runtime });
} catch (error) {
await context?.close().catch(() => undefined);
try {
await attachRuntimeLogs(runtime, spec, testInfo);
} finally {
await runtime.stopWeb(spec.env).catch(() => undefined);
}
throw error;
}
}
} catch (error) {
const results = await Promise.allSettled(
specs.map((spec) => startClient(browser, testInfo, clusterRoot, spec)),
);
const started = results.flatMap((result) =>
result.status === 'fulfilled' ? [result.value] : []);
const failed = results.find(
(result): result is PromiseRejectedResult => result.status === 'rejected',
);
if (failed) {
await closeStartedClients(started, clusterRoot, testInfo, true);
throw error;
throw failed.reason;
}

return {
Expand All @@ -103,20 +82,53 @@ export async function createCollabCluster(
};
}

async function startClient(
browser: Browser,
testInfo: TestInfo,
clusterRoot: string,
spec: CollabClusterClientSpec,
): Promise<CollabClusterClient> {
const root = join(clusterRoot, sanitizeSegment(spec.id));
const scratchDir = join(root, 'scratch');
await mkdir(scratchDir, { recursive: true });
const runtime = createToolsDevSuite({
codexHomeDir: join(scratchDir, 'codex-home'),
dataDir: join(scratchDir, 'data'),
namespace: `collab-${process.pid}-${testInfo.workerIndex}-${sanitizeSegment(spec.id)}`,
root,
toolsDevRoot: join(scratchDir, 'tools-dev'),
});
let context: BrowserContext | null = null;
try {
await runtime.startWeb(spec.env);
context = await browser.newContext({ baseURL: runtime.url.web() });
const page = await context.newPage();
return { ...spec, context, page, runtime };
} catch (error) {
await context?.close().catch(() => undefined);
try {
await attachRuntimeLogs(runtime, spec, testInfo);
} finally {
await runtime.stopWeb(spec.env).catch(() => undefined);
}
throw error;
}
}

async function closeStartedClients(
clients: readonly CollabClusterClient[],
clusterRoot: string,
testInfo: TestInfo,
preserve: boolean,
): Promise<void> {
for (const client of [...clients].reverse()) {
await Promise.all(clients.map(async (client) => {
await client.context.close().catch(() => undefined);
try {
if (preserve) await attachRuntimeLogs(client.runtime, client, testInfo);
} finally {
await client.runtime.stopWeb(client.env).catch(() => undefined);
}
}
}));
if (!preserve) {
await rm(clusterRoot, { force: true, recursive: true });
}
Expand Down
18 changes: 18 additions & 0 deletions e2e/lib/playwright/mock-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,27 @@ export async function applyStandardMocks(page: Page): Promise<void> {
await applyStorageConfig(page);
await routeMockAgents(page);
await routeAppConfig(page);
await routeSignedOutVelaStatus(page);
await suppressWhatsNew(page);
}

/**
* Keep local-agent scenarios independent from a developer machine's Vela
* session. Workspace-aware project creation treats a signed-in account as
* requiring an exact Workspace authority, so leaking the host login here can
* reject a mocked local-agent create before POST /api/projects is dispatched.
* AMR scenarios register their own status route after this standard fallback.
*/
export async function routeSignedOutVelaStatus(page: Page): Promise<void> {
await page.route('**/api/integrations/vela/status*', async (route) => {
if (route.request().method() !== 'GET') {
await route.fallback();
return;
}
await route.fulfill({ json: { loggedIn: false } });
});
}

/** Keep unrelated release announcements from covering the surface under test. */
export async function suppressWhatsNew(page: Page): Promise<void> {
await page.route('**/api/whats-new', async (route) => {
Expand Down
8 changes: 8 additions & 0 deletions e2e/lib/playwright/suite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,14 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
],
});

/**
* Playwright lifecycle for specs that allocate and release their own isolated
* tools-dev runtimes. Keeping this entrypoint in the suite module preserves
* the UI-test ownership boundary without booting an unused worker runtime in
* addition to the runtimes owned by the spec.
*/
export const clusterTest = base;

export { expect };
export type { PlaywrightToolsDevSuite };

Expand Down
30 changes: 25 additions & 5 deletions e2e/lib/playwright/suites.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,27 +39,44 @@ export const uiP0Groups = {
"ui/workspace-team-interactions.test.ts",
],
},
"entry-automations": {
grep: String.raw`\[P0\]`,
workers: 1,
files: ["ui/automations-page.test.ts"],
},
"project-workspace": {
grep: String.raw`\[P0\]`,
workers: 1,
files: [
"ui/app.test.ts",
"ui/project-management-flows.test.ts",
"ui/workspace-keyboard-flows.test.ts",
],
},
// Keep editor-heavy files on a separate single-worker runtime. Running the
// whole workspace domain serially took 11.6 minutes on CI, while enabling a
// second worker in one job is unsafe because these flows share Workspace
// authority state outside the worker-local daemon. Two runner-isolated jobs
// preserve that boundary and balance the historical file timings.
"project-workspace-editor": {
grep: String.raw`\[P0\]`,
workers: 1,
files: [
"ui/app-design-files.test.ts",
"ui/app-manual-edit.test.ts",
"ui/project-management-flows.test.ts",
"ui/workspace-team-design-system-picker.test.ts",
],
},
// Split out of "project-workspace" (2026-08-04): the two multi-client collab
// specs alone accounted for ~10 of that group's ~26min single-worker wall
// time (workspace-multi-client-collab.test.ts spins up two isolated
// client/daemon runtimes per case). Carving them into their own single-worker
// shard lets both halves run concurrently as separate CI jobs instead of
// serially on one worker, without changing per-shard worker isolation.
// client/daemon runtimes per case). Keep this shard limited to the cluster-
// owned spec so it does not also boot the default worker runtime needed by
// ordinary UI files.
"project-collab": {
grep: String.raw`\[P0\]`,
workers: 1,
files: ["ui/workspace-multi-client-collab.test.ts", "ui/workspace-keyboard-flows.test.ts"],
files: ["ui/workspace-multi-client-collab.test.ts"],
},
"project-runtime": {
grep: String.raw`\[P0\]`,
Expand All @@ -77,7 +94,9 @@ export type UiP0GroupName = keyof typeof uiP0Groups;

export const uiP0CiMatrix = [
{ name: "entry-settings", shard: "entry-settings" },
{ name: "entry-automations", shard: "entry-automations" },
{ name: "project-workspace", shard: "project-workspace" },
{ name: "project-workspace-editor", shard: "project-workspace-editor" },
{ name: "project-collab", shard: "project-collab" },
{ name: "project-runtime", shard: "project-runtime" },
{ name: "workspace-restoration", shard: "workspace-restoration" },
Expand All @@ -97,6 +116,7 @@ const uiP0CoverageFiles = [
"ui/app-manual-edit.test.ts",
"ui/app-restoration.test.ts",
"ui/app.test.ts",
"ui/automations-page.test.ts",
"ui/critical-smoke.test.ts",
"ui/entry-chrome-flows.test.ts",
"ui/entry-configuration-flows.test.ts",
Expand Down
57 changes: 57 additions & 0 deletions e2e/tests/collab-cluster.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,52 @@ afterEach(async () => {
});

describe('createCollabCluster acquisition cleanup', () => {
it('starts and stops isolated client runtimes concurrently', async () => {
const firstRuntime = runtime('first');
const secondRuntime = runtime('second');
const firstStart = deferred<unknown>();
const firstStop = deferred<unknown>();
firstRuntime.startWeb.mockImplementationOnce(() => firstStart.promise);
firstRuntime.stopWeb.mockImplementationOnce(() => firstStop.promise);
runtimeMocks.createToolsDevSuite
.mockReturnValueOnce(firstRuntime)
.mockReturnValueOnce(secondRuntime);

const browser = {
newContext: vi.fn()
.mockResolvedValueOnce(contextWithPage())
.mockResolvedValueOnce(contextWithPage()),
} as unknown as Browser;

const clusterPromise = createCollabCluster(browser, info('passed'), specs());
let startAssertion: unknown;
try {
await vi.waitFor(() => expect(secondRuntime.startWeb).toHaveBeenCalledTimes(1), {
timeout: 1_000,
});
} catch (error) {
startAssertion = error;
} finally {
firstStart.resolve({});
}
const cluster = await clusterPromise;
if (startAssertion) throw startAssertion;

const closePromise = cluster.close();
let stopAssertion: unknown;
try {
await vi.waitFor(() => expect(secondRuntime.stopWeb).toHaveBeenCalledTimes(1), {
timeout: 1_000,
});
} catch (error) {
stopAssertion = error;
} finally {
firstStop.resolve(undefined);
}
await closePromise;
if (stopAssertion) throw stopAssertion;
});

it('closes a successful cluster exactly once and removes its allocated root', async () => {
const firstRuntime = runtime('first');
const secondRuntime = runtime('second');
Expand Down Expand Up @@ -166,3 +212,14 @@ function specs() {
{ id: 'second', env: { CLIENT: 'second' } },
] as const;
}

function deferred<T>(): {
promise: Promise<T>;
resolve: (value: T) => void;
} {
let resolve!: (value: T) => void;
const promise = new Promise<T>((next) => {
resolve = next;
});
return { promise, resolve };
}
16 changes: 12 additions & 4 deletions e2e/tests/packaged-smoke-workflow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1158,22 +1158,30 @@ process.stdin.on("end", () => {
expect(uiP0).toContain("include: ${{ fromJSON(needs.scopes.outputs.ui_p0_matrix) }}");
expect(uiP0CiMatrix.map((entry) => entry.name)).toEqual([
"entry-settings",
"entry-automations",
"project-workspace",
"project-workspace-editor",
"project-collab",
"project-runtime",
"workspace-restoration",
]);
expect(uiP0Groups["project-workspace"].files).toEqual([
"ui/app.test.ts",
"ui/app-design-files.test.ts",
"ui/app-manual-edit.test.ts",
"ui/project-management-flows.test.ts",
"ui/workspace-team-design-system-picker.test.ts",
"ui/workspace-keyboard-flows.test.ts",
]);
expect(uiP0Groups["project-workspace"].workers).toBe(1);
expect(uiP0Groups["project-workspace-editor"]).toEqual({
grep: String.raw`\[P0\]`,
workers: 1,
files: [
"ui/app-design-files.test.ts",
"ui/app-manual-edit.test.ts",
"ui/workspace-team-design-system-picker.test.ts",
],
});
expect(uiP0Groups["project-collab"].files).toEqual([
"ui/workspace-multi-client-collab.test.ts",
"ui/workspace-keyboard-flows.test.ts",
]);
expect(uiP0Groups["project-collab"].workers).toBe(1);
expect(uiP0Groups["critical-extras"]).toEqual({
Expand Down
10 changes: 9 additions & 1 deletion e2e/tests/scripts/scopes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -947,7 +947,15 @@ test("runtime-definition shadow fails closed for mixed, unknown, empty, and unre
assert.equal(decision.mode, "full-fallback", files.join(", "));
assert.deepEqual(
decision.matrix.map((entry) => entry.name),
["entry-settings", "project-workspace", "project-collab", "project-runtime", "workspace-restoration"],
[
"entry-settings",
"entry-automations",
"project-workspace",
"project-workspace-editor",
"project-collab",
"project-runtime",
"workspace-restoration",
],
);
}
assert.equal(evaluateUiP0Shadow([], false).reason, "files-unresolved");
Expand Down
7 changes: 6 additions & 1 deletion e2e/ui/api-empty-response.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { expect, test } from '@/playwright/suite';
import { fulfillAgentsRoute, routeSuccessfulRuns } from '@/playwright/mock-factory';
import {
fulfillAgentsRoute,
routeSignedOutVelaStatus,
routeSuccessfulRuns,
} from '@/playwright/mock-factory';
import { openNewProjectModal as openNewProjectModalFromProjects } from '@/playwright/rail';
import type { Page } from '@playwright/test';
import { T } from '@/timeouts';
Expand All @@ -9,6 +13,7 @@ const STORAGE_KEY = 'open-design:config';
test.describe.configure({ timeout: T.xlong });

test.beforeEach(async ({ page }) => {
await routeSignedOutVelaStatus(page);
await page.addInitScript((key) => {
window.localStorage.setItem(
key,
Expand Down
Loading
Loading