@cosmicdrift/kumiko-testing 0.369.0 → 0.370.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-testing",
3
- "version": "0.369.0",
3
+ "version": "0.370.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>",
@@ -95,8 +95,8 @@
95
95
  "kumiko-testing": "./bin/kumiko-testing.ts"
96
96
  },
97
97
  "dependencies": {
98
- "@cosmicdrift/kumiko-framework": "0.369.0",
99
- "@cosmicdrift/kumiko-types": "0.369.0",
98
+ "@cosmicdrift/kumiko-framework": "0.370.0",
99
+ "@cosmicdrift/kumiko-types": "0.370.0",
100
100
  "zod": "^4.4.3"
101
101
  },
102
102
  "peerDependencies": {
@@ -128,8 +128,8 @@
128
128
  }
129
129
  },
130
130
  "devDependencies": {
131
- "@cosmicdrift/kumiko-bundled-features": "0.369.0",
132
- "@cosmicdrift/kumiko-dev-server": "0.369.0",
131
+ "@cosmicdrift/kumiko-bundled-features": "0.370.0",
132
+ "@cosmicdrift/kumiko-dev-server": "0.370.0",
133
133
  "@happy-dom/global-registrator": "^20.11.2",
134
134
  "@playwright/test": "^1.62.1",
135
135
  "@testing-library/dom": "^10.4.1",
package/src/bunfig.ts CHANGED
@@ -1,5 +1,8 @@
1
+ // @runtime tooling
2
+ // Pure TOML rendering with no test-stack imports: dev-server's managed-files
3
+ // sync renders and merges app bunfigs with it outside of any test run.
1
4
  import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
2
- import { E2E_TIMEOUT_MS } from "./e2e-timeouts";
5
+ import { E2E_TIMEOUT_MS } from "./e2e-timeouts.ts";
3
6
 
4
7
  export const TEST_TIMEOUT_MS = {
5
8
  unit: 5000,
package/src/changes.json CHANGED
@@ -1,4 +1,22 @@
1
1
  [
2
+ {
3
+ "version": "0.370.0",
4
+ "type": "improvement",
5
+ "title": "setupTestStack binds the MFA token secrets",
6
+ "detail": "setupTestStack binds the auth-mfa token secrets from the stack's JWT secret, independent of the host env, so tests that mount auth-mfa without explicit secrets keep working."
7
+ },
8
+ {
9
+ "version": "0.370.0",
10
+ "type": "improvement",
11
+ "title": "renderTestSetup writes the standard test scripts, bunfig.dom.toml and the helper homes",
12
+ "detail": "The scaffolded test setup now adds test:unit, test:integration:ci, test:dom and screenshots, writes bunfig.dom.toml (with @happy-dom/global-registrator as devDependency), e2e/_helpers/index.ts and src/__tests__/_setup.ts."
13
+ },
14
+ {
15
+ "version": "0.370.0",
16
+ "type": "improvement",
17
+ "title": "createTestStoredEvent builds a StoredEvent fixture",
18
+ "detail": "createTestStoredEvent(overrides) from @cosmicdrift/kumiko-testing/helpers returns a complete StoredEvent with stable defaults, for hook tests that construct SaveContext or DeleteContext directly."
19
+ },
2
20
  {
3
21
  "version": "0.365.0",
4
22
  "type": "fix",
@@ -13,7 +13,7 @@ import {
13
13
  type Project,
14
14
  } from "@playwright/test";
15
15
  import { E2E_TIMEOUT_MS } from "../e2e-timeouts";
16
- import { isRealProviderRun } from "../helpers/real-providers.js";
16
+ import { isRealProviderRun } from "../helpers/real-providers.ts";
17
17
  import { SERVICE_ENV_DEFAULTS } from "../preload/service-env-defaults-values";
18
18
  import {
19
19
  DESKTOP_VIEWPORT,
package/src/e2e/index.ts CHANGED
@@ -4,7 +4,7 @@ export {
4
4
  isRealProviderRun,
5
5
  REAL_PROVIDERS_ENV,
6
6
  requireRealProviders,
7
- } from "../helpers/real-providers.js";
7
+ } from "../helpers/real-providers.ts";
8
8
  export {
9
9
  apiCommand,
10
10
  apiQuery,
@@ -3,7 +3,7 @@ import { mkdirSync, statSync } from "node:fs";
3
3
  import { dirname } from "node:path";
4
4
  import { expect, type Page, errors as playwrightErrors, type Request } from "@playwright/test";
5
5
  import { E2E_TIMEOUT_MS } from "../e2e-timeouts";
6
- import { isRealProviderRun } from "../helpers/real-providers.js";
6
+ import { isRealProviderRun } from "../helpers/real-providers.ts";
7
7
  import { DESKTOP_VIEWPORT } from "./constants";
8
8
  import { pinEnglishLocale } from "./pin-english-locale";
9
9
  import { requireScreenshotDir, SCREENSHOT_DIR_ENV } from "./screenshot-dir";
@@ -3,7 +3,7 @@
3
3
 
4
4
  import { isClosedConnectionError } from "@cosmicdrift/kumiko-framework/internal/stack-support";
5
5
  import postgres from "postgres";
6
- import { requireEnv } from "../stack/db.js";
6
+ import { requireEnv } from "../stack/db.ts";
7
7
 
8
8
  export function testDatabaseUrl(): string {
9
9
  return requireEnv("TEST_DATABASE_URL");
@@ -14,45 +14,45 @@ export {
14
14
  resetPiiSubjectKmsForTests,
15
15
  } from "@cosmicdrift/kumiko-framework/internal/testing";
16
16
 
17
- export { rolesOf } from "./access-assertions.js";
18
- export { expectError, expectSuccess } from "./assertions.js";
19
- export { withBootValidatorFixture } from "./boot-validator-fixture.js";
17
+ export { rolesOf } from "./access-assertions.ts";
18
+ export { expectError, expectSuccess } from "./assertions.ts";
19
+ export { withBootValidatorFixture } from "./boot-validator-fixture.ts";
20
20
  export {
21
21
  captureClosedConnectionError,
22
22
  terminateBackendsByApplicationName,
23
23
  testDatabaseUrl,
24
- } from "./closed-connection-error.js";
25
- export { type ClearableTable, clearTables, resetTestTables } from "./db-cleanup.js";
24
+ } from "./closed-connection-error.ts";
25
+ export { type ClearableTable, clearTables, resetTestTables } from "./db-cleanup.ts";
26
26
  export {
27
27
  type E2EGeneratorOptions,
28
28
  type E2ETestSpec,
29
29
  type EditFillOp,
30
30
  generateE2ESpec,
31
31
  generateZodFixture,
32
- } from "./e2e-generator.js";
33
- export { expectErrorIncludes } from "./expect-error.js";
34
- export { describeFileProviderContract } from "./file-provider-contract.js";
32
+ } from "./e2e-generator.ts";
33
+ export { expectErrorIncludes } from "./expect-error.ts";
34
+ export { describeFileProviderContract } from "./file-provider-contract.ts";
35
35
  export {
36
36
  getSetCookieRaw,
37
37
  getSetCookies,
38
38
  getSetCookieValue,
39
39
  type ParsedSetCookie,
40
- } from "./http-cookies.js";
41
- export { createLateBoundHolder, type LateBoundHolder } from "./late-bound.js";
42
- export { createManualClock, type ManualClock } from "./manual-clock.js";
43
- export { buildMultipartBody, patchFileInstanceofForBunTest } from "./multipart-helper.js";
40
+ } from "./http-cookies.ts";
41
+ export { createLateBoundHolder, type LateBoundHolder } from "./late-bound.ts";
42
+ export { createManualClock, type ManualClock } from "./manual-clock.ts";
43
+ export { buildMultipartBody, patchFileInstanceofForBunTest } from "./multipart-helper.ts";
44
44
  export {
45
45
  createMutableMasterKeyProvider,
46
46
  createTestEnvelopeCipher,
47
47
  createTestMasterKeyProvider,
48
48
  type MutableMasterKeyProvider,
49
- } from "./mutable-master-key-provider.js";
49
+ } from "./mutable-master-key-provider.ts";
50
50
  export {
51
51
  createRecordingProvider,
52
52
  type RecordingProvider,
53
- } from "./observability-recorder.js";
54
- export { isRealProviderRun, REAL_PROVIDERS_ENV, requireRealProviders } from "./real-providers.js";
55
- export { deleteRows, seedRow, seedRows, updateRows } from "./seed.js";
53
+ } from "./observability-recorder.ts";
54
+ export { isRealProviderRun, REAL_PROVIDERS_ENV, requireRealProviders } from "./real-providers.ts";
55
+ export { deleteRows, seedRow, seedRows, updateRows } from "./seed.ts";
56
56
  export {
57
57
  sharedItemEntity,
58
58
  sharedItemTable,
@@ -60,8 +60,9 @@ export {
60
60
  sharedUserTable,
61
61
  sharedWidgetEntity,
62
62
  sharedWidgetTable,
63
- } from "./shared-entities.js";
64
- export { sleep } from "./utils.js";
65
- export { waitFor } from "./wait-for.js";
66
- export { withoutAmbientTemporal } from "./without-ambient-temporal.js";
67
- export { zipWithEntries } from "./zip-fixture.js";
63
+ } from "./shared-entities.ts";
64
+ export { createTestStoredEvent } from "./stored-event-fixture.ts";
65
+ export { sleep } from "./utils.ts";
66
+ export { waitFor } from "./wait-for.ts";
67
+ export { withoutAmbientTemporal } from "./without-ambient-temporal.ts";
68
+ export { zipWithEntries } from "./zip-fixture.ts";
@@ -0,0 +1,20 @@
1
+ import type { StoredEvent } from "@cosmicdrift/kumiko-types/event-store-types";
2
+
3
+ // SaveContext and DeleteContext always carry the event that produced them, so a
4
+ // hand-built context in a hook test needs one too.
5
+ export function createTestStoredEvent(overrides: Partial<StoredEvent> = {}): StoredEvent {
6
+ return {
7
+ id: "test-event-1",
8
+ aggregateId: "test-aggregate-1",
9
+ aggregateType: "test-aggregate",
10
+ tenantId: "test-tenant-1",
11
+ version: 1,
12
+ type: "test:event:saved",
13
+ eventVersion: 1,
14
+ payload: {},
15
+ metadata: { userId: "test-user-1" },
16
+ createdAt: Temporal.Instant.from("2026-01-01T00:00:00Z"),
17
+ createdBy: "test-user-1",
18
+ ...overrides,
19
+ };
20
+ }
@@ -1,4 +1,4 @@
1
- import { requireRealProviders } from "../helpers/real-providers.js";
1
+ import { requireRealProviders } from "../helpers/real-providers.ts";
2
2
  import "./schema-env-defaults";
3
3
  import "./service-env-defaults";
4
4
 
package/src/scaffold.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  // @runtime tooling
2
2
  import { BUNFIG_FILES, renderBunfigFiles, TEST_TIMEOUT_MS } from "./bunfig";
3
- import { REAL_PROVIDERS_ENV } from "./helpers/real-providers.js";
3
+ import { REAL_PROVIDERS_ENV } from "./helpers/real-providers";
4
4
 
5
5
  export type ScaffoldTestSetup = {
6
6
  readonly files: Readonly<Record<string, string>>;
@@ -21,6 +21,8 @@ export const SCAFFOLD_E2E_PORT = 4174;
21
21
  export const SCAFFOLD_INTEGRATION_PARALLEL = 4;
22
22
 
23
23
  const PLAYWRIGHT_VERSION = "^1.62.1";
24
+ // bunfig.dom.toml preloads kumiko-testing's dom preload, which registers happy-dom.
25
+ const HAPPY_DOM_VERSION = "^20.11.2";
24
26
 
25
27
  const TEST_RUNTIME_DIRECTIVE = "// @runtime test";
26
28
 
@@ -81,22 +83,8 @@ function renderE2eServer(appName: string): string {
81
83
  function renderE2eSmokeSpec(): string {
82
84
  return [
83
85
  TEST_RUNTIME_DIRECTIVE,
84
- 'import type { SeedPart } from "@cosmicdrift/kumiko-testing";',
85
86
  'import { expect, test } from "@cosmicdrift/kumiko-testing/e2e";',
86
- 'import type { Page } from "@playwright/test";',
87
- "",
88
- `const TASK_CREATE = ${JSON.stringify(TASK_CREATE)};`,
89
- "",
90
- "function withTask(title: string): SeedPart {",
91
- " return async ({ tenant }) => {",
92
- ' await tenant.api.writeOk(TASK_CREATE, { title, status: "todo", priority: 1 });',
93
- " };",
94
- "}",
95
- "",
96
- "async function openTasks(page: Page): Promise<void> {",
97
- ' await page.goto("/");',
98
- ' await page.getByRole("link", { name: "Tasks" }).first().click();',
99
- "}",
87
+ 'import { openTasks, withTask } from "./_helpers";',
100
88
  "",
101
89
  'test("a seeded task shows up on the tasks screen", async ({ seedTenant, page }) => {',
102
90
  ' const tenant = await seedTenant({ with: [withTask("Water the plants")] });',
@@ -117,6 +105,41 @@ function renderE2eSmokeSpec(): string {
117
105
  ].join("\n");
118
106
  }
119
107
 
108
+ function renderE2eHelpers(): string {
109
+ return [
110
+ TEST_RUNTIME_DIRECTIVE,
111
+ 'import type { SeedPart } from "@cosmicdrift/kumiko-testing";',
112
+ 'import type { Page } from "@playwright/test";',
113
+ "",
114
+ `const TASK_CREATE = ${JSON.stringify(TASK_CREATE)};`,
115
+ "",
116
+ "export function withTask(title: string): SeedPart {",
117
+ " return async ({ tenant }) => {",
118
+ ' await tenant.api.writeOk(TASK_CREATE, { title, status: "todo", priority: 1 });',
119
+ " };",
120
+ "}",
121
+ "",
122
+ "export async function openTasks(page: Page): Promise<void> {",
123
+ ' await page.goto("/");',
124
+ ' await page.getByRole("link", { name: "Tasks" }).first().click();',
125
+ "}",
126
+ "",
127
+ ].join("\n");
128
+ }
129
+
130
+ function renderIntegrationSetup(): string {
131
+ return [
132
+ 'import { setupAppTestStack } from "@cosmicdrift/kumiko-testing";',
133
+ 'import type { TestStack } from "@cosmicdrift/kumiko-testing/stack";',
134
+ 'import { tasksFeature } from "../features/tasks";',
135
+ "",
136
+ "export function setupTasksStack(): Promise<TestStack> {",
137
+ " return setupAppTestStack([tasksFeature]);",
138
+ "}",
139
+ "",
140
+ ].join("\n");
141
+ }
142
+
120
143
  function renderUnitTest(): string {
121
144
  return [
122
145
  'import { describe, expect, test } from "bun:test";',
@@ -140,9 +163,9 @@ function renderUnitTest(): string {
140
163
  function renderIntegrationTest(): string {
141
164
  return [
142
165
  'import { afterAll, beforeAll, describe, expect, test } from "bun:test";',
143
- 'import { seedTenant, setupAppTestStack } from "@cosmicdrift/kumiko-testing";',
166
+ 'import { seedTenant } from "@cosmicdrift/kumiko-testing";',
144
167
  'import type { TestStack } from "@cosmicdrift/kumiko-testing/stack";',
145
- 'import { tasksFeature } from "../features/tasks";',
168
+ 'import { setupTasksStack } from "./_setup";',
146
169
  "",
147
170
  `const TASK_CREATE = ${JSON.stringify(TASK_CREATE)};`,
148
171
  `const TASK_LIST = ${JSON.stringify(TASK_LIST)};`,
@@ -152,7 +175,7 @@ function renderIntegrationTest(): string {
152
175
  "let stack: TestStack;",
153
176
  "",
154
177
  "beforeAll(async () => {",
155
- " stack = await setupAppTestStack([tasksFeature]);",
178
+ " stack = await setupTasksStack();",
156
179
  "});",
157
180
  "",
158
181
  "afterAll(async () => {",
@@ -178,18 +201,25 @@ function renderIntegrationTest(): string {
178
201
 
179
202
  export function renderTestSetup(input: RenderTestSetupInput): ScaffoldTestSetup {
180
203
  const { appName, frameworkVersion, port = SCAFFOLD_E2E_PORT } = input;
204
+ const unitTestScript = `bun --config=${BUNFIG_FILES.unit} test --timeout=${TEST_TIMEOUT_MS.unit} --dots`;
205
+ const integrationTestScript = `bun kumiko-testing integration --parallel ${SCAFFOLD_INTEGRATION_PARALLEL}`;
181
206
  return {
182
207
  files: {
183
- ...renderBunfigFiles({ install: { linker: "hoisted" } }),
208
+ ...renderBunfigFiles({ install: { linker: "hoisted" }, dom: true }),
184
209
  "playwright.config.ts": renderPlaywrightConfig(port),
210
+ "e2e/_helpers/index.ts": renderE2eHelpers(),
185
211
  "e2e/server.ts": renderE2eServer(appName),
186
212
  "e2e/smoke.spec.ts": renderE2eSmokeSpec(),
213
+ "src/__tests__/_setup.ts": renderIntegrationSetup(),
187
214
  "src/__tests__/run-config.test.ts": renderUnitTest(),
188
215
  "src/__tests__/tasks.integration.test.ts": renderIntegrationTest(),
189
216
  },
190
217
  scripts: {
191
- test: `bun --config=${BUNFIG_FILES.unit} test --timeout=${TEST_TIMEOUT_MS.unit} --dots`,
192
- "test:integration": `bun kumiko-testing integration --parallel ${SCAFFOLD_INTEGRATION_PARALLEL}`,
218
+ test: unitTestScript,
219
+ "test:unit": unitTestScript,
220
+ "test:integration": integrationTestScript,
221
+ "test:integration:ci": integrationTestScript,
222
+ "test:dom": `bun --config=${BUNFIG_FILES.dom} test --timeout=${TEST_TIMEOUT_MS.unit} --dots .test.tsx`,
193
223
  // bunfig.real.toml's pathIgnorePatterns can only blacklist (no "!" negation,
194
224
  // verified against bun 1.4), so it can't express "match only *.real.test.ts"
195
225
  // on its own — every *.test.ts file matches bun's default test glob too,
@@ -201,9 +231,11 @@ export function renderTestSetup(input: RenderTestSetupInput): ScaffoldTestSetup
201
231
  "test:bunfig": "bun kumiko-testing bunfig --hoisted",
202
232
  e2e: "bunx --bun playwright test",
203
233
  "e2e:real": `${REAL_PROVIDERS_ENV}=1 bunx --bun playwright test`,
234
+ screenshots: "SCREENSHOT_DIR=docs/screenshots bunx --bun playwright test",
204
235
  },
205
236
  devDependencies: {
206
237
  "@cosmicdrift/kumiko-testing": frameworkVersion,
238
+ "@happy-dom/global-registrator": HAPPY_DOM_VERSION,
207
239
  "@playwright/test": PLAYWRIGHT_VERSION,
208
240
  },
209
241
  rulesMarkdown: RULES_MARKDOWN,
@@ -20,7 +20,7 @@ import {
20
20
  splitSeedRoles,
21
21
  withSession,
22
22
  } from "./seed-types";
23
- import type { TestStack } from "./stack/index.js";
23
+ import type { TestStack } from "./stack/index.ts";
24
24
 
25
25
  export type {
26
26
  BoundApi,
@@ -3,7 +3,7 @@ import {
3
3
  setupTestStackFromFeatures,
4
4
  } from "@cosmicdrift/kumiko-dev-server/setup-test-stack-from-features";
5
5
  import type { FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
6
- import { pushEntityProjectionTables, type TestStack } from "./stack/index.js";
6
+ import { pushEntityProjectionTables, type TestStack } from "./stack/index.ts";
7
7
 
8
8
  export type SetupAppTestStackOptions = SetupTestStackFromFeaturesOptions & {
9
9
  readonly registryTables?: boolean;
@@ -5,7 +5,7 @@ import {
5
5
  type ConsumerRecoveryState,
6
6
  getConsumerState,
7
7
  } from "@cosmicdrift/kumiko-framework/pipeline";
8
- import type { TestStack } from "./test-stack.js";
8
+ import type { TestStack } from "./test-stack.ts";
9
9
 
10
10
  const DEFAULT_MAX_PASSES = 25;
11
11
 
@@ -18,11 +18,11 @@ export {
18
18
  type CreateTestDbOptions,
19
19
  createTestDb,
20
20
  type TestDb,
21
- } from "./db.js";
22
- export { drainEventConsumers } from "./drain-event-consumers.js";
23
- export { createEventCollector, type EventCollector } from "./event-collector.js";
24
- export { pushEntityProjectionTables } from "./push-entity-projection-tables.js";
25
- export { createTestRedis, type TestRedis } from "./redis.js";
26
- export { createRequestHelper, type RequestHelper } from "./request-helper.js";
27
- export { resetEventStore } from "./table-helpers.js";
28
- export { setupTestStack, type TestStack, type TestStackOptions } from "./test-stack.js";
21
+ } from "./db.ts";
22
+ export { drainEventConsumers } from "./drain-event-consumers.ts";
23
+ export { createEventCollector, type EventCollector } from "./event-collector.ts";
24
+ export { pushEntityProjectionTables } from "./push-entity-projection-tables.ts";
25
+ export { createTestRedis, type TestRedis } from "./redis.ts";
26
+ export { createRequestHelper, type RequestHelper } from "./request-helper.ts";
27
+ export { resetEventStore } from "./table-helpers.ts";
28
+ export { setupTestStack, type TestStack, type TestStackOptions } from "./test-stack.ts";
@@ -1,6 +1,6 @@
1
1
  // @runtime dev
2
2
  import { generateId } from "@cosmicdrift/kumiko-framework/utils";
3
- import { requireEnv } from "./db.js";
3
+ import { requireEnv } from "./db.ts";
4
4
 
5
5
  export type TestRedis = {
6
6
  redis: import("ioredis").Redis;
@@ -50,11 +50,11 @@ import type { SearchAdapter } from "@cosmicdrift/kumiko-framework/search";
50
50
  import { createInMemorySearchAdapter } from "@cosmicdrift/kumiko-framework/search";
51
51
  import { propertyOf } from "@cosmicdrift/kumiko-types/type-guards";
52
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";
53
+ import { createTestDb } from "./db.ts";
54
+ import { createJobFailureTracker, drainJobs } from "./drain-jobs.ts";
55
+ import { createEventCollector, type EventCollector } from "./event-collector.ts";
56
+ import { createTestRedis, queueNamePrefixForTestRedis, type TestRedis } from "./redis.ts";
57
+ import { createRequestHelper, type RequestHelper } from "./request-helper.ts";
58
58
 
59
59
  export type TestStack = {
60
60
  app: Hono;
@@ -279,6 +279,29 @@ export type TestStackOptions = {
279
279
 
280
280
  const DEFAULT_JWT_SECRET = "test-stack-secret-minimum-32-characters!!";
281
281
 
282
+ type MfaTokenSecretBinder = (
283
+ masterSecret: string,
284
+ env: Readonly<Record<string, string | undefined>>,
285
+ ) => void;
286
+
287
+ // Mirrors bindMfaTokenSecretsFromFeature from bundled-features/auth-mfa,
288
+ // which this package cannot import: bundled-features is an optional peer.
289
+ function isMfaTokenSecretBinder(value: unknown): value is MfaTokenSecretBinder {
290
+ return typeof value === "function";
291
+ }
292
+
293
+ // The env is empty on purpose: test secrets derive from jwtSecret,
294
+ // independent of the host env.
295
+ function bindMfaTokenSecrets(features: readonly FeatureDefinition[], jwtSecret: string): void {
296
+ for (const { exports } of features) {
297
+ if (typeof exports !== "object" || exports === null || !("bindTokenSecrets" in exports)) {
298
+ continue;
299
+ }
300
+ const { bindTokenSecrets } = exports;
301
+ if (isMfaTokenSecretBinder(bindTokenSecrets)) bindTokenSecrets(jwtSecret, {});
302
+ }
303
+ }
304
+
282
305
  export async function setupTestStack(options: TestStackOptions): Promise<TestStack> {
283
306
  const jwtSecret = options.jwtSecret ?? DEFAULT_JWT_SECRET;
284
307
  const enabledHooks = options.systemHooks ?? ["sse", "search"];
@@ -298,6 +321,7 @@ export async function setupTestStack(options: TestStackOptions): Promise<TestSta
298
321
  // Nav/workspace refs fail the prod boot too; the subset check tolerates refs into unmounted features.
299
322
  validateNavBootSubset(options.features);
300
323
  if (options.validateBoot === "full") validateBoot(options.features);
324
+ bindMfaTokenSecrets(options.features, jwtSecret);
301
325
 
302
326
  // Forward db-name/persistent-flag through to createTestDb. The
303
327
  // defaults (undefined dbName, persistent:false) keep the legacy