diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ebf82f3dc59d..b6bf5a3eceef 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1329,7 +1329,9 @@ jobs: # Do not retry whole files: several rely on one-shot mocked browser globals. pnpm --dir ui test --testTimeout=30000 --isolate else - pnpm --dir ui test + # Three workers deliberately exercise stable non-default file packing so + # isolate:false mock-registry leaks fail close to the introducing change. + pnpm --dir ui test --maxWorkers 3 fi control-ui-i18n: diff --git a/test/vitest-ui-package-config.test.ts b/test/vitest-ui-package-config.test.ts index c198b4db5c97..0a06a8647836 100644 --- a/test/vitest-ui-package-config.test.ts +++ b/test/vitest-ui-package-config.test.ts @@ -5,6 +5,7 @@ import uiNodeConfig from "../ui/vitest.node.config.ts"; type ExpectedTestConfig = { isolate?: boolean; + name?: string; pool?: string; projects?: unknown[]; runner?: string; @@ -18,17 +19,17 @@ function requireTestConfig(config: unknown): ExpectedTestConfig { } describe("ui package vitest config", () => { - it("keeps the standalone ui package on thread workers without isolation", () => { + it("keeps the standalone ui package on thread workers without broad isolation", () => { const testConfig = requireTestConfig(uiConfig); expect(testConfig.pool).toBe("threads"); expect(testConfig.isolate).toBe(false); - expect(testConfig.projects).toHaveLength(3); + expect(testConfig.projects).toHaveLength(4); for (const project of testConfig.projects ?? []) { const projectTestConfig = requireTestConfig(project); expect(projectTestConfig.pool).toBe("threads"); - expect(projectTestConfig.isolate).toBe(false); + expect(projectTestConfig.isolate).toBe(projectTestConfig.name === "unit-mock-registry"); expect(projectTestConfig.runner).toBeUndefined(); } }); diff --git a/ui/src/api/gateway.node.test.ts b/ui/src/api/gateway.node.test.ts index 10f36bd6f3eb..617811d30f20 100644 --- a/ui/src/api/gateway.node.test.ts +++ b/ui/src/api/gateway.node.test.ts @@ -11,6 +11,7 @@ import { loadDeviceAuthToken as loadScopedDeviceAuthToken, storeDeviceAuthToken as storeScopedDeviceAuthToken, } from "../lib/nodes/index.ts"; +import * as nodes from "../lib/nodes/index.ts"; import { createStorageMock } from "../test-helpers/storage.ts"; const wsInstances = vi.hoisted((): MockWebSocket[] => []); @@ -135,12 +136,6 @@ class MockWebSocket { } } -vi.mock("../lib/nodes/index.ts", async (importOriginal) => ({ - ...(await importOriginal()), - loadOrCreateDeviceIdentity: loadOrCreateDeviceIdentityMock, - signDevicePayload: signDevicePayloadMock, -})); - const { GatewayBrowserClient, GatewayRequestError, resolveGatewayErrorDetailCode } = await import("./gateway.ts"); @@ -383,6 +378,10 @@ async function expectRetriedDeviceTokenConnect(params: { describe("GatewayBrowserClient", () => { beforeEach(() => { + vi.spyOn(nodes, "loadOrCreateDeviceIdentity").mockImplementation( + loadOrCreateDeviceIdentityMock, + ); + vi.spyOn(nodes, "signDevicePayload").mockImplementation(signDevicePayloadMock); vi.useRealTimers(); vi.unstubAllGlobals(); const storage = createStorageMock(); @@ -411,6 +410,7 @@ describe("GatewayBrowserClient", () => { afterEach(() => { vi.useRealTimers(); vi.unstubAllGlobals(); + vi.restoreAllMocks(); }); it("requests full control ui operator scopes with explicit shared auth", async () => { diff --git a/ui/src/components/mcp-app-view-registration.ts b/ui/src/components/mcp-app-view-registration.ts index 250465d54916..2b3a236c5cb8 100644 --- a/ui/src/components/mcp-app-view-registration.ts +++ b/ui/src/components/mcp-app-view-registration.ts @@ -1,5 +1,9 @@ import { McpAppView } from "./mcp-app-view.ts"; -if (!customElements.get("mcp-app-view")) { - customElements.define("mcp-app-view", McpAppView); +export function registerMcpAppView(): void { + if (!customElements.get("mcp-app-view")) { + customElements.define("mcp-app-view", McpAppView); + } } + +registerMcpAppView(); diff --git a/ui/src/components/mcp-app-view.test.ts b/ui/src/components/mcp-app-view.test.ts index 96c111580478..6759f60a852e 100644 --- a/ui/src/components/mcp-app-view.test.ts +++ b/ui/src/components/mcp-app-view.test.ts @@ -7,7 +7,10 @@ const bridgeMocks = vi.hoisted(() => ({ transports: [] as Array>, })); -vi.mock("@modelcontextprotocol/ext-apps/app-bridge", () => { +// This constructor seam is a complete factory, and the unit-mock-registry +// project prevents its substituted classes from reaching unrelated files. +vi.mock("@modelcontextprotocol/ext-apps/app-bridge", async (importOriginal) => { + const actual = await importOriginal(); class AppBridge { oninitialized?: () => void; messageHandler?: (params: { @@ -64,7 +67,7 @@ vi.mock("@modelcontextprotocol/ext-apps/app-bridge", () => { } } - return { AppBridge, PostMessageTransport }; + return { ...actual, AppBridge, PostMessageTransport }; }); const { McpAppView } = await import("./mcp-app-view.ts"); diff --git a/ui/src/pages/agents/identity-actions.test.ts b/ui/src/pages/agents/identity-actions.test.ts index bf257ed4533f..a3118caf0f7e 100644 --- a/ui/src/pages/agents/identity-actions.test.ts +++ b/ui/src/pages/agents/identity-actions.test.ts @@ -1,9 +1,18 @@ -import { describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { ApplicationContext } from "../../app/context.ts"; +import * as avatarImage from "./avatar-image.ts"; import { resetIdentityDraft, saveIdentityDraft, selectIdentityAvatar } from "./identity-actions.ts"; -const mocks = vi.hoisted(() => ({ fileToAvatarDataUrl: vi.fn() })); -vi.mock("./avatar-image.ts", () => ({ fileToAvatarDataUrl: mocks.fileToAvatarDataUrl })); +const fileToAvatarDataUrlMock = vi.fn(); + +beforeEach(() => { + vi.spyOn(avatarImage, "fileToAvatarDataUrl").mockImplementation(fileToAvatarDataUrlMock); +}); + +afterEach(() => { + fileToAvatarDataUrlMock.mockReset(); + vi.restoreAllMocks(); +}); function host(): Parameters[0] { return { @@ -35,7 +44,7 @@ describe("agent identity actions", () => { it("drops an avatar decode that completes after the selected agent resets", async () => { let resolveAvatar!: (value: string | null) => void; - mocks.fileToAvatarDataUrl.mockReturnValueOnce( + fileToAvatarDataUrlMock.mockReturnValueOnce( new Promise((resolve) => { resolveAvatar = resolve; }), diff --git a/ui/src/pages/chat/chat-controls.test.ts b/ui/src/pages/chat/chat-controls.test.ts index 90736f01a16d..bc84f7019d53 100644 --- a/ui/src/pages/chat/chat-controls.test.ts +++ b/ui/src/pages/chat/chat-controls.test.ts @@ -8,10 +8,6 @@ import { renderChatControls } from "./components/chat-controls.ts"; type ChatControlsProps = Parameters[0]; -vi.mock("../../components/icons.ts", () => ({ - icons: {}, -})); - function createSettings(): UiSettings { return { gatewayUrl: "ws://localhost:18789", diff --git a/ui/src/pages/chat/chat-page.test.ts b/ui/src/pages/chat/chat-page.test.ts index b1afa8404686..47a473400b1a 100644 --- a/ui/src/pages/chat/chat-page.test.ts +++ b/ui/src/pages/chat/chat-page.test.ts @@ -4,8 +4,8 @@ import { expectDefined } from "@openclaw/normalization-core"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -// The dedicated jsdom context keeps this host-only mock from sharing the -// production tag registry with component tests. +// The dedicated unit-mock-registry project keeps this complete, side-effect-only +// module mock from sharing a worker's mock registry with component tests. vi.mock("./chat-pane.ts", () => ({})); import { loadSettings } from "../../app/settings.ts"; diff --git a/ui/src/pages/chat/chat-send.test.ts b/ui/src/pages/chat/chat-send.test.ts index 7b65e3896a34..27914be26794 100644 --- a/ui/src/pages/chat/chat-send.test.ts +++ b/ui/src/pages/chat/chat-send.test.ts @@ -15,6 +15,7 @@ import { releaseChatAttachmentPayloads, } from "./attachment-payload-store.ts"; import { refreshChatAvatar } from "./chat-avatar.ts"; +import * as chatCommandExecutor from "./chat-command-executor.ts"; import type { executeSlashCommand } from "./chat-command-executor.ts"; import type { ChatHost } from "./chat-send.ts"; import { @@ -75,7 +76,8 @@ function cacheChatMessages( }); } -const executeSlashCommandMock = vi.hoisted(() => vi.fn()); +const executeSlashCommandMock = vi.fn(); +const executeSlashCommandActual = chatCommandExecutor.executeSlashCommand; const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu; const registeredAttachmentPayloads = new Map< @@ -91,25 +93,21 @@ function registerChatAttachmentPayload( return attachment; } +beforeEach(() => { + executeSlashCommandMock.mockReset(); + vi.spyOn(chatCommandExecutor, "executeSlashCommand").mockImplementation((...args) => { + const implementation = executeSlashCommandMock.getMockImplementation() as + | ExecuteSlashCommand + | undefined; + return implementation ? executeSlashCommandMock(...args) : executeSlashCommandActual(...args); + }); +}); + afterEach(() => { releaseChatAttachmentPayloads([...registeredAttachmentPayloads.values()]); registeredAttachmentPayloads.clear(); vi.unstubAllGlobals(); -}); - -vi.mock("./chat-command-executor.ts", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - executeSlashCommand: (...args: Parameters) => { - const implementation = executeSlashCommandMock.getMockImplementation() as - | ExecuteSlashCommand - | undefined; - return implementation - ? executeSlashCommandMock(...args) - : actual.executeSlashCommand(...args); - }, - }; + vi.restoreAllMocks(); }); let handleSendChat: typeof import("./chat-send.ts").handleSendChat; @@ -1151,7 +1149,6 @@ describe("handleSendChat", () => { }); beforeEach(() => { - executeSlashCommandMock.mockReset(); vi.stubGlobal("sessionStorage", createStorageMock()); }); diff --git a/ui/src/pages/chat/chat-side-chat.test.ts b/ui/src/pages/chat/chat-side-chat.test.ts index e7b86acee64a..b48e5f2e4f71 100644 --- a/ui/src/pages/chat/chat-side-chat.test.ts +++ b/ui/src/pages/chat/chat-side-chat.test.ts @@ -5,14 +5,6 @@ import { describe, expect, it, vi } from "vitest"; import type { ChatSideResult } from "../../lib/chat/side-result.ts"; import { renderSideChatPanel } from "./components/chat-side-chat.ts"; -vi.mock("../../components/icons.ts", () => ({ - icons: {}, -})); - -vi.mock("../../components/markdown.ts", () => ({ - toSanitizedMarkdownHtml: (value: string) => value, -})); - function turn(overrides: Partial = {}): ChatSideResult { return { kind: "btw", diff --git a/ui/src/pages/chat/chat-state.test.ts b/ui/src/pages/chat/chat-state.test.ts index 47ae295aca90..9148ad11aeeb 100644 --- a/ui/src/pages/chat/chat-state.test.ts +++ b/ui/src/pages/chat/chat-state.test.ts @@ -1,5 +1,6 @@ import type { ReactiveController, ReactiveControllerHost } from "lit"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import * as assistantIdentity from "../../app/assistant-identity.ts"; import { buildFallbackSlashCommands, replaceSlashCommands, @@ -35,10 +36,11 @@ import { } from "./composer-persistence.ts"; import { scheduleControlUiAfterPaint } from "./performance.ts"; -vi.mock("../../app/assistant-identity.ts", async (importOriginal) => ({ - ...(await importOriginal()), - loadLocalAssistantIdentity: () => ({ avatar: "data:image/png;base64,bG9jYWw=" }), -})); +beforeEach(() => { + vi.spyOn(assistantIdentity, "loadLocalAssistantIdentity").mockReturnValue({ + avatar: "data:image/png;base64,bG9jYWw=", + }); +}); afterEach(() => { replaceSlashCommands(buildFallbackSlashCommands()); diff --git a/ui/src/pages/chat/chat-view.test.ts b/ui/src/pages/chat/chat-view.test.ts index 9929c9b9cde9..e019027ca3d8 100644 --- a/ui/src/pages/chat/chat-view.test.ts +++ b/ui/src/pages/chat/chat-view.test.ts @@ -29,6 +29,7 @@ import { releaseChatAttachmentPayloads, } from "./attachment-payload-store.ts"; import { switchChatFastMode, switchChatModel, switchChatThinkingLevel } from "./chat-session.ts"; +import * as chatThread from "./chat-thread.ts"; import { renderChat, resetChatViewState } from "./chat-view.ts"; import { resetChatComposerState } from "./components/chat-composer.ts"; import * as chatMessage from "./components/chat-message.ts"; @@ -58,121 +59,117 @@ function registerChatAttachmentPayload( return attachment; } -const refreshVisibleToolsEffectiveForCurrentSessionMock = vi.hoisted(() => - vi.fn(async (state: ChatHeaderTestState) => { - const agentId = state.agentsSelectedId ?? "main"; - const sessionKey = state.sessionKey; - await state.client?.request("tools.effective", { agentId, sessionKey }); - const override = state.sessions.state.modelOverrides[sessionKey]; - state.toolsEffectiveResultKey = `${agentId}:${sessionKey}:model=${override ?? "(default)"}`; - state.toolsEffectiveResult = { agentId, profile: "coding", groups: [] }; - }), -); -const buildChatItemsMock = vi.hoisted(() => - vi.fn( - (props: { - messages: unknown[]; - stream: string | null; - streamStartedAt: number | null; - runWorking?: boolean; - loading?: boolean; - }) => { - if ( - props.messages.some( - (message) => - typeof message === "object" && - message !== null && - (message as { __testDivider?: unknown })["__testDivider"] === true, - ) - ) { - return [ - { - kind: "divider", - key: "divider:compaction:test", - label: "Compacted history", - description: - "The compacted transcript is preserved as a checkpoint. Open thread checkpoints to branch or restore from that compacted view.", - action: { - kind: "session-checkpoints", - label: "Open checkpoints", - }, - timestamp: 1, +async function refreshVisibleToolsEffectiveForCurrentSessionForTest(state: ChatHeaderTestState) { + const agentId = state.agentsSelectedId ?? "main"; + const sessionKey = state.sessionKey; + await state.client?.request("tools.effective", { agentId, sessionKey }); + const override = state.sessions.state.modelOverrides[sessionKey]; + state.toolsEffectiveResultKey = `${agentId}:${sessionKey}:model=${override ?? "(default)"}`; + state.toolsEffectiveResult = { agentId, profile: "coding", groups: [] }; +} +const buildChatItemsMock = vi.fn( + (props: { + messages: unknown[]; + stream: string | null; + streamStartedAt: number | null; + runWorking?: boolean; + loading?: boolean; + }): ReturnType => { + if ( + props.messages.some( + (message) => + typeof message === "object" && + message !== null && + (message as { __testDivider?: unknown })["__testDivider"] === true, + ) + ) { + return [ + { + kind: "divider", + key: "divider:compaction:test", + label: "Compacted history", + description: + "The compacted transcript is preserved as a checkpoint. Open thread checkpoints to branch or restore from that compacted view.", + action: { + kind: "session-checkpoints", + label: "Open checkpoints", }, - ]; - } - const items: unknown[] = []; - if (props.messages.length > 0) { - const virtualRows = props.messages.every( - (message) => - typeof message === "object" && - message !== null && - (message as { testVirtualRow?: unknown }).testVirtualRow === true, - ); - if (virtualRows) { - items.push( - ...props.messages.map((message, index) => { - const testMessage = message as { - testVirtualKey?: string; - testVirtualRole?: string; - }; - const key = testMessage.testVirtualKey ?? String(index); - return { - kind: "group", - key: `group:${key}`, - role: testMessage.testVirtualRole ?? (index % 2 === 0 ? "user" : "assistant"), - messages: [{ key: `message:${key}`, message }], - timestamp: index + 1, - isStreaming: false, - }; - }), - ); - } else { - items.push({ - kind: "group", - key: "group:assistant:test", - role: "assistant", - messages: props.messages.map((message, index) => ({ - key: `message:${index}`, - message, - })), - timestamp: 1, - isStreaming: false, - }); - } - } - // Mirrors buildChatItems: streamed text renders as a stream item; an - // empty stream or a working run with no stream shows the reading - // indicator (working spark), except on the initial empty load where - // the skeleton owns the thread. - if (props.stream !== null) { + timestamp: 1, + }, + ] as ReturnType; + } + const items: unknown[] = []; + if (props.messages.length > 0) { + const virtualRows = props.messages.every( + (message) => + typeof message === "object" && + message !== null && + (message as { testVirtualRow?: unknown }).testVirtualRow === true, + ); + if (virtualRows) { items.push( - props.stream - ? { - kind: "stream", - key: "stream:test", - text: props.stream, - startedAt: props.streamStartedAt ?? 1, - isStreaming: true, - } - : { - kind: "reading-indicator", - key: "reading:test", - startedAt: props.streamStartedAt ?? 1, - }, + ...props.messages.map((message, index) => { + const testMessage = message as { + testVirtualKey?: string; + testVirtualRole?: string; + }; + const key = testMessage.testVirtualKey ?? String(index); + return { + kind: "group", + key: `group:${key}`, + role: testMessage.testVirtualRole ?? (index % 2 === 0 ? "user" : "assistant"), + messages: [{ key: `message:${key}`, message }], + timestamp: index + 1, + isStreaming: false, + }; + }), ); - } else if ( - props.runWorking === true && - !(props.loading === true && props.messages.length === 0) - ) { + } else { items.push({ - kind: "reading-indicator", - key: "reading:test", - startedAt: props.streamStartedAt ?? 1, + kind: "group", + key: "group:assistant:test", + role: "assistant", + messages: props.messages.map((message, index) => ({ + key: `message:${index}`, + message, + })), + timestamp: 1, + isStreaming: false, }); } - return items; - }, - ), + } + // Mirrors buildChatItems: streamed text renders as a stream item; an + // empty stream or a working run with no stream shows the reading + // indicator (working spark), except on the initial empty load where + // the skeleton owns the thread. + if (props.stream !== null) { + items.push( + props.stream + ? { + kind: "stream", + key: "stream:test", + text: props.stream, + startedAt: props.streamStartedAt ?? 1, + isStreaming: true, + } + : { + kind: "reading-indicator", + key: "reading:test", + startedAt: props.streamStartedAt ?? 1, + }, + ); + } else if ( + props.runWorking === true && + !(props.loading === true && props.messages.length === 0) + ) { + items.push({ + kind: "reading-indicator", + key: "reading:test", + startedAt: props.streamStartedAt ?? 1, + }); + } + return items as ReturnType; + }, ); const renderMessageGroupMock = vi.fn( ( @@ -250,24 +247,6 @@ function requireFirstAttachmentsChange( return attachments as ChatAttachment[]; } -vi.mock("../../components/icons.ts", async (importOriginal) => - importOriginal(), -); - -vi.mock("./chat-thread.ts", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - buildCachedChatItems: buildChatItemsMock, - getExpandedToolCards: () => new Map(), - syncToolCardExpansionState: () => undefined, - }; -}); - -vi.mock("../../lib/agents/tools-effective.ts", () => ({ - refreshVisibleToolsEffectiveForCurrentSession: refreshVisibleToolsEffectiveForCurrentSessionMock, -})); - function renderStreamGroupMock( ...[parts, _opts]: Parameters ): ReturnType { @@ -287,6 +266,9 @@ function renderWorkGroupSummaryMock( } beforeEach(() => { + vi.spyOn(chatThread, "buildCachedChatItems").mockImplementation(buildChatItemsMock); + vi.spyOn(chatThread, "getExpandedToolCards").mockReturnValue(new Map()); + vi.spyOn(chatThread, "syncToolCardExpansionState").mockImplementation(() => undefined); vi.spyOn(chatMessage, "getAssistantAttachmentAvailabilityRenderVersion").mockImplementation( () => assistantAttachmentRenderVersionMock.value, ); @@ -482,7 +464,8 @@ function createChatHeaderState( resetChatInputHistoryNavigation: vi.fn(), resetToolStream: vi.fn(), resetChatScroll: vi.fn(), - onModelChanged: (): Promise => refreshVisibleToolsEffectiveForCurrentSessionMock(state), + onModelChanged: (): Promise => + refreshVisibleToolsEffectiveForCurrentSessionForTest(state), }; sessions.subscribe((next) => { state.sessionsResult = next.result; @@ -1690,7 +1673,6 @@ afterEach(() => { buildChatItemsMock.mockClear(); renderMessageGroupMock.mockClear(); assistantAttachmentRenderVersionMock.value = 0; - refreshVisibleToolsEffectiveForCurrentSessionMock.mockClear(); resetChatViewState(); vi.unstubAllGlobals(); vi.restoreAllMocks(); diff --git a/ui/src/pages/chat/components/chat-tool-cards.node.test.ts b/ui/src/pages/chat/components/chat-tool-cards.node.test.ts index 7358cd0bb560..290191f89bc2 100644 --- a/ui/src/pages/chat/components/chat-tool-cards.node.test.ts +++ b/ui/src/pages/chat/components/chat-tool-cards.node.test.ts @@ -1,15 +1,11 @@ // @vitest-environment node -import { describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { extractToolCardsCached as extractToolCards } from "../../../lib/chat/tool-cards.ts"; +import * as toolDisplay from "../../../lib/chat/tool-display.ts"; -vi.mock("../../../components/icons.ts", () => ({ - icons: {}, -})); - -vi.mock("../../../lib/chat/tool-display.ts", () => ({ - formatToolDetail: () => undefined, - resolveToolDisplay: ({ name }: { name: string }) => ({ +function resolveToolDisplay({ name = "" }: Parameters[0]) { + return { name, label: { @@ -22,8 +18,17 @@ vi.mock("../../../lib/chat/tool-display.ts", () => ({ .map((part) => (part ? part.charAt(0).toUpperCase() + part.slice(1) : part)) .join(" "), icon: "zap", - }), -})); + } as ReturnType; +} + +beforeEach(() => { + vi.spyOn(toolDisplay, "formatToolDetail").mockReturnValue(undefined); + vi.spyOn(toolDisplay, "resolveToolDisplay").mockImplementation(resolveToolDisplay); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); describe("tool-card extraction", () => { it("pretty-prints structured args and pairs tool output onto the same card", () => { diff --git a/ui/src/pages/chat/components/chat-tool-cards.test.ts b/ui/src/pages/chat/components/chat-tool-cards.test.ts index 639f464791a7..0ff00e075e99 100644 --- a/ui/src/pages/chat/components/chat-tool-cards.test.ts +++ b/ui/src/pages/chat/components/chat-tool-cards.test.ts @@ -40,12 +40,9 @@ function pointerClick(element: Element) { describe("tool-cards", () => { it("routes MCP App previews through the dedicated double-iframe host", async () => { - vi.resetModules(); - const { renderToolPreview: renderToolPreviewWithLazyMock } = - await import("./chat-tool-cards.ts"); const container = document.createElement("div"); render( - renderToolPreviewWithLazyMock( + renderToolPreview( { kind: "canvas", surface: "assistant_message", @@ -72,7 +69,7 @@ describe("tool-cards", () => { const toolContainer = document.createElement("div"); render( - renderToolPreviewWithLazyMock( + renderToolPreview( { kind: "canvas", surface: "assistant_message", diff --git a/ui/src/pages/chat/components/widget-card.ts b/ui/src/pages/chat/components/widget-card.ts index edf5ee3a8507..04a94db0af45 100644 --- a/ui/src/pages/chat/components/widget-card.ts +++ b/ui/src/pages/chat/components/widget-card.ts @@ -293,7 +293,10 @@ function renderPreviewFrame(params: { ); } -const loadMcpAppView = () => import("../../../components/mcp-app-view-registration.ts"); +const loadMcpAppView = async () => { + const registration = await import("../../../components/mcp-app-view-registration.ts"); + registration.registerMcpAppView(); +}; function renderMcpAppView(params: { sessionKey: string; diff --git a/ui/src/pages/chat/realtime-talk.test.ts b/ui/src/pages/chat/realtime-talk.test.ts index 58cc79a5cb53..29f0b22f0b88 100644 --- a/ui/src/pages/chat/realtime-talk.test.ts +++ b/ui/src/pages/chat/realtime-talk.test.ts @@ -1,5 +1,12 @@ // @vitest-environment node -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import * as gatewayRelayTransport from "./realtime-talk-gateway-relay.ts"; +import * as googleLiveTransport from "./realtime-talk-google-live.ts"; +import type { + RealtimeTalkTransport, + RealtimeTalkTransportContext, +} from "./realtime-talk-shared.ts"; +import * as webRtcTransport from "./realtime-talk-webrtc.ts"; const { googleStart, @@ -12,10 +19,7 @@ const { webRtcSetVideoEnabled, googleSwitchCamera, webRtcSwitchCamera, - googleCtor, - relayCtor, - webRtcCtor, -} = vi.hoisted(() => ({ +} = { googleStart: vi.fn(async () => undefined), googleStop: vi.fn(), relayStart: vi.fn(async () => undefined), @@ -26,41 +30,23 @@ const { webRtcSetVideoEnabled: vi.fn(async () => undefined), googleSwitchCamera: vi.fn(async () => undefined), webRtcSwitchCamera: vi.fn(async () => undefined), - googleCtor: vi.fn(function (_session: unknown, _ctx: unknown) { - return { - start: googleStart, - stop: googleStop, - setVideoEnabled: googleSetVideoEnabled, - switchCamera: googleSwitchCamera, - }; - }), - relayCtor: vi.fn(function (_session: unknown, _ctx: unknown) { - return { start: relayStart, stop: relayStop }; - }), - webRtcCtor: vi.fn(function (_session: unknown, _ctx: unknown) { - return { - start: webRtcStart, - stop: webRtcStop, - setVideoEnabled: webRtcSetVideoEnabled, - switchCamera: webRtcSwitchCamera, - }; - }), -})); - -vi.mock("./realtime-talk-google-live.ts", () => ({ - GoogleLiveRealtimeTalkTransport: googleCtor, -})); - -vi.mock("./realtime-talk-gateway-relay.ts", () => ({ - GatewayRelayRealtimeTalkTransport: relayCtor, -})); - -vi.mock("./realtime-talk-webrtc.ts", () => ({ - WebRtcSdpRealtimeTalkTransport: webRtcCtor, -})); +}; import { RealtimeTalkSession, switchActiveRealtimeTalkCameras } from "./realtime-talk.ts"; +type MockTransport = RealtimeTalkTransport & { ctx: RealtimeTalkTransportContext }; + +const googleInstances: MockTransport[] = []; +const relayInstances: MockTransport[] = []; +const webRtcInstances: MockTransport[] = []; + +function transportContext(transport: object | undefined): RealtimeTalkTransportContext { + if (!transport) { + throw new Error("Expected realtime transport instance"); + } + return (transport as { ctx: RealtimeTalkTransportContext }).ctx; +} + describe("RealtimeTalkSession", () => { beforeEach(() => { googleStart.mockClear(); @@ -73,9 +59,46 @@ describe("RealtimeTalkSession", () => { webRtcSetVideoEnabled.mockClear(); googleSwitchCamera.mockClear(); webRtcSwitchCamera.mockClear(); - googleCtor.mockClear(); - relayCtor.mockClear(); - webRtcCtor.mockClear(); + googleInstances.length = 0; + relayInstances.length = 0; + webRtcInstances.length = 0; + vi.spyOn(googleLiveTransport, "GoogleLiveRealtimeTalkTransport").mockImplementation( + function (_session, ctx) { + const transport: MockTransport = { + ctx, + start: googleStart, + stop: googleStop, + setVideoEnabled: googleSetVideoEnabled, + switchCamera: googleSwitchCamera, + }; + googleInstances.push(transport); + return transport as unknown as googleLiveTransport.GoogleLiveRealtimeTalkTransport; + }, + ); + vi.spyOn(gatewayRelayTransport, "GatewayRelayRealtimeTalkTransport").mockImplementation( + function (_session, ctx) { + const transport: MockTransport = { ctx, start: relayStart, stop: relayStop }; + relayInstances.push(transport); + return transport as unknown as gatewayRelayTransport.GatewayRelayRealtimeTalkTransport; + }, + ); + vi.spyOn(webRtcTransport, "WebRtcSdpRealtimeTalkTransport").mockImplementation( + function (_session, ctx) { + const transport: MockTransport = { + ctx, + start: webRtcStart, + stop: webRtcStop, + setVideoEnabled: webRtcSetVideoEnabled, + switchCamera: webRtcSwitchCamera, + }; + webRtcInstances.push(transport); + return transport as unknown as webRtcTransport.WebRtcSdpRealtimeTalkTransport; + }, + ); + }); + + afterEach(() => { + vi.restoreAllMocks(); }); it("starts the Google Live WebSocket transport from a generic session result", async () => { @@ -102,10 +125,10 @@ describe("RealtimeTalkSession", () => { sessionKey: "main", capabilities: ["voice-transcript"], }); - expect(googleCtor).toHaveBeenCalledTimes(1); + expect(googleInstances).toHaveLength(1); expect(googleStart).toHaveBeenCalledTimes(1); - expect(webRtcCtor).not.toHaveBeenCalled(); - expect(relayCtor).not.toHaveBeenCalled(); + expect(webRtcInstances).toHaveLength(0); + expect(relayInstances).toHaveLength(0); expect(onStatus).toHaveBeenCalledWith("connecting"); }); @@ -119,9 +142,9 @@ describe("RealtimeTalkSession", () => { await session.start(); - expect(webRtcCtor).toHaveBeenCalledTimes(1); + expect(webRtcInstances).toHaveLength(1); expect(webRtcStart).toHaveBeenCalledTimes(1); - expect(googleCtor).not.toHaveBeenCalled(); + expect(googleInstances).toHaveLength(0); }); it("accepts legacy WebRTC transport names", async () => { @@ -135,8 +158,8 @@ describe("RealtimeTalkSession", () => { await session.start(); - expect(webRtcCtor).toHaveBeenCalledTimes(1); - expect(googleCtor).not.toHaveBeenCalled(); + expect(webRtcInstances).toHaveLength(1); + expect(googleInstances).toHaveLength(0); }); it("accepts legacy provider WebSocket transport names", async () => { @@ -158,8 +181,8 @@ describe("RealtimeTalkSession", () => { await session.start(); - expect(webRtcCtor).not.toHaveBeenCalled(); - expect(googleCtor).toHaveBeenCalledTimes(1); + expect(webRtcInstances).toHaveLength(0); + expect(googleInstances).toHaveLength(1); }); it("starts the Gateway relay transport for backend-only realtime providers", async () => { @@ -179,11 +202,11 @@ describe("RealtimeTalkSession", () => { await session.start(); session.stop(); - expect(relayCtor).toHaveBeenCalledTimes(1); + expect(relayInstances).toHaveLength(1); expect(relayStart).toHaveBeenCalledTimes(1); expect(relayStop).toHaveBeenCalledTimes(1); - expect(googleCtor).not.toHaveBeenCalled(); - expect(webRtcCtor).not.toHaveBeenCalled(); + expect(googleInstances).toHaveLength(0); + expect(webRtcInstances).toHaveLength(0); }); it("falls back to talk.session.create when gateway-relay is rejected by talk.client.create", async () => { @@ -225,7 +248,7 @@ describe("RealtimeTalkSession", () => { mode: "realtime", brain: "agent-consult", }); - expect(relayCtor).toHaveBeenCalledTimes(1); + expect(relayInstances).toHaveLength(1); expect(relayStart).toHaveBeenCalledTimes(1); }); @@ -282,7 +305,7 @@ describe("RealtimeTalkSession", () => { }); expect(onVideoCapability).toHaveBeenCalledOnce(); expect(onVideoCapability).toHaveBeenCalledWith(false); - expect(relayCtor).toHaveBeenCalledTimes(1); + expect(relayInstances).toHaveLength(1); }); it("starts the WebRTC transport for canonical WebRTC sessions", async () => { @@ -297,11 +320,11 @@ describe("RealtimeTalkSession", () => { await session.start(); session.stop(); - expect(webRtcCtor).toHaveBeenCalledTimes(1); + expect(webRtcInstances).toHaveLength(1); expect(webRtcStart).toHaveBeenCalledTimes(1); expect(webRtcStop).toHaveBeenCalledTimes(1); - expect(googleCtor).not.toHaveBeenCalled(); - expect(relayCtor).not.toHaveBeenCalled(); + expect(googleInstances).toHaveLength(0); + expect(relayInstances).toHaveLength(0); }); it("passes launch options to client-owned realtime session creation", async () => { @@ -342,8 +365,7 @@ describe("RealtimeTalkSession", () => { reasoningEffort: "low", capabilities: ["voice-transcript"], }); - expect(webRtcCtor).toHaveBeenCalledWith( - expect.any(Object), + expect(transportContext(webRtcInstances[0])).toEqual( expect.objectContaining({ inputDeviceId: "usb-mic", videoDeviceId: "desk-camera" }), ); }); @@ -378,8 +400,7 @@ describe("RealtimeTalkSession", () => { capabilities: ["voice-transcript", "camera-frame"], }); expect(onVideoCapability).toHaveBeenCalledWith(true); - expect(webRtcCtor).toHaveBeenCalledWith( - expect.any(Object), + expect(transportContext(webRtcInstances[0])).toEqual( expect.not.objectContaining({ videoEnabled: expect.anything() }), ); @@ -516,7 +537,7 @@ describe("RealtimeTalkSession", () => { ["talk.client.create", { sessionKey: "main", capabilities: ["voice-transcript"] }], ["talk.config", {}], ]); - expect(relayCtor).not.toHaveBeenCalled(); + expect(relayInstances).toHaveLength(0); }); it("falls back to Gateway relay when config selects Gateway relay", async () => { @@ -558,7 +579,7 @@ describe("RealtimeTalkSession", () => { transport: "gateway-relay", brain: "agent-consult", }); - expect(relayCtor).toHaveBeenCalledTimes(1); + expect(relayInstances).toHaveLength(1); expect(relayStart).toHaveBeenCalledTimes(1); }); @@ -595,7 +616,7 @@ describe("RealtimeTalkSession", () => { transport: "gateway-relay", brain: "agent-consult", }); - expect(relayCtor).toHaveBeenCalledTimes(1); + expect(relayInstances).toHaveLength(1); }); it("does not fall back when the effective config cannot be read", async () => { @@ -617,7 +638,7 @@ describe("RealtimeTalkSession", () => { ["talk.client.create", { sessionKey: "main", capabilities: ["voice-transcript"] }], ["talk.config", {}], ]); - expect(relayCtor).not.toHaveBeenCalled(); + expect(relayInstances).toHaveLength(0); }); it("does not fall back when the effective config payload is missing", async () => { @@ -639,7 +660,7 @@ describe("RealtimeTalkSession", () => { ["talk.client.create", { sessionKey: "main", capabilities: ["voice-transcript"] }], ["talk.config", {}], ]); - expect(relayCtor).not.toHaveBeenCalled(); + expect(relayInstances).toHaveLength(0); }); it("retries finalized transcript writes in order", async () => { @@ -668,7 +689,7 @@ describe("RealtimeTalkSession", () => { }); const session = new RealtimeTalkSession({ request } as never, "agent:main:main"); await session.start(); - const context = webRtcCtor.mock.calls[0]?.[1] as { + const context = transportContext(webRtcInstances[0]) as { callbacks: { onTranscript?: (entry: { role: "user" | "assistant"; @@ -708,7 +729,7 @@ describe("RealtimeTalkSession", () => { const session = new RealtimeTalkSession({ request } as never, "agent:main:main", {}); await session.start(); - const firstContext = webRtcCtor.mock.calls[0]?.[1] as { + const firstContext = transportContext(webRtcInstances[0]) as { callbacks: { onTranscript?: (entry: { role: "user" | "assistant"; @@ -722,7 +743,7 @@ describe("RealtimeTalkSession", () => { await firstContext.flushTranscriptWrites?.(); await session.start(); - const secondContext = webRtcCtor.mock.calls[1]?.[1] as typeof firstContext; + const secondContext = transportContext(webRtcInstances[1]) as typeof firstContext; secondContext.callbacks.onTranscript?.({ role: "assistant", text: "second", final: true }); await secondContext.flushTranscriptWrites?.(); @@ -765,7 +786,7 @@ describe("RealtimeTalkSession", () => { onStatus, }); await session.start(); - const context = webRtcCtor.mock.calls[0]?.[1] as { + const context = transportContext(webRtcInstances[0]) as { callbacks: { onTranscript?: (entry: { role: "user" | "assistant"; @@ -875,7 +896,7 @@ describe("RealtimeTalkSession", () => { }); const session = new RealtimeTalkSession({ request } as never, "agent:main:main"); await session.start(); - const context = webRtcCtor.mock.calls[0]?.[1] as { + const context = transportContext(webRtcInstances[0]) as { callbacks: { onTranscript?: (entry: { role: "user" | "assistant"; @@ -911,7 +932,7 @@ describe("RealtimeTalkSession", () => { onTranscript, }); await session.start(); - const previousContext = webRtcCtor.mock.calls[0]?.[1] as { + const previousContext = transportContext(webRtcInstances[0]) as { callbacks: { onTranscript?: (entry: { role: "user" | "assistant"; @@ -953,7 +974,7 @@ describe("RealtimeTalkSession", () => { }); const session = new RealtimeTalkSession({ request } as never, "agent:main:main"); await session.start(); - const context = relayCtor.mock.calls[0]?.[1] as { + const context = transportContext(relayInstances[0]) as { callbacks: { onTranscript?: (entry: { role: "user" | "assistant"; diff --git a/ui/src/pages/config/config-page.test.ts b/ui/src/pages/config/config-page.test.ts index 7bd1534f64b5..c3b02b02f122 100644 --- a/ui/src/pages/config/config-page.test.ts +++ b/ui/src/pages/config/config-page.test.ts @@ -10,15 +10,13 @@ import type { ApplicationGatewaySnapshot, } from "../../app/context.ts"; import { createStorageMock } from "../../test-helpers/storage.ts"; +import * as realtimeTalk from "../chat/realtime-talk.ts"; import { ConfigPage, configSelectionFromSearch, supportsSystemInfo } from "./config-page.ts"; import { configSectionKeysForPage } from "./config-sections.ts"; import type { ConfigViewState } from "./view.ts"; -const { switchActiveRealtimeTalkCameras } = vi.hoisted(() => ({ - switchActiveRealtimeTalkCameras: vi.fn<() => Promise>(), -})); - -vi.mock("../chat/realtime-talk.ts", () => ({ switchActiveRealtimeTalkCameras })); +const switchActiveRealtimeTalkCameras = + vi.fn(); function deferred() { let resolve!: (value: T) => void; @@ -31,6 +29,9 @@ function deferred() { let localStorageMock: Storage; beforeEach(() => { + vi.spyOn(realtimeTalk, "switchActiveRealtimeTalkCameras").mockImplementation( + switchActiveRealtimeTalkCameras, + ); localStorageMock = createStorageMock(); vi.stubGlobal("localStorage", localStorageMock); switchActiveRealtimeTalkCameras.mockReset(); diff --git a/ui/src/pages/cron/cron-page.test.ts b/ui/src/pages/cron/cron-page.test.ts index a557224af41d..2646de3affd6 100644 --- a/ui/src/pages/cron/cron-page.test.ts +++ b/ui/src/pages/cron/cron-page.test.ts @@ -304,12 +304,12 @@ describe("CronPage editor state sync", () => { }); describe("CronPage lifecycle", () => { - it("registers idempotently after a module reset with the shared custom element registry", async () => { + it("registers idempotently when the module is evaluated again", async () => { const registered = customElements.get("openclaw-cron-page"); expect(registered).toBeDefined(); - vi.resetModules(); - await expect(import("./cron-page.ts")).resolves.toBeDefined(); + const freshModulePath = "./cron-page.ts?custom-element-idempotence"; + await expect(import(/* @vite-ignore */ freshModulePath)).resolves.toBeDefined(); expect(customElements.get("openclaw-cron-page")).toBe(registered); }); diff --git a/ui/src/pages/plugins/view.test.ts b/ui/src/pages/plugins/view.test.ts index 16553133f607..daece8c01758 100644 --- a/ui/src/pages/plugins/view.test.ts +++ b/ui/src/pages/plugins/view.test.ts @@ -200,15 +200,14 @@ describe("renderPlugins", () => { it("keeps plugin monograms usable when Intl.Segmenter is unavailable", async () => { const originalSegmenter = Intl.Segmenter; Object.defineProperty(Intl, "Segmenter", { configurable: true, value: undefined }); - vi.resetModules(); try { - const { pluginMonogram } = await import("./presentation.ts"); + const freshModulePath = "./presentation.ts?without-intl-segmenter"; + const { pluginMonogram } = await import(/* @vite-ignore */ freshModulePath); expect(pluginMonogram("😀 Tools")).toBe("😀T"); expect(pluginMonogram("👩‍đŸ’ģ Tools")).toBe("👩T"); } finally { Object.defineProperty(Intl, "Segmenter", { configurable: true, value: originalSegmenter }); - vi.resetModules(); } }); diff --git a/ui/src/pages/workboard/workboard-page.test.ts b/ui/src/pages/workboard/workboard-page.test.ts index 83bc4e23b2d4..9b19f1cae3aa 100644 --- a/ui/src/pages/workboard/workboard-page.test.ts +++ b/ui/src/pages/workboard/workboard-page.test.ts @@ -1,26 +1,15 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { ApplicationContext, ApplicationGatewaySnapshot } from "../../app/context.ts"; import { createWorkboardCapability } from "../../lib/workboard/capability.ts"; import type { WorkboardCapability } from "../../lib/workboard/capability.ts"; +import * as workboardLib from "../../lib/workboard/index.ts"; -const { configureLiveRefresh, handleChanged, loadBoard, stopLiveRefresh, stopLifecycleRefresh } = - vi.hoisted(() => ({ - configureLiveRefresh: vi.fn((): boolean => false), - handleChanged: vi.fn(), - loadBoard: vi.fn(async () => true), - stopLiveRefresh: vi.fn(), - stopLifecycleRefresh: vi.fn(), - })); - -vi.mock("../../lib/workboard/index.ts", async (importOriginal) => ({ - ...(await importOriginal()), - configureWorkboardLiveRefresh: configureLiveRefresh, - handleWorkboardChanged: handleChanged, - loadWorkboard: loadBoard, - stopWorkboardLifecycleRefresh: stopLifecycleRefresh, - stopWorkboardLiveRefresh: stopLiveRefresh, - syncWorkboardLifecycle: vi.fn(async () => undefined), -})); +const configureLiveRefresh = vi.fn((): boolean => false); +const handleChanged = vi.fn(); +const loadBoard = vi.fn(async () => true); +const stopLiveRefresh = vi.fn(); +const stopLifecycleRefresh = vi.fn(); +const syncLifecycle = vi.fn(async () => undefined); await import("./workboard-page.ts"); @@ -81,11 +70,21 @@ function contextWithWorkboard(workboard: WorkboardCapability): ApplicationContex } as unknown as ApplicationContext; } +beforeEach(() => { + vi.spyOn(workboardLib, "configureWorkboardLiveRefresh").mockImplementation(configureLiveRefresh); + vi.spyOn(workboardLib, "handleWorkboardChanged").mockImplementation(handleChanged); + vi.spyOn(workboardLib, "loadWorkboard").mockImplementation(loadBoard); + vi.spyOn(workboardLib, "stopWorkboardLifecycleRefresh").mockImplementation(stopLifecycleRefresh); + vi.spyOn(workboardLib, "stopWorkboardLiveRefresh").mockImplementation(stopLiveRefresh); + vi.spyOn(workboardLib, "syncWorkboardLifecycle").mockImplementation(syncLifecycle); +}); + afterEach(() => { document.body.replaceChildren(); configureLiveRefresh.mockReset().mockReturnValue(false); loadBoard.mockClear(); vi.clearAllMocks(); + vi.restoreAllMocks(); }); describe("WorkboardPage lifecycle", () => { diff --git a/ui/vitest.config.ts b/ui/vitest.config.ts index 51452fa29ec4..079001777d15 100644 --- a/ui/vitest.config.ts +++ b/ui/vitest.config.ts @@ -85,6 +85,10 @@ const nodeDrivenBrowserLayoutTests = [ "src/components/form-controls.browser.test.ts", "src/pages/sessions/view.browser.test.ts", ] as const; +const mockRegistryUnitTests = [ + "src/components/mcp-app-view.test.ts", + "src/pages/chat/chat-page.test.ts", +] as const; const chromiumExecutableOverrideEnvKey = "PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH"; const systemChromiumExecutableCandidates = [ "/snap/bin/chromium", @@ -134,7 +138,28 @@ export default defineConfig({ deps: jsdomOptimizedDeps, name: "unit", include: ["src/**/*.test.ts"], - exclude: ["src/**/*.browser.test.ts", "src/**/*.e2e.test.ts", "src/**/*.node.test.ts"], + exclude: [ + "src/**/*.browser.test.ts", + "src/**/*.e2e.test.ts", + "src/**/*.node.test.ts", + ...mockRegistryUnitTests, + ], + environment: "jsdom", + setupFiles: ["./src/test-helpers/lit-warnings.setup.ts"], + }, + }), + defineProject({ + resolve: { + alias: workspaceSourceAliases, + }, + test: { + ...sharedUiTestConfig, + // These two tests intentionally replace module exports. Isolate only this tiny + // project so the main 339-file suite keeps its isolate:false speed contract. + isolate: true, + deps: jsdomOptimizedDeps, + name: "unit-mock-registry", + include: [...mockRegistryUnitTests], environment: "jsdom", setupFiles: ["./src/test-helpers/lit-warnings.setup.ts"], },