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.
- package/.env.example +68 -0
- package/README.md +17 -0
- package/docs/01_shibboleth_auth.md +122 -0
- package/docs/02_versioning_and_release.md +146 -0
- package/docs/backlog.md +71 -0
- package/docs/combine_videos.md +109 -0
- package/docs/test_data.md +34 -0
- package/lib/auth-file.ts +22 -0
- package/lib/login-adapters/builtin.ts +42 -0
- package/lib/login-adapters/duo-mfa.ts +41 -0
- package/lib/login-adapters/incommon-seamlessaccess.ts +56 -0
- package/lib/login-adapters/index.ts +68 -0
- package/lib/login-adapters/shibboleth-direct.ts +34 -0
- package/lib/login-adapters/types.ts +21 -0
- package/package.json +51 -0
- package/playwright.config.ts +206 -0
- package/scripts/combine_videos.py +250 -0
- package/tests/suite/01-preflight.spec.ts +147 -0
- package/tests/suite/02-account-management.spec.ts +113 -0
- package/tests/suite/03-create-dataverse.spec.ts +114 -0
- package/tests/suite/04-publish-dataverse.spec.ts +33 -0
- package/tests/suite/05-theme-widgets.spec.ts +65 -0
- package/tests/suite/06-theme-widgets-edit.spec.ts +60 -0
- package/tests/suite/10-assign-user-group-roles.spec.ts +34 -0
- package/tests/suite/11-create-edit-metadata-template.spec.ts +61 -0
- package/tests/suite/12-create-dataverse-collection.spec.ts +27 -0
- package/tests/suite/13-dataset-actions.spec.ts +105 -0
- package/tests/suite/14-browse-dataset-records.spec.ts +32 -0
- package/tests/suite/15-search-dataset-records.spec.ts +26 -0
- package/tests/suite/16-view-dataset-version-history.spec.ts +28 -0
- package/tests/suite/17-download-dataset-files.spec.ts +35 -0
- package/tests/suite/assets/footer.png +0 -0
- package/tests/suite/assets/logo.png +0 -0
- package/tests/suite/assets/thumbnail.png +0 -0
- package/tests/suite/auth.setup.ts +71 -0
- package/tests/suite/s02-state.ts +35 -0
- package/tests/suite/test-data/replaced-sample-dataset-file.txt +7 -0
- package/tests/suite/test-data/sample-dataset-file-2.txt +7 -0
- package/tests/suite/test-data/sample-dataset-file.txt +7 -0
- package/tests/suite/tsconfig.json +10 -0
- 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()
|