morphit/apps/web/scripts/static-asset-link-reload-smoke.ts

131 lines
4.5 KiB
TypeScript

#!/usr/bin/env tsx
/**
* static-asset-link-reload-smoke — every same-origin <a> that points at
* a static file (a literal href like "/morphit-mediakit.zip") MUST carry
* `data-sveltekit-reload` (or `rel="external"`).
*
* Why: under adapter-static the SvelteKit client router intercepts
* same-origin <a> clicks and tries to resolve them as app routes. A
* file URL like `/pgp_keys.asc` has no matching route — and because the
* top-level segment is the unmatched `[lang]` param, the router treats
* "pgp_keys.asc" as a locale, redirects to `/<lang>/pgp_keys.asc`, and
* renders a 404 instead of letting the browser fetch the actual file
* from the static server. `data-sveltekit-reload` forces a real browser
* navigation so the static asset is served directly.
*
* This is the beta.13 footer-link bug: the mediakit and pgp_keys.asc
* links 404'd with a spurious `/<lang>/` prefix until the attribute was
* added. The same latent bug existed on the security and
* about-this-instance pages. This smoke prevents it recurring.
*
* Scope: literal `href="/<path>.<ext>"` anchors where <ext> is a static
* file type the app ships (zip|asc|txt|xml|pdf|json). Off-origin links
* (`http(s)://…`, `//…`) and `lp()`/dynamic hrefs are not in scope.
*
* Scenarios:
* 1. Every in-scope static-asset anchor includes data-sveltekit-reload
* (or rel="external").
* 2. Sanity meta-check: at least one in-scope anchor was found
* (catches a refactor that drops all of them, which would make
* scenario 1 vacuously pass).
*
* Usage:
* tsx apps/web/scripts/static-asset-link-reload-smoke.ts
*/
import { readFileSync, readdirSync, existsSync } from 'node:fs';
import { join, relative } from 'node:path';
const REPO_ROOT = join(import.meta.dirname, '..', '..', '..');
const WEB_SRC = join(REPO_ROOT, 'apps', 'web', 'src');
// Static file extensions that must bypass the SPA router.
const STATIC_EXT = ['zip', 'asc', 'txt', 'xml', 'pdf', 'json'];
const HREF_RE = new RegExp(`href="/[^"]+\\.(?:${STATIC_EXT.join('|')})"`);
interface ScenarioResult {
readonly name: string;
readonly ok: boolean;
readonly detail?: string;
}
const results: ScenarioResult[] = [];
/** Recursively collect *.svelte files under a directory. */
function collectSvelte(root: string): string[] {
if (!existsSync(root)) return [];
const out: string[] = [];
for (const ent of readdirSync(root, { withFileTypes: true })) {
const full = join(root, ent.name);
if (ent.isDirectory()) {
if (ent.name === 'node_modules' || ent.name.startsWith('.')) continue;
out.push(...collectSvelte(full));
} else if (ent.name.endsWith('.svelte')) {
out.push(full);
}
}
return out;
}
/** Match each <a …> opening tag in a blob (attributes may span lines).
* Attribute values here never contain '>', so `[^>]*` is safe. */
function anchorTags(text: string): string[] {
return text.match(/<a\b[^>]*>/g) ?? [];
}
function main(): void {
const files = collectSvelte(WEB_SRC);
let inScopeAnchors = 0;
const violations: string[] = [];
for (const file of files) {
const text = readFileSync(file, 'utf8');
for (const tag of anchorTags(text)) {
if (!HREF_RE.test(tag)) continue; // not a static-asset anchor
inScopeAnchors += 1;
const hasReload = tag.includes('data-sveltekit-reload');
const hasExternal = /rel="[^"]*\bexternal\b[^"]*"/.test(tag);
if (!hasReload && !hasExternal) {
const href = tag.match(HREF_RE)?.[0] ?? '(?)';
violations.push(`${relative(REPO_ROOT, file)}${href} lacks data-sveltekit-reload`);
}
}
}
results.push({
name: 'static_asset_anchors_have_reload',
ok: violations.length === 0,
detail:
violations.length === 0
? `all ${inScopeAnchors} static-asset anchor(s) force a real navigation`
: `${violations.length} static-asset anchor(s) missing data-sveltekit-reload:\n - ${violations.join('\n - ')}`
});
results.push({
name: 'meta_found_static_asset_anchors',
ok: inScopeAnchors >= 1,
detail:
inScopeAnchors >= 1
? `scanned ${inScopeAnchors} static-asset anchor(s)`
: 'no static-asset anchors found — did the footer links move?'
});
let pass = 0;
let fail = 0;
for (const r of results) {
if (r.ok) {
pass += 1;
console.log(` PASS ${r.name}${r.detail ? `${r.detail}` : ''}`);
} else {
fail += 1;
console.error(` FAIL ${r.name}${r.detail ? `${r.detail}` : ''}`);
}
}
if (fail > 0) {
console.error(`\nstatic-asset-link-reload-smoke: ${pass} pass / ${fail} fail`);
process.exit(1);
}
console.log(`\n✓ all ${pass} static-asset-link-reload scenarios passed`);
}
main();