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>
182 lines
8.1 KiB
TypeScript
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);
|
|
});
|
|
});
|