fix(ui): prevent cross-file mock leakage in Control UI tests (#111554)

* test(ui): stop shared mock registry leakage

* ci: add three-worker UI leakage canary
This commit is contained in:
Peter Steinberger
2026-07-19 15:38:50 -07:00
committed by GitHub
parent 7116ad6e28
commit 3247a56d15
21 changed files with 341 additions and 303 deletions
+3 -1
View File
@@ -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:
+4 -3
View File
@@ -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();
}
});
+6 -6
View File
@@ -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<typeof import("../lib/nodes/index.ts")>()),
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 () => {
@@ -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();
+5 -2
View File
@@ -7,7 +7,10 @@ const bridgeMocks = vi.hoisted(() => ({
transports: [] as Array<Record<string, unknown>>,
}));
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<typeof import("@modelcontextprotocol/ext-apps/app-bridge")>();
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");
+13 -4
View File
@@ -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<typeof avatarImage.fileToAvatarDataUrl>();
beforeEach(() => {
vi.spyOn(avatarImage, "fileToAvatarDataUrl").mockImplementation(fileToAvatarDataUrlMock);
});
afterEach(() => {
fileToAvatarDataUrlMock.mockReset();
vi.restoreAllMocks();
});
function host(): Parameters<typeof resetIdentityDraft>[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;
}),
-4
View File
@@ -8,10 +8,6 @@ import { renderChatControls } from "./components/chat-controls.ts";
type ChatControlsProps = Parameters<typeof renderChatControls>[0];
vi.mock("../../components/icons.ts", () => ({
icons: {},
}));
function createSettings(): UiSettings {
return {
gatewayUrl: "ws://localhost:18789",
+2 -2
View File
@@ -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";
+14 -17
View File
@@ -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<typeof import("./chat-command-executor.ts")>();
return {
...actual,
executeSlashCommand: (...args: Parameters<ExecuteSlashCommand>) => {
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());
});
-8
View File
@@ -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> = {}): ChatSideResult {
return {
kind: "btw",
+7 -5
View File
@@ -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<typeof import("../../app/assistant-identity.ts")>()),
loadLocalAssistantIdentity: () => ({ avatar: "data:image/png;base64,bG9jYWw=" }),
}));
beforeEach(() => {
vi.spyOn(assistantIdentity, "loadLocalAssistantIdentity").mockReturnValue({
avatar: "data:image/png;base64,bG9jYWw=",
});
});
afterEach(() => {
replaceSlashCommands(buildFallbackSlashCommands());
+111 -129
View File
@@ -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<typeof chatThread.buildCachedChatItems> => {
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<typeof chatThread.buildCachedChatItems>;
}
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<typeof chatThread.buildCachedChatItems>;
},
);
const renderMessageGroupMock = vi.fn(
(
@@ -250,24 +247,6 @@ function requireFirstAttachmentsChange(
return attachments as ChatAttachment[];
}
vi.mock("../../components/icons.ts", async (importOriginal) =>
importOriginal<typeof import("../../components/icons.ts")>(),
);
vi.mock("./chat-thread.ts", async (importOriginal) => {
const actual = await importOriginal<typeof import("./chat-thread.ts")>();
return {
...actual,
buildCachedChatItems: buildChatItemsMock,
getExpandedToolCards: () => new Map<string, boolean>(),
syncToolCardExpansionState: () => undefined,
};
});
vi.mock("../../lib/agents/tools-effective.ts", () => ({
refreshVisibleToolsEffectiveForCurrentSession: refreshVisibleToolsEffectiveForCurrentSessionMock,
}));
function renderStreamGroupMock(
...[parts, _opts]: Parameters<typeof chatMessage.renderStreamGroup>
): ReturnType<typeof chatMessage.renderStreamGroup> {
@@ -287,6 +266,9 @@ function renderWorkGroupSummaryMock(
}
beforeEach(() => {
vi.spyOn(chatThread, "buildCachedChatItems").mockImplementation(buildChatItemsMock);
vi.spyOn(chatThread, "getExpandedToolCards").mockReturnValue(new Map<string, boolean>());
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<void> => refreshVisibleToolsEffectiveForCurrentSessionMock(state),
onModelChanged: (): Promise<void> =>
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();
@@ -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<typeof toolDisplay.resolveToolDisplay>[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<typeof toolDisplay.resolveToolDisplay>;
}
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", () => {
@@ -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",
+4 -1
View File
@@ -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;
+94 -73
View File
@@ -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";
+6 -5
View File
@@ -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<void>>(),
}));
vi.mock("../chat/realtime-talk.ts", () => ({ switchActiveRealtimeTalkCameras }));
const switchActiveRealtimeTalkCameras =
vi.fn<typeof realtimeTalk.switchActiveRealtimeTalkCameras>();
function deferred<T>() {
let resolve!: (value: T) => void;
@@ -31,6 +29,9 @@ function deferred<T>() {
let localStorageMock: Storage;
beforeEach(() => {
vi.spyOn(realtimeTalk, "switchActiveRealtimeTalkCameras").mockImplementation(
switchActiveRealtimeTalkCameras,
);
localStorageMock = createStorageMock();
vi.stubGlobal("localStorage", localStorageMock);
switchActiveRealtimeTalkCameras.mockReset();
+3 -3
View File
@@ -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);
});
+2 -3
View File
@@ -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();
}
});
+18 -19
View File
@@ -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<typeof import("../../lib/workboard/index.ts")>()),
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", () => {
+26 -1
View File
@@ -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"],
},