@cosmicdrift/kumiko-testing 0.387.0 → 0.388.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.388.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>",
@@ -99,9 +99,9 @@
99
99
  "kumiko-testing": "./bin/kumiko-testing.ts"
100
100
  },
101
101
  "dependencies": {
102
- "@cosmicdrift/kumiko-framework": "0.387.0",
103
- "@cosmicdrift/kumiko-guards": "0.387.0",
104
- "@cosmicdrift/kumiko-types": "0.387.0",
102
+ "@cosmicdrift/kumiko-framework": "0.388.0",
103
+ "@cosmicdrift/kumiko-guards": "0.388.0",
104
+ "@cosmicdrift/kumiko-types": "0.388.0",
105
105
  "zod": "^4.4.3"
106
106
  },
107
107
  "peerDependencies": {
@@ -125,8 +125,8 @@
125
125
  }
126
126
  },
127
127
  "devDependencies": {
128
- "@cosmicdrift/kumiko-bundled-features": "0.387.0",
129
- "@cosmicdrift/kumiko-dev-server": "0.387.0",
128
+ "@cosmicdrift/kumiko-bundled-features": "0.388.0",
129
+ "@cosmicdrift/kumiko-dev-server": "0.388.0",
130
130
  "@happy-dom/global-registrator": "^20.11.2",
131
131
  "@playwright/test": "^1.62.1",
132
132
  "@testing-library/dom": "^10.4.1",
package/src/changes.json CHANGED
@@ -1,4 +1,22 @@
1
1
  [
2
+ {
3
+ "version": "0.388.0",
4
+ "type": "improvement",
5
+ "title": "setupTestStack split into setup phases",
6
+ "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."
7
+ },
8
+ {
9
+ "version": "0.388.0",
10
+ "type": "fix",
11
+ "title": "Barrier, SSE reader, boundary guard and Redis helper fixes",
12
+ "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."
13
+ },
14
+ {
15
+ "version": "0.388.0",
16
+ "type": "fix",
17
+ "title": "consoleCallsMentioning helper for console spies",
18
+ "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."
19
+ },
2
20
  {
3
21
  "version": "0.386.0",
4
22
  "type": "fix",
@@ -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,
@@ -156,7 +156,7 @@ function toCapturedMail(message: InboxMessage): CapturedMail {
156
156
  // createE2eSeedRoutes() : [])` in a server entry that also runs in prod.
157
157
  // Each route's own assertGateOpen() 404s outside the seed condition regardless
158
158
  // — this is belt-and-suspenders, not the only gate.
159
- export function createE2eSeedRoutes(
159
+ export function createE2eSeedRoutesWith(
160
160
  options: E2eSeedRoutesOptions,
161
161
  bundled: SeedRouteBundledApi,
162
162
  ): readonly ExtraRouteDefinition[] {
@@ -327,3 +327,6 @@ export function createE2eSeedRoutes(
327
327
 
328
328
  return [seedTenantRoute, seedUserRoute, inboxRoute, extraSeedRoute];
329
329
  }
330
+
331
+ /** @deprecated Use `createE2eSeedRoutesWith`; the old name collides with the bound `createE2eSeedRoutes` of dev-server/testing. */
332
+ export const createE2eSeedRoutes = createE2eSeedRoutesWith;
@@ -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
  }
@@ -0,0 +1,12 @@
1
+ export type ConsoleSpyLike = {
2
+ readonly mock: { readonly calls: ReadonlyArray<ReadonlyArray<unknown>> };
3
+ };
4
+
5
+ // Other test files in the same bun process emit async console output while a console spy is
6
+ // active, so assertions only count the calls that mention the message under test.
7
+ export function consoleCallsMentioning(
8
+ spy: ConsoleSpyLike,
9
+ fragment: string,
10
+ ): ReadonlyArray<ReadonlyArray<unknown>> {
11
+ return spy.mock.calls.filter((args) => args.some((arg) => String(arg).includes(fragment)));
12
+ }
@@ -23,6 +23,7 @@ export {
23
23
  terminateBackendsByApplicationName,
24
24
  testDatabaseUrl,
25
25
  } from "./closed-connection-error.ts";
26
+ export { type ConsoleSpyLike, consoleCallsMentioning } from "./console-calls.ts";
26
27
  export { type ClearableTable, clearTables, resetTestTables } from "./db-cleanup.ts";
27
28
  export {
28
29
  type E2EGeneratorOptions,
@@ -25,6 +25,8 @@ export async function readSseFrames(
25
25
  const decoder = new TextDecoder();
26
26
  const frames: SseFrame[] = [];
27
27
  let buffer = "";
28
+ // A "\r" at a chunk end may be the first half of "\r\n", so it is held back until the next chunk.
29
+ let heldCarriageReturn: boolean = false;
28
30
  let timer: ReturnType<typeof setTimeout> | undefined;
29
31
  const timedOut = new Promise<never>((_, reject) => {
30
32
  timer = setTimeout(
@@ -40,7 +42,10 @@ export async function readSseFrames(
40
42
  if (chunk.done) {
41
43
  throw new Error(`sse stream ended before condition: ${frames.length} frames read`);
42
44
  }
43
- buffer += decoder.decode(chunk.value, { stream: true });
45
+ const decodedText = decoder.decode(chunk.value, { stream: true });
46
+ const decoded: string = (heldCarriageReturn ? "\r" : "") + decodedText;
47
+ heldCarriageReturn = decoded.endsWith("\r");
48
+ buffer += (heldCarriageReturn ? decoded.slice(0, -1) : decoded).replace(/\r\n?/g, "\n");
44
49
  let separator = buffer.indexOf("\n\n");
45
50
  while (separator !== -1) {
46
51
  const rawFrame = buffer.slice(0, separator);
package/src/stack/db.ts CHANGED
@@ -54,7 +54,7 @@ export function testDbTemplatePrefix(runId: string): string {
54
54
  return `kumiko_test_tpl_${runId}_`;
55
55
  }
56
56
 
57
- function adminUrlOf(url: string): string {
57
+ export function adminUrlOf(url: string): string {
58
58
  return url.replace(/\/[^/]+$/, "/postgres");
59
59
  }
60
60
 
@@ -132,7 +132,7 @@ export function ensureTestDbTemplate(
132
132
  return pending;
133
133
  }
134
134
 
135
- async function buildTestDbTemplate(
135
+ export async function buildTestDbTemplate(
136
136
  templateDbName: string,
137
137
  buildSchema: (db: DbConnection) => Promise<void>,
138
138
  ): Promise<void> {
@@ -40,7 +40,13 @@ export async function createTestRedis(opts: CreateTestRedisOptions = {}): Promis
40
40
  const prefix = opts.borrowKeyPrefix ?? `kt:${generateId().slice(-8)}:`;
41
41
  // Callers that check redis.status (rate-limit resolver) must not see "connect" on their first call.
42
42
  const redis = new Redis(redisUrl, { keyPrefix: prefix, lazyConnect: true });
43
- await redis.connect();
43
+ try {
44
+ await redis.connect();
45
+ } catch (error) {
46
+ // Without this the client keeps retrying in the background and holds the process open.
47
+ redis.disconnect();
48
+ throw error;
49
+ }
44
50
 
45
51
  async function deleteKeysMatching(pattern: string): Promise<void> {
46
52
  // Open a prefix-less client for the scan — ioredis' keyPrefix is applied