diff --git a/packages/app/e2e/browser/worktree-codex-restore.real.spec.ts b/packages/app/e2e/browser/worktree-codex-restore.real.spec.ts index 968839a6c1..8b9010e742 100644 --- a/packages/app/e2e/browser/worktree-codex-restore.real.spec.ts +++ b/packages/app/e2e/browser/worktree-codex-restore.real.spec.ts @@ -1,345 +1,63 @@ -import { existsSync } from "node:fs"; -import { readFile } from "node:fs/promises"; -import path from "node:path"; -import { Client } from "@modelcontextprotocol/sdk/client/index.js"; -import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; -import { z } from "zod"; -import { test as base, expect, type Page } from "../support/fixtures"; -import { buildHostAgentDetailRoute } from "@/utils/host-routes"; -import { openSessions } from "../support/helpers/archive-tab"; -import { getE2EDaemonPort } from "../support/helpers/daemon-port"; -import { assertComposerIdle } from "../support/helpers/rewind-flow"; -import { getServerId } from "../support/helpers/server-id"; -import { - archiveWorkspaceFromSidebar, - expectWorkspaceAbsentFromSidebar, -} from "../support/helpers/sidebar"; -import { createTempGitRepo } from "../support/helpers/workspace"; -import { waitForSidebarHydration } from "../support/helpers/workspace-ui"; - -const REPLY = "CODEX_WORKTREE_RESTORE_READY"; -// This diagnosis owns cleanup so failed state can be retained for inspection. -const test = base.extend({ - projectOwnership: async ({ e2eWorkerClient }, provide) => { - void e2eWorkerClient; - await provide(); - }, -}); -const workspaceSchema = z.object({ - workspaceId: z.string(), - projectId: z.string(), - cwd: z.string(), - isolation: z.literal("worktree"), -}); - -async function callPaseoTool( - mcp: Client, - name: string, - args: Record, -): Promise { - const result = await mcp.callTool({ name, arguments: args }, undefined, { timeout: 240_000 }); - expect(result.isError, JSON.stringify(result)).not.toBe(true); - return result.structuredContent; -} - -async function openArchivedWorkspaceFromHistory(page: Page, agentId: string): Promise { - await openSessions(page); - const row = page.getByTestId(`agent-row-${getServerId()}-${agentId}`); - await expect(row).toContainText("Archived"); - await row.click(); - await expect(page.getByText("Workspace archived", { exact: true })).toBeVisible(); -} - -async function defaultCodexModel(mcp: Client): Promise { - const result = z - .object({ - models: z.array(z.object({ id: z.string(), isDefault: z.boolean() })), - }) - .parse(await callPaseoTool(mcp, "list_models", { provider: "codex" })); - const model = result.models.find((entry) => entry.isDefault); - if (!model) throw new Error("Codex did not advertise a default model"); - return model.id; -} - -async function restoreWorkspace(page: Page): Promise { - await page.getByRole("button", { name: "Restore", exact: true }).click(); - await expect(page.getByText("Workspace archived", { exact: true })).toHaveCount(0, { - timeout: 60_000, - }); -} - -async function unarchiveAgent(page: Page): Promise { - await page.getByRole("button", { name: "Unarchive", exact: true }).click({ timeout: 30_000 }); - await expect(page.getByText("This agent is archived", { exact: true })).toHaveCount(0, { - timeout: 60_000, - }); -} +import { test } from "../support/helpers/codex-workspace-restore"; test.use({ e2eDaemonConfig: { version: 1, daemon: { mcp: { enabled: true } } }, trace: "on", video: "on", }); - -async function reloadWithoutAgentCache(page: Page): Promise { - const url = page.url(); - const cdp = await page.context().newCDPSession(page); - // Unmount first so an open connection cannot repopulate the cache while it is cleared. - await page.goto("about:blank"); - await cdp.send("Storage.clearDataForOrigin", { - origin: new URL(url).origin, - storageTypes: "indexeddb,cache_storage", - }); - await cdp.send("Network.clearBrowserCache"); - await cdp.send("Network.setCacheDisabled", { cacheDisabled: true }); - await page.goto(url); - await cdp.detach(); -} +test.describe.configure({ timeout: 600_000 }); test("a fresh client restores an archived Codex agent across a reload without agent cache", async ({ - page, - e2eWorkerClient: client, -}, testInfo) => { - test.setTimeout(600_000); - const repo = await createTempGitRepo("codex-cold-restore-"); - const mcp = new Client({ name: "codex-cold-restore-playwright", version: "1.0.0" }); - const timelineWire: string[] = []; - page.on("websocket", (socket) => { - const recordFrame = (direction: string, message: string) => { - if (/agent.*timeline|fetch_agents|agent_update/.test(message)) { - timelineWire.push(JSON.stringify({ at: Date.now(), direction, message })); - } - }; - socket.on("framesent", ({ payload }) => recordFrame("sent", payload.toString())); - socket.on("framereceived", ({ payload }) => recordFrame("received", payload.toString())); + codexRestore, +}) => { + await test.step("Create, finish, and archive Codex before this browser connects", async () => { + await codexRestore.createWorktreeWithCodex(); + await codexRestore.archiveBeforeBrowserConnects(); + }); + await test.step("Connect the fresh browser, open History, and restore the workspace", async () => { + await codexRestore.connectFreshBrowser(); + await codexRestore.openArchivedWorkspaceFromHistory(); + await codexRestore.restoreWorkspace(); + await codexRestore.expectArchivedAgentSelectedWithHistory(); + }); + await test.step("Reload without cached agent data and keep the selected archived agent", async () => { + await codexRestore.reloadWithoutAgentCache(); + await codexRestore.waitForWorkspaceHydration(); + await codexRestore.expectArchivedAgentSelectedWithHistory(); + }); + await test.step("Unarchive the agent and keep its idle composer after another cache-empty reload", async () => { + await codexRestore.unarchiveAgent(); + await codexRestore.reloadWithoutAgentCache(); + await codexRestore.expectIdleAgentWithVisibleComposer(); }); - let workspace: z.infer | undefined; - let completed = false; - try { - await mcp.connect( - new StreamableHTTPClientTransport( - new URL(`http://127.0.0.1:${getE2EDaemonPort()}/mcp/agents`), - ), - ); - const created = - await test.step("Create, finish, and archive Codex before this browser connects", async () => { - workspace = workspaceSchema.parse( - await callPaseoTool(mcp, "create_workspace", { - isolation: "worktree", - path: repo.path, - baseBranch: "main", - title: "Cold Codex restore", - }), - ); - const { agentId } = z.object({ agentId: z.string() }).parse( - await callPaseoTool(mcp, "create_agent", { - workspaceId: workspace.workspaceId, - provider: `codex/${await defaultCodexModel(mcp)}`, - settings: { modeId: "full-access", thinkingOptionId: "low" }, - title: "Cold Codex restore", - initialPrompt: `Reply with exactly ${REPLY} and nothing else. Do not use tools.`, - background: true, - }), - ); - await client.waitForFinish(agentId, 240_000); - expect((await client.archiveWorkspace(workspace.workspaceId)).error).toBeNull(); - await expect.poll(() => existsSync(workspace!.cwd)).toBe(false); - await expect - .poll(() => client.fetchAgent({ agentId })) - .toMatchObject({ agent: { archivedAt: expect.any(String) } }); - return { ...workspace, agentId }; - }); - await test.step("Connect the fresh browser, open History, and restore the workspace", async () => { - await page.goto("/"); - await openArchivedWorkspaceFromHistory(page, created.agentId); - await restoreWorkspace(page); - await expect(page.getByTestId(`workspace-tab-agent_${created.agentId}`)).toHaveAttribute( - "aria-selected", - "true", - ); - await expect(page.getByText("This agent is archived", { exact: true })).toBeVisible(); - await expect(page.getByTestId("assistant-message")).toContainText(REPLY); - }); - await test.step("Reload without cached agent data and keep the selected archived agent", async () => { - await reloadWithoutAgentCache(page); - await waitForSidebarHydration(page); - await expect(page.getByTestId(`workspace-tab-agent_${created.agentId}`)).toHaveAttribute( - "aria-selected", - "true", - ); - await expect(page.getByText("This agent is archived", { exact: true })).toBeVisible(); - await expect(page.getByTestId("assistant-message")).toContainText(REPLY); - }); - await test.step("Unarchive the agent and keep its idle composer after another cache-empty reload", async () => { - await unarchiveAgent(page); - await reloadWithoutAgentCache(page); - await expect - .poll(() => client.fetchAgent({ agentId: created.agentId })) - .toMatchObject({ agent: { status: "idle", archivedAt: null } }); - await expect(page.getByTestId(`workspace-tab-agent_${created.agentId}`)).toHaveAttribute( - "aria-selected", - "true", - ); - await assertComposerIdle({ page }); - await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible(); - }); - completed = true; - } finally { - await testInfo.attach("cold-client-screen", { - body: await page.screenshot(), - contentType: "image/png", - }); - await testInfo.attach("cold-client-timeline-wire", { - body: timelineWire.join("\n"), - contentType: "text/plain", - }); - await testInfo.attach("cold-client-daemon-log", { - body: await readFile(path.join(process.env.E2E_PASEO_HOME!, "daemon.log")), - contentType: "text/plain", - }); - await mcp.close(); - if (completed || process.env.E2E_KEEP_PASEO_HOME !== "1") { - if (workspace) await client.removeProject(workspace.projectId); - await repo.cleanup(); - } - } }); test("restore an archived worktree, then unarchive its completed Codex agent", async ({ - page, - e2eWorkerClient: client, -}, testInfo) => { - test.setTimeout(600_000); - const repo = await createTempGitRepo("codex-worktree-restore-"); - const mcp = new Client({ name: "worktree-codex-restore-playwright", version: "1.0.0" }); - let workspace: z.infer | undefined; - let agentId: string | undefined; - let completed = false; - - try { - await mcp.connect( - new StreamableHTTPClientTransport( - new URL(`http://127.0.0.1:${getE2EDaemonPort()}/mcp/agents`), - ), - ); - const created = - await test.step("1. Create a managed worktree with a real Codex agent through Paseo MCP", async () => { - workspace = workspaceSchema.parse( - await callPaseoTool(mcp, "create_workspace", { - isolation: "worktree", - path: repo.path, - baseBranch: "main", - title: "Codex worktree restore reproduction", - }), - ); - const agent = z.object({ agentId: z.string() }).parse( - await callPaseoTool(mcp, "create_agent", { - workspaceId: workspace.workspaceId, - provider: `codex/${await defaultCodexModel(mcp)}`, - settings: { modeId: "full-access", thinkingOptionId: "low" }, - title: "Codex worktree restore reproduction", - initialPrompt: `Reply with exactly ${REPLY} and nothing else. Do not use tools.`, - background: true, - }), - ); - agentId = agent.agentId; - return { ...workspace, agentId }; - }); - - await test.step("2. Wait for Codex to finish and show its reply", async () => { - await page.goto( - buildHostAgentDetailRoute(getServerId(), created.agentId, created.workspaceId), - ); - await waitForSidebarHydration(page); - await expect(page.getByTestId("assistant-message")).toContainText(REPLY, { - timeout: 240_000, - }); - await expect - .poll(() => client.fetchAgent({ agentId: created.agentId })) - .toMatchObject({ - agent: { status: "idle", archivedAt: null }, - }); - await assertComposerIdle({ page }); - }); - - await test.step("3. Archive the workspace from its sidebar menu", async () => { - await archiveWorkspaceFromSidebar(page, created.workspaceId); - }); - - await test.step("4. Assert workspace and agent are archived and the worktree is removed", async () => { - await expectWorkspaceAbsentFromSidebar(page, created.workspaceId); - await expect - .poll(() => client.fetchAgent({ agentId: created.agentId })) - .toMatchObject({ - agent: { archivedAt: expect.any(String) }, - }); - await expect.poll(() => existsSync(created.cwd), { timeout: 30_000 }).toBe(false); - await expect - .poll(() => client.fetchWorkspaces()) - .not.toMatchObject({ - entries: expect.arrayContaining([expect.objectContaining({ id: created.workspaceId })]), - }); - }); - - await test.step("5. Open the archived workspace from History", async () => { - await openArchivedWorkspaceFromHistory(page, created.agentId); - }); - - await test.step("6. Restore the workspace", async () => { - await restoreWorkspace(page); - await expect.poll(() => existsSync(created.cwd), { timeout: 30_000 }).toBe(true); - await expect(page.getByTestId(`workspace-tab-agent_${created.agentId}`)).toHaveAttribute( - "aria-selected", - "true", - ); - await expect(page.getByText("This agent is archived", { exact: true })).toBeVisible(); - await testInfo.attach("after-workspace-restore", { - body: JSON.stringify(await client.fetchAgent({ agentId: created.agentId }), null, 2), - contentType: "application/json", - }); - }); - - await test.step("7. Click Unarchive on the agent", async () => { - await unarchiveAgent(page); - }); - - await test.step("8. Expect an idle agent with a visible editable composer", async () => { - await expect - .poll(() => client.fetchAgent({ agentId: created.agentId }), { timeout: 60_000 }) - .toMatchObject({ agent: { status: "idle", archivedAt: null } }); - await expect(page.getByTestId(`workspace-tab-agent_${created.agentId}`)).toHaveAttribute( - "aria-selected", - "true", - ); - await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible(); - await assertComposerIdle({ page }); - await testInfo.attach("restored-agent-screen", { - body: await page.screenshot(), - contentType: "image/png", - }); - await testInfo.attach("restored-agent-accessibility", { - body: await page.locator("body").ariaSnapshot(), - contentType: "text/plain", - }); - }); - completed = true; - } finally { - await testInfo.attach("final-screen", { - body: await page.screenshot(), - contentType: "image/png", - }); - await testInfo.attach("final-agent", { - body: JSON.stringify(agentId ? await client.fetchAgent({ agentId }) : null, null, 2), - contentType: "application/json", - }); - await testInfo.attach("daemon-log", { - body: await readFile(path.join(process.env.E2E_PASEO_HOME!, "daemon.log")), - contentType: "text/plain", - }); - await mcp.close(); - if (completed || process.env.E2E_KEEP_PASEO_HOME !== "1") { - if (workspace) await client.removeProject(workspace.projectId); - await repo.cleanup(); - } - } + codexRestore, +}) => { + await test.step("1. Create a managed worktree with a real Codex agent through Paseo MCP", async () => { + await codexRestore.createWorktreeWithCodex(); + }); + await test.step("2. Wait for Codex to finish and show its reply", async () => { + await codexRestore.waitForCompletedReply(); + }); + await test.step("3. Archive the workspace from its sidebar menu", async () => { + await codexRestore.archiveWorkspaceFromSidebar(); + }); + await test.step("4. Assert workspace and agent are archived and the worktree is removed", async () => { + await codexRestore.expectWorkspaceAndAgentArchived(); + }); + await test.step("5. Open the archived workspace from History", async () => { + await codexRestore.openArchivedWorkspaceFromHistory(); + }); + await test.step("6. Restore the workspace", async () => { + await codexRestore.restoreWorkspace(); + await codexRestore.expectWorktreeRestoredWithArchivedAgentSelected(); + }); + await test.step("7. Click Unarchive on the agent", async () => { + await codexRestore.unarchiveAgent(); + }); + await test.step("8. Expect an idle agent with a visible editable composer", async () => { + await codexRestore.expectIdleAgentWithVisibleComposer(60_000); + }); }); diff --git a/packages/app/e2e/support/helpers/codex-workspace-restore.ts b/packages/app/e2e/support/helpers/codex-workspace-restore.ts new file mode 100644 index 0000000000..8bb5dd822f --- /dev/null +++ b/packages/app/e2e/support/helpers/codex-workspace-restore.ts @@ -0,0 +1,260 @@ +import { existsSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import { z } from "zod"; +import type { TestInfo } from "@playwright/test"; +import { test as base, expect, type Page } from "../fixtures"; +import { buildHostAgentDetailRoute } from "@/utils/host-routes"; +import { openSessions } from "./archive-tab"; +import { getE2EDaemonPort } from "./daemon-port"; +import { assertComposerIdle } from "./rewind-flow"; +import { getServerId } from "./server-id"; +import { archiveWorkspaceFromSidebar, expectWorkspaceAbsentFromSidebar } from "./sidebar"; +import { createTempGitRepo } from "./workspace"; +import { waitForSidebarHydration } from "./workspace-ui"; +import type { SeedDaemonClient } from "./seed-client"; + +const REPLY = "CODEX_WORKTREE_RESTORE_READY"; +const workspaceSchema = z.object({ + workspaceId: z.string(), + projectId: z.string(), + cwd: z.string(), + isolation: z.literal("worktree"), +}); + +async function callPaseoTool( + mcp: Client, + name: string, + args: Record, +): Promise { + const result = await mcp.callTool({ name, arguments: args }, undefined, { timeout: 240_000 }); + expect(result).not.toMatchObject({ isError: true }); + return result.structuredContent; +} + +async function defaultCodexModel(mcp: Client): Promise { + const result = z + .object({ models: z.array(z.object({ id: z.string(), isDefault: z.boolean() })) }) + .parse(await callPaseoTool(mcp, "list_models", { provider: "codex" })); + const model = result.models.find((entry) => entry.isDefault); + if (!model) throw new Error("Codex did not advertise a default model"); + return model.id; +} + +function recordTimelineWire(page: Page): string[] { + const wire: string[] = []; + page.on("websocket", (socket) => { + const recordFrame = (direction: string, message: string) => { + if (/agent.*timeline|fetch_agents|agent_update/.test(message)) { + wire.push(JSON.stringify({ at: Date.now(), direction, message })); + } + }; + socket.on("framesent", ({ payload }) => recordFrame("sent", payload.toString())); + socket.on("framereceived", ({ payload }) => recordFrame("received", payload.toString())); + }); + return wire; +} + +async function createCodexRestoreJourney(page: Page, client: SeedDaemonClient, info: TestInfo) { + const repo = await createTempGitRepo("codex-worktree-restore-"); + const mcp = new Client({ name: "worktree-codex-restore-playwright", version: "1.0.0" }); + const wire = recordTimelineWire(page); + let workspace: z.infer | undefined; + let agentId: string | undefined; + + function created() { + if (!workspace || !agentId) throw new Error("Create the Codex worktree before using it"); + return { ...workspace, agentId }; + } + + async function expectSelected() { + await expect(page.getByTestId(`workspace-tab-agent_${created().agentId}`)).toHaveAttribute( + "aria-selected", + "true", + ); + } + + return { + async createWorktreeWithCodex() { + await mcp.connect( + new StreamableHTTPClientTransport( + new URL(`http://127.0.0.1:${getE2EDaemonPort()}/mcp/agents`), + ), + ); + workspace = workspaceSchema.parse( + await callPaseoTool(mcp, "create_workspace", { + isolation: "worktree", + path: repo.path, + baseBranch: "main", + title: "Codex worktree restore reproduction", + }), + ); + const agent = z.object({ agentId: z.string() }).parse( + await callPaseoTool(mcp, "create_agent", { + workspaceId: workspace.workspaceId, + provider: `codex/${await defaultCodexModel(mcp)}`, + settings: { modeId: "full-access", thinkingOptionId: "low" }, + title: "Codex worktree restore reproduction", + initialPrompt: `Reply with exactly ${REPLY} and nothing else. Do not use tools.`, + background: true, + }), + ); + agentId = agent.agentId; + }, + async waitForCompletedReply() { + const target = created(); + await page.goto(buildHostAgentDetailRoute(getServerId(), target.agentId, target.workspaceId)); + await waitForSidebarHydration(page); + await expect(page.getByTestId("assistant-message")).toContainText(REPLY, { + timeout: 240_000, + }); + await expect + .poll(() => client.fetchAgent({ agentId: target.agentId })) + .toMatchObject({ + agent: { status: "idle", archivedAt: null }, + }); + await assertComposerIdle({ page }); + }, + async archiveBeforeBrowserConnects() { + const target = created(); + await client.waitForFinish(target.agentId, 240_000); + expect((await client.archiveWorkspace(target.workspaceId)).error).toBeNull(); + await expect.poll(() => existsSync(target.cwd)).toBe(false); + await expect + .poll(() => client.fetchAgent({ agentId: target.agentId })) + .toMatchObject({ + agent: { archivedAt: expect.any(String) }, + }); + }, + async connectFreshBrowser() { + await page.goto("/"); + }, + async archiveWorkspaceFromSidebar() { + await archiveWorkspaceFromSidebar(page, created().workspaceId); + }, + async expectWorkspaceAndAgentArchived() { + const target = created(); + await expectWorkspaceAbsentFromSidebar(page, target.workspaceId); + await expect + .poll(() => client.fetchAgent({ agentId: target.agentId })) + .toMatchObject({ + agent: { archivedAt: expect.any(String) }, + }); + await expect.poll(() => existsSync(target.cwd), { timeout: 30_000 }).toBe(false); + await expect + .poll(() => client.fetchWorkspaces()) + .not.toMatchObject({ + entries: expect.arrayContaining([expect.objectContaining({ id: target.workspaceId })]), + }); + }, + async openArchivedWorkspaceFromHistory() { + await openSessions(page); + const row = page.getByTestId(`agent-row-${getServerId()}-${created().agentId}`); + await expect(row).toContainText("Archived"); + await row.click(); + await expect(page.getByText("Workspace archived", { exact: true })).toBeVisible(); + }, + async restoreWorkspace() { + await page.getByRole("button", { name: "Restore", exact: true }).click(); + await expect(page.getByText("Workspace archived", { exact: true })).toHaveCount(0, { + timeout: 60_000, + }); + }, + async expectWorktreeRestoredWithArchivedAgentSelected() { + await expect.poll(() => existsSync(created().cwd), { timeout: 30_000 }).toBe(true); + await expectSelected(); + await expect(page.getByText("This agent is archived", { exact: true })).toBeVisible(); + await info.attach("after-workspace-restore", { + body: JSON.stringify(await client.fetchAgent({ agentId: created().agentId }), null, 2), + contentType: "application/json", + }); + }, + async expectArchivedAgentSelectedWithHistory() { + await expectSelected(); + await expect(page.getByText("This agent is archived", { exact: true })).toBeVisible(); + await expect(page.getByTestId("assistant-message")).toContainText(REPLY); + }, + async reloadWithoutAgentCache() { + const url = page.url(); + const cdp = await page.context().newCDPSession(page); + // Unmount first so an open connection cannot repopulate the cache while it is cleared. + await page.goto("about:blank"); + await cdp.send("Storage.clearDataForOrigin", { + origin: new URL(url).origin, + storageTypes: "indexeddb,cache_storage", + }); + await cdp.send("Network.clearBrowserCache"); + await cdp.send("Network.setCacheDisabled", { cacheDisabled: true }); + await page.goto(url); + await cdp.detach(); + }, + async waitForWorkspaceHydration() { + await waitForSidebarHydration(page); + }, + async unarchiveAgent() { + await page.getByRole("button", { name: "Unarchive", exact: true }).click({ timeout: 30_000 }); + await expect(page.getByText("This agent is archived", { exact: true })).toHaveCount(0, { + timeout: 60_000, + }); + }, + async expectIdleAgentWithVisibleComposer(statusTimeout?: number) { + await expect + .poll(() => client.fetchAgent({ agentId: created().agentId }), { timeout: statusTimeout }) + .toMatchObject({ agent: { status: "idle", archivedAt: null } }); + await expectSelected(); + await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible(); + await assertComposerIdle({ page }); + await info.attach("restored-agent-screen", { + body: await page.screenshot(), + contentType: "image/png", + }); + await info.attach("restored-agent-accessibility", { + body: await page.locator("body").ariaSnapshot(), + contentType: "text/plain", + }); + }, + async dispose() { + try { + await info.attach("final-screen", { + body: await page.screenshot(), + contentType: "image/png", + }); + await info.attach("timeline-wire", { body: wire.join("\n"), contentType: "text/plain" }); + await info.attach("final-agent", { + body: JSON.stringify(agentId ? await client.fetchAgent({ agentId }) : null, null, 2), + contentType: "application/json", + }); + await info.attach("daemon-log", { + body: await readFile(path.join(process.env.E2E_PASEO_HOME!, "daemon.log")), + contentType: "text/plain", + }); + } finally { + await mcp.close(); + if (info.status === info.expectedStatus || process.env.E2E_KEEP_PASEO_HOME !== "1") { + if (workspace) await client.removeProject(workspace.projectId); + await repo.cleanup(); + } + } + }, + }; +} + +export const test = base.extend<{ + codexRestore: Awaited>; +}>({ + // This fixture owns cleanup so a failed reproduction can retain its daemon state. + projectOwnership: async ({ e2eWorkerClient }, provide) => { + void e2eWorkerClient; + await provide(); + }, + codexRestore: async ({ page, e2eWorkerClient }, provide, info) => { + const journey = await createCodexRestoreJourney(page, e2eWorkerClient, info); + try { + await provide(journey); + } finally { + await journey.dispose(); + } + }, +});