fix(ui): repair shared vitest lane cross-file leaks (#123453)

The non-isolated |ui| lane resets the module graph per test file while the
jsdom document and customElements registry persist for the whole worker.
Three producers leaked across that lifetime mismatch:

- github-link-hovercard-registration.ts: each file's module instance adds
  document-level bootstrap listeners; on the first real focusin every stale
  instance raced customElements.define -> unhandled NotSupportedError.
  Guard the define inside the single-flight loader.
- app-host.test.ts locale retry asserted through createElement, which serves
  a sibling graph's shell class bound to a different i18n singleton; construct
  ShellGatewayOwner directly so spy and callee share one graph.
- route-transition.test.ts assigned updateComplete over the real Lit outlet's
  getter-only accessor when a sibling had registered it; define an own data
  property instead (byte-identical to the hunk in PR #123347).

Adds a boundary regression test reproducing the duplicate-module-instance
order; it failed pre-fix with 2 registry defines and the NotSupportedError.
This commit is contained in:
Peter Steinberger
2026-08-13 21:12:57 -07:00
committed by GitHub
parent 63bc139872
commit 92eed63f58
4 changed files with 74 additions and 15 deletions
+26 -13
View File
@@ -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<unknown> };
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();
+39
View File
@@ -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();
+3 -1
View File
@@ -5,9 +5,11 @@ function testDocumentWithOutlet(animate = vi.fn()) {
const outlet = document.createElement("openclaw-router-outlet") as HTMLElement & {
updateComplete: Promise<void>;
};
// 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);
@@ -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;
}