@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-testing",
3
- "version": "0.388.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.388.0",
103
- "@cosmicdrift/kumiko-guards": "0.388.0",
104
- "@cosmicdrift/kumiko-types": "0.388.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.388.0",
129
- "@cosmicdrift/kumiko-dev-server": "0.388.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
+ }
@@ -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
 
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
+ }
@@ -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(),
@@ -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
- // 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
  });
@@ -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
  }
@@ -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
+ }
@@ -23,7 +23,11 @@ export {
23
23
  terminateBackendsByApplicationName,
24
24
  testDatabaseUrl,
25
25
  } from "./closed-connection-error.ts";
26
- export { type ConsoleSpyLike, consoleCallsMentioning } from "./console-calls.ts";
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.client,
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
- return withUploadLedger(() => Promise.resolve(provider), testDb.db);
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 fileProviderResolver = await resolveStackFileProvider(options, testDb);
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
- ...(typeof options.extraContext === "function"
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