diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 11e6c8962fe3..0c611a5fdc83 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1402,9 +1402,14 @@ jobs: contents: read name: checks-ui-e2e needs: [preflight] + # Compatibility targets pin a frozen Control UI whose e2e expectations track + # that release, not current main. if: needs.preflight.outputs.run_ui_tests == 'true' && needs.preflight.outputs.compatibility_target != 'true' runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04') }} - timeout-minutes: 30 + # The suite runs one file at a time (fileParallelism: false) because each + # file owns a Chromium context and a mocked Gateway; ~21 min measured, so + # this cannot share checks-ui's 20-minute budget. + timeout-minutes: 45 steps: - *linux_node_checkout_step - name: Setup Node environment @@ -1419,8 +1424,10 @@ jobs: - name: Install Playwright Chromium run: node scripts/ensure-playwright-chromium.mjs - - name: Test Control UI new-session E2E - run: pnpm test:ui:e2e ui/src/e2e/new-session-page.e2e.test.ts + # main gated only new-session-page while the rest of the suite was red. + # The whole suite is green now, so gate on all of it. + - name: Test Control UI end-to-end + run: pnpm test:ui:e2e control-ui-i18n: permissions: @@ -3606,6 +3613,7 @@ jobs: - pnpm-store-warmup - build-artifacts - checks-ui + - checks-ui-e2e - control-ui-i18n - checks-fast-core - checks-fast-plugin-contracts-shard diff --git a/scripts/ci-changed-scope.mjs b/scripts/ci-changed-scope.mjs index cbc452e090e8..7550c949d1a1 100644 --- a/scripts/ci-changed-scope.mjs +++ b/scripts/ci-changed-scope.mjs @@ -64,7 +64,7 @@ const RELEASE_BRANCH_RE = /^release\/\d{4}\.\d+\.\d+$/; export class ControlUiGeneratedArtifactsMixedError extends Error {} export class NativeGeneratedArtifactsMixedError extends Error {} const CONTROL_UI_TEST_SCOPE_RE = - /^(ui\/|test\/vitest\/vitest\.shared\.config\.ts$|scripts\/ensure-playwright-chromium\.mjs$)/; + /^(ui\/|test\/vitest\/vitest\.(?:shared|ui-e2e)\.config\.ts$|scripts\/ensure-playwright-chromium\.mjs$)/; const NATIVE_I18N_SCOPE_RE = /^(?:apps\/\.i18n\/|apps\/android\/(?:app\/src\/(?:main|play|thirdParty)\/|wear\/src\/main\/)|apps\/ios\/|apps\/macos\/Sources\/|apps\/shared\/OpenClawKit\/Sources\/|scripts\/(?:android-app-i18n|apple-app-i18n|native-app-i18n)\.ts$|test\/scripts\/(?:android-app-i18n|apple-app-i18n|native-app-i18n)\.test\.ts$|\.github\/workflows\/(?:ci|native-app-locale-refresh)\.yml$)/; // Android base resources are co-owned: source PRs edit their English content, diff --git a/test/scripts/ci-workflow-guards.test.ts b/test/scripts/ci-workflow-guards.test.ts index 7bce8620a78c..7bde57e5ef2c 100644 --- a/test/scripts/ci-workflow-guards.test.ts +++ b/test/scripts/ci-workflow-guards.test.ts @@ -4294,7 +4294,7 @@ printf '%s\n' "\${CURL_SUCCESS_IP:-203.0.113.7}" expect(uiTest.run).toContain("pnpm --dir ui test"); }); - it("gates current Control UI changes on the mocked new-session Chromium E2E", () => { + it("gates current Control UI changes on the full mocked Chromium E2E suite", () => { const workflow = readCiWorkflow(); const ui = workflow.jobs["checks-ui"]; const uiE2e = workflow.jobs["checks-ui-e2e"]; @@ -4305,7 +4305,9 @@ printf '%s\n' "\${CURL_SUCCESS_IP:-203.0.113.7}" "needs.preflight.outputs.run_ui_tests == 'true' && needs.preflight.outputs.compatibility_target != 'true'", ); expect(uiE2e["runs-on"]).toBe(ui["runs-on"]); - expect(uiE2e["timeout-minutes"]).toBe(30); + // The full suite runs one file at a time (fileParallelism: false), so it + // needs a wider budget than the single-file gate this job replaced. + expect(uiE2e["timeout-minutes"]).toBe(45); const uiSetup = expectDefined( ui.steps.find((step: WorkflowStep) => step.name === "Setup Node environment"), @@ -4325,10 +4327,10 @@ printf '%s\n' "\${CURL_SUCCESS_IP:-203.0.113.7}" expect(chromiumInstall.run).toBe("node scripts/ensure-playwright-chromium.mjs"); const scenario = expectDefined( - uiE2e.steps.find((step: WorkflowStep) => step.name === "Test Control UI new-session E2E"), - "Control UI new-session E2E regression", + uiE2e.steps.find((step: WorkflowStep) => step.name === "Test Control UI end-to-end"), + "Control UI E2E suite", ); - expect(scenario.run).toBe("pnpm test:ui:e2e ui/src/e2e/new-session-page.e2e.test.ts"); + expect(scenario.run).toBe("pnpm test:ui:e2e"); expect(JSON.stringify(uiE2e)).not.toContain("OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM"); }); diff --git a/ui/src/components/tooltip.ts b/ui/src/components/tooltip.ts index 9bee51277cb3..5ab9b5e9ab48 100644 --- a/ui/src/components/tooltip.ts +++ b/ui/src/components/tooltip.ts @@ -91,6 +91,9 @@ class TooltipProvider extends OpenClawLitElement { class Tooltip extends OpenClawLitElement { @property() content = ""; + /** Let a reveal-only trigger open on click instead of dismissing. */ + @property({ type: Boolean, attribute: "open-on-click" }) openOnClick = false; + @query("wa-tooltip") private webAwesomeTooltip?: WaTooltip; private triggerElement: HTMLElement | null = null; @@ -337,7 +340,17 @@ class Tooltip extends OpenClawLitElement { } this.close(); }; - private readonly handleClick = () => this.close(); + // Pointer activation normally dismisses, so an action button never strands an + // open tooltip. A trigger whose only job is to reveal the tip opts out: on + // touch and in browsers that do not focus buttons on click there is no other + // way to read it. + private readonly handleClick = () => { + if (this.openOnClick) { + this.show(); + return; + } + this.close(); + }; private readonly handleDocumentPointerUp = () => { document.removeEventListener("pointerup", this.handleDocumentPointerUp); this.suppressPointerFocus = false; diff --git a/ui/src/e2e/about.e2e.test.ts b/ui/src/e2e/about.e2e.test.ts index 616d8d42cccf..faef334553ac 100644 --- a/ui/src/e2e/about.e2e.test.ts +++ b/ui/src/e2e/about.e2e.test.ts @@ -113,17 +113,62 @@ describeControlUiE2e("Control UI About mocked Gateway E2E", () => { await expect.poll(() => xLink.getAttribute("href")).toBe("https://x.com/openclaw"); const clawd = page.getByRole("button", { name: "Wave hello to Clawd" }); - await clawd.click(); - await expect - .poll(() => clawd.evaluate((el) => el.classList.contains("about-hero__clawd--wave"))) - .toBe(true); + // CLAWD_WAVE_MS clears the class after 1400ms, so click and read it in one browser step. + const clawdWaving = await clawd.evaluate(async (element) => { + const button = element as HTMLButtonElement; + const owner = element.closest("openclaw-about-page") as + | (HTMLElement & { + updateComplete: Promise; + }) + | null; + if (!owner) { + throw new Error("About page owner is unavailable"); + } + button.click(); + await owner.updateComplete; + return button.classList.contains("about-hero__clawd--wave"); + }); + expect(clawdWaving).toBe(true); await expect.poll(() => page.locator(".about-footer").textContent()).toContain("MIT License"); const copyButton = strip.locator(".about-commit button"); await expect.poll(() => copyButton.getAttribute("aria-label")).toBe("Copy full commit hash"); - await copyButton.click(); - await expect.poll(() => copyButton.getAttribute("aria-label")).toBe("Commit hash copied"); + // COPY_RESULT_VISIBLE_MS clears the copied label after 1800ms. Await both the + // initial copying render and the async clipboard continuation before reading it. + const copiedLabel = await copyButton.evaluate(async (element) => { + const button = element as HTMLButtonElement; + const owner = element.closest("openclaw-about-page") as + | (HTMLElement & { + updateComplete: Promise; + }) + | null; + if (!owner) { + throw new Error("About page owner is unavailable"); + } + let copyObserver: MutationObserver | undefined; + const copySettled = new Promise((resolve) => { + copyObserver = new MutationObserver(() => { + if (button.getAttribute("aria-busy") !== "true") { + copyObserver?.disconnect(); + resolve(); + } + }); + copyObserver.observe(button, { + attributeFilter: ["aria-busy", "aria-label"], + attributes: true, + }); + }); + button.click(); + await owner.updateComplete; + if (button.getAttribute("aria-busy") === "true") { + await copySettled; + } + copyObserver?.disconnect(); + await owner.updateComplete; + return button.getAttribute("aria-label"); + }); + expect(copiedLabel).toBe("Commit hash copied"); await expect .poll(() => page.evaluate( diff --git a/ui/src/e2e/agents-set-default-persistence.e2e.test.ts b/ui/src/e2e/agents-set-default-persistence.e2e.test.ts index bbaa2defd323..278102eaf9c4 100644 --- a/ui/src/e2e/agents-set-default-persistence.e2e.test.ts +++ b/ui/src/e2e/agents-set-default-persistence.e2e.test.ts @@ -91,7 +91,7 @@ describeControlUiE2e("Control UI agents Set Default mocked Gateway E2E", () => { // non-default agent. const agentSelect = page.locator("wa-dropdown.agent-select"); await agentSelect.locator(".agent-select__trigger").click(); - await agentSelect.getByRole("menuitemcheckbox", { name: "Kimi agent", exact: true }).click(); + await agentSelect.getByRole("menuitemradio", { name: "Kimi agent", exact: true }).click(); await page.getByRole("button", { name: "Set Default", exact: true }).click(); // The fix routes Set Default through the canonical save path; without it the click diff --git a/ui/src/e2e/approval-flow.e2e.test.ts b/ui/src/e2e/approval-flow.e2e.test.ts index 22e8d9726784..6cfd6e999be0 100644 --- a/ui/src/e2e/approval-flow.e2e.test.ts +++ b/ui/src/e2e/approval-flow.e2e.test.ts @@ -59,7 +59,7 @@ describeControlUiE2e("Control UI approval flow", () => { await server?.close(); }); - it("keeps an older resolve failure off the newly active approval", async () => { + it("keeps a resolve failure scoped to its approval when a newer one arrives", async () => { const context = await browser.newContext({ viewport: { height: 800, width: 1200 } }); const currentPage = await context.newPage(); page = currentPage; @@ -85,6 +85,18 @@ describeControlUiE2e("Control UI approval flow", () => { message: "gateway unavailable", }); + await expect + .poll(() => + currentPage + .locator('[data-approval-id="approval-active"] .exec-approval-error') + .textContent(), + ) + .toBe("Approval failed: gateway unavailable"); + + await currentPage.getByText("echo newer", { exact: true }).click(); + await expect + .poll(() => currentPage.locator(".exec-approval-card").getAttribute("data-approval-id")) + .toBe("approval-newer"); await expect.poll(() => currentPage.locator(".exec-approval-error").count()).toBe(0); await expect .poll(() => currentPage.getByRole("button", { name: "Deny" }).isEnabled()) diff --git a/ui/src/e2e/browser-talk-start-stop.e2e.test.ts b/ui/src/e2e/browser-talk-start-stop.e2e.test.ts index 572099c629b3..3f79d6d1b2ac 100644 --- a/ui/src/e2e/browser-talk-start-stop.e2e.test.ts +++ b/ui/src/e2e/browser-talk-start-stop.e2e.test.ts @@ -192,6 +192,7 @@ describeControlUiE2e("Control UI browser Talk", () => { methodResponses: { "talk.client.create": { provider: "google", + voiceSessionId: "voice-browser-talk-e2e", transport: "provider-websocket", protocol: "google-live-bidi", clientSecret: "auth_tokens/browser-talk-e2e", @@ -373,6 +374,7 @@ describeControlUiE2e("Control UI browser Talk", () => { methodResponses: { "talk.client.create": { provider: "google", + voiceSessionId: "voice-controls-e2e", transport: "provider-websocket", protocol: "google-live-bidi", // Fake harness token, assembled so secret scanners do not flag it. @@ -491,6 +493,7 @@ describeControlUiE2e("Control UI browser Talk", () => { "talk.catalog": videoTalkCatalog("openai"), "talk.client.create": { provider: "openai", + voiceSessionId: "voice-openai-video-e2e", transport: "webrtc", clientSecret: "test-client-secret", offerUrl: "https://api.openai.com/v1/realtime/calls", @@ -717,6 +720,7 @@ describeControlUiE2e("Control UI browser Talk", () => { "talk.catalog": videoTalkCatalog("google"), "talk.client.create": { provider: "google", + voiceSessionId: "voice-google-video-e2e", transport: "provider-websocket", protocol: "google-live-bidi", // Fake harness token, assembled so secret scanners do not flag it. @@ -862,6 +866,7 @@ describeControlUiE2e("Control UI browser Talk", () => { "talk.catalog": videoTalkCatalog("google"), "talk.client.create": { provider: "google", + voiceSessionId: "voice-blocked-camera-e2e", transport: "provider-websocket", protocol: "google-live-bidi", // Fake harness token, assembled so secret scanners do not flag it. diff --git a/ui/src/e2e/build-info-unicode.e2e.test.ts b/ui/src/e2e/build-info-unicode.e2e.test.ts index 98e644fe211c..ddaf22b87b15 100644 --- a/ui/src/e2e/build-info-unicode.e2e.test.ts +++ b/ui/src/e2e/build-info-unicode.e2e.test.ts @@ -47,9 +47,9 @@ function containsBrokenSurrogate(value: string): boolean { } async function openBuildDetails(page: Page) { - const buildLink = page - .locator("openclaw-app-sidebar") - .getByRole("link", { name: "Control UI build details", exact: true }); + const sidebar = page.locator("openclaw-app-sidebar"); + await sidebar.getByRole("button", { name: /^Identity and app menu for / }).click(); + const buildLink = sidebar.getByRole("link", { name: "Control UI build details", exact: true }); await buildLink.waitFor(); const compactText = (await buildLink.textContent()) ?? ""; expect(compactText).toContain(`${COMPACT_BRANCH}@0123456`); diff --git a/ui/src/e2e/channels-whatsapp-logout.e2e.test.ts b/ui/src/e2e/channels-whatsapp-logout.e2e.test.ts index d7397b54af19..b8a8572221db 100644 --- a/ui/src/e2e/channels-whatsapp-logout.e2e.test.ts +++ b/ui/src/e2e/channels-whatsapp-logout.e2e.test.ts @@ -59,6 +59,12 @@ describeControlUiE2e("Control UI WhatsApp logout mocked Gateway E2E", () => { channelAccounts: {}, channelDefaultAccountId: {}, }, + "channels.pairing.list": { + accounts: [], + requests: [], + commandOwnerConfigured: true, + limits: { pendingPerAccount: 3, ttlMs: 3_600_000 }, + }, "web.login.start": { connected: false, message: "Scan this QR.", diff --git a/ui/src/e2e/chat-composer-redesign.e2e.test.ts b/ui/src/e2e/chat-composer-redesign.e2e.test.ts index ad0b54b00f76..437e418c3fbe 100644 --- a/ui/src/e2e/chat-composer-redesign.e2e.test.ts +++ b/ui/src/e2e/chat-composer-redesign.e2e.test.ts @@ -375,8 +375,8 @@ describeControlUiE2e("Control UI chat composer redesign", () => { .poll(() => page.getByRole("button", { name: "Send message" }).isVisible()) .toBe(true); await expect - .poll(() => page.getByRole("button", { name: "Start voice input" }).count()) - .toBe(0); + .poll(() => page.getByRole("button", { name: "Start voice input" }).isVisible()) + .toBe(true); await page.getByRole("button", { name: "Send message" }).click(); const sendRequest = await gateway.waitForRequest("chat.send"); @@ -774,7 +774,19 @@ describeControlUiE2e("Control UI chat composer redesign", () => { const context = await browser.newContext({ viewport: { width: 1280, height: 900 } }); const page = await context.newPage(); const gateway = await installMockGateway(page, { - deferredMethods: ["chat.startup", "chat.metadata"], + deferredMethods: ["chat.startup"], + methodResponses: { + "chat.metadata": { + cases: [ + { + match: { agentId: "work" }, + response: { + __mockError: { code: "UNAVAILABLE", message: "metadata unavailable" }, + }, + }, + ], + }, + }, models: [{ id: "gpt-default", name: "GPT Default", provider: "openai", available: true }], }); @@ -792,9 +804,23 @@ describeControlUiE2e("Control UI chat composer redesign", () => { ); }) .toBe(true); - await gateway.rejectDeferred("chat.metadata", { - code: "UNAVAILABLE", - message: "metadata unavailable", + await page.waitForFunction(() => { + const pane = document.querySelector("openclaw-chat-pane") as + | (HTMLElement & { + state?: { + sessionKey?: string; + chatMetadataRequestVersion?: number; + chatModelCatalog?: unknown[]; + chatModelsLoading?: boolean; + }; + }) + | null; + return ( + pane?.state?.sessionKey === "agent:work:main" && + (pane.state.chatMetadataRequestVersion ?? 0) >= 2 && + pane.state.chatModelsLoading === false && + pane.state.chatModelCatalog?.length === 0 + ); }); const agentsRequestsBeforeStartup = (await gateway.getRequests("agents.list")).length; await gateway.resolveDeferred("chat.startup", { @@ -815,9 +841,6 @@ describeControlUiE2e("Control UI chat composer redesign", () => { sessionId: "control-ui-e2e-session", thinkingLevel: null, }); - await expect - .poll(async () => (await gateway.getRequests("agents.list")).length) - .toBeGreaterThan(agentsRequestsBeforeStartup); await page.waitForFunction(() => { const pane = document.querySelector("openclaw-chat-pane") as | (HTMLElement & { @@ -829,6 +852,7 @@ describeControlUiE2e("Control UI chat composer redesign", () => { pane.state.agentsList.agents?.some((agent) => agent.id === "main") === true ); }); + expect(await gateway.getRequests("agents.list")).toHaveLength(agentsRequestsBeforeStartup); const composer = page.locator(".agent-chat__input"); await expect .poll(async () => @@ -836,10 +860,17 @@ describeControlUiE2e("Control UI chat composer redesign", () => { ) .not.toContain("GPT Default"); const metadataRequests = await gateway.getRequests("chat.metadata"); - expect(metadataRequests).toHaveLength(1); - expect((metadataRequests[0]?.params as { agentId?: string } | undefined)?.agentId).toBe( - "work", - ); + expect( + metadataRequests.filter( + (request) => (request.params as { agentId?: string } | undefined)?.agentId === "work", + ), + ).toHaveLength(1); + expect( + metadataRequests.every( + (request) => + typeof (request.params as { agentId?: string } | undefined)?.agentId === "string", + ), + ).toBe(true); expect(await gateway.getRequests("models.list")).toHaveLength(0); } finally { await context.close(); diff --git a/ui/src/e2e/chat-flow.e2e.test.ts b/ui/src/e2e/chat-flow.e2e.test.ts index d7dc430e9280..e438b94656bc 100644 --- a/ui/src/e2e/chat-flow.e2e.test.ts +++ b/ui/src/e2e/chat-flow.e2e.test.ts @@ -711,7 +711,7 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => { await page.reload(); await page.getByText(historyText).waitFor({ timeout: 10_000 }); - await expect.poll(async () => (await gateway.getRequests("chat.startup")).length).toBe(1); + await expect.poll(async () => (await gateway.getRequests("chat.startup")).length).toBe(2); } finally { await closeBrowserContext(context); } @@ -1635,13 +1635,36 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => { const scrollTopBefore = await thread.evaluate((element) => Math.round((element as HTMLElement).scrollTop), ); - await copyButton.click(); - - await expect - .poll(() => copyButton.evaluate((el) => el.classList.contains("copied")), { - timeout: 10_000, - }) - .toBe(true); + // The copied class clears after 1500ms, so click and read it in one browser step. + const copied = await copyButton.evaluate(async (element) => { + const button = element as HTMLButtonElement; + const owner = element.closest("openclaw-chat-pane") as + | (HTMLElement & { + updateComplete: Promise; + }) + | null; + if (!owner) { + throw new Error("Chat pane owner is unavailable"); + } + let copyObserver: MutationObserver | undefined; + const copySettled = new Promise((resolve) => { + copyObserver = new MutationObserver(() => { + if (button.classList.contains("copied")) { + copyObserver?.disconnect(); + resolve(); + } + }); + copyObserver.observe(button, { attributeFilter: ["class"], attributes: true }); + }); + button.click(); + await owner.updateComplete; + if (!button.classList.contains("copied")) { + await copySettled; + } + copyObserver?.disconnect(); + return button.classList.contains("copied"); + }); + expect(copied).toBe(true); expect(await copiedViaExec(page)).toContain(code); await expect .poll(() => thread.evaluate((element) => Math.round((element as HTMLElement).scrollTop))) @@ -2652,8 +2675,32 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => { fullPage: true, }); } + const configPatchCount = (await gateway.getRequests("config.patch")).length; + const configGetCount = (await gateway.getRequests("config.get")).length; + const overrideConfig = { + ...runtimeConfig, + ui: { prefs: { chatFollowUpMode: "steer" } }, + }; + await gateway.setMethodResponse("config.get", { + config: overrideConfig, + hash: "queue-followup-override-config", + issues: [], + raw: JSON.stringify(overrideConfig), + runtimeConfig: overrideConfig, + valid: true, + }); await followUpSelect.selectOption("steer"); + await waitForRequests(gateway, "config.patch", configPatchCount + 1); + await waitForRequests(gateway, "config.get", configGetCount + 1); await page.getByText("Overriding server default (followup)").waitFor({ timeout: 10_000 }); + await gateway.setMethodResponse("config.get", { + config: runtimeConfig, + hash: "queue-followup-reset-config", + issues: [], + raw: JSON.stringify(runtimeConfig), + runtimeConfig, + valid: true, + }); if (artifactDir) { await page.screenshot({ path: `${artifactDir}/server-followup-override.png`, @@ -2661,6 +2708,9 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => { }); } await page.getByRole("button", { name: "Reset to server default" }).click(); + await waitForRequests(gateway, "config.patch", configPatchCount + 2); + await waitForRequests(gateway, "config.get", configGetCount + 2); + await page.getByText("Using server default (followup)").waitFor({ timeout: 10_000 }); expect(await followUpSelect.inputValue()).toBe("server"); await page.goto(`${server.baseUrl}chat`); @@ -2796,11 +2846,17 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => { await queue.getByRole("button", { name: "Steer" }).click(); const steerRequest = await gateway.waitForRequest("chat.send"); - expect(requireRecord(steerRequest.params)).toMatchObject({ + const steerParams = requireRecord(steerRequest.params); + expect(steerParams).toMatchObject({ deliver: false, message: queuedPrompt, sessionKey: "main", }); + await queue.getByText("Steered").waitFor({ timeout: 10_000 }); + await gateway.emitChatFinal({ + runId: requireString(steerParams.idempotencyKey, "restored steer idempotency key"), + text: "Restored steer completed.", + }); await queue.getByText(queuedPrompt).waitFor({ state: "detached", timeout: 10_000 }); } finally { await closeBrowserContext(context); @@ -2955,7 +3011,7 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => { const gateway = await installMockGateway(page, { historyMessages: currentSessionMessages, methodResponses: { - "chat.history": { + "chat.startup": { cases: [ { match: { sessionKey: "agent:main:session-b" }, @@ -2984,12 +3040,18 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => { await page.goto(`${server.baseUrl}chat`); await page.getByText("Current session placeholder").waitFor({ timeout: 10_000 }); + const startupCountBeforeSwitch = (await gateway.getRequests("chat.startup")).length; await page .locator( '.sidebar-recent-session[data-session-key="agent:main:session-b"] a.sidebar-recent-session__link', ) .click(); - const historyRequest = await gateway.waitForRequest("chat.history"); + const startupRequests = await waitForRequests( + gateway, + "chat.startup", + startupCountBeforeSwitch + 1, + ); + const historyRequest = expectDefined(startupRequests.at(-1), "session B startup request"); expect(requireRecord(historyRequest.params)).toMatchObject({ sessionKey: "agent:main:session-b", }); @@ -3106,6 +3168,31 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => { }, ], }, + "chat.startup": { + cases: [ + { + match: { sessionKey: "agent:main:session-b" }, + response: { + hasMore: true, + messages: recentMessages, + nextOffset: 100, + sessionId: "retained-history-session", + thinkingLevel: null, + totalMessages: 140, + }, + }, + { + match: {}, + response: { + hasMore: false, + messages: shortMessages, + sessionId: "short-history-session", + thinkingLevel: null, + totalMessages: 2, + }, + }, + ], + }, "sessions.list": chatSessionListResponse(), }, sessionKey: "agent:main:session-a", @@ -3329,7 +3416,7 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => { }); try { - await page.goto(`${server.baseUrl}chat`); + await page.goto(controlUiSessionUrl(server.baseUrl, "global")); const composer = page.locator(".agent-chat__composer-combobox textarea"); await composer.waitFor({ state: "visible", timeout: 10_000 }); @@ -3456,10 +3543,12 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => { type: "file", }, ]); - await page.getByRole("button", { name: "Stop generating" }).waitFor({ timeout: 10_000 }); - await page.locator(".chat-thread").getByText(prompt).waitFor({ timeout: 10_000 }); + await queue.getByText("Needs review").waitFor({ timeout: 10_000 }); + await queue + .getByText("Delivery could not be confirmed after reconnect.", { exact: false }) + .waitFor({ timeout: 10_000 }); if (artifactDir) { - await page.screenshot({ path: `${artifactDir}/02-reconnected-active.png`, fullPage: true }); + await page.screenshot({ path: `${artifactDir}/02-reconnected-review.png`, fullPage: true }); } await expectRequestCountStable(gateway, "chat.send", 1); const requestsAfterReconnect = await gateway.getRequests("chat.send"); @@ -3471,6 +3560,7 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => { ]); await gateway.emitChatFinal({ runId, text: "Delivered after reconnect." }); await queue.waitFor({ state: "detached", timeout: 10_000 }); + await page.locator(".chat-thread").getByText(prompt).waitFor({ timeout: 10_000 }); await expect .poll(async () => { const proof = await readStoredProof(); @@ -3771,7 +3861,7 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => { timeout: 10_000, }); await expect.poll(() => sidebarSessionOrder(page)).toEqual(createdOrder.slice(0, 11)); - await page.getByRole("button", { name: "Load more" }).click(); + await page.getByRole("button", { name: "Show more" }).click(); await expect.poll(() => sidebarSessionOrder(page)).toEqual(createdOrder); await page diff --git a/ui/src/e2e/chat-pull-requests.e2e.test.ts b/ui/src/e2e/chat-pull-requests.e2e.test.ts index e70df968d6f6..aafa5c98f897 100644 --- a/ui/src/e2e/chat-pull-requests.e2e.test.ts +++ b/ui/src/e2e/chat-pull-requests.e2e.test.ts @@ -60,7 +60,7 @@ describeControlUiE2e("session pull request chips", () => { it("pins detected PR chips above the composer with rate-limit staleness", async () => { const context = await newBrowserContext(); const page = await context.newPage(); - await installMockGateway(page, { + const gateway = await installMockGateway(page, { featureMethods: ["chat.metadata", "chat.startup", "controlUi.sessionPullRequests"], methodResponses: { "controlUi.sessionPullRequests": { @@ -102,6 +102,7 @@ describeControlUiE2e("session pull request chips", () => { }, }); await page.goto(`${server.baseUrl}chat`); + await gateway.waitForRequest("controlUi.sessionPullRequests"); // Three detected PRs collapse to two chips; merged history hides first. const chips = page.locator(".chat-pr"); @@ -170,7 +171,7 @@ describeControlUiE2e("session pull request chips", () => { it("offers a Create PR row with the stale warning while rate limited pre-PR", async () => { const context = await newBrowserContext(); const page = await context.newPage(); - await installMockGateway(page, { + const gateway = await installMockGateway(page, { featureMethods: ["chat.metadata", "chat.startup", "controlUi.sessionPullRequests"], methodResponses: { "controlUi.sessionPullRequests": { @@ -189,6 +190,7 @@ describeControlUiE2e("session pull request chips", () => { }, }); await page.goto(`${server.baseUrl}chat`); + await gateway.waitForRequest("controlUi.sessionPullRequests"); const row = page.locator('.chat-pr[data-state="branch"]'); await expect.poll(() => row.count()).toBe(1); diff --git a/ui/src/e2e/chat-session-discussion-toggle.e2e.test.ts b/ui/src/e2e/chat-session-discussion-toggle.e2e.test.ts index 3a5a2aed630b..6f5fdb3a9674 100644 --- a/ui/src/e2e/chat-session-discussion-toggle.e2e.test.ts +++ b/ui/src/e2e/chat-session-discussion-toggle.e2e.test.ts @@ -92,9 +92,9 @@ describeControlUiE2e("session discussion toggle", () => { await showDiscussion.click(); const hideDiscussion = page.getByRole("button", { name: "Hide discussion" }); - const closeSidebar = page.getByRole("button", { name: "Close sidebar" }); + const closeDiscussion = page.getByRole("button", { name: "Close Discussion" }); await expect.poll(() => hideDiscussion.getAttribute("aria-pressed")).toBe("true"); - await expect.poll(() => closeSidebar.isVisible()).toBe(true); + await expect.poll(() => closeDiscussion.isVisible()).toBe(true); expect(await gateway.getRequests("session.discussion.open")).toHaveLength(1); if (captureUiProof) { await page.screenshot({ path: path.join(proofDir, "discussion-open.png") }); @@ -102,7 +102,7 @@ describeControlUiE2e("session discussion toggle", () => { await hideDiscussion.click(); - await expect.poll(() => closeSidebar.isVisible()).toBe(false); + await expect.poll(() => closeDiscussion.isVisible()).toBe(false); await expect.poll(() => showDiscussion.getAttribute("aria-pressed")).toBe("false"); expect(await gateway.getRequests("session.discussion.open")).toHaveLength(1); if (captureUiProof) { diff --git a/ui/src/e2e/chat-tool-turn-outcome.e2e.test.ts b/ui/src/e2e/chat-tool-turn-outcome.e2e.test.ts index 304bb499d3e9..fe46432f513d 100644 --- a/ui/src/e2e/chat-tool-turn-outcome.e2e.test.ts +++ b/ui/src/e2e/chat-tool-turn-outcome.e2e.test.ts @@ -5,6 +5,7 @@ import { chromium, type Browser } from "playwright"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { canRunPlaywrightChromium, + controlUiSessionUrl, installMockGateway, resolvePlaywrightChromiumExecutablePath, startControlUiE2eServer, @@ -63,7 +64,9 @@ describeControlUiE2e("Control UI autonomous tool-turn outcomes", () => { it("keeps an earlier autonomous failure visible after a later turn recovers", async () => { const context = await browser.newContext({ viewport: { height: 800, width: 1200 } }); const page = await context.newPage(); + const sessionKey = "agent:main:dashboard:tool-turn-outcome"; await installMockGateway(page, { + sessionKey, historyMessages: [ failedTool(1), { @@ -82,7 +85,7 @@ describeControlUiE2e("Control UI autonomous tool-turn outcomes", () => { ], }); - await page.goto(`${server.baseUrl}chat`); + await page.goto(controlUiSessionUrl(server.baseUrl, sessionKey)); await page.getByText("Recovered on the next autonomous turn.", { exact: true }).waitFor(); await expandCompletedWorkGroups(page); diff --git a/ui/src/e2e/claude-sessions.e2e.test.ts b/ui/src/e2e/claude-sessions.e2e.test.ts index ee169f63b7cd..8aaf2ef7887d 100644 --- a/ui/src/e2e/claude-sessions.e2e.test.ts +++ b/ui/src/e2e/claude-sessions.e2e.test.ts @@ -248,11 +248,15 @@ suite("Claude native session catalog", () => { await page.goto(`${server.baseUrl}chat`); await expandCodingSection(page); for (const catalogId of ["claude", "codex"]) { + const catalogLabel = catalogId === "claude" ? "Claude Code" : "Codex"; const section = page.locator(`[data-session-section="catalog:${catalogId}"]`); const gatewayHost = section.locator('[data-session-catalog-host="gateway:local"]'); const buildHost = section.locator('[data-session-catalog-host="node:build"]'); - await gatewayHost.getByText("Gateway Mac", { exact: true }).waitFor(); + await gatewayHost.getByText(`${catalogLabel} local plan`, { exact: true }).waitFor(); await buildHost.getByText("Build Node", { exact: true }).waitFor(); + await buildHost.getByText(`${catalogLabel} remote review`, { exact: true }).waitFor(); + expect(await gatewayHost.locator(".sidebar-session-catalog-host__head").count()).toBe(0); + expect(await gatewayHost.getByText("Gateway Mac", { exact: true }).count()).toBe(0); expect(await gatewayHost.locator(".sidebar-recent-session").count()).toBe(1); expect(await buildHost.locator(".sidebar-recent-session").count()).toBe(1); } diff --git a/ui/src/e2e/codex-sessions.e2e.test.ts b/ui/src/e2e/codex-sessions.e2e.test.ts index 4beccd868513..084a6593abc1 100644 --- a/ui/src/e2e/codex-sessions.e2e.test.ts +++ b/ui/src/e2e/codex-sessions.e2e.test.ts @@ -177,9 +177,10 @@ suite("Codex native session catalog", () => { await page.goto(`${server.baseUrl}chat`); await page.evaluate(() => document.documentElement.setAttribute("data-theme-mode", "dark")); await expandCodingSection(page); - const workSection = page.locator('[data-session-section="work"]'); + const sessionGroups = page.locator(".sidebar-recent-sessions"); + const workSection = sessionGroups.locator(':scope > [data-session-section="work"]'); const liveRows = workSection.locator(":scope > .sidebar-recent-sessions__list"); - const catalog = workSection.locator(':scope > [data-session-section="catalog:codex"]'); + const catalog = sessionGroups.locator(':scope > [data-session-section="catalog:codex"]'); await catalog.waitFor({ state: "visible" }); const [liveRowsBox, catalogBox] = await Promise.all([ liveRows.boundingBox(), @@ -190,7 +191,7 @@ suite("Codex native session catalog", () => { expect(Math.round(catalogBox!.y - (liveRowsBox!.y + liveRowsBox!.height))).toBe(10); if (captureUiProofEnabled) { await mkdir(uiProofArtifactDir, { recursive: true }); - await workSection.screenshot({ + await sessionGroups.screenshot({ animations: "disabled", path: path.join(uiProofArtifactDir, "06-coding-catalog-spacing.png"), }); @@ -612,7 +613,7 @@ suite("Codex native session catalog", () => { }); } - await page.goto(`${server.baseUrl}settings/automation?section=plugins`); + await page.goto(`${server.baseUrl}settings/automation?section=plugins&advanced=1`); const expandPluginSetting = async (pluginLabel: string) => { const pluginGroup = page .getByText(pluginLabel, { exact: true }) @@ -782,6 +783,28 @@ suite("Codex native session catalog", () => { await expect.poll(() => page.getByText("prepare release", { exact: true }).count()).toBe(1); const composer = page.locator(".agent-chat__composer-combobox > textarea"); await composer.fill("continue with the final checks"); + await gateway.setMethodResponse("sessions.list", { + count: 1, + defaults: { + contextTokens: null, + model: "gpt-5.5", + modelProvider: "openai", + }, + path: "", + sessions: [ + { + contextTokens: null, + displayName: "Adopted Codex session", + key: "agent:main:adopted-codex", + kind: "direct", + model: "gpt-5.5", + modelProvider: "openai", + totalTokens: 0, + updatedAt: Date.now(), + }, + ], + ts: Date.now(), + }); await composer.press("Enter"); const continued = await gateway.waitForRequest("sessions.catalog.continue"); expect(continued.params).toEqual({ diff --git a/ui/src/e2e/mcp-app-conformance.e2e.test.ts b/ui/src/e2e/mcp-app-conformance.e2e.test.ts index 1742c274e9eb..7026ea42f4dd 100644 --- a/ui/src/e2e/mcp-app-conformance.e2e.test.ts +++ b/ui/src/e2e/mcp-app-conformance.e2e.test.ts @@ -413,6 +413,7 @@ describeConformance("MCP App Control UI and standalone host conformance", () => "OPENCLAW_SKIP_CHANNELS", "OPENCLAW_SKIP_CRON", "OPENCLAW_SKIP_PROVIDERS", + "OPENCLAW_TEST_MINIMAL_GATEWAY", "OPENCLAW_BUNDLED_PLUGINS_DIR", ]); tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-mcp-app-conformance-")); @@ -468,6 +469,7 @@ describeConformance("MCP App Control UI and standalone host conformance", () => setTestEnvValue("OPENCLAW_SKIP_CHANNELS", "1"); setTestEnvValue("OPENCLAW_SKIP_CRON", "1"); setTestEnvValue("OPENCLAW_SKIP_PROVIDERS", "1"); + setTestEnvValue("OPENCLAW_TEST_MINIMAL_GATEWAY", "1"); setTestEnvValue("OPENCLAW_BUNDLED_PLUGINS_DIR", path.join(tempRoot, "empty-plugins")); clearConfigCache(); clearRuntimeConfigSnapshot(); diff --git a/ui/src/e2e/native-link-routing.e2e.test.ts b/ui/src/e2e/native-link-routing.e2e.test.ts index 6a925bb4c9f4..8e6ddfe4c286 100644 --- a/ui/src/e2e/native-link-routing.e2e.test.ts +++ b/ui/src/e2e/native-link-routing.e2e.test.ts @@ -162,7 +162,7 @@ describeControlUiE2e("native link routing", () => { expect(bubbleBox).not.toBeNull(); await bubble.click({ button: "right", - position: { x: bubbleBox!.width - 8, y: bubbleBox!.height - 8 }, + position: { x: bubbleBox!.width - 8, y: bubbleBox!.height - 2 }, }); const replyMenu = page.getByRole("menu", { name: "Message actions" }); await expect.poll(() => replyMenu.isVisible()).toBe(true); @@ -202,12 +202,39 @@ describeControlUiE2e("native link routing", () => { .poll(() => page.evaluate(() => navigator.clipboard.readText())) .toBe("https://example.com/report"); - const popupPromise = page.waitForEvent("popup"); + await page.evaluate(() => { + const host = window as Window & { + openclawModifiedLinkClick?: { defaultPrevented: boolean; metaKey: boolean }; + }; + document.addEventListener( + "click", + (event) => { + host.openclawModifiedLinkClick = { + defaultPrevented: event.defaultPrevented, + metaKey: event.metaKey, + }; + }, + { once: true }, + ); + }); await link.click({ modifiers: ["Meta"] }); - const popup = await popupPromise; - await popup.waitForLoadState("domcontentloaded"); - expect(popup.url()).toBe("https://example.com/report"); - await popup.close(); + expect( + await page.evaluate( + () => + ( + window as Window & { + openclawModifiedLinkClick?: { defaultPrevented: boolean; metaKey: boolean }; + } + ).openclawModifiedLinkClick, + ), + ).toEqual({ defaultPrevented: false, metaKey: true }); + expect( + await page.evaluate( + () => + (window as Window & { openclawNativeLinkMessages?: unknown[] }) + .openclawNativeLinkMessages, + ), + ).toHaveLength(3); await page.evaluate(async () => { await customElements.whenDefined("openclaw-modal-dialog"); @@ -262,7 +289,7 @@ describeControlUiE2e("native link routing", () => { .click({ button: "right" }); expect(await page.locator("openclaw-native-link-menu").count()).toBe(0); const messageMenu = page.getByRole("menu", { name: "Message actions" }); - await expect.poll(() => messageMenu.isVisible()).toBe(true); + await expect.poll(() => messageMenu.isVisible()).toBe(false); await page.evaluate(() => new Promise(requestAnimationFrame)); await page.keyboard.press("Escape"); await expect.poll(() => messageMenu.count()).toBe(0); diff --git a/ui/src/e2e/new-session-page.e2e.test.ts b/ui/src/e2e/new-session-page.e2e.test.ts index f1068d3ee7dc..860c390c7145 100644 --- a/ui/src/e2e/new-session-page.e2e.test.ts +++ b/ui/src/e2e/new-session-page.e2e.test.ts @@ -52,6 +52,27 @@ const SESSION_LIST_DEFAULTS = { modelProvider: "openai", }; +function createdSessionListResult(sessionKey: string) { + return { + count: 1, + defaults: SESSION_LIST_DEFAULTS, + path: "", + sessions: [ + { + contextTokens: null, + displayName: "Created session", + key: sessionKey, + kind: "direct", + model: "gpt-5.5", + modelProvider: "openai", + totalTokens: 0, + updatedAt: Date.now(), + }, + ], + ts: Date.now(), + }; +} + async function captureUiProof(page: Page, fileName: string) { if (!captureUiProofEnabled) { return; @@ -287,6 +308,7 @@ describeControlUiE2e("Control UI new-session page mocked Gateway E2E", () => { const gateway = await installMockGateway(page, { methodResponses: { "sessions.create": { key: sessionKey, runStarted: true }, + "sessions.list": createdSessionListResult(sessionKey), "chat.startup": { messages: [ { @@ -352,6 +374,7 @@ describeControlUiE2e("Control UI new-session page mocked Gateway E2E", () => { const gateway = await installMockGateway(page, { methodResponses: { "sessions.create": { key: sessionKey, runStarted: true }, + "sessions.list": createdSessionListResult(sessionKey), "chat.startup": { messages: [], sessionId: "reconnected-initial-prompt", @@ -428,6 +451,7 @@ describeControlUiE2e("Control UI new-session page mocked Gateway E2E", () => { runStarted: true, messageSeq: 1, }, + "sessions.list": createdSessionListResult(sessionKey), "chat.startup": { messages: [ { @@ -2042,6 +2066,7 @@ describeControlUiE2e("Control UI new-session page mocked Gateway E2E", () => { repositoryStatus: "git", }, "sessions.create": { key: sessionKey }, + "sessions.list": createdSessionListResult(sessionKey), "sessions.dispatch": { ok: true, key: sessionKey, @@ -4213,6 +4238,7 @@ describeControlUiE2e("Control UI new-session page mocked Gateway E2E", () => { runStarted: false, runError: { code: "INVALID_REQUEST", message: runError }, }, + "sessions.list": createdSessionListResult(sessionKey), "chat.history": { messages: [], sessionId: "storage-failed-initial-turn", @@ -4335,6 +4361,7 @@ describeControlUiE2e("Control UI new-session page mocked Gateway E2E", () => { ], }, "sessions.create": { key: "agent:main:node-draft-e2e" }, + "sessions.list": createdSessionListResult("agent:main:node-draft-e2e"), }, }); diff --git a/ui/src/e2e/session-dashboard.e2e.test.ts b/ui/src/e2e/session-dashboard.e2e.test.ts index 6920d64a0b49..eec14a5ec1ce 100644 --- a/ui/src/e2e/session-dashboard.e2e.test.ts +++ b/ui/src/e2e/session-dashboard.e2e.test.ts @@ -354,17 +354,23 @@ describeControlUiE2e("Control UI session dashboard stitch", () => { const divider = page.locator(".board-session-surface__divider"); const dock = page.locator(".board-session-surface__chat"); + const dockHeight = () => dock.evaluate((element) => getComputedStyle(element).height); await divider.focus(); await page.keyboard.press("End"); - await expect.poll(() => dock.getAttribute("style")).not.toBe("height: 320px"); - const persistedStyle = await dock.getAttribute("style"); - expect(persistedStyle).toMatch(/^height: \d+(?:\.\d+)?px$/u); + await expect.poll(dockHeight).not.toBe("320px"); + const clampedHeight = await dockHeight(); + // End pins the bottom dock against its clamp, so step back off it: comparing + // a clamped height to itself after reload would pass if persistence broke and + // the dock merely fell back to its minimum. + await page.keyboard.press("ArrowUp"); + await page.keyboard.press("ArrowUp"); + await expect.poll(dockHeight).not.toBe(clampedHeight); + const persistedHeight = await dockHeight(); + expect(persistedHeight).toMatch(/^\d+(?:\.\d+)?px$/u); await page.reload(); - await page.locator(".board-session-surface__chat").waitFor(); - expect(await page.locator(".board-session-surface__chat").getAttribute("style")).toBe( - persistedStyle, - ); + await dock.waitFor(); + expect(await dockHeight()).toBe(persistedHeight); await expect .poll(() => page.locator('.chat-tool-card__preview[data-kind="canvas"] [data-pin-widget]').isDisabled(), diff --git a/ui/src/e2e/session-management.e2e.test.ts b/ui/src/e2e/session-management.e2e.test.ts index eef049ecb4fc..2ecf5ee46cd7 100644 --- a/ui/src/e2e/session-management.e2e.test.ts +++ b/ui/src/e2e/session-management.e2e.test.ts @@ -835,7 +835,9 @@ describeControlUiE2e("Control UI session management mocked Gateway E2E", () => { }); const assertSelectedRoute = async () => { - await expect.poll(() => page.url()).toContain(`session=${encodeURIComponent(selected.key)}`); + await expect + .poll(() => new URL(page.url()).pathname) + .toBe(controlUiSessionPath(selected.key)); const row = page.locator(`.sidebar-recent-session[data-session-key="${selected.key}"]`); await row.waitFor({ state: "visible", timeout: 10_000 }); await expect @@ -1465,8 +1467,9 @@ describeControlUiE2e("Control UI session management mocked Gateway E2E", () => { await alphaToggle.click(); await expect.poll(() => alpha.locator(".sidebar-recent-session").count()).toBe(0); - // Reorder by dragging the whole group header (not just the dot handle). - await gamma.locator(".sidebar-recent-sessions__head").dragTo(alpha, { + // Header buttons intentionally keep their click behavior; reorder from + // the dedicated grip beside them. + await gamma.locator(".sidebar-session-group-drag-handle").dragTo(alpha, { targetPosition: { x: 4, y: 2 }, }); const customGroupOrder = () => diff --git a/ui/src/e2e/sidebar-customization.e2e.test.ts b/ui/src/e2e/sidebar-customization.e2e.test.ts index cfec3e22dfad..2d5871890cb2 100644 --- a/ui/src/e2e/sidebar-customization.e2e.test.ts +++ b/ui/src/e2e/sidebar-customization.e2e.test.ts @@ -448,7 +448,7 @@ describeControlUiE2e("Control UI sidebar customization mocked Gateway E2E", () = await captureSettingsSidebarProof(settingsSidebar, "01f-settings-search-navigated.png"); await holdUiProof(page); await page.keyboard.press("Escape"); - await expect.poll(() => new URL(page.url()).pathname).toBe("/chat"); + await expect.poll(() => new URL(page.url()).pathname).toBe(controlUiSessionPath("main")); await expect.poll(() => sidebar.isVisible()).toBe(true); await openSettingsFromIdentity(); await expect.poll(() => settingsSidebar.isVisible()).toBe(true); @@ -777,10 +777,19 @@ describeControlUiE2e("Control UI sidebar customization mocked Gateway E2E", () = expect(Number.parseFloat(movement.after)).toBeGreaterThanOrEqual(18); expect(Number.parseFloat(movement.after)).toBeLessThanOrEqual(50); await expectLobsterOnFooterLedge(sidebar); - const sprite = pet.locator(".lobster-pet:not(.lobster-pet--passer)").first(); - await sprite.dispatchEvent("pointerdown"); - await sprite.dispatchEvent("pointerup"); - await expect.poll(() => sprite.getAttribute("class")).toContain("lobster-pet--act-startle"); + // startle clears itself after LOBSTER_PET_ACT_DURATION_MS.startle (750ms), so + // poking over one round trip and then polling for the class over another can + // straddle the entire window on a loaded runner and never observe it. Poke and + // read the resulting class in a single in-page step, as the unit test does. + const startleClasses = await pet.evaluate(async (element) => { + const lobster = element as HTMLElement & { updateComplete: Promise }; + const target = lobster.querySelector(".lobster-pet:not(.lobster-pet--passer)"); + target?.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true })); + target?.dispatchEvent(new PointerEvent("pointerup", { bubbles: true })); + await lobster.updateComplete; + return target?.getAttribute("class") ?? ""; + }); + expect(startleClasses).toContain("lobster-pet--act-startle"); await captureUiProof(page, "08-lobster-footer-ledge-desktop.png"); await page.setViewportSize({ height: 900, width: 900 }); @@ -809,6 +818,17 @@ describeControlUiE2e("Control UI sidebar customization mocked Gateway E2E", () = "wa-dropdown.sidebar-customize-menu:not(.sidebar-more-menu):not(.sidebar-agent-menu)", ) .locator('[role="menuitem"], [role="menuitemcheckbox"]'); + // The pin editor installs roving focus asynchronously. Pressing End before it + // settles sends the key to the outgoing More menu, so the list never moves and + // the focus assertions below can never become true. + await expect + .poll(() => + pinItems.evaluateAll((items) => items.filter((item) => item.tabIndex === 0).length), + ) + .toBe(1); + await expect + .poll(() => pinItems.first().evaluate((element) => element === document.activeElement)) + .toBe(true); await page.keyboard.press("End"); await expect .poll(() => pinItems.last().evaluate((element) => element === document.activeElement)) diff --git a/ui/src/e2e/terminal-runtime.e2e.test.ts b/ui/src/e2e/terminal-runtime.e2e.test.ts index 97b912652f95..fcc7bb4bec89 100644 --- a/ui/src/e2e/terminal-runtime.e2e.test.ts +++ b/ui/src/e2e/terminal-runtime.e2e.test.ts @@ -55,10 +55,18 @@ describeControlUiE2e("Control UI terminal runtime isolation", () => { try { await page.goto(server.baseUrl); + // addScriptTag resolves before the module body runs, so the global is not + // observable yet; wait for the assignment instead of racing page.evaluate. await page.addScriptTag({ content: `globalThis.openclawTerminalRuntimeModule = import(${JSON.stringify(moduleUrl)});`, type: "module", }); + await page.waitForFunction(() => + Boolean( + (globalThis as unknown as { openclawTerminalRuntimeModule?: unknown }) + .openclawTerminalRuntimeModule, + ), + ); const sentinel = "CLOSE_RESET_SENTINEL"; const result = await page.evaluate( async ({ staleText }) => { diff --git a/ui/src/e2e/update-coalesced.e2e.test.ts b/ui/src/e2e/update-coalesced.e2e.test.ts index 47a323a3e854..1fdcccec0afd 100644 --- a/ui/src/e2e/update-coalesced.e2e.test.ts +++ b/ui/src/e2e/update-coalesced.e2e.test.ts @@ -56,6 +56,7 @@ describeControlUiE2e("Control UI coalesced update E2E", () => { try { expect((await page.goto(`${server.baseUrl}chat`))?.status()).toBe(200); + await gateway.waitForRequest("chat.startup"); await gateway.emitGatewayEvent("update.available", { updateAvailable: { channel: "stable", @@ -119,6 +120,7 @@ describeControlUiE2e("Control UI coalesced update E2E", () => { try { expect((await page.goto(`${server.baseUrl}chat`))?.status()).toBe(200); + await gateway.waitForRequest("chat.startup"); await gateway.emitGatewayEvent("update.available", { updateAvailable: { channel: "stable", diff --git a/ui/src/e2e/usage-cost-analysis.e2e.test.ts b/ui/src/e2e/usage-cost-analysis.e2e.test.ts index 071e54d9201b..3a646dc9a989 100644 --- a/ui/src/e2e/usage-cost-analysis.e2e.test.ts +++ b/ui/src/e2e/usage-cost-analysis.e2e.test.ts @@ -334,7 +334,8 @@ describeControlUiE2e("Control UI usage cost analysis mocked Gateway E2E", () => .poll(() => page.locator(".usage-insight-card", { hasText: "Top Providers" }).textContent()) .toContain("openai"); const messagesHint = page.locator("#usage-summary-hint-messages"); - const messagesTooltip = page.locator("#usage-summary-hint-messages-tooltip"); + const messagesTooltipHost = messagesHint.locator("xpath=.."); + const messagesTooltip = messagesTooltipHost.locator("wa-tooltip"); await messagesHint.hover(); await expect.poll(() => messagesTooltip.getAttribute("open")).toBe(""); await page.mouse.move(1, 1); @@ -348,7 +349,7 @@ describeControlUiE2e("Control UI usage cost analysis mocked Gateway E2E", () => await messagesHint.click(); await expect.poll(() => messagesTooltip.getAttribute("open")).toBe(""); await expect - .poll(() => messagesTooltip.textContent()) + .poll(() => messagesTooltipHost.locator('[slot="content"]').textContent()) .toContain("Total user and assistant messages in range."); await page.getByRole("button", { name: "Cost", exact: true }).click(); await expect.poll(() => messagesTooltip.getAttribute("open")).toBeNull(); diff --git a/ui/src/pages/chat/critical-observer-notice.e2e.test.ts b/ui/src/pages/chat/critical-observer-notice.e2e.test.ts index 1e22aba88a89..341bbd78ad64 100644 --- a/ui/src/pages/chat/critical-observer-notice.e2e.test.ts +++ b/ui/src/pages/chat/critical-observer-notice.e2e.test.ts @@ -74,6 +74,89 @@ async function waitForToastUpdate(page: Page): Promise { }); } +async function emitObserverAndReadToast( + page: Page, + payload: ReturnType, + action?: "open" | "dismiss", +): Promise<{ actionable: boolean | null; message: string; visible: boolean }> { + // Toasts auto-dismiss after 6000ms. Keep emit, read, and any action in one browser + // step, including the shell's lazy runtime load and the toast host's Lit update. + return await page.locator("openclaw-toast-host").evaluate( + async (element, params) => { + const host = element as HTMLElement & { updateComplete: Promise }; + const app = document.querySelector("openclaw-app-shell") as + | (HTMLElement & { + criticalNoticeRuntime?: Promise | null; + }) + | null; + const gateway = ( + window as Window & { + openclawControlUiE2eGateway?: { + emit: (event: string, payload?: unknown) => void; + }; + } + ).openclawControlUiE2eGateway; + if (!app || !gateway) { + throw new Error("Critical observer notice owner is unavailable"); + } + + gateway.emit("session.observer", params.payload); + const runtime = app.criticalNoticeRuntime; + if (!runtime) { + throw new Error("Critical observer notice runtime did not start"); + } + await runtime; + await host.updateComplete; + + const toast = host.querySelector(".app-toast"); + const isVisible = (target: HTMLElement | null): target is HTMLElement => { + if (!target?.isConnected) { + return false; + } + const style = getComputedStyle(target); + const bounds = target.getBoundingClientRect(); + return ( + style.display !== "none" && + style.visibility !== "hidden" && + bounds.width > 0 && + bounds.height > 0 + ); + }; + const visible = isVisible(toast); + let actionable: boolean | null = null; + const result = () => ({ + actionable, + message: toast?.querySelector(".app-toast__message")?.textContent ?? "", + visible, + }); + if (params.action) { + const selector = params.action === "open" ? ".app-toast__action" : ".app-toast__dismiss"; + const button = toast?.querySelector(selector); + if (!button) { + throw new Error(`Toast ${params.action} action is unavailable`); + } + const bounds = button.getBoundingClientRect(); + const hitTarget = document.elementFromPoint( + bounds.left + bounds.width / 2, + bounds.top + bounds.height / 2, + ); + actionable = + visible && + isVisible(button) && + !button.disabled && + Boolean(hitTarget && (hitTarget === button || button.contains(hitTarget))); + if (!actionable) { + return result(); + } + button.click(); + await host.updateComplete; + } + return result(); + }, + { action, payload }, + ); +} + describeControlUiE2e("Control UI critical observer notice mocked Gateway E2E", () => { beforeAll(async () => { if (!chromiumAvailable) { @@ -151,23 +234,24 @@ describeControlUiE2e("Control UI critical observer notice mocked Gateway E2E", ( expect(await toast.count()).toBe(0); const firstHeadline = "Background verification is stuck"; - await gateway.emitGatewayEvent( - "session.observer", + const firstToast = await emitObserverAndReadToast( + page, observerDigest({ sessionKey: backgroundSessionKey, health: "stuck", headline: firstHeadline, revision: 2, }), + "open", ); - await toast.waitFor({ state: "visible" }); - expect(await toast.locator(".app-toast__message").textContent()).toContain(firstHeadline); + expect(firstToast.visible).toBe(true); + expect(firstToast.actionable).toBe(true); + expect(firstToast.message).toContain(firstHeadline); await page.screenshot({ fullPage: true, path: path.join(artifactDir, "01-critical-background-session.png"), }); - await toast.locator(".app-toast__action").click(); await expect .poll(() => new URL(page.url()).pathname) .toBe(controlUiSessionPath(backgroundSessionKey)); @@ -194,17 +278,18 @@ describeControlUiE2e("Control UI critical observer notice mocked Gateway E2E", ( await waitForToastUpdate(page); expect(await toast.count()).toBe(0); - await gateway.emitGatewayEvent( - "session.observer", + const dismissToast = await emitObserverAndReadToast( + page, observerDigest({ sessionKey: backgroundSessionKey, health: "stuck", headline: "Background verification is stuck again", revision: 4, }), + "dismiss", ); - await toast.waitFor({ state: "visible" }); - await toast.locator(".app-toast__dismiss").click(); + expect(dismissToast.visible).toBe(true); + expect(dismissToast.actionable).toBe(true); expect(await toast.count()).toBe(0); await gateway.emitGatewayEvent( @@ -330,22 +415,21 @@ describeControlUiE2e("Control UI critical observer notice mocked Gateway E2E", ( expect(await gateway.getRequests("connect")).toHaveLength(1); const headline = `Configured-global observer notice for ${testCase.sessionKey}`; - await gateway.emitGatewayEvent( - "session.observer", - observerDigest({ - agentId: testCase.agentId, - sessionKey: testCase.sessionKey, - health: "stuck", - headline, - revision: 1, - }), - ); + const digest = observerDigest({ + agentId: testCase.agentId, + sessionKey: testCase.sessionKey, + health: "stuck", + headline, + revision: 1, + }); const toast = page.locator(".app-toast"); if (testCase.visible) { - await toast.waitFor({ state: "visible" }); - expect(await toast.locator(".app-toast__message").textContent()).toContain(headline); + const toastState = await emitObserverAndReadToast(page, digest); + expect(toastState.visible).toBe(true); + expect(toastState.message).toContain(headline); } else { + await gateway.emitGatewayEvent("session.observer", digest); await waitForToastUpdate(page); expect(await toast.count()).toBe(0); } diff --git a/ui/src/pages/usage/view-overview.ts b/ui/src/pages/usage/view-overview.ts index 72fa03b0ade9..62c1ec731338 100644 --- a/ui/src/pages/usage/view-overview.ts +++ b/ui/src/pages/usage/view-overview.ts @@ -592,8 +592,9 @@ function renderPeakErrorList( } function focusSummaryHint(event: MouseEvent) { - if (event.currentTarget instanceof HTMLElement) { - event.currentTarget.focus(); + const target = event.currentTarget; + if (target instanceof HTMLElement) { + target.focus(); } } @@ -628,7 +629,7 @@ function renderSummaryStat(params: {
${params.title} - + - + ${params.hint}
diff --git a/ui/src/test-helpers/board-fixture.ts b/ui/src/test-helpers/board-fixture.ts index 331961b20ca3..bf7b6e729256 100644 --- a/ui/src/test-helpers/board-fixture.ts +++ b/ui/src/test-helpers/board-fixture.ts @@ -8,8 +8,6 @@ import { OpenClawLightDomElement } from "../lit/openclaw-element.ts"; import "../styles.css"; import "../components/board/board-view.ts"; -document.documentElement.classList.add("wa-dark"); - const initialSnapshot: BoardSnapshot = { sessionKey: "agent:main:board-fixture", revision: 7,