mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-30 14:58:08 +00:00
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.
164 lines
6.8 KiB
TypeScript
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()
|
|
}
|
|
})
|
|
}
|