Skip to content

Commit 0965a7d

Browse files
lucasgelfondclaude
andcommitted
End-to-end tests of the website's live examples and playground
`npm run test:e2e` (also part of `npm test`) builds apps/website and drives it in Chromium with the CDN's @ffmpeg/core routed to packages/core/dist: - every live example on the usage page (webm -> mp4, timeout, progress, segments, drawtext, interlacing two videos): load the core, click its button, and check the <video> it fills actually loads (the timeout example only has to stop within seconds); - the playground: load sample files, run the default command, find the output in its file manager. Sample media from the testdata repository is downloaded once into .cache; ads and analytics are blocked. `npm run test:docker` runs the whole `npm test` in Playwright's Linux image (tests/Dockerfile). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent bb1a4af commit 0965a7d

6 files changed

Lines changed: 136 additions & 8 deletions

File tree

‎.dockerignore‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
**/node_modules
2+
.cache
3+
tests/heavy/results
4+
apps/website/build

‎apps/website/src/components/Playground/index.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ export default function Playground() {
4343

4444
return (
4545
<MuiThemeProvider>
46-
<Stack spacing={4}>
46+
<Stack spacing={4} data-testid="playground">
4747
{(() => {
4848
switch (state) {
4949
case State.LOADING:

‎package.json‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,10 @@
99
"build:wasm": "bash scripts/build-wasm.sh",
1010
"serve": "http-server -c-1 -s -p 3000 . --cors --headers '{\"Cross-Origin-Embedder-Policy\":\"require-corp\",\"Cross-Origin-Opener-Policy\":\"same-origin\",\"Cross-Origin-Resource-Policy\":\"cross-origin\",\"Origin-Agent-Cluster\":\"?1\"}'",
1111
"pretest": "npm run build",
12-
"test": "node tests/run.mjs && npm run test:node",
12+
"test": "node tests/run.mjs && npm run test:node && npm run test:e2e",
1313
"test:node": "node --test \"tests/node/*.test.mjs\"",
14+
"test:e2e": "node tests/e2e/run.mjs",
15+
"test:docker": "docker build -f tests/Dockerfile -t ffmpeg-wasm-tests . && docker run --rm --ipc=host ffmpeg-wasm-tests",
1416
"test:install": "node node_modules/playwright/cli.js install --with-deps chromium",
1517
"test:heavy": "node tests/heavy/fate.mjs",
1618
"bench": "node tests/heavy/bench.mjs",

‎tests/Dockerfile‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
# Runs `npm test` (unit, Node.js and end-to-end tests) in Playwright's image:
2+
# npm run test:docker
3+
# packages/core/dist must hold a built core (npm run build:wasm).
4+
FROM mcr.microsoft.com/playwright:v1.63.0-noble
5+
WORKDIR /repo
6+
COPY . .
7+
RUN npm install
8+
CMD ["npm", "test"]

‎tests/e2e/run.mjs‎

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
// End-to-end tests: builds the website (apps/website) and drives every live
2+
// example on the usage page and the playground in Chromium, with the CDN's
3+
// @ffmpeg/core swapped for this repository's build.
4+
//
5+
// npm run test:e2e build the site if needed, run everything
6+
// npm run test:e2e -- --rebuild rebuild the site first
7+
// npm run test:e2e -- --headed
8+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
9+
import { execSync } from "node:child_process";
10+
import { join } from "node:path";
11+
import { chromium } from "playwright";
12+
import { startServer, root } from "../lib/server.mjs";
13+
14+
const args = new Set(process.argv.slice(2));
15+
const site = join(root, "apps/website/build");
16+
if (!existsSync(site) || args.has("--rebuild")) {
17+
execSync("npm run build -w website", { cwd: root, stdio: "inherit" });
18+
}
19+
20+
const { server, url } = await startServer({ dir: site });
21+
const browser = await chromium.launch({ headless: !args.has("--headed") });
22+
const context = await browser.newContext();
23+
24+
// Keep the run hermetic: no ads or analytics.
25+
await context.route(/google|doubleclick|adtrafficquality/, (route) => route.abort());
26+
// jsDelivr's @ffmpeg/core -> packages/core/dist.
27+
await context.route(/cdn\.jsdelivr\.net\/npm\/@ffmpeg\/core@[^/]+\/dist\/(.*)$/, (route) => {
28+
const file = route.request().url().match(/\/dist\/(.*)$/)[1];
29+
route.fulfill({ path: join(root, "packages/core/dist", file), headers: { "Access-Control-Allow-Origin": "*" } });
30+
});
31+
// Sample media from the testdata repository, downloaded once into .cache.
32+
const cache = join(root, ".cache/testdata");
33+
mkdirSync(cache, { recursive: true });
34+
await context.route(/raw\.githubusercontent\.com\/ffmpegwasm\/testdata\/master\/(.*)$/, async (route) => {
35+
const file = join(cache, route.request().url().split("/").pop());
36+
if (!existsSync(file)) {
37+
writeFileSync(file, Buffer.from(await (await fetch(route.request().url())).arrayBuffer()));
38+
}
39+
route.fulfill({ body: readFileSync(file), headers: { "Access-Control-Allow-Origin": "*" } });
40+
});
41+
42+
let failures = 0;
43+
async function test(name, fn) {
44+
const page = await context.newPage();
45+
const errors = [];
46+
page.on("pageerror", (e) => errors.push(e.message));
47+
const start = Date.now();
48+
try {
49+
await fn(page);
50+
if (errors.length) throw new Error(errors.join("\n"));
51+
console.log(`✓ ${name} (${((Date.now() - start) / 1000).toFixed(1)}s)`);
52+
} catch (e) {
53+
failures++;
54+
console.log(`✗ ${name}\n ${e.message.split("\n").join("\n ")}`);
55+
}
56+
await page.close();
57+
}
58+
59+
// Every live example: click "Load ffmpeg-core", then its action button, and
60+
// wait for the <video> it fills to load.
61+
const probe = await context.newPage();
62+
await probe.goto(`${url}/docs/getting-started/usage`);
63+
const examples = await probe.$$eval('[class*="playgroundPreview"]', (previews) =>
64+
previews.map((preview) => {
65+
let el = preview.closest('[class*="playgroundContainer"]');
66+
while (el && el.tagName !== "H2") el = el.previousElementSibling;
67+
return el?.textContent.replace(/\u200b/g, "").trim() ?? "untitled";
68+
})
69+
);
70+
await probe.close();
71+
72+
await test("usage page has live examples", async () => {
73+
if (examples.length < 6) throw new Error(`found ${examples.length} live examples`);
74+
});
75+
76+
for (const [i, title] of examples.entries()) {
77+
await test(`usage: ${title}`, async (page) => {
78+
await page.goto(`${url}/docs/getting-started/usage`);
79+
const preview = page.locator('[class*="playgroundPreview"]').nth(i);
80+
await preview.scrollIntoViewIfNeeded();
81+
const load = preview.getByRole("button", { name: /^Load ffmpeg-core/ });
82+
await load.click();
83+
await load.waitFor({ state: "detached", timeout: 60000 });
84+
const started = Date.now();
85+
await preview.getByRole("button").first().click();
86+
const video = await preview.locator("video").elementHandle();
87+
await page.waitForFunction((el) => el.src.startsWith("blob:"), video, { timeout: 180000 });
88+
if (/timeout/i.test(title)) {
89+
// exec(..., 1000) stops early; the partial output isn't expected to play.
90+
if (Date.now() - started > 15000) throw new Error("the 1 s timeout took over 15 s");
91+
return;
92+
}
93+
await page.waitForFunction((el) => el.readyState >= 1 && el.duration > 0, video, { timeout: 30000 });
94+
});
95+
}
96+
97+
await test("playground: loads the core and runs the default command", async (page) => {
98+
await page.goto(`${url}/playground`);
99+
// The page also renders standalone copies of the file manager and editor
100+
// to explain them.
101+
const workspace = page.getByTestId("playground");
102+
await workspace.getByRole("button", { name: "Load Sample Files" }).click({ timeout: 120000 });
103+
await workspace.getByText("video.webm", { exact: true }).waitFor();
104+
await workspace.getByRole("button", { name: "Run" }).click(); // ["-i", "video.webm", "video.mp4"]
105+
await workspace.getByText(/Time Elapsed/).waitFor({ timeout: 180000 });
106+
await workspace.getByRole("button", { name: "fresh" }).click();
107+
await workspace.getByText("video.mp4", { exact: true }).waitFor();
108+
});
109+
110+
await browser.close();
111+
server.close();
112+
process.exit(failures ? 1 : 0);

‎tests/lib/server.mjs‎

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
// /__test/gzip/<path> gzip-encoded body
66
// /__test/echo?body=<txt> plain text response
77
import { createServer } from "node:http";
8-
import { readFile } from "node:fs/promises";
8+
import { readFile, stat } from "node:fs/promises";
99
import { extname, join, resolve, dirname } from "node:path";
1010
import { fileURLToPath } from "node:url";
1111
import { gzipSync } from "node:zlib";
@@ -28,15 +28,16 @@ const HEADERS = {
2828
"Cache-Control": "no-store",
2929
};
3030

31-
async function handle(req, res) {
31+
async function handle(req, res, dir) {
3232
const url = new URL(req.url, "http://localhost");
3333
let [, route, path] = url.pathname.match(/^\/__test\/(\w+)(\/.*)?$/) ?? [];
3434
if (route === "echo") {
3535
res.writeHead(200, { ...HEADERS, "Content-Type": "text/plain" });
3636
return res.end(url.searchParams.get("body") ?? "");
3737
}
38-
path = join(root, decodeURIComponent(path ?? url.pathname));
39-
if (!path.startsWith(root)) return res.writeHead(403).end();
38+
path = join(dir, decodeURIComponent(path ?? url.pathname));
39+
if (!path.startsWith(dir)) return res.writeHead(403).end();
40+
if (await stat(path).then((s) => s.isDirectory(), () => false)) path = join(path, "index.html");
4041

4142
let body;
4243
try {
@@ -60,8 +61,9 @@ async function handle(req, res) {
6061
res.end(body);
6162
}
6263

63-
export function startServer() {
64-
const server = createServer(handle);
64+
/** Serves `dir` (the repository by default) on a free port. */
65+
export function startServer({ dir = root } = {}) {
66+
const server = createServer((req, res) => handle(req, res, dir));
6567
return new Promise((ok) =>
6668
server.listen(0, "127.0.0.1", () => ok({ server, url: `http://127.0.0.1:${server.address().port}` }))
6769
);

0 commit comments

Comments
 (0)