diff --git a/ui/src/app/app-host.test.ts b/ui/src/app/app-host.test.ts index f50a80504366..7456b08238d8 100644 --- a/ui/src/app/app-host.test.ts +++ b/ui/src/app/app-host.test.ts @@ -19,6 +19,7 @@ import { SESSION_FACE_PREFERENCE_PARAM } from "../lib/sessions/route-navigation. import { createStorageMock } from "../test-helpers/storage.ts"; import { selectShellRouteState } from "./app-host-route-state.ts"; import { resetAppHostTestGlobals, type ShellKeyboardState } from "./app-host.test-support.ts"; +import { ShellGatewayOwner, type ShellGatewayHost } from "./app-shell-gateway.ts"; import "./app-host.ts"; import type { ApplicationContext, @@ -60,11 +61,6 @@ type ShellInitializationState = { ) => void; }; -type ShellGatewaySynchronizationState = { - outboxStoreImport: { load: () => Promise }; - synchronizeGateway: (snapshot: ApplicationGatewaySnapshot) => void; -}; - type I18nRecoveryWiring = { localeLoadRecovery?: { isUnrecoverableError: (error: unknown) => boolean; @@ -319,10 +315,27 @@ describe("OpenClaw shell source initialization", () => { it("retries a pending locale once when the Gateway becomes connected", () => { const retryPendingLocale = vi.spyOn(i18n, "retryPendingLocale").mockImplementation(() => {}); - const shell = document.createElement( - "openclaw-app-shell", - ) as unknown as ShellGatewaySynchronizationState; - shell.outboxStoreImport = { load: vi.fn(async () => undefined) }; + // Owner-direct: the shared jsdom lane can retain a sibling graph's + // openclaw-app-shell class bound to a different i18n instance; constructing + // the owner keeps the spy and the callee in the current module graph. + const host = { + activeSessionKey: "", + agentRosterRefreshTimer: null, + agentsListClient: null, + agentsListSource: null, + context: undefined, + criticalNoticeRuntime: null, + lastLocalePrefSignature: null, + outboxStoreImport: { load: vi.fn(async () => undefined) }, + previousGatewayPhase: null, + routeState: {}, + runtimeConfigClient: null, + runtimeConfigSource: null, + sessionKeyClient: null, + sidebarWorkboardRuntime: null, + syncSidebarWorkboard: vi.fn(), + } as unknown as ShellGatewayHost; + const owner = new ShellGatewayOwner(host); const reconnecting = { client: null, phase: "reconnecting", @@ -334,10 +347,10 @@ describe("OpenClaw shell source initialization", () => { sessionKey: "", } as ApplicationGatewaySnapshot; - shell.synchronizeGateway(reconnecting); - shell.synchronizeGateway(connected); - shell.synchronizeGateway({ ...connected }); - shell.synchronizeGateway({ ...connected }); + owner.synchronizeGateway(reconnecting); + owner.synchronizeGateway(connected); + owner.synchronizeGateway({ ...connected }); + owner.synchronizeGateway({ ...connected }); expect(retryPendingLocale).toHaveBeenCalledOnce(); retryPendingLocale.mockRestore(); diff --git a/ui/src/app/native-link-routing.test.ts b/ui/src/app/native-link-routing.test.ts index d2217b7d7cfc..09da7e33599a 100644 --- a/ui/src/app/native-link-routing.test.ts +++ b/ui/src/app/native-link-routing.test.ts @@ -100,6 +100,45 @@ describe("native link routing", () => { ]); }); + it("defines the hovercard once across duplicate bootstrap module instances", async () => { + // Regression: the non-isolated jsdom lane evaluates the registration + // module once per sibling file against one persistent document, so stale + // bootstrap listeners fire alongside this file's own. Reproduce that order + // and require a single registry definition. + vi.resetModules(); + await import("../components/github-link-hovercard-registration.ts"); + const define = vi.spyOn(customElements, "define"); + const provider = document.createElement( + "openclaw-github-link-hovercard-provider", + ) as GitHubLinkHovercardProvider; + provider.client = { + request: vi.fn().mockResolvedValue({ + comments: 1, + createdAt: "2026-07-09T10:00:00Z", + kind: "issue", + login: "octocat", + number: 102691, + owner: "openclaw", + repo: "openclaw", + state: "open", + title: "Open links in a sidebar browser", + updatedAt: "2026-07-09T10:00:00Z", + }), + } as unknown as GatewayBrowserClient; + const anchor = document.createElement("a"); + anchor.href = "https://github.com/openclaw/openclaw/issues/102691"; + anchor.textContent = "#102691"; + provider.append(anchor); + document.body.append(provider); + anchor.focus(); + await vi.waitFor(() => expect(document.querySelector(".github-link-hovercard")).not.toBeNull()); + const hovercardDefines = define.mock.calls.filter( + ([tag]) => tag === "openclaw-github-link-hovercard-provider", + ); + expect(hovercardDefines).toHaveLength(1); + define.mockRestore(); + }); + it("closes an active GitHub hovercard after routing its link", async () => { const bridge = installBridge(); routing = startNativeLinkRouting(); diff --git a/ui/src/app/route-transition.test.ts b/ui/src/app/route-transition.test.ts index 0ad53c8c10f1..960d2c1799c9 100644 --- a/ui/src/app/route-transition.test.ts +++ b/ui/src/app/route-transition.test.ts @@ -5,9 +5,11 @@ function testDocumentWithOutlet(animate = vi.fn()) { const outlet = document.createElement("openclaw-router-outlet") as HTMLElement & { updateComplete: Promise; }; + // Own data property: the real OpenClawRouterOutlet may already be registered by a + // sibling test in this worker, and Lit's updateComplete is a getter-only accessor. Object.defineProperty(outlet, "updateComplete", { - configurable: true, value: Promise.resolve(), + configurable: true, }); outlet.animate = animate; document.body.append(outlet); diff --git a/ui/src/components/github-link-hovercard-registration.ts b/ui/src/components/github-link-hovercard-registration.ts index fc0f3385b5a5..0dc5bffe4fc6 100644 --- a/ui/src/components/github-link-hovercard-registration.ts +++ b/ui/src/components/github-link-hovercard-registration.ts @@ -40,7 +40,12 @@ async function activateHovercard(event: Event, trigger: "focus" | "pointer"): Pr ); await ensureCustomElementDefined(HOVERCARD_TAG, async () => { const runtime = await import("./github-link-hovercard.runtime.ts"); - customElements.define(HOVERCARD_TAG, runtime.GitHubLinkHovercardProvider); + // Sibling module instances can share one document + registry (the + // non-isolated jsdom lane evaluates this module once per test file), so a + // concurrent instance may have defined the tag while our load resolved. + if (!customElements.get(HOVERCARD_TAG)) { + customElements.define(HOVERCARD_TAG, runtime.GitHubLinkHovercardProvider); + } for (const [provider, client] of pendingClients) { provider.client = client; }