Files
EventSnap/frontend/export-viewer/vite.standalone.config.js
fabi cfc8bd0016 test: replace coverage that could not fail with coverage that can
`backend/tests/` follows a house rule of copying production SQL character-for-
character rather than calling `src/`, because the crate is a binary and nothing
in it is importable from an integration test. For pinning behaviour that already
existed that is a defensible trade. Applied to a NEW fix whose only coverage is
the copy, it proves nothing: the fix and its test become two independent
implementations, and deleting the fix leaves the test green.

`audit_names.rs` did exactly that. It never called `audit::record` — it
reimplemented `resolve_names` and the INSERT inside the test file, down to a
hardcoded `.bind("host")`, and then asserted `actor_role == "host"` against its
own literal. That assertion could not fail for any change to the code it named,
and grep confirmed there was no other coverage of the audit-name work anywhere.

Moved into `#[cfg(test)]` inside `services/audit.rs`, where the real function IS
callable. CI already runs `cargo test --all-features` with a live DATABASE_URL,
so `#[sqlx::test]` works there; verified all four run and pass. The role
assertion now compares against `UserRole::as_str()` itself rather than a literal,
so it tracks a rename instead of pretending to, plus an explicit `assert_ne!`
against the Debug spelling.

Also:

- `retry-after-release.spec.ts` filtered the feed on `u.id === original.id` to
  prove "no second row was created". A duplicate gets a fresh uuid and could
  never match, so the filter yielded exactly 1 whether the gallery held one copy
  or five. Counts by uploader now, with the original's identity asserted
  separately. (The rest of that spec is sound — its 403 control and replay-id
  check both fail if the header fast-path is reverted.)

- `upload_after_release_commits_sees_the_lock_and_is_rejected` claimed the
  handler answers `UploadsLocked`. It answers `GalleryReleased` since the check
  order was inverted on this branch, and the test asserts no variant at all.
  Documented what it actually covers (the locked READ) and where the ordering IS
  covered (two e2e specs).

- Two `// SRC:` pointers had drifted ~130 lines into unrelated code, which is how
  a hand-copied fixture silently stops matching its original. Now named, not
  numbered.

- `emptyOutDir: false` claimed a failed viewer build "leaves the last good
  artifact in place". True for the `generateBundle` error, false for the newer
  `writeBundle` assertion, which fires after Vite has already written the file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 19:46:04 +02:00

133 lines
6.8 KiB
JavaScript

import { svelte, vitePreprocess } from '@sveltejs/vite-plugin-svelte';
import tailwindcss from '@tailwindcss/vite';
import { viteSingleFile } from 'vite-plugin-singlefile';
import { defineConfig } from 'vite';
import { fileURLToPath } from 'node:url';
import { readFileSync } from 'node:fs';
/** Webfonts the shared theme declares, and where their bytes actually live. */
const FONTS = ['Inter', 'Fraunces'];
/**
* Inline the webfonts the shared theme CSS references by ABSOLUTE path.
*
* `src/tailwind-theme.css` is the design-token source of truth for both the live app and this
* viewer, and it declares `src: url('/fonts/Inter.woff2')`. That is correct for the app, which
* serves `static/fonts/` from the site root — but the keepsake is opened from `file://` off a USB
* stick or a Downloads folder, where `/fonts/...` resolves to the root of the guest's DISK. Both
* requests 404, silently: `font-display: swap` means the viewer renders in a fallback system font
* with no error, so nothing on the server side can ever report it. The keepsake is the one artifact
* the whole event exists to produce, and it was shipping without the typography it was designed in.
*
* Fixing it in the shared CSS would break the app (a data URI there would inline ~154 KB into every
* page load for no reason), and shipping a `fonts/` folder beside `index.html` would give the guest
* a directory they can break by moving one file. So the substitution belongs HERE, in the build that
* knows its output has no origin: rewrite the emitted HTML only.
*
* Runs in `generateBundle` rather than `transformIndexHtml` because `viteSingleFile` inlines the
* stylesheet during the latter — the `url()` we need to rewrite does not exist in the HTML until
* after it has run.
*/
function inlineThemeFonts() {
return {
name: 'eventsnap:inline-theme-fonts',
enforce: 'post',
generateBundle(_options, bundle) {
const html = bundle['index.html'];
if (!html || typeof html.source !== 'string') return;
for (const family of FONTS) {
const bytes = readFileSync(
fileURLToPath(new URL(`../static/fonts/${family}.woff2`, import.meta.url))
);
const uri = `data:font/woff2;base64,${bytes.toString('base64')}`;
const before = html.source;
html.source = html.source.replaceAll(`/fonts/${family}.woff2`, uri);
// A silent no-op here is the exact failure this plugin exists to prevent, and it
// would come back the moment the theme renames a font or switches to a CDN. Fail
// the build instead of shipping another keepsake in Times New Roman.
if (html.source === before) {
this.error(
`inline-theme-fonts: no reference to /fonts/${family}.woff2 in the built ` +
`keepsake. The shared theme CSS changed how it loads webfonts — update ` +
`FONTS in vite.standalone.config.js to match, or the offline viewer will ` +
`render in a fallback font.`
);
}
}
},
// 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.`
);
}
}
};
}
// Builds the keepsake viewer as ONE self-contained index.html (all JS + CSS
// inlined) so it renders when opened via file://. Uses the plain Svelte plugin
// (not SvelteKit) because SvelteKit emits multiple module entry points, which
// cannot be inlined into a single file.
export default defineConfig({
plugins: [
tailwindcss(),
svelte({ configFile: false, preprocess: vitePreprocess(), compilerOptions: { runes: true } }),
viteSingleFile(),
inlineThemeFonts()
],
resolve: {
alias: { $lib: fileURLToPath(new URL('./src/lib', import.meta.url)) }
},
build: {
outDir: fileURLToPath(new URL('../../backend/static/export-viewer', import.meta.url)),
// NOT `true`. Vite empties outDir BEFORE generating, so a build that fails late — which is
// now a real possibility, since `inlineThemeFonts` calls `this.error` on a keepsake that is
// not self-contained — left the directory EMPTY. `include_dir!` over an empty directory
// compiles perfectly happily, and `write_viewer_with_data` iterates zero files and returns
// Ok, so the next `cargo build` produced a binary whose Memories.zip has the photos and no
// viewer at all. Before the guard existed the build could not fail, so neither could this.
//
// The only output is a single `index.html`, overwritten on every successful build, so
// there is nothing to accumulate.
//
// This protects ONE of the two failure paths, not both. `inlineThemeFonts` errors from
// `generateBundle`, before anything is written, so the previous artifact survives intact.
// The external-asset assertion errors from `writeBundle` — which runs AFTER Vite has
// written `index.html` — so that failure does overwrite the good viewer with the broken
// one. It cannot ship: the build exits non-zero, and `the_keepsake_viewer_is_compiled_into_
// this_binary` plus the `!html.contains("url(/")` assertion both fail the Rust test suite
// before any image is built. But if a `writeBundle` failure is what you are looking at,
// restore the artifact with `git checkout backend/static/export-viewer/` rather than
// assuming the working copy is still the last good one.
emptyOutDir: false,
target: 'es2020'
}
});