diff --git a/packages/web/playwright.icons.config.ts b/packages/web/playwright.icons.config.ts index 2e3e1f6..c0ffd57 100644 --- a/packages/web/playwright.icons.config.ts +++ b/packages/web/playwright.icons.config.ts @@ -1,16 +1,20 @@ import { defineConfig, devices } from "@playwright/test"; /** - * Cross-engine config for the icon sprite's `` resolution (tests/icons.spec.ts only). The main - * config (playwright.config.ts) runs a single unnamed project, because most specs use - * `testing/ax.ts`, which reads the accessibility tree over CDP and so only runs in Chromium; this - * config exists so `` can still be proven in Firefox and WebKit without breaking those. + * Cross-engine config for the icon-only specs (tests/icons*.spec.ts). The main config + * (playwright.config.ts) runs a single unnamed project, because most specs use `testing/ax.ts`, + * which reads the accessibility tree over CDP and so only runs in Chromium; this config exists so + * `` — its `` resolution, its painted shape against the raw codicon, and the + * per-root sprite sharing — can still be proven in Firefox and WebKit without breaking those. */ const port = Number(process.env.PORT ?? 8952); export default defineConfig({ testDir: "tests", - testMatch: "icons.spec.ts", + // A glob relative to testDir, not a regex: a regex is matched against the *absolute* path, and + // this worktree's own directory (.../platform/icons/...) contains the literal substring "icons", + // which made an earlier `/icons.*\.spec\.ts/` match every spec file, not just this package's. + testMatch: "icons*.spec.ts", use: { baseURL: `http://localhost:${port}` }, projects: [ { name: "chromium", use: { ...devices["Desktop Chrome"] } }, diff --git a/packages/web/tests/icons-registry.spec.ts b/packages/web/tests/icons-registry.spec.ts new file mode 100644 index 0000000..3abc8c2 --- /dev/null +++ b/packages/web/tests/icons-registry.spec.ts @@ -0,0 +1,124 @@ +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { expect, test, type Page } from "@playwright/test"; +import { ICON_NAMES, type IconName } from "../src/icons/names.ts"; + +/** + * Renders every registry icon as a `` (light DOM and inside a shadow root) at a fixed + * pixel size, and compares a real screenshot of each against a real screenshot of an independent + * reference: the raw codicon SVG read straight from node_modules, inlined and sized the same way, + * rendered by the browser's normal paint pipeline (not through our sprite code at all). Real + * screenshots, not a reconstructed/detached re-render, because clipping is an *overflow* effect — + * a nested `` defaults to `overflow: hidden`, which a reconstructed, re-serialized, detached + * copy loaded as its own top-level SVG document does not reproduce (a root SVG's default overflow is + * visible), so that approach missed exactly the clipping this test exists to catch. + */ +const packageDir = join(dirname(fileURLToPath(import.meta.url)), ".."); +const codiconsDir = join(packageDir, "node_modules/@vscode/codicons/src/icons"); +const SIZE = 32; + +/** Decodes a PNG buffer in-page (an `` natively decodes PNG) and returns its painted pixels. */ +async function paintedPixels(page: Page, png: Buffer): Promise<{ count: number; bbox: { w: number; h: number } | null }> { + return page.evaluate( + ({ dataUrl, size }) => + new Promise((resolve, reject) => { + const img = new Image(); + img.onerror = () => reject(new Error("screenshot failed to decode")); + img.onload = () => { + const canvas = document.createElement("canvas"); + canvas.width = size; + canvas.height = size; + const ctx = canvas.getContext("2d")!; + ctx.drawImage(img, 0, 0, size, size); + const data = ctx.getImageData(0, 0, size, size).data; + let minX = size, minY = size, maxX = -1, maxY = -1, count = 0; + for (let y = 0; y < size; y++) { + for (let x = 0; x < size; x++) { + // A near-white pixel against the fixture's white background counts as unpainted; + // everything else (the icon's dark currentColor fill) counts as painted. + const i = (y * size + x) * 4; + const painted = data[i]! < 240 || data[i + 1]! < 240 || data[i + 2]! < 240; + if (painted) { + count++; + if (x < minX) minX = x; + if (x > maxX) maxX = x; + if (y < minY) minY = y; + if (y > maxY) maxY = y; + } + } + } + resolve({ count, bbox: count ? { w: maxX - minX + 1, h: maxY - minY + 1 } : null }); + }; + img.src = dataUrl; + }), + { dataUrl: `data:image/png;base64,${png.toString("base64")}`, size: SIZE }, + ); +} + +for (const [name, codiconId] of Object.entries(ICON_NAMES) as [IconName, string][]) { + test(`icon "${name}" matches the raw codicon's painted shape, light DOM and shadow root`, async ({ page }) => { + const reference = readFileSync(join(codiconsDir, `${codiconId}.svg`), "utf8"); + await page.goto("/icons.html"); + + // The independent reference: the raw codicon markup, inlined directly (no sprite, no ) + // and sized to the test box, nothing of ours involved in producing it. + await page.evaluate( + ({ reference, size }) => { + const doc = new DOMParser().parseFromString(reference, "image/svg+xml"); + const svg = doc.documentElement; + svg.id = "registry-test-reference"; + svg.setAttribute("width", String(size)); + svg.setAttribute("height", String(size)); + document.body.append(svg); + }, + { reference, size: SIZE }, + ); + const refPng = await page.locator("#registry-test-reference").screenshot(); + await page.evaluate(() => document.getElementById("registry-test-reference")?.remove()); + const ref = await paintedPixels(page, refPng); + expect(ref.count, `reference "${name}" painted nothing`).toBeGreaterThan(0); + + for (const variant of ["light", "shadow"] as const) { + await page.evaluate( + ({ name, size, variant }) => { + const nibIcon = document.createElement("nib-icon"); + nibIcon.id = "registry-test-subject"; + nibIcon.name = name as never; + nibIcon.style.width = `${size}px`; + nibIcon.style.height = `${size}px`; + if (variant === "shadow") { + const host = document.createElement("div"); + host.id = "registry-test-host"; + document.body.append(host); + host.attachShadow({ mode: "open" }).append(nibIcon); + } else { + document.body.append(nibIcon); + } + }, + { name, size: SIZE, variant }, + ); + const ourLocator = variant === "light" ? page.locator("#registry-test-subject") : page.locator("#registry-test-host").locator("#registry-test-subject"); + const ourPng = await ourLocator.screenshot(); + const ours = await paintedPixels(page, ourPng); + await page.evaluate(() => { + document.getElementById("registry-test-subject")?.remove(); + document.getElementById("registry-test-host")?.remove(); + }); + + expect(ours.count, `"${name}" (${variant}) painted nothing`).toBeGreaterThan(0); + // Painted pixel count within 20% of the independent reference: catches both "nothing drew" + // and "drew, but clipped to a fraction of the glyph" (the actual bug: "files" and "terminal" + // are natively 24x24; a hardcoded "0 0 16 16" symbol viewBox painted roughly their top-left + // two-thirds, inside a box whose default overflow is hidden). + const ratio = ours.count / ref.count; + expect(ratio, `painted pixel ratio for "${name}" (${variant})`).toBeGreaterThan(0.8); + expect(ratio, `painted pixel ratio for "${name}" (${variant})`).toBeLessThan(1.25); + // The painted bbox should match the reference's own bbox proportionally, not some fixed + // fraction of the box — some glyphs (a wide "git-commit" line, a short "chevron-down") are + // legitimately far from square, so comparing against the box itself would be too strict. + expect(ours.bbox!.w, `painted width for "${name}" (${variant})`).toBeGreaterThan(ref.bbox!.w * 0.8); + expect(ours.bbox!.h, `painted height for "${name}" (${variant})`).toBeGreaterThan(ref.bbox!.h * 0.8); + } + }); +} diff --git a/packages/web/tests/icons-sharing.spec.ts b/packages/web/tests/icons-sharing.spec.ts new file mode 100644 index 0000000..9074091 --- /dev/null +++ b/packages/web/tests/icons-sharing.spec.ts @@ -0,0 +1,42 @@ +import { expect, test } from "@playwright/test"; + +/** + * A big tree's rows each carry their own shadow root (TreeItemElement's own design, so a tree + * renders correctly inside any shadow root — see tree/element.ts). `icon()` can't share a `` + * across separate tree scopes; what it can and must do is give each row's own shadow root exactly + * one copy of the symbol it uses, not a fresh one per re-render, and not a uniquely-numbered one + * that can never be deduped. This is the sharing `icon()` actually provides: many icons in one + * shared root (a header, a toolbar) cost one symbol, not one per occurrence. + */ +test("a tree of 200 branches gives each row's shadow root exactly one chevron-down symbol", async ({ page }) => { + await page.goto("/tree.html"); + const result = await page.evaluate(() => { + const tree = document.getElementById("tree") as HTMLElement & { nodes: unknown }; + tree.nodes = Array.from({ length: 200 }, (_, i) => ({ id: `dir${i}`, label: `dir${i}`, children: [{ id: `dir${i}/file.txt`, label: "file.txt" }] })); + const items = Array.from(tree.querySelectorAll("nib-tree-item")); + const counts = items.map((item) => item.shadowRoot!.querySelectorAll('symbol[id="nib-icon-chevron-down"]').length); + const ids = items.map((item) => [...item.shadowRoot!.querySelectorAll("symbol")].map((s) => s.id)); + return { rows: items.length, counts, sampleIds: ids[0] }; + }); + expect(result.rows).toBe(200); + // Every row's own shadow root has exactly one copy — not zero (missing), not more than one + // (re-injected on redraw), and it's the stable, un-suffixed id, not a uniquely-numbered one. + expect(result.counts.every((n) => n === 1)).toBe(true); + expect(result.sampleIds).toEqual(["nib-icon-chevron-down"]); +}); + +test("re-rendering a row (toggling its expanded state) does not add a second symbol to its shadow root", async ({ page }) => { + await page.goto("/tree.html"); + const before = await page.evaluate(() => { + const item = document.querySelector("nib-tree-item")!; + return item.shadowRoot!.querySelectorAll("symbol").length; + }); + await page.locator("nib-tree-item").first().click(); + await page.locator("nib-tree-item").first().click(); + const after = await page.evaluate(() => { + const item = document.querySelector("nib-tree-item")!; + return item.shadowRoot!.querySelectorAll("symbol").length; + }); + expect(before).toBe(1); + expect(after).toBe(1); +});