Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

commit ed5c706d6d4c68cf21f74082681201accbd151b8
parent 0197fb7cfcf4bffa741240bb325eef5a409c6a78
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Mon, 28 Sep 2026 18:43:20 -0400

homepage: the social row and the Options gear in the header at every width; Changelog in the footer only

- The header: one group, the social row then the gear, boxes touching (glyph
  to glyph 16 px), in the bar at every width. From md: wordmark · nav · group;
  below md the nav has the rule below to itself and fits at 360 px. The bar
  shows the last n of the header's links by width and pointer (mouse: 4 from
  768, 3 from 360, else 2; touch: 4 from 768, 3 from 390, 2 from 340, else 1),
  one CSS rule per step; no width from 320 up scrolls sideways.
- lib/nav.ts: HEADER_NAV (four) and FOOTER_NAV (the same, then Changelog); the
  footer and the 404 page read FOOTER_NAV. ThemeMenu and ThemeToggle leave the
  homepage header.
- The footer's Elsewhere column uses SocialLinks.
- e2e: the dev server reads its own settings (SETTINGS_FILE) and an empty
  sites directory (SITES_DIR), never the checkout's; synthetic icons (a
  gradient, one colour, a sized two-colour disc) and hostile ones written raw.
  social.spec (the rule table from 320 px, sizes, focus, six links, featured,
  none, the synthesized viewBox, per-copy ids, no script from a stored icon),
  options.spec (name, modality, focus trap and return, the checked radio,
  Escape/Close/outside click, 360×640, 36/44 px), the nav specs, and one
  chooseTheme helper for theme.spec.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Diffstat:
M.gitignore | 4++++
Mhomepage/app/components/Footer.tsx | 26+++++---------------------
Mhomepage/app/components/Header.tsx | 79+++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------------
Mhomepage/app/lib/nav.ts | 16++++++++++++----
Mhomepage/app/not-found.tsx | 4++--
Ahomepage/e2e/fixture-social.ts | 114+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ahomepage/e2e/helpers.ts | 48++++++++++++++++++++++++++++++++++++++++++++++++
Mhomepage/e2e/marketing.spec.ts | 32++++++++++++++++++++++++++++++++
Ahomepage/e2e/options.spec.ts | 176+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ahomepage/e2e/social.spec.ts | 340+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mhomepage/e2e/theme.spec.ts | 19+++++++------------
Mhomepage/playwright.config.ts | 27++++++++++++++++++++++++++-
12 files changed, 823 insertions(+), 62 deletions(-)

diff --git a/.gitignore b/.gitignore @@ -141,6 +141,10 @@ yarn-error.log* /homepage/out/ # the e2e's synthetic summary, and a killed run's temp copy (homepage/e2e/fixture-summary.ts) /homepage/e2e/.e2e-summary.json* +# the e2e's settings.json (social links only), and its temp copy (homepage/e2e/fixture-social.ts) +/homepage/e2e/.e2e-settings.json* +# the e2e's empty sites directory (no homepage.json; homepage/playwright.config.ts) +/homepage/e2e/.e2e-sites/ # site config /settings.json diff --git a/homepage/app/components/Footer.tsx b/homepage/app/components/Footer.tsx @@ -1,15 +1,13 @@ import Link from "next/link"; -import { - getSettings, - sizeSocialSvg, -} from "yt-dlp-transcript-common/lib/settings"; +import { getSettings } from "yt-dlp-transcript-common/lib/settings"; import { resolveHomepageSocialLinks } from "yt-dlp-transcript-common/lib/homepage"; +import { SocialLinks } from "yt-dlp-transcript-common/components/SocialLinks"; import { PROJECT_NAME, PROJECT_TAGLINE, } from "yt-dlp-transcript-common/lib/project"; import { currentHomepage } from "../lib/homepage"; -import { NAV } from "../lib/nav"; +import { FOOTER_NAV } from "../lib/nav"; // The project site's footer. Note the identity split it embodies: the wordmark // and tagline are PRODUCT strings (the same on every install), while the social @@ -36,7 +34,7 @@ export default function Footer() { <nav aria-label="Footer" className="flex flex-col gap-3"> <span className="label-machine">Sections</span> <ul className="flex flex-col gap-2 list-none"> - {NAV.map((item) => ( + {FOOTER_NAV.map((item) => ( <li key={item.href}> <Link href={item.href} @@ -52,21 +50,7 @@ export default function Footer() { {socialLinks.length > 0 && ( <div className="flex flex-col gap-3"> <span className="label-machine">Elsewhere</span> - <ul className="flex items-center gap-4 list-none"> - {socialLinks.map((link, i) => ( - <li key={`${link.url}-${i}`}> - <a - href={link.url} - title={link.label} - aria-label={link.label} - target="_blank" - rel="noopener noreferrer" - className="inline-block w-5 h-5 text-[var(--faint)] hover:text-[var(--foreground)] transition-colors [&_svg]:w-full [&_svg]:h-full" - dangerouslySetInnerHTML={{ __html: sizeSocialSvg(link.svg) }} - /> - </li> - ))} - </ul> + <SocialLinks links={socialLinks} placement="footer" /> </div> )} </div> diff --git a/homepage/app/components/Header.tsx b/homepage/app/components/Header.tsx @@ -6,14 +6,17 @@ import { import { ICON_PALETTES } from "yt-dlp-transcript-common/lib/brand"; import { BrandMark } from "yt-dlp-transcript-common/components/BrandMark"; import { Wordmark } from "yt-dlp-transcript-common/components/Wordmark"; -import { ThemeToggle } from "yt-dlp-transcript-common/components/ThemeToggle"; -import { ThemeMenu } from "yt-dlp-transcript-common/components/ThemeMenu"; -import { NAV } from "../lib/nav"; +import { OptionsDialog } from "yt-dlp-transcript-common/components/OptionsDialog"; +import { SocialLinks } from "yt-dlp-transcript-common/components/SocialLinks"; +import { getSettings } from "yt-dlp-transcript-common/lib/settings"; +import { resolveHomepageSocialLinks } from "yt-dlp-transcript-common/lib/homepage"; +import { currentHomepage } from "../lib/homepage"; +import { HEADER_NAV } from "../lib/nav"; function NavList({ className }: { className?: string }) { return ( <ul className={`flex items-center list-none ${className ?? ""}`}> - {NAV.map((item) => ( + {HEADER_NAV.map((item) => ( <li key={item.href}> <Link href={item.href} @@ -27,23 +30,48 @@ function NavList({ className }: { className?: string }) { ); } -// The project site's header: a hairline bar with the wordmark and the five -// destinations. It carried NO links at all before this — the page it sat above -// was the whole site. +// The project site's header: a hairline bar with the wordmark and the four +// destinations (Changelog is in the footer only; lib/nav.ts). It carried NO +// links at all before this — the page it sat above was the whole site. // // The wordmark is PRODUCT identity (common/lib/project.ts), not the operator's // editable homepage config: this bar says what the software is called, and that // is the same string on every install. The operator's own naming still governs -// /stats/ and the social links in the footer. +// /stats/ and the social links, which this bar carries as well as the footer. +// +// THE SOCIAL ROW AND THE GEAR ARE ONE GROUP, in the bar at every width: the +// operator's links (common/components/SocialLinks.tsx) and then "Options" +// (common/components/OptionsDialog.tsx), dressed alike, their 36 px boxes +// touching, so every glyph is 16 px from the next. Measured with the wordmark +// at 148 px, the nav at 276 px and a key 36 px (44 px under a coarse pointer): +// ≥ md (768) wordmark · nav · group, the nav 32 px from the group's first +// box (40 px from its first glyph). +// < md wordmark · group, and the nav alone on the rule below (the +// four links fit a 360 px rule, so nothing scrolls). +// THE BOUND: the header shows at most four links (headerSocialLinks); the +// footer shows every link. The bar shows the LAST n of those four, by viewport +// width and pointer — one CSS rule per step on the row below, each a width the +// wordmark (148–152 px), a 12 px gap and the group fit inside the 20 px gutters: +// +// width mouse touch (coarse pointer, 44 px keys) +// ≥ 768 4 4 +// 390–767 3 3 +// 360–389 3 2 (three 44 px keys and the gear need 380 px) +// 340–359 2 2 (three 36 px keys and the gear need 348–352 px) +// < 340 2 1 (two 44 px keys and the gear need 336 px) +// +// The row is one element; the ids inside its icons are still scoped per copy +// (scopeSvgIds), because the footer inlines the same icons. // // The mark is the family's Found-line mark in its parent colours — bone on // slate, no accent: the project spends no colour on its own chrome. The // wordmark splits on the subject ("Archi" + "lyzer"); the link keeps its // explicit name, "Archilyzer home". export default function Header() { + const socialLinks = resolveHomepageSocialLinks(currentHomepage(), getSettings()); return ( <header className="sticky top-0 z-20 border-b border-[var(--border)] bg-[var(--background)]/85 backdrop-blur-md"> - <div className="max-w-6xl mx-auto px-5 sm:px-6 flex h-14 items-center gap-6"> + <div className="max-w-6xl mx-auto px-5 sm:px-6 flex h-14 items-center gap-3 md:gap-6"> <Link href="/" className="flex items-center gap-2.5 shrink-0" @@ -56,25 +84,32 @@ export default function Header() { className="text-[1.3rem] leading-none tracking-[-0.01em]" /> </Link> - <nav aria-label="Main" className="ml-auto hidden md:block"> - <NavList className="gap-6" /> - </nav> - <div className="ml-auto md:ml-0 flex items-center gap-2"> - <ThemeMenu /> - <ThemeToggle /> + <div className="ml-auto flex items-center gap-8"> + <nav aria-label="Main" className="hidden md:block"> + <NavList className="gap-6" /> + </nav> + <div className="flex items-center"> + <SocialLinks + links={socialLinks} + placement="header" + className="max-md:[&>li:nth-last-child(4)]:hidden max-[390px]:pointer-coarse:[&>li:nth-last-child(3)]:hidden max-[360px]:[&>li:nth-last-child(3)]:hidden max-[340px]:pointer-coarse:[&>li:nth-last-child(2)]:hidden" + /> + <OptionsDialog /> + </div> </div> </div> - {/* Below `md` the bar has no room for five labels beside the wordmark and - the theme controls (at `sm` four fitted; Source made it five), so the - nav drops to its own scrollable rule rather than collapsing behind a - menu button — five links do not earn a disclosure widget. Only one of the two is ever in the accessibility - tree (the other is display:none), but they carry distinct labels so a - test or a screen reader can never conflate them. */} + {/* Below `md` the nav drops to its own rule rather than collapsing behind + a menu button — four links do not earn a disclosure widget, and they + fit a 360 px rule. Only one of the two navs is ever in the + accessibility tree (the other is display:none), but they carry + distinct labels so a test or a screen reader can never conflate them. + `overflow-x-auto` only guards a larger text setting; nothing scrolls + at the default size. */} <nav aria-label="Main, compact" className="md:hidden border-t border-[var(--border)] overflow-x-auto" > - <NavList className="gap-5 px-5 h-10" /> + <NavList className="gap-5 px-5 sm:px-6 h-10" /> </nav> </header> ); diff --git a/homepage/app/lib/nav.ts b/homepage/app/lib/nav.ts @@ -1,16 +1,24 @@ -// The site's navigation, declared once and rendered by both the header and the -// footer. Order is editorial: what the software is, where its code lives, how -// to get it, what this deployment has done, what changed. +// The site's navigation, declared once. Order is editorial: what the software +// is, where its code lives, how to get it, what this deployment has done, what +// changed. +// +// HEADER_NAV the header's links; +// FOOTER_NAV the footer's "Sections" and the 404 page: the header's links, +// then Changelog, which is in the footer only. // // Every entry must resolve on a `build:nodata` tree — /stats/ renders an honest // no-data panel and /source/ an honest "nothing published" rather than 404ing — // so the nav never has to be conditional. export type NavItem = { href: string; label: string }; -export const NAV: NavItem[] = [ +export const HEADER_NAV: readonly NavItem[] = [ { href: "/docs/", label: "Docs" }, { href: "/source/", label: "Source" }, { href: "/downloads/", label: "Downloads" }, { href: "/stats/", label: "Stats" }, +]; + +export const FOOTER_NAV: readonly NavItem[] = [ + ...HEADER_NAV, { href: "/changelog/", label: "Changelog" }, ]; diff --git a/homepage/app/not-found.tsx b/homepage/app/not-found.tsx @@ -1,6 +1,6 @@ import Link from "next/link"; import { PageShell, PageHeading } from "./components/PageShell"; -import { NAV } from "./lib/nav"; +import { FOOTER_NAV } from "./lib/nav"; // Branded 404. Static export renders this to out/404.html, which Cloudflare // Pages serves for unmatched paths. @@ -15,7 +15,7 @@ export default function NotFound() { <nav aria-label="Site sections" className="flex flex-col gap-3"> <span className="label-machine">Try one of these</span> <ul className="flex flex-wrap gap-x-6 gap-y-2 list-none"> - {[{ href: "/", label: "Home" }, ...NAV].map((item) => ( + {[{ href: "/", label: "Home" }, ...FOOTER_NAV].map((item) => ( <li key={item.href}> <Link href={item.href} diff --git a/homepage/e2e/fixture-social.ts b/homepage/e2e/fixture-social.ts @@ -0,0 +1,114 @@ +import fs from "node:fs"; +import { + normalizeSocialSvg, + type SocialLink, +} from "../../common/lib/settingsSchema"; + +// THE HOMEPAGE E2E'S SOCIAL LINKS, never the operator's. +// +// The header and the footer render `socialLinks` from settings.json. A checkout's +// settings.json is the operator's own (a worktree seeds it from the primary), so +// the e2e dev server reads THIS instead: `SETTINGS_FILE` (common/lib/paths.ts) +// points it at e2e/.e2e-settings.json (gitignored), written by +// playwright.config.ts before the server starts. Only the social links are set; +// every other key reads as its default, and nothing else on the homepage reads +// settings. (`SITES_DIR` points at an empty directory too, so no homepage.json +// can override these links.) +// +// Each icon is given here in its SOURCE form — as an operator would paste it — +// and goes through normalizeSocialSvg, the function every save path runs +// (Settings, a site's form, the homepage config), so the page renders what a +// save would have stored. The default trio is shaped like the icons operators +// paste, all synthetic: +// • Leaf — a gradient (an id, a url(#…) reference) and one solid colour, +// which the normalizer themes to currentColor; +// • Bubble — one colour, drawn white, as vendors ship them for dark grounds; +// themed to currentColor; +// • Disc — a two-colour disc with a letter, pasted with only width/height and +// NO viewBox, so the page proves the normalizer made its viewBox. +// +// A spec that needs another list (six links, none, a hostile icon) rewrites the +// file with writeFixtureSettings (or writeRawFixtureSettings, which skips the +// normalizer the way a hand-edited file would) and restores FIXTURE_SOCIAL_TRIO +// after: `next dev` reads settings on every request, and the suite runs one +// worker. + +export const FIXTURE_SETTINGS_NAME = ".e2e-settings.json"; + +const LEAF = + `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">` + + `<defs><linearGradient id="leaf-grad" x1="0" y1="0" x2="0" y2="1">` + + `<stop offset="0" stop-color="#E0A030"/><stop offset="1" stop-color="#1C9A5B"/>` + + `</linearGradient></defs>` + + `<path fill="url(#leaf-grad)" d="M12 2c4.9 3.6 8 7.6 8 11.3A8 8 0 0 1 4 13.3C4 9.6 7.1 5.6 12 2z"/>` + + `<circle fill="#333333" cx="12" cy="15" r="2.4"/></svg>`; + +const BUBBLE = + `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">` + + `<path fill="white" d="M4 4h16a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2h-7l-5 4v-4H4a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2z"/></svg>`; + +// A two-colour disc with a letter, sized and with no viewBox (the normalizer +// makes `viewBox="0 0 81 81"`): a dark offset disc, a light disc with a dark +// outline, a dark "F". +export const FIXTURE_DISC_SVG = + `<svg xmlns="http://www.w3.org/2000/svg" width="81" height="81" fill="none">` + + `<circle cx="44" cy="43" r="37" fill="#1a1a1a"/>` + + `<circle cx="39" cy="39" r="38" fill="#f4c542" stroke="#1a1a1a" stroke-width="1.5"/>` + + `<path fill="#1a1a1a" d="M30 22h20v8H38v6h10v8H38v14h-8z"/></svg>`; + +// Icons that must never run: each sets window.__hpHostile if it does. Written +// raw (writeRawFixtureSettings), as a hand-edited settings.json could hold them. +export const FIXTURE_HOSTILE_SVGS = [ + `<svg/onload="window.__hpHostile=1" viewBox="0 0 8 8"><path d="M0 0"/></svg>`, + `<svg viewBox="0 0 8 8"><img src="x:" onerror="window.__hpHostile=1"></svg>`, + `<svg viewBox="0 0 8 8"><image href="x:"/onerror="window.__hpHostile=1"/></svg>`, +]; + +const SQUARE = `<svg viewBox="0 0 24 24"><rect x="4" y="4" width="16" height="16" rx="3"/></svg>`; +const RING = `<svg viewBox="0 0 24 24"><path fill-rule="evenodd" d="M12 3a9 9 0 1 1 0 18 9 9 0 0 1 0-18zm0 4a5 5 0 1 0 0 10 5 5 0 0 0 0-10z"/></svg>`; +const TRIANGLE = `<svg viewBox="0 0 24 24"><path d="M12 3l10 18H2z"/></svg>`; + +const raw = (label: string, svg: string, featured = false) => ({ + label, + url: `https://${label.toLowerCase()}.example/fixture`, + svg, + ...(featured ? { featured: true } : {}), +}); + +export const FIXTURE_SOCIAL_TRIO = [ + raw("Leaf", LEAF), + raw("Bubble", BUBBLE), + raw("Disc", FIXTURE_DISC_SVG), +]; + +// Six, none marked: the header takes the last four. +export const FIXTURE_SOCIAL_SIX = [ + raw("Square", SQUARE), + raw("Ring", RING), + ...FIXTURE_SOCIAL_TRIO.slice(0, 1), + raw("Triangle", TRIANGLE), + ...FIXTURE_SOCIAL_TRIO.slice(1), +]; + +// Six, two marked: the header takes those two. +export const FIXTURE_SOCIAL_SIX_FEATURED = FIXTURE_SOCIAL_SIX.map((l) => + l.label === "Square" || l.label === "Bubble" ? { ...l, featured: true } : l, +); + +type RawLink = { label: string; url: string; svg: string; featured?: boolean }; + +export function writeFixtureSettings(dest: string, links: RawLink[]): void { + const socialLinks: SocialLink[] = links.map((link) => { + const svg = normalizeSocialSvg(link.svg); + if (svg === null) throw new Error(`fixture icon "${link.label}" was refused`); + return { ...link, svg }; + }); + writeRawFixtureSettings(dest, socialLinks); +} + +// The links as given, NOT normalized: what a hand-edited settings.json holds. +export function writeRawFixtureSettings(dest: string, links: RawLink[]): void { + const tmp = `${dest}.${process.pid}.tmp`; + fs.writeFileSync(tmp, JSON.stringify({ socialLinks: links }, null, 2)); + fs.renameSync(tmp, dest); +} diff --git a/homepage/e2e/helpers.ts b/homepage/e2e/helpers.ts @@ -0,0 +1,48 @@ +import { expect, type Page } from "@playwright/test"; + +// THE ONE WAY a homepage spec changes the theme through the UI: the header's +// gear, "Options", opens a dialog with two radio groups, Base and Accent +// (common/components/OptionsDialog.tsx + ThemeRadios.tsx). Specs that set +// `localStorage` directly do not go through here. +// +// The labels are the radios' names: Base "System" | "Light" | "Sepia" | "Dark"; +// Accent a named accent ("Signal", "Brass", "Vermilion", "Violet", "Sakura", +// "Blue", "Green"). The dialog closes again with Escape before this returns. + +export const optionsButton = (page: Page) => + page.locator("header").getByRole("button", { name: "Options", exact: true }); + +export const optionsDialog = (page: Page) => + page.getByRole("dialog", { name: "Options" }); + +// A click before hydration is lost, so click until the dialog is open. +export async function openOptions(page: Page) { + const dialog = optionsDialog(page); + await expect(async () => { + if (!(await dialog.isVisible())) await optionsButton(page).click(); + await expect(dialog).toBeVisible({ timeout: 1_000 }); + }).toPass({ timeout: 15_000 }); + return dialog; +} + +export async function chooseTheme( + page: Page, + choice: { base?: string; accent?: string }, +) { + const dialog = await openOptions(page); + if (choice.base) { + const radio = dialog + .getByRole("radiogroup", { name: "Base" }) + .getByRole("radio", { name: choice.base, exact: true }); + await radio.check(); + } + if (choice.accent) { + // An accent's name carries "default" after the site's own. + const radio = dialog + .getByRole("radiogroup", { name: "Accent" }) + .getByRole("radio", { name: new RegExp(`^${choice.accent}( default)?$`) }); + await radio.check(); + } + await page.keyboard.press("Escape"); + await expect(dialog).toBeHidden(); +} diff --git a/homepage/e2e/marketing.spec.ts b/homepage/e2e/marketing.spec.ts @@ -37,6 +37,38 @@ test("the hub link, when there is one, leaves for the hub", async ({ page }) => await expect(hub).toHaveAttribute("href", /^https?:\/\/[^/]/); }); +// The header carries four destinations; the footer's "Sections" carries the +// same four and Changelog, which is in the footer only (app/lib/nav.ts). +const HEADER_LINKS = ["Docs", "Source", "Downloads", "Stats"]; +const FOOTER_LINKS = [...HEADER_LINKS, "Changelog"]; + +const linkNames = (nav: import("@playwright/test").Locator) => + nav.getByRole("link").evaluateAll((els) => els.map((e) => e.textContent?.trim())); + +test("the header nav has four links and no Changelog; the footer's has five", async ({ + page, +}) => { + for (const width of [360, 1280]) { + await page.setViewportSize({ width, height: 800 }); + await page.goto("/"); + const main = page.getByRole("navigation", { name: width < 768 ? "Main, compact" : "Main", exact: true }); + expect(await linkNames(main)).toEqual(HEADER_LINKS); + await expect(page.locator("header").getByRole("link", { name: "Changelog" })).toHaveCount(0); + expect(await linkNames(page.getByRole("navigation", { name: "Footer" }))).toEqual(FOOTER_LINKS); + } +}); + +test("Changelog is reachable from the footer on every page", async ({ page }) => { + for (const path of ["/", "/docs/", "/source/", "/downloads/", "/stats/", "/changelog/", "/no-such-page/"]) { + await page.goto(path); + await expect( + page.getByRole("navigation", { name: "Footer" }).getByRole("link", { name: "Changelog" }), + path, + ).toHaveAttribute("href", "/changelog/"); + await expect(page.locator("header").getByRole("link", { name: "Changelog" }), path).toHaveCount(0); + } +}); + test("every nav destination resolves", async ({ page }) => { // The nav must never point at a 404, including on a build with no corpus // data — which is why /stats/ always exists and degrades in place. diff --git a/homepage/e2e/options.spec.ts b/homepage/e2e/options.spec.ts @@ -0,0 +1,176 @@ +import { test, expect, type Page } from "@playwright/test"; +import { chooseTheme, openOptions, optionsButton, optionsDialog } from "./helpers"; + +// ONE OPTIONS BUTTON: the header's gear, named "Options", opens a modal titled +// "Options" with the theme's radio groups, Base and Accent. It replaced the +// theme dropdown ("Choose theme") and the base toggle ("Switch to …"). + +const html = (page: Page) => + page.evaluate(() => ({ + base: document.documentElement.getAttribute("data-base"), + accent: document.documentElement.getAttribute("data-accent"), + })); + +for (const width of [360, 1280]) { + test(`${width} px: one Options gear in the header, a 36 px box, and no other theme button`, async ({ + page, + }) => { + await page.setViewportSize({ width, height: 800 }); + await page.goto("/"); + const gear = optionsButton(page); + await expect(gear).toBeVisible(); + await expect(gear).toBeInViewport(); + const box = (await gear.boundingBox())!; + expect(box.width).toBe(36); + expect(box.height).toBe(36); + expect(box.x + box.width).toBeLessThanOrEqual(width); + await expect(page.getByRole("button", { name: "Choose theme" })).toHaveCount(0); + await expect(page.getByRole("button", { name: /switch to/i })).toHaveCount(0); + await expect(gear).toHaveText(""); + }); +} + +test.describe("under a coarse pointer", () => { + test.use({ hasTouch: true }); + test("the gear is 44 px", async ({ page }) => { + await page.setViewportSize({ width: 360, height: 800 }); + await page.goto("/"); + const box = (await optionsButton(page).boundingBox())!; + expect(box.width).toBe(44); + expect(box.height).toBe(44); + }); +}); + +test("the dialog: its role and name, both radio groups, focus kept inside, Escape closes and focus returns", async ({ + page, +}) => { + await page.goto("/"); + const dialog = await openOptions(page); + // Modal: the page behind leaves the accessibility tree while it is open. + await expect(page.getByRole("link", { name: "Archilyzer home" })).toHaveCount(0); + await expect(dialog.getByRole("heading", { name: "Options" })).toBeVisible(); + const base = dialog.getByRole("radiogroup", { name: "Base" }); + const accent = dialog.getByRole("radiogroup", { name: "Accent" }); + expect( + await base.getByRole("radio").evaluateAll((els) => els.map((e) => e.closest("label")?.textContent?.trim())), + ).toEqual(["System", "Light", "Sepia", "Dark"]); + await expect(accent.getByRole("radio")).toHaveCount(7); + await expect(accent.getByRole("radio", { name: "Signal default" })).toBeChecked(); + // It opens on the CHECKED base (the homepage's default is Dark), so an arrow + // key moves from the base in force. + await expect(base.getByRole("radio", { name: "Dark", exact: true })).toBeChecked(); + await expect(base.getByRole("radio", { name: "Dark", exact: true })).toBeFocused(); + + // Tab and Shift+Tab never leave the dialog. + for (const key of ["Tab", "Shift+Tab"]) { + for (let i = 0; i < 14; i++) { + await page.keyboard.press(key); + expect( + await page.evaluate(() => !!document.activeElement?.closest('[role="dialog"]')), + `${key} #${i + 1} left the dialog`, + ).toBe(true); + } + } + + await page.keyboard.press("Escape"); + await expect(dialog).toBeHidden(); + await expect(optionsButton(page)).toBeFocused(); + await expect(page.getByRole("link", { name: "Archilyzer home" })).toHaveCount(1); +}); + +test("a choice applies at once and the dialog stays open; the close button and a click outside close it", async ({ + page, +}) => { + await page.goto("/"); + const dialog = await openOptions(page); + await dialog.getByRole("radio", { name: "Sepia", exact: true }).check(); + await expect.poll(() => html(page)).toMatchObject({ base: "sepia" }); + await expect(dialog).toBeVisible(); + await dialog.getByRole("radio", { name: "Violet", exact: true }).check(); + await expect.poll(() => html(page)).toEqual({ base: "sepia", accent: "violet" }); + await expect(dialog).toBeVisible(); + expect( + await page.evaluate(() => [localStorage.getItem("ytdlp-tb:base"), localStorage.getItem("ytdlp-tb:accent")]), + ).toEqual(["sepia", "violet"]); + + // Its own Close: a 36 px key. + const close = dialog.getByRole("button", { name: "Close", exact: true }); + const cb = (await close.boundingBox())!; + expect([cb.width, cb.height]).toEqual([36, 36]); + await close.click(); + await expect(dialog).toBeHidden(); + await expect(optionsButton(page)).toBeFocused(); + + await openOptions(page); + await page.mouse.click(5, 400); + await expect(optionsDialog(page)).toBeHidden(); + await expect(optionsButton(page)).toBeFocused(); + + // The helper the other specs use lands the same way. + await chooseTheme(page, { base: "Dark", accent: "Signal" }); + await expect.poll(() => html(page)).toEqual({ base: "dark", accent: "signal" }); +}); + +test("360 × 640: the dialog fits, scrolls inside when it must, locks the page, and nothing scrolls sideways", async ({ + page, +}) => { + await page.setViewportSize({ width: 360, height: 640 }); + await page.goto("/"); + const dialog = await openOptions(page); + const box = (await dialog.boundingBox())!; + expect(box.x).toBeGreaterThanOrEqual(0); + expect(box.y).toBeGreaterThanOrEqual(0); + expect(box.x + box.width).toBeLessThanOrEqual(360); + expect(box.y + box.height).toBeLessThanOrEqual(640); + // Its last choice is reachable (by the dialog's own scroll when it must). + const last = dialog.getByRole("radio", { name: "Green", exact: true }); + await last.scrollIntoViewIfNeeded(); + await expect(last).toBeInViewport(); + await last.check(); + await expect.poll(() => html(page)).toMatchObject({ accent: "green" }); + + const { scroll, client } = await page.evaluate(() => ({ + scroll: document.documentElement.scrollWidth, + client: document.documentElement.clientWidth, + })); + expect(scroll).toBeLessThanOrEqual(client); + + // The page behind does not scroll while the dialog is open. + const before = await page.evaluate(() => window.scrollY); + await page.mouse.move(180, 5); + await page.mouse.wheel(0, 800); + await page.waitForTimeout(200); + expect(await page.evaluate(() => window.scrollY)).toBe(before); +}); + +test("the gear's focus: the ring, and in forced colours the browser's own outline, none at rest", async ({ + page, +}) => { + await page.goto("/"); + const gear = optionsButton(page); + const tabTo = async () => { + for (let i = 0; i < 25; i++) { + await page.keyboard.press("Tab"); + if (await gear.evaluate((el) => el === document.activeElement)) return; + } + throw new Error("Tab never reached the gear"); + }; + await tabTo(); + const normal = await gear.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("/"); + expect(await gear.evaluate((el) => getComputedStyle(el).outlineStyle)).toBe("none"); + await tabTo(); + const forced = await gear.evaluate((el) => { + const s = getComputedStyle(el); + return { style: s.outlineStyle, width: parseFloat(s.outlineWidth) }; + }); + expect(forced.style).not.toBe("none"); + expect(forced.width).toBeGreaterThan(0); +}); diff --git a/homepage/e2e/social.spec.ts b/homepage/e2e/social.spec.ts @@ -0,0 +1,340 @@ +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, + writeFixtureSettings, + writeRawFixtureSettings, +} from "./fixture-social"; + +// THE SOCIAL ROW: in the header at every width (at most four links), and in the +// footer (every link). 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, in the bar at every width, followed by the Options gear. It +// shows the LAST n of its (at most four) links by width and pointer +// (Header.tsx): mouse ≥ 768 → 4, 360–767 → 3, < 360 → 2; touch ≥ 768 → 4, +// 390–767 → 3, 340–389 → 2, < 340 → 1. A role query sees only those. +const headerRow = (page: Page) => + page.locator('header [data-social-links="header"]'); +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"); +} + +for (const [width, shown] of [[320, 2], [339, 2], [359, 2], [360, 3], [390, 3], [768, 3], [1280, 3]] as const) { + test(`${width} px: the header's social row is on screen with the wordmark whole, and the page does not scroll sideways`, async ({ + page, + }) => { + await page.setViewportSize({ width, height: 800 }); + await page.goto("/"); + const row = headerRow(page); + await expect(row).toHaveCount(1); + expect(await labelsOf(row)).toEqual(TRIO.slice(-shown)); + + const box = (await row.boundingBox())!; + expect(box.x).toBeGreaterThanOrEqual(0); + expect(box.x + box.width).toBeLessThanOrEqual(width); + expect(box.y).toBeGreaterThanOrEqual(0); + expect(box.y + box.height).toBeLessThanOrEqual(800); + for (const key of await row.getByRole("link").all()) { + const k = (await key.boundingBox())!; + expect(k.width).toBeGreaterThanOrEqual(24); + expect(k.height).toBeGreaterThanOrEqual(24); + expect(k.x + k.width).toBeLessThanOrEqual(width); + } + + // The wordmark: one line, all of it on screen, and nothing drawn over it. + const home = page.getByRole("link", { name: "Archilyzer home" }); + const wm = (await home.boundingBox())!; + expect(wm.x).toBeGreaterThanOrEqual(0); + expect(wm.x + wm.width).toBeLessThanOrEqual(width); + expect(wm.height).toBeLessThanOrEqual(32); + expect( + await home.evaluate((el) => el.scrollWidth <= el.clientWidth + 1), + "the wordmark is clipped", + ).toBe(true); + const clear = box.x >= wm.x + wm.width || box.y >= wm.y + wm.height; + expect(clear, "the row overlaps the wordmark").toBe(true); + await expect(page.getByRole("button", { name: "Options", exact: true })).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 twice (the header, the footer). Every copy's ids +// are its own, and the header's 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: 360, height: 800 }); + await page.goto("/"); + const dupes = await page.evaluate(() => { + const seen = new Map<string, number>(); + 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 }); + + test("the narrow bar under touch: the last one below 340, two to 389, three from 390", async ({ + page, + }) => { + for (const [width, shown] of [[320, 1], [339, 1], [340, 2], [360, 2], [389, 2], [390, 3]] as const) { + await page.setViewportSize({ width, height: 800 }); + await page.goto("/"); + expect(await labelsOf(headerRow(page)), `${width} px`).toEqual(TRIO.slice(-shown)); + await expect(page.getByRole("button", { name: "Options", exact: true })).toBeInViewport(); + await noHorizontalOverflow(page); + } + }); + + test("six links under touch at 360 px: the last two of the header's four", async ({ page }) => { + writeFixtureSettings(settingsFile(), FIXTURE_SOCIAL_SIX); + await page.setViewportSize({ width: 360, height: 800 }); + await page.goto("/"); + expect(await labelsOf(headerRow(page))).toEqual(FIXTURE_SOCIAL_SIX.map((l) => l.label).slice(-2)); + await noHorizontalOverflow(page); + }); + + 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 header shows the last four from md, three below it, two below 360; the footer all six", async ({ + page, +}) => { + writeFixtureSettings(settingsFile(), FIXTURE_SOCIAL_SIX); + const six = FIXTURE_SOCIAL_SIX.map((l) => l.label); + for (const [width, shown] of [[320, 2], [360, 3], [390, 3], [767, 3], [768, 4], [1280, 4]] as const) { + await page.setViewportSize({ width, height: 800 }); + await page.goto("/"); + expect(await labelsOf(headerRow(page)), `${width} px`).toEqual(six.slice(-shown)); + expect(await labelsOf(footerRow(page))).toEqual(six); + await noHorizontalOverflow(page); + } +}); + +// The gear 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 gear 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 page.getByRole("button", { name: "Options", exact: true }).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 for the header: the header shows those two", async ({ page }) => { + writeFixtureSettings(settingsFile(), FIXTURE_SOCIAL_SIX_FEATURED); + await page.goto("/"); + expect(await labelsOf(headerRow(page))).toEqual(["Square", "Bubble"]); + expect(await labelsOf(footerRow(page))).toEqual(FIXTURE_SOCIAL_SIX.map((l) => l.label)); +}); + +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 is not inlined: no script runs, and the link shows its label", async ({ + page, +}) => { + const hostile = FIXTURE_HOSTILE_SVGS.map((svg, i) => ({ + label: `Hostile ${i + 1}`, + url: `https://hostile-${i + 1}.example/`, + svg, + })); + writeRawFixtureSettings(settingsFile(), [...FIXTURE_SOCIAL_TRIO.slice(0, 1), ...hostile]); + 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); +}); diff --git a/homepage/e2e/theme.spec.ts b/homepage/e2e/theme.spec.ts @@ -1,9 +1,10 @@ import { test, expect, type Page } from "@playwright/test"; import { REQUIRED_TOKENS } from "../../common/components/themeConfig"; import { ACCENTS, BASE_GROUNDS } from "../../common/lib/brand"; +import { chooseTheme } from "./helpers"; // The shared theme system (common/styles/tokens.css + ThemeScript + -// ThemeProvider + ThemeToggle/ThemeMenu) as the project's own site uses it: it +// ThemeProvider + the header's Options dialog) as the project's own site uses it: it // opens on the DARK base in Signal, the family's accent. A reader's base // persists across reloads and is applied before hydration (no flash of the // wrong theme). @@ -23,7 +24,7 @@ async function htmlState(page: Page) { }, BASE_KEY); } -test("dark + Signal by default; the base toggle persists with no FOUC", async ({ +test("dark + Signal by default; a base chosen in Options persists with no FOUC", async ({ page, }) => { // The OS says light: the default is still dark (it is the site's, not the @@ -39,16 +40,10 @@ test("dark + Signal by default; the base toggle persists with no FOUC", async ({ brand: ACCENTS.signal.onDark, }); - const toggle = page.getByRole("button", { name: /switch to/i }); - await expect(toggle).toBeVisible(); - - // Cycle to an explicit light base (dark → system → light). A click before - // hydration is lost, so click until it is stored; a click React took commits - // synchronously, so the check never races it into a second one. - await expect(async () => { - if ((await htmlState(page)).stored !== "light") await toggle.click(); - expect((await htmlState(page)).stored).toBe("light"); - }).toPass({ timeout: 10_000 }); + // An explicit Light base, through the header's Options dialog (chooseTheme + // retries the gear until hydration has made it live). + await chooseTheme(page, { base: "Light" }); + await expect.poll(async () => (await htmlState(page)).stored).toBe("light"); let s = await htmlState(page); expect(s.base).toBe("light"); expect(s.dark).toBe(false); diff --git a/homepage/playwright.config.ts b/homepage/playwright.config.ts @@ -1,3 +1,4 @@ +import fs from "node:fs"; import path from "node:path"; import { defineConfig, devices } from "@playwright/test"; import { portFor } from "yt-dlp-transcript-common/lib/ports.mjs"; @@ -5,6 +6,11 @@ import { FIXTURE_SUMMARY_NAME, writeFixtureSummary, } from "./e2e/fixture-summary"; +import { + FIXTURE_SETTINGS_NAME, + FIXTURE_SOCIAL_TRIO, + writeFixtureSettings, +} from "./e2e/fixture-social"; // Homepage e2e. Runs against `next dev` (default mode) so it reflects uncommitted // source. Kill any stale dev server on the port between runs. @@ -20,6 +26,14 @@ import { // summary it reads instead of // public/homepage-summary.json (app/lib/summary.ts, // ignored by a production build) +// SETTINGS_FILE set below on the dev server: a settings.json +// holding only the fixture's social links +// (e2e/fixture-social.ts), never the checkout's +// own — the header and footer render them +// SITES_DIR set below on the dev server: an EMPTY +// directory, so no homepage.json (whose +// socialLinks would win over settings.json's) +// is read from the checkout // E2E_EXPECT_SOURCE `1` (set by whoever runs the suite, after // `archilyzer source publish`): e2e/source.spec.ts // FAILS on the /source/ page's empty state instead @@ -35,6 +49,13 @@ const baseURL = `http://localhost:${PORT}`; const FIXTURE_SUMMARY = path.resolve(process.cwd(), "e2e", FIXTURE_SUMMARY_NAME); writeFixtureSummary(FIXTURE_SUMMARY); +// The same for settings.json: the operator's social links move, and a worktree's +// settings.json is a copy of theirs. The e2e reads the fixture's (the trio). +const FIXTURE_SETTINGS = path.resolve(process.cwd(), "e2e", FIXTURE_SETTINGS_NAME); +writeFixtureSettings(FIXTURE_SETTINGS, FIXTURE_SOCIAL_TRIO); +const FIXTURE_SITES_DIR = path.resolve(process.cwd(), "e2e", ".e2e-sites"); +fs.mkdirSync(FIXTURE_SITES_DIR, { recursive: true }); + export default defineConfig({ testDir: "./e2e", timeout: 30_000, @@ -48,7 +69,11 @@ export default defineConfig({ url: baseURL, timeout: 120_000, reuseExistingServer: !process.env.CI, - env: { E2E_HOMEPAGE_SUMMARY_FILE: FIXTURE_SUMMARY }, + env: { + E2E_HOMEPAGE_SUMMARY_FILE: FIXTURE_SUMMARY, + SETTINGS_FILE: FIXTURE_SETTINGS, + SITES_DIR: FIXTURE_SITES_DIR, + }, }, use: { baseURL,