diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index a00e263..48fbf26 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -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 diff --git a/e2e/specs/06-export/viewer-no-broken-tiles.spec.ts b/e2e/specs/06-export/viewer-no-broken-tiles.spec.ts index 30a4b2a..831f35e 100644 --- a/e2e/specs/06-export/viewer-no-broken-tiles.spec.ts +++ b/e2e/specs/06-export/viewer-no-broken-tiles.spec.ts @@ -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) diff --git a/frontend/export-viewer/vite.standalone.config.js b/frontend/export-viewer/vite.standalone.config.js index dc6d1c2..92cc2b5 100644 --- a/frontend/export-viewer/vite.standalone.config.js +++ b/frontend/export-viewer/vite.standalone.config.js @@ -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.` + ); + } } }; }