From f166d4ee988fa3977b4f49dc6951bed93c8396b7 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 11 Aug 2026 22:26:52 -0700 Subject: [PATCH] fix(ui): repair shared test isolation (#122451) --- test/vitest/vitest.ui-isolated-paths.mjs | 1 + ui/src/components/sidebar-update-card.test.ts | 23 ++++++------- ui/src/components/tooltip.test.ts | 18 ++++++++++ ui/src/components/tooltip.ts | 10 +++--- .../chat/chat-pane-task-suggestions.test.ts | 34 +++++++++++++++---- ui/src/test-helpers/modal-dialog.ts | 7 ++++ 6 files changed, 70 insertions(+), 23 deletions(-) diff --git a/test/vitest/vitest.ui-isolated-paths.mjs b/test/vitest/vitest.ui-isolated-paths.mjs index 881ed3adc149..574cd989d0d2 100644 --- a/test/vitest/vitest.ui-isolated-paths.mjs +++ b/test/vitest/vitest.ui-isolated-paths.mjs @@ -6,6 +6,7 @@ export const uiIsolatedTestFiles = [ "ui/src/app/bootstrap.test.ts", "ui/src/app/router-outlet.test.ts", "ui/src/components/resizable-divider.test.ts", + "ui/src/components/sidebar-update-card.test.ts", "ui/src/components/viewer-facepile.test.ts", "ui/src/pages/agents/memory/memory-panel.test.ts", "ui/src/pages/chat/chat-page-attachment-handoff.test.ts", diff --git a/ui/src/components/sidebar-update-card.test.ts b/ui/src/components/sidebar-update-card.test.ts index 89a18c151329..d2f691410b79 100644 --- a/ui/src/components/sidebar-update-card.test.ts +++ b/ui/src/components/sidebar-update-card.test.ts @@ -7,9 +7,10 @@ import { NATIVE_UPDATE_DECLINED_EVENT, } from "../app/native-link-routing.ts"; import { + answerConfirmDialog, + cancelOpenModalDialogs, installDialogPolyfill, - nextFrame, - waitForRenderedModalDialog, + waitForConfirmDialogActions, } from "../test-helpers/modal-dialog.ts"; import { createStorageMock } from "../test-helpers/storage.ts"; import "./sidebar-update-card.ts"; @@ -20,15 +21,9 @@ const DISMISS_KEY = "openclaw:control-ui:update-banner-dismissed:v1"; async function resolveUpdateConfirmation( label: "Cancel" | "Update and restart" | "Update Mac app and restart", ) { - const { modal } = await waitForRenderedModalDialog(document.body); - const button = [...modal.querySelectorAll("button")].find( - (candidate) => candidate.textContent?.trim() === label, - ); - if (!(button instanceof HTMLButtonElement)) { - throw new Error(`Expected ${label} button in the update confirmation`); - } - button.click(); - await nextFrame(); + const actions = await waitForConfirmDialogActions(); + expect(actions.textContent).toContain(label); + answerConfirmDialog(actions, label === "Cancel" ? "cancel" : "confirm"); } type SidebarUpdateCardElement = HTMLElement & { @@ -79,6 +74,7 @@ beforeEach(() => { afterEach(() => { vi.useRealTimers(); + cancelOpenModalDialogs(); document.body.replaceChildren(); restoreDialogPolyfill(); if (originalLocalStorage) { @@ -91,6 +87,7 @@ afterEach(() => { } else { Reflect.deleteProperty(window, "webkit"); } + vi.resetModules(); }); describe("SidebarUpdateCard", () => { @@ -163,7 +160,7 @@ describe("SidebarUpdateCard", () => { expect(element.querySelector(".sidebar-update-card__subtitle")).toBeNull(); expect(element.querySelector(".sidebar-update-card__arrow")).toBeNull(); action?.click(); - await nextFrame(); + await waitForConfirmDialogActions(); expect(onUpdate).not.toHaveBeenCalled(); await resolveUpdateConfirmation("Update and restart"); @@ -239,7 +236,7 @@ describe("SidebarUpdateCard", () => { expect(action?.textContent).toContain("Update Mac app + Gateway"); expect(action?.textContent).toContain("v2.0.0"); action?.click(); - await nextFrame(); + await waitForConfirmDialogActions(); expect(postMessage).not.toHaveBeenCalled(); await resolveUpdateConfirmation("Update Mac app and restart"); diff --git a/ui/src/components/tooltip.test.ts b/ui/src/components/tooltip.test.ts index 755bf9d4e880..7cbe884a61ff 100644 --- a/ui/src/components/tooltip.test.ts +++ b/ui/src/components/tooltip.test.ts @@ -153,6 +153,24 @@ describe("openclaw-tooltip", () => { expect(webAwesomeTooltip(tooltip)?.anchor).toBe(trigger); }); + it("recognizes an HTML trigger created by another document realm", async () => { + const frame = document.createElement("iframe"); + document.body.append(frame); + const foreignDocument = frame.contentDocument; + if (!foreignDocument) { + throw new Error("Expected iframe document"); + } + const tooltip = document.createElement("openclaw-tooltip") as TooltipElement; + tooltip.content = "Cross-realm tooltip"; + const trigger = foreignDocument.createElement("button"); + trigger.textContent = "trigger"; + tooltip.append(trigger); + document.body.append(tooltip); + await tooltip.updateComplete; + + expect(trigger.getAttribute("aria-describedby")).toBeTruthy(); + }); + it("restores the normal hover delay after the provider reconnects", async () => { const provider = createProvider(); provider.delay = 40; diff --git a/ui/src/components/tooltip.ts b/ui/src/components/tooltip.ts index 5ab9b5e9ab48..93819bf54df2 100644 --- a/ui/src/components/tooltip.ts +++ b/ui/src/components/tooltip.ts @@ -24,6 +24,10 @@ function normalizeTooltipText(text: string) { return text.replace(/\s+/gu, " ").trim(); } +function isHtmlElement(element: Element): element is HTMLElement { + return element.namespaceURI === "http://www.w3.org/1999/xhtml"; +} + class TooltipProvider extends OpenClawLitElement { @property({ type: Number }) delay = HOVER_DELAY; @property({ type: Number }) skipDelay = SKIP_DELAY; @@ -189,9 +193,7 @@ class Tooltip extends OpenClawLitElement { private attachTrigger() { const slot = this.renderRoot.querySelector("slot:not([name])"); - const trigger = slot - ?.assignedElements({ flatten: true }) - .find((element): element is HTMLElement => element instanceof HTMLElement); + const trigger = slot?.assignedElements({ flatten: true }).find(isHtmlElement); if (trigger === this.triggerElement) { return; } @@ -412,7 +414,7 @@ class Tooltip extends OpenClawLitElement { const content = normalizeTooltipText(this.content); const triggerText = normalizeTooltipText(trigger.textContent ?? ""); const clipsContent = [trigger, ...trigger.querySelectorAll("*")].some( - (element) => element instanceof HTMLElement && element.scrollWidth > element.clientWidth, + (element) => isHtmlElement(element) && element.scrollWidth > element.clientWidth, ); return Boolean(content && triggerText && triggerText.includes(content) && !clipsContent); } diff --git a/ui/src/pages/chat/chat-pane-task-suggestions.test.ts b/ui/src/pages/chat/chat-pane-task-suggestions.test.ts index c64c679ea203..08b0ec5345f3 100644 --- a/ui/src/pages/chat/chat-pane-task-suggestions.test.ts +++ b/ui/src/pages/chat/chat-pane-task-suggestions.test.ts @@ -8,12 +8,9 @@ import type { } from "../../../../packages/gateway-protocol/src/index.js"; import { createDeferred } from "../../../../test/helpers/promise.js"; import type { GatewayBrowserClient } from "../../api/gateway.ts"; -import { copyToClipboard } from "../../lib/clipboard.ts"; import type { SessionCapability } from "../../lib/sessions/index.ts"; import { createTestChatPane } from "./chat-pane.test-support.ts"; -vi.mock("../../lib/clipboard.ts", () => ({ copyToClipboard: vi.fn() })); - const suggestion: TaskSuggestion = { id: "task_123", title: "Remove stale adapter", @@ -27,16 +24,41 @@ const suggestion: TaskSuggestion = { describe("chat pane task suggestion lifecycle", () => { it("surfaces clipboard failure through the pane error path", async () => { - vi.mocked(copyToClipboard).mockResolvedValueOnce(false); + const originalClipboard = Object.getOwnPropertyDescriptor(navigator, "clipboard"); + const originalExecCommand = Object.getOwnPropertyDescriptor(document, "execCommand"); + const writeText = vi.fn().mockRejectedValue(new Error("denied")); + const execCommand = vi.fn(() => false); + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { writeText }, + }); + Object.defineProperty(document, "execCommand", { + configurable: true, + value: execCommand, + }); const client = { request: vi.fn() } as unknown as GatewayBrowserClient; const { pane, state } = createTestChatPane({ client, sessions: {} as SessionCapability, }); - await pane.copyTaskSuggestionPrompt(suggestion); + try { + await pane.copyTaskSuggestionPrompt(suggestion); + } finally { + if (originalClipboard) { + Object.defineProperty(navigator, "clipboard", originalClipboard); + } else { + Reflect.deleteProperty(navigator, "clipboard"); + } + if (originalExecCommand) { + Object.defineProperty(document, "execCommand", originalExecCommand); + } else { + Reflect.deleteProperty(document, "execCommand"); + } + } - expect(copyToClipboard).toHaveBeenCalledWith(suggestion.prompt); + expect(writeText).toHaveBeenCalledWith(suggestion.prompt); + expect(execCommand).toHaveBeenCalledWith("copy"); expect(state.lastError).toBe("Couldn't copy the prompt to the clipboard"); expect(state.chatError).toBe("Couldn't copy the prompt to the clipboard"); }); diff --git a/ui/src/test-helpers/modal-dialog.ts b/ui/src/test-helpers/modal-dialog.ts index 52738d5d8c4c..e54fb3f2cb72 100644 --- a/ui/src/test-helpers/modal-dialog.ts +++ b/ui/src/test-helpers/modal-dialog.ts @@ -70,6 +70,13 @@ export function answerConfirmDialog(actions: HTMLElement, choice: "confirm" | "c button.click(); } +/** Let each dialog owner release its module state before a test removes the DOM. */ +export function cancelOpenModalDialogs() { + for (const dialog of document.body.querySelectorAll("openclaw-modal-dialog")) { + dialog.dispatchEvent(new CustomEvent("modal-cancel")); + } +} + /** Await a dialog whose owner loads it behind a lazy import, then read it. */ export async function waitForRenderedModalDialog(container: HTMLElement) { await vi.waitFor(() => {