@cosmicdrift/kumiko-testing 0.369.0 → 0.371.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.371.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.371.0",
99
+ "@cosmicdrift/kumiko-types": "0.371.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.371.0",
132
+ "@cosmicdrift/kumiko-dev-server": "0.371.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,28 @@
1
1
  [
2
+ {
3
+ "version": "0.371.0",
4
+ "type": "improvement",
5
+ "title": "Scaffold test:dom passes without test files",
6
+ "detail": "The scaffold sets test:dom to pass without test files and adds the devDependencies @testing-library/react, @testing-library/dom and @happy-dom/global-registrator."
7
+ },
8
+ {
9
+ "version": "0.370.0",
10
+ "type": "improvement",
11
+ "title": "setupTestStack binds the MFA token secrets",
12
+ "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."
13
+ },
14
+ {
15
+ "version": "0.370.0",
16
+ "type": "improvement",
17
+ "title": "renderTestSetup writes the standard test scripts, bunfig.dom.toml and the helper homes",
18
+ "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."
19
+ },
20
+ {
21
+ "version": "0.370.0",
22
+ "type": "improvement",
23
+ "title": "createTestStoredEvent builds a StoredEvent fixture",
24
+ "detail": "createTestStoredEvent(overrides) from @cosmicdrift/kumiko-testing/helpers returns a complete StoredEvent with stable defaults, for hook tests that construct SaveContext or DeleteContext directly."
25
+ },
2
26
  {
3
27
  "version": "0.365.0",
4
28
  "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,10 @@ 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";
26
+ const TESTING_LIBRARY_REACT_VERSION = "^16.3.2";
27
+ const TESTING_LIBRARY_DOM_VERSION = "^10.4.1";
24
28
 
25
29
  const TEST_RUNTIME_DIRECTIVE = "// @runtime test";
26
30
 
@@ -81,22 +85,8 @@ function renderE2eServer(appName: string): string {
81
85
  function renderE2eSmokeSpec(): string {
82
86
  return [
83
87
  TEST_RUNTIME_DIRECTIVE,
84
- 'import type { SeedPart } from "@cosmicdrift/kumiko-testing";',
85
88
  '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
- "}",
89
+ 'import { openTasks, withTask } from "./_helpers";',
100
90
  "",
101
91
  'test("a seeded task shows up on the tasks screen", async ({ seedTenant, page }) => {',
102
92
  ' const tenant = await seedTenant({ with: [withTask("Water the plants")] });',
@@ -117,6 +107,41 @@ function renderE2eSmokeSpec(): string {
117
107
  ].join("\n");
118
108
  }
119
109
 
110
+ function renderE2eHelpers(): string {
111
+ return [
112
+ TEST_RUNTIME_DIRECTIVE,
113
+ 'import type { SeedPart } from "@cosmicdrift/kumiko-testing";',
114
+ 'import type { Page } from "@playwright/test";',
115
+ "",
116
+ `const TASK_CREATE = ${JSON.stringify(TASK_CREATE)};`,
117
+ "",
118
+ "export function withTask(title: string): SeedPart {",
119
+ " return async ({ tenant }) => {",
120
+ ' await tenant.api.writeOk(TASK_CREATE, { title, status: "todo", priority: 1 });',
121
+ " };",
122
+ "}",
123
+ "",
124
+ "export async function openTasks(page: Page): Promise<void> {",
125
+ ' await page.goto("/");',
126
+ ' await page.getByRole("link", { name: "Tasks" }).first().click();',
127
+ "}",
128
+ "",
129
+ ].join("\n");
130
+ }
131
+
132
+ function renderIntegrationSetup(): string {
133
+ return [
134
+ 'import { setupAppTestStack } from "@cosmicdrift/kumiko-testing";',
135
+ 'import type { TestStack } from "@cosmicdrift/kumiko-testing/stack";',
136
+ 'import { tasksFeature } from "../features/tasks";',
137
+ "",
138
+ "export function setupTasksStack(): Promise<TestStack> {",
139
+ " return setupAppTestStack([tasksFeature]);",
140
+ "}",
141
+ "",
142
+ ].join("\n");
143
+ }
144
+
120
145
  function renderUnitTest(): string {
121
146
  return [
122
147
  'import { describe, expect, test } from "bun:test";',
@@ -140,9 +165,9 @@ function renderUnitTest(): string {
140
165
  function renderIntegrationTest(): string {
141
166
  return [
142
167
  'import { afterAll, beforeAll, describe, expect, test } from "bun:test";',
143
- 'import { seedTenant, setupAppTestStack } from "@cosmicdrift/kumiko-testing";',
168
+ 'import { seedTenant } from "@cosmicdrift/kumiko-testing";',
144
169
  'import type { TestStack } from "@cosmicdrift/kumiko-testing/stack";',
145
- 'import { tasksFeature } from "../features/tasks";',
170
+ 'import { setupTasksStack } from "./_setup";',
146
171
  "",
147
172
  `const TASK_CREATE = ${JSON.stringify(TASK_CREATE)};`,
148
173
  `const TASK_LIST = ${JSON.stringify(TASK_LIST)};`,
@@ -152,7 +177,7 @@ function renderIntegrationTest(): string {
152
177
  "let stack: TestStack;",
153
178
  "",
154
179
  "beforeAll(async () => {",
155
- " stack = await setupAppTestStack([tasksFeature]);",
180
+ " stack = await setupTasksStack();",
156
181
  "});",
157
182
  "",
158
183
  "afterAll(async () => {",
@@ -178,18 +203,25 @@ function renderIntegrationTest(): string {
178
203
 
179
204
  export function renderTestSetup(input: RenderTestSetupInput): ScaffoldTestSetup {
180
205
  const { appName, frameworkVersion, port = SCAFFOLD_E2E_PORT } = input;
206
+ const unitTestScript = `bun --config=${BUNFIG_FILES.unit} test --timeout=${TEST_TIMEOUT_MS.unit} --dots`;
207
+ const integrationTestScript = `bun kumiko-testing integration --parallel ${SCAFFOLD_INTEGRATION_PARALLEL}`;
181
208
  return {
182
209
  files: {
183
- ...renderBunfigFiles({ install: { linker: "hoisted" } }),
210
+ ...renderBunfigFiles({ install: { linker: "hoisted" }, dom: true }),
184
211
  "playwright.config.ts": renderPlaywrightConfig(port),
212
+ "e2e/_helpers/index.ts": renderE2eHelpers(),
185
213
  "e2e/server.ts": renderE2eServer(appName),
186
214
  "e2e/smoke.spec.ts": renderE2eSmokeSpec(),
215
+ "src/__tests__/_setup.ts": renderIntegrationSetup(),
187
216
  "src/__tests__/run-config.test.ts": renderUnitTest(),
188
217
  "src/__tests__/tasks.integration.test.ts": renderIntegrationTest(),
189
218
  },
190
219
  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}`,
220
+ test: unitTestScript,
221
+ "test:unit": unitTestScript,
222
+ "test:integration": integrationTestScript,
223
+ "test:integration:ci": integrationTestScript,
224
+ "test:dom": `bun --config=${BUNFIG_FILES.dom} test --timeout=${TEST_TIMEOUT_MS.unit} --dots --pass-with-no-tests .test.tsx`,
193
225
  // bunfig.real.toml's pathIgnorePatterns can only blacklist (no "!" negation,
194
226
  // verified against bun 1.4), so it can't express "match only *.real.test.ts"
195
227
  // on its own — every *.test.ts file matches bun's default test glob too,
@@ -201,10 +233,14 @@ export function renderTestSetup(input: RenderTestSetupInput): ScaffoldTestSetup
201
233
  "test:bunfig": "bun kumiko-testing bunfig --hoisted",
202
234
  e2e: "bunx --bun playwright test",
203
235
  "e2e:real": `${REAL_PROVIDERS_ENV}=1 bunx --bun playwright test`,
236
+ screenshots: "SCREENSHOT_DIR=docs/screenshots bunx --bun playwright test",
204
237
  },
205
238
  devDependencies: {
206
239
  "@cosmicdrift/kumiko-testing": frameworkVersion,
240
+ "@happy-dom/global-registrator": HAPPY_DOM_VERSION,
207
241
  "@playwright/test": PLAYWRIGHT_VERSION,
242
+ "@testing-library/dom": TESTING_LIBRARY_DOM_VERSION,
243
+ "@testing-library/react": TESTING_LIBRARY_REACT_VERSION,
208
244
  },
209
245
  rulesMarkdown: RULES_MARKDOWN,
210
246
  };
@@ -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