Files
hanzo.ai/e2e/gates/export.ts
zeekay 82ba1b471f gates: the export server closes at once; two gates wait for what they measure
A gate that closed the export server with its page still open waited on that
page's keep-alive connections: server.close() resolves only once they drop,
measured at 10-12 s on /board, which spent thumbrooms', workspace's and the
corner's budget and failed them on CI with no step named. close() now drops
the connections too.

The composer's flash check measured before React owned the page: the export's
controls carry no transition until gui's runtime styles land at hydration
(about 0.5 s), so it read `none` one run in three. It waits for
html[data-hydrated] first; every assertion stands.

The support-session gate allows one identify, the operator's own on the
link's page before the hop to hanzo.id: that page is the operator's browser
signed in as itself, and whether its identify flushes before the page leaves
is a race. Once the support session lands nothing is identified, the person
or anyone, and the hint still goes nowhere.

Claude-Session: https://claude.ai/code/session_01BLLMyBFSvAfCt7ZGqYuiEz
2026-10-22 13:05:18 -07:00

209 lines
8.9 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { createServer, type Server } from 'node:http'
import { createReadStream, existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
import { createBrotliCompress, constants } from 'node:zlib'
import { extname, join, normalize, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { routes, shipped, sources, type Page } from '../../lib/routes'
import { APP as PAGES, FRAME, NOREFERRER, framed } from '../../lib/edge'
/**
* The real static export, as the tests' subject.
*
* These gates assert against `out/` — the bytes that ship — and not against the
* source that produced them. Both defects they exist to catch were invisible in
* the source: `lineHeight={1.1}` reads correctly and compiles to
* `line-height: 1.1px`, and a page absent from the nav is still written into
* sitemap.xml. A gate that reads the source would have passed on both.
*
* There is no fallback if `out/` is missing. A gate that skips itself when its
* subject is absent is not a gate.
*/
/** The repo root, found rather than counted in `..`s, so moving a spec cannot break it. */
function root(): string {
let dir = fileURLToPath(new URL('.', import.meta.url))
for (let up = 0; up < 8; up++) {
if (existsSync(join(dir, 'package.json'))) return dir
dir = resolve(dir, '..')
}
throw new Error('no package.json above the test file')
}
export const ROOT = root()
export const OUT = join(ROOT, 'out')
export const APP = join(ROOT, 'app')
/**
* The floor under every gate: the subject is the WHOLE site.
*
* `output: export` writing a short `out/` is the failure mode that silently
* blanks hanzo.ai, and it is worse than a blank page here — every assertion in
* this suite is of the form "no page in the export does X", and each of those
* is trivially true of an export with three pages in it. A truncated subject
* does not fail a gate; it disarms the whole suite and reports green. So the
* floor is stated once, loudly, and it is the export's own size against the
* tree that produced it: every addressable route must have shipped a page.
*/
export function requireExport(): string {
if (!existsSync(join(OUT, 'index.html'))) {
throw new Error(`no static export at ${OUT}: run \`pnpm build\` before \`pnpm test\``)
}
const answered = new Set(shipped(OUT).map(({ route }) => route))
const missing = routes(APP)
.map(({ path }) => path)
.filter((route) => !answered.has(route))
if (missing.length > 0) {
throw new Error(
`the export is short ${missing.length} of ${answered.size + missing.length} routes, so every ` +
`gate below it would measure a smaller site and pass: ${missing.slice(0, 10).join(', ')}`,
)
}
return OUT
}
/**
* The routes built on `components/marketing/page-kit`, read from the tree.
*
* Derived, never listed. A spec that names its pages measures the pages
* somebody remembered, and the page most worth measuring is always the newest
* one. A route counts when any source in its own directory reaches the kit —
* `/risk` renders it through `risk-client.tsx`, so scanning `page.tsx` alone
* would miss it.
*/
export function kit(): string[] {
return routes(APP)
.filter((route) => sources(route).some((file) => readFileSync(file, 'utf8').includes(KIT)))
.map(({ path }) => path)
}
const KIT = 'components/marketing/page-kit'
/** Read one exported file, failing loudly rather than returning empty. */
export function read(relative: string): string {
const path = join(requireExport(), relative)
if (!existsSync(path)) throw new Error(`the export has no ${relative}`)
return readFileSync(path, 'utf8')
}
/**
* Every route the export ships a page for, with the file that answers it.
*
* The subject of a gate over publication is the whole site, not one page a
* spec happened to name: an assertion pinned to `risk.html` says nothing about
* the next route added to a list. The walk is `lib/routes`'s — the same one
* `scripts/noindex.mjs` stamps through, so what a gate measures and what the
* build writes cannot disagree about which file answers which route.
*
* `pages()` is that walk WITH the floor above it, and it is the one every spec
* uses; the floor itself has to read the export to measure it, so it calls the
* walk directly rather than calling this and recursing.
*/
export function pages(): Page[] {
requireExport()
return shipped(OUT)
}
const TYPES: Record<string, string> = {
'.html': 'text/html; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.json': 'application/json',
'.svg': 'image/svg+xml',
'.woff2': 'font/woff2',
'.txt': 'text/plain; charset=utf-8',
'.xml': 'application/xml',
'.ico': 'image/x-icon',
'.png': 'image/png',
}
/**
* Serve `out/` on an ephemeral port.
*
* The export references `/_next/…` by absolute path, so `file://` cannot load a
* stylesheet and every computed style would come back from an unstyled
* document — which is to say the test would measure nothing and pass.
*/
/** The ports above 1023 Chromium refuses to load (net/base/port_util.cc, kRestrictedPorts). */
const UNSAFE = new Set([1719, 1720, 1723, 2049, 3659, 4045, 4190, 5060, 5061, 6000, 6566, 6665, 6666, 6667, 6668, 6669, 6679, 6697, 10080])
export async function serveExport(
options: { compress?: boolean } = {},
): Promise<{ url: string; close: () => Promise<void> }> {
const root = requireExport()
const server: Server = createServer((req, res) => {
// Every path under /dev is Dev's one page and every conversation under /chat
// Chat's, as the edge answers them (lib/edge.ts `APP`).
const raw = (req.url ?? '/').split('?')[0]
const page = PAGES.find((r) => new RegExp(r.regex).test(raw))
const rel = normalize(page ? page.replacement : decodeURIComponent(raw)).replace(/^(\.\.[/\\])+/, '')
const base = join(root, rel)
// `out/api.html` AND `out/api/` both exist — the second holds the nested
// routes and has no index of its own. Resolving the directory first is how
// /api came back 404 and every computed style was measured on an unstyled
// error page, which is a test that measures nothing and passes.
const file = [base, base + '.html', join(base, 'index.html')].find(
(p) => existsSync(p) && statSync(p).isFile(),
)
if (!file) {
res.writeHead(404).end('not found')
return
}
const type = TYPES[extname(file)] ?? 'application/octet-stream'
// The headers the edge adds by path (lib/edge.ts), so a gate measures what a
// reader's browser is told and not only the bytes.
const at = rel.replace(/\\/g, '/')
const headers: Record<string, string> = {
...(at.startsWith(NOREFERRER.prefix) ? NOREFERRER.headers : {}),
...(framed(at) ? FRAME.headers : {}),
}
// Brotli for what the edge would compress, so a measured byte count is the
// one a reader downloads. Off by default: most gates read text, not bytes.
const squeeze = options.compress && /^(text\/|application\/(json|xml)|image\/svg)|javascript/.test(type)
if (squeeze && /\bbr\b/.test(String(req.headers['accept-encoding']))) {
res.writeHead(200, { ...headers, 'content-type': type, 'content-encoding': 'br' })
createReadStream(file)
.pipe(createBrotliCompress({ params: { [constants.BROTLI_PARAM_QUALITY]: 6 } }))
.pipe(res)
return
}
res.writeHead(200, { ...headers, 'content-type': type })
createReadStream(file).pipe(res)
})
// An OS whose ephemeral range starts at 1024 can hand out a port Chromium
// refuses (ERR_UNSAFE_PORT), so one of those is given back and another asked for.
let port = 0
do {
if (port) await new Promise<void>((ok) => server.close(() => ok()))
await new Promise<void>((ok) => server.listen(0, '127.0.0.1', ok))
port = (server.address() as { port: number }).port
} while (UNSAFE.has(port))
return {
url: `http://127.0.0.1:${port}`,
// A gate that closes the server with its page still open would otherwise wait on
// that page's keep-alive connections: `close` alone resolves only once they drop,
// measured at 10–12 s on /board, which spent the test's budget with no step named.
close: () =>
new Promise<void>((ok) => {
server.close(() => ok())
server.closeAllConnections()
}),
}
}
/**
* Serves the export at `origin` inside `page`, from `local` (serveExport's
* address), so a gate measures a secure page on the real host — its secure
* context, its `__Host-` cookies, its brand — as a reader there gets it. Only
* that origin is answered from `out/`; every other request goes where it would.
*/
export async function secure(
page: import('@playwright/test').Page,
local: string,
origin = 'https://hanzo.ai',
): Promise<string> {
await page.route(`${origin}/**`, async (route) => {
const u = new URL(route.request().url())
await route.fulfill({ response: await route.fetch({ url: local + u.pathname + u.search }) })
})
return origin
}