fix(export-viewer): make the self-contained guard, and its spec, actually load-bearing

The font-inlining guard only caught a RENAME. It asked "did /fonts/<listed
family>.woff2 disappear?", so an ADDITION walked straight past it — and an
addition is the likelier accident: someone doing ordinary app work adds a
display font or a decorative background to the shared theme, has no reason to
open a viewer build config, and ships a keepsake that reaches for
/fonts/Playfair.woff2 on the guest's own disk. font-display: swap hides it, so
the artifact looks right to everyone who happens to have the file locally and
renders in Times New Roman for the couple.

It now asserts the invariant instead of a list: nothing in the emitted
keepsake may reference an external URL. Self-maintaining, and it covers fonts,
images and stylesheets alike. In writeBundle rather than generateBundle —
generateBundle runs more than once and the stylesheet is not inlined on the
earlier pass, so asserting there fails a perfectly good build.

viewer-no-broken-tiles gets a positive anchor. Its "nothing is broken" check
filters img elements, so a viewer that rendered NOTHING yields [] and passes:
the one spec whose whole subject is that the images resolve was the one that
would have stayed green through a total viewer regression. Everything else it
checks comes from the backend and the classic head script, neither of which
needs the viewer bundle to have run.

And a CI job, because neither of the above fires on its own: no workflow,
Dockerfile or script built this viewer, so the guard could sit disarmed
indefinitely, and the committed artifact — compiled into the binary with
include_dir! — could drift from its source with nothing to say so.
This commit is contained in:
fabi
2026-08-12 09:15:24 +02:00
parent a2b3cb0e8d
commit 9f239882ac
3 changed files with 81 additions and 1 deletions

View File

@@ -102,6 +102,44 @@ jobs:
working-directory: ./frontend
run: npm run format:check
export-viewer:
name: Keepsake viewer — builds, self-contained, committed artifact in sync
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
cache-dependency-path: 'frontend/export-viewer/package-lock.json'
- name: Install deps
working-directory: ./frontend/export-viewer
run: npm ci || npm install
# Two things nothing else in CI covered, both of which ship a broken keepsake silently.
#
# 1. The build's own self-contained guard (`inlineThemeFonts`) is the only thing standing
# between an added theme asset and a viewer that reaches for files on the guest's disk.
# It is a build-time `this.error`, so it only fires when somebody runs this build — and
# no workflow, Dockerfile or script did. It could sit disarmed indefinitely.
#
# 2. `backend/static/export-viewer/index.html` is COMMITTED and compiled into the binary with
# `include_dir!`. A viewer source change merged without a manual rebuild ships the stale
# artifact, and nothing anywhere would say so. `git diff --exit-code` is the check.
- name: Build the standalone viewer
working-directory: ./frontend/export-viewer
run: npm run build
- name: Committed artifact matches a clean rebuild
run: |
if ! git diff --exit-code -- backend/static/export-viewer/; then
echo "::error::backend/static/export-viewer/ is out of date with frontend/export-viewer/."
echo "Run 'npm run build' in frontend/export-viewer and commit the result."
exit 1
fi
e2e-typecheck:
name: E2E — typecheck + lint
runs-on: ubuntu-latest

View File

@@ -98,8 +98,18 @@ test.describe('Export — the keepsake has no broken tiles', () => {
).toBe(true);
}
// THE assertion: nothing rendered broken. Give the images a moment to settle first.
await page.waitForLoadState('networkidle');
// POSITIVE anchor FIRST, and it is load-bearing. The "nothing is broken" assertion below
// filters `img` elements, so a viewer that rendered NOTHING AT ALL yields `[]` and passes —
// this file, whose whole subject is that the images resolve, was the one spec that would
// have stayed green through a total viewer regression. Everything else it checks
// (`__EXPORT_DATA__`, the archive entries) comes from the backend and the classic head
// script, neither of which needs the viewer bundle to have run at all.
const rendered = await page.locator('img').count();
expect(rendered, 'the keepsake viewer rendered no images at all').toBeGreaterThan(0);
// THE assertion: nothing rendered broken.
const broken = await page.evaluate(() =>
Array.from(document.querySelectorAll('img'))
.filter((i) => i.complete && i.naturalWidth === 0)

View File

@@ -55,6 +55,38 @@ function inlineThemeFonts() {
);
}
}
},
// The FONTS loop above only catches a RENAME. It cannot catch an ADDITION, and an addition
// is the likelier accident by far: someone doing ordinary app work adds a display font or a
// decorative background to the shared theme, has no reason to open a viewer build config,
// and ships a keepsake that reaches for `/fonts/Playfair.woff2` on the guest's own disk.
// `font-display: swap` hides it, so the artifact looks correct to everyone who happens to
// have the file locally, and renders in Times New Roman for the couple.
//
// So assert the invariant itself rather than a list: nothing in the emitted keepsake may
// reference an external URL. Self-maintaining — it covers renames, additions, fonts,
// images and stylesheets alike, and nobody has to remember it exists.
//
// In `writeBundle`, NOT `generateBundle`: the latter runs more than once, and on the
// earlier pass the stylesheet has not been inlined yet, so asserting there fails a
// perfectly good build. This hook sees only what was actually written.
writeBundle(_options, bundle) {
const html = bundle['index.html'];
if (!html || typeof html.source !== 'string') return;
const external = [...html.source.matchAll(/url\(\s*(['"]?)([^'")]+)\1\s*\)/g)]
.map((m) => m[2].trim())
.filter((u) => !u.startsWith('data:'));
if (external.length) {
this.error(
`inline-theme-fonts: the built keepsake still references ${external.length} ` +
`external asset(s): ${[...new Set(external)].join(', ')}. The viewer is opened ` +
`from file:// with no network and no origin, so every one of these resolves to ` +
`the root of the guest's disk and 404s silently. Inline them (see FONTS above) ` +
`or remove them from the theme the viewer imports.`
);
}
}
};
}