@cosmicdrift/kumiko-testing 0.379.0 → 0.381.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 +6 -6
- package/src/changes.json +6 -0
- package/src/helpers/barrier.ts +59 -0
- package/src/helpers/burst.ts +22 -0
- package/src/helpers/db-cleanup.ts +5 -19
- package/src/helpers/index.ts +2 -1
- package/src/helpers/seed.ts +10 -16
- package/src/stack/db.ts +6 -4
- package/src/stack/index.ts +1 -0
- package/src/stack/test-stack.ts +3 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-testing",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.381.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.
|
|
103
|
-
"@cosmicdrift/kumiko-guards": "0.
|
|
104
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
102
|
+
"@cosmicdrift/kumiko-framework": "0.381.0",
|
|
103
|
+
"@cosmicdrift/kumiko-guards": "0.381.0",
|
|
104
|
+
"@cosmicdrift/kumiko-types": "0.381.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.
|
|
129
|
-
"@cosmicdrift/kumiko-dev-server": "0.
|
|
128
|
+
"@cosmicdrift/kumiko-bundled-features": "0.381.0",
|
|
129
|
+
"@cosmicdrift/kumiko-dev-server": "0.381.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,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.381.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "Deterministic concurrency test helpers",
|
|
6
|
+
"detail": "createBarrier proves handler calls overlapped, burst fires N requests and tallies statuses with a guard against the pool size, TestStack exposes dbPoolMax."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.376.0",
|
|
4
10
|
"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
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { EntityTableMeta } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
-
import { deleteMany } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
+
import { deleteMany, requireEntityTableMeta } from "@cosmicdrift/kumiko-framework/db";
|
|
3
3
|
import type { AnyDb } from "@cosmicdrift/kumiko-framework/internal/stack-support";
|
|
4
4
|
import { withExecutorManagedWritesForTests } from "@cosmicdrift/kumiko-framework/internal/testing";
|
|
5
5
|
|
|
@@ -13,30 +13,16 @@ function tableFromName(name: string): EntityTableMeta {
|
|
|
13
13
|
return { tableName: name, columns: [], indexes: [], source: "unmanaged" };
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
function resolveClearableTable(table: ClearableTable):
|
|
16
|
+
function resolveClearableTable(table: ClearableTable): EntityTableMeta {
|
|
17
17
|
if (typeof table === "string") return tableFromName(table);
|
|
18
|
-
|
|
19
|
-
typeof table === "object" &&
|
|
20
|
-
table !== null &&
|
|
21
|
-
"tableName" in table &&
|
|
22
|
-
typeof table.tableName === "string"
|
|
23
|
-
) {
|
|
24
|
-
return table;
|
|
25
|
-
}
|
|
26
|
-
return table;
|
|
18
|
+
return requireEntityTableMeta(table, "clearTables");
|
|
27
19
|
}
|
|
28
20
|
|
|
29
21
|
/** Delete all rows from each table (order preserved — FK-sensitive callers order explicitly). */
|
|
30
22
|
export async function clearTables(db: AnyDb, tables: readonly ClearableTable[]): Promise<void> {
|
|
31
23
|
for (const table of tables) {
|
|
32
|
-
// Test
|
|
33
|
-
|
|
34
|
-
// clearing a managed projection between tests is not a production write.
|
|
35
|
-
await deleteMany(
|
|
36
|
-
withExecutorManagedWritesForTests(db),
|
|
37
|
-
resolveClearableTable(table) as EntityTableMeta,
|
|
38
|
-
{},
|
|
39
|
-
);
|
|
24
|
+
// Test teardown: clearing a managed projection between tests is not a production write.
|
|
25
|
+
await deleteMany(withExecutorManagedWritesForTests(db), resolveClearableTable(table), {});
|
|
40
26
|
}
|
|
41
27
|
}
|
|
42
28
|
|
package/src/helpers/index.ts
CHANGED
|
@@ -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,
|
package/src/helpers/seed.ts
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
// Test-only ES-bypass: seed / mutate a managed projection directly, skipping
|
|
2
|
-
// the event log. Production code
|
|
3
|
-
//
|
|
4
|
-
// arrange a scenario without driving the full event path;
|
|
5
|
-
// is not part of the test lifecycle, so eventless writes
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
// plain identifier rename.
|
|
2
|
+
// the event log. Production code cannot do this: the write helpers only accept
|
|
3
|
+
// a runner carrying the executor capability or a Writable table. Tests seed
|
|
4
|
+
// read-model state to arrange a scenario without driving the full event path;
|
|
5
|
+
// a projection rebuild is not part of the test lifecycle, so eventless writes
|
|
6
|
+
// are safe here. The signatures mirror the query helpers so a call-site
|
|
7
|
+
// migration is a plain identifier rename.
|
|
9
8
|
|
|
10
9
|
import type { EntityTable, EntityTableMeta } from "@cosmicdrift/kumiko-framework/db";
|
|
11
10
|
import {
|
|
@@ -25,7 +24,7 @@ export function seedRow<TRow = unknown>(
|
|
|
25
24
|
table: SeedTable,
|
|
26
25
|
values: Record<string, unknown>,
|
|
27
26
|
): Promise<TRow | undefined> {
|
|
28
|
-
return insertOne<TRow>(withExecutorManagedWritesForTests(db), table
|
|
27
|
+
return insertOne<TRow>(withExecutorManagedWritesForTests(db), table, values);
|
|
29
28
|
}
|
|
30
29
|
|
|
31
30
|
export function seedRows<TRow = unknown>(
|
|
@@ -33,7 +32,7 @@ export function seedRows<TRow = unknown>(
|
|
|
33
32
|
table: SeedTable,
|
|
34
33
|
rows: ReadonlyArray<Record<string, unknown>>,
|
|
35
34
|
): Promise<readonly TRow[]> {
|
|
36
|
-
return insertMany<TRow>(withExecutorManagedWritesForTests(db), table
|
|
35
|
+
return insertMany<TRow>(withExecutorManagedWritesForTests(db), table, rows);
|
|
37
36
|
}
|
|
38
37
|
|
|
39
38
|
export function updateRows<TRow = unknown>(
|
|
@@ -42,14 +41,9 @@ export function updateRows<TRow = unknown>(
|
|
|
42
41
|
set: Record<string, unknown>,
|
|
43
42
|
where: WhereObject,
|
|
44
43
|
): Promise<readonly TRow[]> {
|
|
45
|
-
return updateMany<TRow>(
|
|
46
|
-
withExecutorManagedWritesForTests(db),
|
|
47
|
-
table as EntityTableMeta,
|
|
48
|
-
set,
|
|
49
|
-
where,
|
|
50
|
-
);
|
|
44
|
+
return updateMany<TRow>(withExecutorManagedWritesForTests(db), table, set, where);
|
|
51
45
|
}
|
|
52
46
|
|
|
53
47
|
export function deleteRows(db: AnyDb, table: SeedTable, where: WhereObject): Promise<number> {
|
|
54
|
-
return deleteMany(withExecutorManagedWritesForTests(db), table
|
|
48
|
+
return deleteMany(withExecutorManagedWritesForTests(db), table, where);
|
|
55
49
|
}
|
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
|
|
63
|
-
|
|
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,
|
package/src/stack/index.ts
CHANGED
package/src/stack/test-stack.ts
CHANGED
|
@@ -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,
|