kunai-runner 6.10.101

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/.env.example +68 -0
  2. package/README.md +17 -0
  3. package/docs/01_shibboleth_auth.md +122 -0
  4. package/docs/02_versioning_and_release.md +146 -0
  5. package/docs/backlog.md +71 -0
  6. package/docs/combine_videos.md +109 -0
  7. package/docs/test_data.md +34 -0
  8. package/lib/auth-file.ts +22 -0
  9. package/lib/login-adapters/builtin.ts +42 -0
  10. package/lib/login-adapters/duo-mfa.ts +41 -0
  11. package/lib/login-adapters/incommon-seamlessaccess.ts +56 -0
  12. package/lib/login-adapters/index.ts +68 -0
  13. package/lib/login-adapters/shibboleth-direct.ts +34 -0
  14. package/lib/login-adapters/types.ts +21 -0
  15. package/package.json +51 -0
  16. package/playwright.config.ts +206 -0
  17. package/scripts/combine_videos.py +250 -0
  18. package/tests/suite/01-preflight.spec.ts +147 -0
  19. package/tests/suite/02-account-management.spec.ts +113 -0
  20. package/tests/suite/03-create-dataverse.spec.ts +114 -0
  21. package/tests/suite/04-publish-dataverse.spec.ts +33 -0
  22. package/tests/suite/05-theme-widgets.spec.ts +65 -0
  23. package/tests/suite/06-theme-widgets-edit.spec.ts +60 -0
  24. package/tests/suite/10-assign-user-group-roles.spec.ts +34 -0
  25. package/tests/suite/11-create-edit-metadata-template.spec.ts +61 -0
  26. package/tests/suite/12-create-dataverse-collection.spec.ts +27 -0
  27. package/tests/suite/13-dataset-actions.spec.ts +105 -0
  28. package/tests/suite/14-browse-dataset-records.spec.ts +32 -0
  29. package/tests/suite/15-search-dataset-records.spec.ts +26 -0
  30. package/tests/suite/16-view-dataset-version-history.spec.ts +28 -0
  31. package/tests/suite/17-download-dataset-files.spec.ts +35 -0
  32. package/tests/suite/assets/footer.png +0 -0
  33. package/tests/suite/assets/logo.png +0 -0
  34. package/tests/suite/assets/thumbnail.png +0 -0
  35. package/tests/suite/auth.setup.ts +71 -0
  36. package/tests/suite/s02-state.ts +35 -0
  37. package/tests/suite/test-data/replaced-sample-dataset-file.txt +7 -0
  38. package/tests/suite/test-data/sample-dataset-file-2.txt +7 -0
  39. package/tests/suite/test-data/sample-dataset-file.txt +7 -0
  40. package/tests/suite/tsconfig.json +10 -0
  41. package/tsconfig.json +15 -0
@@ -0,0 +1,56 @@
1
+ import type { Page } from "@playwright/test";
2
+ import type { LoginAdapter, LoginCredentials } from "./types";
3
+ import { handleDuoMfa } from "./duo-mfa";
4
+
5
+ /**
6
+ * InCommonSeamlessAccessAdapter
7
+ *
8
+ * Logs in via the **InCommon / SeamlessAccess** waypoint that appears on the
9
+ * standard Dataverse login page ("Log In via Your Institution").
10
+ *
11
+ * Flow:
12
+ * 1. Click "Log In via Your Institution"
13
+ * 2. Land on wayfinder.incommon.org → click the SeamlessAccess button
14
+ * 3. Land on service.seamlessaccess.org → search for the institution
15
+ * 4. Select the institution link → forward to sso.unc.edu
16
+ * 5. Fill username / password on UNC SSO
17
+ * 6. Handle optional Duo MFA challenge
18
+ *
19
+ * Required env vars (read at call time — never at import time):
20
+ * INCOMMON_INSTITUTION_SEARCH Text to type into the SeamlessAccess search
21
+ * box (defaults to "chapel hill")
22
+ * INCOMMON_INSTITUTION_LINK Accessible name (or partial) of the result
23
+ * link to click (defaults to
24
+ * "University of North Carolina")
25
+ */
26
+ export class InCommonSeamlessAccessAdapter implements LoginAdapter {
27
+ async login(page: Page, credentials: LoginCredentials): Promise<void> {
28
+ const institutionSearch =
29
+ process.env.INCOMMON_INSTITUTION_SEARCH ?? "chapel hill";
30
+ const institutionLink =
31
+ process.env.INCOMMON_INSTITUTION_LINK ?? "University of North Carolina";
32
+
33
+ // 1. Click "Log In via Your Institution" on the Dataverse login page
34
+ await page.waitForURL(/loginpage/);
35
+ await page.getByRole("link", { name: /Log In via Your Inst/i }).click();
36
+
37
+ // 2. InCommon wayfinder → SeamlessAccess button
38
+ await page.waitForURL(/wayfinder\.incommon\.org/);
39
+ await page.locator("a.d-flex.sa-button").click();
40
+
41
+ // 3. SeamlessAccess → search for institution
42
+ await page.waitForURL(/service\.seamlessaccess\.org/);
43
+ await page.locator("#searchinput").type(institutionSearch);
44
+ await page.getByRole("link", { name: institutionLink }).click();
45
+
46
+ // 4. UNC SSO credential entry
47
+ await page.waitForURL(/sso\.unc\.edu/);
48
+ await page.locator("#username").fill(credentials.username);
49
+ await page.locator("#nextBtn").click();
50
+ await page.locator("#password").fill(credentials.password);
51
+ await page.locator("#submitBtn").click();
52
+
53
+ // 5. Handle Duo MFA (no-op if device is already trusted)
54
+ await handleDuoMfa(page);
55
+ }
56
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Login Adapter Factory
3
+ *
4
+ * Returns the correct LoginAdapter implementation based on the
5
+ * `LOGIN_ADAPTER` environment variable.
6
+ *
7
+ * Supported adapter names (case-insensitive):
8
+ *
9
+ * shibboleth-direct – Direct IdP selector embedded in the Dataverse
10
+ * login page (e.g. #idpSelectSelector)
11
+ * incommon-seamlessaccess – InCommon / SeamlessAccess waypoint flow
12
+ * builtin – Dataverse built-in username / password form
13
+ *
14
+ * Example .env entry:
15
+ *
16
+ * LOGIN_ADAPTER=incommon-seamlessaccess
17
+ *
18
+ * Usage in a setup file:
19
+ *
20
+ * import { getLoginAdapter } from "../../lib/login-adapters";
21
+ * const adapter = getLoginAdapter();
22
+ * await adapter.login(page, credentials);
23
+ */
24
+
25
+ export type { LoginAdapter, LoginCredentials, LoginAdapterName } from "./types";
26
+ export { ShibbolethDirectAdapter } from "./shibboleth-direct";
27
+ export { InCommonSeamlessAccessAdapter } from "./incommon-seamlessaccess";
28
+ export { BuiltinAdapter } from "./builtin";
29
+
30
+ import type { LoginAdapter, LoginAdapterName } from "./types";
31
+ import { ShibbolethDirectAdapter } from "./shibboleth-direct";
32
+ import { InCommonSeamlessAccessAdapter } from "./incommon-seamlessaccess";
33
+ import { BuiltinAdapter } from "./builtin";
34
+
35
+ /**
36
+ * Returns the LoginAdapter configured via the `LOGIN_ADAPTER` env var.
37
+ *
38
+ * @throws {Error} When the env var is missing or the adapter name is unknown.
39
+ */
40
+ export function getLoginAdapter(): LoginAdapter {
41
+ const adapterName = process.env.LOGIN_ADAPTER;
42
+
43
+ if (!adapterName) {
44
+ throw new Error(
45
+ `Missing required environment variable "LOGIN_ADAPTER". ` +
46
+ `Set it to one of: shibboleth-direct, incommon-seamlessaccess, builtin.`,
47
+ );
48
+ }
49
+
50
+ const normalized = adapterName.trim().toLowerCase() as LoginAdapterName;
51
+
52
+ switch (normalized) {
53
+ case "shibboleth-direct":
54
+ return new ShibbolethDirectAdapter();
55
+
56
+ case "incommon-seamlessaccess":
57
+ return new InCommonSeamlessAccessAdapter();
58
+
59
+ case "builtin":
60
+ return new BuiltinAdapter();
61
+
62
+ default:
63
+ throw new Error(
64
+ `Unknown login adapter "${adapterName}" specified in "LOGIN_ADAPTER". ` +
65
+ `Valid options are: shibboleth-direct, incommon-seamlessaccess, builtin.`,
66
+ );
67
+ }
68
+ }
@@ -0,0 +1,34 @@
1
+ import type { Page } from "@playwright/test";
2
+ import type { LoginAdapter, LoginCredentials } from "./types";
3
+ import { handleDuoMfa } from "./duo-mfa";
4
+
5
+ /**
6
+ * ShibbolethDirectAdapter
7
+ *
8
+ * Logs in via a **direct** Shibboleth IdP selector that is embedded on the
9
+ * Dataverse login page itself (no InCommon / SeamlessAccess waypoint).
10
+ *
11
+ * This is the adapter used by the 21 CFR Part 11 suite, where the target
12
+ * instance hosts a custom IdP dropdown (e.g. `#idpSelectSelector`) that lets
13
+ * the user pick `https://sso.unc.edu/idp` before being forwarded to UNC SSO.
14
+ *
15
+ * Required env vars (read at call time — never at import time):
16
+ * IDP_SELECTOR_VALUE The <option> value to select in #idpSelectSelector
17
+ * (defaults to "https://sso.unc.edu/idp")
18
+ */
19
+ export class ShibbolethDirectAdapter implements LoginAdapter {
20
+ async login(page: Page, credentials: LoginCredentials): Promise<void> {
21
+ const idpValue =
22
+ process.env.IDP_SELECTOR_VALUE ?? "https://sso.unc.edu/idp";
23
+
24
+ await page.locator("#idpSelectSelector").selectOption(idpValue);
25
+ await page.getByRole("button", { name: "Continue" }).click({ force: true });
26
+
27
+ await page.locator("#username").fill(credentials.username);
28
+ await page.locator("#nextBtn").click();
29
+ await page.locator("#password").fill(credentials.password);
30
+ await page.locator("#submitBtn").click();
31
+
32
+ await handleDuoMfa(page);
33
+ }
34
+ }
@@ -0,0 +1,21 @@
1
+ import type { Page } from "@playwright/test";
2
+
3
+ export interface LoginCredentials {
4
+ username: string;
5
+ password: string;
6
+ }
7
+
8
+ /**
9
+ * A LoginAdapter encapsulates one complete end-to-end authentication flow,
10
+ * starting from the Dataverse home page (after the "Log In" button has been
11
+ * confirmed visible) and finishing with the browser landed on an authenticated
12
+ * Dataverse page.
13
+ */
14
+ export interface LoginAdapter {
15
+ login(page: Page, credentials: LoginCredentials): Promise<void>;
16
+ }
17
+
18
+ export type LoginAdapterName =
19
+ | "shibboleth-direct"
20
+ | "incommon-seamlessaccess"
21
+ | "builtin";
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "kunai-runner",
3
+ "version": "6.10.101",
4
+ "description": "High-performance Dataverse Playwright frontend testing framework and E2E automation scaffolding.",
5
+ "directories": {
6
+ "doc": "docs",
7
+ "lib": "lib",
8
+ "test": "tests"
9
+ },
10
+ "files": [
11
+ "lib/",
12
+ "tests/",
13
+ "scripts/",
14
+ "docs/",
15
+ "playwright.config.ts",
16
+ "tsconfig.json",
17
+ ".env.example",
18
+ "README.md"
19
+ ],
20
+ "scripts": {
21
+ "test": "echo \"Error: no test specified\" && exit 1"
22
+ },
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "git+https://github.com/uncch-rdmc/kunai-runner.git"
26
+ },
27
+ "keywords": [
28
+ "playwright",
29
+ "dataverse",
30
+ "e2e",
31
+ "testing"
32
+ ],
33
+ "author": "",
34
+ "license": "ISC",
35
+ "type": "commonjs",
36
+ "publishConfig": {
37
+ "access": "public"
38
+ },
39
+ "bugs": {
40
+ "url": "https://github.com/uncch-rdmc/kunai-runner/issues"
41
+ },
42
+ "homepage": "https://github.com/uncch-rdmc/kunai-runner#readme",
43
+ "devDependencies": {
44
+ "@playwright/test": "^1.61.1",
45
+ "@types/node": "^26.1.0"
46
+ },
47
+ "dependencies": {
48
+ "dotenv": "^17.4.2",
49
+ "typescript": "^6.0.3"
50
+ }
51
+ }
@@ -0,0 +1,206 @@
1
+ import { defineConfig } from "@playwright/test";
2
+ import fs from "fs";
3
+ import path from "path";
4
+ const process = (globalThis as any).process;
5
+
6
+ // Derive the auth file path at config-load time so both the setup project
7
+ // and the suite project reference the same per-endpoint, per-browser file.
8
+ // We inline the slug logic here (rather than importing lib/auth-file.ts)
9
+ // because playwright.config.ts is evaluated before TypeScript compilation.
10
+ function authFilePath(browser: string): string {
11
+ const url = (process.env.BASE_URL ?? "default").trim();
12
+ const slug = url
13
+ .replace(/^https?:\/\//, "")
14
+ .replace(/\/$/, "")
15
+ .replace(/[^a-z0-9]/gi, "-")
16
+ .toLowerCase();
17
+ return `playwright/.auth/${slug}-${browser}.json`;
18
+ }
19
+
20
+ const AUTH_CHROMIUM = authFilePath("chromium");
21
+ const AUTH_FIREFOX = authFilePath("firefox");
22
+ const AUTH_WEBKIT = authFilePath("webkit");
23
+
24
+ /**
25
+ * Load environment variables from .env at the repository root.
26
+ * dotenv is a transitive dependency of @playwright/test — no extra install needed.
27
+ */
28
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
29
+ require("dotenv").config({ path: path.resolve(__dirname, ".env") });
30
+
31
+ /**
32
+ * See https://playwright.dev/docs/test-configuration.
33
+ */
34
+ export default defineConfig({
35
+ testDir: "./tests/suite",
36
+ timeout: 90000,
37
+ /* All tests within a project run in strict sequence; projects themselves
38
+ run one at a time because workers is 1. */
39
+ workers: 1,
40
+ fullyParallel: false,
41
+ /* Reporter to use. See https://playwright.dev/docs/test-reporters */
42
+ reporter: "html",
43
+ /* Shared settings for all projects. See https://playwright.dev/docs/api/class-testoptions. */
44
+ use: {
45
+ headless: true,
46
+
47
+ launchOptions: {
48
+ slowMo: 2000,
49
+ },
50
+
51
+ // Sets the default viewport size for all tests
52
+ viewport: { width: 1920, height: 1080 },
53
+
54
+ /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
55
+ trace: "on",
56
+
57
+ /* Always collect video and screenshots, even when tests pass. See https://playwright.dev/docs/video-and-screenshots */
58
+ video: {
59
+ mode: "on",
60
+ size: { width: 1920, height: 1080 },
61
+ },
62
+
63
+ screenshot: {
64
+ mode: "on",
65
+ fullPage: true,
66
+ },
67
+ },
68
+
69
+ /* Configure the output directory for test artifacts such as screenshots, videos, traces, etc. */
70
+ outputDir: "test-results/",
71
+
72
+ /* Configure projects
73
+ *
74
+ * Execution order (workers: 1 → strictly sequential):
75
+ *
76
+ * preflight
77
+ * → setup-chromium → suite-chromium
78
+ * → setup-firefox → suite-firefox
79
+ * → setup-webkit → suite-webkit
80
+ *
81
+ * "suite-firefox" declares a dependency on "suite-chromium" and
82
+ * "suite-webkit" declares a dependency on "suite-firefox", which
83
+ * enforces the Chromium → Firefox → WebKit ordering even if workers
84
+ * is ever increased.
85
+ */
86
+ projects: [
87
+ /* =========================================================
88
+ 1. Preflight — Chromium, no auth required, @standard only.
89
+ The test itself returns immediately when SKIP_PREFLIGHT=true.
90
+ ========================================================= */
91
+ {
92
+ name: "preflight",
93
+ testMatch: /suite\/01-preflight\.spec\.ts/,
94
+ tsconfig: "./tests/suite/tsconfig.json",
95
+ use: {
96
+ browserName: "chromium",
97
+ baseURL: process.env.BASE_URL,
98
+ },
99
+ },
100
+
101
+ /* =========================================================
102
+ 2a. Auth setup — Chromium
103
+ ========================================================= */
104
+ {
105
+ name: "setup-chromium",
106
+ testMatch: /suite\/auth\.setup\.ts/,
107
+ tsconfig: "./tests/suite/tsconfig.json",
108
+ use: {
109
+ browserName: "chromium",
110
+ baseURL: process.env.BASE_URL,
111
+ ...(fs.existsSync(AUTH_CHROMIUM) && {
112
+ storageState: AUTH_CHROMIUM,
113
+ }),
114
+ },
115
+ dependencies: [],
116
+ },
117
+
118
+ /* =========================================================
119
+ 2b. Auth setup — Firefox
120
+ ========================================================= */
121
+ {
122
+ name: "setup-firefox",
123
+ testMatch: /suite\/auth\.setup\.ts/,
124
+ tsconfig: "./tests/suite/tsconfig.json",
125
+ use: {
126
+ browserName: "firefox",
127
+ baseURL: process.env.BASE_URL,
128
+ ...(fs.existsSync(AUTH_FIREFOX) && {
129
+ storageState: AUTH_FIREFOX,
130
+ }),
131
+ },
132
+ dependencies: [],
133
+ },
134
+
135
+ /* =========================================================
136
+ 2c. Auth setup — WebKit (Safari)
137
+ ========================================================= */
138
+ {
139
+ name: "setup-webkit",
140
+ testMatch: /suite\/auth\.setup\.ts/,
141
+ tsconfig: "./tests/suite/tsconfig.json",
142
+ use: {
143
+ browserName: "webkit",
144
+ baseURL: process.env.BASE_URL,
145
+ ...(fs.existsSync(AUTH_WEBKIT) && {
146
+ storageState: AUTH_WEBKIT,
147
+ }),
148
+ },
149
+ dependencies: [],
150
+ },
151
+
152
+ /* =========================================================
153
+ 3a. Main test suite — Chromium
154
+ Runs first. Firefox suite depends on this completing.
155
+ ========================================================= */
156
+ {
157
+ name: "suite-chromium",
158
+ testMatch: /suite\/(?!auth\.setup|01-preflight)\d+.*\.spec\.ts/,
159
+ tsconfig: "./tests/suite/tsconfig.json",
160
+ use: {
161
+ browserName: "chromium",
162
+ baseURL: process.env.BASE_URL,
163
+ ...(fs.existsSync(AUTH_CHROMIUM) && {
164
+ storageState: AUTH_CHROMIUM,
165
+ }),
166
+ },
167
+ dependencies: ["setup-chromium"],
168
+ },
169
+
170
+ /* =========================================================
171
+ 3b. Main test suite — Firefox
172
+ Runs after Chromium suite completes.
173
+ ========================================================= */
174
+ {
175
+ name: "suite-firefox",
176
+ testMatch: /suite\/(?!auth\.setup|01-preflight)\d+.*\.spec\.ts/,
177
+ tsconfig: "./tests/suite/tsconfig.json",
178
+ use: {
179
+ browserName: "firefox",
180
+ baseURL: process.env.BASE_URL,
181
+ ...(fs.existsSync(AUTH_FIREFOX) && {
182
+ storageState: AUTH_FIREFOX,
183
+ }),
184
+ },
185
+ dependencies: ["setup-firefox", "suite-chromium"],
186
+ },
187
+
188
+ /* =========================================================
189
+ 3c. Main test suite — WebKit (Safari)
190
+ Runs after Firefox suite completes.
191
+ ========================================================= */
192
+ {
193
+ name: "suite-webkit",
194
+ testMatch: /suite\/(?!auth\.setup|01-preflight)\d+.*\.spec\.ts/,
195
+ tsconfig: "./tests/suite/tsconfig.json",
196
+ use: {
197
+ browserName: "webkit",
198
+ baseURL: process.env.BASE_URL,
199
+ ...(fs.existsSync(AUTH_WEBKIT) && {
200
+ storageState: AUTH_WEBKIT,
201
+ }),
202
+ },
203
+ dependencies: ["setup-webkit", "suite-firefox"],
204
+ },
205
+ ],
206
+ });
@@ -0,0 +1,250 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ combine_videos.py
4
+ -----------------
5
+ Walks the Playwright test-results directory, collects every .webm video
6
+ whose parent folder starts with the given suite prefix, concatenates them
7
+ in sorted order, and re-encodes the result as an MPEG-4 file (H.264 + AAC).
8
+
9
+ Usage
10
+ -----
11
+ # Unified suite (all tests)
12
+ python scripts/combine_videos.py --suite suite
13
+
14
+ # Custom output filename
15
+ python scripts/combine_videos.py --suite suite --output my_demo.mp4
16
+
17
+ # Absolute output path
18
+ python scripts/combine_videos.py --suite suite --output ~/Desktop/demo.mp4
19
+
20
+ Requirements
21
+ ------------
22
+ - Python 3.8+
23
+ - ffmpeg must be on $PATH (brew install ffmpeg / apt install ffmpeg)
24
+ """
25
+
26
+ import argparse
27
+ import os
28
+ import subprocess
29
+ import sys
30
+ import tempfile
31
+ from pathlib import Path
32
+
33
+ # ---------------------------------------------------------------------------
34
+ # Configuration
35
+ # ---------------------------------------------------------------------------
36
+
37
+ RESULTS_ROOT = Path(__file__).resolve().parent.parent / "test-results"
38
+
39
+
40
+ # ---------------------------------------------------------------------------
41
+ # Helpers
42
+ # ---------------------------------------------------------------------------
43
+
44
+ def find_webm_files(results_root: Path, suite: str) -> list[Path]:
45
+ """
46
+ Recursively walk *results_root* and return a sorted list of .webm files
47
+ that live inside folders whose name starts with *suite* + '-'.
48
+
49
+ Playwright names each test's output folder as:
50
+ <suite>-<spec-slug>-<hash>-<test-title>-<project>/
51
+ All of these sit directly under test-results/, NOT inside a suite
52
+ sub-directory. We therefore search the root and filter by prefix.
53
+ """
54
+ webm_files: list[Path] = []
55
+
56
+ for root, _dirs, files in os.walk(results_root):
57
+ root_path = Path(root)
58
+ # Accept the folder if any ancestor (relative to results_root)
59
+ # starts with the suite prefix, OR if the folder itself does.
60
+ try:
61
+ rel = root_path.relative_to(results_root)
62
+ except ValueError:
63
+ continue
64
+
65
+ parts = rel.parts
66
+ if not parts:
67
+ continue # the root itself — skip
68
+
69
+ # Playwright appends the project name as the SUFFIX of each folder:
70
+ # <spec-slug>-<test-title>-<project>
71
+ # We therefore filter by suffix, not prefix.
72
+ if not parts[0].endswith(f"-{suite}"):
73
+ continue
74
+
75
+ for filename in files:
76
+ if filename.lower().endswith(".webm"):
77
+ webm_files.append(root_path / filename)
78
+
79
+ if not webm_files:
80
+ print(
81
+ f"[ERROR] No .webm files found under: {results_root}\n"
82
+ f" (looked for folders ending with '-{suite}')",
83
+ file=sys.stderr,
84
+ )
85
+ sys.exit(1)
86
+
87
+ webm_files.sort() # sort by full path → preserves spec/test ordering
88
+ return webm_files
89
+
90
+
91
+ def check_ffmpeg() -> None:
92
+ """Abort early with a helpful message if ffmpeg is not available."""
93
+ try:
94
+ subprocess.run(
95
+ ["ffmpeg", "-version"],
96
+ stdout=subprocess.DEVNULL,
97
+ stderr=subprocess.DEVNULL,
98
+ check=True,
99
+ )
100
+ except (FileNotFoundError, subprocess.CalledProcessError):
101
+ print(
102
+ "[ERROR] ffmpeg not found on PATH.\n"
103
+ " macOS : brew install ffmpeg\n"
104
+ " Ubuntu: sudo apt install ffmpeg",
105
+ file=sys.stderr,
106
+ )
107
+ sys.exit(1)
108
+
109
+
110
+ def write_concat_list(webm_files: list[Path], list_path: Path) -> None:
111
+ """
112
+ Write an ffmpeg concat-demuxer input file.
113
+
114
+ Each line looks like:
115
+ file '/absolute/path/to/clip.webm'
116
+ Paths are written as absolute POSIX strings so ffmpeg can find them
117
+ regardless of where the script is invoked from.
118
+ """
119
+ with list_path.open("w", encoding="utf-8") as fh:
120
+ for p in webm_files:
121
+ # ffmpeg requires single-quoted paths; escape any embedded quotes
122
+ safe = str(p.resolve()).replace("'", "'\\''")
123
+ fh.write(f"file '{safe}'\n")
124
+
125
+
126
+ def concat_and_encode(list_path: Path, output_path: Path) -> None:
127
+ """
128
+ Use ffmpeg's concat demuxer to stitch the clips together and
129
+ re-encode as MPEG-4 / H.264 + AAC in a single pass.
130
+
131
+ Key flags
132
+ ---------
133
+ -f concat use the concat demuxer (works with .webm inputs)
134
+ -safe 0 allow absolute paths in the list file
135
+ -c:v libx264 H.264 video codec
136
+ -crf 22 quality level (18=near-lossless, 28=smaller file)
137
+ -preset fast encoding speed/compression trade-off
138
+ -c:a aac AAC audio codec
139
+ -b:a 128k audio bitrate
140
+ -movflags +faststart move MOOV atom to the front for web streaming
141
+ -y overwrite output without prompting
142
+ """
143
+ cmd = [
144
+ "ffmpeg",
145
+ "-f", "concat",
146
+ "-safe", "0",
147
+ "-i", str(list_path),
148
+ "-c:v", "libx264",
149
+ "-crf", "22",
150
+ "-preset", "fast",
151
+ "-c:a", "aac",
152
+ "-b:a", "128k",
153
+ "-movflags", "+faststart",
154
+ "-y",
155
+ str(output_path),
156
+ ]
157
+
158
+ print("\n[ffmpeg] Running:")
159
+ print(" " + " ".join(cmd) + "\n")
160
+
161
+ result = subprocess.run(cmd)
162
+
163
+ if result.returncode != 0:
164
+ print("\n[ERROR] ffmpeg exited with a non-zero status.", file=sys.stderr)
165
+ sys.exit(result.returncode)
166
+
167
+
168
+ # ---------------------------------------------------------------------------
169
+ # Entry-point
170
+ # ---------------------------------------------------------------------------
171
+
172
+ def main() -> None:
173
+ parser = argparse.ArgumentParser(
174
+ description="Combine Playwright .webm videos into a single MP4."
175
+ )
176
+ parser.add_argument(
177
+ "--suite",
178
+ required=True,
179
+ help=(
180
+ "Playwright project name prefix to collect videos for "
181
+ "(e.g. 'suite' for the unified suite)."
182
+ ),
183
+ )
184
+ parser.add_argument(
185
+ "--output",
186
+ default=None,
187
+ help=(
188
+ "Destination MP4 filename (default: test-results/<suite>_combined.mp4)."
189
+ ),
190
+ )
191
+ parser.add_argument(
192
+ "--results-root",
193
+ default=str(RESULTS_ROOT),
194
+ help=f"Override the test-results root directory (default: {RESULTS_ROOT}).",
195
+ )
196
+
197
+ args = parser.parse_args()
198
+
199
+ results_root = Path(args.results_root)
200
+
201
+ if not results_root.is_dir():
202
+ print(
203
+ f"[ERROR] test-results directory not found: {results_root}\n"
204
+ " Make sure you have run the Playwright tests first so that "
205
+ "test-results/ is populated.",
206
+ file=sys.stderr,
207
+ )
208
+ sys.exit(1)
209
+
210
+ if args.output:
211
+ raw = Path(args.output)
212
+ # Keep absolute paths as-is; resolve relative paths into test-results/
213
+ # so the file never lands in the repo root and gets accidentally committed.
214
+ output_path = raw if raw.is_absolute() else results_root / raw
215
+ else:
216
+ output_path = results_root / f"{args.suite}_combined.mp4"
217
+
218
+ # -----------------------------------------------------------------------
219
+ check_ffmpeg()
220
+
221
+ webm_files = find_webm_files(results_root, args.suite)
222
+
223
+ print(f"[INFO] Suite : {args.suite}")
224
+ print(f"[INFO] Source dir : {results_root} (suffix: -{args.suite})")
225
+ print(f"[INFO] Output : {output_path}")
226
+ print(f"[INFO] Clips found: {len(webm_files)}")
227
+ for i, f in enumerate(webm_files, 1):
228
+ print(f" {i:>3}. {f.relative_to(Path(args.results_root))}")
229
+
230
+ # Write the temporary concat list inside the results root so that
231
+ # relative paths (if any) resolve correctly.
232
+ with tempfile.NamedTemporaryFile(
233
+ mode="w",
234
+ suffix="_concat_list.txt",
235
+ dir=Path(args.results_root),
236
+ delete=False,
237
+ ) as tmp:
238
+ tmp_path = Path(tmp.name)
239
+
240
+ try:
241
+ write_concat_list(webm_files, tmp_path)
242
+ concat_and_encode(tmp_path, output_path)
243
+ finally:
244
+ tmp_path.unlink(missing_ok=True)
245
+
246
+ print(f"\n[DONE] Combined video saved to:\n {output_path.resolve()}")
247
+
248
+
249
+ if __name__ == "__main__":
250
+ main()