import path from "node:path"; import { test, expect, type Locator, type Page } from "@playwright/test"; import { FIXTURE_SETTINGS_NAME, FIXTURE_SOCIAL_SIX, FIXTURE_SOCIAL_SIX_FEATURED, FIXTURE_HOSTILE_SVGS, FIXTURE_SOCIAL_TRIO, unmarked, writeFixtureSettings, writeRawFixtureSettings, } from "./fixture-social"; import { themeToggle } from "./helpers"; import { ADVERSARIAL, LOADS_ELSEWHERE } from "../../common/lib/socialSvg.vectors"; import { headerSocialLinks } from "../../common/lib/socialLinks"; // THE SOCIAL ROW: in the footer (every link) and in the header, which keeps the // NAME on a narrow screen: below 520 px only the links marked `featured` (none // marked → none), from 520 px every link up to four, the marked ones kept first. The links are the e2e's own (fixture-social.ts): Leaf (a // gradient and one themed colour), Bubble (one colour) and Disc (two colours, // pasted with only a size — no viewBox until the normalizer made one). No copy: // every link is an icon whose accessible name is its label. // // Specs that need another list rewrite the dev server's settings file and the // afterEach restores the trio; `next dev` reads it on every request. const settingsFile = () => path.resolve(test.info().project.testDir, FIXTURE_SETTINGS_NAME); test.afterEach(() => { writeFixtureSettings(settingsFile(), FIXTURE_SOCIAL_TRIO); }); const TRIO = FIXTURE_SOCIAL_TRIO.map((l) => l.label); // The header's row at the current width (a narrow and a wide copy are both // rendered; CSS shows one), followed by the theme toggle. Only as last resorts // does the wordmark's TEXT hide (the mark stays) or the row scroll inside its // box (Header.tsx). const headerRow = (page: Page) => page.locator('header [data-social-links="header"]:visible'); const scrollBox = (page: Page) => page.locator("header [data-social-scroll]:visible"); const footerRow = (page: Page) => page.locator('footer [data-social-links="footer"]'); const labelsOf = (row: Locator) => row.getByRole("link").evaluateAll((els) => els.map((e) => e.getAttribute("aria-label"))); async function noHorizontalOverflow(page: Page) { const { scroll, client } = await page.evaluate(() => ({ scroll: document.documentElement.scrollWidth, client: document.documentElement.clientWidth, })); expect(scroll, "the page scrolls sideways").toBeLessThanOrEqual(client); } async function tabTo(page: Page, target: Locator) { for (let i = 0; i < 25; i++) { await page.keyboard.press("Tab"); if (await target.evaluate((el) => el === document.activeElement)) return; } throw new Error("Tab never reached the link"); } // The bar's content width (in px, default text) below which the wordmark's // text is hidden, by how many links the header shows and the pointer: the // wordmark link (152 px), a 12 px gap, and the keys and the toggle (Header.tsx // WORDMARK_FITS). const WORDMARK_NEEDS = { mouse: { 0: 200, 1: 236, 3: 308, 4: 344 }, touch: { 0: 208, 1: 252, 3: 340, 4: 384 }, } as const; const allMarked = (links: readonly T[]) => links.map((l) => ({ ...l, featured: true })); // The link sets by how many a NARROW header shows (its marked ones). const LINK_SETS = { 0: unmarked(FIXTURE_SOCIAL_TRIO), 1: FIXTURE_SOCIAL_TRIO, // Disc, the last, is marked 3: allMarked(FIXTURE_SOCIAL_TRIO), 4: allMarked(FIXTURE_SOCIAL_SIX), // the narrow header shows the last four } as const; async function narrowHeader(page: Page, width: number, count: 0 | 1 | 3 | 4, pointer: "mouse" | "touch") { await page.setViewportSize({ width, height: 800 }); await page.goto("/"); const labels = LINK_SETS[count] .filter((l) => (l as { featured?: boolean }).featured === true) .map((l) => l.label) .slice(-4); expect(await labelsOf(headerRow(page)), `${width} px, ${count} marked`).toEqual(labels); // The copy for the other width is out of the accessibility tree. for (const other of LINK_SETS[count].map((l) => l.label).filter((l) => !labels.includes(l))) { await expect(page.getByRole("banner").getByRole("link", { name: other, exact: true })).toHaveCount(0); } // Nothing is hidden but (maybe) the wordmark's text, and nothing scrolls. const home = page.getByRole("link", { name: "Archilyzer home", exact: true }); const textShown = await home.locator("[data-wordmark]").isVisible(); expect(textShown, `${pointer} ${width} px, ${count}: wordmark text`).toBe( width - 40 >= WORDMARK_NEEDS[pointer][count], ); if (textShown) { expect(await home.evaluate((el) => el.scrollWidth <= el.clientWidth + 1), "the wordmark is clipped").toBe(true); expect((await home.boundingBox())!.height).toBeLessThanOrEqual(32); } await expect(home.locator("svg").first()).toBeVisible(); // the mark stays const toggle = themeToggle(page); await expect(toggle).toBeInViewport({ ratio: 1 }); if (count > 0) { const box = scrollBox(page); const scrolls = await box.evaluate((el) => el.scrollWidth > el.clientWidth + 1); expect(scrolls, `${pointer} ${width} px, ${count}: the row scrolls`).toBe(false); const b = (await box.boundingBox())!; const wm = (await home.boundingBox())!; expect(b.x + 4 - (wm.x + wm.width), "the gap after the wordmark").toBeGreaterThanOrEqual(11.5); for (const key of await headerRow(page).getByRole("link").all()) { const k = (await key.boundingBox())!; expect(k.x).toBeGreaterThanOrEqual(b.x); expect(k.x + k.width).toBeLessThanOrEqual(b.x + b.width); } expect((await toggle.boundingBox())!.x, "the toggle is after the box").toBeGreaterThanOrEqual(b.x + b.width - 4.5); } else { await expect(page.locator("header [data-social-scroll]:visible")).toHaveCount(0); } await noHorizontalOverflow(page); } for (const width of [320, 340, 360, 390]) { test(`${width} px, a mouse: 0, 1, 3 and 4 marked links, only those shown, nothing scrolls, the wordmark's text where it fits`, async ({ page, }) => { for (const count of [0, 1, 3, 4] as const) { writeFixtureSettings(settingsFile(), [...LINK_SETS[count]]); await narrowHeader(page, width, count, "mouse"); } }); } test("768 and 1280 px: the full wordmark, the nav, the row and the toggle in one bar", async ({ page }) => { for (const width of [768, 1280]) { await page.setViewportSize({ width, height: 800 }); await page.goto("/"); const home = page.getByRole("link", { name: "Archilyzer home", exact: true }); await expect(home.locator("[data-wordmark]")).toBeVisible(); await expect(page.getByRole("navigation", { name: "Main", exact: true })).toBeVisible(); expect(await labelsOf(headerRow(page))).toEqual(TRIO); expect(await scrollBox(page).evaluate((el) => el.scrollWidth > el.clientWidth + 1)).toBe(false); await expect(themeToggle(page)).toBeInViewport(); await noHorizontalOverflow(page); } }); test("every link: its href, its label as its name and title, a new tab with no opener, and no text", async ({ page, }) => { await page.goto("/"); for (const row of [headerRow(page), footerRow(page)]) { for (const want of FIXTURE_SOCIAL_TRIO) { const link = row.getByRole("link", { name: want.label, exact: true }); await expect(link).toHaveAttribute("href", want.url); await expect(link).toHaveAttribute("title", want.label); await expect(link).toHaveAttribute("target", "_blank"); await expect(link).toHaveAttribute("rel", "noopener noreferrer"); // No copy: the link's only content is its icon. await expect(link).toHaveText(""); await expect(link.locator("svg")).toHaveAttribute("aria-hidden", "true"); } } }); test("header icons are the muted foreground; a two-colour icon keeps its colours", async ({ page, }) => { await page.goto("/"); // The glyph itself: Bubble was drawn white and themed to currentColor, so // its path paints the link's colour, the muted foreground. const { glyph, muted } = await page.evaluate(() => { const probe = document.createElement("span"); probe.style.color = "var(--muted-foreground)"; document.body.append(probe); const muted = getComputedStyle(probe).color; probe.remove(); const path = document.querySelector( 'header [data-social-links="header"] a[aria-label="Bubble"] path', )!; return { glyph: getComputedStyle(path).fill, muted }; }); expect(glyph).toBe(muted); const disc = headerRow(page).getByRole("link", { name: "Disc" }); expect(await disc.innerHTML()).toContain('fill="#f4c542"'); }); // End to end for the normalizer's size rule: the fixture's Disc is pasted with // only width="81" height="81"; the page must carry the viewBox the normalizer // made, and the render size instead of 81. test("an icon pasted with a size and no viewBox renders with the viewBox made from its size", async ({ page, }) => { await page.goto("/"); for (const row of [headerRow(page), footerRow(page)]) { const svg = row.getByRole("link", { name: "Disc" }).locator("svg"); await expect(svg).toHaveAttribute("viewBox", "0 0 81 81"); await expect(svg).toHaveAttribute("width", "20"); await expect(svg).toHaveAttribute("height", "20"); const box = (await svg.boundingBox())!; expect(Math.round(box.width)).toBe(20); } }); // The same icon is inlined up to three times (the header's narrow and wide // rows, the footer). Every copy's ids are its own, and the visible header // copy paints its gradient from its own defs. test("each copy of an icon owns its ids; the visible copy's gradient resolves inside it", async ({ page, }) => { await page.setViewportSize({ width: 1280, height: 800 }); await page.goto("/"); const dupes = await page.evaluate(() => { const seen = new Map(); for (const el of document.querySelectorAll("[data-social-links] [id]")) { seen.set(el.id, (seen.get(el.id) ?? 0) + 1); } return [...seen].filter(([, n]) => n > 1).map(([id]) => id); }); expect(dupes).toEqual([]); const own = await headerRow(page) .getByRole("link", { name: "Leaf" }) .evaluate((a) => { const svg = a.querySelector("svg")!; const ref = /url\(#([^)]+)\)/.exec( svg.querySelector("path")!.getAttribute("fill") ?? "", )?.[1]; const target = ref ? document.getElementById(ref) : null; return { ref, inside: !!target && svg.contains(target) }; }); expect(own.ref).toBeTruthy(); expect(own.inside).toBe(true); }); test.describe("under a coarse pointer", () => { test.use({ hasTouch: true }); for (const width of [320, 340, 360, 390]) { test(`${width} px, touch: 0, 1, 3 and 4 marked links, only those shown, nothing scrolls, the wordmark's text where it fits`, async ({ page, }) => { for (const count of [0, 1, 3, 4] as const) { writeFixtureSettings(settingsFile(), [...LINK_SETS[count]]); await narrowHeader(page, width, count, "touch"); } }); } // THE LAST RESORT: at 280 px, four 44 px keys, the toggle and the mark do not // fit, so the row scrolls inside its box — its END in view first, the toggle // outside it, the page never sideways, and each link scrolled fully into view // (its focus ring too) when it takes focus. test("280 px, four marked links: the row scrolls in its box, its end first; focus brings each link into view", async ({ page, }) => { writeFixtureSettings(settingsFile(), LINK_SETS[4]); await page.setViewportSize({ width: 280, height: 800 }); await page.goto("/"); const box = scrollBox(page); expect(await box.evaluate((el) => el.scrollWidth > el.clientWidth + 1)).toBe(true); // Not tab stops of their own (Firefox makes an overflowing scroll box one): // the row's box, and the compact nav's rule, which overflows here too. await expect(box).toHaveAttribute("tabindex", "-1"); await expect(page.getByRole("navigation", { name: "Main, compact" })).toHaveAttribute("tabindex", "-1"); await noHorizontalOverflow(page); await expect(themeToggle(page)).toBeInViewport({ ratio: 1 }); const inBox = (key: import("@playwright/test").Locator) => Promise.all([box.boundingBox(), key.boundingBox()]).then(([b, k]) => k!.x - 2 >= b!.x - 0.5 && k!.x + k!.width + 2 <= b!.x + b!.width + 0.5, ); const keys = await headerRow(page).getByRole("link").all(); expect(await inBox(keys[keys.length - 1]), "the last link, on load").toBe(true); expect(await inBox(keys[0]), "the first link, on load").toBe(false); for (const key of keys) { await tabTo(page, key); await page.waitForTimeout(100); expect(await inBox(key), (await key.getAttribute("aria-label")) ?? "a link").toBe(true); } }); test("320 px at a 200 % text size: the header does not scroll sideways; the last link's end and the toggle are in view", async ({ page, }) => { writeFixtureSettings(settingsFile(), LINK_SETS[4]); await page.addInitScript(() => { document.addEventListener("DOMContentLoaded", () => { document.documentElement.style.fontSize = "200%"; }); }); await page.setViewportSize({ width: 320, height: 800 }); await page.goto("/"); await expect(page.locator("html")).toHaveCSS("font-size", "32px"); // The header only: at twice the text size the page's own content is not // this slice's to reflow. expect(await page.locator("header").evaluate((el) => el.scrollWidth <= el.clientWidth)).toBe(true); await expect(themeToggle(page)).toBeInViewport({ ratio: 1 }); // The box may be narrower than one 88 px key here: the last link is the // one at its end, in view as far as the box allows. const [b, k] = await Promise.all([ scrollBox(page).boundingBox(), headerRow(page).getByRole("link").last().boundingBox(), ]); expect(k!.x + k!.width).toBeLessThanOrEqual(b!.x + b!.width + 0.5); expect(k!.x + k!.width).toBeGreaterThan(b!.x + b!.width / 2); }); for (const width of [360, 1280]) { test(`${width} px: every key is at least 44 px, and the header row still fits`, async ({ page, }) => { await page.setViewportSize({ width, height: 800 }); await page.goto("/"); expect(await page.evaluate(() => matchMedia("(pointer: coarse)").matches)).toBe(true); for (const row of [headerRow(page), footerRow(page)]) { for (const key of await row.getByRole("link").all()) { const k = (await key.boundingBox())!; expect(k.width).toBeGreaterThanOrEqual(44); expect(k.height).toBeGreaterThanOrEqual(44); } } const box = (await headerRow(page).boundingBox())!; expect(box.x + box.width).toBeLessThanOrEqual(width); await noHorizontalOverflow(page); }); } }); test("keyboard focus shows the ring; in forced colours the browser's own outline, and none at rest", async ({ page, }) => { await page.goto("/"); const link = headerRow(page).getByRole("link").first(); await tabTo(page, link); const normal = await link.evaluate((el) => { const s = getComputedStyle(el); return { outline: s.outlineStyle, shadow: s.boxShadow }; }); expect(normal.outline).toBe("none"); expect(normal.shadow).toMatch(/0px 0px 0px 2px/); await page.emulateMedia({ forcedColors: "active" }); await page.goto("/"); const forced = headerRow(page).getByRole("link").first(); const rest = await forced.evaluate((el) => getComputedStyle(el).outlineStyle); expect(rest, "a permanent outline in forced colours").toBe("none"); await tabTo(page, forced); const focused = await forced.evaluate((el) => { const s = getComputedStyle(el); return { style: s.outlineStyle, width: parseFloat(s.outlineWidth) }; }); expect(focused.style).not.toBe("none"); expect(focused.width).toBeGreaterThan(0); }); test("six links, the last marked: a narrow header shows it alone, a wide one the last four, the footer all six", async ({ page, }) => { writeFixtureSettings(settingsFile(), FIXTURE_SOCIAL_SIX); const six = FIXTURE_SOCIAL_SIX.map((l) => l.label); for (const width of [320, 360, 390, 519, 520, 767, 768, 1280]) { await page.setViewportSize({ width, height: 800 }); await page.goto("/"); expect(await labelsOf(headerRow(page)), `${width} px`).toEqual(width < 520 ? ["Disc"] : six.slice(-4)); expect(await labelsOf(footerRow(page))).toEqual(six); await noHorizontalOverflow(page); } }); // The footer shows every link: with eight under touch at 320 px 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 }) => { const extra = (label: string, d: string) => ({ label, url: `https://${label.toLowerCase()}.example/fixture`, svg: ``, }); writeFixtureSettings(settingsFile(), [ ...FIXTURE_SOCIAL_SIX, extra("Bar", "M3 10h18v4H3z"), extra("Cross", "M10 3h4v7h7v4h-7v7h-4v-7H3v-4h7z"), ]); await page.setViewportSize({ width: 320, height: 800 }); await page.goto("/"); await expect(footerRow(page).getByRole("link")).toHaveCount(8); const boxes = await footerRow(page) .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); await noHorizontalOverflow(page); }); }); // The toggle sits in the row's rhythm: its box directly after the last link's, // as the links' boxes sit after each other, so glyph to glyph is the same all // along; the nav keeps a larger gap before the group. for (const width of [1024, 1280]) { test(`${width} px: the toggle is spaced like a fourth key, and the nav stands apart`, async ({ page, }) => { await page.setViewportSize({ width, height: 800 }); await page.goto("/"); const boxes: { x: number; y: number; width: number; height: number }[] = []; for (const key of await headerRow(page).getByRole("link").all()) boxes.push((await key.boundingBox())!); boxes.push((await themeToggle(page).boundingBox())!); expect(boxes).toHaveLength(TRIO.length + 1); const gaps = boxes.slice(1).map((b, i) => b.x - (boxes[i].x + boxes[i].width)); for (const g of gaps) { expect(g, "boxes overlap").toBeGreaterThanOrEqual(-0.5); expect(Math.abs(g - gaps[0]), `gaps ${gaps.join(", ")}`).toBeLessThanOrEqual(1); } const lastNav = page.getByRole("navigation", { name: "Main", exact: true }).getByRole("link").last(); const nav = (await lastNav.boundingBox())!; expect(boxes[0].x - (nav.x + nav.width), "the nav's gap before the group").toBeGreaterThan(gaps[0] + 16); }); } test("six links, two marked: a narrow header shows those two; a wide one those two and the last two of the rest, in order", async ({ page, }) => { writeFixtureSettings(settingsFile(), FIXTURE_SOCIAL_SIX_FEATURED); await page.setViewportSize({ width: 390, height: 800 }); await page.goto("/"); expect(await labelsOf(headerRow(page))).toEqual(["Square", "Bubble"]); await page.setViewportSize({ width: 1280, height: 800 }); await page.goto("/"); expect(await labelsOf(headerRow(page))).toEqual(["Square", "Triangle", "Bubble", "Disc"]); expect(await labelsOf(footerRow(page))).toEqual(FIXTURE_SOCIAL_SIX.map((l) => l.label)); }); // 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 mark → the marked // link → the toggle. The footer keeps every link at every width. for (const touch of [false, true]) { test.describe(touch ? "the narrow header, touch" : "the narrow header, a mouse", () => { test.use({ hasTouch: touch }); for (const width of [360, 390]) { test(`${width} px: the full name, the marked link alone, then the toggle, in focus order`, async ({ page }) => { await page.setViewportSize({ width, height: 800 }); await page.goto("/"); const home = page.getByRole("link", { name: "Archilyzer home", exact: true }); await expect(home.locator("[data-wordmark]")).toBeVisible(); const banner = page.getByRole("banner"); await expect(banner.getByRole("link", { name: "Disc", exact: true })).toHaveCount(1); for (const other of ["Leaf", "Bubble"]) { await expect(banner.getByRole("link", { name: other, exact: true })).toHaveCount(0); } expect(await labelsOf(footerRow(page))).toEqual(TRIO); const order: string[] = []; for (let i = 0; i < 3; i++) { await page.keyboard.press("Tab"); order.push( await page.evaluate(() => document.activeElement?.getAttribute("aria-label") ?? document.activeElement?.textContent ?? ""), ); } expect(order.slice(0, 2)).toEqual(["Archilyzer home", "Disc"]); expect(order[2]).toMatch(/^Switch to /); }); } test("none marked: the narrow header has no social link, and the name shows", async ({ page }) => { writeFixtureSettings(settingsFile(), unmarked(FIXTURE_SOCIAL_TRIO)); for (const width of [320, 360, 390]) { await page.setViewportSize({ width, height: 800 }); await page.goto("/"); await expect(page.getByRole("link", { name: "Archilyzer home", exact: true }).locator("[data-wordmark]")).toBeVisible(); for (const l of TRIO) { await expect(page.getByRole("banner").getByRole("link", { name: l, exact: true })).toHaveCount(0); } expect(await labelsOf(footerRow(page))).toEqual(TRIO); } }); }); } test("nothing scrolls the page sideways from 280 to 1400 px, and the footer keeps every link", async ({ page }) => { for (const width of [280, 300, 320, 360, 390, 430, 519, 520, 640, 767, 768, 1024, 1280, 1400]) { await page.setViewportSize({ width, height: 800 }); await page.goto("/"); await noHorizontalOverflow(page); expect(await labelsOf(footerRow(page)), `${width} px`).toEqual(TRIO); } }); // AT TWICE THE TEXT SIZE. 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, and // the bar is the default-size bar at half the width. A page that sets its own // root size moves the page's rem but not a media query's; there, from the // switch, the wordmark's fit is keyed by the WIDE row's count, so the text // drops before that row scrolls. 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 } }); } const barState = (page: Page) => page.evaluate(() => { const wm = document.querySelector("header [data-wordmark]") as HTMLElement; const box = ([...document.querySelectorAll("header [data-social-scroll]")] as HTMLElement[]).find( (e) => e.offsetParent !== null, ); const header = document.querySelector("header") as HTMLElement; return { text: getComputedStyle(wm).display !== "none", scrolls: box ? box.scrollWidth > box.clientWidth + 1 : false, over: header.scrollWidth - header.clientWidth, }; }); for (const touch of [false, true]) { test.describe(touch ? "at 200 % text, touch" : "at 200 % text, a mouse", () => { test.use({ hasTouch: touch }); test("the browser's text size at 32 px: the narrow row until 1040 px, the wide one from there; the row never scrolls while the name shows", async ({ page, }) => { test.setTimeout(90_000); await browserTextSize(page, 32); for (const count of [1, 4] as const) { writeFixtureSettings(settingsFile(), [...LINK_SETS[count]]); const narrow = headerSocialLinks(LINK_SETS[count], "narrow").map((l) => l.label); const wide = headerSocialLinks(LINK_SETS[count], "wide").map((l) => l.label); for (const width of [600, 720, 767, 900, 1039, 1040, 1100, 1300]) { await page.setViewportSize({ width, height: 800 }); await page.goto("/"); await expect(page.locator("html")).toHaveCSS("font-size", "32px"); const at = `${count} marked, ${width} px`; expect(await labelsOf(headerRow(page)), at).toEqual(width < 1040 ? narrow : wide); const s = await barState(page); if (s.scrolls) expect(s.text, `${at}: the row scrolls while the name shows`).toBe(false); expect(s.over, `${at}: the header overflows`).toBeLessThanOrEqual(0); if (count === 1 && width >= 720) expect(s.text, `${at}: the name`).toBe(true); } } }); test("the page's root at 200 %: from 520 to 767 px the wide row never scrolls while the name shows", async ({ page, }) => { test.setTimeout(90_000); await page.addInitScript(() => { document.addEventListener("DOMContentLoaded", () => { document.documentElement.style.fontSize = "200%"; }); }); for (const count of [1, 4] as const) { writeFixtureSettings(settingsFile(), [...LINK_SETS[count]]); const wide = headerSocialLinks(LINK_SETS[count], "wide").map((l) => l.label); for (const width of [520, 560, 600, 640, 680, 720, 767]) { await page.setViewportSize({ width, height: 800 }); await page.goto("/"); await expect(page.locator("html")).toHaveCSS("font-size", "32px"); const at = `${count} marked, ${width} px`; expect(await labelsOf(headerRow(page)), at).toEqual(wide); const s = await barState(page); if (s.scrolls) expect(s.text, `${at}: the row scrolls while the name shows`).toBe(false); expect(s.over, `${at}: the header overflows`).toBeLessThanOrEqual(0); } } }); }); } test("no links: no row in the header, and no Elsewhere column in the footer", async ({ page, }) => { writeFixtureSettings(settingsFile(), []); for (const width of [360, 1280]) { await page.setViewportSize({ width, height: 800 }); await page.goto("/"); await expect(page.locator("header [data-social-links]")).toHaveCount(0); await expect(page.locator("footer [data-social-links]")).toHaveCount(0); await expect(page.locator("footer").getByText("Elsewhere")).toHaveCount(0); await expect(page.getByRole("link", { name: "Archilyzer home" })).toBeVisible(); } }); // A settings.json edited by hand (never through a save) holding icons that // would run script: none is inlined, none runs, and each link still renders — // as its label — while the good icons beside them render as icons. test("a stored icon that would run script or load from elsewhere is not inlined: nothing runs, nothing is fetched, the link shows its label", async ({ page, }) => { const hostile = [...FIXTURE_HOSTILE_SVGS, ...LOADS_ELSEWHERE.map((n) => ADVERSARIAL[n])].map((svg, i) => ({ label: `Hostile ${i + 1}`, url: `https://hostile-${i + 1}.example/`, svg, })); writeRawFixtureSettings(settingsFile(), [...FIXTURE_SOCIAL_TRIO.slice(0, 1), ...hostile]); const elsewhere: string[] = []; page.on("request", (r) => { if (new URL(r.url()).origin !== new URL(page.url() || "http://localhost").origin && !r.url().startsWith("data:")) { elsewhere.push(r.url()); } }); await page.goto("/"); await page.waitForLoadState("load"); await page.waitForTimeout(300); expect(await page.evaluate(() => (window as unknown as { __hpHostile?: unknown }).__hpHostile)).toBeUndefined(); const footer = footerRow(page); for (const h of hostile) { const link = footer.getByRole("link", { name: h.label, exact: true }); await expect(link).toHaveAttribute("href", h.url); await expect(link).toHaveText(h.label); await expect(link.locator("svg, img, image")).toHaveCount(0); } // The good icon beside them is still an icon. await expect(footer.getByRole("link", { name: "Leaf", exact: true }).locator("svg")).toHaveCount(1); expect(await page.evaluate(() => document.querySelectorAll("[data-social-links] img, [data-social-links] image").length)).toBe(0); expect(elsewhere.filter((u) => !u.startsWith(new URL(page.url()).origin)), "requests to another origin").toEqual([]); });