From cb8aaa4031eea7fc70b8eb684653e17ca3643bfd Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 1 Nov 2026 19:04:47 +0100 Subject: [PATCH] test: check each page's first screen without scripts and its layout shift --- package.json | 1 + .../boot-skeleton-guard.spec.ts | 208 ++++++++++++++++++ .../boot-skeleton-guard/playwright.config.ts | 50 +++++ vitest.config.ts | 2 +- 4 files changed, 260 insertions(+), 1 deletion(-) create mode 100644 scripts/boot-skeleton-guard/boot-skeleton-guard.spec.ts create mode 100644 scripts/boot-skeleton-guard/playwright.config.ts diff --git a/package.json b/package.json index 5adc7a774..5ccbc9782 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "test:lite-smoke:stalwart": "playwright test -c scripts/lite/smoke/playwright.stalwart.config.ts", "test:perf-guard": "playwright test -c scripts/perf-guard/playwright.config.ts", "test:a11y-guard": "playwright test -c scripts/a11y-guard/playwright.config.ts", + "test:boot-skeleton-guard": "playwright test -c scripts/boot-skeleton-guard/playwright.config.ts", "start": "node scripts/start.mjs", "lint": "eslint .", "lint:fix": "eslint . --fix", diff --git a/scripts/boot-skeleton-guard/boot-skeleton-guard.spec.ts b/scripts/boot-skeleton-guard/boot-skeleton-guard.spec.ts new file mode 100644 index 000000000..77a3c659d --- /dev/null +++ b/scripts/boot-skeleton-guard/boot-skeleton-guard.spec.ts @@ -0,0 +1,208 @@ +import { test, expect, type Page, type BrowserContext } from "@playwright/test"; + +/** + * Page skeletons from the first paint. See playwright.config.ts. + */ + +type Seed = Record; + +const settings = (state: Record): Seed => ({ + "settings-storage": JSON.stringify({ state, version: 12 }), +}); +/** A stored session, so the skeleton is the page's and not the login card. */ +const SIGNED_IN: Seed = { + "auth-storage": JSON.stringify({ state: { isAuthenticated: true }, version: 0 }), +}; + +interface Case { + path: string; + /** The skeleton that must be the visible one. */ + surface: string; + seed?: Seed; + viewport?: { width: number; height: number }; + /** Attributes the boot script must have set on . */ + attrs?: Record; +} + +const PHONE = { width: 390, height: 844 }; +const TABLET = { width: 820, height: 1180 }; + +const CASES: Case[] = [ + { path: "/en", surface: "mail", attrs: { "data-boot-mail-layout": "split" } }, + { path: "/en/mail/folder/inbox", surface: "mail" }, + { path: "/en/mail/folder/inbox", surface: "mail", seed: settings({ mailLayout: "horizontal" }), attrs: { "data-boot-mail-layout": "horizontal" } }, + { path: "/en/mail/folder/inbox", surface: "mail", seed: settings({ mailLayout: "focus", density: "compact" }), attrs: { "data-boot-mail-layout": "focus" } }, + { path: "/en/mail/folder/inbox", surface: "mail", seed: settings({ interfaceLayout: "mountain-view" }), attrs: { "data-interface-layout": "mountain-view" } }, + { path: "/en/mail/message/x1", surface: "mail", attrs: { "data-boot-pane": "message" } }, + { path: "/en/mail/thread/x1", surface: "mail", viewport: PHONE, attrs: { "data-boot-pane": "thread" } }, + { path: "/en/mail/folder/inbox", surface: "mail", viewport: TABLET }, + { path: "/ar/mail/folder/inbox", surface: "mail" }, + { path: "/en/calendar/month/2026-11-04", surface: "calendar", attrs: { "data-boot-calendar-view": "month" } }, + { path: "/en/calendar/week/2026-11-04", surface: "calendar", attrs: { "data-boot-calendar-view": "week" } }, + { path: "/en/calendar/day/2026-11-04", surface: "calendar", attrs: { "data-boot-calendar-view": "day" } }, + { path: "/en/calendar/agenda/2026-11-04", surface: "calendar", attrs: { "data-boot-calendar-view": "agenda" } }, + { path: "/en/calendar/tasks/2026-11-04", surface: "calendar", attrs: { "data-boot-calendar-view": "tasks" } }, + { path: "/en/calendar/event/e1", surface: "calendar" }, + { path: "/en/calendar/week/2026-11-04", surface: "calendar", viewport: PHONE }, + { path: "/en/contacts", surface: "contacts" }, + { path: "/en/contacts/c1", surface: "contacts", viewport: PHONE, attrs: { "data-boot-pane": "detail" } }, + { path: "/en/files/Documents", surface: "files" }, + { path: "/en/apps/some-plugin/page", surface: "apps" }, + { path: "/en/settings", surface: "settings" }, + { path: "/en/settings/appearance", surface: "settings", attrs: { "data-boot-settings-tab": "appearance" } }, + { path: "/en/settings/themes", surface: "settings", attrs: { "data-boot-settings-tab": "appearance" } }, + { path: "/en/settings/filters", surface: "settings", viewport: PHONE, attrs: { "data-boot-pane": "detail" } }, + { path: "/en/pro", surface: "pro", seed: settings({ proInterface: true }) }, + { path: "/en/mail/folder/inbox", surface: "pro", seed: settings({ proInterface: true }), attrs: { "data-boot-pro": "on" } }, + { path: "/admin", surface: "admin" }, + { path: "/en/login", surface: "login" }, + { path: "/connector/calendar?view=week", surface: "calendar", attrs: { "data-boot-calendar-view": "week" } }, + { path: "/connector/settings?tab=filters", surface: "settings", attrs: { "data-boot-settings-tab": "filters" } }, + { path: "/connector/app", surface: "mail" }, + { path: "/connector/contacts", surface: "contacts" }, + { path: "/connector/files", surface: "files" }, + { path: "/protocol/mailto?url=mailto%3Aa%40example.com", surface: "mail" }, + { path: "/protocol/webcal?url=webcal%3A%2F%2Fexample.com%2Fa.ics", surface: "calendar" }, +]; + +async function seedStorage(context: BrowserContext, items: Seed) { + await context.addInitScript((seed) => { + if (sessionStorage.getItem("__boot_guard_seeded")) return; + localStorage.clear(); + for (const [k, v] of Object.entries(seed)) localStorage.setItem(k, v); + sessionStorage.setItem("__boot_guard_seeded", "1"); + }, items); +} + +/** What a visitor sees: visible skeleton regions, spinners and text. */ +async function firstScreen(page: Page) { + return page.evaluate(() => { + const shown = (el: Element) => { + if (el.closest(".sr-only")) return false; + const r = el.getBoundingClientRect(); + if (r.width === 0 || r.height === 0) return false; + const s = getComputedStyle(el); + return s.visibility !== "hidden" && s.display !== "none"; + }; + const regions = [...document.querySelectorAll("[data-boot-skeleton]")].filter(shown).map((el) => el.getAttribute("data-boot-skeleton")); + const spinners = [...document.querySelectorAll(".animate-spin")].filter(shown).length; + const texts: string[] = []; + const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT); + for (let n = walker.nextNode(); n; n = walker.nextNode()) { + const text = n.textContent?.trim(); + if (text && n.parentElement && shown(n.parentElement)) texts.push(text); + } + const attrs: Record = {}; + for (const a of document.documentElement.attributes) attrs[a.name] = a.value; + return { regions, spinners, text: texts.join(" "), attrs }; + }); +} + +test.describe("server HTML without JavaScript", () => { + for (const c of CASES) { + const name = `${c.path}${c.viewport ? ` @${c.viewport.width}` : ""}${c.seed ? ` ${Object.values(c.seed).join(" ").slice(0, 60)}` : ""}`; + test(name, async ({ browser }) => { + const context = await browser.newContext({ viewport: c.viewport ?? { width: 1440, height: 900 } }); + await seedStorage(context, { ...SIGNED_IN, ...(c.seed ?? {}) }); + const page = await context.newPage(); + await page.route(/\.js(\?|$)/, (route) => route.abort()); + await page.goto(c.path, { waitUntil: "load" }); + const screen = await firstScreen(page); + expect(screen.regions, `visible skeletons on ${c.path}`).toEqual([c.surface]); + expect(screen.spinners, "spinners").toBe(0); + expect(screen.text, "visible text").not.toMatch(/loading|opening/i); + for (const [name, value] of Object.entries(c.attrs ?? {})) expect(screen.attrs[name], name).toBe(value); + await context.close(); + }); + } + + test("a visitor with nothing stored sees the login card's shape", async ({ browser }) => { + for (const path of ["/en/mail/folder/inbox", "/en/calendar/week/2026-11-04", "/en/settings"]) { + const context = await browser.newContext(); + await seedStorage(context, {}); + const page = await context.newPage(); + await page.route(/\.js(\?|$)/, (route) => route.abort()); + await page.goto(path, { waitUntil: "load" }); + const screen = await firstScreen(page); + expect(screen.regions, path).toEqual(["login"]); + expect(screen.attrs["data-boot-auth"]).toBe("out"); + await context.close(); + } + }); +}); + +/** Mock sign-in; the session is remembered, so fresh page loads restore it. */ +async function signIn(page: Page) { + await page.goto("/en/login"); + await page.getByRole("button", { name: /^sign in$/i }).first().click(); + await page.locator('[data-tour="email-list"]').waitFor({ timeout: 120_000 }); + await page.waitForTimeout(1500); +} + +/** Records every layout shift from the start of each page load. */ +async function recordShifts(context: BrowserContext) { + await context.addInitScript(() => { + const w = window as unknown as { __shifts: { value: number; at: number; nodes: string[] }[] }; + w.__shifts = []; + try { + new PerformanceObserver((list) => { + for (const e of list.getEntries() as (PerformanceEntry & { value: number; hadRecentInput: boolean; sources?: { node?: Node }[] })[]) { + if (e.hadRecentInput) continue; + w.__shifts.push({ + value: e.value, + at: Math.round(e.startTime), + nodes: (e.sources ?? []).map((s) => { + const el = s.node as Element | undefined; + return el && el.nodeType === 1 ? `${el.tagName.toLowerCase()}.${String(el.className).slice(0, 80)}` : String(s.node?.nodeName); + }), + }); + } + }).observe({ type: "layout-shift", buffered: true }); + } catch { /* no layout-shift support */ } + }); +} + +/** Waits until no skeleton shape is on screen any more, then a little longer. */ +async function waitLoaded(page: Page) { + await page.waitForFunction(() => { + const visible = [...document.querySelectorAll("[data-boot-skeleton], .sk-shape")].some((el) => el.getClientRects().length > 0); + return !visible; + }, null, { timeout: 60_000, polling: 100 }).catch(() => {}); + await page.waitForTimeout(1500); +} + +test("layout shift from navigation to the loaded page stays at or under 0.01", async ({ browser }) => { + const context = await browser.newContext({ viewport: { width: 1440, height: 900 } }); + await recordShifts(context); + const page = await context.newPage(); + await signIn(page); + // A real message and thread from the mock mailbox. + await page.locator('[data-tour="email-list"] [role="row"]').first().click(); + await page.waitForURL(/\/mail\/(message|thread)\//, { timeout: 30_000 }).catch(() => {}); + const messagePath = new URL(page.url()).pathname; + + const routes = [ + "/en/mail/folder/inbox", + messagePath, + "/en/calendar/month/2026-11-04", + "/en/calendar/week/2026-11-04", + "/en/calendar/day/2026-11-04", + "/en/calendar/agenda/2026-11-04", + "/en/contacts", + "/en/files", + "/en/settings/appearance", + "/en/settings/filters", + ]; + const over: string[] = []; + for (const route of routes) { + await page.goto(route, { waitUntil: "commit" }); + await waitLoaded(page); + const shifts = await page.evaluate(() => (window as unknown as { __shifts: { value: number; at: number; nodes: string[] }[] }).__shifts); + const total = shifts.reduce((sum, s) => sum + s.value, 0); + if (total > 0.01) { + over.push(`${route}: CLS ${total.toFixed(4)}\n` + shifts.map((s) => ` ${s.at}ms ${s.value.toFixed(4)} ${s.nodes.join(", ")}`).join("\n")); + } + } + await context.close(); + expect(over, over.join("\n")).toEqual([]); +}); diff --git a/scripts/boot-skeleton-guard/playwright.config.ts b/scripts/boot-skeleton-guard/playwright.config.ts new file mode 100644 index 000000000..eecbdd7a4 --- /dev/null +++ b/scripts/boot-skeleton-guard/playwright.config.ts @@ -0,0 +1,50 @@ +import { defineConfig } from "@playwright/test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +// Page skeletons from the first paint (components/boot-skeleton). Two checks +// against the same production build as the perf and accessibility guards +// (`DEV_MOCK_JMAP=true npx next build --webpack`): +// +// - with every script request blocked, each route's server HTML shows the +// skeleton of the right page and no spinner or "Loading" text; +// - from navigation to the loaded page, the layout shift stays at or under +// 0.01 on every route. +// +// DEV_MOCK_JMAP=true npx next build --webpack +// npm run test:boot-skeleton-guard +// BOOT_GUARD_BASE_URL=http://localhost:3100 npm run test:boot-skeleton-guard # against a running server +const port = Number(process.env.BOOT_GUARD_PORT || 3152); +const externalBaseUrl = (process.env.BOOT_GUARD_BASE_URL ?? "").replace(/\/+$/, ""); +const adminDir = mkdtempSync(join(tmpdir(), "boot-guard-")); + +export default defineConfig({ + testDir: ".", + testMatch: /boot-skeleton-guard\.spec\.ts$/, + timeout: 600_000, + retries: 0, + workers: 1, + reporter: process.env.CI ? [["list"]] : "list", + use: { + baseURL: externalBaseUrl || `http://localhost:${port}`, + locale: "en-US", + viewport: { width: 1440, height: 900 }, + }, + projects: [{ name: "chromium", use: { browserName: "chromium" } }], + webServer: externalBaseUrl ? undefined : { + command: `npx next start -p ${port}`, + port, + reuseExistingServer: !process.env.CI, + cwd: process.cwd(), + timeout: 120_000, + env: { + DEV_MOCK_JMAP: "true", + JMAP_SERVER_URL: `http://localhost:${port}/api/dev-jmap`, + SESSION_SECRET: "boot-guard-session-secret-0123456789abcdef", + ADMIN_CONFIG_DIR: join(adminDir, "config"), + ADMIN_STATE_DIR: join(adminDir, "state"), + NEXT_TELEMETRY_DISABLED: "1", + }, + }, +}); diff --git a/vitest.config.ts b/vitest.config.ts index 1d0dd83b5..4492f030e 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -15,7 +15,7 @@ export default defineConfig({ // must be globbed at any depth or the nested repos/*/node_modules trees // get collected too. // scripts/lite/smoke/** is the Playwright smoke for the static Lite export. - exclude: ['integration/**', 'examples/**', 'repos/**', '**/node_modules/**', '.next/**', 'out/**', 'scripts/lite/smoke/**', 'scripts/perf-guard/**', 'scripts/a11y-guard/**'], + exclude: ['integration/**', 'examples/**', 'repos/**', '**/node_modules/**', '.next/**', 'out/**', 'scripts/lite/smoke/**', 'scripts/perf-guard/**', 'scripts/a11y-guard/**', 'scripts/boot-skeleton-guard/**'], // The default node_modules/.vitest-cache sits inside the tree Turbopack // watches, so every test run made a running `next dev` rebuild and reload // the open page about once a second.