@cosmicdrift/kumiko-testing 0.388.0 → 0.390.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/README.md +4 -1
- package/package.json +12 -8
- package/src/changes.json +101 -0
- package/src/e2e/app-boot-budget.ts +22 -0
- package/src/e2e/auth-kit.ts +8 -2
- package/src/e2e/define-app-e2e-config.ts +79 -12
- package/src/e2e/index.ts +7 -1
- package/src/e2e/present-identities.ts +90 -0
- package/src/e2e/screenshots.ts +6 -75
- package/src/e2e/seed-route.ts +11 -0
- package/src/e2e/seeded-tenant-fixture.ts +27 -39
- package/src/helpers/closed-connection-error.ts +4 -7
- package/src/helpers/console-calls.ts +9 -0
- package/src/helpers/event-store-writes.ts +160 -0
- package/src/helpers/index.ts +19 -1
- package/src/helpers/mock-module.ts +24 -0
- package/src/scaffold.ts +1 -1
- package/src/stack/db.ts +110 -17
- package/src/stack/index.ts +2 -0
- package/src/stack/push-entity-projection-tables.ts +12 -7
- package/src/stack/test-stack-setup.ts +95 -23
- package/src/stack/test-stack.ts +10 -5
package/README.md
CHANGED
|
@@ -47,8 +47,11 @@ test("member sees the note", async ({ seedTenant, page }) => {
|
|
|
47
47
|
});
|
|
48
48
|
```
|
|
49
49
|
|
|
50
|
-
- `defineAppE2eConfig({ port, env?, locale?, projects?, serverEntry?, testDir?, testMatch? })`: parallelism, retries, timeouts and
|
|
50
|
+
- `defineAppE2eConfig({ port, env?, locale?, projects?, serverEntry?, testDir?, testMatch?, isolatedSpecs? })`: parallelism, retries, timeouts and
|
|
51
51
|
workers (`KUMIKO_E2E_WORKERS` overrides) are fixed by the template; projects cannot set them.
|
|
52
|
+
- `isolatedSpecs: ["**/composer.spec.ts"]` moves specs that change app-wide state (catalog entries, global
|
|
53
|
+
settings) into the `chromium-isolated` project, which runs after all parallel projects on one worker. It cannot
|
|
54
|
+
be combined with `--shard`; details in `docs/guides/testing-standard.md`.
|
|
52
55
|
- `KUMIKO_E2E_REMOTE_BASE_URL=https://staging.example.com` runs the same specs against an already running
|
|
53
56
|
instance: no web server is started and `baseURL` is that URL (http or https only). Add
|
|
54
57
|
`KUMIKO_E2E_STORAGE_STATE=<file>` for a session recorded with `playwright codegen --save-storage`;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-testing",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.390.0",
|
|
4
4
|
"description": "Test template for Kumiko apps: per-flow seedTenant, app test stack, canonical test preloads, bunfig generator and integration runner.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -34,6 +34,10 @@
|
|
|
34
34
|
"types": "./src/helpers/index.ts",
|
|
35
35
|
"default": "./src/helpers/index.ts"
|
|
36
36
|
},
|
|
37
|
+
"./helpers/mock-module": {
|
|
38
|
+
"types": "./src/helpers/mock-module.ts",
|
|
39
|
+
"default": "./src/helpers/mock-module.ts"
|
|
40
|
+
},
|
|
37
41
|
"./helpers/real-providers": {
|
|
38
42
|
"types": "./src/helpers/real-providers.ts",
|
|
39
43
|
"default": "./src/helpers/real-providers.ts"
|
|
@@ -99,13 +103,13 @@
|
|
|
99
103
|
"kumiko-testing": "./bin/kumiko-testing.ts"
|
|
100
104
|
},
|
|
101
105
|
"dependencies": {
|
|
102
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
103
|
-
"@cosmicdrift/kumiko-guards": "0.
|
|
104
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
106
|
+
"@cosmicdrift/kumiko-framework": "0.390.0",
|
|
107
|
+
"@cosmicdrift/kumiko-guards": "0.390.0",
|
|
108
|
+
"@cosmicdrift/kumiko-types": "0.390.0",
|
|
105
109
|
"zod": "^4.4.3"
|
|
106
110
|
},
|
|
107
111
|
"peerDependencies": {
|
|
108
|
-
"@happy-dom/global-registrator": "^
|
|
112
|
+
"@happy-dom/global-registrator": "^21.0.4",
|
|
109
113
|
"@playwright/test": "^1.62.1",
|
|
110
114
|
"@testing-library/dom": "^10.4.1",
|
|
111
115
|
"@testing-library/react": "^16.3.2"
|
|
@@ -125,9 +129,9 @@
|
|
|
125
129
|
}
|
|
126
130
|
},
|
|
127
131
|
"devDependencies": {
|
|
128
|
-
"@cosmicdrift/kumiko-bundled-features": "0.
|
|
129
|
-
"@cosmicdrift/kumiko-dev-server": "0.
|
|
130
|
-
"@happy-dom/global-registrator": "^
|
|
132
|
+
"@cosmicdrift/kumiko-bundled-features": "0.390.0",
|
|
133
|
+
"@cosmicdrift/kumiko-dev-server": "0.390.0",
|
|
134
|
+
"@happy-dom/global-registrator": "^21.0.4",
|
|
131
135
|
"@playwright/test": "^1.62.1",
|
|
132
136
|
"@testing-library/dom": "^10.4.1",
|
|
133
137
|
"@testing-library/react": "^16.3.2",
|
package/src/changes.json
CHANGED
|
@@ -1,4 +1,105 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.390.0",
|
|
4
|
+
"type": "breaking",
|
|
5
|
+
"title": "happy-dom and undici updated for security advisories",
|
|
6
|
+
"detail": "The happy-dom range for the global registrator and the DOM test setup moves to ^21.0.4 (fixes GHSA-834V-8H66-3H79, incomplete fix for an unsanitized import alias leading to host code execution), undici is pinned to 7.30.1 (fixes GHSA-XJJ6-F33Q-929C, denial of service through an unhandled error on excessive content-encodings).",
|
|
7
|
+
"migration": "Bump @happy-dom/global-registrator (and happy-dom, if listed) to ^21.0.4 in the app's devDependencies; the kumiko-testing peer range is now ^21.0.4. happy-dom 21 is a major release: run the app's DOM test suite after the bump."
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"version": "0.390.0",
|
|
11
|
+
"type": "improvement",
|
|
12
|
+
"title": "Run e2e specs that change global app state in an isolated project",
|
|
13
|
+
"detail": "defineAppE2eConfig takes an isolatedSpecs option with the same pattern form as testMatch. Matching specs are removed from chromium and from every app project and run in a separate chromium-isolated project with one worker, only after all other projects have finished. Apps use it for specs that change state shared across the app, such as creating catalog channels. With isolatedSpecs set, every app project needs a name, and a run with --shard throws because Playwright does not shard dependency projects. Without the option the generated config is unchanged."
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"version": "0.390.0",
|
|
17
|
+
"type": "improvement",
|
|
18
|
+
"title": "The e2e suites run in two parallel CI shards",
|
|
19
|
+
"detail": "The framework CI splits the Playwright e2e suites across two runners instead of running them one after another on a single runner. The required e2e check passes only when both shards succeed. Nothing changes for apps; the package is only republished."
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
"version": "0.390.0",
|
|
23
|
+
"type": "improvement",
|
|
24
|
+
"title": "The puppeteer render tests, including the egress guard, run in CI",
|
|
25
|
+
"detail": "The integration job installs Chrome through the trusted puppeteer postinstall and caches it between runs, so the real render tests no longer skip in CI. Nothing changes for apps; the package is only republished."
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
"version": "0.390.0",
|
|
29
|
+
"type": "breaking",
|
|
30
|
+
"title": "extraContext factories get the raw db in the test stack and dev",
|
|
31
|
+
"detail": "The test stack and dev now pass the enforcing pool to extraContext factories, as runProdApp does, so request-time services keep the ambient request scope and a tenant leak shows up in tests instead of only in production.",
|
|
32
|
+
"migration": "Services built in extraContext that read across tenants at request time, or that a test calls directly, must scope themselves: createTenantDb({ db, tenantId, mode: \"system\", reason: \"<why>\" }). Test code reads through stack.db."
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
"version": "0.390.0",
|
|
36
|
+
"type": "improvement",
|
|
37
|
+
"title": "Export applicationRoleUrlOf from kumiko-testing/stack",
|
|
38
|
+
"detail": "Use it to boot runProdApp in tests against the app role. It returns the URL of a database as the non-bypass application role with the password of TEST_DATABASE_URL; pair it with createTestDb({ applicationRole: true })."
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
"version": "0.390.0",
|
|
42
|
+
"type": "improvement",
|
|
43
|
+
"title": "The test application role is realigned to the configured password",
|
|
44
|
+
"detail": "createApplicationRole used to return early when the role existed, so a kumiko_app role with another password made every integration test fail with 28P01. It now runs ALTER ROLE with the configured password and the NOSUPERUSER NOBYPASSRLS attributes."
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
"version": "0.390.0",
|
|
48
|
+
"type": "breaking",
|
|
49
|
+
"title": "The test stack runs with row level security on as role kumiko_app",
|
|
50
|
+
"detail": "setupTestStack creates the role kumiko_app (LOGIN NOSUPERUSER NOBYPASSRLS, password taken from TEST_DATABASE_URL) once, makes it owner of the test database and opens every pool as that role with rowLevelSecurity \"on\"; template databases are built the same way. stack.db runs in system scope and sees every tenant's rows as before. The new stack.unscopedDb is the enforced pool the services use, on which a statement without a scope sees no tenant rows. rowLevelSecurity: \"off\" in the stack options restores the superuser stack without policies. createTestDb takes applicationRole: true for the same setup.",
|
|
51
|
+
"migration": "App code that reads or writes tenant tables through a raw connection outside a request, a job or a TenantDb now sees no rows in integration tests. Test code that calls such a path directly runs it inside runInSystemRowLevelSecurityScope or runInRowLevelSecurityScope(tenantRowLevelSecurityScope(tenantId), ...) from @cosmicdrift/kumiko-testing/helpers. Production code works through a TenantDb for the tenant, a job, a system-scoped handler or a granted unsafeRaw. Tests that need superuser rights on stack.db, such as CREATE EXTENSION, CREATE ROLE or event triggers, open their own connection from TEST_DATABASE_URL or use createTestDb(). Manual migration, there is no codemod."
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
"version": "0.390.0",
|
|
55
|
+
"type": "improvement",
|
|
56
|
+
"title": "Test helpers for event-store writes",
|
|
57
|
+
"detail": "The testing helpers gain updateRowViaEventStore, deleteRowViaEventStore and deleteRowsViaEventStore. They update or delete rows of an event-sourced table through the event-store executor, so the event log and the projection stay in step, instead of writing the projection table directly."
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
"version": "0.390.0",
|
|
61
|
+
"type": "improvement",
|
|
62
|
+
"title": "Seed event-sourced rows through the event store",
|
|
63
|
+
"detail": "The testing helpers gain seedRowViaEventStore(db, table, values). It takes the same arguments as seedRow, appends a created event through the event-store executor and projects the row, so a rebuild keeps the seeded row. The row's tenantId selects the acting tenant; global entities such as user are seeded without a tenantId, because the executor rejects one on them. It throws for tables that are not event-sourced entity tables, for a non-string id and when the executor rejects the row. To support this, buildEntityTable and r.entity now remember which entity a managed table belongs to; nothing changes for apps."
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
"version": "0.389.0",
|
|
67
|
+
"type": "breaking",
|
|
68
|
+
"title": "captureClosedConnectionError returns a Bun.SQL error",
|
|
69
|
+
"detail": "The helper opens Bun.SQL pools, so the captured error has the code ERR_POSTGRES_CONNECTION_CLOSED instead of the postgres-js CONNECTION_CLOSED. The client of createTestDb is the connection handle's listenClient, the same Bun.SQL pool as before.",
|
|
70
|
+
"migration": "Tests that compared the captured error's code with CONNECTION_CLOSED compare with ERR_POSTGRES_CONNECTION_CLOSED or call isClosedConnectionError. Manual migration, there is no codemod."
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
"version": "0.389.0",
|
|
74
|
+
"type": "breaking",
|
|
75
|
+
"title": "e2e tests fail after more than 8 app boots; the per-test client IP no longer disables the HTTP cache",
|
|
76
|
+
"detail": "The test fixture sent the per-test X-Forwarded-For through context.route, which turns Chromium's HTTP cache off for the whole context, so every page.goto re-downloaded all assets and GET /api/schema. It now uses setExtraHTTPHeaders and the cache stays on. The fixture also counts GET /api/schema per test and fails a passing test that boots the app more than 8 times (default of the new maxAppBootsPerTest fixture option), and closes pages left open (window.open tabs) before the context closes.",
|
|
77
|
+
"migration": "Seed through tenant.api, tenant.seed and tenant.addUser instead of calling page.goto(APP_BASE_PATH) before every seed; each page.goto reloads the app. runMatrix and runScreenshots boot the app once per test themselves, so they need no change; only your own scenario flows or specs that navigate on purpose raise the budget with test.use({ maxAppBootsPerTest: <n> }). Manual migration, there is no codemod. The X-Forwarded-For header is now sent on every request of the context, including cross-origin ones."
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
"version": "0.389.0",
|
|
81
|
+
"type": "fix",
|
|
82
|
+
"title": "loginViaUi no longer races the MFA auto-submit",
|
|
83
|
+
"detail": "The MFA code field submits itself on the sixth digit since 0.388. loginViaUi now fills the code while waiting for the verify response and no longer presses Enter on a field that may already be disabled."
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
"version": "0.389.0",
|
|
87
|
+
"type": "improvement",
|
|
88
|
+
"title": "consoleCallsFromNamespace test helper for silent-log assertions",
|
|
89
|
+
"detail": "Filters a console spy by logger namespace prefix, so a new or reworded warning from the module under test fails a silent test. The boot-validator, client-ip and settings-hub silent tests use it."
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
"version": "0.389.0",
|
|
93
|
+
"type": "improvement",
|
|
94
|
+
"title": "mockModuleForFile restores module mocks automatically",
|
|
95
|
+
"detail": "bun's mock.module is process-global, so tests had to copy the real module and restore it by hand in afterAll. The helper does both and can point re-exporting modules back at the real export."
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
"version": "0.389.0",
|
|
99
|
+
"type": "improvement",
|
|
100
|
+
"title": "E2E specs can present stable identities around their own screenshots",
|
|
101
|
+
"detail": "withPresentedIdentities replaces generated identities in page text and form values, runs the callback and restores the originals in a finally block. The lower-level present and restore functions are exported too."
|
|
102
|
+
},
|
|
2
103
|
{
|
|
3
104
|
"version": "0.388.0",
|
|
4
105
|
"type": "improvement",
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { BrowserContext } from "@playwright/test";
|
|
2
|
+
|
|
3
|
+
// Duplicated from kumiko-renderer (APP_SCHEMA_API_PATH): kumiko-testing does not depend on the renderer.
|
|
4
|
+
export const APP_SCHEMA_API_PATH = "/api/schema";
|
|
5
|
+
|
|
6
|
+
export const DEFAULT_MAX_APP_BOOTS_PER_TEST = 8;
|
|
7
|
+
|
|
8
|
+
// Every app boot fetches the schema once, so the request count is the number of page loads.
|
|
9
|
+
export function observeAppBoots(context: Pick<BrowserContext, "on">): {
|
|
10
|
+
readonly count: () => number;
|
|
11
|
+
} {
|
|
12
|
+
let bootCount = 0;
|
|
13
|
+
context.on("request", (request) => {
|
|
14
|
+
if (new URL(request.url()).pathname === APP_SCHEMA_API_PATH) bootCount += 1;
|
|
15
|
+
});
|
|
16
|
+
return { count: () => bootCount };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function appBootBudgetViolation(bootCount: number, maxBoots: number): string | undefined {
|
|
20
|
+
if (bootCount <= maxBoots) return undefined;
|
|
21
|
+
return `This test booted the app ${bootCount} times (GET ${APP_SCHEMA_API_PATH}), budget is ${maxBoots}. Seed through tenant.api instead of page.goto per seed; each page.goto reloads the app. Specs that navigate on purpose (screenshot scenarios) raise the budget with test.use({ maxAppBootsPerTest }).`;
|
|
22
|
+
}
|
package/src/e2e/auth-kit.ts
CHANGED
|
@@ -273,8 +273,14 @@ export async function loginViaUi(page: Page, credentials: LoginCredentials): Pro
|
|
|
273
273
|
// avoids racing the transition from the password form.
|
|
274
274
|
const codeField = page.locator("#mfa-verify-code");
|
|
275
275
|
await expect(codeField).toBeVisible();
|
|
276
|
-
|
|
277
|
-
|
|
276
|
+
const code = await unburnedTotpCode(credentials.mfaTotpSecret);
|
|
277
|
+
// The sixth digit submits on its own and disables the field; an Enter press would race that.
|
|
278
|
+
await Promise.all([
|
|
279
|
+
page.waitForResponse(
|
|
280
|
+
(response) => response.url().includes("/api/auth/mfa/verify") && response.ok(),
|
|
281
|
+
),
|
|
282
|
+
codeField.fill(code),
|
|
283
|
+
]);
|
|
278
284
|
await expect(codeField).toHaveCount(0);
|
|
279
285
|
}
|
|
280
286
|
|
|
@@ -77,6 +77,8 @@ const TEMPLATE_OWNED_PROJECT_KEYS = [
|
|
|
77
77
|
] as const;
|
|
78
78
|
const TEMPLATE_OWNED_USE_KEYS = ["actionTimeout", "navigationTimeout"] as const;
|
|
79
79
|
const REAL_SPEC_GLOB = "**/*.real.spec.ts";
|
|
80
|
+
export const ISOLATED_E2E_PROJECT_NAME = "chromium-isolated";
|
|
81
|
+
export const NON_REAL_SPEC_PATTERN = /(?<!\.real)\.spec\.ts$/;
|
|
80
82
|
const RESERVED_ENV_KEYS = [
|
|
81
83
|
"PORT",
|
|
82
84
|
SEED_ENABLE_ENV,
|
|
@@ -111,6 +113,7 @@ export type AppE2eConfigInput = {
|
|
|
111
113
|
readonly projects?: readonly E2eProject[];
|
|
112
114
|
readonly testDir?: string;
|
|
113
115
|
readonly testMatch?: string | RegExp | readonly (string | RegExp)[];
|
|
116
|
+
readonly isolatedSpecs?: string | RegExp | readonly (string | RegExp)[];
|
|
114
117
|
readonly permissions?: readonly string[];
|
|
115
118
|
readonly launchOptions?: LaunchOptions;
|
|
116
119
|
} & ServerStart;
|
|
@@ -140,6 +143,13 @@ function mutableTestMatch(
|
|
|
140
143
|
return typeof match === "string" || match instanceof RegExp ? match : [...match];
|
|
141
144
|
}
|
|
142
145
|
|
|
146
|
+
function patternList(
|
|
147
|
+
patterns: NonNullable<AppE2eConfigInput["testMatch"]> | undefined,
|
|
148
|
+
): (string | RegExp)[] {
|
|
149
|
+
if (patterns === undefined) return [];
|
|
150
|
+
return typeof patterns === "string" || patterns instanceof RegExp ? [patterns] : [...patterns];
|
|
151
|
+
}
|
|
152
|
+
|
|
143
153
|
export function resolveE2eWorkers(
|
|
144
154
|
env: Readonly<Record<string, string | undefined>> = process.env,
|
|
145
155
|
// cpus() reports every host core inside a CPU-limited container (1.5 CPU CI
|
|
@@ -233,6 +243,58 @@ function failureRecording(
|
|
|
233
243
|
return { trace: "off", screenshot: "off", video: "off" };
|
|
234
244
|
}
|
|
235
245
|
|
|
246
|
+
// Worker processes do not carry --shard in argv, so only the runner trips this.
|
|
247
|
+
function isShardedRun(argv: readonly string[] = process.argv): boolean {
|
|
248
|
+
return argv.some((arg) => arg === "--shard" || arg.startsWith("--shard="));
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
type ProjectsInput = {
|
|
252
|
+
readonly chromiumUse: Project["use"];
|
|
253
|
+
readonly appProjects: readonly E2eProject[];
|
|
254
|
+
readonly isolated: readonly (string | RegExp)[];
|
|
255
|
+
readonly baseIgnore: readonly string[];
|
|
256
|
+
readonly realRun: boolean;
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
function assertIsolatedSpecsUsable(appProjects: readonly E2eProject[]): void {
|
|
260
|
+
if (appProjects.some((project) => project.name === undefined)) {
|
|
261
|
+
throw new Error(
|
|
262
|
+
"defineAppE2eConfig: isolatedSpecs needs named projects, because the isolated project waits for them via dependencies",
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
if (isShardedRun()) {
|
|
266
|
+
throw new Error(
|
|
267
|
+
"defineAppE2eConfig: isolatedSpecs cannot be combined with --shard; Playwright does not shard dependency projects, so one shard would run every spec.",
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function buildE2eProjects(input: ProjectsInput): Project[] {
|
|
273
|
+
const { chromiumUse, appProjects, isolated, baseIgnore, realRun } = input;
|
|
274
|
+
const baseProjects: Project[] = [{ name: "chromium", use: chromiumUse }, ...appProjects];
|
|
275
|
+
if (isolated.length === 0) return baseProjects;
|
|
276
|
+
assertIsolatedSpecsUsable(appProjects);
|
|
277
|
+
// A project-level testIgnore replaces the top-level one instead of merging with it.
|
|
278
|
+
const parallelProjects = baseProjects.map((project) => ({
|
|
279
|
+
...project,
|
|
280
|
+
testIgnore: [...baseIgnore, ...patternList(project.testIgnore), ...isolated],
|
|
281
|
+
}));
|
|
282
|
+
return [
|
|
283
|
+
...parallelProjects,
|
|
284
|
+
{
|
|
285
|
+
name: ISOLATED_E2E_PROJECT_NAME,
|
|
286
|
+
testMatch: [...isolated],
|
|
287
|
+
testIgnore: [...baseIgnore, ...(realRun ? [NON_REAL_SPEC_PATTERN] : [])],
|
|
288
|
+
dependencies: parallelProjects.flatMap((project) =>
|
|
289
|
+
project.name === undefined ? [] : [project.name],
|
|
290
|
+
),
|
|
291
|
+
fullyParallel: false,
|
|
292
|
+
workers: 1,
|
|
293
|
+
use: chromiumUse,
|
|
294
|
+
},
|
|
295
|
+
];
|
|
296
|
+
}
|
|
297
|
+
|
|
236
298
|
export function defineAppE2eConfig(input: AppE2eConfigInput): PlaywrightTestConfig {
|
|
237
299
|
const {
|
|
238
300
|
port,
|
|
@@ -241,6 +303,7 @@ export function defineAppE2eConfig(input: AppE2eConfigInput): PlaywrightTestConf
|
|
|
241
303
|
projects = [],
|
|
242
304
|
testDir = "./e2e",
|
|
243
305
|
testMatch,
|
|
306
|
+
isolatedSpecs,
|
|
244
307
|
permissions,
|
|
245
308
|
launchOptions,
|
|
246
309
|
} = input;
|
|
@@ -257,13 +320,27 @@ export function defineAppE2eConfig(input: AppE2eConfigInput): PlaywrightTestConf
|
|
|
257
320
|
const remote = remoteTargetFromEnv(process.env);
|
|
258
321
|
const baseURL = remote?.baseURL ?? `http://localhost:${port}`;
|
|
259
322
|
const realRun = isRealProviderRun();
|
|
323
|
+
const baseIgnore = [...screenshotSpecsIgnore(), ...(realRun ? [] : [REAL_SPEC_GLOB])];
|
|
324
|
+
const isolated = patternList(isolatedSpecs);
|
|
325
|
+
const chromiumUse = {
|
|
326
|
+
...devices["Desktop Chrome"],
|
|
327
|
+
viewport: DESKTOP_VIEWPORT,
|
|
328
|
+
deviceScaleFactor: isScreenshotRun() ? SCREENSHOT_DEVICE_SCALE_FACTOR : 1,
|
|
329
|
+
};
|
|
330
|
+
const e2eProjects = buildE2eProjects({
|
|
331
|
+
chromiumUse,
|
|
332
|
+
appProjects: projects,
|
|
333
|
+
isolated,
|
|
334
|
+
baseIgnore,
|
|
335
|
+
realRun,
|
|
336
|
+
});
|
|
260
337
|
|
|
261
338
|
return defineConfig({
|
|
262
339
|
testDir,
|
|
263
340
|
...(realRun
|
|
264
341
|
? { testMatch: REAL_SPEC_GLOB }
|
|
265
342
|
: testMatch !== undefined && { testMatch: mutableTestMatch(testMatch) }),
|
|
266
|
-
testIgnore:
|
|
343
|
+
testIgnore: baseIgnore,
|
|
267
344
|
fullyParallel: true,
|
|
268
345
|
forbidOnly: !!process.env["CI"],
|
|
269
346
|
retries: 0,
|
|
@@ -281,17 +358,7 @@ export function defineAppE2eConfig(input: AppE2eConfigInput): PlaywrightTestConf
|
|
|
281
358
|
...(permissions !== undefined && { permissions: [...permissions] }),
|
|
282
359
|
...(launchOptions !== undefined && { launchOptions }),
|
|
283
360
|
},
|
|
284
|
-
projects:
|
|
285
|
-
{
|
|
286
|
-
name: "chromium",
|
|
287
|
-
use: {
|
|
288
|
-
...devices["Desktop Chrome"],
|
|
289
|
-
viewport: DESKTOP_VIEWPORT,
|
|
290
|
-
deviceScaleFactor: isScreenshotRun() ? SCREENSHOT_DEVICE_SCALE_FACTOR : 1,
|
|
291
|
-
},
|
|
292
|
-
},
|
|
293
|
-
...projects,
|
|
294
|
-
],
|
|
361
|
+
projects: e2eProjects,
|
|
295
362
|
...(remote === undefined && {
|
|
296
363
|
webServer: {
|
|
297
364
|
command: serverCommand,
|
package/src/e2e/index.ts
CHANGED
|
@@ -40,6 +40,7 @@ export {
|
|
|
40
40
|
type AppE2eConfigInput,
|
|
41
41
|
defineAppE2eConfig,
|
|
42
42
|
type E2eProject,
|
|
43
|
+
ISOLATED_E2E_PROJECT_NAME,
|
|
43
44
|
resolveE2eWorkers,
|
|
44
45
|
} from "./define-app-e2e-config";
|
|
45
46
|
export { type MailCaptureOptions, mailCapture } from "./mail-capture";
|
|
@@ -51,6 +52,12 @@ export {
|
|
|
51
52
|
} from "./mail-links";
|
|
52
53
|
export { pinEnglishLocale } from "./pin-english-locale";
|
|
53
54
|
export { pollForRow, waitForProjection } from "./poll";
|
|
55
|
+
export {
|
|
56
|
+
type PresentIdentity,
|
|
57
|
+
presentIdentitiesOnPage,
|
|
58
|
+
restoreIdentitiesOnPage,
|
|
59
|
+
withPresentedIdentities,
|
|
60
|
+
} from "./present-identities";
|
|
54
61
|
export {
|
|
55
62
|
requireScreenshotDir,
|
|
56
63
|
SCREENSHOT_DIR_ENV,
|
|
@@ -66,7 +73,6 @@ export {
|
|
|
66
73
|
type FlatOptions,
|
|
67
74
|
findIdenticalThemeScreenshots,
|
|
68
75
|
type MatrixOptions,
|
|
69
|
-
type PresentIdentity,
|
|
70
76
|
runMatrix,
|
|
71
77
|
runScreenshots,
|
|
72
78
|
type Scenario,
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { Page } from "@playwright/test";
|
|
2
|
+
|
|
3
|
+
// Seeded identities are unique per run (admin-<tenantId>@…), so a screenshot
|
|
4
|
+
// would show a different address every time. Replaced in the page's text and
|
|
5
|
+
// form values right before each capture; the seeded data stays untouched.
|
|
6
|
+
export interface PresentIdentity {
|
|
7
|
+
readonly from: string;
|
|
8
|
+
readonly to: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function assertPresentableMappings(mappings: readonly PresentIdentity[]): void {
|
|
12
|
+
for (const { from } of mappings) {
|
|
13
|
+
if (from === "") throw new Error("presentIdentities: `from` must not be empty");
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
declare global {
|
|
18
|
+
interface Window {
|
|
19
|
+
__kumikoPresentRestore?: Array<() => void>;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Runs in the browser, so it may not reference module scope. Originals are kept
|
|
24
|
+
// on window so a later evaluate can put them back (captureScreenshot runs
|
|
25
|
+
// mid-flow, where the spec keeps asserting and submitting forms).
|
|
26
|
+
function replaceIdentitiesInDocument(mappings: readonly PresentIdentity[]): void {
|
|
27
|
+
window.__kumikoPresentRestore ??= [];
|
|
28
|
+
const restorers = window.__kumikoPresentRestore;
|
|
29
|
+
const present = (text: string): string =>
|
|
30
|
+
mappings.reduce((current, { from, to }) => current.replaceAll(from, to), text);
|
|
31
|
+
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
|
|
32
|
+
for (let node = walker.nextNode(); node !== null; node = walker.nextNode()) {
|
|
33
|
+
const text = node.nodeValue ?? "";
|
|
34
|
+
const presented = present(text);
|
|
35
|
+
if (presented !== text) {
|
|
36
|
+
const textNode = node;
|
|
37
|
+
textNode.nodeValue = presented;
|
|
38
|
+
restorers.push(() => {
|
|
39
|
+
textNode.nodeValue = text;
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
for (const field of document.querySelectorAll("input, textarea")) {
|
|
44
|
+
if (field instanceof HTMLInputElement || field instanceof HTMLTextAreaElement) {
|
|
45
|
+
const original = field.value;
|
|
46
|
+
const presented = present(original);
|
|
47
|
+
if (presented !== original) {
|
|
48
|
+
field.value = presented;
|
|
49
|
+
restorers.push(() => {
|
|
50
|
+
field.value = original;
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Reverse order: a second presentation pass records already-presented values.
|
|
58
|
+
function restoreIdentitiesInDocument(): void {
|
|
59
|
+
const restorers = window.__kumikoPresentRestore ?? [];
|
|
60
|
+
for (const restore of restorers.reverse()) restore();
|
|
61
|
+
window.__kumikoPresentRestore = [];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function presentIdentitiesOnPage(
|
|
65
|
+
page: Page,
|
|
66
|
+
mappings: readonly PresentIdentity[],
|
|
67
|
+
): Promise<void> {
|
|
68
|
+
if (mappings.length > 0) await page.evaluate(replaceIdentitiesInDocument, mappings);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function restoreIdentitiesOnPage(
|
|
72
|
+
page: Page,
|
|
73
|
+
mappings: readonly PresentIdentity[],
|
|
74
|
+
): Promise<void> {
|
|
75
|
+
if (mappings.length > 0) await page.evaluate(restoreIdentitiesInDocument);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function withPresentedIdentities<T>(
|
|
79
|
+
page: Page,
|
|
80
|
+
mappings: readonly PresentIdentity[],
|
|
81
|
+
fn: () => Promise<T>,
|
|
82
|
+
): Promise<T> {
|
|
83
|
+
assertPresentableMappings(mappings);
|
|
84
|
+
await presentIdentitiesOnPage(page, mappings);
|
|
85
|
+
try {
|
|
86
|
+
return await fn();
|
|
87
|
+
} finally {
|
|
88
|
+
await restoreIdentitiesOnPage(page, mappings);
|
|
89
|
+
}
|
|
90
|
+
}
|
package/src/e2e/screenshots.ts
CHANGED
|
@@ -6,6 +6,12 @@ import { E2E_TIMEOUT_MS } from "../e2e-timeouts";
|
|
|
6
6
|
import { isRealProviderRun } from "../helpers/real-providers.ts";
|
|
7
7
|
import { DESKTOP_VIEWPORT } from "./constants";
|
|
8
8
|
import { pinEnglishLocale } from "./pin-english-locale";
|
|
9
|
+
import {
|
|
10
|
+
assertPresentableMappings,
|
|
11
|
+
type PresentIdentity,
|
|
12
|
+
presentIdentitiesOnPage,
|
|
13
|
+
restoreIdentitiesOnPage,
|
|
14
|
+
} from "./present-identities";
|
|
9
15
|
import { requireScreenshotDir, SCREENSHOT_DIR_ENV } from "./screenshot-dir";
|
|
10
16
|
import { type SeedTenantFixture, test } from "./seeded-tenant-fixture";
|
|
11
17
|
|
|
@@ -33,14 +39,6 @@ export async function applyDefaultTheme(page: Page, theme: DefaultThemeId): Prom
|
|
|
33
39
|
}, theme);
|
|
34
40
|
}
|
|
35
41
|
|
|
36
|
-
// Seeded identities are unique per run (admin-<tenantId>@…), so a screenshot
|
|
37
|
-
// would show a different address every time. Replaced in the page's text and
|
|
38
|
-
// form values right before each capture; the seeded data stays untouched.
|
|
39
|
-
export interface PresentIdentity {
|
|
40
|
-
readonly from: string;
|
|
41
|
-
readonly to: string;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
42
|
export interface ScenarioFixtures {
|
|
45
43
|
readonly seedTenant: SeedTenantFixture;
|
|
46
44
|
// runMatrix's current locale, for apps whose routes carry the locale in the
|
|
@@ -165,73 +163,6 @@ async function waitForSettledPage(page: Page, inFlightDataRequests: () => number
|
|
|
165
163
|
.toBeGreaterThanOrEqual(STABLE_POLLS);
|
|
166
164
|
}
|
|
167
165
|
|
|
168
|
-
function assertPresentableMappings(mappings: readonly PresentIdentity[]): void {
|
|
169
|
-
for (const { from } of mappings) {
|
|
170
|
-
if (from === "") throw new Error("presentIdentities: `from` must not be empty");
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
declare global {
|
|
175
|
-
interface Window {
|
|
176
|
-
__kumikoPresentRestore?: Array<() => void>;
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
// Runs in the browser, so it may not reference module scope. Originals are kept
|
|
181
|
-
// on window so a later evaluate can put them back (captureScreenshot runs
|
|
182
|
-
// mid-flow, where the spec keeps asserting and submitting forms).
|
|
183
|
-
function replaceIdentitiesInDocument(mappings: readonly PresentIdentity[]): void {
|
|
184
|
-
window.__kumikoPresentRestore ??= [];
|
|
185
|
-
const restorers = window.__kumikoPresentRestore;
|
|
186
|
-
const present = (text: string): string =>
|
|
187
|
-
mappings.reduce((current, { from, to }) => current.replaceAll(from, to), text);
|
|
188
|
-
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
|
|
189
|
-
for (let node = walker.nextNode(); node !== null; node = walker.nextNode()) {
|
|
190
|
-
const text = node.nodeValue ?? "";
|
|
191
|
-
const presented = present(text);
|
|
192
|
-
if (presented !== text) {
|
|
193
|
-
const textNode = node;
|
|
194
|
-
textNode.nodeValue = presented;
|
|
195
|
-
restorers.push(() => {
|
|
196
|
-
textNode.nodeValue = text;
|
|
197
|
-
});
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
for (const field of document.querySelectorAll("input, textarea")) {
|
|
201
|
-
if (field instanceof HTMLInputElement || field instanceof HTMLTextAreaElement) {
|
|
202
|
-
const original = field.value;
|
|
203
|
-
const presented = present(original);
|
|
204
|
-
if (presented !== original) {
|
|
205
|
-
field.value = presented;
|
|
206
|
-
restorers.push(() => {
|
|
207
|
-
field.value = original;
|
|
208
|
-
});
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
// Reverse order: a second presentation pass records already-presented values.
|
|
215
|
-
function restoreIdentitiesInDocument(): void {
|
|
216
|
-
const restorers = window.__kumikoPresentRestore ?? [];
|
|
217
|
-
for (const restore of restorers.reverse()) restore();
|
|
218
|
-
window.__kumikoPresentRestore = [];
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
async function presentIdentitiesOnPage(
|
|
222
|
-
page: Page,
|
|
223
|
-
mappings: readonly PresentIdentity[],
|
|
224
|
-
): Promise<void> {
|
|
225
|
-
if (mappings.length > 0) await page.evaluate(replaceIdentitiesInDocument, mappings);
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
async function restoreIdentitiesOnPage(
|
|
229
|
-
page: Page,
|
|
230
|
-
mappings: readonly PresentIdentity[],
|
|
231
|
-
): Promise<void> {
|
|
232
|
-
if (mappings.length > 0) await page.evaluate(restoreIdentitiesInDocument);
|
|
233
|
-
}
|
|
234
|
-
|
|
235
166
|
// Order matters: beforeCapture first (it may hide/mask), identities after, so
|
|
236
167
|
// the replaced text is what the screenshot sees. Exported for unit tests.
|
|
237
168
|
export async function captureScenario(
|
package/src/e2e/seed-route.ts
CHANGED
|
@@ -107,6 +107,12 @@ function assertSeededTenant(tenantId: string, seededTenantIds: ReadonlySet<strin
|
|
|
107
107
|
export type E2eSeederContext = {
|
|
108
108
|
readonly write: (handlerQn: string, payload: unknown) => Promise<unknown>;
|
|
109
109
|
readonly query: (handlerQn: string, payload: unknown) => Promise<unknown>;
|
|
110
|
+
// System user with "system" plus exactly these roles, no SystemAdmin.
|
|
111
|
+
readonly writeAs: (
|
|
112
|
+
roles: readonly string[],
|
|
113
|
+
handlerQn: string,
|
|
114
|
+
payload: unknown,
|
|
115
|
+
) => Promise<unknown>;
|
|
110
116
|
};
|
|
111
117
|
|
|
112
118
|
// `body` is whatever the client sent; validate it with a zod schema's parse(),
|
|
@@ -311,6 +317,11 @@ export function createE2eSeedRoutesWith(
|
|
|
311
317
|
handlerQn,
|
|
312
318
|
await deps.dispatchSystemWrite({ handlerQn, payload, tenantId }),
|
|
313
319
|
),
|
|
320
|
+
writeAs: async (roles, handlerQn, payload) =>
|
|
321
|
+
unwrapWriteData(
|
|
322
|
+
handlerQn,
|
|
323
|
+
await deps.dispatchWriteAs({ handlerQn, payload, tenantId, roles }),
|
|
324
|
+
),
|
|
314
325
|
query: (handlerQn, payload) => deps.dispatchSystemQuery({ handlerQn, payload, tenantId }),
|
|
315
326
|
};
|
|
316
327
|
try {
|