fix(ui): harden chromium test runner

This commit is contained in:
Vincent Koc
2026-06-17 05:28:53 +02:00
parent c48657b920
commit 66a8d0a7ec
10 changed files with 108 additions and 106 deletions

View File

@@ -1,25 +1,16 @@
// @vitest-environment node
import { afterEach, describe, expect, it, vi } from "vitest";
const loadNodesMock = vi.hoisted(() => vi.fn(async () => undefined));
vi.mock("./controllers/debug.ts", () => ({
loadDebug: vi.fn(async () => undefined),
}));
vi.mock("./controllers/logs.ts", () => ({
loadLogs: vi.fn(async () => undefined),
}));
vi.mock("./controllers/nodes.ts", () => ({
loadNodes: loadNodesMock,
}));
const { NODES_ACTIVE_POLL_INTERVAL_MS, startNodesPolling, stopNodesPolling } =
await import("./app-polling.ts");
function createHost() {
function createHost(request = vi.fn(async () => ({ nodes: [] }))) {
return {
client: {},
client: { request },
connected: true,
nodesLoading: false,
nodes: [],
lastError: null,
nodesPollInterval: null,
logsPollInterval: null,
debugPollInterval: null,
@@ -37,7 +28,6 @@ describe("startNodesPolling", () => {
}
vi.useRealTimers();
vi.unstubAllGlobals();
loadNodesMock.mockReset();
});
it("does not poll nodes while another tab is active", () => {
@@ -46,16 +36,17 @@ describe("startNodesPolling", () => {
clearInterval: globalThis.clearInterval,
setInterval: globalThis.setInterval,
});
const host = createHost();
const request = vi.fn(async () => ({ nodes: [] }));
const host = createHost(request);
testHost = host;
startNodesPolling(host as never);
vi.advanceTimersByTime(NODES_ACTIVE_POLL_INTERVAL_MS);
expect(loadNodesMock).not.toHaveBeenCalled();
expect(request).not.toHaveBeenCalled();
host.tab = "nodes";
vi.advanceTimersByTime(NODES_ACTIVE_POLL_INTERVAL_MS);
expect(loadNodesMock).toHaveBeenCalledWith(host, { quiet: true });
expect(request).toHaveBeenCalledWith("node.list", {});
stopNodesPolling(host as never);
});

View File

@@ -5,17 +5,8 @@ import type { AppViewState } from "./app-view-state.ts";
import type { LazyView } from "./lazy-view.ts";
import type { UsageProps } from "./views/usageTypes.ts";
const loadUsageMock = vi.hoisted(() => vi.fn(async () => {}));
const renderUsageMock = vi.hoisted(() => vi.fn((_props: UsageProps) => null));
vi.mock("./controllers/usage.ts", async (importOriginal) => {
const actual = await importOriginal<typeof import("./controllers/usage.ts")>();
return {
...actual,
loadUsage: loadUsageMock,
};
});
type UsageViewModule = typeof import("./views/usage.ts");
function createLoadedUsageView(): LazyView<UsageViewModule> {
@@ -46,6 +37,10 @@ function createState(overrides: Partial<AppViewState> = {}): AppViewState {
usageQueryDraft: "",
usageQueryDebounceTimer: null,
usageTimeZone: "local",
connected: true,
client: {
request: vi.fn(async () => ({})),
},
agentsList: {
defaultId: "main",
mainKey: "agent:main:main",
@@ -71,7 +66,10 @@ describe("renderUsageTab", () => {
});
it("reloads usage when selecting an agent scope", () => {
const state = createState();
const request = vi.fn(async () => ({}));
const state = createState({
client: { request } as unknown as AppViewState["client"],
});
renderUsageTab(state, createLoadedUsageView());
expect(renderUsageMock).toHaveBeenCalled();
@@ -82,6 +80,13 @@ describe("renderUsageTab", () => {
props.callbacks.filters.onAgentChange("research");
expect(state.usageAgentId).toBe("research");
expect(loadUsageMock).toHaveBeenCalledWith(state);
expect(request).toHaveBeenCalledWith(
"sessions.usage",
expect.objectContaining({ agentId: "research" }),
);
expect(request).toHaveBeenCalledWith(
"usage.cost",
expect.objectContaining({ agentId: "research" }),
);
});
});

View File

@@ -37,6 +37,7 @@ vi.mock("./views/chat.ts", () => ({
chatProps.current = props;
return html`<div data-testid="chat">${props.composerControls}</div>`;
},
resetChatViewState: vi.fn(),
}));
vi.mock("./app-render.helpers.ts", async (importOriginal) => {

View File

@@ -1,37 +1,9 @@
/* @vitest-environment jsdom */
import { render } from "lit";
import { describe, expect, it, vi } from "vitest";
import { describe, expect, it } from "vitest";
import { renderChatAvatar } from "./chat-avatar.ts";
vi.mock("../views/agents-utils.ts", () => ({
isRenderableControlUiAvatarUrl: (value: string) =>
/^data:image\//i.test(value) || (value.startsWith("/") && !value.startsWith("//")),
assistantAvatarFallbackUrl: () => "apple-touch-icon.png",
resolveAssistantTextAvatar: (value: string | null | undefined) => {
if (!value) {
return null;
}
return value.length <= 3 ? value : null;
},
resolveChatAvatarRenderUrl: (
candidate: string | null | undefined,
agent: { identity?: { avatar?: string; avatarUrl?: string } },
) => {
const isRenderableControlUiAvatarUrl = (value: string) =>
/^data:image\//i.test(value) || (value.startsWith("/") && !value.startsWith("//"));
if (typeof candidate === "string" && candidate.startsWith("blob:")) {
return candidate;
}
for (const value of [candidate, agent.identity?.avatarUrl, agent.identity?.avatar]) {
if (typeof value === "string" && isRenderableControlUiAvatarUrl(value)) {
return value;
}
}
return null;
},
}));
function renderAvatar(params: Parameters<typeof renderChatAvatar>) {
const container = document.createElement("div");
render(renderChatAvatar(...params), container);
@@ -41,13 +13,13 @@ function renderAvatar(params: Parameters<typeof renderChatAvatar>) {
describe("renderChatAvatar", () => {
it("renders assistant fallback, blob image, and text avatars", () => {
const defaultAvatar = renderAvatar(["assistant"]);
expect(defaultAvatar?.getAttribute("src")).toBe("apple-touch-icon.png");
expect(defaultAvatar?.getAttribute("src")).toBe("/apple-touch-icon.png");
const remoteAvatar = renderAvatar([
"assistant",
{ avatar: "https://example.com/avatar.png", name: "Val" },
]);
expect(remoteAvatar?.getAttribute("src")).toBe("apple-touch-icon.png");
expect(remoteAvatar?.getAttribute("src")).toBe("/apple-touch-icon.png");
const blobAvatar = renderAvatar(["assistant", { avatar: "blob:managed-image", name: "Val" }]);
expect(blobAvatar?.tagName).toBe("IMG");
@@ -68,7 +40,7 @@ describe("renderChatAvatar", () => {
"session-token",
]);
expect(avatar?.getAttribute("src")).toBe("apple-touch-icon.png");
expect(avatar?.getAttribute("src")).toBe("/apple-touch-icon.png");
});
it("renders local user image and text avatars", () => {

View File

@@ -1,8 +1,11 @@
// Control UI tests cover chat responsive behavior.
import { existsSync } from "node:fs";
import { chromium, type Browser, type Page } from "playwright";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { readStyleSheet } from "../../../../test/helpers/ui-style-fixtures.js";
import {
canRunPlaywrightChromium,
resolvePlaywrightChromiumExecutablePath,
} from "../../test-helpers/control-ui-e2e.ts";
const VIEWPORTS = [
[320, 568],
@@ -14,7 +17,10 @@ const VIEWPORTS = [
[1440, 900],
] as const;
const TOUCH_TARGET_MIN_PX = 43.5;
const describeBrowserLayout = existsSync(chromium.executablePath()) ? describe : describe.skip;
const chromiumExecutablePath = resolvePlaywrightChromiumExecutablePath(chromium.executablePath());
const describeBrowserLayout = canRunPlaywrightChromium(chromiumExecutablePath)
? describe
: describe.skip;
let browser: Browser;
@@ -329,7 +335,7 @@ async function expectNoHorizontalOverflow(page: Page) {
}
beforeAll(async () => {
browser = await chromium.launch({ headless: true });
browser = await chromium.launch({ executablePath: chromiumExecutablePath, headless: true });
});
afterAll(async () => {

View File

@@ -1,10 +1,16 @@
// Control UI tests cover sidebar session picker layering and interaction.
import { existsSync } from "node:fs";
import { chromium, type Browser, type Page } from "playwright";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { readStyleSheet } from "../../../../test/helpers/ui-style-fixtures.js";
import {
canRunPlaywrightChromium,
resolvePlaywrightChromiumExecutablePath,
} from "../../test-helpers/control-ui-e2e.ts";
const describeBrowserLayout = existsSync(chromium.executablePath()) ? describe : describe.skip;
const chromiumExecutablePath = resolvePlaywrightChromiumExecutablePath(chromium.executablePath());
const describeBrowserLayout = canRunPlaywrightChromium(chromiumExecutablePath)
? describe
: describe.skip;
let browser: Browser;
@@ -263,7 +269,7 @@ async function expectNoHorizontalOverflow(page: Page) {
}
beforeAll(async () => {
browser = await chromium.launch({ headless: true });
browser = await chromium.launch({ executablePath: chromiumExecutablePath, headless: true });
});
afterAll(async () => {

View File

@@ -10,32 +10,6 @@ import {
renderToolCardSidebar,
} from "./tool-cards.ts";
vi.mock("../icons.ts", () => ({
icons: {
check: "✓",
chevronDown: "",
panelRightOpen: "",
x: "✕",
zap: "",
},
}));
vi.mock("../tool-display.ts", () => ({
formatToolDetail: () => undefined,
resolveToolDisplay: ({ name, args }: { name: string; args?: unknown }) => ({
name,
label: name
.split(/[._-]/g)
.map((part) => (part ? part[0].toUpperCase() + part.slice(1) : part))
.join(" "),
icon: "zap",
detail:
args && typeof args === "object" && "detail" in args
? String((args as { detail: unknown }).detail)
: undefined,
}),
}));
function requireFirstMockArg(
mock: ReturnType<typeof vi.fn>,
label: string,
@@ -121,7 +95,7 @@ describe("tool-cards", () => {
const summaryButton = container.querySelector("button.chat-tool-msg-summary");
expect(summaryButton?.querySelector(".chat-tool-msg-summary__label")?.textContent).toBe(
"Sessions Spawn",
"Sub-agent",
);
expect(summaryButton?.getAttribute("aria-expanded")).toBe("false");
expect(container.querySelector(".chat-tool-msg-body")).toBeNull();
@@ -134,8 +108,8 @@ describe("tool-cards", () => {
{
id: "msg:5a:call-5a",
name: "skill_workshop",
args: { detail: "create" },
inputText: '{\n "detail": "create"\n}',
args: { action: "create" },
inputText: '{\n "action": "create"\n}',
outputText: "Proposal created",
},
{ expanded: false, onToggleExpanded: vi.fn() },
@@ -537,7 +511,6 @@ describe("tool-cards", () => {
expect(card?.classList.contains("chat-tool-card--error")).toBe(true);
expect(action?.classList.contains("chat-tool-card__action--error")).toBe(true);
expect(action?.textContent).toContain("View error");
expect(action?.textContent).toContain("✕");
expect(action?.textContent).not.toContain("✓");
});
@@ -558,7 +531,6 @@ describe("tool-cards", () => {
const action = container.querySelector(".chat-tool-card__action");
expect(container.querySelector(".chat-tool-card--error")).not.toBeNull();
expect(action?.textContent).toContain("View error");
expect(action?.textContent).toContain("✕");
expect(action?.textContent).not.toContain("✓");
});
@@ -579,7 +551,6 @@ describe("tool-cards", () => {
const action = container.querySelector(".chat-tool-card__action");
expect(container.querySelector(".chat-tool-card--error")).not.toBeNull();
expect(action?.textContent).toContain("View error");
expect(action?.textContent).toContain("✕");
expect(action?.textContent).not.toContain("✓");
});

View File

@@ -1,10 +1,16 @@
// Control UI tests cover form controls behavior.
import { existsSync } from "node:fs";
import { chromium, type Browser, type Page } from "playwright";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { readStyleSheet } from "../../../test/helpers/ui-style-fixtures.js";
import {
canRunPlaywrightChromium,
resolvePlaywrightChromiumExecutablePath,
} from "../test-helpers/control-ui-e2e.ts";
const describeBrowserLayout = existsSync(chromium.executablePath()) ? describe : describe.skip;
const chromiumExecutablePath = resolvePlaywrightChromiumExecutablePath(chromium.executablePath());
const describeBrowserLayout = canRunPlaywrightChromium(chromiumExecutablePath)
? describe
: describe.skip;
let browser: Browser;
@@ -61,7 +67,7 @@ async function openMobileFixture(): Promise<Page> {
describeBrowserLayout("touch-primary form controls", () => {
beforeAll(async () => {
browser = await chromium.launch({ headless: true });
browser = await chromium.launch({ executablePath: chromiumExecutablePath, headless: true });
});
afterAll(async () => {

View File

@@ -1,8 +1,11 @@
// Control UI tests cover sessions behavior.
import { existsSync } from "node:fs";
import { chromium, type Browser, type Page } from "playwright";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { readStyleSheet } from "../../../../test/helpers/ui-style-fixtures.js";
import {
canRunPlaywrightChromium,
resolvePlaywrightChromiumExecutablePath,
} from "../../test-helpers/control-ui-e2e.ts";
const VIEWPORTS = [
[375, 812],
@@ -11,7 +14,10 @@ const VIEWPORTS = [
[1440, 900],
] as const;
const describeBrowserLayout = existsSync(chromium.executablePath()) ? describe : describe.skip;
const chromiumExecutablePath = resolvePlaywrightChromiumExecutablePath(chromium.executablePath());
const describeBrowserLayout = canRunPlaywrightChromium(chromiumExecutablePath)
? describe
: describe.skip;
let browser: Browser;
@@ -160,7 +166,7 @@ async function openFixture(width: number, height: number): Promise<Page> {
describeBrowserLayout("sessions responsive browser layout", () => {
beforeAll(async () => {
browser = await chromium.launch({ headless: true });
browser = await chromium.launch({ executablePath: chromiumExecutablePath, headless: true });
});
afterAll(async () => {

View File

@@ -1,7 +1,10 @@
// Control UI config module wires vitest behavior.
import { spawnSync } from "node:child_process";
import { existsSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { playwright } from "@vitest/browser-playwright";
import { chromium } from "playwright";
import { defineConfig, defineProject } from "vitest/config";
import {
jsdomOptimizedDeps,
@@ -57,10 +60,43 @@ const sharedUiTestConfig = {
pool: resolveDefaultVitestPool(),
} as const;
const nodeDrivenBrowserLayoutTests = [
"src/ui/chat/sidebar-session-picker.browser.test.ts",
"src/ui/chat/chat-responsive.browser.test.ts",
"src/ui/form-controls.browser.test.ts",
"src/ui/views/sessions.browser.test.ts",
] as const;
const chromiumExecutableOverrideEnvKey = "PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH";
const systemChromiumExecutableCandidates = [
"/snap/bin/chromium",
"/usr/bin/chromium-browser",
"/usr/bin/chromium",
"/usr/bin/google-chrome",
"/usr/bin/google-chrome-stable",
] as const;
function canRunChromiumExecutable(executablePath: string): boolean {
const result = spawnSync(executablePath, ["--version"], { stdio: "ignore" });
return result.status === 0;
}
function resolveChromiumLaunchOptions(): { executablePath: string } | undefined {
const override = process.env[chromiumExecutableOverrideEnvKey]?.trim();
if (override && existsSync(override) && canRunChromiumExecutable(override)) {
return { executablePath: override };
}
const defaultExecutablePath = chromium.executablePath();
if (existsSync(defaultExecutablePath) && canRunChromiumExecutable(defaultExecutablePath)) {
return undefined;
}
const systemExecutablePath = systemChromiumExecutableCandidates.find(
(candidate) => existsSync(candidate) && canRunChromiumExecutable(candidate),
);
return systemExecutablePath ? { executablePath: systemExecutablePath } : undefined;
}
const chromiumLaunchOptions = resolveChromiumLaunchOptions();
export default defineConfig({
resolve: {
@@ -108,7 +144,9 @@ export default defineConfig({
setupFiles: ["./src/test-helpers/lit-warnings.setup.ts"],
browser: {
enabled: true,
provider: playwright(),
provider: playwright(
chromiumLaunchOptions ? { launchOptions: chromiumLaunchOptions } : {},
),
instances: [{ browser: "chromium", name: "chromium" }],
headless: true,
ui: false,