@cosmicdrift/kumiko-testing 0.388.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 +38 -0
- package/src/e2e/app-boot-budget.ts +22 -0
- package/src/e2e/auth-kit.ts +8 -2
- package/src/e2e/index.ts +6 -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/index.ts +5 -1
- package/src/helpers/mock-module.ts +24 -0
- package/src/stack/db.ts +1 -1
- package/src/stack/test-stack-setup.ts +26 -5
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,42 @@
|
|
|
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
|
+
},
|
|
2
40
|
{
|
|
3
41
|
"version": "0.388.0",
|
|
4
42
|
"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
|
|
package/src/e2e/index.ts
CHANGED
|
@@ -51,6 +51,12 @@ export {
|
|
|
51
51
|
} from "./mail-links";
|
|
52
52
|
export { pinEnglishLocale } from "./pin-english-locale";
|
|
53
53
|
export { pollForRow, waitForProjection } from "./poll";
|
|
54
|
+
export {
|
|
55
|
+
type PresentIdentity,
|
|
56
|
+
presentIdentitiesOnPage,
|
|
57
|
+
restoreIdentitiesOnPage,
|
|
58
|
+
withPresentedIdentities,
|
|
59
|
+
} from "./present-identities";
|
|
54
60
|
export {
|
|
55
61
|
requireScreenshotDir,
|
|
56
62
|
SCREENSHOT_DIR_ENV,
|
|
@@ -66,7 +72,6 @@ export {
|
|
|
66
72
|
type FlatOptions,
|
|
67
73
|
findIdenticalThemeScreenshots,
|
|
68
74
|
type MatrixOptions,
|
|
69
|
-
type PresentIdentity,
|
|
70
75
|
runMatrix,
|
|
71
76
|
runScreenshots,
|
|
72
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(),
|
|
@@ -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 {
|
|
@@ -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
|
});
|
|
@@ -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
|
}
|
|
@@ -10,3 +10,12 @@ export function consoleCallsMentioning(
|
|
|
10
10
|
): ReadonlyArray<ReadonlyArray<unknown>> {
|
|
11
11
|
return spy.mock.calls.filter((args) => args.some((arg) => String(arg).includes(fragment)));
|
|
12
12
|
}
|
|
13
|
+
|
|
14
|
+
// For "stays silent" assertions: counts every call from the given logger namespace instead of one
|
|
15
|
+
// message text, so a new or reworded warning from the module under test still fails the test.
|
|
16
|
+
export function consoleCallsFromNamespace(
|
|
17
|
+
spy: ConsoleSpyLike,
|
|
18
|
+
namespace: string,
|
|
19
|
+
): ReadonlyArray<ReadonlyArray<unknown>> {
|
|
20
|
+
return spy.mock.calls.filter((args) => String(args[0]).startsWith(`[${namespace}]`));
|
|
21
|
+
}
|
package/src/helpers/index.ts
CHANGED
|
@@ -23,7 +23,11 @@ export {
|
|
|
23
23
|
terminateBackendsByApplicationName,
|
|
24
24
|
testDatabaseUrl,
|
|
25
25
|
} from "./closed-connection-error.ts";
|
|
26
|
-
export {
|
|
26
|
+
export {
|
|
27
|
+
type ConsoleSpyLike,
|
|
28
|
+
consoleCallsFromNamespace,
|
|
29
|
+
consoleCallsMentioning,
|
|
30
|
+
} from "./console-calls.ts";
|
|
27
31
|
export { type ClearableTable, clearTables, resetTestTables } from "./db-cleanup.ts";
|
|
28
32
|
export {
|
|
29
33
|
type E2EGeneratorOptions,
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { afterAll, mock } from "bun:test";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Mocks `specifier` with the real exports plus `overrides` and restores the real module in
|
|
5
|
+
* `afterAll`: bun's mock.module is process-global, so a leaked mock breaks later files of a
|
|
6
|
+
* shared DOM run. Pass the namespace imported before mocking; it is copied here because
|
|
7
|
+
* mock.module patches an already imported namespace in place. A relative specifier must be
|
|
8
|
+
* absolute (resolve it against the test file), since mock.module resolves it from this file.
|
|
9
|
+
*
|
|
10
|
+
* `restoreOverrides` fixes up modules that re-export a mocked binding: their copy was taken
|
|
11
|
+
* under the mock and holds the spy, so it must be pointed back at the real export.
|
|
12
|
+
*/
|
|
13
|
+
export function mockModuleForFile(
|
|
14
|
+
specifier: string,
|
|
15
|
+
realModule: object,
|
|
16
|
+
overrides: Readonly<Record<string, unknown>>,
|
|
17
|
+
options: { readonly restoreOverrides?: Readonly<Record<string, unknown>> } = {},
|
|
18
|
+
): void {
|
|
19
|
+
const real = { ...realModule };
|
|
20
|
+
mock.module(specifier, () => ({ ...real, ...overrides }));
|
|
21
|
+
afterAll(() => {
|
|
22
|
+
mock.module(specifier, () => ({ ...real, ...options.restoreOverrides }));
|
|
23
|
+
});
|
|
24
|
+
}
|
package/src/stack/db.ts
CHANGED
|
@@ -99,7 +99,7 @@ export async function createTestDb(arg?: string | CreateTestDbOptions): Promise<
|
|
|
99
99
|
db: conn.db,
|
|
100
100
|
poolMax,
|
|
101
101
|
outsideTransactionDb: outsideConn.db,
|
|
102
|
-
client: conn.
|
|
102
|
+
client: conn.listenClient,
|
|
103
103
|
dbName,
|
|
104
104
|
cleanup: async () => {
|
|
105
105
|
await conn.close();
|
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
import type { SearchAdapter } from "@cosmicdrift/kumiko-framework/search";
|
|
32
32
|
import { createInMemorySearchAdapter } from "@cosmicdrift/kumiko-framework/search";
|
|
33
33
|
import { propertyOf } from "@cosmicdrift/kumiko-types/type-guards";
|
|
34
|
+
import type { Clock } from "@cosmicdrift/kumiko-types/tz-context";
|
|
34
35
|
import {
|
|
35
36
|
createTestDb,
|
|
36
37
|
ensureTestDbTemplate,
|
|
@@ -248,14 +249,26 @@ export function createStackCacheSync(
|
|
|
248
249
|
// A static `files.storageProvider` is wired as the per-tenant resolver — the
|
|
249
250
|
// framework test seam that doesn't require mounting config + file-foundation.
|
|
250
251
|
// (Bundled GDPR tests mount the real provider features instead.)
|
|
252
|
+
function isClock(value: unknown): value is Clock {
|
|
253
|
+
return (
|
|
254
|
+
typeof value === "object" && value !== null && "now" in value && typeof value.now === "function"
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
|
|
251
258
|
async function resolveStackFileProvider(
|
|
252
259
|
options: TestStackOptions,
|
|
253
260
|
testDb: StackStorage["testDb"],
|
|
261
|
+
readClock: () => unknown,
|
|
254
262
|
): Promise<import("@cosmicdrift/kumiko-framework/files").FileProviderResolver | undefined> {
|
|
255
263
|
if (!options.files) return undefined;
|
|
256
264
|
const provider = options.files.storageProvider;
|
|
257
265
|
const { withUploadLedger } = await import("@cosmicdrift/kumiko-framework/files");
|
|
258
|
-
|
|
266
|
+
// Read per call: the clock arrives via extraContext, which is merged after this resolver is built.
|
|
267
|
+
const now = (): Temporal.Instant => {
|
|
268
|
+
const clock = readClock();
|
|
269
|
+
return isClock(clock) ? clock.now() : Temporal.Now.instant();
|
|
270
|
+
};
|
|
271
|
+
return withUploadLedger(() => Promise.resolve(provider), testDb.db, now);
|
|
259
272
|
}
|
|
260
273
|
|
|
261
274
|
export async function buildStackServices(
|
|
@@ -277,7 +290,12 @@ export async function buildStackServices(
|
|
|
277
290
|
);
|
|
278
291
|
|
|
279
292
|
const entityCache = createEntityCache(testRedis.redis, { ttlSeconds: 60 });
|
|
280
|
-
const
|
|
293
|
+
const extraContextClock: { current: unknown } = { current: undefined };
|
|
294
|
+
const fileProviderResolver = await resolveStackFileProvider(
|
|
295
|
+
options,
|
|
296
|
+
testDb,
|
|
297
|
+
() => extraContextClock.current,
|
|
298
|
+
);
|
|
281
299
|
const observability = options.observability ?? createNoopProvider();
|
|
282
300
|
|
|
283
301
|
// Same AppContext buildServer() gets below (db/redis/searchAdapter/
|
|
@@ -292,6 +310,10 @@ export async function buildStackServices(
|
|
|
292
310
|
// index.ts). Putting it here would reintroduce the exact test-vs-prod
|
|
293
311
|
// drift fixed: a job/handler reading `ctx.effectiveFeatures` would pass in
|
|
294
312
|
// tests and break in prod.
|
|
313
|
+
const extraContext =
|
|
314
|
+
typeof options.extraContext === "function"
|
|
315
|
+
? options.extraContext({ registry, db: testDb.db, sseBroker, redis: testRedis.redis })
|
|
316
|
+
: options.extraContext;
|
|
295
317
|
const appContext = {
|
|
296
318
|
db: testDb.db,
|
|
297
319
|
redis: testRedis.redis,
|
|
@@ -301,10 +323,9 @@ export async function buildStackServices(
|
|
|
301
323
|
...(options.masterKeyProvider ? { masterKeyProvider: options.masterKeyProvider } : {}),
|
|
302
324
|
...(fileProviderResolver ? { _fileProviderResolver: fileProviderResolver } : {}),
|
|
303
325
|
...(options.defaultLocale !== undefined && { defaultLocale: options.defaultLocale }),
|
|
304
|
-
...
|
|
305
|
-
? options.extraContext({ registry, db: testDb.db, sseBroker, redis: testRedis.redis })
|
|
306
|
-
: options.extraContext),
|
|
326
|
+
...extraContext,
|
|
307
327
|
};
|
|
328
|
+
extraContextClock.current = extraContext?.["clock"];
|
|
308
329
|
return { searchAdapter, events, registry, sseBroker, entityCache, observability, appContext };
|
|
309
330
|
}
|
|
310
331
|
|