@cosmicdrift/kumiko-framework 0.309.0 → 0.311.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 +4 -4
- package/src/changes.json +6 -0
- package/src/engine/__tests__/row-action-navigate-tab.test.ts +107 -0
- package/src/engine/boot-validator/screens.ts +84 -2
- package/src/entrypoint/__tests__/split-deploy.integration.test.ts +4 -11
- package/src/files/__tests__/write-stream.test.ts +10 -6
- package/src/jobs/__tests__/jobs.integration.test.ts +2 -0
- package/src/lifecycle/__tests__/signal-handlers.test.ts +1 -9
- package/src/pipeline/__tests__/event-dispatcher-db-outage.integration.test.ts +6 -1
- package/src/pipeline/__tests__/event-dispatcher-lifecycle.integration.test.ts +3 -14
- package/src/pipeline/__tests__/event-dispatcher-second-audit.integration.test.ts +5 -14
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.311.0",
|
|
4
4
|
"description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -198,8 +198,8 @@
|
|
|
198
198
|
"./package.json": "./package.json"
|
|
199
199
|
},
|
|
200
200
|
"dependencies": {
|
|
201
|
-
"@cosmicdrift/kumiko-http": "0.
|
|
202
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
201
|
+
"@cosmicdrift/kumiko-http": "0.311.0",
|
|
202
|
+
"@cosmicdrift/kumiko-types": "0.311.0",
|
|
203
203
|
"bullmq": "^5.76.7",
|
|
204
204
|
"bun-types": "^1.3.13",
|
|
205
205
|
"hono": "^4.13.1",
|
|
@@ -215,7 +215,7 @@
|
|
|
215
215
|
"zod": "^4.4.3"
|
|
216
216
|
},
|
|
217
217
|
"devDependencies": {
|
|
218
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
218
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.311.0",
|
|
219
219
|
"bun-types": "^1.3.13",
|
|
220
220
|
"pino-pretty": "^13.1.3"
|
|
221
221
|
},
|
package/src/changes.json
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.310.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "Boot validator checks the target tab of navigate actions",
|
|
6
|
+
"detail": "A navigate action with `tab` fails boot when its target is not a\nprojectionDetail with layout.mode \"tabs\" or has no section with that id.\nThe check covers list rowActions, relatedList rowActions, screen actions,\nsection actions and relatedList emptyState actions."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.309.0",
|
|
4
10
|
"type": "fix",
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import * as z from "zod";
|
|
3
|
+
import { withBootValidatorFixture } from "../../testing/boot-validator-fixture";
|
|
4
|
+
import { validateBoot as validateBootRaw } from "../boot-validator";
|
|
5
|
+
import { defineFeature } from "../define-feature";
|
|
6
|
+
import { createEntity, createTextField } from "../factories";
|
|
7
|
+
|
|
8
|
+
function validateBoot(features: Parameters<typeof validateBootRaw>[0]): void {
|
|
9
|
+
validateBootRaw(withBootValidatorFixture(features));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function buildFeature(options: {
|
|
13
|
+
readonly detailTabs: boolean;
|
|
14
|
+
readonly tab: string;
|
|
15
|
+
readonly via: "entityEditSection" | "entityListRowAction";
|
|
16
|
+
}) {
|
|
17
|
+
return defineFeature("app", (r) => {
|
|
18
|
+
r.entity(
|
|
19
|
+
"vehicle",
|
|
20
|
+
createEntity({
|
|
21
|
+
fields: { name: createTextField({ personal: false, reason: "test_fixture" }) },
|
|
22
|
+
}),
|
|
23
|
+
);
|
|
24
|
+
r.queryHandler("vehicle:detail", z.object({}), async () => ({ name: "x" }), {
|
|
25
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
26
|
+
});
|
|
27
|
+
r.screen({
|
|
28
|
+
id: "vehicle-detail",
|
|
29
|
+
type: "projectionDetail",
|
|
30
|
+
query: "app:query:vehicle:detail",
|
|
31
|
+
layout: options.detailTabs
|
|
32
|
+
? {
|
|
33
|
+
mode: "tabs",
|
|
34
|
+
sections: [
|
|
35
|
+
{ id: "overview", title: "Overview", fields: ["name"] },
|
|
36
|
+
{ id: "channels", title: "Channels", fields: ["name"] },
|
|
37
|
+
],
|
|
38
|
+
}
|
|
39
|
+
: { sections: [{ title: "Overview", fields: ["name"] }] },
|
|
40
|
+
});
|
|
41
|
+
const navigateAction = {
|
|
42
|
+
kind: "navigate" as const,
|
|
43
|
+
id: "view-channels",
|
|
44
|
+
label: "app.actions.viewChannels",
|
|
45
|
+
screen: "vehicle-detail",
|
|
46
|
+
tab: options.tab,
|
|
47
|
+
};
|
|
48
|
+
if (options.via === "entityEditSection") {
|
|
49
|
+
r.screen({
|
|
50
|
+
id: "vehicle-edit",
|
|
51
|
+
type: "entityEdit",
|
|
52
|
+
entity: "vehicle",
|
|
53
|
+
layout: {
|
|
54
|
+
sections: [
|
|
55
|
+
{
|
|
56
|
+
id: "main",
|
|
57
|
+
title: "Main",
|
|
58
|
+
columns: 1,
|
|
59
|
+
fields: ["name"],
|
|
60
|
+
actions: [{ ...navigateAction, entityId: "vehicleId" }],
|
|
61
|
+
},
|
|
62
|
+
],
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
} else {
|
|
66
|
+
r.screen({
|
|
67
|
+
id: "vehicle-list",
|
|
68
|
+
type: "entityList",
|
|
69
|
+
entity: "vehicle",
|
|
70
|
+
columns: ["name"],
|
|
71
|
+
rowActions: [navigateAction],
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
describe("validateBoot — RowActionNavigate.tab", () => {
|
|
78
|
+
test("entityEdit section action navigates to a known tab boots cleanly", () => {
|
|
79
|
+
const feature = buildFeature({ detailTabs: true, tab: "channels", via: "entityEditSection" });
|
|
80
|
+
expect(() => validateBoot([feature])).not.toThrow();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("entityEdit section action navigates to an unknown tab throws", () => {
|
|
84
|
+
const feature = buildFeature({ detailTabs: true, tab: "unknown", via: "entityEditSection" });
|
|
85
|
+
expect(() => validateBoot([feature])).toThrow(
|
|
86
|
+
/navigates to tab "unknown", which is not a section id on target screen/,
|
|
87
|
+
);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("entityEdit section action navigating to a non-tabs projectionDetail throws", () => {
|
|
91
|
+
const feature = buildFeature({ detailTabs: false, tab: "overview", via: "entityEditSection" });
|
|
92
|
+
expect(() => validateBoot([feature])).toThrow(
|
|
93
|
+
/is not a projectionDetail with layout.mode "tabs"/,
|
|
94
|
+
);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("entityList rowAction navigating to an unknown tab throws (central resolution path)", () => {
|
|
98
|
+
const feature = buildFeature({
|
|
99
|
+
detailTabs: true,
|
|
100
|
+
tab: "unknown",
|
|
101
|
+
via: "entityListRowAction",
|
|
102
|
+
});
|
|
103
|
+
expect(() => validateBoot([feature])).toThrow(
|
|
104
|
+
/navigates to tab "unknown", which is not a section id on target screen/,
|
|
105
|
+
);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
@@ -542,11 +542,48 @@ type RowActionNavigateRuntime = RowActionNavigateBase & {
|
|
|
542
542
|
readonly entity?: string;
|
|
543
543
|
};
|
|
544
544
|
|
|
545
|
+
// The target can be any screen, so this checks the resolved target's
|
|
546
|
+
// sections, not the host's.
|
|
547
|
+
function validateRowActionNavigateTab(
|
|
548
|
+
featureName: string,
|
|
549
|
+
screenId: string,
|
|
550
|
+
screenType: "entityList" | "projectionList" | "projectionDetail" | "entityEdit",
|
|
551
|
+
actionLabel: string,
|
|
552
|
+
action: RowActionNavigateRuntime,
|
|
553
|
+
target: { readonly featureName: string; readonly screen: ScreenDefinition } | undefined,
|
|
554
|
+
): void {
|
|
555
|
+
// skip: no tab declared — nothing to check against the target's sections
|
|
556
|
+
if (action.tab === undefined) return;
|
|
557
|
+
if (target === undefined) {
|
|
558
|
+
throw new Error(
|
|
559
|
+
`[Feature ${featureName}] Screen "${screenId}" (${screenType}) ${actionLabel} "${action.id}" ` +
|
|
560
|
+
`sets tab "${action.tab}", but its navigate-target could not be resolved to a screen.`,
|
|
561
|
+
);
|
|
562
|
+
}
|
|
563
|
+
if (target.screen.type !== "projectionDetail" || target.screen.layout.mode !== "tabs") {
|
|
564
|
+
throw new Error(
|
|
565
|
+
`[Feature ${featureName}] Screen "${screenId}" (${screenType}) ${actionLabel} "${action.id}" ` +
|
|
566
|
+
`sets tab "${action.tab}", but navigate-target screen "${target.screen.id}" is not a ` +
|
|
567
|
+
`projectionDetail with layout.mode "tabs" — only those read the tab param.`,
|
|
568
|
+
);
|
|
569
|
+
}
|
|
570
|
+
const sectionIds = target.screen.layout.sections
|
|
571
|
+
.map((section) => section.id)
|
|
572
|
+
.filter((id): id is string => id !== undefined);
|
|
573
|
+
if (!sectionIds.includes(action.tab)) {
|
|
574
|
+
throw new Error(
|
|
575
|
+
`[Feature ${featureName}] Screen "${screenId}" (${screenType}) ${actionLabel} "${action.id}" ` +
|
|
576
|
+
`navigates to tab "${action.tab}", which is not a section id on target screen ` +
|
|
577
|
+
`"${target.screen.id}". Available: ${sectionIds.join(", ") || "(none)"}.`,
|
|
578
|
+
);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
545
582
|
function resolveRowActionNavigateTarget(
|
|
546
583
|
featureName: string,
|
|
547
584
|
screenId: string,
|
|
548
585
|
screenType: "entityList" | "projectionList" | "projectionDetail" | "entityEdit",
|
|
549
|
-
actionLabel:
|
|
586
|
+
actionLabel: string,
|
|
550
587
|
action: RowActionNavigateRuntime,
|
|
551
588
|
allScreenQns: ReadonlySet<string>,
|
|
552
589
|
navTargetShortIds: ReadonlySet<string>,
|
|
@@ -586,6 +623,7 @@ function resolveRowActionNavigateTarget(
|
|
|
586
623
|
`detailFor: "${action.entity}".`,
|
|
587
624
|
);
|
|
588
625
|
}
|
|
626
|
+
validateRowActionNavigateTab(featureName, screenId, screenType, actionLabel, action, detail);
|
|
589
627
|
return detail;
|
|
590
628
|
}
|
|
591
629
|
if (action.screen === undefined) {
|
|
@@ -601,7 +639,9 @@ function resolveRowActionNavigateTarget(
|
|
|
601
639
|
`navigate-target "${action.screen}" does not resolve to a registered screen in any feature.`,
|
|
602
640
|
);
|
|
603
641
|
}
|
|
604
|
-
|
|
642
|
+
const target = screensByShortId.get(action.screen)?.[0];
|
|
643
|
+
validateRowActionNavigateTab(featureName, screenId, screenType, actionLabel, action, target);
|
|
644
|
+
return target;
|
|
605
645
|
}
|
|
606
646
|
|
|
607
647
|
// Shared by the mint step's own handler (screen.handler) and its optional
|
|
@@ -1225,6 +1265,19 @@ export function validateScreens(
|
|
|
1225
1265
|
`section "${sectionLabel}" action`,
|
|
1226
1266
|
action,
|
|
1227
1267
|
);
|
|
1268
|
+
if (action.kind === "navigate" && action.tab !== undefined) {
|
|
1269
|
+
resolveRowActionNavigateTarget(
|
|
1270
|
+
feature.name,
|
|
1271
|
+
screenId,
|
|
1272
|
+
"projectionDetail",
|
|
1273
|
+
`section "${sectionLabel}" action`,
|
|
1274
|
+
action,
|
|
1275
|
+
allScreenQns,
|
|
1276
|
+
navTargetShortIds,
|
|
1277
|
+
screensByShortId,
|
|
1278
|
+
detailForScreens,
|
|
1279
|
+
);
|
|
1280
|
+
}
|
|
1228
1281
|
}
|
|
1229
1282
|
}
|
|
1230
1283
|
if (section.kind === "relatedList" && section.emptyState?.action !== undefined) {
|
|
@@ -1235,6 +1288,22 @@ export function validateScreens(
|
|
|
1235
1288
|
`section "${sectionLabel}" emptyState action`,
|
|
1236
1289
|
section.emptyState.action,
|
|
1237
1290
|
);
|
|
1291
|
+
if (
|
|
1292
|
+
section.emptyState.action.kind === "navigate" &&
|
|
1293
|
+
section.emptyState.action.tab !== undefined
|
|
1294
|
+
) {
|
|
1295
|
+
resolveRowActionNavigateTarget(
|
|
1296
|
+
feature.name,
|
|
1297
|
+
screenId,
|
|
1298
|
+
"projectionDetail",
|
|
1299
|
+
`section "${sectionLabel}" emptyState action`,
|
|
1300
|
+
section.emptyState.action,
|
|
1301
|
+
allScreenQns,
|
|
1302
|
+
navTargetShortIds,
|
|
1303
|
+
screensByShortId,
|
|
1304
|
+
detailForScreens,
|
|
1305
|
+
);
|
|
1306
|
+
}
|
|
1238
1307
|
}
|
|
1239
1308
|
if (isExtensionEditSection(section)) {
|
|
1240
1309
|
// projectionDetail is read-only (no composed form submit) — an
|
|
@@ -1983,6 +2052,19 @@ export function validateScreens(
|
|
|
1983
2052
|
`section "${sectionLabel}" action`,
|
|
1984
2053
|
action,
|
|
1985
2054
|
);
|
|
2055
|
+
if (action.kind === "navigate" && action.tab !== undefined) {
|
|
2056
|
+
resolveRowActionNavigateTarget(
|
|
2057
|
+
feature.name,
|
|
2058
|
+
screenId,
|
|
2059
|
+
"entityEdit",
|
|
2060
|
+
`section "${sectionLabel}" action`,
|
|
2061
|
+
action,
|
|
2062
|
+
allScreenQns,
|
|
2063
|
+
navTargetShortIds,
|
|
2064
|
+
screensByShortId,
|
|
2065
|
+
detailForScreens,
|
|
2066
|
+
);
|
|
2067
|
+
}
|
|
1986
2068
|
}
|
|
1987
2069
|
}
|
|
1988
2070
|
if (isExtensionEditSection(section)) {
|
|
@@ -20,6 +20,7 @@ import { createRegistry, defineFeature } from "../../engine";
|
|
|
20
20
|
import { createArchivedStreamsTable } from "../../event-store";
|
|
21
21
|
import { createEventConsumerStateTable } from "../../pipeline";
|
|
22
22
|
import { createTestRedis, type TestRedis, TestUsers } from "../../stack";
|
|
23
|
+
import { waitFor } from "../../testing";
|
|
23
24
|
import { createAllInOneEntrypoint, createApiEntrypoint, createWorkerEntrypoint } from "../index";
|
|
24
25
|
|
|
25
26
|
const splitFeature = defineFeature("split", (r) => {
|
|
@@ -103,14 +104,6 @@ const fileJobFeature = defineFeature("fileJob", (r) => {
|
|
|
103
104
|
r.multiStreamProjection({ name: "noop", apply: { [requested.name]: async () => {} } });
|
|
104
105
|
});
|
|
105
106
|
|
|
106
|
-
async function waitForCondition(check: () => boolean, timeoutMs = 5000): Promise<void> {
|
|
107
|
-
const deadline = Date.now() + timeoutMs;
|
|
108
|
-
while (!check()) {
|
|
109
|
-
if (Date.now() > deadline) throw new Error("waitForCondition: timed out");
|
|
110
|
-
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
|
|
114
107
|
const JWT = "split-deploy-test-secret-must-be-32-chars!!";
|
|
115
108
|
|
|
116
109
|
// Per-test queue-name with a random suffix. Date.now() alone collided
|
|
@@ -195,7 +188,7 @@ describe("entrypoint factories", () => {
|
|
|
195
188
|
{ storageKey: "some/key.pdf" },
|
|
196
189
|
TestUsers.admin,
|
|
197
190
|
);
|
|
198
|
-
await
|
|
191
|
+
await waitFor(() => jobSawFilesRef.length > 0);
|
|
199
192
|
expect(jobSawFilesRef[0]).toBe("function");
|
|
200
193
|
} finally {
|
|
201
194
|
await worker.stop();
|
|
@@ -239,7 +232,7 @@ describe("entrypoint factories", () => {
|
|
|
239
232
|
// The worker's own eventDispatcher (started above) picks the event
|
|
240
233
|
// back up and runs the MSP — proves the afterCommit/MSP chain works
|
|
241
234
|
// inside the worker lane, not just that the write itself succeeded.
|
|
242
|
-
await
|
|
235
|
+
await waitFor(() => consumedNotes.includes("written from the worker"));
|
|
243
236
|
} finally {
|
|
244
237
|
await worker.stop();
|
|
245
238
|
}
|
|
@@ -264,7 +257,7 @@ describe("entrypoint factories", () => {
|
|
|
264
257
|
{ storageKey: "some/key.pdf" },
|
|
265
258
|
TestUsers.admin,
|
|
266
259
|
);
|
|
267
|
-
await
|
|
260
|
+
await waitFor(() => jobSawFilesRef.length > 0);
|
|
268
261
|
expect(jobSawFilesRef[0]).toBe("function");
|
|
269
262
|
} finally {
|
|
270
263
|
await entry.stop();
|
|
@@ -16,6 +16,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
|
16
16
|
import { mkdtemp, readdir, rm, stat } from "node:fs/promises";
|
|
17
17
|
import { tmpdir } from "node:os";
|
|
18
18
|
import { join } from "node:path";
|
|
19
|
+
import { waitFor } from "../../testing";
|
|
19
20
|
import { createInMemoryFileProvider } from "../in-memory-provider";
|
|
20
21
|
import { createLocalProvider } from "../local-provider";
|
|
21
22
|
|
|
@@ -215,12 +216,15 @@ describe("FileStorageProvider.writeStream — Streaming-Property", () => {
|
|
|
215
216
|
// Poll bis tmp existiert ODER final schon fertig (zu schneller CI).
|
|
216
217
|
let hasTmp = false;
|
|
217
218
|
let alreadyDone = false;
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
219
|
+
await waitFor(
|
|
220
|
+
async () => {
|
|
221
|
+
const dirContents = await readdir(basePath);
|
|
222
|
+
hasTmp = dirContents.some((f) => f.endsWith(".tmp"));
|
|
223
|
+
alreadyDone = dirContents.includes("streamed.bin");
|
|
224
|
+
return hasTmp || alreadyDone;
|
|
225
|
+
},
|
|
226
|
+
{ delays: Array(20).fill(10) },
|
|
227
|
+
);
|
|
224
228
|
if (alreadyDone && !hasTmp) {
|
|
225
229
|
throw new Error(
|
|
226
230
|
"slowChunks-delay zu kurz fuer CI: writeStream war fertig bevor poll start. " +
|
|
@@ -439,6 +439,7 @@ describe("concurrency: skip", () => {
|
|
|
439
439
|
|
|
440
440
|
// Try dispatching multiple times while first is running
|
|
441
441
|
let skippedCount = 0;
|
|
442
|
+
// @timeout-exception: #3118 paces repeated dispatch attempts, nothing to poll for
|
|
442
443
|
for (let i = 0; i < 5; i++) {
|
|
443
444
|
await sleep(50);
|
|
444
445
|
const id = await runner.dispatch("test:job:skip-job", { n: i + 2 });
|
|
@@ -1212,6 +1213,7 @@ describe("handleEvent maxPerTenant", () => {
|
|
|
1212
1213
|
// racing against the handler's own 500ms sleep, which could land
|
|
1213
1214
|
// outside the window on a starved runner and silently pass.
|
|
1214
1215
|
await runner.handleEvent("test:write:capped-event", { n: 3 }, user);
|
|
1216
|
+
// @timeout-exception: #3118 asserts a negative (stays capped) at each checkpoint
|
|
1215
1217
|
for (const delayMs of [50, 100, 200, 400]) {
|
|
1216
1218
|
await sleep(delayMs);
|
|
1217
1219
|
const startedNow = jobLog.filter((e) => e.name === "test:job:per-tenant-limited-event");
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { describe, expect, mock, test } from "bun:test";
|
|
2
|
+
import { waitFor } from "../../testing";
|
|
2
3
|
import { createLifecycle } from "../lifecycle";
|
|
3
4
|
import { attachSignalHandlers } from "../signal-handlers";
|
|
4
5
|
import { createTestLifecycle } from "./create-test-lifecycle";
|
|
@@ -95,12 +96,3 @@ describe("attachSignalHandlers", () => {
|
|
|
95
96
|
expect(process.listenerCount("SIGTERM")).toBe(before);
|
|
96
97
|
});
|
|
97
98
|
});
|
|
98
|
-
|
|
99
|
-
async function waitFor(predicate: () => boolean, timeoutMs = 500): Promise<void> {
|
|
100
|
-
const deadline = Date.now() + timeoutMs;
|
|
101
|
-
while (Date.now() < deadline) {
|
|
102
|
-
if (predicate()) return;
|
|
103
|
-
await new Promise((r) => setTimeout(r, 5));
|
|
104
|
-
}
|
|
105
|
-
throw new Error("waitFor timed out");
|
|
106
|
-
}
|
|
@@ -186,7 +186,12 @@ describe("E: dispatcher survives a DB outage and logs the recovery", () => {
|
|
|
186
186
|
} finally {
|
|
187
187
|
await dispatcher.stop();
|
|
188
188
|
await listenClient.end({ timeout: 1 });
|
|
189
|
-
|
|
189
|
+
// Destroy instead of a graceful end(): when proxy2.stop() kills a socket
|
|
190
|
+
// mid-query, postgres.js keeps that query referenced on the dead
|
|
191
|
+
// connection, and a plain end() then waits for a resolver only
|
|
192
|
+
// terminate() fires, so it never returns. The pool is test-owned and
|
|
193
|
+
// every assertion has already run, so dropping in-flight work is safe.
|
|
194
|
+
await proxiedDb.client.end({ timeout: 0 });
|
|
190
195
|
}
|
|
191
196
|
});
|
|
192
197
|
});
|
|
@@ -31,7 +31,7 @@ import {
|
|
|
31
31
|
TestUsers,
|
|
32
32
|
unsafeCreateEntityTable,
|
|
33
33
|
} from "../../stack";
|
|
34
|
-
import { sharedWidgetEntity, sharedWidgetTable } from "../../testing";
|
|
34
|
+
import { sharedWidgetEntity, sharedWidgetTable, waitFor } from "../../testing";
|
|
35
35
|
|
|
36
36
|
// --- Test fixtures ---
|
|
37
37
|
|
|
@@ -118,7 +118,7 @@ describe("E.1 — .start() lifecycle + slow handler", () => {
|
|
|
118
118
|
|
|
119
119
|
// pollIntervalMs in the test-stack is 50ms. Give the timer a few
|
|
120
120
|
// ticks to observe the event.
|
|
121
|
-
await waitFor(() => observations.length >= 1,
|
|
121
|
+
await waitFor(() => observations.length >= 1, { delays: Array(40).fill(50) });
|
|
122
122
|
expect(observations).toHaveLength(1);
|
|
123
123
|
expect(observations[0]?.event.payload["name"]).toBe("started-delivery");
|
|
124
124
|
} finally {
|
|
@@ -140,7 +140,7 @@ describe("E.1 — .start() lifecycle + slow handler", () => {
|
|
|
140
140
|
await appendWidget("slow-3");
|
|
141
141
|
|
|
142
142
|
// Wait until all 3 slow-observer invocations have completed.
|
|
143
|
-
await waitFor(() => slowHandlerInvocations.length >= 3,
|
|
143
|
+
await waitFor(() => slowHandlerInvocations.length >= 3, { delays: Array(50).fill(100) });
|
|
144
144
|
|
|
145
145
|
// Check: no invocation overlapped with the next — every pass
|
|
146
146
|
// finished before the following one started. The guard does
|
|
@@ -207,14 +207,3 @@ describe("E.1 — consumer-lag metric", () => {
|
|
|
207
207
|
});
|
|
208
208
|
|
|
209
209
|
// --- Helpers ---
|
|
210
|
-
|
|
211
|
-
async function waitFor(predicate: () => boolean, timeoutMs: number): Promise<void> {
|
|
212
|
-
const start = Date.now();
|
|
213
|
-
while (Date.now() - start < timeoutMs) {
|
|
214
|
-
if (predicate()) return;
|
|
215
|
-
await new Promise((r) => setTimeout(r, 20));
|
|
216
|
-
}
|
|
217
|
-
if (!predicate()) {
|
|
218
|
-
throw new Error(`waitFor: predicate never became true within ${timeoutMs}ms`);
|
|
219
|
-
}
|
|
220
|
-
}
|
|
@@ -33,7 +33,7 @@ import {
|
|
|
33
33
|
TestUsers,
|
|
34
34
|
unsafeCreateEntityTable,
|
|
35
35
|
} from "../../stack";
|
|
36
|
-
import { sharedWidgetEntity } from "../../testing";
|
|
36
|
+
import { sharedWidgetEntity, waitFor } from "../../testing";
|
|
37
37
|
import { generateId } from "../../utils";
|
|
38
38
|
|
|
39
39
|
// --- Fixture ---
|
|
@@ -247,7 +247,7 @@ describe("Second audit — LISTEN gauge", () => {
|
|
|
247
247
|
).length;
|
|
248
248
|
|
|
249
249
|
// Wait for the initial onlisten (gauge.set 1) to land.
|
|
250
|
-
await waitFor(() => connectsWithValue1() >= 1,
|
|
250
|
+
await waitFor(() => connectsWithValue1() >= 1, { delays: Array(40).fill(50) });
|
|
251
251
|
const initialConnects = connectsWithValue1();
|
|
252
252
|
expect(initialConnects).toBeGreaterThanOrEqual(1);
|
|
253
253
|
|
|
@@ -266,7 +266,9 @@ describe("Second audit — LISTEN gauge", () => {
|
|
|
266
266
|
|
|
267
267
|
// Wait for the SECOND gauge.set(1) — the reconnect. Generous timeout
|
|
268
268
|
// because postgres.js's reconnect loop includes backoff.
|
|
269
|
-
await waitFor(() => connectsWithValue1() > initialConnects,
|
|
269
|
+
await waitFor(() => connectsWithValue1() > initialConnects, {
|
|
270
|
+
delays: Array(50).fill(200),
|
|
271
|
+
});
|
|
270
272
|
expect(connectsWithValue1()).toBeGreaterThan(initialConnects);
|
|
271
273
|
} finally {
|
|
272
274
|
await recStack.eventDispatcher?.stop();
|
|
@@ -278,14 +280,3 @@ describe("Second audit — LISTEN gauge", () => {
|
|
|
278
280
|
});
|
|
279
281
|
|
|
280
282
|
// --- Helpers ---
|
|
281
|
-
|
|
282
|
-
async function waitFor(predicate: () => boolean, timeoutMs: number): Promise<void> {
|
|
283
|
-
const start = Date.now();
|
|
284
|
-
while (Date.now() - start < timeoutMs) {
|
|
285
|
-
if (predicate()) return;
|
|
286
|
-
await new Promise((r) => setTimeout(r, 25));
|
|
287
|
-
}
|
|
288
|
-
if (!predicate()) {
|
|
289
|
-
throw new Error(`waitFor: predicate never became true within ${timeoutMs}ms`);
|
|
290
|
-
}
|
|
291
|
-
}
|