@cosmicdrift/kumiko-testing 0.315.0 → 0.317.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/README.md CHANGED
@@ -11,14 +11,19 @@ const persisted = await seedTenant(stack, { persist: true }); // real rows via t
11
11
  await tenant.api.writeOk("my-feature:write:thing:create", { title: "x" });
12
12
  ```
13
13
 
14
- - `preload/{temporal,ci-log,scrub-env,env,real}`: bunfig `preload` entries. `scrub-env` removes
14
+ - `preload/{temporal,ci-log,scrub-env,env,real,dom}`: bunfig `preload` entries. `scrub-env` removes
15
15
  `PROVIDER_ENV_KEYS` (unit default); `env` adds the localhost service defaults (integration);
16
- `real` refuses to run without `KUMIKO_REAL_PROVIDERS=1` and in CI, and keeps the keys.
16
+ `real` refuses to run without `KUMIKO_REAL_PROVIDERS=1` and in CI, and keeps the keys; `dom`
17
+ registers happy-dom (window/document/HTMLElement) while preserving Bun's native
18
+ fetch/Request/Response, and cleans up (`@testing-library/react` unmount, Radix DOM/style leaks,
19
+ `window.location`) after every test.
17
20
  - `kumiko-testing bunfig [--dom] [--coverage]`: writes `bunfig.toml`,
18
- `bunfig.integration.toml` and `bunfig.real.toml` (plus `bunfig.dom.toml`). `--dom` needs your own
19
- `./test-setup/dom.preload.ts`.
21
+ `bunfig.integration.toml` and `bunfig.real.toml` (plus `bunfig.dom.toml`). `--dom` adds
22
+ `preload/dom` (happy-dom + testing-library/react cleanup); `@happy-dom/global-registrator` and
23
+ `@testing-library/react` are optional peer dependencies, install them when you use `--dom`.
20
24
  - `kumiko-testing integration [--parallel N] [--timings <file>]`: runs `*.integration.test.ts`
21
- with the 15s budget. No `--no-isolate`; `--parallel` only when you ask for it.
25
+ with the 15s budget. `--parallel` only when you ask for it, and then with `--no-isolate`:
26
+ bun 1.4.0's implicit `--isolate` leaks native memory per file until CI workers are OOM-killed.
22
27
 
23
28
  ## E2E (`./e2e`, `./e2e/seed-route`)
24
29
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-testing",
3
- "version": "0.315.0",
3
+ "version": "0.317.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>",
@@ -46,6 +46,10 @@
46
46
  "types": "./src/preload/real.ts",
47
47
  "default": "./src/preload/real.ts"
48
48
  },
49
+ "./preload/dom": {
50
+ "types": "./src/preload/dom.ts",
51
+ "default": "./src/preload/dom.ts"
52
+ },
49
53
  "./scaffold": {
50
54
  "types": "./src/scaffold.ts",
51
55
  "default": "./src/scaffold.ts"
@@ -63,21 +67,31 @@
63
67
  "kumiko-testing": "./bin/kumiko-testing.ts"
64
68
  },
65
69
  "dependencies": {
66
- "@cosmicdrift/kumiko-bundled-features": "0.315.0",
67
- "@cosmicdrift/kumiko-dev-server": "0.315.0",
68
- "@cosmicdrift/kumiko-framework": "0.315.0",
70
+ "@cosmicdrift/kumiko-bundled-features": "0.317.0",
71
+ "@cosmicdrift/kumiko-dev-server": "0.317.0",
72
+ "@cosmicdrift/kumiko-framework": "0.317.0",
69
73
  "zod": "^4.4.3"
70
74
  },
71
75
  "peerDependencies": {
72
- "@playwright/test": "^1.62.1"
76
+ "@happy-dom/global-registrator": "^20.11.2",
77
+ "@playwright/test": "^1.62.1",
78
+ "@testing-library/react": "^16.3.2"
73
79
  },
74
80
  "peerDependenciesMeta": {
81
+ "@happy-dom/global-registrator": {
82
+ "optional": true
83
+ },
75
84
  "@playwright/test": {
76
85
  "optional": true
86
+ },
87
+ "@testing-library/react": {
88
+ "optional": true
77
89
  }
78
90
  },
79
91
  "devDependencies": {
80
- "@playwright/test": "^1.62.1"
92
+ "@happy-dom/global-registrator": "^20.11.2",
93
+ "@playwright/test": "^1.62.1",
94
+ "@testing-library/react": "^16.3.2"
81
95
  },
82
96
  "publishConfig": {
83
97
  "registry": "https://registry.npmjs.org",
package/src/bunfig.ts CHANGED
@@ -25,7 +25,7 @@ export type BunfigOptions = {
25
25
  };
26
26
 
27
27
  const PRELOAD_PREFIX = "@cosmicdrift/kumiko-testing/preload";
28
- const DOM_PRELOAD = "./test-setup/dom.preload.ts";
28
+ const DOM_PRELOAD = `${PRELOAD_PREFIX}/dom`;
29
29
 
30
30
  const PRELOADS = {
31
31
  unit: [`${PRELOAD_PREFIX}/temporal`, `${PRELOAD_PREFIX}/scrub-env`],
@@ -125,6 +125,57 @@ export type BunfigMergeResult =
125
125
  | { readonly ok: true; readonly content: string }
126
126
  | { readonly ok: false; readonly unknownKeys: readonly UnknownBunfigKey[] };
127
127
 
128
+ // Unlike an unknown key, which blocks the merge, an array entry the template
129
+ // stops emitting stays on disk silently until the generator excludes it by
130
+ // name, as it does below for the superseded DOM preload path.
131
+ const MERGED_ARRAY_KEYS = ["preload", "pathIgnorePatterns", "coveragePathIgnorePatterns"] as const;
132
+ type MergedArrayKey = (typeof MERGED_ARRAY_KEYS)[number];
133
+
134
+ const CURRENT_PACKAGE_PRELOADS = new Set<string>([...Object.values(PRELOADS).flat(), DOM_PRELOAD]);
135
+
136
+ // Old app-local path of DOM_PRELOAD; excluded so an app that hasn't
137
+ // regenerated doesn't run DOM setup twice.
138
+ const SUPERSEDED_PRELOADS = new Set<string>(["./test-setup/dom.preload.ts"]);
139
+
140
+ function isKeepableExtra(key: MergedArrayKey, entry: string): boolean {
141
+ if (key !== "preload") return true;
142
+ if (SUPERSEDED_PRELOADS.has(entry)) return false;
143
+ if (entry.startsWith(PRELOAD_PREFIX)) return CURRENT_PACKAGE_PRELOADS.has(entry);
144
+ return true;
145
+ }
146
+
147
+ function parseTestSection(content: string): Readonly<Record<string, unknown>> {
148
+ const parsed = Bun.TOML.parse(content) as { test?: Record<string, unknown> };
149
+ return parsed.test ?? {};
150
+ }
151
+
152
+ function arrayField(
153
+ testSection: Readonly<Record<string, unknown>>,
154
+ key: MergedArrayKey,
155
+ ): readonly string[] {
156
+ const value = testSection[key];
157
+ if (typeof value === "string") return [value];
158
+ return Array.isArray(value)
159
+ ? value.filter((item): item is string => typeof item === "string")
160
+ : [];
161
+ }
162
+
163
+ // TOML comments attached to an extra on disk don't survive: only the parsed
164
+ // string value is carried over, not the source lines around it.
165
+ function appendArrayExtras(
166
+ generated: string,
167
+ key: MergedArrayKey,
168
+ extras: readonly string[],
169
+ ): string {
170
+ if (extras.length === 0) return generated;
171
+ const extraLines = extras.map((item) => ` ${JSON.stringify(item)},`).join("\n");
172
+ const pattern = new RegExp(`^(${key} = \\[\\n)([\\s\\S]*?)(\\n\\])`, "m");
173
+ return generated.replace(
174
+ pattern,
175
+ (_match, open: string, body: string, close: string) => `${open}${body}\n${extraLines}${close}`,
176
+ );
177
+ }
178
+
128
179
  /**
129
180
  * Merges freshly generated bunfig content with whatever an app already has
130
181
  * on disk. A section this run's `generated` output doesn't touch at all
@@ -134,7 +185,11 @@ export type BunfigMergeResult =
134
185
  * template doesn't own this run is never silently dropped. A key inside a
135
186
  * section the template DOES emit that isn't in this run's own version of
136
187
  * that section (e.g. `concurrency`, `timeout`, kumiko-framework#3120) fails
137
- * the merge instead of silently disappearing.
188
+ * the merge instead of silently disappearing. Within an array key the
189
+ * template does emit on both sides (`preload`, `pathIgnorePatterns`,
190
+ * `coveragePathIgnorePatterns`), entries the app appended on top of the
191
+ * generated ones survive the regeneration instead of being overwritten with
192
+ * the generated array alone.
138
193
  */
139
194
  export function mergeBunfig(generated: string, existingContent: string): BunfigMergeResult {
140
195
  const generatedKeysBySection = new Map(
@@ -154,11 +209,22 @@ export function mergeBunfig(generated: string, existingContent: string): BunfigM
154
209
  }
155
210
  }
156
211
  if (unknownKeys.length > 0) return { ok: false, unknownKeys };
157
- if (foreignBlocks.length === 0) return { ok: true, content: generated };
212
+ const generatedTest = parseTestSection(generated);
213
+ const existingTest = parseTestSection(existingContent);
214
+ let content = generated;
215
+ for (const key of MERGED_ARRAY_KEYS) {
216
+ const generatedValues = arrayField(generatedTest, key);
217
+ const existingValues = arrayField(existingTest, key);
218
+ const extras = existingValues.filter(
219
+ (item) => !generatedValues.includes(item) && isKeepableExtra(key, item),
220
+ );
221
+ content = appendArrayExtras(content, key, extras);
222
+ }
223
+ if (foreignBlocks.length === 0) return { ok: true, content };
158
224
  const foreignText = foreignBlocks
159
225
  .map((block) => `[${block.header}]\n${block.lines.join("\n").replace(/\n+$/, "")}\n`)
160
226
  .join("\n");
161
- return { ok: true, content: `${generated}\n${foreignText}` };
227
+ return { ok: true, content: `${content}\n${foreignText}` };
162
228
  }
163
229
 
164
230
  export function renderBunfigFiles(opts: BunfigOptions = {}): Readonly<Record<string, string>> {
package/src/changes.json CHANGED
@@ -1,4 +1,35 @@
1
1
  [
2
+ {
3
+ "version": "0.317.0",
4
+ "type": "fix",
5
+ "title": "mergeBunfig keeps app-local array extras instead of overwriting them",
6
+ "detail": "mergeBunfig only checked whether a key such as `preload` existed on both\nsides of the merge, not whether its array value matched. Regenerating a\nbunfig therefore silently replaced an app's `[test].preload`,\n`pathIgnorePatterns` or `coveragePathIgnorePatterns` array with the\ngenerated one, dropping any entry the app had appended by hand (measured\nagainst publicstatus's real bunfig.toml, which carries exactly this case\nfor env.preload.ts/codegen.preload.ts and says so in a comment).\nmergeBunfig now diffs the parsed array for each of these three keys and\nappends entries only present on disk after the generated ones, so\n`kumiko-testing bunfig` is safe to rerun on an app that extended one of\nthem. A `./test-setup/dom.preload.ts` entry left over from before the\nDOM preload became a package export (see the DOM-preload-package\nchangeset in this same release) is not carried forward as an extra,\nsince keeping it would preload the same DOM setup twice.\nA package preload an app deliberately adds from another variant (say\n`preload/env` in its unit bunfig) is kept; an unrecognized or superseded\npackage preload path is dropped instead of kept as an extra, so a rename\ninside the package doesn't leave both the old and new path preloaded. A\npreload written as a single string rather than an array is read the same\nas a one-item array.\nTwo known limits: a comment attached to an extra in the source bunfig is\nnot preserved, only the value; and the template can no longer remove a\n`pathIgnorePatterns`/`coveragePathIgnorePatterns` entry from a consumer,\nsince either key has no equivalent to preload's exclusion list, so a\npattern it stops emitting stays in every consumer's bunfig until removed\nby hand. Unlike an unknown key, which blocks the merge, a stale array\nentry is kept silently rather than failing loudly."
7
+ },
8
+ {
9
+ "version": "0.317.0",
10
+ "type": "breaking",
11
+ "title": "bunfig --dom preloads the package's own DOM setup instead of an app-local file",
12
+ "detail": "The package now exports `./preload/dom`, the happy-dom GlobalRegistrator\nsetup (IS_REACT_ACT_ENVIRONMENT, Bun's native fetch/Request/Response\npreserved over happy-dom's broken cookie header, testing-library/react\ncleanup, Radix pointer-capture/scrollIntoView stubs, the inspect-size\nlimit for waitFor) that each app using --dom previously copied into its\nown ./test-setup/dom.preload.ts. `kumiko-testing bunfig --dom` now generates\nbunfig.dom.toml with @cosmicdrift/kumiko-testing/preload/dom instead of\nthe app-local path. @happy-dom/global-registrator and\n@testing-library/react are optional peer dependencies, same as\n@playwright/test for ./e2e. This reverses kumiko-framework#3128, which\nkept the file as an app-local copy because at the time it was\nworkspace-internal, not a published contract. In practice the copies\nnever diverged on purpose, only by drift: publicstatus, phronexsis and\nkumiko-enterprise carried older, partial copies of the framework's own\ntest-setup/dom.preload.ts (missing the inspect-size fix for #3082), and\nonly offlot-app matched the framework's code (comments reworded, no\nfunctional diff). The framework's own copy is now deleted; the\nframework runs the same package preload it ships.",
13
+ "migration": "In every bunfig file that lists \"./test-setup/dom.preload.ts\"\n(publicstatus, phronexsis, offlot-app and kumiko-enterprise each have\nexactly one, their bunfig.dom.toml), replace that line with\n\"@cosmicdrift/kumiko-testing/preload/dom\", then delete the now-\nunreferenced ./test-setup/dom.preload.ts. Ensure\n@happy-dom/global-registrator and @testing-library/react are in the\napp's devDependencies (all four already have both). Running\n`kumiko-testing bunfig --dom` instead also works: mergeBunfig now keeps\nan app-local extra preload (publicstatus's\nenv.preload.ts/codegen.preload.ts) across the regen instead of dropping\nit, and treats an existing \"./test-setup/dom.preload.ts\" entry as\nsuperseded rather than an extra, so it doesn't end up preloaded twice\nalongside the new package path. The generated preload array's own\nentries stay first; a carried-over extra lands after them, which can\nmove it relative to entries that used to sit between the template's\npreloads and the app's own (publicstatus: dom.preload.ts used to run\nlast, after env/codegen; the package preload now runs before them)."
14
+ },
15
+ {
16
+ "version": "0.316.0",
17
+ "type": "fix",
18
+ "title": "Parallel integration runs no longer OOM-kill their workers",
19
+ "detail": "bun 1.4.0's --parallel implies --isolate, which leaks native memory per\ntest file while the JS heap stays flat: about 4 MB per file for a bare\nsetupTestStack, about 40 MB with bundled-features. In publicstatus\n(--parallel 4) each worker grew from ~290 to ~1000 MiB and the run died\nwith exit 137 on the 3 GiB CI runner. With --no-isolate the same run\npeaks at 380 MiB and finishes in 28.9 s instead of 49.9 s, with no new\nfailures. Without --parallel bun never isolated files, so the tests\nalready isolate through data (seedTenant per flow, queue prefix per\nstack), not through processes."
20
+ },
21
+ {
22
+ "version": "0.316.0",
23
+ "type": "fix",
24
+ "title": "A dedicated screenshots Playwright config without SCREENSHOT_DIR now fails with a clear message",
25
+ "detail": "A dedicated screenshots config (testDir: \"./e2e/screenshots\", used by\nphronexsis and publicstatus) had its entire testDir excluded by\nscreenshotSpecsIgnore()'s \"**/screenshots/**\" pattern whenever\nSCREENSHOT_DIR was unset, so Playwright reported the generic \"No tests\nfound\" with no mention of the missing env var — even though\nrequireScreenshotDir() already existed with a clear message, just never\ncalled on this path. defineAppE2eConfig now calls it up front for any\ntestDir ending in \"screenshots\" outside a screenshot run."
26
+ },
27
+ {
28
+ "version": "0.316.0",
29
+ "type": "improvement",
30
+ "title": "tenant.addUser accepts an optional displayName/email identity",
31
+ "detail": "seedTenant()'s admin already accepted `admin: { displayName, email }`, but\n`tenant.addUser(roles)` (the seed-user route and the in-process seedTenant\nalike) had no such parameter and always fell back to `Seed <hash>` /\n`user-<uuid>@example.test`, which then leaked into screenshots and docs\n(solon, money-horse). `addUser(roles, { displayName, email })` now threads\nan optional identity through to the created user row, the same\n`SeedAdminIdentity` shape the admin already uses. Also: the seedTenant\nfixture logging the shared Playwright `context` in as the tenant admin was\npreviously documented only in a code comment in auth-kit.ts — it is now on\nthe `SeedTenantFixture` type's own JSDoc."
32
+ },
2
33
  {
3
34
  "version": "0.314.0",
4
35
  "type": "improvement",
@@ -20,7 +20,7 @@ import {
20
20
  SEED_TOKEN_ENV,
21
21
  STYLESHEET_WATCH_ENV,
22
22
  } from "./constants";
23
- import { isScreenshotRun, screenshotSpecsIgnore } from "./screenshot-dir";
23
+ import { isScreenshotRun, requireScreenshotDir, screenshotSpecsIgnore } from "./screenshot-dir";
24
24
  import { E2E_TIMEOUT_MS } from "./timeouts";
25
25
 
26
26
  // Apps pick Meilisearch over their in-memory search adapter just because
@@ -146,6 +146,13 @@ export function defineAppE2eConfig(input: AppE2eConfigInput): PlaywrightTestConf
146
146
  } = input;
147
147
  assertNoReservedEnv(env);
148
148
  for (const project of projects) assertTemplateOwnedKeysUnset(project);
149
+ // A dedicated screenshots config (testDir: "./e2e/screenshots") has its
150
+ // entire testDir excluded by screenshotSpecsIgnore() until SCREENSHOT_DIR
151
+ // is set, which Playwright reports as a generic "No tests found" with no
152
+ // mention of the missing env var.
153
+ if (!isScreenshotRun() && /(?:^|\/)screenshots\/?$/.test(testDir)) {
154
+ requireScreenshotDir();
155
+ }
149
156
  const baseURL = `http://localhost:${port}`;
150
157
  const realRun = isRealProviderRun();
151
158
 
@@ -60,6 +60,9 @@ export function createSeedUserRequestSchema(extraRoles: readonly string[] = [])
60
60
  )
61
61
  .min(1)
62
62
  .max(seedable.length),
63
+ displayName: z.string().min(1).max(MAX_DISPLAY_NAME_LENGTH).optional(),
64
+ // May contain the literal "{tenantId}" placeholder, same as seedAdminIdentitySchema.
65
+ email: z.string().min(1).max(MAX_EMAIL_LENGTH).optional(),
63
66
  });
64
67
  }
65
68
 
@@ -206,7 +206,12 @@ export function createE2eSeedRoutes(
206
206
  const write: SeedWriter = async (handlerQn, payload, tenantId) =>
207
207
  unwrapSavedRow(handlerQn, await deps.dispatchSystemWrite({ handlerQn, payload, tenantId }));
208
208
  try {
209
- return c.json(await persistUserRows(write, verified.tenantId, verified.roles));
209
+ return c.json(
210
+ await persistUserRows(write, verified.tenantId, verified.roles, {
211
+ displayName: verified.displayName,
212
+ email: verified.email,
213
+ }),
214
+ );
210
215
  } catch (error) {
211
216
  return c.json({ error: error instanceof Error ? error.message : "seed failed" }, 500);
212
217
  }
@@ -36,6 +36,11 @@ export type E2eSeededTenant = SeededTenant & {
36
36
  readonly seed: (seeder: string, body?: unknown) => Promise<unknown>;
37
37
  };
38
38
 
39
+ /**
40
+ * Seeds a tenant over HTTP and logs the shared `context` in as its admin, so
41
+ * a `page` opened from that context is already authenticated — call
42
+ * `loginAs` to switch it to another seeded user instead.
43
+ */
39
44
  export type SeedTenantFixture = (opts?: E2eSeedTenantOptions) => Promise<E2eSeededTenant>;
40
45
 
41
46
  type E2eFixtures = {
@@ -125,12 +130,17 @@ export async function provideSeedTenant(
125
130
  name: seeded.name,
126
131
  admin,
127
132
  members: seeded.members.map((member) => toUser(member, [ROLES.Member])),
128
- addUser: async (roles = [ROLES.Member]) =>
133
+ addUser: async (roles = [ROLES.Member], identity = {}) =>
129
134
  toUser(
130
135
  await postSeedRoute(
131
136
  request,
132
137
  SEED_ROUTES.seedUser,
133
- { tenantId, roles: [...roles] } satisfies SeedUserRequest,
138
+ {
139
+ tenantId,
140
+ roles: [...roles],
141
+ displayName: identity.displayName,
142
+ email: identity.email,
143
+ } satisfies SeedUserRequest,
134
144
  seedUserResponseSchema,
135
145
  ),
136
146
  roles,
@@ -29,7 +29,10 @@ export function buildIntegrationTestArgs(opts: IntegrationRunOptions): string[]
29
29
  "test",
30
30
  `--config=${BUNFIG_FILES.integration}`,
31
31
  `--timeout=${TEST_TIMEOUT_MS.integration}`,
32
- ...(opts.parallel !== undefined ? [`--parallel=${opts.parallel}`] : []),
32
+ // bun 1.4.0's --parallel implies --isolate, which leaks native memory per test file
33
+ // (~40-60 MB with bundled-features) until the workers blow the CI runner's limit.
34
+ // Tests isolate through data (seedTenant per flow, queue prefix per stack), not processes.
35
+ ...(opts.parallel !== undefined ? [`--parallel=${opts.parallel}`, "--no-isolate"] : []),
33
36
  ...(opts.timings !== undefined ? [`--timings=${opts.timings}`] : []),
34
37
  ...(opts.updateTimings === true ? ["--update-timings"] : []),
35
38
  ...opts.files,
@@ -0,0 +1,143 @@
1
+ // DOM polyfill for tests that use testing-library/react or Radix components.
2
+ // happy-dom's global-registrator attaches window/document/HTMLElement to
3
+ // globalThis. Plain Node tests are unaffected, no code path changes for them.
4
+
5
+ import { afterEach } from "bun:test";
6
+ import { GlobalRegistrator } from "@happy-dom/global-registrator";
7
+
8
+ declare global {
9
+ // React's own typings don't declare this property on globalThis.
10
+ var IS_REACT_ACT_ENVIRONMENT: boolean | undefined;
11
+ }
12
+
13
+ // react-dom checks IS_REACT_ACT_ENVIRONMENT to suppress act() warnings.
14
+ // vitest set this automatically via @testing-library/react; with bun:test we
15
+ // set it explicitly.
16
+ globalThis.IS_REACT_ACT_ENVIRONMENT = true;
17
+
18
+ // Register idempotently, so a second preload entry doesn't crash.
19
+ if (typeof globalThis.window === "undefined") {
20
+ // Preserve Bun's native fetch/Request/Response/Headers before happy-dom
21
+ // overwrites them. happy-dom ships its own Request implementation whose
22
+ // headers.get("cookie") returns null, which breaks Hono's getCookie() in
23
+ // every auth/csrf/sse test. We only want the DOM globals (window,
24
+ // document, HTMLElement) from happy-dom, not the fetch API.
25
+ const bunRequest = globalThis.Request;
26
+ const bunResponse = globalThis.Response;
27
+ const bunHeaders = globalThis.Headers;
28
+ const bunFetch = globalThis.fetch;
29
+ const bunAbortController = globalThis.AbortController;
30
+ const bunAbortSignal = globalThis.AbortSignal;
31
+ const bunWritableStream = globalThis.WritableStream;
32
+ const bunTransformStream = globalThis.TransformStream;
33
+ // The url option sets window.location to http://localhost/. Without it
34
+ // happy-dom defaults to about:blank, so history.pushState/replaceState
35
+ // doesn't work (invalid origin) and window.location.pathname stays
36
+ // "blank", breaking every router/nav test.
37
+ GlobalRegistrator.register({ url: "http://localhost/" });
38
+ globalThis.Request = bunRequest;
39
+ globalThis.Response = bunResponse;
40
+ globalThis.Headers = bunHeaders;
41
+ globalThis.fetch = bunFetch;
42
+ globalThis.AbortController = bunAbortController;
43
+ globalThis.AbortSignal = bunAbortSignal;
44
+ globalThis.WritableStream = bunWritableStream;
45
+ globalThis.TransformStream = bunTransformStream;
46
+ }
47
+
48
+ // @testing-library/dom/dist/screen.js checks document.body at module import
49
+ // time. A static import would evaluate screen before happy-dom registers, so
50
+ // every screen query would throw TypeError. Hence require() only after the
51
+ // registration above.
52
+ const { cleanup } = require("@testing-library/react/pure") as {
53
+ cleanup: () => void;
54
+ };
55
+
56
+ const HTML_PRINT_LIMIT = 2000;
57
+
58
+ // Pointer-capture APIs are missing in happy-dom, same as in jsdom. Radix-UI
59
+ // (DropdownMenu/Select/Popover triggers) calls them, so nothing opens in
60
+ // tests without the polyfill.
61
+ if (typeof globalThis.HTMLElement !== "undefined") {
62
+ const proto = globalThis.HTMLElement.prototype as unknown as Record<string | symbol, unknown>;
63
+ if (proto["hasPointerCapture"] === undefined) proto["hasPointerCapture"] = () => false;
64
+ if (proto["setPointerCapture"] === undefined) proto["setPointerCapture"] = () => undefined;
65
+ if (proto["releasePointerCapture"] === undefined)
66
+ proto["releasePointerCapture"] = () => undefined;
67
+ if (proto["scrollIntoView"] === undefined) proto["scrollIntoView"] = () => undefined;
68
+
69
+ // Without this, printing a happy-dom node walks its whole object graph:
70
+ // ownerDocument plus every React fiber property, which is 15 MB of string
71
+ // for a two-element tree and 0.5-1.7 s per call. A failed assertion inside
72
+ // waitFor pays that on every poll and blocks the loop long enough to starve
73
+ // React's commit and waitFor's own timeout (#3082).
74
+ const inspect = Symbol.for("nodejs.util.inspect.custom");
75
+ if (proto[inspect] === undefined) {
76
+ proto[inspect] = function (this: HTMLElement): string {
77
+ const html = this.outerHTML;
78
+ return html.length > HTML_PRINT_LIMIT ? `${html.slice(0, HTML_PRINT_LIMIT)}…` : html;
79
+ };
80
+ }
81
+ }
82
+
83
+ // Auto-cleanup after every test (DOM pollution guard): bun test runs all
84
+ // test files in one process, so without afterEach, React components from
85
+ // file N stay mounted in file N+1's DOM.
86
+ //
87
+ // FIVE leak sources:
88
+ //
89
+ // a) testing-library/react container: cleanup() unmounts and removes
90
+ // every container node render() created.
91
+ //
92
+ // b) body.replaceChildren(): clears containers not created via
93
+ // testing-library/react (e.g. #root via ReactDOM.createRoot +
94
+ // renderShell). Must run after cleanup(), since React needs its nodes
95
+ // to unmount.
96
+ //
97
+ // c) Radix DismissableLayer sets body.style.pointerEvents='none' when
98
+ // opening a Dialog/Popover/Dropdown.
99
+ //
100
+ // d) Radix-injected style tags in head.
101
+ //
102
+ // e) window.location / history.pushState.
103
+ afterEach(() => {
104
+ // skip: this preload module runs its own afterEach unconditionally; plain
105
+ // unit test files (no --dom) never get happy-dom registered, so document
106
+ // stays undefined and there is nothing to clean up.
107
+ if (typeof globalThis.document === "undefined") return;
108
+
109
+ // (a) React cleanup first: unmounts every testing-library-rendered
110
+ // component via ReactDOM.unmountComponentAtNode. Must happen before
111
+ // any DOM manipulation because React needs its nodes.
112
+ cleanup();
113
+
114
+ const doc = globalThis.document;
115
+ // skip: happy-dom always creates body, but a defensive guard keeps this
116
+ // afterEach from throwing if a future registration option removes it.
117
+ if (!doc.body) return;
118
+
119
+ // (b) Remaining nodes are not actively removed: cleanup() from
120
+ // testing-library/react clears all render() containers. Non-standard
121
+ // containers (#root via createRoot) must be cleaned by the tests
122
+ // themselves or via afterEach in the test file. body.replaceChildren()
123
+ // triggers async React effects without an act context.
124
+
125
+ // (c) Radix leak: reset the body inline style
126
+ doc.body.style.pointerEvents = "";
127
+
128
+ // (d) Radix-injected style tags
129
+ for (const el of doc.head.querySelectorAll("style")) {
130
+ const style = el as HTMLStyleElement;
131
+ if (style.id?.startsWith("radix-")) {
132
+ style.remove();
133
+ }
134
+ }
135
+
136
+ // (e) Reset window.location to happy-dom's initial url. replaceState
137
+ // instead of pushState, otherwise a history stack of 3000+ entries
138
+ // accumulates across all tests (memory leak, plus it breaks tests that
139
+ // check history.length).
140
+ if (typeof globalThis.history !== "undefined") {
141
+ globalThis.history.replaceState(null, "", "http://localhost/");
142
+ }
143
+ });
@@ -184,9 +184,13 @@ export async function seedTenant(
184
184
  name: rows.name,
185
185
  admin,
186
186
  members: rows.members.map((member) => withSession(member, id, [ROLES.Member])),
187
- addUser: async (roles = [ROLES.Member]) =>
187
+ addUser: async (roles = [ROLES.Member], identity = {}) =>
188
188
  withSession(
189
- persist ? await persistUserRows(write, id, roles) : lightCredentials(),
189
+ persist
190
+ ? await persistUserRows(write, id, roles, identity)
191
+ : lightCredentials(
192
+ identity.email !== undefined ? substituteTenantId(identity.email, id) : undefined,
193
+ ),
190
194
  id,
191
195
  roles,
192
196
  ),
package/src/seed-types.ts CHANGED
@@ -45,7 +45,10 @@ export type SeededTenant = {
45
45
  readonly name: string;
46
46
  readonly admin: SeededUser;
47
47
  readonly members: readonly SeededUser[];
48
- readonly addUser: (roles?: readonly string[]) => Promise<SeededUser>;
48
+ readonly addUser: (
49
+ roles?: readonly string[],
50
+ identity?: SeedAdminIdentity,
51
+ ) => Promise<SeededUser>;
49
52
  readonly api: BoundApi;
50
53
  readonly apiAs: (user: SeededUser) => BoundApi;
51
54
  };