Files
hanzo.ai/e2e/gates/links.spec.ts
zeekay 7cc5f225ce Require a file, not a directory, for a served link
existsSync answers true for a directory, so a parent that keeps its
children and loses its own index read as served: 26 model pages shipped
three dead links each while the gate that forbids exactly that stayed
green. serveExport one file over already requires a file.
2026-09-01 08:53:07 -07:00

92 lines
4.2 KiB
TypeScript

import { test, expect } from '@playwright/test'
import { existsSync, readFileSync, statSync } from 'node:fs'
import { join } from 'node:path'
import { OUT, pages } from './export'
/**
* A page never sends a reader to an address this site does not serve.
*
* Measured before this gate existed: `/trust` — the page a security reviewer
* reads — offered "The Data Processing Addendum" at `/dpa` and "The
* subprocessor register" at `/subprocessors`, described them as published so
* "a reviewer reads them without asking and without waiting", and both
* addresses 404'd. The documents were real and had been committed for months;
* `content/legal/*.md` held eleven of them and no route rendered any.
*
* Nothing caught it. The build was green because a static export has no idea
* what an `href` means, and every other gate here asks about a page's own
* bytes rather than about where it points. A promise to a reviewer that ends
* in a 404 is worse than no promise, which is why this reads the WHOLE export
* rather than a list of important pages.
*
* Only same-site paths are checked. An external URL is somebody else's uptime
* and a fetch of one would make this suite depend on the network; a fragment
* or a query names the same document either way; and a path ending in a file
* extension is an asset, which `out/` serves directly.
*/
/**
* How a static host resolves a path: a FILE, at one of three names.
*
* A bare `existsSync` answers true for a DIRECTORY, and a directory is the
* commonest way an export drops a page — a parent that keeps its children and
* loses its own index. `/models/~anthropic` is exactly that: the folder holds
* the model pages, no `~anthropic.html` is written, and the host 404s while this
* gate called it served. Twenty-six pages shipped three dead links each under a
* green gate that says "a page never sends a reader to an address this site does
* not serve". `serveExport()` one file over already requires a file, and records
* the same miss for /api.
*/
const isFile = (p: string): boolean => existsSync(p) && statSync(p).isFile()
function served(url: string): boolean {
const base = join(OUT, url)
return isFile(base) || isFile(base + '.html') || isFile(join(base, 'index.html'))
}
function internalLinks(html: string): string[] {
return [...html.matchAll(/href="(\/[^"#?]*)"/g)]
.map((m) => m[1].replace(/\/+$/, ''))
.filter((u) => u !== '' && !u.startsWith('//') && !/\.[a-z0-9]{2,5}$/i.test(u))
}
test('no page links to an address the export does not serve', () => {
const shipped = pages()
expect(shipped.length).toBeGreaterThan(500)
const dangling = new Map<string, Set<string>>()
for (const { route, file } of shipped) {
for (const url of internalLinks(readFileSync(file, 'utf8'))) {
if (served(url)) continue
if (!dangling.has(url)) dangling.set(url, new Set())
dangling.get(url)!.add(route)
}
}
const report = [...dangling.entries()]
.sort((a, b) => b[1].size - a[1].size)
.map(([url, from]) => ` ${url} <- ${[...from].slice(0, 4).join(', ')}${from.size > 4 ? ` (+${from.size - 4} more)` : ''}`)
expect(report.join('\n') || 'none', `addresses linked but not served:\n${report.join('\n')}`).toBe('none')
})
/**
* The legal documents are the reason the gate above exists, so they are also
* asserted directly: every file in `content/legal` is reachable, and each says
* whether it is in force. A document a reviewer relies on without knowing it
* is a draft is the failure /trust is written to avoid.
*/
test('every legal document is served, and says whether it is effective', () => {
const index = join(OUT, 'legal.html')
expect(existsSync(index), '/legal is not served').toBe(true)
const docs = ['acceptable-use', 'biometric-voice', 'consumer-health', 'cookies', 'dpa',
'privacy', 'research-api', 'research-contribution', 'subprocessors', 'terms']
for (const slug of docs) {
const file = join(OUT, 'legal', `${slug}.html`)
expect(existsSync(file), `/legal/${slug} is not served`).toBe(true)
const html = readFileSync(file, 'utf8')
expect(html.length, `/legal/${slug} shipped nearly empty`).toBeGreaterThan(4000)
expect(/not yet effective|Version /.test(html), `/legal/${slug} states no effective date`).toBe(true)
}
})