From aab2bd2bb93b33b7db4a951a749d65b2608ecd9d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 2 Aug 2026 14:00:48 -0700 Subject: [PATCH] test(ui): deduplicate control UI e2e setup (#118180) --- ui/src/e2e/chat-flow.messaging.e2e.test.ts | 492 +++++------- ...ession-page.prompt-attachments.e2e.test.ts | 716 ++++++++---------- ...-session-page.workspace-memory.e2e.test.ts | 567 ++++---------- ...sion-page.workspace-validation.e2e.test.ts | 545 +++++-------- 4 files changed, 889 insertions(+), 1431 deletions(-) diff --git a/ui/src/e2e/chat-flow.messaging.e2e.test.ts b/ui/src/e2e/chat-flow.messaging.e2e.test.ts index bc1c1cccf99c..920118144536 100644 --- a/ui/src/e2e/chat-flow.messaging.e2e.test.ts +++ b/ui/src/e2e/chat-flow.messaging.e2e.test.ts @@ -1,5 +1,6 @@ import { mkdir } from "node:fs/promises"; import path from "node:path"; +import type { Page } from "playwright"; import { expect, it } from "vitest"; import { captureUiProofEnabled, @@ -16,25 +17,31 @@ import { const suite = createChatFlowE2eSuite(); +async function withChatPage(run: (page: Page) => Promise): Promise { + const context = await suite.newBrowserContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }); + try { + await run(await context.newPage()); + } finally { + await suite.closeBrowserContext(context); + } +} + suite.define(() => { it("sends a chat turn through the GUI and renders the final Gateway event", async () => { - const context = await suite.newBrowserContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - }); - const page = await context.newPage(); - const gateway = await installMockGateway(page, { - historyMessages: [ - { - content: [{ text: "Ready for an end-to-end GUI check.", type: "text" }], - role: "assistant", - timestamp: Date.now(), - }, - ], - }); - - try { + await withChatPage(async (page) => { + const gateway = await installMockGateway(page, { + historyMessages: [ + { + content: [{ text: "Ready for an end-to-end GUI check.", type: "text" }], + role: "assistant", + timestamp: Date.now(), + }, + ], + }); await page.goto(`${suite.server.baseUrl}chat`); await page.getByText("Ready for an end-to-end GUI check.").waitFor({ timeout: 10_000 }); @@ -63,22 +70,13 @@ suite.define(() => { const commandRequests = await waitForRequests(gateway, "chat.send", 2); const commandParams = requireRecord(commandRequests[1]?.params); expect(commandParams.message).toBe(spacedPairCommand); - } finally { - await suite.closeBrowserContext(context); - } + }); }); it("adopts a browser-local prompt from a metadata-free gateway event without duplication", async () => { - const context = await suite.newBrowserContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - }); - const page = await context.newPage(); - const gateway = await installMockGateway(page, { historyMessages: [] }); - const prompt = "Keep my browser-local prompt synchronized."; - - try { + await withChatPage(async (page) => { + const gateway = await installMockGateway(page, { historyMessages: [] }); + const prompt = "Keep my browser-local prompt synchronized."; await page.goto(`${suite.server.baseUrl}chat`); await page.locator(".agent-chat__composer-combobox textarea").fill(prompt); await page.getByRole("button", { name: "Send message" }).click(); @@ -135,32 +133,23 @@ suite.define(() => { timeout: 10_000, }); await expect.poll(() => userRow.count()).toBe(1); - } finally { - await suite.closeBrowserContext(context); - } + }); }); it("preserves distinct same-text peer messages through conflicting envelopes and stale history", async () => { - const context = await suite.newBrowserContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - }); - const page = await context.newPage(); - const gateway = await installMockGateway(page, { historyMessages: [] }); - const prompt = "Both clients independently sent the same prompt."; - const persistedMessages = ["web", "tui"].map((client, index) => ({ - __openclaw: { - id: `canonical-${client}-same-text`, - idempotencyKey: `${client}-same-text-run:user`, - seq: index + 1, - }, - content: [{ text: prompt, type: "text" }], - role: "user", - timestamp: 1_700_000_000_000 + index, - })); - - try { + await withChatPage(async (page) => { + const gateway = await installMockGateway(page, { historyMessages: [] }); + const prompt = "Both clients independently sent the same prompt."; + const persistedMessages = ["web", "tui"].map((client, index) => ({ + __openclaw: { + id: `canonical-${client}-same-text`, + idempotencyKey: `${client}-same-text-run:user`, + seq: index + 1, + }, + content: [{ text: prompt, type: "text" }], + role: "user", + timestamp: 1_700_000_000_000 + index, + })); await page.goto(`${suite.server.baseUrl}chat`); await gateway.waitForRequest("chat.startup"); const historyCount = (await gateway.getRequests("chat.history")).length; @@ -217,9 +206,7 @@ suite.define(() => { await emitPersistedMessage(0); await expectDistinctPeerBubbles(); - } finally { - await suite.closeBrowserContext(context); - } + }); }); it.each([ @@ -228,41 +215,34 @@ suite.define(() => { ])( "keeps another client's $identity user turn ahead of its already-streaming reply", async ({ includeMessageMetadata }) => { - const context = await suite.newBrowserContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - }); - const page = await context.newPage(); - const gateway = await installMockGateway(page, { historyMessages: [] }); - const runId = "shared-session-tui-run"; - const prompt = "Sent from the other client."; - const secondPrompt = "A distinct turn in the same shared run."; - const partial = "Streaming into both clients."; - const userMessage = { - ...(includeMessageMetadata - ? { __openclaw: { id: "shared-session-user", idempotencyKey: `${runId}:user`, seq: 1 } } - : {}), - content: [{ text: prompt, type: "text" }], - role: "user", - timestamp: Date.now(), - }; - const secondUserMessage = { - ...(includeMessageMetadata - ? { - __openclaw: { - id: "shared-session-second-user", - idempotencyKey: `${runId}:user`, - seq: 2, - }, - } - : {}), - content: [{ text: secondPrompt, type: "text" }], - role: "user", - timestamp: Date.now(), - }; - - try { + await withChatPage(async (page) => { + const gateway = await installMockGateway(page, { historyMessages: [] }); + const runId = "shared-session-tui-run"; + const prompt = "Sent from the other client."; + const secondPrompt = "A distinct turn in the same shared run."; + const partial = "Streaming into both clients."; + const userMessage = { + ...(includeMessageMetadata + ? { __openclaw: { id: "shared-session-user", idempotencyKey: `${runId}:user`, seq: 1 } } + : {}), + content: [{ text: prompt, type: "text" }], + role: "user", + timestamp: Date.now(), + }; + const secondUserMessage = { + ...(includeMessageMetadata + ? { + __openclaw: { + id: "shared-session-second-user", + idempotencyKey: `${runId}:user`, + seq: 2, + }, + } + : {}), + content: [{ text: secondPrompt, type: "text" }], + role: "user", + timestamp: Date.now(), + }; await page.goto(`${suite.server.baseUrl}chat`); await gateway.waitForRequest("chat.startup"); await gateway.setHistoryMessages([userMessage]); @@ -368,9 +348,7 @@ suite.define(() => { }); await expect.poll(() => userRow.count()).toBe(1); await expect.poll(() => secondUserRow.count()).toBe(1); - } finally { - await suite.closeBrowserContext(context); - } + }); }, ); @@ -380,30 +358,23 @@ suite.define(() => { ])( "preserves a delayed persisted prompt ahead of a finalized reply with $history history", async ({ includesPrompt }) => { - const context = await suite.newBrowserContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - }); - const page = await context.newPage(); - const gateway = await installMockGateway(page, { historyMessages: [] }); - const runId = "shared-session-finalized-run"; - const prompt = "The persisted prompt arrived after the final."; - const finalText = "The reply was already finished."; - const userMessage = { - __openclaw: { id: "finalized-run-user", idempotencyKey: `${runId}:user`, seq: 1 }, - content: [{ text: prompt, type: "text" }], - role: "user", - timestamp: Date.now(), - }; - const assistantMessage = { - __openclaw: { id: "finalized-run-assistant", seq: 2 }, - content: [{ text: finalText, type: "text" }], - role: "assistant", - timestamp: Date.now(), - }; - - try { + await withChatPage(async (page) => { + const gateway = await installMockGateway(page, { historyMessages: [] }); + const runId = "shared-session-finalized-run"; + const prompt = "The persisted prompt arrived after the final."; + const finalText = "The reply was already finished."; + const userMessage = { + __openclaw: { id: "finalized-run-user", idempotencyKey: `${runId}:user`, seq: 1 }, + content: [{ text: prompt, type: "text" }], + role: "user", + timestamp: Date.now(), + }; + const assistantMessage = { + __openclaw: { id: "finalized-run-assistant", seq: 2 }, + content: [{ text: finalText, type: "text" }], + role: "assistant", + timestamp: Date.now(), + }; await page.goto(`${suite.server.baseUrl}chat`); await gateway.waitForRequest("chat.startup"); await gateway.emitGatewayEvent("chat", { @@ -473,41 +444,35 @@ suite.define(() => { await expect .poll(() => page.locator(".chat-group.user", { hasText: prompt }).count()) .toBe(1); - } finally { - await suite.closeBrowserContext(context); - } + }); }, ); it("keeps a browser-local prompt before a clock-skewed Gateway reply", async () => { - const context = await suite.newBrowserContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - }); - const page = await context.newPage(); - const gateway = await installMockGateway(page, { historyMessages: [] }); - const prompt = "verify clock-skewed chat order"; - const partial = "The Gateway is replying from an earlier clock."; - const reply = "The Gateway reply stayed below its prompt."; - const appearsBefore = (lowerSelector: string, lowerText: string) => - page.locator(".chat-thread-inner").evaluate( - (thread: Element, texts: { lowerSelector: string; lowerText: string; prompt: string }) => { - const findByText = (selector: string, text: string) => - Array.from(thread.querySelectorAll(selector)).find((row) => - (row.textContent ?? "").includes(text), - ); - const promptRow = findByText(".chat-group.user", texts.prompt); - const lowerRow = findByText(texts.lowerSelector, texts.lowerText); - if (!promptRow || !lowerRow) { - return false; - } - return promptRow.getBoundingClientRect().top < lowerRow.getBoundingClientRect().top; - }, - { lowerSelector, lowerText, prompt }, - ); - - try { + await withChatPage(async (page) => { + const gateway = await installMockGateway(page, { historyMessages: [] }); + const prompt = "verify clock-skewed chat order"; + const partial = "The Gateway is replying from an earlier clock."; + const reply = "The Gateway reply stayed below its prompt."; + const appearsBefore = (lowerSelector: string, lowerText: string) => + page.locator(".chat-thread-inner").evaluate( + ( + thread: Element, + texts: { lowerSelector: string; lowerText: string; prompt: string }, + ) => { + const findByText = (selector: string, text: string) => + Array.from(thread.querySelectorAll(selector)).find((row) => + (row.textContent ?? "").includes(text), + ); + const promptRow = findByText(".chat-group.user", texts.prompt); + const lowerRow = findByText(texts.lowerSelector, texts.lowerText); + if (!promptRow || !lowerRow) { + return false; + } + return promptRow.getBoundingClientRect().top < lowerRow.getBoundingClientRect().top; + }, + { lowerSelector, lowerText, prompt }, + ); await page.goto(`${suite.server.baseUrl}chat`); await gateway.deferNext("chat.send"); await page.locator(".agent-chat__composer-combobox textarea").fill(prompt); @@ -552,20 +517,12 @@ suite.define(() => { expect(await appearsBefore(".chat-group.assistant", reply)).toBe(true); await gateway.resolveDeferred("chat.send", { runId, status: "started" }); expect(await appearsBefore(".chat-group.assistant", reply)).toBe(true); - } finally { - await suite.closeBrowserContext(context); - } + }); }); it("reconciles authoritative history before a trailing final by run identity", async () => { - const context = await suite.newBrowserContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - }); - const page = await context.newPage(); - const gateway = await installMockGateway(page, { historyMessages: [] }); - try { + await withChatPage(async (page) => { + const gateway = await installMockGateway(page, { historyMessages: [] }); await page.goto(`${suite.server.baseUrl}chat`); const composer = page.locator(".agent-chat__composer-combobox textarea"); await composer.fill("reconcile the terminal event ordering"); @@ -648,31 +605,22 @@ suite.define(() => { page.locator(".chat-group.assistant .chat-text", { hasText: finalText }).count(), ) .toBe(2); - } finally { - await suite.closeBrowserContext(context); - } + }); }); it("restores the selected session transcript after a hard reload", async () => { - const context = await suite.newBrowserContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - }); - const page = await context.newPage(); - const historyText = "Transcript survives a hard reload."; - const gateway = await installMockGateway(page, { - historyMessages: [ - { - content: [{ text: historyText, type: "text" }], - role: "assistant", - timestamp: Date.now(), - }, - ], - sessionKey: "agent:main:main", - }); - - try { + await withChatPage(async (page) => { + const historyText = "Transcript survives a hard reload."; + const gateway = await installMockGateway(page, { + historyMessages: [ + { + content: [{ text: historyText, type: "text" }], + role: "assistant", + timestamp: Date.now(), + }, + ], + sessionKey: "agent:main:main", + }); await page.goto(controlUiSessionUrl(suite.server.baseUrl, "main")); await page.getByText(historyText).waitFor({ timeout: 10_000 }); await gateway.waitForRequest("chat.startup"); @@ -684,21 +632,12 @@ suite.define(() => { // on reload, so the restored page records its own startup request once. await gateway.waitForRequest("chat.startup"); expect(await gateway.getRequests("chat.startup")).toHaveLength(1); - } finally { - await suite.closeBrowserContext(context); - } + }); }); it("sends idle stop aliases as ordinary chat messages", async () => { - const context = await suite.newBrowserContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - }); - const page = await context.newPage(); - const gateway = await installMockGateway(page); - - try { + await withChatPage(async (page) => { + const gateway = await installMockGateway(page); await page.goto(`${suite.server.baseUrl}chat`); const composer = page.locator(".agent-chat__composer-combobox textarea"); await composer.waitFor({ state: "visible", timeout: 10_000 }); @@ -708,37 +647,28 @@ suite.define(() => { const sendRequest = await gateway.waitForRequest("chat.send"); expect(requireRecord(sendRequest.params).message).toBe("wait"); expect(await gateway.getRequests("chat.abort")).toHaveLength(0); - } finally { - await suite.closeBrowserContext(context); - } + }); }); it("sends /stop to the exact selected channel session and clears its working indicator", async () => { - const context = await suite.newBrowserContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - }); - const page = await context.newPage(); - const channelSessionKey = "agent:main:openclaw-weixin:direct:wechat-user"; - const gateway = await installMockGateway(page, { - sessionKey: channelSessionKey, - methodResponses: { - "sessions.abort": { abortedRunId: null, ok: true, status: "aborted" }, - "sessions.list": chatSessionListResponse([ - { - hasActiveRun: true, - key: channelSessionKey, - kind: "direct", - label: "WeChat user", - status: "running", - updatedAt: Date.now(), - }, - ]), - }, - }); - - try { + await withChatPage(async (page) => { + const channelSessionKey = "agent:main:openclaw-weixin:direct:wechat-user"; + const gateway = await installMockGateway(page, { + sessionKey: channelSessionKey, + methodResponses: { + "sessions.abort": { abortedRunId: null, ok: true, status: "aborted" }, + "sessions.list": chatSessionListResponse([ + { + hasActiveRun: true, + key: channelSessionKey, + kind: "direct", + label: "WeChat user", + status: "running", + updatedAt: Date.now(), + }, + ]), + }, + }); await page.goto(`${suite.server.baseUrl}chat`); const composer = page.locator(".agent-chat__composer-combobox textarea"); await composer.waitFor({ state: "visible", timeout: 10_000 }); @@ -789,21 +719,12 @@ suite.define(() => { fullPage: true, }); } - } finally { - await suite.closeBrowserContext(context); - } + }); }); it("persists the chat send shortcut and keeps multiline and IME input safe", async () => { - const context = await suite.newBrowserContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - }); - const page = await context.newPage(); - const gateway = await installMockGateway(page); - - try { + await withChatPage(async (page) => { + const gateway = await installMockGateway(page); await page.goto(`${suite.server.baseUrl}chat`); const composer = page.locator(".agent-chat__composer-combobox textarea"); await composer.waitFor({ state: "visible", timeout: 10_000 }); @@ -848,42 +769,33 @@ suite.define(() => { await composer.press("Meta+Enter"); const modifierRequest = await gateway.waitForRequest("chat.send"); expect(requireRecord(modifierRequest.params).message).toBe("modifier send"); - } finally { - await suite.closeBrowserContext(context); - } + }); }); it("steers an active run when the session row only reports hasActiveRun", async () => { - const context = await suite.newBrowserContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - }); - const page = await context.newPage(); - const sessionKey = "main"; - const gateway = await installMockGateway(page, { - historyMessages: [ - { - content: [{ text: "Active run is waiting for steering.", type: "text" }], - role: "assistant", - timestamp: Date.now(), - }, - ], - methodResponses: { - "sessions.list": chatSessionListResponse([ + await withChatPage(async (page) => { + const sessionKey = "main"; + const gateway = await installMockGateway(page, { + historyMessages: [ { - hasActiveRun: true, - key: "agent:main:main", - kind: "direct", - label: "Main", - updatedAt: Date.now(), + content: [{ text: "Active run is waiting for steering.", type: "text" }], + role: "assistant", + timestamp: Date.now(), }, - ]), - }, - sessionKey, - }); - - try { + ], + methodResponses: { + "sessions.list": chatSessionListResponse([ + { + hasActiveRun: true, + key: "agent:main:main", + kind: "direct", + label: "Main", + updatedAt: Date.now(), + }, + ]), + }, + sessionKey, + }); await page.goto(`${suite.server.baseUrl}chat`); await page.getByText("Active run is waiting for steering.").waitFor({ timeout: 10_000 }); await gateway.waitForRequest("sessions.list"); @@ -902,21 +814,12 @@ suite.define(() => { await page.getByText("Steered.", { exact: true }).waitFor({ timeout: 10_000 }); expect(await page.getByText("No active run").count()).toBe(0); - } finally { - await suite.closeBrowserContext(context); - } + }); }); it("keeps a targetless message-tool source reply beside the automatic final reply", async () => { - const context = await suite.newBrowserContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - }); - const page = await context.newPage(); - const gateway = await installMockGateway(page); - - try { + await withChatPage(async (page) => { + const gateway = await installMockGateway(page); await page.goto(`${suite.server.baseUrl}chat`); const prompt = "send progress through the message tool and then finish"; @@ -950,29 +853,20 @@ suite.define(() => { ]) { expect(bubbleTexts.some((text) => text.includes(expectedText))).toBe(true); } - } finally { - await suite.closeBrowserContext(context); - } + }); }); it("keeps the composer clear when a stale native input replay arrives after send", async () => { - const context = await suite.newBrowserContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - }); - const page = await context.newPage(); - const gateway = await installMockGateway(page, { - historyMessages: [ - { - content: [{ text: "Ready for stale replay check.", type: "text" }], - role: "assistant", - timestamp: Date.now(), - }, - ], - }); - - try { + await withChatPage(async (page) => { + const gateway = await installMockGateway(page, { + historyMessages: [ + { + content: [{ text: "Ready for stale replay check.", type: "text" }], + role: "assistant", + timestamp: Date.now(), + }, + ], + }); await page.goto(`${suite.server.baseUrl}chat`); await page.getByText("Ready for stale replay check.").waitFor({ timeout: 10_000 }); @@ -1001,8 +895,6 @@ suite.define(() => { await composer.pressSequentially(prompt); expect(await composer.inputValue()).toBe(prompt); - } finally { - await suite.closeBrowserContext(context); - } + }); }); }); diff --git a/ui/src/e2e/new-session-page.prompt-attachments.e2e.test.ts b/ui/src/e2e/new-session-page.prompt-attachments.e2e.test.ts index 0837c55b1d71..37d4bec730b3 100644 --- a/ui/src/e2e/new-session-page.prompt-attachments.e2e.test.ts +++ b/ui/src/e2e/new-session-page.prompt-attachments.e2e.test.ts @@ -1,5 +1,6 @@ import { mkdir } from "node:fs/promises"; import path from "node:path"; +import type { Page } from "playwright"; import { expect, it } from "vitest"; import { ONE_PIXEL_PNG_B64, @@ -18,16 +19,23 @@ import { const suite = createNewSessionPageE2eSuite(); +async function withNewSessionPage(run: (page: Page) => Promise): Promise { + const context = await suite.browser.newContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }); + try { + await run(await context.newPage()); + } finally { + await context.close(); + } +} + suite.define(() => { it("grows the first prompt through ten lines before using a narrow scrollbar", async () => { - const context = await suite.browser.newContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - }); - const page = await context.newPage(); - const gateway = await installMockGateway(page); - try { + await withNewSessionPage(async (page) => { + const gateway = await installMockGateway(page); await page.goto(`${suite.server.baseUrl}new`); const message = page.locator(".new-session-page__message"); await message.waitFor(); @@ -79,24 +87,16 @@ suite.define(() => { await expect(gateway.waitForRequest("sessions.create")).resolves.toMatchObject({ params: { message: longPrompt }, }); - } finally { - await context.close(); - } + }); }); it("pastes an image into the draft and forwards it with the initial turn", async () => { - const context = await suite.browser.newContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - }); - const page = await context.newPage(); - const gateway = await installMockGateway(page, { - methodResponses: { - "sessions.create": { key: "agent:main:image-draft", runStarted: true }, - }, - }); - try { + await withNewSessionPage(async (page) => { + const gateway = await installMockGateway(page, { + methodResponses: { + "sessions.create": { key: "agent:main:image-draft", runStarted: true }, + }, + }); await page.goto(`${suite.server.baseUrl}new`); const message = page.locator(".new-session-page__message"); await message.waitFor(); @@ -118,55 +118,47 @@ suite.define(() => { }, ], }); - } finally { - await context.close(); - } + }); }); it("shows the initial prompt while the newly created session is still running", async () => { - const context = await suite.browser.newContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - }); - const page = await context.newPage(); - const sessionKey = "agent:main:visible-initial-prompt"; - const message = "keep this prompt visible while the agent works"; - const activeOutputTimestamp = Date.now() + 60_000; - const gateway = await installMockGateway(page, { - methodResponses: { - "sessions.create": { key: sessionKey, runStarted: true }, - "sessions.list": createdSessionListResult(sessionKey), - "chat.startup": { - messages: [ - { - role: "assistant", - content: [ - { - type: "toolCall", - id: "active-tool-call", - name: "read", - arguments: { path: "SKILL.md" }, - }, - ], - timestamp: activeOutputTimestamp, - __openclaw: { id: "active-assistant", seq: 2 }, - }, - { - role: "toolResult", - toolCallId: "active-tool-call", - toolName: "read", - content: [{ type: "text", text: "working" }], - timestamp: activeOutputTimestamp + 1, - __openclaw: { id: "active-tool-result", seq: 3 }, - }, - ], - sessionId: "visible-initial-prompt", - sessionInfo: { hasActiveRun: true, key: sessionKey, status: "running" }, + await withNewSessionPage(async (page) => { + const sessionKey = "agent:main:visible-initial-prompt"; + const message = "keep this prompt visible while the agent works"; + const activeOutputTimestamp = Date.now() + 60_000; + const gateway = await installMockGateway(page, { + methodResponses: { + "sessions.create": { key: sessionKey, runStarted: true }, + "sessions.list": createdSessionListResult(sessionKey), + "chat.startup": { + messages: [ + { + role: "assistant", + content: [ + { + type: "toolCall", + id: "active-tool-call", + name: "read", + arguments: { path: "SKILL.md" }, + }, + ], + timestamp: activeOutputTimestamp, + __openclaw: { id: "active-assistant", seq: 2 }, + }, + { + role: "toolResult", + toolCallId: "active-tool-call", + toolName: "read", + content: [{ type: "text", text: "working" }], + timestamp: activeOutputTimestamp + 1, + __openclaw: { id: "active-tool-result", seq: 3 }, + }, + ], + sessionId: "visible-initial-prompt", + sessionInfo: { hasActiveRun: true, key: sessionKey, status: "running" }, + }, }, - }, - }); - try { + }); await page.goto(`${suite.server.baseUrl}new`); await page.locator(".new-session-page__message").fill(message); await page.getByRole("button", { name: "Start thread" }).click(); @@ -185,32 +177,24 @@ suite.define(() => { throw new Error("expected visible prompt and tool rows"); } expect(userRow.y).toBeLessThan(toolRow.y); - } finally { - await context.close(); - } + }); }); it("keeps the initial prompt visible across a Gateway reconnect", async () => { - const context = await suite.browser.newContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - }); - const page = await context.newPage(); - const sessionKey = "agent:main:reconnected-initial-prompt"; - const message = "keep this first prompt through reconnect"; - const gateway = await installMockGateway(page, { - methodResponses: { - "sessions.create": { key: sessionKey, runStarted: true }, - "sessions.list": createdSessionListResult(sessionKey), - "chat.startup": { - messages: [], - sessionId: "reconnected-initial-prompt", - sessionInfo: { hasActiveRun: true, key: sessionKey, status: "running" }, + await withNewSessionPage(async (page) => { + const sessionKey = "agent:main:reconnected-initial-prompt"; + const message = "keep this first prompt through reconnect"; + const gateway = await installMockGateway(page, { + methodResponses: { + "sessions.create": { key: sessionKey, runStarted: true }, + "sessions.list": createdSessionListResult(sessionKey), + "chat.startup": { + messages: [], + sessionId: "reconnected-initial-prompt", + sessionInfo: { hasActiveRun: true, key: sessionKey, status: "running" }, + }, }, - }, - }); - try { + }); await page.goto(`${suite.server.baseUrl}new`); await page.locator(".new-session-page__message").fill(message); await page.getByRole("button", { name: "Start thread" }).click(); @@ -256,55 +240,47 @@ suite.define(() => { await pollLocatorText(page.locator(".chat-group.user")).toContain(message); await expect.poll(() => page.locator(".chat-group.user").count()).toBe(1); - } finally { - await context.close(); - } + }); }); it("reconciles an image-bearing initial prompt into one user row", async () => { - const context = await suite.browser.newContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - }); - const page = await context.newPage(); - const sessionKey = "agent:main:single-image-prompt"; - const message = "testing if dual prompts show"; - const gateway = await installMockGateway(page, { - deferredMethods: ["chat.startup"], - methodResponses: { - "sessions.create": { - key: sessionKey, - runId: "initial-image-send", - runStarted: true, - messageSeq: 1, - }, - "sessions.list": createdSessionListResult(sessionKey), - "chat.startup": { - messages: [ - { - role: "user", - content: [ - { - type: "image", - source: { type: "url", url: "/persisted-image.png" }, + await withNewSessionPage(async (page) => { + const sessionKey = "agent:main:single-image-prompt"; + const message = "testing if dual prompts show"; + const gateway = await installMockGateway(page, { + deferredMethods: ["chat.startup"], + methodResponses: { + "sessions.create": { + key: sessionKey, + runId: "initial-image-send", + runStarted: true, + messageSeq: 1, + }, + "sessions.list": createdSessionListResult(sessionKey), + "chat.startup": { + messages: [ + { + role: "user", + content: [ + { + type: "image", + source: { type: "url", url: "/persisted-image.png" }, + }, + { type: "text", text: message }, + ], + timestamp: Date.now(), + __openclaw: { + id: "persisted-image-prompt", + idempotencyKey: "initial-image-send:user", + seq: 1, }, - { type: "text", text: message }, - ], - timestamp: Date.now(), - __openclaw: { - id: "persisted-image-prompt", - idempotencyKey: "initial-image-send:user", - seq: 1, }, - }, - ], - sessionId: "single-image-prompt", - sessionInfo: { hasActiveRun: true, key: sessionKey, status: "running" }, + ], + sessionId: "single-image-prompt", + sessionInfo: { hasActiveRun: true, key: sessionKey, status: "running" }, + }, }, - }, - }); - try { + }); await page.goto(`${suite.server.baseUrl}new`); const composer = page.locator(".new-session-page__message"); await composer.fill(message); @@ -333,32 +309,24 @@ suite.define(() => { await expect.poll(() => userImage.getAttribute("src")).toBe(initialImageSrc); await pollLocatorText(userRow).toContain(message); await pollLocatorText(userRow).not.toContain("Attached image"); - } finally { - await context.close(); - } + }); }); it("waits for pasted image reads before enabling session creation", async () => { - const context = await suite.browser.newContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - }); - const page = await context.newPage(); - await page.addInitScript(() => { - const readAsDataUrl = Object.getOwnPropertyDescriptor(FileReader.prototype, "readAsDataURL") - ?.value as FileReader["readAsDataURL"]; - FileReader.prototype.readAsDataURL = function (blob: Blob) { - (globalThis as unknown as { finishPastedImageRead?: () => void }).finishPastedImageRead = - () => readAsDataUrl.call(this, blob); - }; - }); - const gateway = await installMockGateway(page, { - methodResponses: { - "sessions.create": { key: "agent:main:delayed-image-draft", runStarted: true }, - }, - }); - try { + await withNewSessionPage(async (page) => { + await page.addInitScript(() => { + const readAsDataUrl = Object.getOwnPropertyDescriptor(FileReader.prototype, "readAsDataURL") + ?.value as FileReader["readAsDataURL"]; + FileReader.prototype.readAsDataURL = function (blob: Blob) { + (globalThis as unknown as { finishPastedImageRead?: () => void }).finishPastedImageRead = + () => readAsDataUrl.call(this, blob); + }; + }); + const gateway = await installMockGateway(page, { + methodResponses: { + "sessions.create": { key: "agent:main:delayed-image-draft", runStarted: true }, + }, + }); await page.goto(`${suite.server.baseUrl}new`); const composer = page.locator(".new-session-page__message"); const submit = page.getByRole("button", { name: "Start thread" }); @@ -384,43 +352,35 @@ suite.define(() => { message: "include the image that is still loading", attachments: [{ fileName: "pixel.png", content: ONE_PIXEL_PNG_B64 }], }); - } finally { - await context.close(); - } + }); }); it("releases a completed file when the rest of its pasted batch is aborted", async () => { - const context = await suite.browser.newContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - }); - const page = await context.newPage(); - await page.addInitScript(() => { - const readAsDataUrl = Object.getOwnPropertyDescriptor(FileReader.prototype, "readAsDataURL") - ?.value as FileReader["readAsDataURL"]; - let readCount = 0; - FileReader.prototype.readAsDataURL = function (blob: Blob) { - readCount += 1; - if (readCount === 1) { - readAsDataUrl.call(this, blob); - } - }; - const createObjectURL = URL.createObjectURL.bind(URL); - const revokeObjectURL = URL.revokeObjectURL.bind(URL); - const proof = { created: 0, revoked: 0 }; - (globalThis as unknown as { attachmentUrlProof: typeof proof }).attachmentUrlProof = proof; - URL.createObjectURL = (blob: Blob) => { - proof.created += 1; - return createObjectURL(blob); - }; - URL.revokeObjectURL = (url: string) => { - proof.revoked += 1; - revokeObjectURL(url); - }; - }); - await installMockGateway(page); - try { + await withNewSessionPage(async (page) => { + await page.addInitScript(() => { + const readAsDataUrl = Object.getOwnPropertyDescriptor(FileReader.prototype, "readAsDataURL") + ?.value as FileReader["readAsDataURL"]; + let readCount = 0; + FileReader.prototype.readAsDataURL = function (blob: Blob) { + readCount += 1; + if (readCount === 1) { + readAsDataUrl.call(this, blob); + } + }; + const createObjectURL = URL.createObjectURL.bind(URL); + const revokeObjectURL = URL.revokeObjectURL.bind(URL); + const proof = { created: 0, revoked: 0 }; + (globalThis as unknown as { attachmentUrlProof: typeof proof }).attachmentUrlProof = proof; + URL.createObjectURL = (blob: Blob) => { + proof.created += 1; + return createObjectURL(blob); + }; + URL.revokeObjectURL = (url: string) => { + proof.revoked += 1; + revokeObjectURL(url); + }; + }); + await installMockGateway(page); await page.goto(`${suite.server.baseUrl}new`); const composer = page.locator(".new-session-page__message"); await pastePng(composer, 2); @@ -450,71 +410,62 @@ suite.define(() => { ), ) .toBe(1); - } finally { - await context.close(); - } + }); }); it("releases pasted image previews after remove, reset, disconnect, and success", async () => { - const context = await suite.browser.newContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - }); - const page = await context.newPage(); - await page.addInitScript(() => { - const createObjectURL = URL.createObjectURL.bind(URL); - const revokeObjectURL = URL.revokeObjectURL.bind(URL); - const proof = { created: 0, revoked: 0 }; - (globalThis as unknown as { attachmentUrlProof: typeof proof }).attachmentUrlProof = proof; - URL.createObjectURL = (blob: Blob) => { - proof.created += 1; - return createObjectURL(blob); - }; - URL.revokeObjectURL = (url: string) => { - proof.revoked += 1; - revokeObjectURL(url); - }; - }); - await installMockGateway(page, { - methodResponses: { - "agents.list": { - defaultId: "main", - mainKey: "main", - scope: "agent", - agents: [ - { id: "main", name: "Main" }, - { id: "writer", name: "Writer" }, - ], + await withNewSessionPage(async (page) => { + await page.addInitScript(() => { + const createObjectURL = URL.createObjectURL.bind(URL); + const revokeObjectURL = URL.revokeObjectURL.bind(URL); + const proof = { created: 0, revoked: 0 }; + (globalThis as unknown as { attachmentUrlProof: typeof proof }).attachmentUrlProof = proof; + URL.createObjectURL = (blob: Blob) => { + proof.created += 1; + return createObjectURL(blob); + }; + URL.revokeObjectURL = (url: string) => { + proof.revoked += 1; + revokeObjectURL(url); + }; + }); + await installMockGateway(page, { + methodResponses: { + "agents.list": { + defaultId: "main", + mainKey: "main", + scope: "agent", + agents: [ + { id: "main", name: "Main" }, + { id: "writer", name: "Writer" }, + ], + }, + "sessions.create": { key: "agent:main:preview-cleanup", runStarted: true }, }, - "sessions.create": { key: "agent:main:preview-cleanup", runStarted: true }, - }, - }); - const proof = () => - page.evaluate( - () => - (globalThis as unknown as { attachmentUrlProof: { created: number; revoked: number } }) - .attachmentUrlProof, - ); - const navigate = (routeId: string, search = "") => - page.evaluate( - ({ targetRouteId, targetSearch }) => { - const app = document.querySelector("openclaw-app") as HTMLElement & { - runtime?: { - context: { - navigate: (routeId: string, options?: { search?: string }) => void; + }); + const proof = () => + page.evaluate( + () => + (globalThis as unknown as { attachmentUrlProof: { created: number; revoked: number } }) + .attachmentUrlProof, + ); + const navigate = (routeId: string, search = "") => + page.evaluate( + ({ targetRouteId, targetSearch }) => { + const app = document.querySelector("openclaw-app") as HTMLElement & { + runtime?: { + context: { + navigate: (routeId: string, options?: { search?: string }) => void; + }; }; }; - }; - if (!app.runtime) { - throw new Error("OpenClaw application runtime is unavailable"); - } - app.runtime.context.navigate(targetRouteId, { search: targetSearch }); - }, - { targetRouteId: routeId, targetSearch: search }, - ); - - try { + if (!app.runtime) { + throw new Error("OpenClaw application runtime is unavailable"); + } + app.runtime.context.navigate(targetRouteId, { search: targetSearch }); + }, + { targetRouteId: routeId, targetSearch: search }, + ); await page.goto(`${suite.server.baseUrl}new`); const composer = page.locator(".new-session-page__message"); @@ -565,54 +516,45 @@ suite.define(() => { (url) => url.pathname === controlUiSessionPath("agent:main:preview-cleanup"), ); await expect.poll(async () => await proof()).toEqual({ created: 4, revoked: 4 }); - } finally { - await context.close(); - } + }); }); it("locks the submitted draft until creation settles and restores it after failure", async () => { - const context = await suite.browser.newContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - }); - const page = await context.newPage(); - const sessionKey = "agent:main:locked-new-session-draft"; - const submittedMessage = "keep this submitted draft atomic"; - const gateway = await installMockGateway(page, { - workspaceGit: true, - methodResponses: { - "agents.list": { - agents: [ - { - id: "main", - identity: { name: "Main" }, - name: "Main", - workspace: WORKSPACE, - workspaceGit: true, - }, - ], - defaultId: "main", - mainKey: "main", - scope: "agent", + await withNewSessionPage(async (page) => { + const sessionKey = "agent:main:locked-new-session-draft"; + const submittedMessage = "keep this submitted draft atomic"; + const gateway = await installMockGateway(page, { + workspaceGit: true, + methodResponses: { + "agents.list": { + agents: [ + { + id: "main", + identity: { name: "Main" }, + name: "Main", + workspace: WORKSPACE, + workspaceGit: true, + }, + ], + defaultId: "main", + mainKey: "main", + scope: "agent", + }, + "worktrees.branches": { + branches: [{ kind: "local", name: "main" }], + defaultBranch: "main", + repositoryStatus: "git", + }, + "sessions.list": { + count: 0, + defaults: SESSION_LIST_DEFAULTS, + path: "", + sessions: [], + ts: Date.now(), + }, + "sessions.create": { key: sessionKey }, }, - "worktrees.branches": { - branches: [{ kind: "local", name: "main" }], - defaultBranch: "main", - repositoryStatus: "git", - }, - "sessions.list": { - count: 0, - defaults: SESSION_LIST_DEFAULTS, - path: "", - sessions: [], - ts: Date.now(), - }, - "sessions.create": { key: sessionKey }, - }, - }); - - try { + }); await page.goto(`${suite.server.baseUrl}new`); await gateway.deferNext("sessions.create"); @@ -660,72 +602,63 @@ suite.define(() => { await page.waitForURL((url) => url.pathname === controlUiSessionPath(sessionKey), { timeout: 30_000, }); - } finally { - await context.close(); - } + }); }); it("keeps a rejected first message visible and retryable after reload", async () => { - const context = await suite.browser.newContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - }); - const page = await context.newPage(); - const sessionKey = "agent:main:rejected-first-message"; - const message = "keep this rejected first message"; - const runError = "send blocked by session policy"; - const gateway = await installMockGateway(page, { - methodResponses: { - "agents.list": { - agents: [ - { - id: "main", - identity: { name: "Main" }, - name: "Main", - workspace: WORKSPACE, - workspaceGit: true, - }, - ], - defaultId: "main", - mainKey: "main", - scope: "agent", + await withNewSessionPage(async (page) => { + const sessionKey = "agent:main:rejected-first-message"; + const message = "keep this rejected first message"; + const runError = "send blocked by session policy"; + const gateway = await installMockGateway(page, { + methodResponses: { + "agents.list": { + agents: [ + { + id: "main", + identity: { name: "Main" }, + name: "Main", + workspace: WORKSPACE, + workspaceGit: true, + }, + ], + defaultId: "main", + mainKey: "main", + scope: "agent", + }, + "worktrees.branches": { + branches: [{ kind: "local", name: "main" }], + defaultBranch: "main", + repositoryStatus: "git", + }, + "sessions.list": { + count: 1, + defaults: SESSION_LIST_DEFAULTS, + path: "", + sessions: [ + { + hasActiveRun: false, + key: sessionKey, + kind: "direct", + status: "done", + updatedAt: Date.now(), + }, + ], + ts: Date.now(), + }, + "sessions.create": { + key: sessionKey, + runStarted: false, + runError: { code: "INVALID_REQUEST", message: runError }, + }, + "chat.history": { + messages: [], + sessionId: "rejected-first-message", + sessionInfo: { hasActiveRun: false, key: sessionKey, status: "done" }, + }, + "chat.send": { runId: "retry-run", status: "started" }, }, - "worktrees.branches": { - branches: [{ kind: "local", name: "main" }], - defaultBranch: "main", - repositoryStatus: "git", - }, - "sessions.list": { - count: 1, - defaults: SESSION_LIST_DEFAULTS, - path: "", - sessions: [ - { - hasActiveRun: false, - key: sessionKey, - kind: "direct", - status: "done", - updatedAt: Date.now(), - }, - ], - ts: Date.now(), - }, - "sessions.create": { - key: sessionKey, - runStarted: false, - runError: { code: "INVALID_REQUEST", message: runError }, - }, - "chat.history": { - messages: [], - sessionId: "rejected-first-message", - sessionInfo: { hasActiveRun: false, key: sessionKey, status: "done" }, - }, - "chat.send": { runId: "retry-run", status: "started" }, - }, - }); - - try { + }); await page.goto(`${suite.server.baseUrl}new`); const composer = page.locator(".new-session-page__message"); await composer.fill(message); @@ -763,49 +696,40 @@ suite.define(() => { attachments: [{ fileName: "pixel.png", content: ONE_PIXEL_PNG_B64 }], }); expect(await gateway.getRequests("sessions.create")).toHaveLength(0); - } finally { - await context.close(); - } + }); }); it("adopts a created session when rejected-turn persistence exceeds browser storage", async () => { - const context = await suite.browser.newContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - }); - const page = await context.newPage(); - await page.addInitScript(() => { - const setItem = Object.getOwnPropertyDescriptor(Storage.prototype, "setItem") - ?.value as Storage["setItem"]; - Storage.prototype.setItem = function (key: string, value: string) { - if (key.startsWith("openclaw.control.chatComposer.v2:")) { - throw new DOMException("Quota exceeded", "QuotaExceededError"); - } - return setItem.call(this, key, value); - }; - }); - const sessionKey = "agent:main:storage-failed-initial-turn"; - const message = "retry this in the session that already exists"; - const runError = "initial send rejected"; - const gateway = await installMockGateway(page, { - methodResponses: { - "sessions.create": { - key: sessionKey, - runStarted: false, - runError: { code: "INVALID_REQUEST", message: runError }, + await withNewSessionPage(async (page) => { + await page.addInitScript(() => { + const setItem = Object.getOwnPropertyDescriptor(Storage.prototype, "setItem") + ?.value as Storage["setItem"]; + Storage.prototype.setItem = function (key: string, value: string) { + if (key.startsWith("openclaw.control.chatComposer.v2:")) { + throw new DOMException("Quota exceeded", "QuotaExceededError"); + } + return setItem.call(this, key, value); + }; + }); + const sessionKey = "agent:main:storage-failed-initial-turn"; + const message = "retry this in the session that already exists"; + const runError = "initial send rejected"; + const gateway = await installMockGateway(page, { + methodResponses: { + "sessions.create": { + key: sessionKey, + runStarted: false, + runError: { code: "INVALID_REQUEST", message: runError }, + }, + "sessions.list": createdSessionListResult(sessionKey), + "chat.history": { + messages: [], + sessionId: "storage-failed-initial-turn", + sessionInfo: { hasActiveRun: false, key: sessionKey, status: "done" }, + }, + "chat.send": { runId: "storage-failure-retry", status: "started" }, }, - "sessions.list": createdSessionListResult(sessionKey), - "chat.history": { - messages: [], - sessionId: "storage-failed-initial-turn", - sessionInfo: { hasActiveRun: false, key: sessionKey, status: "done" }, - }, - "chat.send": { runId: "storage-failure-retry", status: "started" }, - }, - }); - - try { + }); await page.goto(`${suite.server.baseUrl}new`); const composer = page.locator(".new-session-page__message"); await composer.fill(message); @@ -829,8 +753,6 @@ suite.define(() => { attachments: [{ fileName: "pixel.png", content: ONE_PIXEL_PNG_B64 }], }); expect(await gateway.getRequests("sessions.create")).toHaveLength(1); - } finally { - await context.close(); - } + }); }); }); diff --git a/ui/src/e2e/new-session-page.workspace-memory.e2e.test.ts b/ui/src/e2e/new-session-page.workspace-memory.e2e.test.ts index fed3862a145f..5634bcbb6d78 100644 --- a/ui/src/e2e/new-session-page.workspace-memory.e2e.test.ts +++ b/ui/src/e2e/new-session-page.workspace-memory.e2e.test.ts @@ -1,3 +1,4 @@ +import type { BrowserContextOptions, Page } from "playwright"; import { expect, it } from "vitest"; import { MOVED_WORKSPACE, @@ -14,19 +15,91 @@ import { } from "./new-session-page.test-support.ts"; const suite = createNewSessionPageE2eSuite(); +const BASE_CONTEXT: BrowserContextOptions = { locale: "en-US", serviceWorkers: "block" }; +const DESKTOP_CONTEXT: BrowserContextOptions = { + ...BASE_CONTEXT, + viewport: { height: 900, width: 1280 }, +}; +const MOBILE_CONTEXT: BrowserContextOptions = { + ...BASE_CONTEXT, + viewport: { height: 568, width: 320 }, +}; +const MODELS = [ + { id: "gpt-5.5", name: "GPT 5.5", provider: "openai" }, + { id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6", provider: "anthropic" }, +]; +const GIT_BRANCHES = { + branches: [{ kind: "local", name: "main" }], + defaultBranch: "main", + repositoryStatus: "git", +}; +const FOLDER_LISTINGS = { + cases: [ + { + match: { path: WORKSPACE }, + response: { + path: WORKSPACE, + parent: "/home/peter", + home: "/home/peter", + entries: [{ name: "packages", path: PICKED }], + }, + }, + { + match: { path: PICKED }, + response: { path: PICKED, parent: WORKSPACE, home: "/home/peter", entries: [] }, + }, + ], +}; + +function mainAgentList(workspace = WORKSPACE, workspaceGit = true) { + return { + agents: [ + { + id: "main", + identity: { name: "Main" }, + name: "Main", + workspace, + workspaceGit, + }, + ], + defaultId: "main", + mainKey: "main", + scope: "agent", + }; +} + +async function readMainPreference(page: Page): Promise | null> { + return page.evaluate(() => { + const key = Array.from({ length: localStorage.length }, (_, index) => + localStorage.key(index), + ).find((candidate) => candidate?.startsWith("openclaw.new-session.preferences.v1:")); + const value = key + ? (JSON.parse(localStorage.getItem(key) ?? "null") as { + agents?: Record>; + } | null) + : null; + return value?.agents?.main ?? null; + }); +} + +async function withNewSessionPage( + options: BrowserContextOptions, + run: (page: Page) => Promise, +): Promise { + const context = await suite.browser.newContext(options); + try { + await run(await context.newPage()); + } finally { + await context.close(); + } +} suite.define(() => { it("keeps the mobile incognito and model controls separated", async () => { - const context = await suite.browser.newContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 568, width: 320 }, - }); - const page = await context.newPage(); - await installMockGateway(page, { - models: [{ id: "gpt-5.6-sol", name: "GPT 5.6 Sol", provider: "openai" }], - }); - try { + await withNewSessionPage(MOBILE_CONTEXT, async (page) => { + await installMockGateway(page, { + models: [{ id: "gpt-5.6-sol", name: "GPT 5.6 Sol", provider: "openai" }], + }); await page.goto(`${suite.server.baseUrl}new`); const footer = page.locator(".new-session-page__composer .agent-chat__composer-footer"); const incognito = page.getByRole("switch", { name: "Incognito" }); @@ -50,28 +123,17 @@ suite.define(() => { (footerBox?.x ?? 0) + (footerBox?.width ?? 0), ); } - } finally { - await context.close(); - } + }); }); it("selects the model for a plain new session", async () => { - const context = await suite.browser.newContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - }); - const page = await context.newPage(); - const gateway = await installMockGateway(page, { - models: [ - { id: "gpt-5.5", name: "GPT 5.5", provider: "openai" }, - { id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6", provider: "anthropic" }, - ], - methodResponses: { - "sessions.create": { key: "agent:main:model-draft", runStarted: true }, - }, - }); - try { + await withNewSessionPage(DESKTOP_CONTEXT, async (page) => { + const gateway = await installMockGateway(page, { + models: MODELS, + methodResponses: { + "sessions.create": { key: "agent:main:model-draft", runStarted: true }, + }, + }); await page.goto(`${suite.server.baseUrl}new`); const modelSelect = page.locator('[data-chat-model-select="true"]'); await modelSelect.waitFor(); @@ -119,73 +181,20 @@ suite.define(() => { message: "use this model", model: "anthropic/claude-sonnet-4-6", }); - } finally { - await context.close(); - } + }); }); it("restores valid preferences and repairs a worktree rejected by workspace metadata", async () => { - const context = await suite.browser.newContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - }); - const page = await context.newPage(); - const gateway = await installMockGateway(page, { - workspaceGit: true, - models: [ - { id: "gpt-5.5", name: "GPT 5.5", provider: "openai" }, - { - id: "claude-sonnet-4-6", - name: "Claude Sonnet 4.6", - provider: "anthropic", + await withNewSessionPage(DESKTOP_CONTEXT, async (page) => { + const gateway = await installMockGateway(page, { + workspaceGit: true, + models: MODELS, + methodResponses: { + "agents.list": mainAgentList(), + "worktrees.branches": GIT_BRANCHES, + "fs.listDir": FOLDER_LISTINGS, }, - ], - methodResponses: { - "agents.list": { - agents: [ - { - id: "main", - identity: { name: "Main" }, - name: "Main", - workspace: WORKSPACE, - workspaceGit: true, - }, - ], - defaultId: "main", - mainKey: "main", - scope: "agent", - }, - "worktrees.branches": { - branches: [{ kind: "local", name: "main" }], - defaultBranch: "main", - repositoryStatus: "git", - }, - "fs.listDir": { - cases: [ - { - match: { path: WORKSPACE }, - response: { - path: WORKSPACE, - parent: "/home/peter", - home: "/home/peter", - entries: [{ name: "packages", path: PICKED }], - }, - }, - { - match: { path: PICKED }, - response: { - path: PICKED, - parent: WORKSPACE, - home: "/home/peter", - entries: [], - }, - }, - ], - }, - }, - }); - try { + }); await page.goto(`${suite.server.baseUrl}new`); const placeTrigger = page.locator("#new-session-place-trigger"); await choosePackagesFolder(page); @@ -233,95 +242,28 @@ suite.define(() => { main.workspace = workspace; localStorage.setItem(key, JSON.stringify(value)); }, WORKSPACE); - await gateway.setMethodResponse("agents.list", { - agents: [ - { - id: "main", - identity: { name: "Main" }, - name: "Main", - workspace: WORKSPACE, - workspaceGit: false, - }, - ], - defaultId: "main", - mainKey: "main", - scope: "agent", - }); + await gateway.setMethodResponse("agents.list", mainAgentList(WORKSPACE, false)); await page.reload(); await expect.poll(() => placeTrigger.getAttribute("data-worktree")).toBe("false"); - const storedWorktree = await page.evaluate(() => { - const key = Array.from({ length: localStorage.length }, (_, index) => - localStorage.key(index), - ).find((candidate) => candidate?.startsWith("openclaw.new-session.preferences.v1:")); - const value = key - ? (JSON.parse(localStorage.getItem(key) ?? "null") as { - agents?: Record; - } | null) - : null; - return value?.agents?.main?.worktree; - }); + const storedWorktree = (await readMainPreference(page))?.worktree; expect(storedWorktree).toBe(false); - } finally { - await context.close(); - } + }); }); it("blocks an immediate submit until remembered model and worktree choices validate", async () => { - const context = await suite.browser.newContext({ locale: "en-US", serviceWorkers: "block" }); - const page = await context.newPage(); - const models = [ - { id: "gpt-5.5", name: "GPT 5.5", provider: "openai" }, - { - id: "claude-sonnet-4-6", - name: "Claude Sonnet 4.6", - provider: "anthropic", - }, - ]; - const branches = { - branches: [{ kind: "local", name: "main" }], - defaultBranch: "main", - repositoryStatus: "git", - }; - const gateway = await installMockGateway(page, { - workspaceGit: true, - models, - methodResponses: { - "agents.list": { - agents: [ - { - id: "main", - identity: { name: "Main" }, - name: "Main", - workspace: WORKSPACE, - workspaceGit: true, - }, - ], - defaultId: "main", - mainKey: "main", - scope: "agent", + await withNewSessionPage(BASE_CONTEXT, async (page) => { + const models = MODELS; + const branches = GIT_BRANCHES; + const gateway = await installMockGateway(page, { + workspaceGit: true, + models, + methodResponses: { + "agents.list": mainAgentList(), + "worktrees.branches": branches, + "fs.listDir": FOLDER_LISTINGS, + "sessions.create": { key: "agent:main:restored-fast-submit", runStarted: true }, }, - "worktrees.branches": branches, - "fs.listDir": { - cases: [ - { - match: { path: WORKSPACE }, - response: { - path: WORKSPACE, - parent: "/home/peter", - home: "/home/peter", - entries: [{ name: "packages", path: PICKED }], - }, - }, - { - match: { path: PICKED }, - response: { path: PICKED, parent: WORKSPACE, home: "/home/peter", entries: [] }, - }, - ], - }, - "sessions.create": { key: "agent:main:restored-fast-submit", runStarted: true }, - }, - }); - try { + }); await page.goto(`${suite.server.baseUrl}new`); await choosePackagesFolder(page); const placeTrigger = page.locator("#new-session-place-trigger"); @@ -380,53 +322,25 @@ suite.define(() => { model: "anthropic/claude-sonnet-4-6", worktree: true, }); - } finally { - await context.close(); - } + }); }); it("repairs a remembered default folder when the agent workspace moves", async () => { - const context = await suite.browser.newContext({ locale: "en-US", serviceWorkers: "block" }); - const page = await context.newPage(); - const gateway = await installMockGateway(page, { - workspaceGit: true, - models: [ - { id: "gpt-5.5", name: "GPT 5.5", provider: "openai" }, - { - id: "claude-sonnet-4-6", - name: "Claude Sonnet 4.6", - provider: "anthropic", + await withNewSessionPage(BASE_CONTEXT, async (page) => { + const gateway = await installMockGateway(page, { + workspaceGit: true, + models: MODELS, + methodResponses: { + "agents.list": mainAgentList(), + "worktrees.branches": GIT_BRANCHES, + "fs.listDir": { + path: WORKSPACE, + parent: "/home/peter", + home: "/home/peter", + entries: [], + }, }, - ], - methodResponses: { - "agents.list": { - agents: [ - { - id: "main", - identity: { name: "Main" }, - name: "Main", - workspace: WORKSPACE, - workspaceGit: true, - }, - ], - defaultId: "main", - mainKey: "main", - scope: "agent", - }, - "worktrees.branches": { - branches: [{ kind: "local", name: "main" }], - defaultBranch: "main", - repositoryStatus: "git", - }, - "fs.listDir": { - path: WORKSPACE, - parent: "/home/peter", - home: "/home/peter", - entries: [], - }, - }, - }); - try { + }); await page.goto(`${suite.server.baseUrl}new`); const placeTrigger = page.locator("#new-session-place-trigger"); await placeTrigger.click(); @@ -435,20 +349,7 @@ suite.define(() => { await navigateInApp(page, "chat"); await page.waitForURL((url) => url.pathname.endsWith("/chat")); - await gateway.setMethodResponse("agents.list", { - agents: [ - { - id: "main", - identity: { name: "Main" }, - name: "Main", - workspace: MOVED_WORKSPACE, - workspaceGit: true, - }, - ], - defaultId: "main", - mainKey: "main", - scope: "agent", - }); + await gateway.setMethodResponse("agents.list", mainAgentList(MOVED_WORKSPACE)); await page.reload(); await navigateInApp(page, "new-session"); await pollLocatorText(placeTrigger.locator(".new-session-page__trigger-label")).toBe( @@ -459,75 +360,27 @@ suite.define(() => { await modelSelect.click(); await page.locator('[data-chat-model-provider="anthropic"]').click(); await page.locator('[data-chat-model-option="anthropic/claude-sonnet-4-6"]').click(); - const storedPreference = await page.evaluate(() => { - const key = Array.from({ length: localStorage.length }, (_, index) => - localStorage.key(index), - ).find((candidate) => candidate?.startsWith("openclaw.new-session.preferences.v1:")); - if (!key) { - return null; - } - const value = JSON.parse(localStorage.getItem(key) ?? "null") as { - agents?: Record; - } | null; - return value?.agents?.main ?? null; - }); + const storedPreference = await readMainPreference(page); expect(storedPreference).toMatchObject({ workspace: MOVED_WORKSPACE, folder: MOVED_WORKSPACE, model: "anthropic/claude-sonnet-4-6", }); - } finally { - await context.close(); - } + }); }); it("falls back to the current workspace when the remembered folder is gone", async () => { - const context = await suite.browser.newContext({ locale: "en-US", serviceWorkers: "block" }); - const page = await context.newPage(); - const gateway = await installMockGateway(page, { - workspace: WORKSPACE, - workspaceGit: true, - methodResponses: { - "agents.list": { - agents: [ - { - id: "main", - identity: { name: "Main" }, - name: "Main", - workspace: WORKSPACE, - workspaceGit: true, - }, - ], - defaultId: "main", - mainKey: "main", - scope: "agent", + await withNewSessionPage(BASE_CONTEXT, async (page) => { + const gateway = await installMockGateway(page, { + workspace: WORKSPACE, + workspaceGit: true, + methodResponses: { + "agents.list": mainAgentList(), + "worktrees.branches": GIT_BRANCHES, + "fs.listDir": FOLDER_LISTINGS, + "sessions.create": { key: "agent:main:stale-folder-fallback", runStarted: true }, }, - "worktrees.branches": { - branches: [{ kind: "local", name: "main" }], - defaultBranch: "main", - repositoryStatus: "git", - }, - "fs.listDir": { - cases: [ - { - match: { path: WORKSPACE }, - response: { - path: WORKSPACE, - parent: "/home/peter", - home: "/home/peter", - entries: [{ name: "packages", path: PICKED }], - }, - }, - { - match: { path: PICKED }, - response: { path: PICKED, parent: WORKSPACE, home: "/home/peter", entries: [] }, - }, - ], - }, - "sessions.create": { key: "agent:main:stale-folder-fallback", runStarted: true }, - }, - }); - try { + }); await page.goto(`${suite.server.baseUrl}new`); await choosePackagesFolder(page); @@ -588,58 +441,21 @@ suite.define(() => { await page.getByRole("button", { name: "Start thread" }).click(); const create = await gateway.waitForRequest("sessions.create"); expect(create.params).not.toHaveProperty("cwd"); - } finally { - await context.close(); - } + }); }); it("keeps a newer folder choice when remembered-folder validation finishes late", async () => { - const context = await suite.browser.newContext({ locale: "en-US", serviceWorkers: "block" }); - const page = await context.newPage(); - const gateway = await installMockGateway(page, { - workspace: WORKSPACE, - workspaceGit: true, - methodResponses: { - "agents.list": { - agents: [ - { - id: "main", - identity: { name: "Main" }, - name: "Main", - workspace: WORKSPACE, - workspaceGit: true, - }, - ], - defaultId: "main", - mainKey: "main", - scope: "agent", + await withNewSessionPage(BASE_CONTEXT, async (page) => { + const gateway = await installMockGateway(page, { + workspace: WORKSPACE, + workspaceGit: true, + methodResponses: { + "agents.list": mainAgentList(), + "worktrees.branches": GIT_BRANCHES, + "fs.listDir": FOLDER_LISTINGS, + "sessions.create": { key: "agent:main:newer-folder-wins", runStarted: true }, }, - "worktrees.branches": { - branches: [{ kind: "local", name: "main" }], - defaultBranch: "main", - repositoryStatus: "git", - }, - "fs.listDir": { - cases: [ - { - match: { path: WORKSPACE }, - response: { - path: WORKSPACE, - parent: "/home/peter", - home: "/home/peter", - entries: [{ name: "packages", path: PICKED }], - }, - }, - { - match: { path: PICKED }, - response: { path: PICKED, parent: WORKSPACE, home: "/home/peter", entries: [] }, - }, - ], - }, - "sessions.create": { key: "agent:main:newer-folder-wins", runStarted: true }, - }, - }); - try { + }); await page.goto(`${suite.server.baseUrl}new`); await choosePackagesFolder(page); @@ -671,41 +487,20 @@ suite.define(() => { await page.getByRole("button", { name: "Start thread" }).click(); const create = await gateway.waitForRequest("sessions.create"); expect(create.params).not.toHaveProperty("cwd"); - } finally { - await context.close(); - } + }); }); it("keeps a folder chosen before the agent roster finishes loading submit-ready", async () => { - const context = await suite.browser.newContext({ locale: "en-US", serviceWorkers: "block" }); - const page = await context.newPage(); - const gateway = await installMockGateway(page, { - deferredMethods: ["agents.list"], - workspaceGit: true, - methodResponses: { - "agents.list": { - agents: [ - { - id: "main", - identity: { name: "Main" }, - name: "Main", - workspace: WORKSPACE, - workspaceGit: true, - }, - ], - defaultId: "main", - mainKey: "main", - scope: "agent", + await withNewSessionPage(BASE_CONTEXT, async (page) => { + const gateway = await installMockGateway(page, { + deferredMethods: ["agents.list"], + workspaceGit: true, + methodResponses: { + "agents.list": mainAgentList(), + "fs.listDir": { path: TARGET_REPO, home: "/home/peter", entries: [] }, + "worktrees.branches": GIT_BRANCHES, }, - "fs.listDir": { path: TARGET_REPO, home: "/home/peter", entries: [] }, - "worktrees.branches": { - branches: [{ kind: "local", name: "main" }], - defaultBranch: "main", - repositoryStatus: "git", - }, - }, - }); - try { + }); await page.goto(`${suite.server.baseUrl}new`); const trigger = page.locator("#new-session-place-trigger"); await trigger.click(); @@ -716,20 +511,7 @@ suite.define(() => { await expect.poll(() => browserPath.inputValue()).toBe(TARGET_REPO); await browserPath.fill(TARGET_REPO); await page.getByRole("button", { name: "Use this folder" }).click(); - await gateway.resolveDeferred("agents.list", { - agents: [ - { - id: "main", - identity: { name: "Main" }, - name: "Main", - workspace: WORKSPACE, - workspaceGit: true, - }, - ], - defaultId: "main", - mainKey: "main", - scope: "agent", - }); + await gateway.resolveDeferred("agents.list", mainAgentList()); await page.locator(".new-session-page__message").fill("keep my early folder choice"); await expect @@ -738,21 +520,8 @@ suite.define(() => { await pollLocatorText(trigger.locator(".new-session-page__trigger-label")).toContain( "target-repo", ); - const storedPreference = await page.evaluate(() => { - const key = Array.from({ length: localStorage.length }, (_, index) => - localStorage.key(index), - ).find((candidate) => candidate?.startsWith("openclaw.new-session.preferences.v1:")); - if (!key) { - return null; - } - const value = JSON.parse(localStorage.getItem(key) ?? "null") as { - agents?: Record; - } | null; - return value?.agents?.main ?? null; - }); + const storedPreference = await readMainPreference(page); expect(storedPreference).toMatchObject({ folder: TARGET_REPO }); - } finally { - await context.close(); - } + }); }); }); diff --git a/ui/src/e2e/new-session-page.workspace-validation.e2e.test.ts b/ui/src/e2e/new-session-page.workspace-validation.e2e.test.ts index 895c50a76c73..47376adcd0ba 100644 --- a/ui/src/e2e/new-session-page.workspace-validation.e2e.test.ts +++ b/ui/src/e2e/new-session-page.workspace-validation.e2e.test.ts @@ -1,3 +1,4 @@ +import type { BrowserContextOptions, Page } from "playwright"; import { expect, it } from "vitest"; import { SOURCE_REPO, @@ -10,25 +11,85 @@ import { } from "./new-session-page.test-support.ts"; const suite = createNewSessionPageE2eSuite(); +const BASE_CONTEXT: BrowserContextOptions = { locale: "en-US", serviceWorkers: "block" }; +const DESKTOP_CONTEXT: BrowserContextOptions = { + ...BASE_CONTEXT, + viewport: { height: 900, width: 1280 }, +}; + +function mainAgentList(name = "Main", workspace = WORKSPACE) { + return { + agents: [ + { + id: "main", + identity: { name }, + name, + workspace, + workspaceGit: true, + }, + ], + defaultId: "main", + mainKey: "main", + scope: "agent", + }; +} + +function branchList(name = "main") { + return { + branches: [{ kind: "local", name }], + defaultBranch: name, + repositoryStatus: "git", + }; +} + +async function withNewSessionPage( + options: BrowserContextOptions, + run: (page: Page) => Promise, +): Promise { + const context = await suite.browser.newContext(options); + try { + await run(await context.newPage()); + } finally { + await context.close(); + } +} + +type MockGateway = Awaited>; + +async function chooseCustomFolder(page: Page, gateway: MockGateway) { + const trigger = page.locator("#new-session-place-trigger"); + const place = page.locator("wa-popover.new-session-page__place-popover"); + await trigger.click(); + await place.getByRole("button", { name: "Browse folders" }).click(); + await page.locator("input.new-session-page__browser-path").fill(TARGET_REPO); + await page.getByRole("button", { name: "Use this folder" }).click(); + await expect + .poll(async () => (await gateway.getRequests("worktrees.branches")).at(-1)?.params) + .toEqual({ repoRoot: TARGET_REPO, includeRepositoryStatus: true }); + return { place, trigger }; +} + +async function reconnectForBranchRediscovery(page: Page, gateway: MockGateway) { + const branchRequests = (await gateway.getRequests("worktrees.branches")).length; + await gateway.setOnline(false); + await page.locator(".sidebar-identity-card__subtitle").waitFor({ timeout: 10_000 }); + await gateway.setOnline(true); + await expect + .poll(async () => (await gateway.getRequests("worktrees.branches")).length) + .toBe(branchRequests + 1); +} suite.define(() => { it("preserves a selected workspace worktree when branch rediscovery is unavailable", async () => { - const context = await suite.browser.newContext({ locale: "en-US", serviceWorkers: "block" }); - const page = await context.newPage(); - const gateway = await installMockGateway(page, { - workspace: WORKSPACE, - workspaceGit: true, - methodResponses: { - "worktrees.branches": { - branches: [{ kind: "local", name: "main" }], - defaultBranch: "main", - repositoryStatus: "git", + await withNewSessionPage(BASE_CONTEXT, async (page) => { + const gateway = await installMockGateway(page, { + workspace: WORKSPACE, + workspaceGit: true, + methodResponses: { + "worktrees.branches": branchList(), + "sessions.create": { key: "agent:main:worktree-unavailable" }, }, - "sessions.create": { key: "agent:main:worktree-unavailable" }, - }, - }); - - try { + }); await page.goto(`${suite.server.baseUrl}new`); await gateway.waitForRequest("worktrees.branches"); const trigger = page.locator("#new-session-place-trigger"); @@ -66,39 +127,22 @@ suite.define(() => { message: "keep this task isolated", worktree: true, }); - } finally { - await context.close(); - } + }); }); it("clears a custom worktree when the folder becomes confirmed non-Git", async () => { - const context = await suite.browser.newContext({ locale: "en-US", serviceWorkers: "block" }); - const page = await context.newPage(); - const gateway = await installMockGateway(page, { - workspace: WORKSPACE, - workspaceGit: true, - methodResponses: { - "fs.listDir": { path: WORKSPACE, home: "/home/peter", entries: [] }, - "worktrees.branches": { - branches: [{ kind: "local", name: "main" }], - defaultBranch: "main", - repositoryStatus: "git", + await withNewSessionPage(BASE_CONTEXT, async (page) => { + const gateway = await installMockGateway(page, { + workspace: WORKSPACE, + workspaceGit: true, + methodResponses: { + "fs.listDir": { path: WORKSPACE, home: "/home/peter", entries: [] }, + "worktrees.branches": branchList(), + "sessions.create": { key: "agent:main:custom-now-direct" }, }, - "sessions.create": { key: "agent:main:custom-now-direct" }, - }, - }); - - try { + }); await page.goto(`${suite.server.baseUrl}new`); - const trigger = page.locator("#new-session-place-trigger"); - const place = page.locator("wa-popover.new-session-page__place-popover"); - await trigger.click(); - await place.getByRole("button", { name: "Browse folders" }).click(); - await page.locator("input.new-session-page__browser-path").fill(TARGET_REPO); - await page.getByRole("button", { name: "Use this folder" }).click(); - await expect - .poll(async () => (await gateway.getRequests("worktrees.branches")).at(-1)?.params) - .toEqual({ repoRoot: TARGET_REPO, includeRepositoryStatus: true }); + const { place, trigger } = await chooseCustomFolder(page, gateway); await trigger.click(); await place.getByRole("button", { name: "Worktree" }).click(); await page.keyboard.press("Escape"); @@ -108,13 +152,7 @@ suite.define(() => { branches: [], repositoryStatus: "not_git", }); - const branchRequests = (await gateway.getRequests("worktrees.branches")).length; - await gateway.setOnline(false); - await page.locator(".sidebar-identity-card__subtitle").waitFor({ timeout: 10_000 }); - await gateway.setOnline(true); - await expect - .poll(async () => (await gateway.getRequests("worktrees.branches")).length) - .toBe(branchRequests + 1); + await reconnectForBranchRediscovery(page, gateway); await expect.poll(() => trigger.getAttribute("data-worktree")).toBe("false"); const storedWorktree = await page.evaluate(() => { @@ -137,39 +175,22 @@ suite.define(() => { const create = await gateway.waitForRequest("sessions.create"); expect(create.params).toMatchObject({ cwd: TARGET_REPO, message: "continue directly" }); expect(create.params).not.toHaveProperty("worktree"); - } finally { - await context.close(); - } + }); }); it("allows clearing a custom worktree when Git rediscovery is unavailable", async () => { - const context = await suite.browser.newContext({ locale: "en-US", serviceWorkers: "block" }); - const page = await context.newPage(); - const gateway = await installMockGateway(page, { - workspace: WORKSPACE, - workspaceGit: true, - methodResponses: { - "fs.listDir": { path: WORKSPACE, home: "/home/peter", entries: [] }, - "worktrees.branches": { - branches: [{ kind: "local", name: "main" }], - defaultBranch: "main", - repositoryStatus: "git", + await withNewSessionPage(BASE_CONTEXT, async (page) => { + const gateway = await installMockGateway(page, { + workspace: WORKSPACE, + workspaceGit: true, + methodResponses: { + "fs.listDir": { path: WORKSPACE, home: "/home/peter", entries: [] }, + "worktrees.branches": branchList(), + "sessions.create": { key: "agent:main:custom-worktree-cleared" }, }, - "sessions.create": { key: "agent:main:custom-worktree-cleared" }, - }, - }); - - try { + }); await page.goto(`${suite.server.baseUrl}new`); - const trigger = page.locator("#new-session-place-trigger"); - const place = page.locator("wa-popover.new-session-page__place-popover"); - await trigger.click(); - await place.getByRole("button", { name: "Browse folders" }).click(); - await page.locator("input.new-session-page__browser-path").fill(TARGET_REPO); - await page.getByRole("button", { name: "Use this folder" }).click(); - await expect - .poll(async () => (await gateway.getRequests("worktrees.branches")).at(-1)?.params) - .toEqual({ repoRoot: TARGET_REPO, includeRepositoryStatus: true }); + const { place, trigger } = await chooseCustomFolder(page, gateway); await trigger.click(); await place.getByRole("button", { name: "Worktree" }).click(); await page.keyboard.press("Escape"); @@ -179,13 +200,7 @@ suite.define(() => { branches: [], repositoryStatus: "unavailable", }); - const branchRequests = (await gateway.getRequests("worktrees.branches")).length; - await gateway.setOnline(false); - await page.locator(".sidebar-identity-card__subtitle").waitFor({ timeout: 10_000 }); - await gateway.setOnline(true); - await expect - .poll(async () => (await gateway.getRequests("worktrees.branches")).length) - .toBe(branchRequests + 1); + await reconnectForBranchRediscovery(page, gateway); await expect.poll(() => trigger.getAttribute("data-worktree")).toBe("true"); await page.locator(".new-session-page__message").fill("do not run directly"); @@ -210,43 +225,26 @@ suite.define(() => { message: "do not run directly", }); expect(create.params).not.toHaveProperty("worktree"); - } finally { - await context.close(); - } + }); }); it("blocks a custom cloud worktree when Git rediscovery is unavailable", async () => { - const context = await suite.browser.newContext({ locale: "en-US", serviceWorkers: "block" }); - const page = await context.newPage(); - const gateway = await installMockGateway(page, { - workspace: WORKSPACE, - workspaceGit: true, - methodResponses: { - "environments.list": { - environments: [], - profiles: [{ id: "aws", providerId: "crabbox" }], + await withNewSessionPage(BASE_CONTEXT, async (page) => { + const gateway = await installMockGateway(page, { + workspace: WORKSPACE, + workspaceGit: true, + methodResponses: { + "environments.list": { + environments: [], + profiles: [{ id: "aws", providerId: "crabbox" }], + }, + "fs.listDir": { path: WORKSPACE, home: "/home/peter", entries: [] }, + "worktrees.branches": branchList(), }, - "fs.listDir": { path: WORKSPACE, home: "/home/peter", entries: [] }, - "worktrees.branches": { - branches: [{ kind: "local", name: "main" }], - defaultBranch: "main", - repositoryStatus: "git", - }, - }, - }); - - try { + }); await page.goto(`${suite.server.baseUrl}new`); await gateway.waitForRequest("environments.list"); - const trigger = page.locator("#new-session-place-trigger"); - const place = page.locator("wa-popover.new-session-page__place-popover"); - await trigger.click(); - await place.getByRole("button", { name: "Browse folders" }).click(); - await page.locator("input.new-session-page__browser-path").fill(TARGET_REPO); - await page.getByRole("button", { name: "Use this folder" }).click(); - await expect - .poll(async () => (await gateway.getRequests("worktrees.branches")).at(-1)?.params) - .toEqual({ repoRoot: TARGET_REPO, includeRepositoryStatus: true }); + const { place, trigger } = await chooseCustomFolder(page, gateway); await trigger.click(); await place.getByRole("button", { name: "Cloud ยท aws" }).click(); await expect.poll(() => trigger.getAttribute("data-cloud-profile")).toBe("aws"); @@ -256,13 +254,7 @@ suite.define(() => { branches: [], repositoryStatus: "unavailable", }); - const branchRequests = (await gateway.getRequests("worktrees.branches")).length; - await gateway.setOnline(false); - await page.locator(".sidebar-identity-card__subtitle").waitFor({ timeout: 10_000 }); - await gateway.setOnline(true); - await expect - .poll(async () => (await gateway.getRequests("worktrees.branches")).length) - .toBe(branchRequests + 1); + await reconnectForBranchRediscovery(page, gateway); await expect.poll(() => trigger.getAttribute("data-cloud-profile")).toBe("aws"); await expect.poll(() => trigger.getAttribute("data-worktree")).toBe("true"); @@ -279,54 +271,28 @@ suite.define(() => { expect(await worktree.getAttribute("aria-pressed")).toBe("true"); expect(await worktree.isDisabled()).toBe(true); expect(await gateway.getRequests("sessions.create")).toHaveLength(0); - } finally { - await context.close(); - } + }); }); it("validates a retained device before enabling submit after reconnect", async () => { - const context = await suite.browser.newContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - }); - const page = await context.newPage(); - const gateway = await installMockGateway(page, { - methodResponses: { - "agents.list": { - agents: [ - { - id: "main", - identity: { name: "Main" }, - name: "Main", - workspace: WORKSPACE, - workspaceGit: true, - }, - ], - defaultId: "main", - mainKey: "main", - scope: "agent", + await withNewSessionPage(DESKTOP_CONTEXT, async (page) => { + const gateway = await installMockGateway(page, { + methodResponses: { + "agents.list": mainAgentList(), + "node.list": { + nodes: [ + { + nodeId: "old-device", + displayName: "Old device", + connected: true, + commands: ["system.run", "fs.listDir"], + }, + ], + }, + "worktrees.branches": branchList(), + "sessions.create": { key: "agent:main:validated-device" }, }, - "node.list": { - nodes: [ - { - nodeId: "old-device", - displayName: "Old device", - connected: true, - commands: ["system.run", "fs.listDir"], - }, - ], - }, - "worktrees.branches": { - branches: [{ kind: "local", name: "main" }], - defaultBranch: "main", - repositoryStatus: "git", - }, - "sessions.create": { key: "agent:main:validated-device" }, - }, - }); - - try { + }); await page.goto(`${suite.server.baseUrl}new`); await gateway.waitForRequest("node.list"); const placeSelect = page.locator("wa-popover.new-session-page__place-popover"); @@ -352,53 +318,27 @@ suite.define(() => { const create = await gateway.waitForRequest("sessions.create"); expect(create.params).not.toHaveProperty("execNode"); expect(create.params).not.toHaveProperty("cwd"); - } finally { - await context.close(); - } + }); }); it("rediscovers Gateway-owned draft state when the app replaces its client", async () => { - const context = await suite.browser.newContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - }); - const page = await context.newPage(); - const gateway = await installMockGateway(page, { - methodResponses: { - "agents.list": { - agents: [ - { - id: "main", - identity: { name: "Original agent" }, - name: "Original agent", - workspace: SOURCE_REPO, - workspaceGit: true, - }, - ], - defaultId: "main", - mainKey: "main", - scope: "agent", + await withNewSessionPage(DESKTOP_CONTEXT, async (page) => { + const gateway = await installMockGateway(page, { + methodResponses: { + "agents.list": mainAgentList("Original agent", SOURCE_REPO), + "node.list": { + nodes: [ + { + nodeId: "old-device", + displayName: "Old device", + connected: true, + commands: ["system.run", "fs.listDir"], + }, + ], + }, + "worktrees.branches": branchList("alpha"), }, - "node.list": { - nodes: [ - { - nodeId: "old-device", - displayName: "Old device", - connected: true, - commands: ["system.run", "fs.listDir"], - }, - ], - }, - "worktrees.branches": { - branches: [{ kind: "local", name: "alpha" }], - defaultBranch: "alpha", - repositoryStatus: "git", - }, - }, - }); - - try { + }); await page.goto(`${suite.server.baseUrl}new`); await page.getByRole("heading", { name: "Original agent" }).waitFor(); await gateway.waitForRequest("node.list"); @@ -418,20 +358,10 @@ suite.define(() => { await placeSelect.getByRole("button", { name: "Browse folders" }).click(); await gateway.waitForRequest("fs.listDir"); - await gateway.setMethodResponse("agents.list", { - agents: [ - { - id: "main", - identity: { name: "Replacement agent" }, - name: "Replacement agent", - workspace: TARGET_REPO, - workspaceGit: true, - }, - ], - defaultId: "main", - mainKey: "main", - scope: "agent", - }); + await gateway.setMethodResponse( + "agents.list", + mainAgentList("Replacement agent", TARGET_REPO), + ); await gateway.setMethodResponse("node.list", { nodes: [ { @@ -442,11 +372,7 @@ suite.define(() => { }, ], }); - await gateway.setMethodResponse("worktrees.branches", { - branches: [{ kind: "local", name: "beta" }], - defaultBranch: "beta", - repositoryStatus: "git", - }); + await gateway.setMethodResponse("worktrees.branches", branchList("beta")); const socketsBefore = await gateway.getSocketCount(); const nodesBefore = (await gateway.getRequests("node.list")).length; const branchesBefore = (await gateway.getRequests("worktrees.branches")).length; @@ -494,46 +420,20 @@ suite.define(() => { ) .toBe(false); await expect.poll(() => message.inputValue()).toBe("preserve this replacement draft"); - } finally { - await context.close(); - } + }); }); for (const reconnectKind of ["same-client reconnect", "client replacement"] as const) { it(`marks a pending creation outcome unknown after ${reconnectKind}`, async () => { - const context = await suite.browser.newContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - }); - const page = await context.newPage(); - const sessionKey = `agent:main:unknown-${reconnectKind.replaceAll(" ", "-")}`; - const gateway = await installMockGateway(page, { - methodResponses: { - "agents.list": { - agents: [ - { - id: "main", - identity: { name: "Original agent" }, - name: "Original agent", - workspace: SOURCE_REPO, - workspaceGit: true, - }, - ], - defaultId: "main", - mainKey: "main", - scope: "agent", + await withNewSessionPage(DESKTOP_CONTEXT, async (page) => { + const sessionKey = `agent:main:unknown-${reconnectKind.replaceAll(" ", "-")}`; + const gateway = await installMockGateway(page, { + methodResponses: { + "agents.list": mainAgentList("Original agent", SOURCE_REPO), + "worktrees.branches": branchList(), + "sessions.create": { key: sessionKey }, }, - "worktrees.branches": { - branches: [{ kind: "local", name: "main" }], - defaultBranch: "main", - repositoryStatus: "git", - }, - "sessions.create": { key: sessionKey }, - }, - }); - - try { + }); await page.goto(`${suite.server.baseUrl}new`); await page.getByRole("heading", { name: "Original agent" }).waitFor(); const message = page.locator(".new-session-page__message"); @@ -545,20 +445,10 @@ suite.define(() => { await expect.poll(() => start.isDisabled()).toBe(true); if (reconnectKind === "client replacement") { - await gateway.setMethodResponse("agents.list", { - agents: [ - { - id: "main", - identity: { name: "Replacement agent" }, - name: "Replacement agent", - workspace: TARGET_REPO, - workspaceGit: true, - }, - ], - defaultId: "main", - mainKey: "main", - scope: "agent", - }); + await gateway.setMethodResponse( + "agents.list", + mainAgentList("Replacement agent", TARGET_REPO), + ); const socketsBefore = await gateway.getSocketCount(); await replaceGatewayClient(page); await expect.poll(() => gateway.getSocketCount()).toBe(socketsBefore + 1); @@ -582,66 +472,53 @@ suite.define(() => { .waitFor(); expect(new URL(page.url()).pathname).toBe("/new"); expect(await gateway.getRequests("sessions.create")).toHaveLength(1); - } finally { - await context.close(); - } + }); }); } it("resets agent-derived workspace state when retargeted to a catalog", async () => { - const context = await suite.browser.newContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - }); - const page = await context.newPage(); - const gateway = await installMockGateway(page, { - methodResponses: { - "agents.list": { - agents: [ - { - id: "main", - identity: { name: "Main" }, - name: "Main", - workspace: WORKSPACE, - workspaceGit: true, - }, - { - id: "research", - identity: { name: "Research" }, - name: "Research", - workspace: "/home/peter/research", - workspaceGit: true, - }, - ], - defaultId: "main", - mainKey: "main", - scope: "agent", - }, - "worktrees.branches": { - branches: [{ kind: "local", name: "main" }], - defaultBranch: "main", - repositoryStatus: "git", - }, - "sessions.catalog.list": { - catalogs: [ - { - id: "claude", - label: "Claude Code", - capabilities: { - continueSession: true, - archive: false, - createSession: { model: "anthropic/claude-opus-4-8" }, + await withNewSessionPage(DESKTOP_CONTEXT, async (page) => { + const gateway = await installMockGateway(page, { + methodResponses: { + "agents.list": { + agents: [ + { + id: "main", + identity: { name: "Main" }, + name: "Main", + workspace: WORKSPACE, + workspaceGit: true, }, - hosts: [], - }, - ], + { + id: "research", + identity: { name: "Research" }, + name: "Research", + workspace: "/home/peter/research", + workspaceGit: true, + }, + ], + defaultId: "main", + mainKey: "main", + scope: "agent", + }, + "worktrees.branches": branchList(), + "sessions.catalog.list": { + catalogs: [ + { + id: "claude", + label: "Claude Code", + capabilities: { + continueSession: true, + archive: false, + createSession: { model: "anthropic/claude-opus-4-8" }, + }, + hosts: [], + }, + ], + }, + "sessions.create": { key: "agent:main:claude-retarget" }, }, - "sessions.create": { key: "agent:main:claude-retarget" }, - }, - }); - - try { + }); await page.goto(`${suite.server.baseUrl}new?agent=research`); const folderLabel = page.locator( "#new-session-place-trigger .new-session-page__trigger-label", @@ -666,8 +543,6 @@ suite.define(() => { }); expect(create.params).not.toHaveProperty("model"); expect(create.params).not.toHaveProperty("cwd"); - } finally { - await context.close(); - } + }); }); });