Files
zeekay 55520311de The whole gates suite runs on four shards at two workers, and its log is the report
603 tests hold some 48 minutes of browser time (2,908 test-seconds measured
here), so one runner at one worker spent its 45 minutes and was cancelled
with nothing printed: the html reporter writes at the end (run 38753765551).

- The full run is four jobs, Playwright's --shard, two workers each; what
  changed still runs on one. Each shard builds its own export: no artifact,
  for the quota reason the file already gives.
- No .next/cache in gates: 1 GB that took up to six minutes to unpack, for
  about half a minute of compile.
- On CI the reporters are list (each test as it ends, every failure at the
  end, so a cut run still shows what finished) and json, which each job turns
  into its counts and failures on the run's summary.
- The 390px walk over every exported page is eight tests of every eighth
  page, so the shards take a part apiece instead of one worker walking 1,077
  pages.
2026-10-15 09:48:10 -07:00

164 lines
6.8 KiB
TypeScript

import { test, expect } from '@playwright/test'
import { readdirSync } from 'node:fs'
import { join } from 'node:path'
import { OUT, requireExport, serveExport } from './export'
/**
* No page hides content off the side of a phone.
*
* @hanzo/design's base.css sets `overflow-x: clip` on html AND body, so the page genuinely
* cannot scroll sideways — which means content wider than the viewport is not
* awkward, it is UNREACHABLE. There is no gesture that brings it back.
*
* This lived in `e2e/mobile-export.spec.ts`, outside the gates project, so it
* never ran in CI — and while it was out there it could not have caught
* anything anyway. It measured `documentElement.scrollWidth - clientWidth` and
* `window.scrollX`, and `overflow-x: clip` pins both constant: injecting a
* 1400px-wide div into a real exported page left scrollWidth at 390 and scrollX
* at 0. The fix that makes the invariant true is the same fix that blinded the
* test for it. Once it could fail it immediately found three pages — a tab
* strip, a chat panel, and a hero's code column — with text outside the
* viewport, each a flex or grid item whose default `min-width: auto` refused to
* shrink below its content.
*
* So it belongs here, against `out/`, gating the deploy. It costs ~40s for the
* whole export.
*
* Two things it deliberately does NOT flag, both learned by measuring:
*
* Decoration. /about's 800px radial glow is centred with -translate-x-1/2 and
* sits 205px past the edge BY CONSTRUCTION. A reader loses nothing, so only
* text-bearing elements count. 8px of slack absorbs sub-pixel rounding and the
* `whileInView` x:20 enter animation, which is briefly wider on purpose.
*
* Content inside a horizontally scrollable ancestor. That is the sanctioned
* pattern — wide things scroll in their own container — and condemning it
* reported ~40 pages broken for doing exactly the right thing.
*/
const VIEWPORT = { width: 390, height: 844 }
/** Every page in the export that ships. */
function routes(): string[] {
requireExport()
const out: string[] = []
const walk = (dir: string) => {
for (const e of readdirSync(dir, { withFileTypes: true })) {
const p = join(dir, e.name)
if (e.isDirectory()) walk(p)
else if (e.name.endsWith('.html')) out.push('/' + p.replace(OUT + '/', ''))
}
}
walk(OUT)
return out.sort()
}
/**
* The walk is cut into PARTS, every PARTS-th page of the sorted list each, so the
* workers and the CI shards take a part apiece and the wall time falls as the
* site grows instead of one serial loop holding a worker for the whole export
* (1,077 pages, 287s of one worker on a loaded box). A part is a test of its own;
* together they are every page, and a page belongs to exactly one.
*/
const PARTS = 8
for (let part = 0; part < PARTS; part++) {
test(`no exported page hides text off the side at 390px (part ${part + 1} of ${PARTS})`, async ({ page }) => {
const pages = routes().filter((_, i) => i % PARTS === part)
// The budget is the part's size, not a number somebody picked.
//
// A walk loads each of its pages once, serially, so its cost is a function of
// the export and a constant cap can only ever be right for one size of site.
// Measured on a 970-page export the whole walk took 905s (0.93s a page) with
// the box to itself, and failing on the CLOCK reads in CI as a layout
// regression and is not one.
//
// Two seconds a page is roughly twice the worst rate measured here, which is the
// headroom a slower or busier CI box needs; the same walk has also been seen at
// 0.40s a page, so the rate is not stable enough to budget tightly. The floor
// is two minutes beside the gates' own envelope.
test.setTimeout(Math.max(3 * 60 * 1000, pages.length * 2000 + 120_000))
expect(pages.length, 'a part with no pages measures nothing').toBeGreaterThan(0)
const server = await serveExport()
try {
await page.setViewportSize(VIEWPORT)
const bad: string[] = []
const forwarded: string[] = []
for (const route of pages) {
let landed = false
for (let i = 0; i < 4 && !landed; i++) {
try {
await page.goto(server.url + route, { waitUntil: 'domcontentloaded' })
landed = true
} catch {
await page.waitForTimeout(200)
}
}
if (!landed) {
bad.push(`${route} NAVIGATION FAILED`)
continue
}
let worst: number | null = null
try {
worst = await page.evaluate(() => {
const d = document.documentElement
const prevHtml = d.style.overflowX
const prevBody = document.body.style.overflowX
// Un-clip, or every measurement below is a constant.
d.style.overflowX = 'visible'
document.body.style.overflowX = 'visible'
void d.offsetWidth
const vw = d.clientWidth
const reachable = (el: Element) => {
for (let p = el.parentElement; p; p = p.parentElement) {
const ox = getComputedStyle(p).overflowX
if ((ox === 'auto' || ox === 'scroll') && p.scrollWidth > p.clientWidth + 1) return true
}
return false
}
const SLACK = 8
let max = 0
for (const el of Array.from(document.querySelectorAll('*'))) {
if (!(el.textContent || '').trim()) continue
const r = el.getBoundingClientRect()
if (r.width <= 0) continue
const past = Math.round(r.right - vw)
if (past > SLACK && past > max && !reachable(el)) max = past
}
d.style.overflowX = prevHtml
document.body.style.overflowX = prevBody
return max
})
} catch (e) {
// A redirect stub (/status, /login, /signup forward on hydration)
// destroys the execution context. Left unhandled this threw out of the
// loop and ONE such page ended the whole sweep, reporting a single
// failure that read like a layout bug. A page that leaves cannot hide
// anything; skip it, but say so — silent skips shrink coverage while
// still reporting green.
if (!/Execution context was destroyed|navigation/i.test(String(e))) throw e
forwarded.push(route)
continue
}
if (worst > 0) bad.push(`${route} — ${worst}px of text outside the viewport`)
}
if (forwarded.length) {
console.log(`skipped ${forwarded.length} forwarding page(s): ${forwarded.join(', ')}`)
}
expect(
bad,
`pages hiding text off-screen at 390px (overflow-x: clip means it is unreachable):\n${bad.join('\n')}`,
).toEqual([])
} finally {
await server.close()
}
})
}