import fs from "node:fs"; import path from "node:path"; import { test, expect, type Locator, type Page } from "@playwright/test"; import { INSTANCES_URL } from "../../common/lib/project"; import { normalizeSocialSvg } from "../../common/lib/settingsSchema"; import { ADVERSARIAL, EVIL, LOADS_ELSEWHERE } from "../../common/lib/socialSvg.vectors"; import { installRoutes } from "./helpers"; // THE EXPORT HEADER (export/app/components/Header.tsx): brand · nav · // Archilyzer · the group (the social row and the theme toggle) from `lg`; // brand · group · menu trigger below it. No sites dropdown, no hub link, no // theme menu; Changelog is in the footer. The header keeps the NAME on a // narrow screen: below 520 px its row holds only the links marked `featured` // (none marked → none), from 520 px every link up to four, the marked ones // kept first; the footer holds every link. Only as last resorts does the // wordmark's text drop (whenever it does not fit, whatever the title) and the // social row scroll in its box. // // The suite serves ONE site (SITE_ID=testsite) and currentSite() re-reads // site.json on every dev render, so each test rewrites the fixture's title and // social links and puts the file back after. The original rides along in // `_e2ePristine`, which playwright.config.ts restores if a run dies mid-test. // The icons are synthetic, put through the save path's normalizer. const SITE_FILE = path.resolve(process.cwd(), "e2e", "fixtures", "sites", "testsite", "site.json"); let pristine = ""; test.describe.configure({ mode: "serial" }); test.beforeAll(() => { pristine = fs.readFileSync(SITE_FILE, "utf8"); }); test.afterEach(() => { fs.writeFileSync(SITE_FILE, pristine); }); type RawLink = { label: string; url: string; svg: string; featured?: boolean }; function writeSite(patch: { headerTitle?: string; wordmarkLead?: string; socialLinks?: RawLink[] }) { const site = JSON.parse(pristine) as Record; fs.writeFileSync( SITE_FILE, `${JSON.stringify({ ...site, ...patch, _e2ePristine: pristine }, null, 2)}\n`, ); } const icon = (d: string) => ``; const link = (label: string, d: string, featured = false): RawLink => ({ label, url: `https://${label.toLowerCase()}.example/fixture`, svg: normalizeSocialSvg(icon(d))!, ...(featured ? { featured: true } : {}), }); const SIX = [ link("Square", "M4 4h16v16H4z"), link("Triangle", "M12 3l10 18H2z"), link("Bar", "M3 10h18v4H3z"), link("Diamond", "M12 2l10 10-10 10L2 12z"), link("Post", "M10 2h4v20h-4z"), link("Chevron", "M4 8l8 8 8-8-3-3-5 5-5-5z"), ]; const TITLES = { short: { headerTitle: "Shortlyzer", wordmarkLead: "Short" }, long: { headerTitle: "Longestfixturealyzer", wordmarkLead: "Longestfixture" }, } as const; const banner = (page: Page) => page.getByRole("banner"); const brand = (page: Page) => page.locator("header [data-header-brand]"); // The row at the current width: a narrow and a wide copy are both rendered and // CSS shows one. const headerRow = (page: Page) => page.locator('header [data-social-links="header"]:visible'); const footerRow = (page: Page) => page.locator('footer [data-social-links="footer"]'); const marked = (links: RawLink[]) => links.map((l) => ({ ...l, featured: true })); const lastMarked = (links: RawLink[]) => links.map((l, i) => (i === links.length - 1 ? { ...l, featured: true } : l)); const labelsOf = (loc: Locator) => loc.getByRole("link").evaluateAll((els) => els.map((e) => e.getAttribute("aria-label"))); const toggle = (page: Page) => banner(page).getByRole("button", { name: /^switch to /i }); async function open(page: Page, width: number) { await page.setViewportSize({ width, height: 800 }); await page.goto("/"); await expect(toggle(page)).toBeVisible(); } // What the narrow header is doing: is the wordmark's text on its visible line, // does the row's box scroll, does anything overflow. const layout = (page: Page) => page.evaluate(() => { const box = document.querySelector("header [data-header-brand] span.flex-wrap") as HTMLElement; const text = box.lastElementChild as HTMLElement; const scroll = ([...document.querySelectorAll("header [data-social-scroll]")] as HTMLElement[]).find( (e) => e.offsetParent !== null, ); const row = document.querySelector("header > div") as HTMLElement; return { text: text.offsetTop < box.clientHeight - 1, scrolls: scroll ? scroll.scrollWidth > scroll.clientWidth + 1 : false, page: document.documentElement.scrollWidth - document.documentElement.clientWidth, row: row.scrollWidth - row.clientWidth, }; }); for (const touch of [false, true]) { test.describe(touch ? "under a coarse pointer" : "with a mouse", () => { test.use({ hasTouch: touch }); for (const [kind, title] of Object.entries(TITLES)) { test(`${kind} title, 0–4 marked links, 280–430 px: nothing scrolls the page, only the marked shown, the text drops before the row scrolls`, async ({ page, }) => { test.setTimeout(150_000); await installRoutes(page); for (const n of [0, 1, 2, 3, 4]) { // Four links, the last n marked. writeSite({ ...title, socialLinks: SIX.slice(0, 4).map((l, i) => (i >= 4 - n ? { ...l, featured: true } : l)) }); for (const w of [280, 300, 320, 340, 360, 375, 390, 414, 430]) { await open(page, w); const at = `${kind} title, ${n} marked, ${w} px`; await expect(headerRow(page).getByRole("link"), at).toHaveCount(n); const l = await layout(page); if (n === 0) expect(l.scrolls, `${at}: a row with no links`).toBe(false); expect(l.page, `${at}: the page scrolls sideways`).toBeLessThanOrEqual(0); expect(l.row, `${at}: the header row overflows`).toBeLessThanOrEqual(0); if (l.scrolls) expect(l.text, `${at}: the row scrolls while the text shows`).toBe(false); await expect(brand(page).locator("svg[data-brand-mark]"), at).toBeInViewport({ ratio: 1 }); await expect(toggle(page), at).toBeInViewport({ ratio: 1 }); // The name is the title whether or not the text shows. await expect(brand(page), at).toHaveAccessibleName(title.headerTitle); } } }); } }); } // From lg the brand link is sized by its content; with a very long title it // must shrink first (`shrink-[999]`), so its text drops before any icon // scrolls — as below lg. test.describe("from lg, under a coarse pointer, a very long title", () => { test.use({ hasTouch: true }); test("1024–1050 px: the text drops before the icons scroll, and nothing scrolls the page", async ({ page, }) => { await installRoutes(page); writeSite({ headerTitle: "Longestfixturearchivealyzer", wordmarkLead: "Longestfixturearchive", socialLinks: lastMarked(SIX.slice(0, 4)), }); await open(page, 1024); for (let w = 1024; w <= 1050; w += 2) { await page.setViewportSize({ width: w, height: 800 }); const l = await layout(page); expect(l.scrolls, `${w} px: the icons scroll`).toBe(false); expect(l.page, `${w} px: the page scrolls sideways`).toBeLessThanOrEqual(0); expect(l.row, `${w} px: the header row overflows`).toBeLessThanOrEqual(0); } }); }); // At twice the text size the header's fixed parts (the mark, the toggle, the // menu trigger) are twice as wide; the social row's box takes up the rest and // scrolls, so the header itself never widens the page. (The page's own // content below is not this header's.) for (const touch of [false, true]) { test.describe(touch ? "at 200 % text, under a coarse pointer" : "at 200 % text, with a mouse", () => { test.use({ hasTouch: touch }); test("320 and 360 px: the header fits, every key in reach", async ({ page }) => { await installRoutes(page); writeSite({ ...TITLES.long, socialLinks: marked(SIX.slice(0, 4)) }); await page.addInitScript(() => { document.addEventListener("DOMContentLoaded", () => { document.documentElement.style.fontSize = "200%"; }); }); for (const w of [320, 360]) { await open(page, w); await expect(page.locator("html")).toHaveCSS("font-size", "32px"); const header = await page.locator("header").first().evaluate((el) => ({ over: el.scrollWidth - el.clientWidth, right: Math.max(...[...el.querySelectorAll("a, button")].filter((e) => (e as HTMLElement).offsetParent && !e.closest("[data-social-scroll]")).map((e) => e.getBoundingClientRect().right)), })); expect(header.over, `${w} px: the header overflows`).toBeLessThanOrEqual(0); expect(header.right, `${w} px: a control past the edge`).toBeLessThanOrEqual(w); await expect(toggle(page)).toBeInViewport({ ratio: 1 }); await expect(page.getByRole("button", { name: "Open menu" })).toBeInViewport({ ratio: 1 }); // The last key is at the box's end, in view, wherever the box has room // for one (at 320 px under touch the mark, the 88 px toggle and the // 72 px menu button leave it none: the phone header is the operator's // to rule on). const room = await page.locator("header [data-social-scroll]:visible").evaluate((el) => el.clientWidth); if (room >= (touch ? 88 : 72)) { await expect(headerRow(page).getByRole("link").last()).toBeInViewport(); } expect(w === 360 || !touch ? room : 1, `${w} px: the row's box`).toBeGreaterThan(0); } }); }); } // The switch between the narrow and the wide row is 32.5rem, so it moves with // the reader's own text size (the browser's default font size, which rem in a // media query follows): at 32 px it is 1040 px. A page that sets its own root // size moves the page's rem but not a media query's; the export's wrap needs // no count, so there too the row never scrolls while the name shows. async function browserTextSize(page: Page, px: number) { const cdp = await page.context().newCDPSession(page); await cdp.send("Page.enable"); await cdp.send("Page.setFontSizes", { fontSizes: { standard: px } }); } for (const touch of [false, true]) { test.describe(touch ? "at 200 % text, the switch, under a coarse pointer" : "at 200 % text, the switch, with a mouse", () => { test.use({ hasTouch: touch }); test("the browser's text size at 32 px: the narrow row until 1040 px; the page's root at 200 %: the switch stays at 520 px; the row never scrolls while the name shows", async ({ page, }) => { test.setTimeout(90_000); await installRoutes(page); writeSite({ ...LONGEST, socialLinks: lastMarked(SIX.slice(0, 4)) }); const four = SIX.slice(0, 4).map((l) => l.label); await browserTextSize(page, 32); for (const w of [600, 767, 1039, 1040, 1300]) { await open(page, w); await expect(page.locator("html")).toHaveCSS("font-size", "32px"); const at = `browser text 32 px, ${w} px`; expect(await labelsOf(headerRow(page)), at).toEqual(w < 1040 ? ["Diamond"] : four); const l = await layout(page); if (l.scrolls) expect(l.text, `${at}: the row scrolls while the name shows`).toBe(false); expect(l.row, `${at}: the header row overflows`).toBeLessThanOrEqual(0); // 767 px at 32 px text is 383 px at the default size: past every real // title's full-name width. if (w >= 767) expect(l.text, `${at}: the name`).toBe(true); } await browserTextSize(page, 16); await page.addInitScript(() => { document.addEventListener("DOMContentLoaded", () => { document.documentElement.style.fontSize = "200%"; }); }); for (const w of [519, 520, 600, 680, 767]) { await open(page, w); await expect(page.locator("html")).toHaveCSS("font-size", "32px"); const at = `root 200 %, ${w} px`; expect(await labelsOf(headerRow(page)), at).toEqual(w < 520 ? ["Diamond"] : four); const l = await layout(page); if (l.scrolls) expect(l.text, `${at}: the row scrolls while the name shows`).toBe(false); expect(l.row, `${at}: the header row overflows`).toBeLessThanOrEqual(0); } }); }); } // The footer shows every link: its row wraps rather than widen the page. test.describe("under a coarse pointer, eight links", () => { test.use({ hasTouch: true }); test("320 px: the footer's row wraps, and nothing scrolls the page", async ({ page }) => { await installRoutes(page); const eight = [...SIX, link("Ring", "M12 3a9 9 0 1 1 0 18 9 9 0 0 1 0-18z"), link("Cross", "M10 3h4v7h7v4h-7v7h-4v-7H3v-4h7z")]; writeSite({ ...TITLES.short, socialLinks: eight }); await open(page, 320); const row = page.locator('footer [data-social-links="footer"]'); await expect(row.getByRole("link")).toHaveCount(8); const boxes = await row.getByRole("link").evaluateAll((els) => els.map((e) => e.getBoundingClientRect()).map((r) => ({ right: r.right, top: r.top }))); for (const b of boxes) expect(b.right).toBeLessThanOrEqual(320); expect(new Set(boxes.map((b) => Math.round(b.top))).size, "one row of eight").toBeGreaterThan(1); expect((await layout(page)).page).toBeLessThanOrEqual(0); }); }); // The title's fit does not wait on the web font: its width is reserved from // the display face's metrics (lib/wordmarkWidth.ts), so in the fallback face // (narrower) and in Archivo the text is shown, or not, at the same widths. test("the title shows at the same widths in the fallback face and in Archivo", async ({ page }) => { test.setTimeout(120_000); await installRoutes(page); const widths = [360, 375, 390, 412, 430]; const fits = async () => { const out: boolean[] = []; for (const w of widths) { await open(page, w); await page.evaluate(() => document.fonts.ready); out.push((await layout(page)).text); } return out; }; const archivo = () => page.evaluate(() => [...document.fonts].filter((f) => /Archivo/i.test(f.family)).map((f) => f.status)); for (const title of [TITLES.short, { headerTitle: "Mediumfixturealyzer", wordmarkLead: "Mediumfixture" }, TITLES.long]) { writeSite({ ...title, socialLinks: lastMarked(SIX.slice(0, 3)) }); await page.route(/\.woff2(\?|$)/, (r) => r.abort()); const fallback = await fits(); expect(await archivo(), "the web font is blocked").not.toContain("loaded"); await page.unroute(/\.woff2(\?|$)/); const loaded = await fits(); expect(await archivo(), "the web font loaded").toContain("loaded"); expect(loaded, `${title.headerTitle} at ${widths.join(", ")} px`).toEqual(fallback); } }); // The reservation is a MINIMUM width: where the text renders wider than the // display face's metrics say (here forced wider by letter-spacing), its box // grows with it and the text drops sooner; its last letter is never clipped. test("a wordmark that renders wider than its reservation widens its box and is never clipped", async ({ page }) => { await installRoutes(page); writeSite({ headerTitle: "Rekietalyzer", wordmarkLead: "Rekieta", socialLinks: lastMarked(SIX.slice(0, 4)) }); await open(page, 519); await page.addStyleTag({ content: "header [data-wordmark] { letter-spacing: 0.08em !important; }" }); await page.evaluate(() => document.fonts.ready); let shown = 0; let hidden = 0; for (let w = 519; w >= 280; w--) { await page.setViewportSize({ width: w, height: 800 }); const r = await page.evaluate(() => { const box = document.querySelector("header [data-header-brand] span.flex-wrap") as HTMLElement; const wm = box.lastElementChild as HTMLElement; return { shown: wm.offsetTop < box.clientHeight - 1, textRight: (wm.lastElementChild as HTMLElement).getBoundingClientRect().right, boxRight: box.getBoundingClientRect().right, over: wm.scrollWidth - wm.clientWidth, }; }); if (!r.shown) { hidden++; continue; } shown++; expect(r.over, `${w} px: the text overflows its box`).toBeLessThanOrEqual(1); expect(r.textRight, `${w} px: the last letter is clipped`).toBeLessThanOrEqual(r.boxRight + 0.5); } expect(shown, "widths with the name").toBeGreaterThan(0); expect(hidden, "widths without it").toBeGreaterThan(0); }); // THE RULING: on a narrow screen the header keeps the NAME and shows only the // marked link(s), in the accessibility tree too; focus goes brand → the marked // link → the toggle → the menu. Every real site title shows in full at 360 px // with one marked link, even under touch: below 520 px the bar's two gaps are // 8 px. Rekietalyzer and Hasanalyzer are the longest. const REAL_TITLES = [ { headerTitle: "Jeralyzer", wordmarkLead: "Jer" }, { headerTitle: "Anilyzer", wordmarkLead: "Ani" }, { headerTitle: "Bonnellyzer", wordmarkLead: "Bonnell" }, { headerTitle: "Hasanalyzer", wordmarkLead: "Hasan" }, { headerTitle: "Rekietalyzer", wordmarkLead: "Rekieta" }, { headerTitle: "Jasolyzer", wordmarkLead: "Jaso" }, ] as const; const LONGEST = REAL_TITLES[4]; const NARROW_TITLES = { short: TITLES.short, Bonnellyzer: REAL_TITLES[2], Hasanalyzer: REAL_TITLES[3], Rekietalyzer: LONGEST, }; test.describe("the narrow header, touch, every real title", () => { test.use({ hasTouch: true }); test("360 px, one marked link: the full name, the link, and nothing overflows", async ({ page }) => { await installRoutes(page); for (const title of REAL_TITLES) { writeSite({ ...title, socialLinks: lastMarked(SIX.slice(0, 4)) }); await open(page, 360); await page.evaluate(() => document.fonts.ready); const at = `${title.headerTitle} at 360 px`; const l = await layout(page); expect(l.text, `${at}: the name`).toBe(true); expect(l.scrolls, `${at}: the row scrolls`).toBe(false); expect(l.page, `${at}: the page scrolls sideways`).toBeLessThanOrEqual(0); expect(l.row, `${at}: the header row overflows`).toBeLessThanOrEqual(0); expect(await labelsOf(headerRow(page)), at).toEqual(["Diamond"]); await expect(page.getByRole("button", { name: "Open menu" }), at).toBeInViewport({ ratio: 1 }); } }); }); for (const touch of [false, true]) { test.describe(touch ? "the narrow header, touch" : "the narrow header, a mouse", () => { test.use({ hasTouch: touch }); for (const [kind, title] of Object.entries(NARROW_TITLES)) { test(`${kind} title at 360 and 390 px: the full name, the marked link alone, focus brand → link → toggle → menu`, async ({ page, }) => { await installRoutes(page); writeSite({ ...title, socialLinks: lastMarked(SIX.slice(0, 4)) }); for (const w of [360, 390]) { await open(page, w); expect((await layout(page)).text, `${w} px: the name`).toBe(true); expect(await labelsOf(headerRow(page))).toEqual(["Diamond"]); for (const other of ["Square", "Triangle", "Bar"]) { await expect(banner(page).getByRole("link", { name: other, exact: true })).toHaveCount(0); } expect(await labelsOf(footerRow(page))).toEqual(SIX.slice(0, 4).map((l) => l.label)); const order: string[] = []; for (let i = 0; i < 4; i++) { await page.keyboard.press("Tab"); order.push( await page.evaluate( () => document.activeElement?.getAttribute("aria-label") ?? document.activeElement?.textContent?.trim() ?? "", ), ); } expect(order[0], "the brand").toBe(title.headerTitle); expect(order[1]).toBe("Diamond"); expect(order[2]).toMatch(/^Switch to /); expect(order[3]).toBe("Open menu"); } }); } test("none marked: the narrow header has no social link, and the name shows", async ({ page }) => { await installRoutes(page); writeSite({ ...LONGEST, socialLinks: SIX.slice(0, 4) }); for (const w of [320, 360, 390]) { await open(page, w); expect((await layout(page)).text, `${w} px: the name`).toBe(true); await expect(banner(page).locator('[data-social-links="header"]:visible')).toHaveCount(0); expect(await labelsOf(footerRow(page))).toEqual(SIX.slice(0, 4).map((l) => l.label)); } }); }); } test("from 520 px every link shows, up to four; the footer keeps every link; nothing scrolls the page from 280 to 1400 px", async ({ page, }) => { await installRoutes(page); writeSite({ ...LONGEST, socialLinks: lastMarked(SIX.slice(0, 4)) }); for (const w of [280, 320, 360, 390, 430, 519, 520, 640, 768, 1023, 1024, 1280, 1400]) { await open(page, w); expect(await labelsOf(headerRow(page)), `${w} px`).toEqual( w < 520 ? ["Diamond"] : SIX.slice(0, 4).map((l) => l.label), ); expect(await labelsOf(footerRow(page))).toEqual(SIX.slice(0, 4).map((l) => l.label)); expect((await layout(page)).page, `${w} px: the page scrolls sideways`).toBeLessThanOrEqual(0); if (w >= 520) expect((await layout(page)).text, `${w} px: the name`).toBe(true); } }); test("a short title shows in full on a phone with one marked link; a very long one gives way", async ({ page }) => { await installRoutes(page); writeSite({ ...TITLES.short, socialLinks: lastMarked(SIX.slice(0, 1)) }); await open(page, 390); expect((await layout(page)).text).toBe(true); writeSite({ ...TITLES.long, socialLinks: marked(SIX.slice(0, 4)) }); await open(page, 390); expect(await layout(page)).toMatchObject({ text: false, scrolls: false }); await open(page, 1280); expect((await layout(page)).text).toBe(true); }); for (const width of [390, 1280]) { test(`${width} px: the group's keys are 36 px, the toggle is the last, their boxes touch`, async ({ page, }) => { await installRoutes(page); writeSite({ ...TITLES.short, socialLinks: marked(SIX.slice(0, 3)) }); await open(page, width); const keys: Locator[] = [...(await headerRow(page).getByRole("link").all()), toggle(page)]; expect(keys).toHaveLength(4); const boxes = await Promise.all(keys.map((k) => k.boundingBox())); for (const b of boxes) expect([b!.width, b!.height]).toEqual([36, 36]); for (let i = 1; i < boxes.length; i++) { expect(Math.abs(boxes[i]!.x - (boxes[i - 1]!.x + boxes[i - 1]!.width))).toBeLessThanOrEqual(0.5); } }); } test.describe("under a coarse pointer", () => { test.use({ hasTouch: true }); test("the keys and the toggle are 44 px", async ({ page }) => { await installRoutes(page); writeSite({ ...TITLES.short, socialLinks: marked(SIX.slice(0, 2)) }); await open(page, 390); const keys = [...(await headerRow(page).getByRole("link").all()), toggle(page)]; expect(keys).toHaveLength(3); for (const k of keys) { const b = (await k.boundingBox())!; expect([b.width, b.height]).toEqual([44, 44]); } }); }); test("keyboard focus draws the ring on a social key", async ({ page }) => { await installRoutes(page); writeSite({ ...TITLES.short, socialLinks: SIX.slice(0, 2) }); await open(page, 1280); const first = headerRow(page).getByRole("link").first(); for (let i = 0; i < 30; i++) { await page.keyboard.press("Tab"); if (await first.evaluate((el) => el === document.activeElement)) break; } const s = await first.evaluate((el) => { const cs = getComputedStyle(el); return { outline: cs.outlineStyle, shadow: cs.boxShadow }; }); expect(s.outline).toBe("none"); expect(s.shadow).toMatch(/0px 0px 0px 2px/); }); test("the Archilyzer link goes to the official instances; no sites menu, hub link or theme menu", async ({ page, }) => { await installRoutes(page); await open(page, 1280); const archilyzer = banner(page).getByRole("link", { name: "Archilyzer — official instances", exact: true, }); await expect(archilyzer).toBeVisible(); await expect(archilyzer).toHaveText("Archilyzer"); await expect(archilyzer).toHaveAttribute("href", INSTANCES_URL); await expect(archilyzer).not.toHaveAttribute("target", /.+/); await expect(banner(page).getByRole("button", { name: /sites/i })).toHaveCount(0); await expect(page.getByRole("button", { name: "Choose theme" })).toHaveCount(0); await expect(banner(page).getByRole("link", { name: "Hub", exact: true })).toHaveCount(0); // Below lg it is in the menu instead (responsive.spec), not the bar. await open(page, 390); await expect(archilyzer).toBeHidden(); }); test("Changelog is in the footer, not the header", async ({ page }) => { await installRoutes(page); await open(page, 1280); await expect(banner(page).getByRole("link", { name: "Changelog" })).toHaveCount(0); const footer = page.locator("footer"); const changelog = footer.getByRole("link", { name: "Changelog", exact: true }); await expect(changelog).toHaveAttribute("href", "/changelog"); // After Use with AI, in the same row. const order = await footer .getByRole("link") .evaluateAll((els) => els.map((e) => e.textContent?.trim() ?? "")); expect(order.indexOf("Changelog")).toBe(order.indexOf("Use with AI") + 1); }); test("six links: wide shows the last four, narrow none; two marked: wide keeps them first, narrow shows them alone", async ({ page, }) => { await installRoutes(page); writeSite({ ...TITLES.short, socialLinks: SIX }); await open(page, 1280); expect(await labelsOf(headerRow(page))).toEqual(SIX.slice(2).map((l) => l.label)); expect(await labelsOf(footerRow(page))).toEqual(SIX.map((l) => l.label)); await open(page, 390); await expect(headerRow(page)).toHaveCount(0); const featured = SIX.map((l) => l.label === "Square" || l.label === "Post" ? { ...l, featured: true } : l, ); writeSite({ ...TITLES.short, socialLinks: featured }); await open(page, 1280); expect(await labelsOf(headerRow(page))).toEqual(["Square", "Diamond", "Post", "Chevron"]); await open(page, 390); expect(await labelsOf(headerRow(page))).toEqual(["Square", "Post"]); }); // A hand-edited site.json can hold anything. The header (and the footer) // inline an icon only if it passes the save path's check again; the rest show // their label, run nothing and fetch nothing from anywhere else. test("a hostile icon in a site's socialLinks is its label: nothing runs, nothing is fetched elsewhere", async ({ page, }) => { await installRoutes(page); const hostile: RawLink[] = [ { label: "Hostile 1", url: "https://h1.example", svg: `` }, { label: "Hostile 2", url: "https://h2.example", svg: `` }, ...LOADS_ELSEWHERE.slice(0, 2).map((name, i) => ({ label: `Hostile ${i + 3}`, url: `https://h${i + 3}.example`, svg: ADVERSARIAL[name], })), ]; writeSite({ ...TITLES.short, socialLinks: hostile }); const elsewhere: string[] = []; page.on("request", (r) => { const u = new URL(r.url()); if (u.protocol.startsWith("http") && u.hostname !== "localhost" && u.hostname !== "127.0.0.1") { elsewhere.push(r.url()); } }); await open(page, 1280); await page.waitForLoadState("networkidle"); for (const l of hostile) { await expect(headerRow(page).getByRole("link", { name: l.label, exact: true })).toHaveText(l.label); } expect(await page.evaluate(() => (window as unknown as { __hdrHostile?: number }).__hdrHostile)).toBeUndefined(); expect(elsewhere.filter((u) => u.startsWith(EVIL)), "requests to the icons' origin").toEqual([]); expect(elsewhere, "requests to any other origin").toEqual([]); });