@cosmicdrift/kumiko-testing 0.387.0 → 0.389.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/package.json +10 -6
- package/src/changes.json +56 -0
- package/src/e2e/app-boot-budget.ts +22 -0
- package/src/e2e/auth-kit.ts +8 -2
- package/src/e2e/boundary-guard.ts +37 -13
- package/src/e2e/index.ts +11 -2
- package/src/e2e/present-identities.ts +90 -0
- package/src/e2e/screenshots.ts +6 -75
- package/src/e2e/seed-route.ts +15 -1
- package/src/e2e/seeded-tenant-fixture.ts +27 -39
- package/src/helpers/barrier.ts +5 -1
- package/src/helpers/closed-connection-error.ts +4 -7
- package/src/helpers/console-calls.ts +21 -0
- package/src/helpers/index.ts +5 -0
- package/src/helpers/mock-module.ts +24 -0
- package/src/helpers/sse-frames.ts +6 -1
- package/src/stack/db.ts +3 -3
- package/src/stack/redis.ts +7 -1
- package/src/stack/test-stack-setup.ts +628 -0
- package/src/stack/test-stack.ts +41 -524
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-testing",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.389.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,9 +103,9 @@
|
|
|
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.389.0",
|
|
107
|
+
"@cosmicdrift/kumiko-guards": "0.389.0",
|
|
108
|
+
"@cosmicdrift/kumiko-types": "0.389.0",
|
|
105
109
|
"zod": "^4.4.3"
|
|
106
110
|
},
|
|
107
111
|
"peerDependencies": {
|
|
@@ -125,8 +129,8 @@
|
|
|
125
129
|
}
|
|
126
130
|
},
|
|
127
131
|
"devDependencies": {
|
|
128
|
-
"@cosmicdrift/kumiko-bundled-features": "0.
|
|
129
|
-
"@cosmicdrift/kumiko-dev-server": "0.
|
|
132
|
+
"@cosmicdrift/kumiko-bundled-features": "0.389.0",
|
|
133
|
+
"@cosmicdrift/kumiko-dev-server": "0.389.0",
|
|
130
134
|
"@happy-dom/global-registrator": "^20.11.2",
|
|
131
135
|
"@playwright/test": "^1.62.1",
|
|
132
136
|
"@testing-library/dom": "^10.4.1",
|
package/src/changes.json
CHANGED
|
@@ -1,4 +1,60 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.389.0",
|
|
4
|
+
"type": "breaking",
|
|
5
|
+
"title": "captureClosedConnectionError returns a Bun.SQL error",
|
|
6
|
+
"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.",
|
|
7
|
+
"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."
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"version": "0.389.0",
|
|
11
|
+
"type": "breaking",
|
|
12
|
+
"title": "e2e tests fail after more than 8 app boots; the per-test client IP no longer disables the HTTP cache",
|
|
13
|
+
"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.",
|
|
14
|
+
"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."
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"version": "0.389.0",
|
|
18
|
+
"type": "fix",
|
|
19
|
+
"title": "loginViaUi no longer races the MFA auto-submit",
|
|
20
|
+
"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."
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"version": "0.389.0",
|
|
24
|
+
"type": "improvement",
|
|
25
|
+
"title": "consoleCallsFromNamespace test helper for silent-log assertions",
|
|
26
|
+
"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."
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"version": "0.389.0",
|
|
30
|
+
"type": "improvement",
|
|
31
|
+
"title": "mockModuleForFile restores module mocks automatically",
|
|
32
|
+
"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."
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
"version": "0.389.0",
|
|
36
|
+
"type": "improvement",
|
|
37
|
+
"title": "E2E specs can present stable identities around their own screenshots",
|
|
38
|
+
"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."
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
"version": "0.388.0",
|
|
42
|
+
"type": "improvement",
|
|
43
|
+
"title": "setupTestStack split into setup phases",
|
|
44
|
+
"detail": "setupTestStack() keeps its signature and behavior. Feature validation, storage provisioning with its DDL cleanup, cache sync, services, the job runner and the server start are separate phases in stack/test-stack-setup.ts. The cleanup on a failed setup still stops the job runner, closes cache sync and drops the test database and Redis in that order."
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
"version": "0.388.0",
|
|
48
|
+
"type": "fix",
|
|
49
|
+
"title": "Barrier, SSE reader, boundary guard and Redis helper fixes",
|
|
50
|
+
"detail": "createBarrier rejects late arrivals after a timeout instead of hanging. readSseFrames accepts CRLF and CR line endings. The error-boundary guard observes every page of the context and no longer fails on closed pages. createTestRedis disconnects the client when the initial connect fails. createE2eSeedRoutes of the e2e/seed-route subpath is also available as createE2eSeedRoutesWith, the old name stays as a deprecated alias."
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
"version": "0.388.0",
|
|
54
|
+
"type": "fix",
|
|
55
|
+
"title": "consoleCallsMentioning helper for console spies",
|
|
56
|
+
"detail": "`@cosmicdrift/kumiko-testing/helpers` exports `consoleCallsMentioning(spy, fragment)` and `ConsoleSpyLike`, replacing the per-file filters tests used to ignore console output of other test files."
|
|
57
|
+
},
|
|
2
58
|
{
|
|
3
59
|
"version": "0.386.0",
|
|
4
60
|
"type": "fix",
|
|
@@ -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
|
|
|
@@ -3,7 +3,40 @@ import {
|
|
|
3
3
|
ROOT_ERROR_FALLBACK_TEST_ID,
|
|
4
4
|
SCREEN_ERROR_FALLBACK_TEST_ID,
|
|
5
5
|
} from "@cosmicdrift/kumiko-types/error-boundary";
|
|
6
|
-
import { test as base, expect } from "@playwright/test";
|
|
6
|
+
import { type BrowserContext, test as base, expect } from "@playwright/test";
|
|
7
|
+
|
|
8
|
+
export type BoundaryFindings = {
|
|
9
|
+
readonly boundaryLogs: readonly string[];
|
|
10
|
+
readonly visibleFallbacks: number;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const FALLBACK_SELECTOR = `[data-testid="${SCREEN_ERROR_FALLBACK_TEST_ID}"], [data-testid="${ROOT_ERROR_FALLBACK_TEST_ID}"]`;
|
|
14
|
+
|
|
15
|
+
// Listens on the context so pages opened later (popups, context.newPage()) are covered too.
|
|
16
|
+
export function observeErrorBoundaries(context: BrowserContext): {
|
|
17
|
+
readonly findings: () => Promise<BoundaryFindings>;
|
|
18
|
+
} {
|
|
19
|
+
const boundaryLogs: string[] = [];
|
|
20
|
+
context.on("console", (msg) => {
|
|
21
|
+
if (msg.type() === "error" && msg.text().includes(ERROR_BOUNDARY_LOG_MARKER)) {
|
|
22
|
+
boundaryLogs.push(msg.text());
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
return {
|
|
26
|
+
async findings() {
|
|
27
|
+
let visibleFallbacks = 0;
|
|
28
|
+
for (const page of context.pages()) {
|
|
29
|
+
// A spec may close its page or a page may crash; counting then would throw and mask the test result.
|
|
30
|
+
if (page.isClosed()) continue;
|
|
31
|
+
visibleFallbacks += await page
|
|
32
|
+
.locator(FALLBACK_SELECTOR)
|
|
33
|
+
.count()
|
|
34
|
+
.catch(() => 0);
|
|
35
|
+
}
|
|
36
|
+
return { boundaryLogs: [...boundaryLogs], visibleFallbacks };
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|
|
7
40
|
|
|
8
41
|
// An error boundary keeps the page alive, which also hides the failure from `pageerror`.
|
|
9
42
|
// Every spec using `test` from this package fails when a boundary caught something.
|
|
@@ -13,21 +46,12 @@ export const boundaryGuardedTest = base.extend<{
|
|
|
13
46
|
}>({
|
|
14
47
|
expectErrorBoundary: [false, { option: true }],
|
|
15
48
|
failOnErrorBoundary: [
|
|
16
|
-
async ({
|
|
17
|
-
const
|
|
18
|
-
page.on("console", (msg) => {
|
|
19
|
-
if (msg.type() === "error" && msg.text().includes(ERROR_BOUNDARY_LOG_MARKER)) {
|
|
20
|
-
boundaryLogs.push(msg.text());
|
|
21
|
-
}
|
|
22
|
-
});
|
|
49
|
+
async ({ context, expectErrorBoundary }, use) => {
|
|
50
|
+
const observer = observeErrorBoundaries(context);
|
|
23
51
|
await use(undefined);
|
|
24
52
|
// skip: the spec provokes a boundary on purpose and asserts the fallback itself
|
|
25
53
|
if (expectErrorBoundary) return;
|
|
26
|
-
const visibleFallbacks = await
|
|
27
|
-
.locator(
|
|
28
|
-
`[data-testid="${SCREEN_ERROR_FALLBACK_TEST_ID}"], [data-testid="${ROOT_ERROR_FALLBACK_TEST_ID}"]`,
|
|
29
|
-
)
|
|
30
|
-
.count();
|
|
54
|
+
const { boundaryLogs, visibleFallbacks } = await observer.findings();
|
|
31
55
|
expect(
|
|
32
56
|
{ boundaryLogs, visibleFallbacks },
|
|
33
57
|
"an error boundary caught a render/effect error",
|
package/src/e2e/index.ts
CHANGED
|
@@ -22,7 +22,11 @@ export {
|
|
|
22
22
|
syntheticClientIpFor,
|
|
23
23
|
totpCode,
|
|
24
24
|
} from "./auth-kit";
|
|
25
|
-
export {
|
|
25
|
+
export {
|
|
26
|
+
type BoundaryFindings,
|
|
27
|
+
boundaryGuardedTest,
|
|
28
|
+
observeErrorBoundaries,
|
|
29
|
+
} from "./boundary-guard";
|
|
26
30
|
export {
|
|
27
31
|
CSRF_COOKIE_NAME,
|
|
28
32
|
CSRF_HEADER_NAME,
|
|
@@ -47,6 +51,12 @@ export {
|
|
|
47
51
|
} from "./mail-links";
|
|
48
52
|
export { pinEnglishLocale } from "./pin-english-locale";
|
|
49
53
|
export { pollForRow, waitForProjection } from "./poll";
|
|
54
|
+
export {
|
|
55
|
+
type PresentIdentity,
|
|
56
|
+
presentIdentitiesOnPage,
|
|
57
|
+
restoreIdentitiesOnPage,
|
|
58
|
+
withPresentedIdentities,
|
|
59
|
+
} from "./present-identities";
|
|
50
60
|
export {
|
|
51
61
|
requireScreenshotDir,
|
|
52
62
|
SCREENSHOT_DIR_ENV,
|
|
@@ -62,7 +72,6 @@ export {
|
|
|
62
72
|
type FlatOptions,
|
|
63
73
|
findIdenticalThemeScreenshots,
|
|
64
74
|
type MatrixOptions,
|
|
65
|
-
type PresentIdentity,
|
|
66
75
|
runMatrix,
|
|
67
76
|
runScreenshots,
|
|
68
77
|
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(),
|
|
@@ -156,7 +162,7 @@ function toCapturedMail(message: InboxMessage): CapturedMail {
|
|
|
156
162
|
// createE2eSeedRoutes() : [])` in a server entry that also runs in prod.
|
|
157
163
|
// Each route's own assertGateOpen() 404s outside the seed condition regardless
|
|
158
164
|
// — this is belt-and-suspenders, not the only gate.
|
|
159
|
-
export function
|
|
165
|
+
export function createE2eSeedRoutesWith(
|
|
160
166
|
options: E2eSeedRoutesOptions,
|
|
161
167
|
bundled: SeedRouteBundledApi,
|
|
162
168
|
): readonly ExtraRouteDefinition[] {
|
|
@@ -311,6 +317,11 @@ export function createE2eSeedRoutes(
|
|
|
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 {
|
|
@@ -327,3 +338,6 @@ export function createE2eSeedRoutes(
|
|
|
327
338
|
|
|
328
339
|
return [seedTenantRoute, seedUserRoute, inboxRoute, extraSeedRoute];
|
|
329
340
|
}
|
|
341
|
+
|
|
342
|
+
/** @deprecated Use `createE2eSeedRoutesWith`; the old name collides with the bound `createE2eSeedRoutes` of dev-server/testing. */
|
|
343
|
+
export const createE2eSeedRoutes = createE2eSeedRoutesWith;
|
|
@@ -3,7 +3,6 @@ import type {
|
|
|
3
3
|
APIRequestContext,
|
|
4
4
|
BrowserContext,
|
|
5
5
|
Page,
|
|
6
|
-
Request as PlaywrightRequest,
|
|
7
6
|
PlaywrightTestArgs,
|
|
8
7
|
PlaywrightTestOptions,
|
|
9
8
|
PlaywrightWorkerArgs,
|
|
@@ -19,6 +18,11 @@ import {
|
|
|
19
18
|
type SeedTenantOptions,
|
|
20
19
|
withSession,
|
|
21
20
|
} from "../seed-types";
|
|
21
|
+
import {
|
|
22
|
+
appBootBudgetViolation,
|
|
23
|
+
DEFAULT_MAX_APP_BOOTS_PER_TEST,
|
|
24
|
+
observeAppBoots,
|
|
25
|
+
} from "./app-boot-budget";
|
|
22
26
|
import {
|
|
23
27
|
CLIENT_IP_HEADER,
|
|
24
28
|
clearSession,
|
|
@@ -69,6 +73,7 @@ export type E2eSeededTenant = Omit<SeededTenant, "addUser"> & {
|
|
|
69
73
|
export type SeedTenantFixture = (opts?: E2eSeedTenantOptions) => Promise<E2eSeededTenant>;
|
|
70
74
|
|
|
71
75
|
type E2eFixtures = {
|
|
76
|
+
maxAppBootsPerTest: number;
|
|
72
77
|
seedTenant: SeedTenantFixture;
|
|
73
78
|
};
|
|
74
79
|
|
|
@@ -230,52 +235,35 @@ export function perTestClientIpKey(
|
|
|
230
235
|
return `${runToken ?? ""}:${testInfo.testId}:${testInfo.repeatEachIndex}:${testInfo.retry}`;
|
|
231
236
|
}
|
|
232
237
|
|
|
233
|
-
//
|
|
234
|
-
//
|
|
235
|
-
// here). An explicit X-Forwarded-For set by the test wins.
|
|
236
|
-
export function headersWithClientIp(
|
|
237
|
-
request: { readonly url: string; readonly frameUrl: string | undefined },
|
|
238
|
-
headers: Readonly<Record<string, string>>,
|
|
239
|
-
clientIp: string,
|
|
240
|
-
): Record<string, string> | undefined {
|
|
241
|
-
if (request.frameUrl === undefined) return undefined;
|
|
242
|
-
let sameOrigin: boolean;
|
|
243
|
-
try {
|
|
244
|
-
sameOrigin = new URL(request.frameUrl).origin === new URL(request.url).origin;
|
|
245
|
-
} catch {
|
|
246
|
-
return undefined;
|
|
247
|
-
}
|
|
248
|
-
return sameOrigin ? { [CLIENT_IP_HEADER]: clientIp, ...headers } : undefined;
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
function frameUrlOf(request: PlaywrightRequest): string | undefined {
|
|
252
|
-
try {
|
|
253
|
-
return request.frame().url();
|
|
254
|
-
} catch {
|
|
255
|
-
// Service-worker requests have no frame.
|
|
256
|
-
return undefined;
|
|
257
|
-
}
|
|
258
|
-
}
|
|
259
|
-
|
|
238
|
+
// Chromium turns its HTTP cache off for a context as soon as it has a route,
|
|
239
|
+
// so the header goes through setExtraHTTPHeaders and the cache stays on.
|
|
260
240
|
export async function providePerTestClientIpContext(
|
|
261
|
-
{
|
|
241
|
+
{
|
|
242
|
+
context,
|
|
243
|
+
maxAppBootsPerTest,
|
|
244
|
+
}: Pick<PlaywrightTestArgs, "context"> & Pick<E2eFixtures, "maxAppBootsPerTest">,
|
|
262
245
|
use: (context: BrowserContext) => Promise<void>,
|
|
263
|
-
testInfo: Pick<TestInfo, "testId" | "repeatEachIndex" | "retry">,
|
|
246
|
+
testInfo: Pick<TestInfo, "testId" | "repeatEachIndex" | "retry" | "status" | "expectedStatus">,
|
|
264
247
|
): Promise<void> {
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
const request = route.request();
|
|
268
|
-
const headers = headersWithClientIp(
|
|
269
|
-
{ url: request.url(), frameUrl: frameUrlOf(request) },
|
|
270
|
-
request.headers(),
|
|
271
|
-
clientIp,
|
|
272
|
-
);
|
|
273
|
-
return headers === undefined ? route.fallback() : route.fallback({ headers });
|
|
248
|
+
await context.setExtraHTTPHeaders({
|
|
249
|
+
[CLIENT_IP_HEADER]: syntheticClientIpFor(perTestClientIpKey(testInfo)),
|
|
274
250
|
});
|
|
251
|
+
const appBoots = observeAppBoots(context);
|
|
275
252
|
await use(context);
|
|
253
|
+
|
|
254
|
+
// A window.open tab left open stalls the context close by seconds.
|
|
255
|
+
await Promise.all(context.pages().map((page) => (page.isClosed() ? undefined : page.close())));
|
|
256
|
+
|
|
257
|
+
// Only after a passing body, so the budget never hides the original failure.
|
|
258
|
+
const violation =
|
|
259
|
+
testInfo.status === testInfo.expectedStatus
|
|
260
|
+
? appBootBudgetViolation(appBoots.count(), maxAppBootsPerTest)
|
|
261
|
+
: undefined;
|
|
262
|
+
if (violation !== undefined) throw new Error(violation);
|
|
276
263
|
}
|
|
277
264
|
|
|
278
265
|
export const test = base.extend<E2eFixtures>({
|
|
266
|
+
maxAppBootsPerTest: [DEFAULT_MAX_APP_BOOTS_PER_TEST, { option: true }],
|
|
279
267
|
context: providePerTestClientIpContext,
|
|
280
268
|
seedTenant: provideSeedTenant,
|
|
281
269
|
});
|
package/src/helpers/barrier.ts
CHANGED
|
@@ -19,6 +19,8 @@ export function createBarrier(options: {
|
|
|
19
19
|
let arrived = 0;
|
|
20
20
|
let skipped = 0;
|
|
21
21
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
22
|
+
// Late arrivals after a timeout must fail loudly too instead of hanging until the test timeout.
|
|
23
|
+
let timedOutError: Error | undefined;
|
|
22
24
|
const waiting: { resolve: () => void; reject: (error: Error) => void }[] = [];
|
|
23
25
|
|
|
24
26
|
function openIfComplete(): void {
|
|
@@ -30,15 +32,17 @@ export function createBarrier(options: {
|
|
|
30
32
|
|
|
31
33
|
return {
|
|
32
34
|
arrive() {
|
|
35
|
+
if (timedOutError !== undefined) return Promise.reject(timedOutError);
|
|
33
36
|
arrived++;
|
|
34
37
|
const pending = new Promise<void>((resolve, reject) => {
|
|
35
38
|
waiting.push({ resolve, reject });
|
|
36
39
|
});
|
|
37
|
-
if (
|
|
40
|
+
if (timer === undefined && arrived + skipped < expected) {
|
|
38
41
|
timer = setTimeout(() => {
|
|
39
42
|
const error = new Error(
|
|
40
43
|
`barrier timed out: ${arrived} of ${expected} arrived, ${skipped} skipped`,
|
|
41
44
|
);
|
|
45
|
+
timedOutError = error;
|
|
42
46
|
for (const waiter of waiting.splice(0)) waiter.reject(error);
|
|
43
47
|
}, timeoutMs);
|
|
44
48
|
}
|
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
// the production matcher checks driver-specific codes a fake can't reproduce.
|
|
3
3
|
|
|
4
4
|
import { isClosedConnectionError } from "@cosmicdrift/kumiko-framework/internal/stack-support";
|
|
5
|
-
import postgres from "postgres";
|
|
6
5
|
import { requireEnv } from "../stack/db.ts";
|
|
7
6
|
|
|
8
7
|
export function testDatabaseUrl(): string {
|
|
@@ -14,8 +13,6 @@ const READS_PER_ROUND = 5;
|
|
|
14
13
|
|
|
15
14
|
type AdminClient = { unsafe(sql: string, params?: readonly unknown[]): Promise<unknown> };
|
|
16
15
|
|
|
17
|
-
// A per-round admin client hits a postgres-js reconnect timing bug (the first
|
|
18
|
-
// retry after terminate hangs); a single long-lived admin client avoids it.
|
|
19
16
|
export async function terminateBackendsByApplicationName(
|
|
20
17
|
admin: AdminClient,
|
|
21
18
|
applicationName: string,
|
|
@@ -31,11 +28,11 @@ export async function terminateBackendsByApplicationName(
|
|
|
31
28
|
export async function captureClosedConnectionError(
|
|
32
29
|
url: string = testDatabaseUrl(),
|
|
33
30
|
): Promise<unknown> {
|
|
34
|
-
const admin =
|
|
31
|
+
const admin = new Bun.SQL(url, { max: 1 });
|
|
35
32
|
try {
|
|
36
33
|
for (let round = 0; round < MAX_WARMUP_ROUNDS; round++) {
|
|
37
34
|
const applicationName = `kumiko-closed-conn-test-${crypto.randomUUID()}`;
|
|
38
|
-
const pool =
|
|
35
|
+
const pool = new Bun.SQL(url, { max: 1, connection: { application_name: applicationName } });
|
|
39
36
|
try {
|
|
40
37
|
await pool.unsafe("select 1");
|
|
41
38
|
await terminateBackendsByApplicationName(admin, applicationName);
|
|
@@ -47,13 +44,13 @@ export async function captureClosedConnectionError(
|
|
|
47
44
|
}
|
|
48
45
|
}
|
|
49
46
|
} finally {
|
|
50
|
-
await pool.
|
|
47
|
+
await pool.close({ timeout: 0 });
|
|
51
48
|
}
|
|
52
49
|
}
|
|
53
50
|
throw new Error(
|
|
54
51
|
`captureClosedConnectionError: no closed-connection error observed after ${MAX_WARMUP_ROUNDS} rounds.`,
|
|
55
52
|
);
|
|
56
53
|
} finally {
|
|
57
|
-
await admin.
|
|
54
|
+
await admin.close({ timeout: 0 });
|
|
58
55
|
}
|
|
59
56
|
}
|