diff --git a/packages/webui/src/client/shell/icons.tsx b/packages/webui/src/client/shell/icons.tsx index 18059ac5c..10203aa10 100644 --- a/packages/webui/src/client/shell/icons.tsx +++ b/packages/webui/src/client/shell/icons.tsx @@ -3,12 +3,12 @@ import type { ReactElement } from "react"; import { IconArchive, IconArrowBackUp, IconArrowUp, IconBell, IconBug, IconCirclePlus, - IconCalendarEvent, IconChartBar, IconCheck, IconChevronDown, IconChevronLeft, + IconCalendarEvent, IconCaretRightFilled, IconChartBar, IconCheck, IconChevronDown, IconChevronLeft, IconChevronRight, IconClipboard, IconCode, IconCopy, IconDeviceMobile, IconDots, IconDownload, IconEdit, IconFile, IconFileCode, IconFolder, IconFolderPlus, IconGitBranch, IconGitCompare, IconGitFork, IconHierarchy, IconLayoutDashboard, IconLayoutSidebar, IconMapPin, IconMessage, IconMessageCircle, IconMessageQuestion, - IconMinimize, IconPaperclip, IconPin, IconPuzzle, IconRefresh, IconSearch, + IconMinimize, IconPaperclip, IconPin, IconPinFilled, IconPuzzle, IconRefresh, IconSearch, IconShieldCheck, IconShieldLock, IconShieldX, IconSparkles, IconTarget, IconTerminal2, IconThumbDown, IconThumbUp, IconTrash, IconWorld, IconX, @@ -50,7 +50,8 @@ export function WebuiIconBell({ className }: WebuiIconProps): ReactElement { ret export function WebuiIconAttach({ className }: WebuiIconProps): ReactElement { return glyph(IconPaperclip, className); } export function WebuiIconChevronDown({ className }: WebuiIconProps): ReactElement { return glyph(IconChevronDown, className, 16); } export function WebuiIconSessionDisclosure({ className }: WebuiIconProps): ReactElement { return glyph(IconChevronRight, className, 14); } -export function WebuiIconSessionTreeDisclosure({ className }: WebuiIconProps): ReactElement { return glyph(IconChevronRight, className, 14); } +/** Filled disclosure arrow retained for expandable parent-session rows. */ +export function WebuiIconSessionTreeDisclosure({ className }: WebuiIconProps): ReactElement { return glyph(IconCaretRightFilled, className, 14); } export function WebuiIconPermissionRequest({ className }: WebuiIconProps): ReactElement { return glyph(IconShieldLock, className); } export function WebuiIconPermissionAuto({ className }: WebuiIconProps): ReactElement { return glyph(IconShieldCheck, className); } export function WebuiIconPermissionFull({ className }: WebuiIconProps): ReactElement { return glyph(IconShieldX, className); } @@ -64,7 +65,13 @@ export function WebuiIconMore({ className }: WebuiIconProps): ReactElement { ret export function WebuiIconProjectAdd({ className }: WebuiIconProps): ReactElement { return glyph(IconFolderPlus, className); } export function WebuiIconSessionPinMark({ className }: WebuiIconProps): ReactElement { return glyph(IconPin, className, 14); } export function WebuiIconSessionPin({ className }: WebuiIconProps): ReactElement { return glyph(IconPin, className); } -export function WebuiIconContextPin({ className, pinned = false }: WebuiIconProps & { pinned?: boolean }): ReactElement { return glyph(IconPin, className); } +export function WebuiIconContextPin({ className, pinned = false }: WebuiIconProps & { pinned?: boolean }): ReactElement { + // The pinned state draws the filled pin and the unpinned one the outline, so + // a pinned row reads as pinned without the label having to say it. The glyph + // used to say it through a hand-drawn slashed variant; the tabler pair says + // it the same way. + return glyph(pinned ? IconPinFilled : IconPin, className); +} export function WebuiIconContextRename({ className }: WebuiIconProps): ReactElement { return glyph(IconEdit, className); } export function WebuiIconContextArchive({ className }: WebuiIconProps): ReactElement { return glyph(IconArchive, className); } export function WebuiIconContextFork({ className }: WebuiIconProps): ReactElement { return glyph(IconGitFork, className); } @@ -73,7 +80,11 @@ export function WebuiIconContextFeedback({ className }: WebuiIconProps): ReactEl export function WebuiIconContextExport({ className }: WebuiIconProps): ReactElement { return glyph(IconDownload, className); } export function WebuiIconSessionImport({ className }: WebuiIconProps): ReactElement { return glyph(IconArrowBackUp, className); } export function WebuiIconContextTrash({ className }: WebuiIconProps): ReactElement { return glyph(IconTrash, className); } -export function WebuiIconContextChevron({ className }: WebuiIconProps): ReactElement { return glyph(IconChevronRight, className); } +export function WebuiIconContextChevron({ className }: WebuiIconProps): ReactElement { + // The submenu chevron sits in a 16px slot (`.webui-context-menu-chevron`), + // so it draws at 16 — the same size the other chevrons draw at. + return glyph(IconChevronRight, className, 16); +} export function WebuiIconRunLocation({ className }: WebuiIconProps): ReactElement { return glyph(IconMapPin, className); } export function WebuiIconSidebarToggle({ className }: WebuiIconProps): ReactElement { return glyph(IconLayoutSidebar, className); } export function WebuiIconSearch({ className }: WebuiIconProps): ReactElement { return glyph(IconSearch, className); } diff --git a/packages/webui/test/browser/questionnaire.spec.mjs b/packages/webui/test/browser/questionnaire.spec.mjs index 2fc1bf8f3..d21fe09bb 100644 --- a/packages/webui/test/browser/questionnaire.spec.mjs +++ b/packages/webui/test/browser/questionnaire.spec.mjs @@ -74,7 +74,10 @@ test("steps forward and back, gating every required step", async ({ page }) => { await openApp(page, "#session=A"); await expect(card(page)).toBeVisible(); - await expect(page.locator('[data-testid="questionnaire-progress"]')).toHaveText("‹1/3›"); + // The progress reads 1/3 between the two step affordances. The next one is + // an icon now, so it contributes no text to the indicator. + await expect(page.locator('[data-testid="questionnaire-progress"]')).toContainText("1/3"); + await expect(page.locator('[data-testid="questionnaire-progress-next"] svg')).toBeVisible(); // Required, unanswered: both ways forward are locked. This is the gate the // roadmap's 必填校验 row claimed — asserted on the real buttons. @@ -86,7 +89,7 @@ test("steps forward and back, gating every required step", async ({ page }) => { await page.locator('[data-webui-questionnaire-option="o1a"]').click(); await expect(next).toBeEnabled(); await next.click(); - await expect(page.locator('[data-testid="questionnaire-progress"]')).toHaveText("‹2/3›"); + await expect(page.locator('[data-testid="questionnaire-progress"]')).toContainText("2/3"); // Multi-select: the hint is the only signal the row offers that Space-like // toggling, not replacement, is the semantics. @@ -98,7 +101,7 @@ test("steps forward and back, gating every required step", async ({ page }) => { // Back: the first step's answer survives the round trip. await page.locator('[data-testid="questionnaire-progress-prev"]').click(); - await expect(page.locator('[data-testid="questionnaire-progress"]')).toHaveText("‹1/3›"); + await expect(page.locator('[data-testid="questionnaire-progress"]')).toContainText("1/3"); await expect(page.locator('[data-webui-questionnaire-option="o1a"]')).toHaveAttribute("data-selected", "true"); await page.locator('[data-testid="questionnaire-next"]').click(); @@ -216,5 +219,5 @@ test("a multi-step request with no presentation block renders instead of throwin // DEFAULT_PRESENTATION: progress shown, back navigation allowed. await expect(page.locator('[data-testid="webui-error-boundary"]')).toHaveCount(0); await expect(card(page)).toBeVisible(); - await expect(page.locator('[data-testid="questionnaire-progress"]')).toHaveText("‹1/2›"); + await expect(page.locator('[data-testid="questionnaire-progress"]')).toContainText("1/2"); }); diff --git a/packages/webui/test/browser/rail-pin.spec.mjs b/packages/webui/test/browser/rail-pin.spec.mjs index 45a445081..f0fe00488 100644 --- a/packages/webui/test/browser/rail-pin.spec.mjs +++ b/packages/webui/test/browser/rail-pin.spec.mjs @@ -73,20 +73,22 @@ function clipRightInset(clipPath) { } /** - * The state of the pin glyph in a row's action button, read from the clip path - * the glyph actually references. + * The state of the pin glyph in a row's action button, read from the glyph's + * own class. * - * Off the `` reference rather than off a `clipPath` type selector: - * the question is whether the slash's clip resolves to *this* row's own path - * definition, and the reference is what the browser resolves. + * The filled pin is the pinned state and the outline the unpinned one, and + * the class is what says which: the hand-drawn glyph said it through the id + * of the clip path it carried, and the tabler glyph says it through its + * class. */ async function pinGlyphState(page, sessionId) { - const url = await rowOf(page, sessionId) - .locator(".webui-rail-action g[clip-path]") + const className = await rowOf(page, sessionId) + .locator(".webui-rail-action svg") .first() - .getAttribute("clip-path"); - const id = url?.match(/#(.+)\)/u)?.[1] ?? ""; - return id.includes("unpinned") ? "unpinned" : id.includes("pinned") ? "pinned" : "none"; + .getAttribute("class"); + if (!className) return "none"; + if (className.includes("tabler-icon-pin-filled")) return "pinned"; + return className.includes("tabler-icon-pin") ? "unpinned" : "none"; } async function pinButtonLabel(page, sessionId) { @@ -186,31 +188,19 @@ test("the pin button offers the pin glyph on a row that is not pinned", async ({ expect(await pinButtonLabel(page, "B")).toBe("置顶:灵动岛卡住问题"); }); -test("two pinned rows each get their own clip path", async ({ page }) => { +test("two pinned rows each draw their own pin glyph", async ({ page }) => { // A and the child C. A shared clipPath id would make the second row's slash // clip against the first row's element -- visually identical here because the - // two definitions match, which is exactly why it needs asserting on ids. + // two definitions match, which is exactly why it needs asserting on ids. The + // tabler glyph carries no such id, so the rule that is left is the one that + // still holds: each row has an element of its own. await seedPins(page, { sessions: { A: true, B: true } }); await openApp(page, "#session=A"); - // `url(#...)` reduced to the bare id on both sides: the attribute carries the - // function wrapper, the definition does not. - const ids = await page - .locator(".webui-rail-action g[clip-path]") - .evaluateAll((nodes) => - nodes.map((node) => (node.getAttribute("clip-path") ?? "").replace(/^url\(#(.*)\)$/u, "$1")), - ); - expect(ids).toHaveLength(2); - expect(new Set(ids).size).toBe(2); - // And each reference resolves to a path definition that actually exists in - // the document. Two identical ids would satisfy the uniqueness check above - // while both glyphs clip against the first row's element. - const defined = await page.evaluate(() => - [...document.querySelectorAll("clipPath")].map((node) => node.id), - ); - for (const id of ids) { - expect(defined, `no clipPath defines ${id}`).toContain(id); - } + const glyphs = page.locator('.webui-rail-action svg[class*="tabler-icon-pin"]'); + await expect(glyphs).toHaveCount(2); + // Two separate elements, not one element counted twice. + expect(await glyphs.evaluateAll((nodes) => new Set(nodes).size)).toBe(2); }); test("a pinned project row keeps the folder icon without an extra pin glyph", async ({ page }) => { @@ -340,8 +330,12 @@ test("left rail expanders share the pinned animation and parent rows keep their return [style.transitionProperty, style.transitionDuration, style.transitionTimingFunction]; })).toEqual(pinnedTransition); - const disclosurePath = await childDisclosure.locator("path").getAttribute("d"); - expect(disclosurePath).toBe("M4.25 2.75 8 6l-3.75 3.25v-6.5Z"); + // The parent row's disclosure is the filled caret; the project row's is the + // outline chevron. The hand-drawn glyph said so through the path it drew, + // and the tabler glyph says it through its class and its fill. + const disclosure = childDisclosure.locator("svg").first(); + expect(await disclosure.getAttribute("class")).toContain("tabler-icon-caret-right-filled"); + expect(await disclosure.getAttribute("fill")).toBe("currentColor"); await childDisclosure.click(); await expect(childContent).toHaveAttribute("aria-hidden", "false"); diff --git a/packages/webui/test/gates/webui-dependency-check.test.ts b/packages/webui/test/gates/webui-dependency-check.test.ts index 90e6a6455..d28e7b81c 100644 --- a/packages/webui/test/gates/webui-dependency-check.test.ts +++ b/packages/webui/test/gates/webui-dependency-check.test.ts @@ -11,7 +11,7 @@ // The fixtures live under the OS temp directory, never in the repository, so // `scripts/source-inventory.mjs` never sees them. -import { afterAll, describe, expect, it } from "vitest"; +import { afterAll, describe, expect, it, vi } from "vitest"; import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; import os from "node:os"; import path from "node:path"; @@ -51,6 +51,18 @@ const realBaselinePath = path.join( "scripts/lib/webui-dependency-baseline.json", ); +/** + * The budget every case in this file runs under. + * + * Each fixture builds a TypeScript program, so what a case costs is the + * machine's speed rather than a fact about the rule it checks. Under + * vitest's five-second default a loaded machine turns that into a failure + * that says nothing about the gate — the same case passes on an idle one, + * and a different case fails each time. The budget is written down here so + * the gate reports the rule rather than the machine it ran on. + */ +vi.setConfig({ testTimeout: 30_000 }); + describe("V42 frozen pre-refactor metadata and permissions", () => { const oracle = JSON.parse(readFileSync(path.join(here, "./fixtures/path-metadata-f00.json"), "utf8")) as { cases: { path: string; layers: string[]; role: string; business: string | null; home: string }[]; diff --git a/packages/webui/test/integration/agent-transcript.test.ts b/packages/webui/test/integration/agent-transcript.test.ts index 5549334a8..e4cfdf057 100644 --- a/packages/webui/test/integration/agent-transcript.test.ts +++ b/packages/webui/test/integration/agent-transcript.test.ts @@ -6,7 +6,7 @@ import { createServer } from "node:http"; import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; -import { chromium, expect as browserExpect } from "@playwright/test"; +import { chromium, expect as browserExpect, type Browser } from "@playwright/test"; import { expect, it } from "vitest"; const MODEL_ID = "stub-model"; @@ -23,6 +23,25 @@ function frame(delta: Record, finishReason: string | null = nul })}\n\n`; } +/** + * The browser this case drives. + * + * This file belongs to the `test:webui` Vitest gate, which runs before the + * `test:webui-browser` gate installs playwright's own chromium — so on CI the + * pinned browser is not on disk yet. The runner's own Chrome is, and playwright + * drives it through the `chrome` channel. A launch that fails for any other + * reason is the failure it looks like, and is not retried. + */ +async function launchBrowser(): Promise { + try { + return await chromium.launch({ headless: true }); + } catch (error) { + const missingBrowser = error instanceof Error && error.message.includes("Executable doesn't exist"); + if (!missingBrowser) throw error; + return await chromium.launch({ headless: true, channel: "chrome" }); + } +} + it("a real agent turn keeps thinking and order across browser refresh and resume", async () => { let releaseAnswer: () => void = () => undefined; const answerGate = new Promise((resolve) => { releaseAnswer = resolve; }); @@ -114,7 +133,7 @@ it("a real agent turn keeps thinking and order across browser refresh and resume }); const info = await service.start(); - browser = await chromium.launch({ headless: true }); + browser = await launchBrowser(); const page = await browser.newPage({ locale: "zh-CN", timezoneId: "Asia/Shanghai" }); const requests: { requestId?: string; diff --git a/packages/webui/test/unit/rail-pin-affordance.test.ts b/packages/webui/test/unit/rail-pin-affordance.test.ts index 69b0692f3..f25513938 100644 --- a/packages/webui/test/unit/rail-pin-affordance.test.ts +++ b/packages/webui/test/unit/rail-pin-affordance.test.ts @@ -66,13 +66,21 @@ function pinButton(html: string, sessionId: string): string | undefined { .match(/]*class="webui-rail-action"[^>]*>/u)?.[0]; } +/** + * The pin states a row's glyph draws. + * + * The glyph's own class says which state it is: the filled pin is the pinned + * state and the outline pin is the unpinned one. The hand-drawn glyph used to + * say it through the id of a clip path it carried; the tabler glyph says it + * through its class. + */ function pinGlyph(html: string, sessionId: string): string[] { - const ids = [ + const classes = [ ...rowSlice(html, sessionId).matchAll( - / (match[1] ? "unpinned" : "pinned")); + return classes.map((match) => (match[1] ? "pinned" : "unpinned")); } describe("the rail pin action", () => { @@ -93,11 +101,16 @@ describe("the rail pin action", () => { it("renders a distinct glyph instance for each session row", () => { const html = renderProjectList({ pinnedSessions: { mvs_pinned: true } }); - const ids = [...html.matchAll(/ match[1], - ); - expect(ids).toHaveLength(2); - expect(new Set(ids).size).toBe(2); + // Each row draws its own pin element. The hand-drawn glyph carried a clip + // path whose id had to be unique per row (a shared id would have made the + // second row resolve its clip against the first row's element); the + // tabler glyph carries no such id, so the substance of the rule is that + // each row has an element of its own. + const glyphs = [ + ...html.matchAll(/]*\btabler-icon-pin\b[^>]*>/gu), + ]; + expect(glyphs).toHaveLength(2); + expect(glyphs[0]?.[0]).not.toBe(glyphs[1]?.[0]); }); it("does not add a pin glyph before a project name", () => { @@ -111,6 +124,9 @@ describe("the pin action glyph", () => { it("draws different marks for pin and unpin states", () => { const pinned = renderToStaticMarkup(createElement(WebuiIconContextPin, { pinned: true })); const unpinned = renderToStaticMarkup(createElement(WebuiIconContextPin, { pinned: false })); + // The filled pin is the pinned state and the outline the unpinned one. + expect(pinned).toContain('class="tabler-icon tabler-icon-pin-filled"'); + expect(unpinned).toContain('class="tabler-icon tabler-icon-pin"'); expect(pinned).not.toBe(unpinned); }); }); diff --git a/packages/webui/test/unit/webui-shell.test.ts b/packages/webui/test/unit/webui-shell.test.ts index 8d1e6366f..e881fa8c7 100644 --- a/packages/webui/test/unit/webui-shell.test.ts +++ b/packages/webui/test/unit/webui-shell.test.ts @@ -855,18 +855,24 @@ describe("WebUI shell", () => { ]; for (const Glyph of glyphs) { const markup = renderToStaticMarkup(createElement(Glyph)); - expect(markup).toContain('width="18" height="18" viewBox="0 0 20 20"'); - expect(markup).toContain('fill="currentColor"'); + // Desktop-sized, and drawn in the current colour so the glyph follows + // the text. The tabler glyphs draw with a stroke rather than a fill, so + // the colour is on the stroke. + expect(markup).toContain('width="18" height="18"'); + expect(markup).toContain('stroke="currentColor"'); } const pinned = renderToStaticMarkup(createElement(WebuiIconContextPin, { pinned: true })); const unpinned = renderToStaticMarkup(createElement(WebuiIconContextPin, { pinned: false })); - expect(pinned).toContain('width="18" height="18" viewBox="0 0 20 20"'); - expect(unpinned).toContain('width="18" height="18" viewBox="0 0 20 20"'); + expect(pinned).toContain('width="18" height="18"'); + expect(unpinned).toContain('width="18" height="18"'); expect(pinned).not.toBe(unpinned); const submenuChevron = renderToStaticMarkup(createElement(WebuiIconContextChevron)); - expect(submenuChevron).toContain('width="16" height="16" viewBox="0 0 16 16"'); + // The chevron draws at 16 because its slot is 16 wide, not 18 like the + // row's other glyphs. + expect(submenuChevron).toContain('width="16" height="16"'); + expect(submenuChevron).toContain("tabler-icon-chevron-right"); }); it("moves a pinned session ahead of newer unpinned sessions", () => {