From 63acf73a58d17060276f7234361fe6bebe7b0ba5 Mon Sep 17 00:00:00 2001 From: probe Date: Thu, 22 Oct 2026 22:14:13 +0800 Subject: [PATCH 1/5] fix(webui): keep the pinned and unpinned pin glyphs apart The tabler conversion drew the outline pin for both states, so the `pinned` argument stopped saying anything: a pinned session row looked exactly like an unpinned one. The filled pin says it now, the way the hand-drawn glyph's filled variant did. The same conversion gave the submenu chevron an 18px box inside the 16px slot the context menu reserves for it, which squashes it. It draws at 16 again, the size the other chevrons draw at. The specs that caught both now look for the glyph's own class rather than for the clip path the hand-drawn glyph carried, and for the size that is drawn rather than for a viewBox that belonged to the old drawing. --- packages/webui/src/client/shell/icons.tsx | 16 ++++++++-- .../test/unit/rail-pin-affordance.test.ts | 32 ++++++++++++++----- packages/webui/test/unit/webui-shell.test.ts | 16 +++++++--- 3 files changed, 48 insertions(+), 16 deletions(-) diff --git a/packages/webui/src/client/shell/icons.tsx b/packages/webui/src/client/shell/icons.tsx index 18059ac5c..98a875f55 100644 --- a/packages/webui/src/client/shell/icons.tsx +++ b/packages/webui/src/client/shell/icons.tsx @@ -8,7 +8,7 @@ import { 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, @@ -64,7 +64,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 +79,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/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", () => { From 09d07422610186dee45d1d86ee63eabd4e60432c Mon Sep 17 00:00:00 2001 From: probe Date: Thu, 22 Oct 2026 22:14:18 +0800 Subject: [PATCH 2/5] test(webui): drive the transcript browser through the runner's Chrome 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 and the launch failed with "Executable doesn't exist". The runner's own Chrome is there, 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. --- .../test/integration/agent-transcript.test.ts | 23 +++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) 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; From 7d0c9c68bbc6960d829765d6e22be9c7c1af7856 Mon Sep 17 00:00:00 2001 From: probe Date: Thu, 22 Oct 2026 22:14:23 +0800 Subject: [PATCH 3/5] test(webui): give the dependency gate a budget of its own Every case in this file 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 turned that into a failure that said nothing about the gate: the same case passed on an idle machine, and a different case failed each time. The budget is written down now, so the gate reports the rule rather than the machine it ran on. --- .../test/gates/webui-dependency-check.test.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) 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 }[]; From 20a47bf1e98a6ad34c3ed20a5dbbff60da745fdf Mon Sep 17 00:00:00 2001 From: probe Date: Thu, 22 Oct 2026 22:31:13 +0800 Subject: [PATCH 4/5] fix(webui): keep the filled disclosure arrow for expandable parent rows The tabler conversion gave the parent-session disclosure the same outline chevron the project rows use, so the two kinds of expander looked the same again. The filled arrow is kept for the expandable parent rows, which is what the old glyph set drew and what the row's own comment said it was retained for. It is a filled caret now, so the drawing is tabler's and the distinction is still there. --- packages/webui/src/client/shell/icons.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/webui/src/client/shell/icons.tsx b/packages/webui/src/client/shell/icons.tsx index 98a875f55..10203aa10 100644 --- a/packages/webui/src/client/shell/icons.tsx +++ b/packages/webui/src/client/shell/icons.tsx @@ -3,7 +3,7 @@ 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, @@ -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); } From cdb78497dfb9a5e40b855c40d7c560112b1e0581 Mon Sep 17 00:00:00 2001 From: probe Date: Thu, 22 Oct 2026 22:31:13 +0800 Subject: [PATCH 5/5] test(webui): follow the browser specs to the tabler glyphs The pin specs read the state off the clip path the hand-drawn glyph carried, and the tabler glyph carries none, so the locator never resolved and the cases waited out their timeout. The state is now read off the glyph's own class, and the rule about two pinned rows is the one that is left: each row has an element of its own. The disclosure case pinned the path the hand-drawn triangle drew. It now asserts the filled caret it is drawn with, which is the claim the case's name makes. The questionnaire's progress indicator is read as "1/3" between the two step affordances. The next one is an icon now, so it contributes no text to the indicator. --- .../webui/test/browser/questionnaire.spec.mjs | 11 ++-- packages/webui/test/browser/rail-pin.spec.mjs | 58 +++++++++---------- 2 files changed, 33 insertions(+), 36 deletions(-) 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");