@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 +10 -5
- package/package.json +20 -6
- package/src/bunfig.ts +70 -4
- package/src/changes.json +31 -0
- package/src/e2e/define-app-e2e-config.ts +8 -1
- package/src/e2e/seed-contract.ts +3 -0
- package/src/e2e/seed-route.ts +6 -1
- package/src/e2e/seeded-tenant-fixture.ts +12 -2
- package/src/integration-runner.ts +4 -1
- package/src/preload/dom.ts +143 -0
- package/src/seed-tenant.ts +6 -2
- package/src/seed-types.ts +4 -1
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`
|
|
19
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
67
|
-
"@cosmicdrift/kumiko-dev-server": "0.
|
|
68
|
-
"@cosmicdrift/kumiko-framework": "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
|
-
"@
|
|
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
|
-
"@
|
|
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 =
|
|
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
|
-
|
|
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: `${
|
|
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
|
|
package/src/e2e/seed-contract.ts
CHANGED
|
@@ -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
|
|
package/src/e2e/seed-route.ts
CHANGED
|
@@ -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(
|
|
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
|
-
{
|
|
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
|
-
|
|
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
|
+
});
|
package/src/seed-tenant.ts
CHANGED
|
@@ -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
|
|
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: (
|
|
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
|
};
|