@cosmicdrift/kumiko-testing 0.380.0 → 0.382.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.380.0",
3
+ "version": "0.382.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": "BUSL-1.1",
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.380.0",
103
- "@cosmicdrift/kumiko-guards": "0.380.0",
104
- "@cosmicdrift/kumiko-types": "0.380.0",
102
+ "@cosmicdrift/kumiko-framework": "0.382.0",
103
+ "@cosmicdrift/kumiko-guards": "0.382.0",
104
+ "@cosmicdrift/kumiko-types": "0.382.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.380.0",
129
- "@cosmicdrift/kumiko-dev-server": "0.380.0",
128
+ "@cosmicdrift/kumiko-bundled-features": "0.382.0",
129
+ "@cosmicdrift/kumiko-dev-server": "0.382.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,16 @@
1
1
  [
2
+ {
3
+ "version": "0.382.0",
4
+ "type": "improvement",
5
+ "title": "withEnv and readSseFrames test helpers",
6
+ "detail": "withEnv restores process.env after a callback so parallel no-isolate runs do not leak variables; readSseFrames replaces hand-written SSE stream parsers in tests."
7
+ },
8
+ {
9
+ "version": "0.381.0",
10
+ "type": "improvement",
11
+ "title": "Deterministic concurrency test helpers",
12
+ "detail": "createBarrier proves handler calls overlapped, burst fires N requests and tallies statuses with a guard against the pool size, TestStack exposes dbPoolMax."
13
+ },
2
14
  {
3
15
  "version": "0.376.0",
4
16
  "type": "fix",
@@ -0,0 +1,59 @@
1
+ export type Barrier = {
2
+ readonly arrive: () => Promise<void>;
3
+ readonly skip: () => void;
4
+ readonly arrived: number;
5
+ readonly skipped: number;
6
+ };
7
+
8
+ const DEFAULT_BARRIER_TIMEOUT_MS = 5000;
9
+
10
+ export function createBarrier(options: {
11
+ readonly expected: number;
12
+ readonly timeoutMs?: number;
13
+ }): Barrier {
14
+ const { expected, timeoutMs = DEFAULT_BARRIER_TIMEOUT_MS } = options;
15
+ if (expected < 1) {
16
+ throw new Error(`barrier expected must be at least 1, got ${expected}`);
17
+ }
18
+
19
+ let arrived = 0;
20
+ let skipped = 0;
21
+ let timer: ReturnType<typeof setTimeout> | undefined;
22
+ const waiting: { resolve: () => void; reject: (error: Error) => void }[] = [];
23
+
24
+ function openIfComplete(): void {
25
+ if (arrived + skipped >= expected) {
26
+ clearTimeout(timer);
27
+ for (const waiter of waiting.splice(0)) waiter.resolve();
28
+ }
29
+ }
30
+
31
+ return {
32
+ arrive() {
33
+ arrived++;
34
+ const pending = new Promise<void>((resolve, reject) => {
35
+ waiting.push({ resolve, reject });
36
+ });
37
+ if (arrived === 1 && arrived + skipped < expected) {
38
+ timer = setTimeout(() => {
39
+ const error = new Error(
40
+ `barrier timed out: ${arrived} of ${expected} arrived, ${skipped} skipped`,
41
+ );
42
+ for (const waiter of waiting.splice(0)) waiter.reject(error);
43
+ }, timeoutMs);
44
+ }
45
+ openIfComplete();
46
+ return pending;
47
+ },
48
+ skip() {
49
+ skipped++;
50
+ openIfComplete();
51
+ },
52
+ get arrived() {
53
+ return arrived;
54
+ },
55
+ get skipped() {
56
+ return skipped;
57
+ },
58
+ };
59
+ }
@@ -0,0 +1,22 @@
1
+ export type BurstResult = {
2
+ readonly responses: readonly Response[];
3
+ readonly statuses: Readonly<Record<number, number>>;
4
+ };
5
+
6
+ export async function burst(
7
+ stack: { readonly dbPoolMax: number },
8
+ count: number,
9
+ request: (index: number) => Promise<Response>,
10
+ ): Promise<BurstResult> {
11
+ if (count > stack.dbPoolMax) {
12
+ throw new Error(
13
+ `burst of ${count} exceeds dbPoolMax ${stack.dbPoolMax}: extra requests queue on the pool and a barrier inside the handler deadlocks; raise TestStackOptions.dbPoolMax or lower the count`,
14
+ );
15
+ }
16
+ const responses = await Promise.all(Array.from({ length: count }, (_, index) => request(index)));
17
+ const statuses: Record<number, number> = {};
18
+ for (const response of responses) {
19
+ statuses[response.status] = (statuses[response.status] ?? 0) + 1;
20
+ }
21
+ return { responses, statuses };
22
+ }
@@ -13,10 +13,11 @@ export {
13
13
  resetEventPiiCatalogForTests,
14
14
  resetPiiSubjectKmsForTests,
15
15
  } from "@cosmicdrift/kumiko-framework/internal/testing";
16
-
17
16
  export { rolesOf } from "./access-assertions.ts";
18
17
  export { expectError, expectSuccess } from "./assertions.ts";
18
+ export { type Barrier, createBarrier } from "./barrier.ts";
19
19
  export { withBootValidatorFixture } from "./boot-validator-fixture.ts";
20
+ export { type BurstResult, burst } from "./burst.ts";
20
21
  export {
21
22
  captureClosedConnectionError,
22
23
  terminateBackendsByApplicationName,
@@ -61,8 +62,10 @@ export {
61
62
  sharedWidgetEntity,
62
63
  sharedWidgetTable,
63
64
  } from "./shared-entities.ts";
65
+ export { readSseFrames, type SseFrame } from "./sse-frames.ts";
64
66
  export { createTestStoredEvent } from "./stored-event-fixture.ts";
65
67
  export { sleep } from "./utils.ts";
66
68
  export { waitFor } from "./wait-for.ts";
69
+ export { withEnv } from "./with-env.ts";
67
70
  export { withoutAmbientTemporal } from "./without-ambient-temporal.ts";
68
71
  export { zipWithEntries } from "./zip-fixture.ts";
@@ -0,0 +1,82 @@
1
+ export type SseFrame = {
2
+ readonly event: string | undefined;
3
+ readonly data: string;
4
+ readonly id: string | undefined;
5
+ };
6
+
7
+ export type ReadSseFramesOptions = {
8
+ readonly until: (frame: SseFrame, all: readonly SseFrame[]) => boolean;
9
+ readonly skipPings?: boolean;
10
+ readonly timeoutMs?: number;
11
+ };
12
+
13
+ const DEFAULT_TIMEOUT_MS = 5000;
14
+
15
+ export async function readSseFrames(
16
+ response: Response,
17
+ options: ReadSseFramesOptions,
18
+ ): Promise<SseFrame[]> {
19
+ if (response.body === null) {
20
+ throw new Error("sse response has no body");
21
+ }
22
+ const skipPings = options.skipPings ?? true;
23
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
24
+ const reader = response.body.getReader();
25
+ const decoder = new TextDecoder();
26
+ const frames: SseFrame[] = [];
27
+ let buffer = "";
28
+ let timer: ReturnType<typeof setTimeout> | undefined;
29
+ const timedOut = new Promise<never>((_, reject) => {
30
+ timer = setTimeout(
31
+ () =>
32
+ reject(new Error(`sse read timed out after ${timeoutMs}ms: ${frames.length} frames read`)),
33
+ timeoutMs,
34
+ );
35
+ });
36
+
37
+ try {
38
+ for (;;) {
39
+ const chunk = await Promise.race([reader.read(), timedOut]);
40
+ if (chunk.done) {
41
+ throw new Error(`sse stream ended before condition: ${frames.length} frames read`);
42
+ }
43
+ buffer += decoder.decode(chunk.value, { stream: true });
44
+ let separator = buffer.indexOf("\n\n");
45
+ while (separator !== -1) {
46
+ const rawFrame = buffer.slice(0, separator);
47
+ buffer = buffer.slice(separator + 2);
48
+ const frame = parseFrame(rawFrame);
49
+ if (frame !== undefined && !(skipPings && frame.event === "ping")) {
50
+ frames.push(frame);
51
+ if (options.until(frame, frames)) {
52
+ return frames;
53
+ }
54
+ }
55
+ separator = buffer.indexOf("\n\n");
56
+ }
57
+ }
58
+ } finally {
59
+ clearTimeout(timer);
60
+ await reader.cancel().catch(() => undefined);
61
+ }
62
+ }
63
+
64
+ function parseFrame(rawFrame: string): SseFrame | undefined {
65
+ let event: string | undefined;
66
+ let id: string | undefined;
67
+ const dataLines: string[] = [];
68
+ for (const line of rawFrame.split("\n")) {
69
+ if (line === "" || line.startsWith(":")) continue;
70
+ const colon = line.indexOf(":");
71
+ const field = colon === -1 ? line : line.slice(0, colon);
72
+ const rawValue = colon === -1 ? "" : line.slice(colon + 1);
73
+ const value = rawValue.startsWith(" ") ? rawValue.slice(1) : rawValue;
74
+ if (field === "event") event = value;
75
+ else if (field === "id") id = value;
76
+ else if (field === "data") dataLines.push(value);
77
+ }
78
+ if (event === undefined && id === undefined && dataLines.length === 0) {
79
+ return undefined;
80
+ }
81
+ return { event, data: dataLines.join("\n"), id };
82
+ }
@@ -0,0 +1,28 @@
1
+ export async function withEnv<T>(
2
+ overrides: Readonly<Record<string, string | undefined>>,
3
+ run: () => T | Promise<T>,
4
+ ): Promise<T> {
5
+ const previous = new Map<string, string | undefined>();
6
+ for (const key of Object.keys(overrides)) {
7
+ previous.set(key, process.env[key]);
8
+ }
9
+ try {
10
+ for (const [key, value] of Object.entries(overrides)) {
11
+ applyEnvValue(key, value);
12
+ }
13
+ return await run();
14
+ } finally {
15
+ for (const [key, value] of previous) {
16
+ applyEnvValue(key, value);
17
+ }
18
+ }
19
+ }
20
+
21
+ // Assigning undefined would store the string "undefined", so absence must go through delete.
22
+ function applyEnvValue(key: string, value: string | undefined): void {
23
+ if (value === undefined) {
24
+ delete process.env[key];
25
+ } else {
26
+ process.env[key] = value;
27
+ }
28
+ }
package/src/stack/db.ts CHANGED
@@ -22,8 +22,11 @@ function requireEnv(name: string): string {
22
22
  return value;
23
23
  }
24
24
 
25
+ export const DEFAULT_TEST_DB_POOL_MAX = 10;
26
+
25
27
  export type TestDb = {
26
28
  db: DbConnection;
29
+ poolMax: number;
27
30
  outsideTransactionDb: DbConnection;
28
31
  client: PgListenClient;
29
32
  dbName: string;
@@ -59,10 +62,8 @@ export async function createTestDb(arg?: string | CreateTestDbOptions): Promise<
59
62
  }
60
63
 
61
64
  const testUrl = url.replace(/\/[^/]+$/, `/${dbName}`);
62
- const conn = createDbConnection({
63
- url: testUrl,
64
- ...(opts.poolMax !== undefined ? { maxConnections: opts.poolMax } : {}),
65
- });
65
+ const poolMax = opts.poolMax ?? DEFAULT_TEST_DB_POOL_MAX;
66
+ const conn = createDbConnection({ url: testUrl, maxConnections: poolMax });
66
67
  const outsideConn = createDbConnection({
67
68
  url: testUrl,
68
69
  maxConnections: opts.outsideTransactionPoolMax ?? DEFAULT_OUTSIDE_TRANSACTION_POOL_MAX,
@@ -73,6 +74,7 @@ export async function createTestDb(arg?: string | CreateTestDbOptions): Promise<
73
74
 
74
75
  return {
75
76
  db: conn.db,
77
+ poolMax,
76
78
  outsideTransactionDb: outsideConn.db,
77
79
  client: conn.client,
78
80
  dbName,
@@ -17,6 +17,7 @@ export {
17
17
  export {
18
18
  type CreateTestDbOptions,
19
19
  createTestDb,
20
+ DEFAULT_TEST_DB_POOL_MAX,
20
21
  type TestDb,
21
22
  } from "./db.ts";
22
23
  export { drainEventConsumers } from "./drain-event-consumers.ts";
@@ -61,6 +61,8 @@ export type TestStack = {
61
61
  jwt: JwtHelper;
62
62
  registry: Registry;
63
63
  db: import("@cosmicdrift/kumiko-framework/db").DbConnection;
64
+ /** Resolved connection pool size of `db`. */
65
+ readonly dbPoolMax: number;
64
66
  redis: TestRedis;
65
67
  // The bus this stack publishes/subscribes on; pass to TierResolverPlugin.build
66
68
  // so a tier-engine resolver built outside buildServer joins the same sync.
@@ -686,6 +688,7 @@ export async function setupTestStack(options: TestStackOptions): Promise<TestSta
686
688
  jwt: server.jwt,
687
689
  registry,
688
690
  db: testDb.db,
691
+ dbPoolMax: testDb.poolMax,
689
692
  redis: testRedis,
690
693
  cacheSync,
691
694
  search: searchAdapter,