@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-testing",
3
- "version": "0.387.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.387.0",
103
- "@cosmicdrift/kumiko-guards": "0.387.0",
104
- "@cosmicdrift/kumiko-types": "0.387.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.387.0",
129
- "@cosmicdrift/kumiko-dev-server": "0.387.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
+ }
@@ -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
- await codeField.fill(await unburnedTotpCode(credentials.mfaTotpSecret));
277
- await codeField.press("Enter");
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 ({ page, expectErrorBoundary }, use) => {
17
- const boundaryLogs: string[] = [];
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 page
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 { boundaryGuardedTest } from "./boundary-guard";
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
+ }
@@ -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(
@@ -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 createE2eSeedRoutes(
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
- // Same-origin only: on a cross-origin fetch the extra header would force a
234
- // CORS preflight the target never agreed to (why extraHTTPHeaders is no option
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
- { context }: Pick<PlaywrightTestArgs, "context">,
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
- const clientIp = syntheticClientIpFor(perTestClientIpKey(testInfo));
266
- await context.route("**/api/**", (route) => {
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
  });
@@ -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 (arrived === 1 && arrived + skipped < expected) {
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 = postgres(url, { max: 1 });
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 = postgres(url, { max: 1, connection: { application_name: applicationName } });
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.end({ timeout: 0 });
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.end({ timeout: 0 });
54
+ await admin.close({ timeout: 0 });
58
55
  }
59
56
  }