Skip to content

Commit f9ab5e2

Browse files
committed
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).
1 parent de739cc commit f9ab5e2

8 files changed

Lines changed: 158 additions & 4 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

‎.github/workflows/CI.yml‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ jobs:
8484
node-version: ${{ matrix.node }}
8585
cache: pnpm
8686
- run: pnpm install --frozen-lockfile
87-
- run: pnpm test --project core-st --project core-mt --project node
87+
- run: pnpm test:unit --project core-st --project core-mt --project node
8888
browser:
8989
# @ffmpeg/ffmpeg and @ffmpeg/util in Chromium.
9090
runs-on: ubuntu-latest
@@ -111,7 +111,8 @@ jobs:
111111
cache: pnpm
112112
- run: pnpm install --frozen-lockfile
113113
- run: pnpm test:install
114-
- run: pnpm test --project browser-st --project browser-mt
114+
- run: pnpm test:unit --project browser-st --project browser-mt
115+
- run: pnpm test:e2e
115116
heavy:
116117
# FATE samples + benchmarks: nightly, on demand, or with the "heavy" PR label.
117118
if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'heavy')

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

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

5656
return (
5757
<MuiThemeProvider>
58-
<Stack spacing={4}>
58+
<Stack spacing={4} data-testid="playground">
5959
<CoreSwitcher
6060
checked={isCoreMT}
6161
onChange={(evt) => {

‎package.json‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,10 @@
1313
"prepublishOnly": "pnpm build",
1414
"postinstall": "pnpm build",
1515
"pretest": "pnpm build",
16-
"test": "vitest run",
16+
"test": "vitest run && pnpm test:e2e",
17+
"test:unit": "vitest run",
18+
"test:e2e": "node tests/e2e/run.mjs",
19+
"test:docker": "docker build -f tests/Dockerfile -t ffmpeg-wasm-tests . && docker run --rm --ipc=host ffmpeg-wasm-tests",
1720
"test:install": "playwright install --with-deps chromium",
1821
"test:heavy": "vitest run --config tests/heavy/vitest.config.js",
1922
"bench": "vitest bench --run --config tests/heavy/vitest.config.js"
@@ -25,6 +28,7 @@
2528
"@ffmpeg/util": "workspace:*",
2629
"@vitest/browser-playwright": "^5.0.2",
2730
"playwright": "^1.63.0",
31+
"sirv": "^3.0.2",
2832
"vitest": "^5.0.2"
2933
}
3034
}

‎pnpm-lock.yaml‎

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎tests/Dockerfile‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# Runs `pnpm test` (unit, Node.js and end-to-end tests) in Playwright's image:
2+
# pnpm test:docker
3+
# packages/core/dist must hold a built core (pnpm build:wasm).
4+
FROM mcr.microsoft.com/playwright:v1.63.0-noble
5+
WORKDIR /repo
6+
RUN corepack enable
7+
COPY . .
8+
RUN pnpm install --frozen-lockfile
9+
CMD ["pnpm", "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+
// pnpm test:e2e build the site if needed, run everything
6+
// pnpm test:e2e --rebuild rebuild the site first
7+
// pnpm 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 "../helpers/server.js";
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("pnpm --filter website build", { 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 and @ffmpeg/core-mt -> packages/core(-mt)/dist.
27+
await context.route(/cdn\.jsdelivr\.net\/npm\/@ffmpeg\/(core(?:-mt)?)@[^/]+\/dist\/(.*)$/, (route) => {
28+
const [, pkg, file] = route.request().url().match(/@ffmpeg\/(core(?:-mt)?)@[^/]+\/dist\/(.*)$/);
29+
route.fulfill({ path: join(root, "packages", pkg, "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/helpers/server.js‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { createServer } from "node:http";
2+
import { fileURLToPath } from "node:url";
3+
import sirv from "sirv";
4+
5+
export const root = fileURLToPath(new URL("../..", import.meta.url));
6+
7+
// Serves `dir` (the repository by default) cross-origin isolated, which the
8+
// multi-threaded core needs.
9+
export function startServer({ dir = root } = {}) {
10+
const serve = sirv(dir, {
11+
dev: true,
12+
setHeaders: (res) => {
13+
res.setHeader("Cross-Origin-Opener-Policy", "same-origin");
14+
res.setHeader("Cross-Origin-Embedder-Policy", "require-corp");
15+
},
16+
});
17+
const server = createServer((req, res) => serve(req, res, () => res.writeHead(404).end()));
18+
return new Promise((ready) =>
19+
server.listen(0, "127.0.0.1", () => ready({ server, url: `http://127.0.0.1:${server.address().port}` }))
20+
);
21+
}

0 commit comments

Comments
 (0)