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.
124 lines
5.7 KiB
TypeScript
124 lines
5.7 KiB
TypeScript
/**
|
|
* Regression guard — the keepsake never renders a broken image tile.
|
|
*
|
|
* The HTML export wrote `thumb: "media/<id>_thumb.jpg"` into `data.json` unconditionally. When
|
|
* ffmpeg produced no poster frame — which it did, silently and with exit 0, for any clip of a
|
|
* second or less — the ZIP writer skipped the unopenable file but `data.json` still advertised it.
|
|
* The viewer then requested an entry the archive did not contain and drew a broken `<img>`.
|
|
*
|
|
* The viewer was never the problem: `+page.svelte` already guards `{#if post.media.thumb}` and
|
|
* falls back to a proper dark video tile with a play glyph. The guard simply never fired, because
|
|
* the backend always handed it a non-empty string. The fix is the backend telling the truth —
|
|
* `thumb: ""` when there is no poster — so no viewer change was needed.
|
|
*
|
|
* This asserts the property that actually matters to a guest and that no server-side signal can
|
|
* report: **every image in the opened keepsake resolves**. `naturalWidth > 0` is false for exactly
|
|
* the broken-tile case, whatever produced it — a missing video poster, a failed image thumbnail, or
|
|
* some future path nobody has thought of yet. It is deliberately not an assertion about ffmpeg.
|
|
*
|
|
* Runs over `file://`, the way a guest opens it.
|
|
*/
|
|
import { test, expect } from '../../fixtures/test';
|
|
import { execFileSync } from 'node:child_process';
|
|
import { mkdtempSync, writeFileSync, rmSync, readFileSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { uploadRaw } from '../../helpers/upload-client';
|
|
import { seedUpload } from '../../helpers/seed';
|
|
import { BASE } from '../../helpers/env';
|
|
|
|
test.describe('Export — the keepsake has no broken tiles', () => {
|
|
test('every image in the opened viewer resolves', async ({ page, host, guest, db }) => {
|
|
test.setTimeout(150_000);
|
|
const bearer = { Authorization: `Bearer ${host.jwt}` };
|
|
|
|
const g = await guest('TileChecker');
|
|
const ids: string[] = [await seedUpload(g.jwt, { caption: 'ein Foto' })];
|
|
|
|
// Both clips: the 1.000 s one is the case that produced the broken tile, the 5 s one is the
|
|
// ordinary path that had no coverage at all.
|
|
for (const file of ['sample.mp4', 'sample-5s.mp4']) {
|
|
const bytes = readFileSync(join(process.cwd(), 'fixtures', 'media', file));
|
|
const res = await uploadRaw(g.jwt, bytes, { filename: file, contentType: 'video/mp4' });
|
|
expect(res.status).toBe(201);
|
|
ids.push(((await res.json()) as { id: string }).id);
|
|
}
|
|
for (const id of ids) {
|
|
await expect.poll(() => db.compressionStatus(id), { timeout: 45_000 }).toBe('done');
|
|
}
|
|
|
|
expect(
|
|
(await fetch(`${BASE}/api/v1/host/gallery/release`, { method: 'POST', headers: bearer }))
|
|
.status
|
|
).toBe(204);
|
|
await expect
|
|
.poll(
|
|
async () => {
|
|
const res = await fetch(`${BASE}/api/v1/export/status`, { headers: bearer });
|
|
return (await res.json()).html?.status;
|
|
},
|
|
{ timeout: 120_000, intervals: [500] }
|
|
)
|
|
.toBe('done');
|
|
|
|
const ticketRes = await fetch(`${BASE}/api/v1/export/ticket?kind=html`, {
|
|
method: 'POST',
|
|
headers: bearer,
|
|
});
|
|
const { ticket } = (await ticketRes.json()) as { ticket: string };
|
|
const dl = await fetch(`${BASE}/api/v1/export/html?ticket=${encodeURIComponent(ticket)}`);
|
|
expect(dl.status).toBe(200);
|
|
|
|
const dir = mkdtempSync(join(tmpdir(), 'eventsnap-tiles-'));
|
|
try {
|
|
const zipPath = join(dir, 'Memories.zip');
|
|
writeFileSync(zipPath, Buffer.from(await dl.arrayBuffer()));
|
|
execFileSync('unzip', ['-qo', zipPath, '-d', dir]);
|
|
|
|
await page.goto('file://' + join(dir, 'index.html'));
|
|
|
|
// Every post is present, whether or not it has a poster.
|
|
const posts = await page.evaluate(() => {
|
|
const d = (
|
|
window as unknown as {
|
|
__EXPORT_DATA__?: { posts?: { media?: { thumb?: string; type?: string } }[] };
|
|
}
|
|
).__EXPORT_DATA__;
|
|
return d?.posts?.map((p) => ({ thumb: p.media?.thumb ?? '', type: p.media?.type })) ?? null;
|
|
});
|
|
expect(posts, '__EXPORT_DATA__ was never assigned').not.toBeNull();
|
|
expect(posts!.length).toBe(3);
|
|
|
|
// Any thumb data.json DOES advertise must be a file the archive actually contains.
|
|
const entries = execFileSync('unzip', ['-Z1', zipPath], { encoding: 'utf8' }).split('\n');
|
|
for (const p of posts!.filter((p) => p.thumb)) {
|
|
expect(
|
|
entries.includes(p.thumb),
|
|
`data.json advertises ${p.thumb} but the archive does not contain it`
|
|
).toBe(true);
|
|
}
|
|
|
|
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)
|
|
.map((i) => i.getAttribute('src') ?? '(no src)')
|
|
);
|
|
expect(broken, `broken image tiles in the keepsake: ${broken.join(', ')}`).toEqual([]);
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|