mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 02:06:43 +00:00
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:
@@ -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:
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
}),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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
@@ -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"],
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user