mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-22 11:08:10 +00:00
fix(ui): harden chromium test runner
This commit is contained in:
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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("✓");
|
||||
});
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user