From 66a8d0a7ecc908d7eb2037cd1dd6d8acceed30e3 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 17 Jun 2026 05:28:53 +0200 Subject: [PATCH] fix(ui): harden chromium test runner --- ui/src/ui/app-polling.node.test.ts | 27 +++++-------- ui/src/ui/app-render-usage-tab.test.ts | 27 ++++++++----- ui/src/ui/app-render.assistant-avatar.test.ts | 1 + ui/src/ui/chat/chat-avatar.test.ts | 36 ++--------------- .../ui/chat/chat-responsive.browser.test.ts | 12 ++++-- .../sidebar-session-picker.browser.test.ts | 12 ++++-- ui/src/ui/chat/tool-cards.test.ts | 35 ++-------------- ui/src/ui/form-controls.browser.test.ts | 12 ++++-- ui/src/ui/views/sessions.browser.test.ts | 12 ++++-- ui/vitest.config.ts | 40 ++++++++++++++++++- 10 files changed, 108 insertions(+), 106 deletions(-) diff --git a/ui/src/ui/app-polling.node.test.ts b/ui/src/ui/app-polling.node.test.ts index 06f269e6c87..c5442af9ce7 100644 --- a/ui/src/ui/app-polling.node.test.ts +++ b/ui/src/ui/app-polling.node.test.ts @@ -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); }); diff --git a/ui/src/ui/app-render-usage-tab.test.ts b/ui/src/ui/app-render-usage-tab.test.ts index bbbd8dace24..b89e33109e2 100644 --- a/ui/src/ui/app-render-usage-tab.test.ts +++ b/ui/src/ui/app-render-usage-tab.test.ts @@ -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(); - return { - ...actual, - loadUsage: loadUsageMock, - }; -}); - type UsageViewModule = typeof import("./views/usage.ts"); function createLoadedUsageView(): LazyView { @@ -46,6 +37,10 @@ function createState(overrides: Partial = {}): 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" }), + ); }); }); diff --git a/ui/src/ui/app-render.assistant-avatar.test.ts b/ui/src/ui/app-render.assistant-avatar.test.ts index 101478a615e..877740934c7 100644 --- a/ui/src/ui/app-render.assistant-avatar.test.ts +++ b/ui/src/ui/app-render.assistant-avatar.test.ts @@ -37,6 +37,7 @@ vi.mock("./views/chat.ts", () => ({ chatProps.current = props; return html`
${props.composerControls}
`; }, + resetChatViewState: vi.fn(), })); vi.mock("./app-render.helpers.ts", async (importOriginal) => { diff --git a/ui/src/ui/chat/chat-avatar.test.ts b/ui/src/ui/chat/chat-avatar.test.ts index 5ab5b488414..a19dd1d0c9c 100644 --- a/ui/src/ui/chat/chat-avatar.test.ts +++ b/ui/src/ui/chat/chat-avatar.test.ts @@ -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) { const container = document.createElement("div"); render(renderChatAvatar(...params), container); @@ -41,13 +13,13 @@ function renderAvatar(params: Parameters) { 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", () => { diff --git a/ui/src/ui/chat/chat-responsive.browser.test.ts b/ui/src/ui/chat/chat-responsive.browser.test.ts index 4e3a2950927..029d7fb5ed4 100644 --- a/ui/src/ui/chat/chat-responsive.browser.test.ts +++ b/ui/src/ui/chat/chat-responsive.browser.test.ts @@ -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 () => { diff --git a/ui/src/ui/chat/sidebar-session-picker.browser.test.ts b/ui/src/ui/chat/sidebar-session-picker.browser.test.ts index 7da72307a32..e2ba3873b37 100644 --- a/ui/src/ui/chat/sidebar-session-picker.browser.test.ts +++ b/ui/src/ui/chat/sidebar-session-picker.browser.test.ts @@ -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 () => { diff --git a/ui/src/ui/chat/tool-cards.test.ts b/ui/src/ui/chat/tool-cards.test.ts index fa49d3c6a08..854b297e8ae 100644 --- a/ui/src/ui/chat/tool-cards.test.ts +++ b/ui/src/ui/chat/tool-cards.test.ts @@ -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, 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("✓"); }); diff --git a/ui/src/ui/form-controls.browser.test.ts b/ui/src/ui/form-controls.browser.test.ts index bbe5086e8c7..c9544986d83 100644 --- a/ui/src/ui/form-controls.browser.test.ts +++ b/ui/src/ui/form-controls.browser.test.ts @@ -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 { describeBrowserLayout("touch-primary form controls", () => { beforeAll(async () => { - browser = await chromium.launch({ headless: true }); + browser = await chromium.launch({ executablePath: chromiumExecutablePath, headless: true }); }); afterAll(async () => { diff --git a/ui/src/ui/views/sessions.browser.test.ts b/ui/src/ui/views/sessions.browser.test.ts index d788aa07b71..4eb38130fbd 100644 --- a/ui/src/ui/views/sessions.browser.test.ts +++ b/ui/src/ui/views/sessions.browser.test.ts @@ -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 { describeBrowserLayout("sessions responsive browser layout", () => { beforeAll(async () => { - browser = await chromium.launch({ headless: true }); + browser = await chromium.launch({ executablePath: chromiumExecutablePath, headless: true }); }); afterAll(async () => { diff --git a/ui/vitest.config.ts b/ui/vitest.config.ts index 82586e0b1ee..723536c8ecc 100644 --- a/ui/vitest.config.ts +++ b/ui/vitest.config.ts @@ -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,