@cosmicdrift/kumiko-testing 0.295.0
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/LICENSE +57 -0
- package/README.md +49 -0
- package/bin/kumiko-testing.ts +76 -0
- package/package.json +93 -0
- package/src/bunfig.ts +94 -0
- package/src/changes.json +8 -0
- package/src/e2e/auth-kit.ts +151 -0
- package/src/e2e/constants.ts +27 -0
- package/src/e2e/define-app-e2e-config.ts +152 -0
- package/src/e2e/index.ts +56 -0
- package/src/e2e/mail-capture.ts +43 -0
- package/src/e2e/pin-english-locale.ts +14 -0
- package/src/e2e/poll.ts +39 -0
- package/src/e2e/screenshot-dir.ts +28 -0
- package/src/e2e/screenshots.ts +312 -0
- package/src/e2e/seed-contract.ts +85 -0
- package/src/e2e/seed-route.ts +135 -0
- package/src/e2e/seeded-tenant-fixture.ts +123 -0
- package/src/e2e/timeouts.ts +10 -0
- package/src/index.ts +18 -0
- package/src/integration-runner.ts +37 -0
- package/src/preload/ci-log.ts +2 -0
- package/src/preload/env.ts +2 -0
- package/src/preload/real.ts +4 -0
- package/src/preload/scrub-env.ts +3 -0
- package/src/preload/service-env-defaults.ts +14 -0
- package/src/preload/temporal.ts +6 -0
- package/src/provider-env-keys.ts +16 -0
- package/src/scaffold.ts +203 -0
- package/src/seed-tenant.ts +171 -0
- package/src/seed-types.ts +49 -0
- package/src/setup-app-test-stack.ts +26 -0
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { cpus } from "node:os";
|
|
3
|
+
import {
|
|
4
|
+
defineConfig,
|
|
5
|
+
devices,
|
|
6
|
+
type PlaywrightTestConfig,
|
|
7
|
+
type PlaywrightTestOptions,
|
|
8
|
+
type PlaywrightWorkerOptions,
|
|
9
|
+
type Project,
|
|
10
|
+
} from "@playwright/test";
|
|
11
|
+
import {
|
|
12
|
+
E2E_WORKERS_ENV,
|
|
13
|
+
PLAYWRIGHT_DEMO_ENV,
|
|
14
|
+
REAL_PROVIDERS_ENV,
|
|
15
|
+
SEED_ENABLE_ENV,
|
|
16
|
+
SEED_TOKEN_ENV,
|
|
17
|
+
} from "./constants";
|
|
18
|
+
import { screenshotSpecsIgnore } from "./screenshot-dir";
|
|
19
|
+
import { E2E_TIMEOUT_MS } from "./timeouts";
|
|
20
|
+
|
|
21
|
+
const TEMPLATE_OWNED_PROJECT_KEYS = [
|
|
22
|
+
"fullyParallel",
|
|
23
|
+
"retries",
|
|
24
|
+
"timeout",
|
|
25
|
+
"workers",
|
|
26
|
+
"expect",
|
|
27
|
+
] as const;
|
|
28
|
+
const TEMPLATE_OWNED_USE_KEYS = ["actionTimeout", "navigationTimeout"] as const;
|
|
29
|
+
const REAL_SPEC_GLOB = "**/*.real.spec.ts";
|
|
30
|
+
const RESERVED_ENV_KEYS = ["PORT", SEED_ENABLE_ENV, SEED_TOKEN_ENV] as const;
|
|
31
|
+
|
|
32
|
+
type ProjectUse = Partial<PlaywrightTestOptions & PlaywrightWorkerOptions>;
|
|
33
|
+
|
|
34
|
+
export type E2eProject = Omit<Project, (typeof TEMPLATE_OWNED_PROJECT_KEYS)[number] | "use"> & {
|
|
35
|
+
readonly use?: Omit<ProjectUse, (typeof TEMPLATE_OWNED_USE_KEYS)[number]>;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export type AppE2eConfigInput = {
|
|
39
|
+
readonly port: number;
|
|
40
|
+
readonly env?: Readonly<Record<string, string>>;
|
|
41
|
+
readonly locale?: string;
|
|
42
|
+
readonly projects?: readonly E2eProject[];
|
|
43
|
+
readonly serverEntry?: string;
|
|
44
|
+
readonly testDir?: string;
|
|
45
|
+
readonly testMatch?: string | RegExp | readonly (string | RegExp)[];
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
function mutableTestMatch(
|
|
49
|
+
match: NonNullable<AppE2eConfigInput["testMatch"]>,
|
|
50
|
+
): string | RegExp | (string | RegExp)[] {
|
|
51
|
+
return typeof match === "string" || match instanceof RegExp ? match : [...match];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function resolveE2eWorkers(
|
|
55
|
+
env: Readonly<Record<string, string | undefined>> = process.env,
|
|
56
|
+
cpuCount: number = cpus().length,
|
|
57
|
+
): number {
|
|
58
|
+
const override = env[E2E_WORKERS_ENV];
|
|
59
|
+
if (override === undefined || override === "") {
|
|
60
|
+
return Math.max(2, Math.min(4, Math.floor(cpuCount / 2)));
|
|
61
|
+
}
|
|
62
|
+
const workers = Number(override);
|
|
63
|
+
if (!Number.isInteger(workers) || workers < 1) {
|
|
64
|
+
throw new Error(`${E2E_WORKERS_ENV} must be a positive integer, got "${override}"`);
|
|
65
|
+
}
|
|
66
|
+
return workers;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function assertTemplateOwnedKeysUnset(project: E2eProject): void {
|
|
70
|
+
const name = project.name ?? "(unnamed)";
|
|
71
|
+
const setKeys: readonly string[] = Object.keys(project);
|
|
72
|
+
const useKeys: readonly string[] = Object.keys(project.use ?? {});
|
|
73
|
+
const offending = [
|
|
74
|
+
...TEMPLATE_OWNED_PROJECT_KEYS.filter((key) => setKeys.includes(key)),
|
|
75
|
+
...TEMPLATE_OWNED_USE_KEYS.filter((key) => useKeys.includes(key)).map((key) => `use.${key}`),
|
|
76
|
+
];
|
|
77
|
+
if (offending.length > 0) {
|
|
78
|
+
throw new Error(
|
|
79
|
+
`defineAppE2eConfig: project "${name}" sets ${offending.join(", ")}; timeouts, retries and workers belong to the template. Diagnose per docs/guides/test-failures.md instead of raising them.`,
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function assertNoReservedEnv(env: Readonly<Record<string, string>>): void {
|
|
85
|
+
const reserved = RESERVED_ENV_KEYS.filter((key) => key in env);
|
|
86
|
+
if (reserved.length > 0) {
|
|
87
|
+
throw new Error(
|
|
88
|
+
`defineAppE2eConfig: env must not set ${reserved.join(", ")}; the template owns them.`,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Workers re-evaluate this config but inherit the runner's env, so ??= keeps one token per run.
|
|
94
|
+
function seedTokenForRun(): string {
|
|
95
|
+
process.env[SEED_TOKEN_ENV] ??= randomUUID();
|
|
96
|
+
return process.env[SEED_TOKEN_ENV];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function defineAppE2eConfig(input: AppE2eConfigInput): PlaywrightTestConfig {
|
|
100
|
+
const {
|
|
101
|
+
port,
|
|
102
|
+
env = {},
|
|
103
|
+
locale = "en",
|
|
104
|
+
projects = [],
|
|
105
|
+
serverEntry = "e2e/server.ts",
|
|
106
|
+
testDir = "./e2e",
|
|
107
|
+
testMatch,
|
|
108
|
+
} = input;
|
|
109
|
+
assertNoReservedEnv(env);
|
|
110
|
+
for (const project of projects) assertTemplateOwnedKeysUnset(project);
|
|
111
|
+
const baseURL = `http://localhost:${port}`;
|
|
112
|
+
const realRun = process.env[REAL_PROVIDERS_ENV] === "1";
|
|
113
|
+
|
|
114
|
+
return defineConfig({
|
|
115
|
+
testDir,
|
|
116
|
+
...(realRun
|
|
117
|
+
? { testMatch: REAL_SPEC_GLOB }
|
|
118
|
+
: testMatch !== undefined && { testMatch: mutableTestMatch(testMatch) }),
|
|
119
|
+
testIgnore: [...screenshotSpecsIgnore(), ...(realRun ? [] : [REAL_SPEC_GLOB])],
|
|
120
|
+
fullyParallel: true,
|
|
121
|
+
forbidOnly: !!process.env["CI"],
|
|
122
|
+
retries: 0,
|
|
123
|
+
workers: resolveE2eWorkers(),
|
|
124
|
+
reporter: [["list"]],
|
|
125
|
+
timeout: E2E_TIMEOUT_MS.test,
|
|
126
|
+
expect: { timeout: E2E_TIMEOUT_MS.expect },
|
|
127
|
+
use: {
|
|
128
|
+
baseURL,
|
|
129
|
+
locale,
|
|
130
|
+
trace: "retain-on-failure",
|
|
131
|
+
screenshot: "only-on-failure",
|
|
132
|
+
actionTimeout: E2E_TIMEOUT_MS.action,
|
|
133
|
+
navigationTimeout: E2E_TIMEOUT_MS.navigation,
|
|
134
|
+
},
|
|
135
|
+
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }, ...projects],
|
|
136
|
+
webServer: {
|
|
137
|
+
command: `bun run ${serverEntry}`,
|
|
138
|
+
url: baseURL,
|
|
139
|
+
env: {
|
|
140
|
+
...PLAYWRIGHT_DEMO_ENV,
|
|
141
|
+
...env,
|
|
142
|
+
PORT: String(port),
|
|
143
|
+
[SEED_ENABLE_ENV]: "1",
|
|
144
|
+
[SEED_TOKEN_ENV]: seedTokenForRun(),
|
|
145
|
+
},
|
|
146
|
+
reuseExistingServer: false,
|
|
147
|
+
timeout: E2E_TIMEOUT_MS.webServer,
|
|
148
|
+
stdout: "pipe",
|
|
149
|
+
stderr: "pipe",
|
|
150
|
+
},
|
|
151
|
+
});
|
|
152
|
+
}
|
package/src/e2e/index.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
export { expect } from "@playwright/test";
|
|
2
|
+
export {
|
|
3
|
+
apiCommand,
|
|
4
|
+
apiQuery,
|
|
5
|
+
apiWrite,
|
|
6
|
+
createHttpApi,
|
|
7
|
+
csrfFetch,
|
|
8
|
+
csrfHeaderFromCookies,
|
|
9
|
+
type LoginCredentials,
|
|
10
|
+
loginViaApi,
|
|
11
|
+
loginViaUi,
|
|
12
|
+
totpCode,
|
|
13
|
+
} from "./auth-kit";
|
|
14
|
+
export {
|
|
15
|
+
CSRF_COOKIE_NAME,
|
|
16
|
+
CSRF_HEADER_NAME,
|
|
17
|
+
E2E_WORKERS_ENV,
|
|
18
|
+
KUMIKO_SECRETS_MASTER_KEY_V1,
|
|
19
|
+
PLAYWRIGHT_DEMO_ENV,
|
|
20
|
+
SEED_ENABLE_ENV,
|
|
21
|
+
SEED_TOKEN_ENV,
|
|
22
|
+
} from "./constants";
|
|
23
|
+
export {
|
|
24
|
+
type AppE2eConfigInput,
|
|
25
|
+
defineAppE2eConfig,
|
|
26
|
+
type E2eProject,
|
|
27
|
+
resolveE2eWorkers,
|
|
28
|
+
} from "./define-app-e2e-config";
|
|
29
|
+
export { mailCapture } from "./mail-capture";
|
|
30
|
+
export { pinEnglishLocale } from "./pin-english-locale";
|
|
31
|
+
export { pollForRow, waitForProjection } from "./poll";
|
|
32
|
+
export {
|
|
33
|
+
requireScreenshotDir,
|
|
34
|
+
SCREENSHOT_DIR_ENV,
|
|
35
|
+
screenshotSpecsIgnore,
|
|
36
|
+
} from "./screenshot-dir";
|
|
37
|
+
export {
|
|
38
|
+
applyDefaultTheme,
|
|
39
|
+
DEFAULT_THEMES,
|
|
40
|
+
type DefaultThemeId,
|
|
41
|
+
type FlatOptions,
|
|
42
|
+
findIdenticalThemeScreenshots,
|
|
43
|
+
type MatrixOptions,
|
|
44
|
+
runMatrix,
|
|
45
|
+
runScreenshots,
|
|
46
|
+
type Scenario,
|
|
47
|
+
type ThemeScreenshotDigest,
|
|
48
|
+
validateScenarios,
|
|
49
|
+
} from "./screenshots";
|
|
50
|
+
export type { CapturedMail } from "./seed-contract";
|
|
51
|
+
export {
|
|
52
|
+
type E2eSeededTenant,
|
|
53
|
+
type E2eSeedTenantOptions,
|
|
54
|
+
test,
|
|
55
|
+
} from "./seeded-tenant-fixture";
|
|
56
|
+
export { E2E_TIMEOUT_MS } from "./timeouts";
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { APIRequestContext } from "@playwright/test";
|
|
2
|
+
import { SEED_ROUTES, SEED_TOKEN_ENV, SEED_TOKEN_HEADER } from "./constants";
|
|
3
|
+
import { waitForProjection } from "./poll";
|
|
4
|
+
import { type CapturedMail, inboxResponseSchema } from "./seed-contract";
|
|
5
|
+
|
|
6
|
+
export function seedRouteHeaders(): Record<string, string> {
|
|
7
|
+
const token = process.env[SEED_TOKEN_ENV];
|
|
8
|
+
if (token === undefined || token === "") {
|
|
9
|
+
throw new Error(
|
|
10
|
+
`${SEED_TOKEN_ENV} is not set; build the Playwright config with defineAppE2eConfig`,
|
|
11
|
+
);
|
|
12
|
+
}
|
|
13
|
+
return { [SEED_TOKEN_HEADER]: token };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async function readInbox(
|
|
17
|
+
request: APIRequestContext,
|
|
18
|
+
tenantId: string,
|
|
19
|
+
to: string,
|
|
20
|
+
): Promise<readonly CapturedMail[]> {
|
|
21
|
+
const response = await request.get(SEED_ROUTES.inbox, {
|
|
22
|
+
headers: seedRouteHeaders(),
|
|
23
|
+
params: { tenantId, to },
|
|
24
|
+
});
|
|
25
|
+
if (!response.ok()) {
|
|
26
|
+
throw new Error(
|
|
27
|
+
`mailCapture: GET ${SEED_ROUTES.inbox} -> ${response.status()} ${await response.text()}`,
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
return inboxResponseSchema.parse(await response.json()).messages;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function mailCapture(
|
|
34
|
+
request: APIRequestContext,
|
|
35
|
+
tenantId: string,
|
|
36
|
+
to: string,
|
|
37
|
+
): Promise<readonly CapturedMail[]> {
|
|
38
|
+
return waitForProjection(
|
|
39
|
+
() => readInbox(request, tenantId, to),
|
|
40
|
+
(messages) => messages.length > 0,
|
|
41
|
+
`mailCapture: no mail arrived for ${to} in tenant ${tenantId}`,
|
|
42
|
+
);
|
|
43
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Page } from "@playwright/test";
|
|
2
|
+
|
|
3
|
+
const STORAGE_KEY = "kumiko:locale";
|
|
4
|
+
|
|
5
|
+
/** Docs screenshots are EN-only — call before the first navigation. */
|
|
6
|
+
export async function pinEnglishLocale(page: Page): Promise<void> {
|
|
7
|
+
await page.addInitScript((key: string) => {
|
|
8
|
+
try {
|
|
9
|
+
localStorage.setItem(key, "en");
|
|
10
|
+
} catch {
|
|
11
|
+
// Safari private mode etc. — Playwright locale still applies.
|
|
12
|
+
}
|
|
13
|
+
}, STORAGE_KEY);
|
|
14
|
+
}
|
package/src/e2e/poll.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { expect } from "@playwright/test";
|
|
2
|
+
import type { BoundApi } from "../seed-types";
|
|
3
|
+
import { E2E_TIMEOUT_MS } from "./timeouts";
|
|
4
|
+
|
|
5
|
+
export async function waitForProjection<T>(
|
|
6
|
+
read: () => Promise<T>,
|
|
7
|
+
isReady: (value: T) => boolean,
|
|
8
|
+
message = "waitForProjection: condition never became true",
|
|
9
|
+
): Promise<T> {
|
|
10
|
+
let ready: { readonly value: T } | undefined;
|
|
11
|
+
await expect
|
|
12
|
+
.poll(
|
|
13
|
+
async () => {
|
|
14
|
+
const value = await read();
|
|
15
|
+
if (isReady(value)) ready = { value };
|
|
16
|
+
return ready !== undefined;
|
|
17
|
+
},
|
|
18
|
+
{ message, timeout: E2E_TIMEOUT_MS.poll },
|
|
19
|
+
)
|
|
20
|
+
.toBe(true);
|
|
21
|
+
if (ready === undefined) throw new Error(message);
|
|
22
|
+
return ready.value;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function pollForRow<TRow extends { readonly id: string } = { readonly id: string }>(
|
|
26
|
+
api: Pick<BoundApi, "queryOk">,
|
|
27
|
+
queryType: string,
|
|
28
|
+
predicate: (row: TRow) => boolean,
|
|
29
|
+
payload: unknown = {},
|
|
30
|
+
): Promise<TRow> {
|
|
31
|
+
const rows = await waitForProjection(
|
|
32
|
+
async () => (await api.queryOk<{ readonly rows: readonly TRow[] }>(queryType, payload)).rows,
|
|
33
|
+
(candidates) => candidates.some(predicate),
|
|
34
|
+
`pollForRow: no row of "${queryType}" matched the predicate`,
|
|
35
|
+
);
|
|
36
|
+
const row = rows.find(predicate);
|
|
37
|
+
if (row === undefined) throw new Error(`pollForRow: no row of "${queryType}" matched`);
|
|
38
|
+
return row;
|
|
39
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export const SCREENSHOT_DIR_ENV = "SCREENSHOT_DIR";
|
|
2
|
+
|
|
3
|
+
const NORMAL_RUN_IGNORE: readonly string[] = [
|
|
4
|
+
"**/screenshots.spec.ts",
|
|
5
|
+
"**/*.screenshots.spec.ts",
|
|
6
|
+
"**/screenshots/**",
|
|
7
|
+
];
|
|
8
|
+
|
|
9
|
+
type Env = Readonly<Record<string, string | undefined>>;
|
|
10
|
+
|
|
11
|
+
function isScreenshotRun(env: Env): boolean {
|
|
12
|
+
const dir = env[SCREENSHOT_DIR_ENV];
|
|
13
|
+
return dir !== undefined && dir !== "";
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function requireScreenshotDir(env: Env = process.env): string {
|
|
17
|
+
const dir = env[SCREENSHOT_DIR_ENV];
|
|
18
|
+
if (dir === undefined || dir === "") {
|
|
19
|
+
throw new Error(
|
|
20
|
+
`${SCREENSHOT_DIR_ENV} is required for screenshot runs: set it to the directory the PNGs are written to (e.g. ${SCREENSHOT_DIR_ENV}=$(mktemp -d)). There is deliberately no default, because a default path would overwrite the committed docs images on every run.`,
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
return dir;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function screenshotSpecsIgnore(env: Env = process.env): string[] {
|
|
27
|
+
return isScreenshotRun(env) ? [] : [...NORMAL_RUN_IGNORE];
|
|
28
|
+
}
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { mkdirSync, statSync } from "node:fs";
|
|
3
|
+
import { expect, type Page, type Request, test } from "@playwright/test";
|
|
4
|
+
import { pinEnglishLocale } from "./pin-english-locale";
|
|
5
|
+
import { requireScreenshotDir } from "./screenshot-dir";
|
|
6
|
+
|
|
7
|
+
// runScreenshots: one image per scenario → $SCREENSHOT_DIR/<name>.png.
|
|
8
|
+
// runMatrix: every scenario × locale × theme × viewport in ONE run →
|
|
9
|
+
// $SCREENSHOT_DIR/<name>/<locale>/<theme>/<viewport>.png (feeds the preview switcher).
|
|
10
|
+
//
|
|
11
|
+
// Both are registrars: call at the spec's module top, do NOT await — otherwise
|
|
12
|
+
// they register test() only after Playwright's collection pass (0 tests).
|
|
13
|
+
// SCREENSHOT_DIR is read when a registrar runs, never at import time.
|
|
14
|
+
|
|
15
|
+
const MIN_BYTES = 5 * 1024;
|
|
16
|
+
|
|
17
|
+
// The two themes every sample app renders out of the box: renderer-web's
|
|
18
|
+
// light default and its `.dark` variant. Apps with extra themes (styleguide's
|
|
19
|
+
// brand-token override) pass their own pair to runMatrix.
|
|
20
|
+
export const DEFAULT_THEMES = ["default-light", "default-dark"] as const;
|
|
21
|
+
export type DefaultThemeId = (typeof DEFAULT_THEMES)[number];
|
|
22
|
+
|
|
23
|
+
export async function applyDefaultTheme(page: Page, theme: DefaultThemeId): Promise<void> {
|
|
24
|
+
await page.evaluate((t) => {
|
|
25
|
+
document.documentElement.classList.toggle("dark", t === "default-dark");
|
|
26
|
+
}, theme);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface Scenario {
|
|
30
|
+
readonly name: string;
|
|
31
|
+
readonly description?: string;
|
|
32
|
+
readonly url?: string;
|
|
33
|
+
readonly flow?: (page: Page) => Promise<void>;
|
|
34
|
+
readonly waitFor?: string;
|
|
35
|
+
readonly fullPage?: boolean;
|
|
36
|
+
readonly viewport?: { readonly width: number; readonly height: number };
|
|
37
|
+
// runMatrix only: opt out of the identical-theme-screenshot check for
|
|
38
|
+
// scenarios that legitimately render the same pixels across themes — e.g.
|
|
39
|
+
// a plain server-rendered content page with no client-side theme wiring.
|
|
40
|
+
readonly themeInvariant?: boolean;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// EventSource (hot-reload, live events) has its own resource type, so a
|
|
44
|
+
// never-ending stream does not count as an in-flight data request.
|
|
45
|
+
const DATA_REQUEST_TYPES: ReadonlySet<string> = new Set(["fetch", "xhr"]);
|
|
46
|
+
const STABLE_POLLS = 2;
|
|
47
|
+
|
|
48
|
+
function countInFlightDataRequests(page: Page): () => number {
|
|
49
|
+
const inFlight = new Set<Request>();
|
|
50
|
+
const settle = (request: Request): void => {
|
|
51
|
+
inFlight.delete(request);
|
|
52
|
+
};
|
|
53
|
+
page.on("request", (request) => {
|
|
54
|
+
if (DATA_REQUEST_TYPES.has(request.resourceType())) inFlight.add(request);
|
|
55
|
+
});
|
|
56
|
+
page.on("requestfinished", settle);
|
|
57
|
+
page.on("requestfailed", settle);
|
|
58
|
+
return () => inFlight.size;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function pageFingerprint(): string {
|
|
62
|
+
return [
|
|
63
|
+
document.fonts.status,
|
|
64
|
+
document.documentElement.scrollWidth,
|
|
65
|
+
document.documentElement.scrollHeight,
|
|
66
|
+
document.body.getElementsByTagName("*").length,
|
|
67
|
+
document.getAnimations().filter((animation) => animation.playState === "running").length,
|
|
68
|
+
].join(":");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Replaces fixed sleeps: settled = no data request in flight and an unchanged
|
|
72
|
+
// DOM/scroll-size/animation fingerprint over STABLE_POLLS consecutive polls.
|
|
73
|
+
async function waitForSettledPage(page: Page, inFlightDataRequests: () => number): Promise<void> {
|
|
74
|
+
let previous: string | undefined;
|
|
75
|
+
let stablePolls = 0;
|
|
76
|
+
await expect
|
|
77
|
+
.poll(
|
|
78
|
+
async () => {
|
|
79
|
+
const current = await page.evaluate(pageFingerprint);
|
|
80
|
+
stablePolls = current === previous && inFlightDataRequests() === 0 ? stablePolls + 1 : 0;
|
|
81
|
+
previous = current;
|
|
82
|
+
return stablePolls;
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
message: "page never settled (data requests in flight or DOM still changing)",
|
|
86
|
+
intervals: [100],
|
|
87
|
+
},
|
|
88
|
+
)
|
|
89
|
+
.toBeGreaterThanOrEqual(STABLE_POLLS);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function openScenario(
|
|
93
|
+
page: Page,
|
|
94
|
+
s: Scenario,
|
|
95
|
+
inFlightDataRequests: () => number,
|
|
96
|
+
): Promise<void> {
|
|
97
|
+
if (s.flow) await s.flow(page);
|
|
98
|
+
else if (s.url) await page.goto(s.url);
|
|
99
|
+
else throw new Error(`Scenario "${s.name}" needs either url or flow`);
|
|
100
|
+
|
|
101
|
+
if (s.waitFor) {
|
|
102
|
+
await expect(page.locator(s.waitFor).first()).toBeVisible({ timeout: 10_000 });
|
|
103
|
+
}
|
|
104
|
+
await waitForSettledPage(page, inFlightDataRequests);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface FlatOptions {
|
|
108
|
+
readonly pinLocale?: boolean;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Fail at registration time, not mid-run: a url-only scenario with no
|
|
112
|
+
// waitFor races the page's own render (screenshot fires before content
|
|
113
|
+
// settles); a scenario with neither url nor flow throws inside openScenario
|
|
114
|
+
// anyway, but only once Playwright actually runs that test — catching it
|
|
115
|
+
// here surfaces every broken scenario in one pass instead of one per run.
|
|
116
|
+
export function validateScenarios(scenarios: readonly Scenario[]): void {
|
|
117
|
+
for (const s of scenarios) {
|
|
118
|
+
if (s.flow === undefined && s.url === undefined) {
|
|
119
|
+
throw new Error(`Scenario "${s.name}" needs either url or flow`);
|
|
120
|
+
}
|
|
121
|
+
if (s.flow === undefined && s.waitFor === undefined) {
|
|
122
|
+
throw new Error(
|
|
123
|
+
`Scenario "${s.name}" uses url without waitFor — the screenshot would race the page's ` +
|
|
124
|
+
`own render. Set waitFor to a selector that's only present once the page is ready.`,
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function runScreenshots(scenarios: readonly Scenario[], opts: FlatOptions = {}): void {
|
|
131
|
+
validateScenarios(scenarios);
|
|
132
|
+
const outDir = requireScreenshotDir();
|
|
133
|
+
mkdirSync(outDir, { recursive: true });
|
|
134
|
+
// Scoped in its own describe so `test.use` below can't leak the pinned
|
|
135
|
+
// locale into sibling test.describe blocks in the same spec file.
|
|
136
|
+
test.describe(() => {
|
|
137
|
+
// Browser-context locale for JS-side Intl/navigator.language (e.g. money-input's
|
|
138
|
+
// resolvedLocale) — pinEnglishLocale() only seeds the app's own kumiko:locale.
|
|
139
|
+
// ponytail: native <input type="number"> still formats per the host OS region,
|
|
140
|
+
// unreachable from Playwright (context.locale and --lang both no-op there). #1851
|
|
141
|
+
if (opts.pinLocale) test.use({ locale: "en-US" });
|
|
142
|
+
for (const s of scenarios) {
|
|
143
|
+
test(s.description ? `${s.name} — ${s.description}` : s.name, async ({ page }) => {
|
|
144
|
+
if (opts.pinLocale) await pinEnglishLocale(page);
|
|
145
|
+
const inFlightDataRequests = countInFlightDataRequests(page);
|
|
146
|
+
if (s.viewport) await page.setViewportSize(s.viewport);
|
|
147
|
+
await openScenario(page, s, inFlightDataRequests);
|
|
148
|
+
const path = `${outDir}/${s.name}.png`;
|
|
149
|
+
await page.screenshot({ path, fullPage: s.fullPage ?? false });
|
|
150
|
+
expect.soft(statSync(path).size).toBeGreaterThan(MIN_BYTES);
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const VIEWPORT_IDS = ["desktop", "tablet", "mobile"] as const;
|
|
157
|
+
type ViewportId = (typeof VIEWPORT_IDS)[number];
|
|
158
|
+
const VIEWPORTS: Record<ViewportId, { readonly width: number; readonly height: number }> = {
|
|
159
|
+
// 1920×1080 instead of the earlier 1280×900: these shots land in the
|
|
160
|
+
// handbook and doc pages, where a 1280 image visibly softens on a HiDPI
|
|
161
|
+
// display. Wider also shows what a two-column layout actually does — at
|
|
162
|
+
// 1280 any list next to a reading pane looks cramped.
|
|
163
|
+
desktop: { width: 1920, height: 1080 },
|
|
164
|
+
// Landscape: portrait tablet shots collapsed two-column layouts into the mobile stack.
|
|
165
|
+
tablet: { width: 1112, height: 834 },
|
|
166
|
+
mobile: { width: 390, height: 844 },
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
// Narrow the axis from env (CSV) or take the default. Filters instead of
|
|
170
|
+
// casting: a typo in the env var (e.g. SCREENSHOT_VIEWPORTS=typo) would
|
|
171
|
+
// otherwise either crash at runtime (page.setViewportSize(undefined)) or,
|
|
172
|
+
// worse, silently produce wrong screenshots (applyTheme with an unknown
|
|
173
|
+
// theme value just toggles nothing).
|
|
174
|
+
function axis<T extends string>(env: string | undefined, all: readonly T[]): readonly T[] {
|
|
175
|
+
const picked = env
|
|
176
|
+
?.split(",")
|
|
177
|
+
.map((s) => s.trim())
|
|
178
|
+
.filter(Boolean);
|
|
179
|
+
if (!picked || picked.length === 0) return all;
|
|
180
|
+
// ponytail: filter-instead-of-cast — unknown values are silently ignored
|
|
181
|
+
const known = new Set<string>(all);
|
|
182
|
+
const matched = picked.filter((p): p is T => known.has(p));
|
|
183
|
+
if (matched.length === 0) {
|
|
184
|
+
throw new Error(
|
|
185
|
+
`axis(): env filter "${env}" matched none of [${all.join(", ")}] — 0 registered tests.`,
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
return matched;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export interface MatrixOptions<T extends string> {
|
|
192
|
+
readonly themes: readonly T[];
|
|
193
|
+
readonly applyTheme: (page: Page, theme: T) => Promise<void>;
|
|
194
|
+
readonly locales?: readonly string[];
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// App locale codes ("en"/"de") -> BCP47 tags for Playwright's browser-context
|
|
198
|
+
// `locale` option, pinning JS-side Intl/navigator.language regardless of the
|
|
199
|
+
// host's own locale — without it, a screenshot regen on a non-en-US host bakes
|
|
200
|
+
// in the host's Intl-driven formatting regardless of SCREENSHOT_LOCALES.
|
|
201
|
+
const LOCALE_TAGS: Readonly<Record<string, string>> = { en: "en-US", de: "de-DE" };
|
|
202
|
+
|
|
203
|
+
export interface ThemeScreenshotDigest<T extends string> {
|
|
204
|
+
readonly viewport: string;
|
|
205
|
+
readonly theme: T;
|
|
206
|
+
readonly hash: string;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Catches a renamed CSS class or an overridden theme provider that leaves every
|
|
210
|
+
// theme rendering identically — MIN_BYTES alone can't (a valid PNG is a valid
|
|
211
|
+
// PNG regardless of which theme produced it). Keyed by viewport so a failure
|
|
212
|
+
// names the exact viewport and colliding theme pair, not just the scenario.
|
|
213
|
+
export function findIdenticalThemeScreenshots<T extends string>(
|
|
214
|
+
digests: readonly ThemeScreenshotDigest<T>[],
|
|
215
|
+
): string[] {
|
|
216
|
+
const seenByViewport = new Map<string, Map<string, T>>();
|
|
217
|
+
const violations: string[] = [];
|
|
218
|
+
for (const { viewport, theme, hash } of digests) {
|
|
219
|
+
let seenHashes = seenByViewport.get(viewport);
|
|
220
|
+
if (seenHashes === undefined) {
|
|
221
|
+
seenHashes = new Map();
|
|
222
|
+
seenByViewport.set(viewport, seenHashes);
|
|
223
|
+
}
|
|
224
|
+
const collidingTheme = seenHashes.get(hash);
|
|
225
|
+
if (collidingTheme !== undefined) {
|
|
226
|
+
violations.push(
|
|
227
|
+
`viewport "${viewport}": themes "${collidingTheme}" and "${theme}" produced byte-identical screenshots`,
|
|
228
|
+
);
|
|
229
|
+
} else {
|
|
230
|
+
seenHashes.set(hash, theme);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return violations;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export function runMatrix<T extends string>(
|
|
237
|
+
scenarios: readonly Scenario[],
|
|
238
|
+
opts: MatrixOptions<T>,
|
|
239
|
+
): void {
|
|
240
|
+
validateScenarios(scenarios);
|
|
241
|
+
|
|
242
|
+
const baseDir = requireScreenshotDir();
|
|
243
|
+
const locales = axis(process.env["SCREENSHOT_LOCALES"], opts.locales ?? ["en", "de"]);
|
|
244
|
+
const themes = axis(process.env["SCREENSHOT_THEMES"], opts.themes);
|
|
245
|
+
const viewports = axis(process.env["SCREENSHOT_VIEWPORTS"], VIEWPORT_IDS);
|
|
246
|
+
const only = process.env["SCREENSHOT_ONLY"];
|
|
247
|
+
|
|
248
|
+
for (const locale of locales) {
|
|
249
|
+
test.describe(locale, () => {
|
|
250
|
+
// Browser-context locale for JS-side Intl/navigator.language — the
|
|
251
|
+
// kumiko:locale seed below only drives the app's own i18n strings.
|
|
252
|
+
// ponytail: see the runScreenshots() locale comment above / #1851
|
|
253
|
+
const tag = LOCALE_TAGS[locale];
|
|
254
|
+
if (tag === undefined) {
|
|
255
|
+
throw new Error(
|
|
256
|
+
`runMatrix(): no BCP47 tag mapped for locale "${locale}" — extend LOCALE_TAGS`,
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
test.use({ locale: tag });
|
|
260
|
+
|
|
261
|
+
for (const s of scenarios) {
|
|
262
|
+
if (only !== undefined && only !== s.name) continue;
|
|
263
|
+
test(s.name, async ({ page }) => {
|
|
264
|
+
// kumiko:locale drives the boot-time language (before goto); kumiko:theme
|
|
265
|
+
// is cleared so the mode is decided solely by applyTheme.
|
|
266
|
+
await page.addInitScript((lng) => {
|
|
267
|
+
localStorage.setItem("kumiko:locale", lng);
|
|
268
|
+
localStorage.removeItem("kumiko:theme");
|
|
269
|
+
}, locale);
|
|
270
|
+
const inFlightDataRequests = countInFlightDataRequests(page);
|
|
271
|
+
await openScenario(page, s, inFlightDataRequests);
|
|
272
|
+
|
|
273
|
+
const digests: ThemeScreenshotDigest<T>[] = [];
|
|
274
|
+
|
|
275
|
+
for (const theme of themes) {
|
|
276
|
+
await opts.applyTheme(page, theme);
|
|
277
|
+
for (const vp of viewports) {
|
|
278
|
+
await page.setViewportSize(VIEWPORTS[vp]);
|
|
279
|
+
await waitForSettledPage(page, inFlightDataRequests);
|
|
280
|
+
const dir = `${baseDir}/${s.name}/${locale}/${theme}`;
|
|
281
|
+
mkdirSync(dir, { recursive: true });
|
|
282
|
+
const path = `${dir}/${vp}.png`;
|
|
283
|
+
// animations: "disabled" jumps to end-state at the engine level — immune to CSS specificity, unlike an addStyleTag injection.
|
|
284
|
+
// screenshot() returns the same bytes it writes to `path` — reuse
|
|
285
|
+
// them for both checks instead of a statSync/read-back roundtrip.
|
|
286
|
+
const buffer = await page.screenshot({
|
|
287
|
+
path,
|
|
288
|
+
fullPage: s.fullPage ?? false,
|
|
289
|
+
animations: "disabled",
|
|
290
|
+
});
|
|
291
|
+
expect.soft(buffer.length).toBeGreaterThan(MIN_BYTES);
|
|
292
|
+
digests.push({
|
|
293
|
+
viewport: vp,
|
|
294
|
+
theme,
|
|
295
|
+
hash: createHash("sha256").update(buffer).digest("hex"),
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
if (!s.themeInvariant) {
|
|
301
|
+
expect
|
|
302
|
+
.soft(
|
|
303
|
+
findIdenticalThemeScreenshots(digests),
|
|
304
|
+
`scenario "${s.name}" (${locale}): theme switch produced no visual difference`,
|
|
305
|
+
)
|
|
306
|
+
.toEqual([]);
|
|
307
|
+
}
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
}
|