Files
hanzo.ai/e2e/gates/mobile.spec.ts
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()
}
})
}