@cosmicdrift/kumiko-testing 0.367.0 → 0.368.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 +31 -6
- package/src/bunfig.ts +1 -1
- package/src/e2e/define-app-e2e-config.ts +2 -2
- package/src/e2e/index.ts +3 -3
- package/src/e2e/poll.ts +1 -1
- package/src/e2e/screenshots.ts +2 -2
- package/src/e2e/seed-route.ts +5 -4
- package/src/helpers/access-assertions.ts +15 -0
- package/src/helpers/assertions.ts +35 -0
- package/src/helpers/boot-validator-fixture.ts +127 -0
- package/src/helpers/closed-connection-error.ts +59 -0
- package/src/helpers/db-cleanup.ts +41 -0
- package/src/helpers/e2e-generator.ts +496 -0
- package/src/helpers/expect-error.ts +25 -0
- package/src/helpers/file-provider-contract.ts +123 -0
- package/src/helpers/http-cookies.ts +52 -0
- package/src/helpers/index.ts +67 -0
- package/src/helpers/late-bound.ts +41 -0
- package/src/helpers/manual-clock.ts +28 -0
- package/src/helpers/multipart-helper.ts +96 -0
- package/src/helpers/mutable-master-key-provider.ts +57 -0
- package/src/helpers/observability-recorder.ts +54 -0
- package/src/helpers/real-providers.ts +30 -0
- package/src/helpers/seed.ts +49 -0
- package/src/helpers/shared-entities.ts +65 -0
- package/src/helpers/utils.ts +1 -0
- package/src/helpers/wait-for.ts +50 -0
- package/src/helpers/without-ambient-temporal.ts +14 -0
- package/src/helpers/zip-fixture.ts +41 -0
- package/src/preload/real.ts +1 -1
- package/src/scaffold.ts +2 -2
- package/src/seed-tenant.ts +1 -1
- package/src/setup-app-test-stack.ts +1 -1
- package/src/stack/db.ts +94 -0
- package/src/stack/drain-event-consumers.ts +81 -0
- package/src/stack/drain-jobs.ts +188 -0
- package/src/stack/event-collector.ts +24 -0
- package/src/stack/index.ts +28 -0
- package/src/stack/push-entity-projection-tables.ts +102 -0
- package/src/stack/redis.ts +78 -0
- package/src/stack/request-helper.ts +298 -0
- package/src/stack/table-helpers.ts +47 -0
- package/src/stack/test-stack.ts +715 -0
- /package/src/{e2e/timeouts.ts → e2e-timeouts.ts} +0 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-testing",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.368.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>",
|
|
@@ -22,6 +22,22 @@
|
|
|
22
22
|
"types": "./src/index.ts",
|
|
23
23
|
"default": "./src/index.ts"
|
|
24
24
|
},
|
|
25
|
+
"./stack": {
|
|
26
|
+
"types": "./src/stack/index.ts",
|
|
27
|
+
"default": "./src/stack/index.ts"
|
|
28
|
+
},
|
|
29
|
+
"./helpers": {
|
|
30
|
+
"types": "./src/helpers/index.ts",
|
|
31
|
+
"default": "./src/helpers/index.ts"
|
|
32
|
+
},
|
|
33
|
+
"./helpers/real-providers": {
|
|
34
|
+
"types": "./src/helpers/real-providers.ts",
|
|
35
|
+
"default": "./src/helpers/real-providers.ts"
|
|
36
|
+
},
|
|
37
|
+
"./helpers/e2e-generator": {
|
|
38
|
+
"types": "./src/helpers/e2e-generator.ts",
|
|
39
|
+
"default": "./src/helpers/e2e-generator.ts"
|
|
40
|
+
},
|
|
25
41
|
"./bunfig": {
|
|
26
42
|
"types": "./src/bunfig.ts",
|
|
27
43
|
"default": "./src/bunfig.ts"
|
|
@@ -79,19 +95,25 @@
|
|
|
79
95
|
"kumiko-testing": "./bin/kumiko-testing.ts"
|
|
80
96
|
},
|
|
81
97
|
"dependencies": {
|
|
82
|
-
"@cosmicdrift/kumiko-
|
|
83
|
-
"@cosmicdrift/kumiko-
|
|
84
|
-
"@cosmicdrift/kumiko-framework": "0.367.0",
|
|
85
|
-
"@cosmicdrift/kumiko-types": "0.367.0",
|
|
98
|
+
"@cosmicdrift/kumiko-framework": "0.368.0",
|
|
99
|
+
"@cosmicdrift/kumiko-types": "0.368.0",
|
|
86
100
|
"zod": "^4.4.3"
|
|
87
101
|
},
|
|
88
102
|
"peerDependencies": {
|
|
103
|
+
"@cosmicdrift/kumiko-bundled-features": ">=0.366.0 <1.0.0",
|
|
104
|
+
"@cosmicdrift/kumiko-dev-server": ">=0.366.0 <1.0.0",
|
|
89
105
|
"@happy-dom/global-registrator": "^20.11.2",
|
|
90
106
|
"@playwright/test": "^1.62.1",
|
|
91
107
|
"@testing-library/dom": "^10.4.1",
|
|
92
108
|
"@testing-library/react": "^16.3.2"
|
|
93
109
|
},
|
|
94
110
|
"peerDependenciesMeta": {
|
|
111
|
+
"@cosmicdrift/kumiko-bundled-features": {
|
|
112
|
+
"optional": true
|
|
113
|
+
},
|
|
114
|
+
"@cosmicdrift/kumiko-dev-server": {
|
|
115
|
+
"optional": true
|
|
116
|
+
},
|
|
95
117
|
"@happy-dom/global-registrator": {
|
|
96
118
|
"optional": true
|
|
97
119
|
},
|
|
@@ -106,10 +128,13 @@
|
|
|
106
128
|
}
|
|
107
129
|
},
|
|
108
130
|
"devDependencies": {
|
|
131
|
+
"@cosmicdrift/kumiko-bundled-features": "0.368.0",
|
|
132
|
+
"@cosmicdrift/kumiko-dev-server": "0.368.0",
|
|
109
133
|
"@happy-dom/global-registrator": "^20.11.2",
|
|
110
134
|
"@playwright/test": "^1.62.1",
|
|
111
135
|
"@testing-library/dom": "^10.4.1",
|
|
112
|
-
"@testing-library/react": "^16.3.2"
|
|
136
|
+
"@testing-library/react": "^16.3.2",
|
|
137
|
+
"ts-morph": "^28.0.0"
|
|
113
138
|
},
|
|
114
139
|
"publishConfig": {
|
|
115
140
|
"registry": "https://registry.npmjs.org",
|
package/src/bunfig.ts
CHANGED
|
@@ -3,7 +3,6 @@ import { existsSync, readFileSync } from "node:fs";
|
|
|
3
3
|
import { availableParallelism } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { parseEnv } from "node:util";
|
|
6
|
-
import { isRealProviderRun } from "@cosmicdrift/kumiko-framework/testing/real-providers";
|
|
7
6
|
import {
|
|
8
7
|
defineConfig,
|
|
9
8
|
devices,
|
|
@@ -13,6 +12,8 @@ import {
|
|
|
13
12
|
type PlaywrightWorkerOptions,
|
|
14
13
|
type Project,
|
|
15
14
|
} from "@playwright/test";
|
|
15
|
+
import { E2E_TIMEOUT_MS } from "../e2e-timeouts";
|
|
16
|
+
import { isRealProviderRun } from "../helpers/real-providers.js";
|
|
16
17
|
import { SERVICE_ENV_DEFAULTS } from "../preload/service-env-defaults-values";
|
|
17
18
|
import {
|
|
18
19
|
DESKTOP_VIEWPORT,
|
|
@@ -26,7 +27,6 @@ import {
|
|
|
26
27
|
TRUSTED_PROXY_HOPS_ENV,
|
|
27
28
|
} from "./constants";
|
|
28
29
|
import { isScreenshotRun, requireScreenshotDir, screenshotSpecsIgnore } from "./screenshot-dir";
|
|
29
|
-
import { E2E_TIMEOUT_MS } from "./timeouts";
|
|
30
30
|
|
|
31
31
|
// Apps pick Meilisearch over their in-memory search adapter just because
|
|
32
32
|
// MEILI_URL is set, so a default here would point every consumer without a
|
package/src/e2e/index.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
+
export { expect } from "@playwright/test";
|
|
2
|
+
export { E2E_TIMEOUT_MS } from "../e2e-timeouts";
|
|
1
3
|
export {
|
|
2
4
|
isRealProviderRun,
|
|
3
5
|
REAL_PROVIDERS_ENV,
|
|
4
6
|
requireRealProviders,
|
|
5
|
-
} from "
|
|
6
|
-
export { expect } from "@playwright/test";
|
|
7
|
+
} from "../helpers/real-providers.js";
|
|
7
8
|
export {
|
|
8
9
|
apiCommand,
|
|
9
10
|
apiQuery,
|
|
@@ -77,4 +78,3 @@ export {
|
|
|
77
78
|
type SeedTenantFixture,
|
|
78
79
|
test,
|
|
79
80
|
} from "./seeded-tenant-fixture";
|
|
80
|
-
export { E2E_TIMEOUT_MS } from "./timeouts";
|
package/src/e2e/poll.ts
CHANGED
package/src/e2e/screenshots.ts
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { mkdirSync, statSync } from "node:fs";
|
|
3
3
|
import { dirname } from "node:path";
|
|
4
|
-
import { isRealProviderRun } from "@cosmicdrift/kumiko-framework/testing/real-providers";
|
|
5
4
|
import { expect, type Page, errors as playwrightErrors, type Request } from "@playwright/test";
|
|
5
|
+
import { E2E_TIMEOUT_MS } from "../e2e-timeouts";
|
|
6
|
+
import { isRealProviderRun } from "../helpers/real-providers.js";
|
|
6
7
|
import { DESKTOP_VIEWPORT } from "./constants";
|
|
7
8
|
import { pinEnglishLocale } from "./pin-english-locale";
|
|
8
9
|
import { requireScreenshotDir, SCREENSHOT_DIR_ENV } from "./screenshot-dir";
|
|
9
10
|
import { type SeedTenantFixture, test } from "./seeded-tenant-fixture";
|
|
10
|
-
import { E2E_TIMEOUT_MS } from "./timeouts";
|
|
11
11
|
|
|
12
12
|
export const DEFAULT_REDUCED_MOTION = "reduce" as const;
|
|
13
13
|
|
package/src/e2e/seed-route.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { createHash, timingSafeEqual } from "node:crypto";
|
|
2
2
|
import type { EmailMessage } from "@cosmicdrift/kumiko-bundled-features/channel-email";
|
|
3
3
|
import {
|
|
4
|
+
createMailTransportInMemoryFeature,
|
|
4
5
|
getInbox,
|
|
5
|
-
mailTransportInMemoryFeature,
|
|
6
6
|
} from "@cosmicdrift/kumiko-bundled-features/mail-transport-inmemory";
|
|
7
7
|
import { TenantQueries } from "@cosmicdrift/kumiko-bundled-features/tenant";
|
|
8
8
|
import {
|
|
@@ -137,7 +137,7 @@ export type E2eSeedRoutesOptions = {
|
|
|
137
137
|
// Apps that send tenantless mail (signup/forgot-password/magic-link) via
|
|
138
138
|
// their own raw createInMemoryTransport() pass its `sent` array here so
|
|
139
139
|
// the inbox route can read it without a tenantId. Independent of
|
|
140
|
-
//
|
|
140
|
+
// createMailTransportInMemoryFeature() — both sources may be present at once.
|
|
141
141
|
readonly mailOutbox?: { readonly sent: readonly EmailMessage[] };
|
|
142
142
|
};
|
|
143
143
|
|
|
@@ -252,12 +252,13 @@ export function createE2eSeedRoutes(
|
|
|
252
252
|
const tenantId = verified.tenantId;
|
|
253
253
|
const mailOutbox = options.mailOutbox;
|
|
254
254
|
const hasTenantSource =
|
|
255
|
-
tenantId !== undefined &&
|
|
255
|
+
tenantId !== undefined &&
|
|
256
|
+
deps.registry.features.has(createMailTransportInMemoryFeature().name);
|
|
256
257
|
if (!hasTenantSource && mailOutbox === undefined) {
|
|
257
258
|
return c.json(
|
|
258
259
|
{
|
|
259
260
|
error:
|
|
260
|
-
`no inbox to read: neither ${
|
|
261
|
+
`no inbox to read: neither ${createMailTransportInMemoryFeature().name} is mounted with a ` +
|
|
261
262
|
"tenantId nor was mailOutbox passed to createE2eSeedRoutes()",
|
|
262
263
|
},
|
|
263
264
|
501,
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { AccessRule } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
|
|
3
|
+
// Test-only helper: extracts the role list from a role-based AccessRule,
|
|
4
|
+
// narrowing the union safely. Throws when the rule is openToAll or missing —
|
|
5
|
+
// the tests that call this always expect roles, and a clear error beats a
|
|
6
|
+
// cryptic undefined assertion downstream.
|
|
7
|
+
export function rolesOf(access: AccessRule | undefined): readonly string[] {
|
|
8
|
+
if (!access) {
|
|
9
|
+
throw new Error("expected role-based access rule, got undefined");
|
|
10
|
+
}
|
|
11
|
+
if (!("roles" in access)) {
|
|
12
|
+
throw new Error("expected role-based access rule, got openToAll");
|
|
13
|
+
}
|
|
14
|
+
return access.roles;
|
|
15
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { WriteResult } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import type { WriteErrorInfo } from "@cosmicdrift/kumiko-framework/errors";
|
|
3
|
+
|
|
4
|
+
export function expectSuccess<T>(
|
|
5
|
+
result: WriteResult<T>,
|
|
6
|
+
): asserts result is { isSuccess: true; data: T } {
|
|
7
|
+
if (!result.isSuccess) {
|
|
8
|
+
throw new Error(
|
|
9
|
+
`Expected success but got error: ${result.error.code} (${result.error.message})`,
|
|
10
|
+
);
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// `matcher` checks either the error code (e.g. "not_found") or looks for a
|
|
15
|
+
// substring in the message. Both are useful: code matches are stable across
|
|
16
|
+
// i18n changes, substrings catch feature-specific detail text.
|
|
17
|
+
export function expectError(
|
|
18
|
+
result: WriteResult,
|
|
19
|
+
matcher?: string,
|
|
20
|
+
): asserts result is { isSuccess: false; error: WriteErrorInfo } {
|
|
21
|
+
if (result.isSuccess) {
|
|
22
|
+
throw new Error("Expected error but got success");
|
|
23
|
+
}
|
|
24
|
+
if (matcher === undefined) {
|
|
25
|
+
// skip: caller only asked for the type narrowing; nothing else to check.
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
const err = result.error;
|
|
29
|
+
const hit = err.code === matcher || err.message.includes(matcher);
|
|
30
|
+
if (!hit) {
|
|
31
|
+
throw new Error(
|
|
32
|
+
`Expected error code or message to match "${matcher}" but got code="${err.code}", message="${err.message}"`,
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
EntityDefinition,
|
|
3
|
+
EntityListScreenDefinition,
|
|
4
|
+
FeatureDefinition,
|
|
5
|
+
TranslationEntry,
|
|
6
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
7
|
+
import { normalizeListColumn } from "@cosmicdrift/kumiko-framework/engine";
|
|
8
|
+
import {
|
|
9
|
+
featureHasI18nSurface,
|
|
10
|
+
requiredKeysFromFeature,
|
|
11
|
+
SEARCHABLE_FALSE_WHITELIST,
|
|
12
|
+
} from "@cosmicdrift/kumiko-framework/internal/stack-support";
|
|
13
|
+
|
|
14
|
+
function ensureEntityListSortable(feature: FeatureDefinition): FeatureDefinition {
|
|
15
|
+
if (!feature.entities) return feature;
|
|
16
|
+
const entities: Record<string, EntityDefinition> = { ...feature.entities };
|
|
17
|
+
|
|
18
|
+
for (const screen of Object.values(feature.screens)) {
|
|
19
|
+
if (screen.type !== "entityList") continue;
|
|
20
|
+
const entity = entities[screen.entity];
|
|
21
|
+
if (!entity) continue;
|
|
22
|
+
|
|
23
|
+
const hasSortable = Object.values(entity.fields).some(
|
|
24
|
+
(field) => "sortable" in field && field.sortable === true,
|
|
25
|
+
);
|
|
26
|
+
if (hasSortable) continue;
|
|
27
|
+
|
|
28
|
+
const firstCol = screen.columns[0];
|
|
29
|
+
if (firstCol === undefined) continue;
|
|
30
|
+
const fieldName = normalizeListColumn(firstCol).field;
|
|
31
|
+
const fieldDef = entity.fields[fieldName];
|
|
32
|
+
if (fieldDef === undefined) continue;
|
|
33
|
+
|
|
34
|
+
if (fieldDef.type !== "text" && fieldDef.type !== "number") continue;
|
|
35
|
+
|
|
36
|
+
entities[screen.entity] = {
|
|
37
|
+
...entity,
|
|
38
|
+
fields: {
|
|
39
|
+
...entity.fields,
|
|
40
|
+
[fieldName]: { ...fieldDef, sortable: true },
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return { ...feature, entities };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function ensureEntityListRowNavigation(
|
|
49
|
+
feature: FeatureDefinition,
|
|
50
|
+
screen: EntityListScreenDefinition,
|
|
51
|
+
): EntityListScreenDefinition {
|
|
52
|
+
const hasEdit = Object.values(feature.screens).some(
|
|
53
|
+
(s) => s.type === "entityEdit" && s.entity === screen.entity,
|
|
54
|
+
);
|
|
55
|
+
if (!hasEdit) return screen;
|
|
56
|
+
const hasNavigate = (screen.rowActions ?? []).some(
|
|
57
|
+
(a) => a.kind === "navigate" && (a.id === "view" || a.id === "edit"),
|
|
58
|
+
);
|
|
59
|
+
if (hasNavigate) return screen;
|
|
60
|
+
const editScreen = Object.values(feature.screens).find(
|
|
61
|
+
(s) => s.type === "entityEdit" && s.entity === screen.entity,
|
|
62
|
+
);
|
|
63
|
+
if (editScreen === undefined) return screen;
|
|
64
|
+
return {
|
|
65
|
+
...screen,
|
|
66
|
+
rowActions: [
|
|
67
|
+
...(screen.rowActions ?? []),
|
|
68
|
+
// No `params` here: this same-entity target always resolves to
|
|
69
|
+
// UPDATE mode (the renderer auto-fills entityId from row["id"]),
|
|
70
|
+
// and entityEdit-update never reads search-params as initial
|
|
71
|
+
// values — the boot-validator now rejects that combination (#1680).
|
|
72
|
+
{
|
|
73
|
+
kind: "navigate",
|
|
74
|
+
id: "edit",
|
|
75
|
+
label: "stub:edit",
|
|
76
|
+
screen: editScreen.id,
|
|
77
|
+
},
|
|
78
|
+
],
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function defaultSortForEntityList(
|
|
83
|
+
feature: FeatureDefinition,
|
|
84
|
+
screen: EntityListScreenDefinition,
|
|
85
|
+
): EntityListScreenDefinition {
|
|
86
|
+
if (screen.defaultSort !== undefined || screen.searchable === false) return screen;
|
|
87
|
+
if (SEARCHABLE_FALSE_WHITELIST.has(screen.id)) return screen;
|
|
88
|
+
|
|
89
|
+
const entity = feature.entities?.[screen.entity];
|
|
90
|
+
const sortableColumn = screen.columns.map(normalizeListColumn).find((col) => {
|
|
91
|
+
const fieldDef = entity?.fields[col.field];
|
|
92
|
+
return fieldDef !== undefined && "sortable" in fieldDef && fieldDef.sortable === true;
|
|
93
|
+
});
|
|
94
|
+
if (sortableColumn === undefined) return screen;
|
|
95
|
+
|
|
96
|
+
return { ...screen, defaultSort: { field: sortableColumn.field, dir: "asc" as const } };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function stubTranslations(feature: FeatureDefinition): FeatureDefinition {
|
|
100
|
+
if (!featureHasI18nSurface(feature)) return feature;
|
|
101
|
+
const keys: Record<string, TranslationEntry> = {
|
|
102
|
+
...Object.fromEntries(
|
|
103
|
+
Object.entries(feature.translations ?? {}).map(([key, value]) => [key, { ...value }]),
|
|
104
|
+
),
|
|
105
|
+
};
|
|
106
|
+
for (const key of requiredKeysFromFeature(feature)) {
|
|
107
|
+
if (keys[key] === undefined) keys[key] = { de: "stub", en: "stub" };
|
|
108
|
+
}
|
|
109
|
+
return { ...feature, translations: keys };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Test-fixture helper: satisfy entityList + i18n boot rules without bloating every makeFeature. */
|
|
113
|
+
export function withBootValidatorFixture(
|
|
114
|
+
features: readonly FeatureDefinition[],
|
|
115
|
+
): FeatureDefinition[] {
|
|
116
|
+
return features.map((feature) => {
|
|
117
|
+
const sortable = ensureEntityListSortable(feature);
|
|
118
|
+
const screens = Object.fromEntries(
|
|
119
|
+
Object.entries(sortable.screens).map(([id, screen]) => {
|
|
120
|
+
if (screen.type !== "entityList") return [id, screen];
|
|
121
|
+
const withDefaults = defaultSortForEntityList(sortable, screen);
|
|
122
|
+
return [id, ensureEntityListRowNavigation(sortable, withDefaults)];
|
|
123
|
+
}),
|
|
124
|
+
);
|
|
125
|
+
return stubTranslations({ ...sortable, screens });
|
|
126
|
+
});
|
|
127
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Captures a real closed-connection error instead of a hand-built fake —
|
|
2
|
+
// the production matcher checks driver-specific codes a fake can't reproduce.
|
|
3
|
+
|
|
4
|
+
import { isClosedConnectionError } from "@cosmicdrift/kumiko-framework/internal/stack-support";
|
|
5
|
+
import postgres from "postgres";
|
|
6
|
+
import { requireEnv } from "../stack/db.js";
|
|
7
|
+
|
|
8
|
+
export function testDatabaseUrl(): string {
|
|
9
|
+
return requireEnv("TEST_DATABASE_URL");
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const MAX_WARMUP_ROUNDS = 10;
|
|
13
|
+
const READS_PER_ROUND = 5;
|
|
14
|
+
|
|
15
|
+
type AdminClient = { unsafe(sql: string, params?: readonly unknown[]): Promise<unknown> };
|
|
16
|
+
|
|
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
|
+
export async function terminateBackendsByApplicationName(
|
|
20
|
+
admin: AdminClient,
|
|
21
|
+
applicationName: string,
|
|
22
|
+
): Promise<void> {
|
|
23
|
+
await admin.unsafe(
|
|
24
|
+
"select pg_terminate_backend(pid) from pg_stat_activity where application_name = $1",
|
|
25
|
+
[applicationName],
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Terminates a throwaway pool's backends and races reads right after — the
|
|
30
|
+
// dead-connection window is only a few ms wide, so this retries rounds until one lands.
|
|
31
|
+
export async function captureClosedConnectionError(
|
|
32
|
+
url: string = testDatabaseUrl(),
|
|
33
|
+
): Promise<unknown> {
|
|
34
|
+
const admin = postgres(url, { max: 1 });
|
|
35
|
+
try {
|
|
36
|
+
for (let round = 0; round < MAX_WARMUP_ROUNDS; round++) {
|
|
37
|
+
const applicationName = `kumiko-closed-conn-test-${crypto.randomUUID()}`;
|
|
38
|
+
const pool = postgres(url, { max: 1, connection: { application_name: applicationName } });
|
|
39
|
+
try {
|
|
40
|
+
await pool.unsafe("select 1");
|
|
41
|
+
await terminateBackendsByApplicationName(admin, applicationName);
|
|
42
|
+
for (let read = 0; read < READS_PER_ROUND; read++) {
|
|
43
|
+
try {
|
|
44
|
+
await pool.unsafe("select 1");
|
|
45
|
+
} catch (err) {
|
|
46
|
+
if (isClosedConnectionError(err)) return err;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
} finally {
|
|
50
|
+
await pool.end({ timeout: 0 });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
throw new Error(
|
|
54
|
+
`captureClosedConnectionError: no closed-connection error observed after ${MAX_WARMUP_ROUNDS} rounds.`,
|
|
55
|
+
);
|
|
56
|
+
} finally {
|
|
57
|
+
await admin.end({ timeout: 0 });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { EntityTableMeta } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
+
import { deleteMany } from "@cosmicdrift/kumiko-framework/db";
|
|
3
|
+
import type { AnyDb } from "@cosmicdrift/kumiko-framework/internal/stack-support";
|
|
4
|
+
|
|
5
|
+
/** EntityTableMeta, a built table, or a plain table name string. */
|
|
6
|
+
export type ClearableTable = string | { readonly tableName?: string } | unknown;
|
|
7
|
+
|
|
8
|
+
// A full-table wipe (empty where) only needs the table name — give deleteMany
|
|
9
|
+
// a minimal-but-canonical EntityTableMeta so extractTableInfo accepts it
|
|
10
|
+
// without inferring columns.
|
|
11
|
+
function tableFromName(name: string): EntityTableMeta {
|
|
12
|
+
return { tableName: name, columns: [], indexes: [], source: "unmanaged" };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function resolveClearableTable(table: ClearableTable): unknown {
|
|
16
|
+
if (typeof table === "string") return tableFromName(table);
|
|
17
|
+
if (
|
|
18
|
+
typeof table === "object" &&
|
|
19
|
+
table !== null &&
|
|
20
|
+
"tableName" in table &&
|
|
21
|
+
typeof table.tableName === "string"
|
|
22
|
+
) {
|
|
23
|
+
return table;
|
|
24
|
+
}
|
|
25
|
+
return table;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Delete all rows from each table (order preserved — FK-sensitive callers order explicitly). */
|
|
29
|
+
export async function clearTables(db: AnyDb, tables: readonly ClearableTable[]): Promise<void> {
|
|
30
|
+
for (const table of tables) {
|
|
31
|
+
// Test-teardown truncate: resolveClearableTable returns `unknown` (string
|
|
32
|
+
// name or table object). The brand-strip cast is the sanctioned bypass —
|
|
33
|
+
// clearing a managed projection between tests is not a production write.
|
|
34
|
+
await deleteMany(db, resolveClearableTable(table) as EntityTableMeta, {});
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Alias — same as clearTables, reads better in beforeEach. */
|
|
39
|
+
export async function resetTestTables(db: AnyDb, tables: readonly ClearableTable[]): Promise<void> {
|
|
40
|
+
await clearTables(db, tables);
|
|
41
|
+
}
|