Files
EventSnap/e2e/specs/03-feed/video-playback.spec.ts
fabi a3d8ae72e3 fix(e2e): stop the video poster assertion racing the ffmpeg thumbnail
Pre-existing, and it fired for real during the full-suite run on a cold stack.

The lightbox binds `poster={upload.thumbnail_url ?? undefined}`, so the attribute is
absent until compression produces the thumbnail. This test asserted on it immediately
after seeding, never waiting for the worker -- unlike the Range test further down the
same file, which does poll. Against a warm stack the worker usually wins; against a
freshly rebuilt one (`stack:down -v`, cold ffmpeg) it doesn't.

That is the worst possible time for a false failure: the first run after a rebuild is
exactly when you are trying to establish whether a change broke something. Poll for
`compression_status = 'done'` before the poster assertion. The `src` assertion needs
no wait and keeps none.

Verified with --repeat-each=3.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 20:10:54 +02:00

182 lines
8.1 KiB
TypeScript

/**
* Regression guard — videos must actually play.
*
* Two independent defects made every video unplayable, and nothing in the suite covered
* either one (no test anywhere played media or asserted a `<video>` src):
*
* 1. The lightbox fed `<video>` the URL from `pickMediaUrl`, which is mime-agnostic.
* Compression only ever produces a *thumbnail* for a video — one ffmpeg frame — so in
* the DEFAULT saver mode the element's src was `/api/v1/upload/{id}/thumbnail`: a JPEG,
* served as image/jpeg with nosniff so the browser can't even sniff its way out.
* Chromium reported DEMUXER_ERROR_COULD_NOT_OPEN.
*
* 2. `stream_media_file` ignored `Range` entirely — always 200 with the whole body, never
* an Accept-Ranges or Content-Range. iOS Safari opens every `<video>` with a
* `Range: bytes=0-1` probe and abandons the load without a 206, so video failed on the
* app's primary platform even in `original` data mode.
*
* Fixing either alone still leaves video broken, so both are asserted here.
*/
import { test, expect } from '../../fixtures/test';
import { uploadRaw } from '../../helpers/upload-client';
import { BASE } from '../../helpers/env';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
const SAMPLE_MP4 = join(process.cwd(), 'fixtures', 'media', 'sample.mp4');
async function seedVideo(jwt: string): Promise<string> {
const res = await uploadRaw(jwt, readFileSync(SAMPLE_MP4), {
filename: 'clip.mp4',
contentType: 'video/mp4',
caption: 'ein Video',
});
if (res.status !== 201) throw new Error(`video seed failed: ${res.status}`);
return ((await res.json()) as { id: string }).id;
}
test.describe('Video — the lightbox plays it', () => {
test('the <video> src is the original, not the thumbnail JPEG', async ({
page,
guest,
signIn,
db,
}) => {
const g = await guest('VideoWatcher');
const id = await seedVideo(g.jwt);
// The poster assertion below needs the ffmpeg thumbnail to EXIST — the lightbox binds
// `poster={upload.thumbnail_url ?? undefined}`, so the attribute is simply absent until
// compression finishes. Without this wait the test races the worker and fails against a
// cold stack (first run after `stack:down -v`, cold ffmpeg), which is exactly when a suite
// is least likely to be believed. The `src` assertion is unconditional; only the poster
// needs the wait.
await expect.poll(() => db.compressionStatus(id), { timeout: 60_000 }).toBe('done');
await signIn(page, g);
await page.goto('/feed');
const card = page.locator('article').first();
await expect(card).toBeVisible({ timeout: 15_000 });
await card.getByRole('button', { name: 'Bild vergrößern' }).click();
const video = page.locator('video');
await expect(video).toBeVisible({ timeout: 10_000 });
// The bug in one assertion: this was `/thumbnail` in the default data mode.
await expect(video).toHaveAttribute('src', `/api/v1/upload/${id}/original`);
// The poster SHOULD still be the thumbnail — that's what it's for.
await expect(video).toHaveAttribute('poster', `/api/v1/upload/${id}/thumbnail`);
// And the browser must accept the bytes as media. preload="none" means nothing is
// fetched until we ask, so drive a load explicitly and wait for metadata.
const readyState = await video.evaluate(async (el: HTMLVideoElement) => {
el.preload = 'metadata';
el.load();
await new Promise<void>((resolve) => {
if (el.readyState > 0) return resolve();
el.addEventListener('loadedmetadata', () => resolve(), { once: true });
el.addEventListener('error', () => resolve(), { once: true });
setTimeout(resolve, 15_000);
});
return { ready: el.readyState, err: el.error?.message ?? null };
});
expect(readyState.err, `the browser rejected the media: ${readyState.err}`).toBeNull();
expect(readyState.ready, 'video metadata must load').toBeGreaterThan(0);
});
test('the video body is not downloaded before the user presses play', async ({
page,
guest,
signIn,
}) => {
// saver mode exists to protect a guest's mobile data, and there is no smaller video
// derivative to fall back to — so opening the lightbox must not pull the file down.
//
// Asserting "no request at all" would be wrong: WebKit opens a connection for a
// preload="none" <video> and immediately ABORTS it (observed: GET with no Range,
// response status 0, nothing transferred), whereas Chromium issues nothing. The
// portable guarantee — and the one that actually protects the data plan — is that no
// response carrying the body ever completes. Dropping preload="none" fails this.
const g = await guest('VideoThrifty');
const id = await seedVideo(g.jwt);
await signIn(page, g);
const delivered: string[] = [];
page.on('response', (r) => {
if (!r.url().includes(`/upload/${id}/original`)) return;
// status 0 = aborted before any bytes landed.
if (r.status() === 200 || r.status() === 206) {
delivered.push(`${r.status()} len=${r.headers()['content-length'] ?? '?'}`);
}
});
await page.goto('/feed');
const card = page.locator('article').first();
await expect(card).toBeVisible({ timeout: 15_000 });
await card.getByRole('button', { name: 'Bild vergrößern' }).click();
await expect(page.locator('video')).toBeVisible({ timeout: 10_000 });
await page.waitForTimeout(2000);
expect(delivered, 'no video bytes may be delivered before play').toEqual([]);
});
});
test.describe('Media — HTTP Range', () => {
test('a range request is answered 206 with the right slice', async ({ guest }) => {
const g = await guest('RangeReader');
const id = await seedVideo(g.jwt);
const url = `${BASE}/api/v1/upload/${id}/original`;
const full = await fetch(url);
expect(full.status).toBe(200);
expect(full.headers.get('accept-ranges'), 'clients must be told seeking works').toBe('bytes');
const total = Number(full.headers.get('content-length'));
expect(total).toBeGreaterThan(0);
// The exact probe iOS Safari opens a <video> with. Two bytes, inclusive.
const probe = await fetch(url, { headers: { Range: 'bytes=0-1' } });
expect(probe.status, 'iOS abandons the load without a 206').toBe(206);
expect(probe.headers.get('content-range')).toBe(`bytes 0-1/${total}`);
expect(Number(probe.headers.get('content-length'))).toBe(2);
expect((await probe.arrayBuffer()).byteLength).toBe(2);
// A mid-file seek must return the matching slice, not the whole body.
const mid = await fetch(url, { headers: { Range: 'bytes=10-19' } });
expect(mid.status).toBe(206);
expect(mid.headers.get('content-range')).toBe(`bytes 10-19/${total}`);
const midBytes = Buffer.from(await mid.arrayBuffer());
expect(midBytes.byteLength).toBe(10);
expect(midBytes).toEqual(Buffer.from(await full.arrayBuffer()).subarray(10, 20));
// Open-ended range runs to EOF.
const tail = await fetch(url, { headers: { Range: `bytes=${total - 5}-` } });
expect(tail.status).toBe(206);
expect(Number(tail.headers.get('content-length'))).toBe(5);
// Past EOF must be 416 — answering 200 makes a player re-request forever.
const bad = await fetch(url, { headers: { Range: `bytes=${total + 10}-` } });
expect(bad.status).toBe(416);
expect(bad.headers.get('content-range')).toBe(`bytes */${total}`);
});
test('image derivatives are range-capable too', async ({ guest, db }) => {
// Same helper serves all four media routes, so the guarantee is uniform.
const g = await guest('RangeImages');
const res = await uploadRaw(
g.jwt,
readFileSync(join(process.cwd(), 'fixtures', 'media', 'sample.jpg')),
{ filename: 'r.jpg', contentType: 'image/jpeg' }
);
const { id } = (await res.json()) as { id: string };
await expect.poll(() => db.compressionStatus(id), { timeout: 30_000 }).toBe('done');
const preview = await fetch(`${BASE}/api/v1/upload/${id}/preview`, {
headers: { Range: 'bytes=0-9' },
});
expect(preview.status).toBe(206);
expect(Number(preview.headers.get('content-length'))).toBe(10);
});
});