@cosmicdrift/kumiko-testing 0.367.0 → 0.369.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
|
@@ -0,0 +1,715 @@
|
|
|
1
|
+
// @runtime dev
|
|
2
|
+
import type { AuthRoutesConfig, JwtHelper } from "@cosmicdrift/kumiko-framework/api";
|
|
3
|
+
import { buildServer, createSseBroker, type SseBroker } from "@cosmicdrift/kumiko-framework/api";
|
|
4
|
+
import { createRegistry, validateBoot } from "@cosmicdrift/kumiko-framework/engine";
|
|
5
|
+
import type {
|
|
6
|
+
AppContext,
|
|
7
|
+
FeatureDefinition,
|
|
8
|
+
JobRunIn,
|
|
9
|
+
Registry,
|
|
10
|
+
TenantId,
|
|
11
|
+
} from "@cosmicdrift/kumiko-framework/engine/types";
|
|
12
|
+
import type { ErrorDocsConfig } from "@cosmicdrift/kumiko-framework/errors";
|
|
13
|
+
import {
|
|
14
|
+
createArchivedStreamsTable,
|
|
15
|
+
createEventsTable,
|
|
16
|
+
} from "@cosmicdrift/kumiko-framework/event-store";
|
|
17
|
+
import type { Lifecycle } from "@cosmicdrift/kumiko-framework/internal/stack-support";
|
|
18
|
+
import {
|
|
19
|
+
createRateLimitResolver,
|
|
20
|
+
unsafePushTables,
|
|
21
|
+
validateNavBootSubset,
|
|
22
|
+
validateOwnershipBoot,
|
|
23
|
+
} from "@cosmicdrift/kumiko-framework/internal/stack-support";
|
|
24
|
+
import {
|
|
25
|
+
createJobRunner,
|
|
26
|
+
type JobRunner,
|
|
27
|
+
type JobRunnerOptions,
|
|
28
|
+
} from "@cosmicdrift/kumiko-framework/jobs";
|
|
29
|
+
import {
|
|
30
|
+
createNoopProvider,
|
|
31
|
+
type ObservabilityProvider,
|
|
32
|
+
} from "@cosmicdrift/kumiko-framework/observability";
|
|
33
|
+
import type {
|
|
34
|
+
Dispatcher,
|
|
35
|
+
EventDispatcher,
|
|
36
|
+
SystemHooks,
|
|
37
|
+
} from "@cosmicdrift/kumiko-framework/pipeline";
|
|
38
|
+
import {
|
|
39
|
+
createEntityCache,
|
|
40
|
+
createEventDedup,
|
|
41
|
+
createIdempotencyGuard,
|
|
42
|
+
dispatcherToWriteRef,
|
|
43
|
+
} from "@cosmicdrift/kumiko-framework/pipeline";
|
|
44
|
+
import {
|
|
45
|
+
type ClosableCacheSyncBus,
|
|
46
|
+
createLocalCacheSyncBus,
|
|
47
|
+
createRedisCacheSyncBus,
|
|
48
|
+
} from "@cosmicdrift/kumiko-framework/redis";
|
|
49
|
+
import type { SearchAdapter } from "@cosmicdrift/kumiko-framework/search";
|
|
50
|
+
import { createInMemorySearchAdapter } from "@cosmicdrift/kumiko-framework/search";
|
|
51
|
+
import { propertyOf } from "@cosmicdrift/kumiko-types/type-guards";
|
|
52
|
+
import type { Hono } from "hono";
|
|
53
|
+
import { createTestDb } from "./db.js";
|
|
54
|
+
import { createJobFailureTracker, drainJobs } from "./drain-jobs.js";
|
|
55
|
+
import { createEventCollector, type EventCollector } from "./event-collector.js";
|
|
56
|
+
import { createTestRedis, queueNamePrefixForTestRedis, type TestRedis } from "./redis.js";
|
|
57
|
+
import { createRequestHelper, type RequestHelper } from "./request-helper.js";
|
|
58
|
+
|
|
59
|
+
export type TestStack = {
|
|
60
|
+
app: Hono;
|
|
61
|
+
jwt: JwtHelper;
|
|
62
|
+
registry: Registry;
|
|
63
|
+
db: import("@cosmicdrift/kumiko-framework/db").DbConnection;
|
|
64
|
+
redis: TestRedis;
|
|
65
|
+
// The bus this stack publishes/subscribes on; pass to TierResolverPlugin.build
|
|
66
|
+
// so a tier-engine resolver built outside buildServer joins the same sync.
|
|
67
|
+
cacheSync: ClosableCacheSyncBus;
|
|
68
|
+
search: SearchAdapter;
|
|
69
|
+
events: EventCollector;
|
|
70
|
+
http: RequestHelper;
|
|
71
|
+
observability: ObservabilityProvider;
|
|
72
|
+
// In-memory broker backing the server's SSE routes + access-invalidation
|
|
73
|
+
// channel. Tests subscribe directly (e.g. sseBroker.subscribeAccessInvalidation)
|
|
74
|
+
// to assert a consumer pushed an invalidation without opening a real SSE
|
|
75
|
+
// connection.
|
|
76
|
+
sseBroker: SseBroker;
|
|
77
|
+
// Command-dispatcher behind the HTTP routes — for direct system-writes in
|
|
78
|
+
// tests and behind entry:"signature" extraRoutes / the `wire` hook
|
|
79
|
+
// (provider-webhook wiring).
|
|
80
|
+
dispatcher: Dispatcher;
|
|
81
|
+
// The AppContext buildServer handed the request path, incl. the fields it
|
|
82
|
+
// wires itself (_fileProviderResolver). A dev-server that starts its own
|
|
83
|
+
// lane job-runners beside this stack must hand them THIS, not a
|
|
84
|
+
// `{ db, registry }` literal — that's the #1232 drift, and it makes an
|
|
85
|
+
// event-triggered job reaching for ctx.files die where the request path works.
|
|
86
|
+
context: AppContext;
|
|
87
|
+
// Present whenever a system consumer (SSE, Search) or
|
|
88
|
+
// r.multiStreamProjection is wired. Tests drain it via runOnce() (or the
|
|
89
|
+
// drainEventConsumers helper for a named-consumer backlog) for
|
|
90
|
+
// deterministic assertion — no timer-induced flakiness.
|
|
91
|
+
eventDispatcher?: EventDispatcher;
|
|
92
|
+
// Only set when the caller passed `lifecycle` via options. Tests that
|
|
93
|
+
// exercise drain() / /health/ready wire one in; ordinary suites ignore it.
|
|
94
|
+
lifecycle?: Lifecycle;
|
|
95
|
+
// Only set when `options.jobs` is truthy AND ≥1 `r.job(...)` is
|
|
96
|
+
// registered in the mounted features. Lets integration tests call
|
|
97
|
+
// `stack.jobRunner.dispatch(...)` directly, mirroring `ctx.jobRunner`.
|
|
98
|
+
jobRunner?: JobRunner;
|
|
99
|
+
// Waits for every in-flight job and its follow-up event-consumer /
|
|
100
|
+
// job-trigger cascade to settle — instead of `waitFor` polling on a
|
|
101
|
+
// specific job's side effect. Rejects with the failing job's name and
|
|
102
|
+
// error once a job exhausts its retries, or with a clear error when this
|
|
103
|
+
// stack has no jobRunner at all. Tracked failures accumulate since the
|
|
104
|
+
// PREVIOUS drainJobs() call (or setup, if none yet) — on a shared stack
|
|
105
|
+
// (e.g. beforeAll), a test that deliberately fails a job and never calls
|
|
106
|
+
// drainJobs() itself leaves that failure for the next test's drainJobs()
|
|
107
|
+
// call to reject on.
|
|
108
|
+
drainJobs: () => Promise<void>;
|
|
109
|
+
cleanup: () => Promise<void>;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
export type TestStackOptions = {
|
|
113
|
+
features: readonly FeatureDefinition[];
|
|
114
|
+
/** System hooks to wire up. Default: all (sse, search) */
|
|
115
|
+
systemHooks?: ("sse" | "search")[];
|
|
116
|
+
/** Pipeline system hooks handed to buildServer, e.g. the cascade-delete preDelete hook. None by default. */
|
|
117
|
+
lifecycleSystemHooks?: SystemHooks;
|
|
118
|
+
/** Search config per tenant — defaults to tenant 1 with all text fields */
|
|
119
|
+
searchConfig?: {
|
|
120
|
+
tenantId: TenantId;
|
|
121
|
+
searchableFields: string[];
|
|
122
|
+
rankingFields: string[];
|
|
123
|
+
};
|
|
124
|
+
jwtSecret?: string;
|
|
125
|
+
/** Extra fields merged into the AppContext (e.g. _notifyFactory, configResolver).
|
|
126
|
+
* Can be a function receiving (registry, db, sseBroker) for late binding. */
|
|
127
|
+
/** Boot default locale (BCP-47) merged into AppContext before extraContext. */
|
|
128
|
+
defaultLocale?: string;
|
|
129
|
+
extraContext?:
|
|
130
|
+
| Record<string, unknown>
|
|
131
|
+
| ((deps: {
|
|
132
|
+
registry: Registry;
|
|
133
|
+
db: import("@cosmicdrift/kumiko-framework/db").DbConnection;
|
|
134
|
+
sseBroker: import("@cosmicdrift/kumiko-framework/api").SseBroker;
|
|
135
|
+
redis: import("ioredis").Redis;
|
|
136
|
+
}) => Record<string, unknown>);
|
|
137
|
+
/** Wire up auth routes (login, tenant-switch). Leave undefined to skip. */
|
|
138
|
+
authConfig?: AuthRoutesConfig;
|
|
139
|
+
/** Register a file storage provider so uploads via POST /api/files work and
|
|
140
|
+
* `ctx.files.ref(key)` is available to hooks/MSPs. Omit to skip — tests
|
|
141
|
+
* without file handling don't need it. */
|
|
142
|
+
files?: { storageProvider: import("@cosmicdrift/kumiko-framework/files").FileStorageProvider };
|
|
143
|
+
/** Observability provider — omit for NoopProvider (no spans/metrics).
|
|
144
|
+
* Pass a ConsoleProvider to see the span tree in stdout, or a custom
|
|
145
|
+
* provider (e.g. a recording provider for assertions in tests). */
|
|
146
|
+
observability?: ObservabilityProvider;
|
|
147
|
+
/** Inject a process lifecycle so tests can drain() and observe
|
|
148
|
+
* /health/ready flipping to 503. Omit if the suite doesn't care. */
|
|
149
|
+
lifecycle?: Lifecycle;
|
|
150
|
+
/** Wire L1 (global-IP) and/or L2 (auth-endpoint) rate-limit middleware.
|
|
151
|
+
* The resolver is auto-built from the test Redis. Mirrors
|
|
152
|
+
* buildServer's `rateLimit` option 1:1 — see there for shape. */
|
|
153
|
+
rateLimit?: import("@cosmicdrift/kumiko-framework/api").ServerOptions["rateLimit"];
|
|
154
|
+
/** Forwarded to buildServer — Integration-Tests that exercise `extraRoutes`
|
|
155
|
+
* MUST go through here (real HTTP via `stack.http`/`stack.app.fetch`),
|
|
156
|
+
* never `createTestDispatcher`. */
|
|
157
|
+
extraRoutes?: import("@cosmicdrift/kumiko-framework/api").ServerOptions["extraRoutes"];
|
|
158
|
+
/** Forwarded to buildServer like runProdApp. Without a PrometheusMeter-backed
|
|
159
|
+
* `observability` the route answers 503, so spread `resolveObservabilityWiring(token)`
|
|
160
|
+
* rather than setting `metrics` alone. */
|
|
161
|
+
metrics?: import("@cosmicdrift/kumiko-framework/api").ServerOptions["metrics"];
|
|
162
|
+
/** Inject a MasterKeyProvider for secrets-backed tests. Lands typed in
|
|
163
|
+
* AppContext — set/delete/get + rotation job pick it up. Omit for
|
|
164
|
+
* suites that don't touch secrets. */
|
|
165
|
+
masterKeyProvider?: import("@cosmicdrift/kumiko-framework/secrets").MasterKeyProvider;
|
|
166
|
+
/** Feature-toggle resolver. When present the dispatcher's feature-gate,
|
|
167
|
+
* hook-filter, and MSP-filter all consult it; absent = every feature
|
|
168
|
+
* treated as always-on. Pass the callback from
|
|
169
|
+
* GlobalFeatureToggleRuntime.effectiveFeatures for real DB-backed
|
|
170
|
+
* toggles, or a plain `() => new Set<string>(registry.features.keys())`
|
|
171
|
+
* to force a specific snapshot in a unit-style setup. */
|
|
172
|
+
effectiveFeatures?: (tenantId: TenantId) => ReadonlySet<string>;
|
|
173
|
+
/** Pin the underlying Postgres DB name instead of the default
|
|
174
|
+
* `kumiko_test_<8chars>`. Forwarded to createTestDb. Primary use
|
|
175
|
+
* case: dev servers that want persistent storage across restarts —
|
|
176
|
+
* combine with `persistentDb: true`. */
|
|
177
|
+
dbName?: string;
|
|
178
|
+
/** When true, cleanup() keeps the Postgres DB around — the caller
|
|
179
|
+
* owns its lifecycle. Default false (test contract). Used by
|
|
180
|
+
* dev-server wiring to survive hot-reloads. */
|
|
181
|
+
persistentDb?: boolean;
|
|
182
|
+
/** Cap on the main app pool (default: driver default). Lets tests saturate it deliberately. */
|
|
183
|
+
dbPoolMax?: number;
|
|
184
|
+
/** Size of the dedicated pool behind ctx.dbOutsideTransaction / ctx.outsideTransaction. */
|
|
185
|
+
outsideTransactionPoolMax?: number;
|
|
186
|
+
/** Forwarded to buildServer — when set, requests without a JWT pass
|
|
187
|
+
* through as anonymous instead of 401. See AnonymousAccessConfig.
|
|
188
|
+
* Accepts either a static config object OR a factory
|
|
189
|
+
* `({registry, db, sseBroker, redis}) => Config`, the same pattern as
|
|
190
|
+
* `extraContext`. The factory runs once at boot; the TenantResolver inside
|
|
191
|
+
* typically closes over `db` for subdomain lookups. */
|
|
192
|
+
anonymousAccess?:
|
|
193
|
+
| import("@cosmicdrift/kumiko-framework/api").ServerOptions["anonymousAccess"]
|
|
194
|
+
| ((deps: {
|
|
195
|
+
registry: Registry;
|
|
196
|
+
db: import("@cosmicdrift/kumiko-framework/db").DbConnection;
|
|
197
|
+
sseBroker: import("@cosmicdrift/kumiko-framework/api").SseBroker;
|
|
198
|
+
redis: import("ioredis").Redis;
|
|
199
|
+
}) => import("@cosmicdrift/kumiko-framework/api").ServerOptions["anonymousAccess"]);
|
|
200
|
+
/** Optional post-factory enricher (e.g. merge auth-foundation tenant
|
|
201
|
+
* providers). Keeps framework free of a bundled-features dependency. */
|
|
202
|
+
enrichAnonymousAccess?: (
|
|
203
|
+
base: import("@cosmicdrift/kumiko-framework/api").ServerOptions["anonymousAccess"] | undefined,
|
|
204
|
+
deps: {
|
|
205
|
+
registry: Registry;
|
|
206
|
+
db: import("@cosmicdrift/kumiko-framework/db").DbConnection;
|
|
207
|
+
},
|
|
208
|
+
) => Promise<
|
|
209
|
+
import("@cosmicdrift/kumiko-framework/api").ServerOptions["anonymousAccess"] | undefined
|
|
210
|
+
>;
|
|
211
|
+
/** Opt-in JobRunner wired into ctx.jobRunner and merged into
|
|
212
|
+
* dispatcherOptions so event-triggered jobs enqueue on commit — mirrors
|
|
213
|
+
* the prod entrypoint's `buildJobRunnerWithHook`. Unlike prod (which
|
|
214
|
+
* always builds one), this only builds a runner when `registry.getAllJobs()`
|
|
215
|
+
* is non-empty — a deliberate test-stack-only shortcut, not parity.
|
|
216
|
+
* Default `undefined`. Pass `consumerLane` only when this test IS the
|
|
217
|
+
* sole consumer (otherwise enqueuer-only, avoiding double-running
|
|
218
|
+
* `runOnBoot`/cron jobs against a caller-owned consumer). */
|
|
219
|
+
jobs?: {
|
|
220
|
+
consumerLane?: JobRunIn;
|
|
221
|
+
/** Default: unique per stack, derived from its test Redis keyPrefix. Pass
|
|
222
|
+
* the same value to every runner that has to share this stack's queues. */
|
|
223
|
+
queueNamePrefix?: string;
|
|
224
|
+
/** Source of active tenant ids for `perTenant: true` jobs. Omit when the
|
|
225
|
+
* tenant feature is among `options.features` — the framework then
|
|
226
|
+
* resolves tenants on its own via that feature's active-tenant-ids
|
|
227
|
+
* query, same as prod. Only needed for suites that fan a job over
|
|
228
|
+
* tenants without mounting the tenant feature. */
|
|
229
|
+
getActiveTenantIds?: JobRunnerOptions["getActiveTenantIds"];
|
|
230
|
+
/** Run-log callbacks (job_runs / tenant job failures), built once registry
|
|
231
|
+
* and db exist. The framework can't import the bundled jobs feature, so the
|
|
232
|
+
* dev-server wrapper supplies prod's `jobRunLoggerCallbacks` here. They are
|
|
233
|
+
* awaited before drainJobs() observes the job's outcome. */
|
|
234
|
+
runLogger?: (deps: {
|
|
235
|
+
registry: Registry;
|
|
236
|
+
db: import("@cosmicdrift/kumiko-framework/db").DbConnection;
|
|
237
|
+
}) =>
|
|
238
|
+
| Pick<
|
|
239
|
+
JobRunnerOptions,
|
|
240
|
+
"onJobStart" | "onJobComplete" | "onJobFailed" | "onJobQueued" | "onJobDropped"
|
|
241
|
+
>
|
|
242
|
+
| undefined;
|
|
243
|
+
};
|
|
244
|
+
/** Override the event dispatcher's polling-timer interval. Default 50ms.
|
|
245
|
+
* Tests that assert LISTEN/NOTIFY wake-up latency need this pushed far
|
|
246
|
+
* out (e.g. 60_000) so the polling timer can't land inside the
|
|
247
|
+
* assertion window and mask a dead subscription — see E.4 (#2042). */
|
|
248
|
+
eventDispatcherPollIntervalMs?: number;
|
|
249
|
+
/** Opt out of auto-created r.entity() tables for callers that create/migrate
|
|
250
|
+
* them themselves. Default true — false pushes only projection/MSP/
|
|
251
|
+
* storeTable sources, same as before fw#3102. */
|
|
252
|
+
entityTables?: boolean;
|
|
253
|
+
/** Time source of the stack's rate-limit resolver, so tests can freeze the
|
|
254
|
+
* clock instead of racing the window. Default: system time. */
|
|
255
|
+
rateLimitNowMs?: () => number;
|
|
256
|
+
/** Forwarded to buildServer's top-level `ServerOptions.trustedProxyHops`
|
|
257
|
+
* — see there. Default 0. */
|
|
258
|
+
trustedProxyHops?: number;
|
|
259
|
+
/** Links the app's own error reasons to its own docs (`docsUrl` in error responses); framework reasons keep the framework docs link. Passed to buildServer's `ServerOptions.errorDocs`. */
|
|
260
|
+
errorDocs?: ErrorDocsConfig;
|
|
261
|
+
/** Second stack on the Redis namespace of `owner`: own connection, same
|
|
262
|
+
* keyPrefix, so rate-limit buckets, locks, idempotency keys and the
|
|
263
|
+
* cache-sync channel are shared across both. The derived
|
|
264
|
+
* queueNamePrefix is identical too, so both stacks share BullMQ queues.
|
|
265
|
+
* Cleanup only disconnects; the owner flushes the namespace and queues, so
|
|
266
|
+
* clean up the borrowing stack first. The DB is NOT shared — pass the same
|
|
267
|
+
* `dbName` + `persistentDb` for that. */
|
|
268
|
+
sharedRedisWith?: TestStack;
|
|
269
|
+
/** Publish/receive cache invalidations (tenant timezone, file providers,
|
|
270
|
+
* tier sets) over Redis Pub/Sub, namespaced by this stack's keyPrefix.
|
|
271
|
+
* Implied by `sharedRedisWith`; set it on the owner stack too, or its writes
|
|
272
|
+
* never reach the borrower. Without it the stack uses a process-local bus. */
|
|
273
|
+
cacheSync?: boolean;
|
|
274
|
+
/** "full" runs the prod-boot validateBoot over `features` (screens and ref
|
|
275
|
+
* entities across features included). Use it with the app's complete feature
|
|
276
|
+
* composition; the default only checks what is safe on a feature subset. */
|
|
277
|
+
validateBoot?: "full";
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
const DEFAULT_JWT_SECRET = "test-stack-secret-minimum-32-characters!!";
|
|
281
|
+
|
|
282
|
+
export async function setupTestStack(options: TestStackOptions): Promise<TestStack> {
|
|
283
|
+
const jwtSecret = options.jwtSecret ?? DEFAULT_JWT_SECRET;
|
|
284
|
+
const enabledHooks = options.systemHooks ?? ["sse", "search"];
|
|
285
|
+
|
|
286
|
+
// Install the Temporal polyfill before feature code runs. Idempotent: the
|
|
287
|
+
// production server boot does the same. A no-op on runtimes with native Temporal.
|
|
288
|
+
const { ensureTemporalPolyfill } = await import("@cosmicdrift/kumiko-framework/time");
|
|
289
|
+
await ensureTemporalPolyfill();
|
|
290
|
+
|
|
291
|
+
// Ownership boot-guards (fw#2639 unqualified where-fragments, fw#2626
|
|
292
|
+
// where-rules on the write path) otherwise run only in the prod boot via
|
|
293
|
+
// validateBoot, leaving every test stack blind to a broken access map.
|
|
294
|
+
// Runs after the polyfill (buildEntityTable needs it to derive the probe
|
|
295
|
+
// columns) and before the ephemeral DB/Redis exist, so a throw can't leak
|
|
296
|
+
// a database that nothing will clean up.
|
|
297
|
+
validateOwnershipBoot(options.features);
|
|
298
|
+
// Nav/workspace refs fail the prod boot too; the subset check tolerates refs into unmounted features.
|
|
299
|
+
validateNavBootSubset(options.features);
|
|
300
|
+
if (options.validateBoot === "full") validateBoot(options.features);
|
|
301
|
+
|
|
302
|
+
// Forward db-name/persistent-flag through to createTestDb. The
|
|
303
|
+
// defaults (undefined dbName, persistent:false) keep the legacy
|
|
304
|
+
// test contract: fresh kumiko_test_<random> DB per setup, dropped
|
|
305
|
+
// on cleanup.
|
|
306
|
+
const [testDb, testRedis] = await Promise.all([
|
|
307
|
+
createTestDb({
|
|
308
|
+
...(options.dbName !== undefined && { dbName: options.dbName }),
|
|
309
|
+
...(options.persistentDb !== undefined && { persistent: options.persistentDb }),
|
|
310
|
+
...(options.dbPoolMax !== undefined && { poolMax: options.dbPoolMax }),
|
|
311
|
+
...(options.outsideTransactionPoolMax !== undefined && {
|
|
312
|
+
outsideTransactionPoolMax: options.outsideTransactionPoolMax,
|
|
313
|
+
}),
|
|
314
|
+
}),
|
|
315
|
+
createTestRedis(
|
|
316
|
+
options.sharedRedisWith && { borrowKeyPrefix: options.sharedRedisWith.redis.keyPrefix },
|
|
317
|
+
),
|
|
318
|
+
]);
|
|
319
|
+
|
|
320
|
+
// Any throw in this DDL region must still drop the just-created ephemeral
|
|
321
|
+
// DB/Redis — the caller never gets the stack object back to call
|
|
322
|
+
// cleanup() itself (same "leaked ephemeral DB" failure class as the later
|
|
323
|
+
// try/catch below, but for setup that runs before that try starts).
|
|
324
|
+
try {
|
|
325
|
+
// Every ES-entity writes events via createEventStoreExecutor in the
|
|
326
|
+
// feature's write handlers. Auto-create the events table so every
|
|
327
|
+
// setupTestStack call is ready for writes without needing a manual
|
|
328
|
+
// createEventsTable().
|
|
329
|
+
await createEventsTable(testDb.db);
|
|
330
|
+
// Archive-stream metadata — needed by ctx.appendEvent's archive guard and
|
|
331
|
+
// loadAggregate's default-skip. Idempotent, so production boot running
|
|
332
|
+
// the same call is fine.
|
|
333
|
+
await createArchivedStreamsTable(testDb.db);
|
|
334
|
+
|
|
335
|
+
// Framework state for projection rebuild/status + event-consumer cursors.
|
|
336
|
+
// Idempotent — production boot flows run the same calls.
|
|
337
|
+
const { createProjectionStateTable, createEventConsumerStateTable } = await import(
|
|
338
|
+
"@cosmicdrift/kumiko-framework/pipeline"
|
|
339
|
+
);
|
|
340
|
+
await createProjectionStateTable(testDb.db);
|
|
341
|
+
await createEventConsumerStateTable(testDb.db);
|
|
342
|
+
|
|
343
|
+
// Files support: when a provider is registered, the fileRefs table must
|
|
344
|
+
// exist before the first upload. Skipped when no provider — the table
|
|
345
|
+
// stays off tenant test DBs that never touch files.
|
|
346
|
+
if (options.files) {
|
|
347
|
+
const { fileRefsTable } = await import("@cosmicdrift/kumiko-framework/files");
|
|
348
|
+
await unsafePushTables(testDb.db, { fileRefsTable });
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// Same table list as `kumiko schema generate` (collectTableMetas) — divergence
|
|
352
|
+
// was the #255 prod-crash and the #3102 missing r.entity() tables.
|
|
353
|
+
const { collectTableMetas } = await import("@cosmicdrift/kumiko-framework/db");
|
|
354
|
+
const { tableExists } = await import("@cosmicdrift/kumiko-framework/db");
|
|
355
|
+
let metas = collectTableMetas(options.features);
|
|
356
|
+
if (options.entityTables === false) {
|
|
357
|
+
const { enumerateFeatureTableSources } = await import("@cosmicdrift/kumiko-framework/db");
|
|
358
|
+
const { extractTableInfo } = await import("@cosmicdrift/kumiko-framework/db");
|
|
359
|
+
const nonEntityNames = new Set(
|
|
360
|
+
options.features.flatMap((f) =>
|
|
361
|
+
enumerateFeatureTableSources(f).map((s) => extractTableInfo(s.table).name),
|
|
362
|
+
),
|
|
363
|
+
);
|
|
364
|
+
metas = metas.filter((m) => nonEntityNames.has(m.tableName));
|
|
365
|
+
}
|
|
366
|
+
const missing: Record<string, unknown> = {};
|
|
367
|
+
for (const meta of metas) {
|
|
368
|
+
if (await tableExists(testDb.db, `public.${meta.tableName}`)) continue;
|
|
369
|
+
missing[meta.tableName] = meta;
|
|
370
|
+
}
|
|
371
|
+
if (Object.keys(missing).length > 0) {
|
|
372
|
+
await unsafePushTables(testDb.db, missing);
|
|
373
|
+
}
|
|
374
|
+
} catch (error) {
|
|
375
|
+
try {
|
|
376
|
+
await testDb.cleanup();
|
|
377
|
+
} catch {
|
|
378
|
+
// ignore — `error` is the one that matters
|
|
379
|
+
}
|
|
380
|
+
try {
|
|
381
|
+
await testRedis.cleanup();
|
|
382
|
+
} catch {
|
|
383
|
+
// ignore — `error` is the one that matters
|
|
384
|
+
}
|
|
385
|
+
throw error;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// Redis only where two stacks can actually disagree (avoids two extra
|
|
389
|
+
// Pub/Sub connections); otherwise a local bus so invalidation still works
|
|
390
|
+
// inside this stack. Passing it explicitly keeps buildServer from using its
|
|
391
|
+
// REDIS_URL default (an unnamespaced channel).
|
|
392
|
+
const cacheSync: ClosableCacheSyncBus =
|
|
393
|
+
options.cacheSync || options.sharedRedisWith
|
|
394
|
+
? createRedisCacheSyncBus({
|
|
395
|
+
redisUrl: testRedis.redisUrl,
|
|
396
|
+
channelPrefix: testRedis.keyPrefix,
|
|
397
|
+
})
|
|
398
|
+
: createLocalCacheSyncBus();
|
|
399
|
+
|
|
400
|
+
const searchAdapter = createInMemorySearchAdapter();
|
|
401
|
+
const events = createEventCollector();
|
|
402
|
+
const registry = createRegistry([...options.features]);
|
|
403
|
+
|
|
404
|
+
// Wire SSE broker with event collector — built early (not just before
|
|
405
|
+
// buildServer) because extraContext + the job context below both need it.
|
|
406
|
+
const sseBroker = createSseBroker();
|
|
407
|
+
sseBroker.addClient(
|
|
408
|
+
"tenant:00000000-0000-4000-8000-000000000001",
|
|
409
|
+
(event) => events.sse.push(event),
|
|
410
|
+
() => {},
|
|
411
|
+
);
|
|
412
|
+
|
|
413
|
+
const entityCache = createEntityCache(testRedis.redis, { ttlSeconds: 60 });
|
|
414
|
+
|
|
415
|
+
// A static `files.storageProvider` is wired as the per-tenant resolver — the
|
|
416
|
+
// framework test seam that doesn't require mounting config + file-foundation.
|
|
417
|
+
// (Bundled GDPR tests mount the real provider features instead.)
|
|
418
|
+
let fileProviderResolver:
|
|
419
|
+
| import("@cosmicdrift/kumiko-framework/files").FileProviderResolver
|
|
420
|
+
| undefined;
|
|
421
|
+
if (options.files) {
|
|
422
|
+
const provider = options.files.storageProvider;
|
|
423
|
+
fileProviderResolver = () => Promise.resolve(provider);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const observability = options.observability ?? createNoopProvider();
|
|
427
|
+
|
|
428
|
+
// Same AppContext buildServer() gets below (db/redis/searchAdapter/
|
|
429
|
+
// entityCache/masterKeyProvider/fileProviderResolver/extraContext) —
|
|
430
|
+
// shared so the job context can't drift from the request-path context
|
|
431
|
+
// (kumiko-framework#1232: a reduced `{ db, registry }` literal let jobs
|
|
432
|
+
// pass in tests while reaching for fields only prod's context has).
|
|
433
|
+
//
|
|
434
|
+
// effectiveFeatures deliberately stays OUT of appContext — prod never puts
|
|
435
|
+
// it there either (only dispatcherOptions.effectiveFeatures, consumed by
|
|
436
|
+
// the command-dispatcher — see buildJobRunnerWithHook in entrypoint/
|
|
437
|
+
// index.ts). Putting it here would reintroduce the exact test-vs-prod
|
|
438
|
+
// drift #1232 fixed: a job/handler reading `ctx.effectiveFeatures` would
|
|
439
|
+
// pass in tests and break in prod (kumiko-framework#1255).
|
|
440
|
+
const appContext = {
|
|
441
|
+
db: testDb.db,
|
|
442
|
+
redis: testRedis.redis,
|
|
443
|
+
searchAdapter,
|
|
444
|
+
entityCache,
|
|
445
|
+
registry,
|
|
446
|
+
...(options.masterKeyProvider ? { masterKeyProvider: options.masterKeyProvider } : {}),
|
|
447
|
+
...(fileProviderResolver ? { _fileProviderResolver: fileProviderResolver } : {}),
|
|
448
|
+
...(options.defaultLocale !== undefined && { defaultLocale: options.defaultLocale }),
|
|
449
|
+
...(typeof options.extraContext === "function"
|
|
450
|
+
? options.extraContext({ registry, db: testDb.db, sseBroker, redis: testRedis.redis })
|
|
451
|
+
: options.extraContext),
|
|
452
|
+
};
|
|
453
|
+
|
|
454
|
+
// Built before buildServer() so it can be merged into dispatcherOptions
|
|
455
|
+
// (write handlers' `ctx.jobRunner.dispatch(...)` and the afterCommit
|
|
456
|
+
// event-trigger hook both need it present at dispatcher-construction
|
|
457
|
+
// time, same as the prod entrypoint's buildJobRunnerWithHook). Uses the
|
|
458
|
+
// test-stack's own ephemeral redis — no separate `redisUrl` seam needed.
|
|
459
|
+
//
|
|
460
|
+
// context = appContext + tracer/meter, mirroring the prod entrypoint's
|
|
461
|
+
// `contextWithObservability(options.context, observability)`.
|
|
462
|
+
let jobRunner: JobRunner | undefined;
|
|
463
|
+
// Fed to createJobRunner's onJobComplete/onJobFailed below so drainJobs()
|
|
464
|
+
// can tell a job's final outcome apart from an attempt that will retry —
|
|
465
|
+
// built before createJobRunner regardless of whether a runner ends up
|
|
466
|
+
// existing, so the returned drainJobs() closure always has one to read.
|
|
467
|
+
const jobFailureTracker = createJobFailureTracker();
|
|
468
|
+
if (options.jobs && registry.getAllJobs().size > 0) {
|
|
469
|
+
const runLogger = options.jobs.runLogger?.({ registry, db: testDb.db });
|
|
470
|
+
jobRunner = createJobRunner({
|
|
471
|
+
registry,
|
|
472
|
+
context: { ...appContext, tracer: observability.tracer, meter: observability.meter },
|
|
473
|
+
// The real REDIS_URL, not one rebuilt from `.options` — that lost
|
|
474
|
+
// password/username/tls/path (pr-review kumiko-framework #1036/2).
|
|
475
|
+
redisUrl: testRedis.redisUrl,
|
|
476
|
+
...(options.jobs.consumerLane !== undefined && { consumerLane: options.jobs.consumerLane }),
|
|
477
|
+
queueNamePrefix:
|
|
478
|
+
options.jobs.queueNamePrefix ?? queueNamePrefixForTestRedis(testRedis.keyPrefix),
|
|
479
|
+
...(options.jobs.getActiveTenantIds !== undefined && {
|
|
480
|
+
getActiveTenantIds: options.jobs.getActiveTenantIds,
|
|
481
|
+
}),
|
|
482
|
+
...(runLogger?.onJobStart !== undefined && { onJobStart: runLogger.onJobStart }),
|
|
483
|
+
...(runLogger?.onJobQueued !== undefined && { onJobQueued: runLogger.onJobQueued }),
|
|
484
|
+
...(runLogger?.onJobDropped !== undefined && { onJobDropped: runLogger.onJobDropped }),
|
|
485
|
+
// Tracker fires in finally: drainJobs wakes on it, so the log row must
|
|
486
|
+
// be written first, and a throwing logger must not swallow the signal.
|
|
487
|
+
onJobComplete: async (jobName, jobId, ...rest) => {
|
|
488
|
+
try {
|
|
489
|
+
await runLogger?.onJobComplete?.(jobName, jobId, ...rest);
|
|
490
|
+
} finally {
|
|
491
|
+
jobFailureTracker.onJobComplete(jobName, jobId);
|
|
492
|
+
}
|
|
493
|
+
},
|
|
494
|
+
onJobFailed: async (jobName, jobId, error, ...rest) => {
|
|
495
|
+
try {
|
|
496
|
+
await runLogger?.onJobFailed?.(jobName, jobId, error, ...rest);
|
|
497
|
+
} finally {
|
|
498
|
+
jobFailureTracker.onJobFailed(jobName, jobId, error);
|
|
499
|
+
}
|
|
500
|
+
},
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// From here on, any throw must stop a jobRunner that was already created
|
|
505
|
+
// above (createJobRunner() itself opens live BullMQ Queue/lock Redis
|
|
506
|
+
// connections, .start() adds a live worker) — otherwise a failing
|
|
507
|
+
// buildServer()/search-config/etc. leaks that connection and the test
|
|
508
|
+
// process hangs on exit (pr-review kumiko-framework #1036/1).
|
|
509
|
+
try {
|
|
510
|
+
if (jobRunner) await jobRunner.start();
|
|
511
|
+
|
|
512
|
+
// Auto-configure search for tenant 1 based on registry
|
|
513
|
+
if (enabledHooks.includes("search")) {
|
|
514
|
+
const searchableFields: string[] = [];
|
|
515
|
+
for (const feature of options.features) {
|
|
516
|
+
for (const [, entity] of Object.entries(feature.entities ?? {})) {
|
|
517
|
+
for (const [fieldName, field] of Object.entries(entity.fields)) {
|
|
518
|
+
if (field.type === "text" && field.searchable) {
|
|
519
|
+
searchableFields.push(fieldName);
|
|
520
|
+
}
|
|
521
|
+
if (field.type === "embedded") {
|
|
522
|
+
for (const [subName, subField] of Object.entries(field.schema)) {
|
|
523
|
+
if (subField.searchable) {
|
|
524
|
+
searchableFields.push(`${fieldName}_${subName}`);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
if (options.searchConfig) {
|
|
533
|
+
await searchAdapter.configure(options.searchConfig.tenantId, {
|
|
534
|
+
searchableFields: options.searchConfig.searchableFields,
|
|
535
|
+
rankingFields: options.searchConfig.rankingFields,
|
|
536
|
+
});
|
|
537
|
+
} else if (searchableFields.length > 0) {
|
|
538
|
+
await searchAdapter.configure("00000000-0000-4000-8000-000000000001", {
|
|
539
|
+
searchableFields,
|
|
540
|
+
rankingFields: searchableFields,
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
const idempotency = createIdempotencyGuard(testRedis.redis, { ttlSeconds: 60 });
|
|
546
|
+
const eventDedup = createEventDedup(testRedis.redis, { ttlSeconds: 60 });
|
|
547
|
+
|
|
548
|
+
// buildServer only auto-wires an L3 RateLimitResolver from
|
|
549
|
+
// `context.redis` — a degradation test that nulls `redis` via
|
|
550
|
+
// extraContext (to exercise a handler's own ctx.redis-down guard) also
|
|
551
|
+
// silently strips rate-limiting, even though every handler with a
|
|
552
|
+
// `rateLimit` declaration still requires a resolver at dispatch time.
|
|
553
|
+
// Since e7bf543ad, an IP-keyed bucket key is built unconditionally
|
|
554
|
+
// (falls back to a shared "unknown" IP rather than skipping), so the
|
|
555
|
+
// missing resolver now throws instead of merely skipping enforcement.
|
|
556
|
+
// Mirrors idempotency/eventDedup above: built from the test-stack's own
|
|
557
|
+
// always-real `testRedis.redis`, independent of whatever extraContext
|
|
558
|
+
// did to `appContext.redis` — an explicit `context.rateLimit` from
|
|
559
|
+
// extraContext still wins (same precedence buildServer documents).
|
|
560
|
+
const explicitRateLimit = propertyOf(appContext, "rateLimit");
|
|
561
|
+
const needsOwnRateLimitResolver =
|
|
562
|
+
explicitRateLimit === undefined &&
|
|
563
|
+
(appContext.redis === undefined || options.rateLimitNowMs !== undefined);
|
|
564
|
+
const rateLimitFallback = needsOwnRateLimitResolver
|
|
565
|
+
? createRateLimitResolver({
|
|
566
|
+
// An app-supplied redis keeps buckets and app redis on the same instance.
|
|
567
|
+
redis: appContext.redis ?? testRedis.redis,
|
|
568
|
+
...(options.rateLimitNowMs ? { nowMs: options.rateLimitNowMs } : {}),
|
|
569
|
+
})
|
|
570
|
+
: undefined;
|
|
571
|
+
|
|
572
|
+
const server = buildServer({
|
|
573
|
+
registry,
|
|
574
|
+
context: rateLimitFallback ? { ...appContext, rateLimit: rateLimitFallback } : appContext,
|
|
575
|
+
jwtSecret,
|
|
576
|
+
...(options.observability ? { observability: options.observability } : {}),
|
|
577
|
+
...(options.lifecycleSystemHooks && { systemHooks: options.lifecycleSystemHooks }),
|
|
578
|
+
dispatcherOptions: {
|
|
579
|
+
idempotency,
|
|
580
|
+
cacheSync,
|
|
581
|
+
outsideTransactionDb: testDb.outsideTransactionDb,
|
|
582
|
+
...(options.effectiveFeatures && { effectiveFeatures: options.effectiveFeatures }),
|
|
583
|
+
...(jobRunner && { jobRunner }),
|
|
584
|
+
},
|
|
585
|
+
eventDedup,
|
|
586
|
+
sseBroker,
|
|
587
|
+
...(options.trustedProxyHops !== undefined && { trustedProxyHops: options.trustedProxyHops }),
|
|
588
|
+
...(options.errorDocs && { errorDocs: options.errorDocs }),
|
|
589
|
+
...(options.extraRoutes && { extraRoutes: options.extraRoutes }),
|
|
590
|
+
...(options.metrics && { metrics: options.metrics }),
|
|
591
|
+
// Tests drive the dispatcher via stack.eventDispatcher.runOnce() for
|
|
592
|
+
// deterministic drains — no timer-induced flakiness. pollIntervalMs
|
|
593
|
+
// stays short anyway in case a test opts into `.start()`. pgClient
|
|
594
|
+
// plumbs through the LISTEN wake-up for tests that want to measure
|
|
595
|
+
// post-commit latency (Sprint E.4).
|
|
596
|
+
eventDispatcher: {
|
|
597
|
+
pollIntervalMs: options.eventDispatcherPollIntervalMs ?? 50,
|
|
598
|
+
pgClient: testDb.client,
|
|
599
|
+
systemConsumers: {
|
|
600
|
+
sse: enabledHooks.includes("sse"),
|
|
601
|
+
search: enabledHooks.includes("search"),
|
|
602
|
+
accessInvalidation: enabledHooks.includes("sse"),
|
|
603
|
+
},
|
|
604
|
+
},
|
|
605
|
+
// Default tests to no login rate-limiter so existing suites that loop
|
|
606
|
+
// over logins don't hit a 429 after 10 attempts. Suites specifically
|
|
607
|
+
// testing the limiter can override via authConfig.loginRateLimit.
|
|
608
|
+
...(options.authConfig
|
|
609
|
+
? {
|
|
610
|
+
auth: {
|
|
611
|
+
...options.authConfig,
|
|
612
|
+
...(options.authConfig.loginRateLimit === undefined ? { loginRateLimit: null } : {}),
|
|
613
|
+
},
|
|
614
|
+
}
|
|
615
|
+
: {}),
|
|
616
|
+
...(options.lifecycle ? { lifecycle: options.lifecycle } : {}),
|
|
617
|
+
...(options.rateLimit ? { rateLimit: options.rateLimit } : {}),
|
|
618
|
+
...(await (async () => {
|
|
619
|
+
const baseAnon =
|
|
620
|
+
typeof options.anonymousAccess === "function"
|
|
621
|
+
? options.anonymousAccess({
|
|
622
|
+
registry,
|
|
623
|
+
db: testDb.db,
|
|
624
|
+
sseBroker,
|
|
625
|
+
redis: testRedis.redis,
|
|
626
|
+
})
|
|
627
|
+
: options.anonymousAccess;
|
|
628
|
+
const resolvedAnon = options.enrichAnonymousAccess
|
|
629
|
+
? await options.enrichAnonymousAccess(baseAnon, {
|
|
630
|
+
registry,
|
|
631
|
+
db: testDb.db,
|
|
632
|
+
})
|
|
633
|
+
: baseAnon;
|
|
634
|
+
return resolvedAnon ? { anonymousAccess: resolvedAnon } : {};
|
|
635
|
+
})()),
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
// Mirrors the prod entrypoints — without this, JobContext.write/writeAs/
|
|
639
|
+
// queryAs/queryAsMember throw on first use inside a test-stack job.
|
|
640
|
+
if (jobRunner) jobRunner.attachDispatcher(dispatcherToWriteRef(server.dispatcher));
|
|
641
|
+
|
|
642
|
+
const eventDispatcher: EventDispatcher | undefined = server.eventDispatcher;
|
|
643
|
+
|
|
644
|
+
// Pre-register consumer state rows so tests can call runOnce() directly
|
|
645
|
+
// without a preceding explicit start(). Timer fires at pollIntervalMs=50
|
|
646
|
+
// but each consumer runs at most one turn at a time — tests that drain via
|
|
647
|
+
// runOnce() remain deterministic. Tests that specifically exercise the
|
|
648
|
+
// timer loop call start() again (idempotent) after setup.
|
|
649
|
+
if (eventDispatcher) await eventDispatcher.ensureRegistered();
|
|
650
|
+
|
|
651
|
+
const http = createRequestHelper(server.app, server.jwt, {
|
|
652
|
+
...(options.authConfig?.sessionCreator !== undefined && {
|
|
653
|
+
sessionCreator: options.authConfig.sessionCreator,
|
|
654
|
+
}),
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
return {
|
|
658
|
+
app: server.app,
|
|
659
|
+
jwt: server.jwt,
|
|
660
|
+
registry,
|
|
661
|
+
db: testDb.db,
|
|
662
|
+
redis: testRedis,
|
|
663
|
+
cacheSync,
|
|
664
|
+
search: searchAdapter,
|
|
665
|
+
events,
|
|
666
|
+
http,
|
|
667
|
+
observability: server.observability,
|
|
668
|
+
sseBroker,
|
|
669
|
+
dispatcher: server.dispatcher,
|
|
670
|
+
context: server.context,
|
|
671
|
+
...(eventDispatcher ? { eventDispatcher } : {}),
|
|
672
|
+
...(server.lifecycle ? { lifecycle: server.lifecycle } : {}),
|
|
673
|
+
...(jobRunner ? { jobRunner } : {}),
|
|
674
|
+
drainJobs: () => drainJobs({ db: testDb.db, eventDispatcher, jobRunner }, jobFailureTracker),
|
|
675
|
+
cleanup: async () => {
|
|
676
|
+
if (jobRunner) await jobRunner.stop();
|
|
677
|
+
if (eventDispatcher) await eventDispatcher.stop();
|
|
678
|
+
await server.observability.shutdown();
|
|
679
|
+
await cacheSync.close();
|
|
680
|
+
await Promise.all([testDb.cleanup(), testRedis.cleanup()]);
|
|
681
|
+
},
|
|
682
|
+
};
|
|
683
|
+
} catch (error) {
|
|
684
|
+
// Best-effort — a broken jobRunner.stop()/cleanup() must never mask the
|
|
685
|
+
// real setup failure below. testDb/testRedis are created before this
|
|
686
|
+
// try even starts, so a throw anywhere in setup (buildServer,
|
|
687
|
+
// search-config, ...) would otherwise leak their open pg/ioredis
|
|
688
|
+
// sockets — the caller never gets the stack object back to call
|
|
689
|
+
// cleanup() itself, which is the same "hangs on exit" failure class
|
|
690
|
+
// this function exists to prevent.
|
|
691
|
+
if (jobRunner) {
|
|
692
|
+
try {
|
|
693
|
+
await jobRunner.stop();
|
|
694
|
+
} catch {
|
|
695
|
+
// ignore — `error` is the one that matters
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
try {
|
|
699
|
+
await cacheSync.close();
|
|
700
|
+
} catch {
|
|
701
|
+
// ignore — `error` is the one that matters
|
|
702
|
+
}
|
|
703
|
+
try {
|
|
704
|
+
await testDb.cleanup();
|
|
705
|
+
} catch {
|
|
706
|
+
// ignore — `error` is the one that matters
|
|
707
|
+
}
|
|
708
|
+
try {
|
|
709
|
+
await testRedis.cleanup();
|
|
710
|
+
} catch {
|
|
711
|
+
// ignore — `error` is the one that matters
|
|
712
|
+
}
|
|
713
|
+
throw error;
|
|
714
|
+
}
|
|
715
|
+
}
|