@cosmicdrift/kumiko-testing 0.334.0 → 0.335.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
@@ -22,7 +22,7 @@ await tenant.api.writeOk("my-feature:write:thing:create", { title: "x" });
22
22
  - `kumiko-testing bunfig [--dom] [--coverage]`: writes `bunfig.toml`,
23
23
  `bunfig.integration.toml` and `bunfig.real.toml` (plus `bunfig.dom.toml`). `--dom` adds
24
24
  `preload/dom` (happy-dom + testing-library/react cleanup); `@happy-dom/global-registrator` and
25
- `@testing-library/react` are optional peer dependencies, install them when you use `--dom`.
25
+ `@testing-library/react` (which needs `@testing-library/dom`) are optional peer dependencies, install them when you use `--dom`.
26
26
  - `kumiko-testing integration [--parallel N] [--timings <file>]`: runs `*.integration.test.ts`
27
27
  with the 15s budget. `--parallel` only when you ask for it, and then with `--no-isolate`:
28
28
  bun 1.4.0's implicit `--isolate` leaks native memory per file until CI workers are OOM-killed.
@@ -89,10 +89,14 @@ receives the seeded-tenant fixture too:
89
89
  ```ts
90
90
  { name: "dispatch", flow: async (page, { seedTenant }) => {
91
91
  const tenant = await seedTenant({ users: 1 });
92
- await tenant.loginAs(page, tenant.members[0]!);
92
+ const [member] = tenant.members;
93
+ if (!member) throw new Error("seedTenant returned no member");
94
+ await tenant.loginAs(page, member);
93
95
  await page.goto("/dispatch");
94
96
  },
95
- beforeCapture: async (page) => page.addStyleTag({ content: ".live-clock { visibility: hidden }" }),
97
+ beforeCapture: async (page) => {
98
+ await page.addStyleTag({ content: ".live-clock { visibility: hidden }" });
99
+ },
96
100
  }
97
101
  ```
98
102
 
@@ -3,10 +3,10 @@
3
3
 
4
4
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
5
5
  import { parseArgs } from "node:util";
6
- import { Glob } from "bun";
7
6
  import { BUNFIG_FILES, mergeBunfig, renderBunfigFiles } from "../src/bunfig";
8
7
  import {
9
8
  buildIntegrationTestArgs,
9
+ listIntegrationTestFiles,
10
10
  resolveRequestedIntegrationFiles,
11
11
  selectIntegrationFiles,
12
12
  } from "../src/integration-runner";
@@ -82,9 +82,7 @@ async function runIntegration(args: readonly string[]): Promise<number> {
82
82
  return 1;
83
83
  }
84
84
  } else {
85
- files = selectIntegrationFiles(
86
- await Array.fromAsync(new Glob("**/*.integration.test.ts").scan({ cwd: process.cwd() })),
87
- );
85
+ files = selectIntegrationFiles(listIntegrationTestFiles(process.cwd()));
88
86
  if (files.length === 0) {
89
87
  console.error("no *.integration.test.ts files found");
90
88
  return 1;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-testing",
3
- "version": "0.334.0",
3
+ "version": "0.335.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>",
@@ -75,14 +75,15 @@
75
75
  "kumiko-testing": "./bin/kumiko-testing.ts"
76
76
  },
77
77
  "dependencies": {
78
- "@cosmicdrift/kumiko-bundled-features": "0.334.0",
79
- "@cosmicdrift/kumiko-dev-server": "0.334.0",
80
- "@cosmicdrift/kumiko-framework": "0.334.0",
78
+ "@cosmicdrift/kumiko-bundled-features": "0.335.0",
79
+ "@cosmicdrift/kumiko-dev-server": "0.335.0",
80
+ "@cosmicdrift/kumiko-framework": "0.335.0",
81
81
  "zod": "^4.4.3"
82
82
  },
83
83
  "peerDependencies": {
84
84
  "@happy-dom/global-registrator": "^20.11.2",
85
85
  "@playwright/test": "^1.62.1",
86
+ "@testing-library/dom": "^10.4.1",
86
87
  "@testing-library/react": "^16.3.2"
87
88
  },
88
89
  "peerDependenciesMeta": {
@@ -92,6 +93,9 @@
92
93
  "@playwright/test": {
93
94
  "optional": true
94
95
  },
96
+ "@testing-library/dom": {
97
+ "optional": true
98
+ },
95
99
  "@testing-library/react": {
96
100
  "optional": true
97
101
  }
@@ -99,6 +103,7 @@
99
103
  "devDependencies": {
100
104
  "@happy-dom/global-registrator": "^20.11.2",
101
105
  "@playwright/test": "^1.62.1",
106
+ "@testing-library/dom": "^10.4.1",
102
107
  "@testing-library/react": "^16.3.2"
103
108
  },
104
109
  "publishConfig": {
package/src/bunfig.ts CHANGED
@@ -141,8 +141,20 @@ const CURRENT_PACKAGE_PRELOADS = new Set<string>([...Object.values(PRELOADS).fla
141
141
  // regenerated doesn't run DOM setup twice.
142
142
  const SUPERSEDED_PRELOADS = new Set<string>(["./test-setup/dom.preload.ts"]);
143
143
 
144
+ // Ignore patterns the generator owns across all variants. One it omits for
145
+ // this variant (`**/*.test.tsx` with --dom, `**/*.integration.test.ts` in the
146
+ // integration variant) is never a legit app extra: keeping it would silently
147
+ // skip the whole suite the variant exists to run.
148
+ const TEMPLATE_OWNED_IGNORE_PATTERNS = new Set<string>([
149
+ ...COVERAGE_IGNORED,
150
+ ...(Object.keys(PRELOADS) as BunfigVariant[]).flatMap((variant) => [
151
+ ...ignoredPaths(variant, false),
152
+ ...ignoredPaths(variant, true),
153
+ ]),
154
+ ]);
155
+
144
156
  function isKeepableExtra(key: MergedArrayKey, entry: string): boolean {
145
- if (key !== "preload") return true;
157
+ if (key !== "preload") return !TEMPLATE_OWNED_IGNORE_PATTERNS.has(entry);
146
158
  if (SUPERSEDED_PRELOADS.has(entry)) return false;
147
159
  if (entry.startsWith(PRELOAD_PREFIX)) return CURRENT_PACKAGE_PRELOADS.has(entry);
148
160
  return true;
package/src/changes.json CHANGED
@@ -1,4 +1,22 @@
1
1
  [
2
+ {
3
+ "version": "0.335.0",
4
+ "type": "fix",
5
+ "title": "Screenshot runner skips empty desktop passes, keeps regional locale tags and restores presented identities",
6
+ "detail": "Screenshot runner fixes: a desktop project whose viewports are all covered by device projects is now skipped with a reason instead of running empty. Locales that already carry a region (`pt-BR`, `en-GB`) keep their tag instead of becoming `pt-BR-BR`. `captureScreenshot` with `presentIdentities` restores the original DOM text and field values after the capture, so later assertions and form submits in the same spec see real data."
7
+ },
8
+ {
9
+ "version": "0.335.0",
10
+ "type": "fix",
11
+ "title": "bunfig merge drops stale template-owned ignore patterns; @testing-library/dom declared as optional peer",
12
+ "detail": "`kumiko-testing bunfig` drops ignore patterns the template owns but omits for the variant (such as `**/*.test.tsx` in a DOM bunfig) instead of keeping them as app extras, which silently skipped the whole suite. `@testing-library/dom` is declared as an optional peer dependency, since `@testing-library/react` needs it for `preload/dom`; apps using `--dom` install it alongside `@testing-library/react`."
13
+ },
14
+ {
15
+ "version": "0.335.0",
16
+ "type": "fix",
17
+ "title": "captureScreenshot fit content ignores non-shrinking scroll containers, integration discovery skips node_modules, apiAs retries a failed login",
18
+ "detail": "`captureScreenshot(..., { fit: \"content\" })` ignores scroll containers whose overflow does not shrink when the viewport grows (textareas, fixed-height panes) instead of failing to converge. `kumiko-testing integration` no longer walks `node_modules` while discovering test files, and the seeded-tenant `apiAs` retries a failed login on the next call instead of replaying the cached error. The privacy-center and profile `cancel-deletion` actions declare the `x` icon instead of resolving to the destructive trash icon."
19
+ },
2
20
  {
3
21
  "version": "0.329.0",
4
22
  "type": "fix",
@@ -210,7 +210,10 @@ export function defineAppE2eConfig(input: AppE2eConfigInput): PlaywrightTestConf
210
210
  // E2e clients all share ::1, so the default trustedProxyHops (0,
211
211
  // socket-only) would collapse every seeded user into one rate-limit
212
212
  // bucket. loginViaApi sends a synthetic per-user X-Forwarded-For, so
213
- // trust exactly one hop to give each user its own bucket.
213
+ // trust exactly one hop to give each user its own bucket. An explicit
214
+ // `trustedProxyHops` / `auth.trustedProxyHops` in the consumer's
215
+ // server entrypoint wins over this env and brings the shared bucket
216
+ // (and 429s) back — such a server must read the value from the env.
214
217
  [TRUSTED_PROXY_HOPS_ENV]: "1",
215
218
  },
216
219
  reuseExistingServer: false,
@@ -155,24 +155,53 @@ function assertPresentableMappings(mappings: readonly PresentIdentity[]): void {
155
155
  }
156
156
  }
157
157
 
158
- // Runs in the browser, so it may not reference module scope.
158
+ declare global {
159
+ interface Window {
160
+ __kumikoPresentRestore?: Array<() => void>;
161
+ }
162
+ }
163
+
164
+ // Runs in the browser, so it may not reference module scope. Originals are kept
165
+ // on window so a later evaluate can put them back (captureScreenshot runs
166
+ // mid-flow, where the spec keeps asserting and submitting forms).
159
167
  function replaceIdentitiesInDocument(mappings: readonly PresentIdentity[]): void {
168
+ window.__kumikoPresentRestore ??= [];
169
+ const restorers = window.__kumikoPresentRestore;
160
170
  const present = (text: string): string =>
161
171
  mappings.reduce((current, { from, to }) => current.replaceAll(from, to), text);
162
172
  const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
163
173
  for (let node = walker.nextNode(); node !== null; node = walker.nextNode()) {
164
174
  const text = node.nodeValue ?? "";
165
175
  const presented = present(text);
166
- if (presented !== text) node.nodeValue = presented;
176
+ if (presented !== text) {
177
+ const textNode = node;
178
+ textNode.nodeValue = presented;
179
+ restorers.push(() => {
180
+ textNode.nodeValue = text;
181
+ });
182
+ }
167
183
  }
168
184
  for (const field of document.querySelectorAll("input, textarea")) {
169
185
  if (field instanceof HTMLInputElement || field instanceof HTMLTextAreaElement) {
170
- const presented = present(field.value);
171
- if (presented !== field.value) field.value = presented;
186
+ const original = field.value;
187
+ const presented = present(original);
188
+ if (presented !== original) {
189
+ field.value = presented;
190
+ restorers.push(() => {
191
+ field.value = original;
192
+ });
193
+ }
172
194
  }
173
195
  }
174
196
  }
175
197
 
198
+ // Reverse order: a second presentation pass records already-presented values.
199
+ function restoreIdentitiesInDocument(): void {
200
+ const restorers = window.__kumikoPresentRestore ?? [];
201
+ for (const restore of restorers.reverse()) restore();
202
+ window.__kumikoPresentRestore = [];
203
+ }
204
+
176
205
  async function presentIdentitiesOnPage(
177
206
  page: Page,
178
207
  mappings: readonly PresentIdentity[],
@@ -180,6 +209,13 @@ async function presentIdentitiesOnPage(
180
209
  if (mappings.length > 0) await page.evaluate(replaceIdentitiesInDocument, mappings);
181
210
  }
182
211
 
212
+ async function restoreIdentitiesOnPage(
213
+ page: Page,
214
+ mappings: readonly PresentIdentity[],
215
+ ): Promise<void> {
216
+ if (mappings.length > 0) await page.evaluate(restoreIdentitiesInDocument);
217
+ }
218
+
183
219
  function collectPresentIdentities(): {
184
220
  readonly mappings: readonly PresentIdentity[];
185
221
  readonly register: (mappings: readonly PresentIdentity[]) => void;
@@ -256,8 +292,8 @@ export function runScreenshots(scenarios: readonly Scenario[], opts: FlatOptions
256
292
  test(
257
293
  s.description ? `${s.name} — ${s.description}` : s.name,
258
294
  async ({ page, seedTenant }) => {
259
- // reducedMotion isn't a PlaywrightTestOptions fixture (test.use can't
260
- // set it), so it's applied per-page like the rest of emulateMedia.
295
+ // reducedMotion isn't a PlaywrightTestOptions fixture (only reachable via test.use({ contextOptions }),
296
+ // which this runner cannot set per scenario), so it's applied per-page like the rest of emulateMedia.
261
297
  await page.emulateMedia({ reducedMotion: opts.reducedMotion ?? DEFAULT_REDUCED_MOTION });
262
298
  if (opts.pinLocale) await pinEnglishLocale(page);
263
299
  const inFlightDataRequests = countInFlightDataRequests(page);
@@ -357,8 +393,10 @@ const DEFAULT_LOCALE_TAGS: Readonly<Record<string, string>> = { en: "en-US", de:
357
393
  // own `localeTags` instead of relying on the derived default.
358
394
  function deriveLocaleTag(locale: string): string | undefined {
359
395
  try {
360
- const region = new Intl.Locale(locale).maximize().region;
361
- return region === undefined ? undefined : `${locale}-${region}`;
396
+ const parsed = new Intl.Locale(locale);
397
+ if (parsed.region !== undefined) return parsed.toString();
398
+ const region = parsed.maximize().region;
399
+ return region === undefined ? undefined : `${parsed.language}-${region}`;
362
400
  } catch {
363
401
  return undefined;
364
402
  }
@@ -459,10 +497,14 @@ export function resolveMatrixViewports(
459
497
  const deviceProjectIds = new Set(
460
498
  projects.filter((p) => p.isMobile && isViewportId(p.name)).map((p) => p.name),
461
499
  );
462
- return {
463
- mode: "desktop",
464
- viewports: allowedViewports.filter((id) => !deviceProjectIds.has(id)),
465
- };
500
+ const viewports = allowedViewports.filter((id) => !deviceProjectIds.has(id));
501
+ if (viewports.length === 0) {
502
+ return {
503
+ mode: "skip",
504
+ reason: `no viewports left for project "${projectName}": SCREENSHOT_VIEWPORTS [${allowedViewports.join(", ")}] all covered by device projects`,
505
+ };
506
+ }
507
+ return { mode: "desktop", viewports };
466
508
  }
467
509
 
468
510
  export function runMatrix<T extends string>(
@@ -489,8 +531,8 @@ export function runMatrix<T extends string>(
489
531
  for (const s of scenarios) {
490
532
  if (only !== undefined && only !== s.name) continue;
491
533
  test(s.name, async ({ page, seedTenant }) => {
492
- // reducedMotion isn't a PlaywrightTestOptions fixture (test.use can't set
493
- // it), so it's applied per-page like the rest of emulateMedia.
534
+ // reducedMotion isn't a PlaywrightTestOptions fixture (only reachable via test.use({ contextOptions }),
535
+ // which this runner cannot set per scenario), so it's applied per-page like the rest of emulateMedia.
494
536
  await page.emulateMedia({ reducedMotion: opts.reducedMotion ?? DEFAULT_REDUCED_MOTION });
495
537
  const info = test.info();
496
538
  const plan = resolveMatrixViewports(
@@ -593,47 +635,67 @@ export interface CaptureScreenshotOptions {
593
635
 
594
636
  const CONTENT_FIT_MAX_ROUNDS = 4;
595
637
 
596
- // Runs in the browser: the largest vertical overflow of the document or of any
638
+ type ScrollMeasurement = {
639
+ readonly documentDeficit: number;
640
+ // [index in document.querySelectorAll("*"), overflow px] per scrolling container.
641
+ readonly containers: readonly (readonly [number, number])[];
642
+ };
643
+
644
+ // Runs in the browser: the document's vertical overflow plus that of every
597
645
  // visible scrolling container. Serialized into the page, so it may not reference
598
646
  // module scope. A 1px container deficit is sub-pixel rounding, not content: an
599
647
  // `overflow-x-auto` table wrapper computes overflow-y to `auto` as well and
600
648
  // reported scrollHeight one pixel above clientHeight in solon at 1280px, so
601
649
  // growth never converged.
602
- function scrollDeficit(): number {
603
- const containerDeficits = [...document.querySelectorAll("*")]
604
- .filter((el) => {
605
- const style = getComputedStyle(el);
606
- return (
607
- (style.overflowY === "auto" || style.overflowY === "scroll") &&
608
- el.scrollHeight - el.clientHeight > 1 &&
609
- el.getClientRects().length > 0 &&
610
- style.visibility !== "hidden"
611
- );
612
- })
613
- .map((el) => el.scrollHeight - el.clientHeight);
614
- return Math.max(
615
- 0,
616
- document.documentElement.scrollHeight - window.innerHeight,
617
- ...containerDeficits,
618
- );
650
+ function scrollDeficit(): ScrollMeasurement {
651
+ const containers: [number, number][] = [];
652
+ [...document.querySelectorAll("*")].forEach((el, index) => {
653
+ const style = getComputedStyle(el);
654
+ if (
655
+ (style.overflowY === "auto" || style.overflowY === "scroll") &&
656
+ el.scrollHeight - el.clientHeight > 1 &&
657
+ el.getClientRects().length > 0 &&
658
+ style.visibility !== "hidden"
659
+ ) {
660
+ containers.push([index, el.scrollHeight - el.clientHeight]);
661
+ }
662
+ });
663
+ return {
664
+ documentDeficit: Math.max(0, document.documentElement.scrollHeight - window.innerHeight),
665
+ containers,
666
+ };
619
667
  }
620
668
 
621
669
  // Growing the viewport re-flows flex-1 regions under an h-svh shell, which can
622
- // reveal more content, hence rounds. A deficit left after the last round throws
623
- // instead of writing a silently cropped screenshot.
670
+ // reveal more content, hence rounds. A container whose overflow is unchanged
671
+ // after a growth round (a textarea, a fixed-height log pane, a max-h list) does
672
+ // not depend on the viewport, so it is ignored from then on instead of
673
+ // inflating the viewport until the round budget runs out; this costs one
674
+ // wasted growth step. A deficit left after the last round throws instead of
675
+ // writing a silently cropped screenshot.
624
676
  async function growViewportToContent(page: Page, name: string, width: number): Promise<void> {
625
- for (let round = 0; round < CONTENT_FIT_MAX_ROUNDS; round++) {
626
- const deficit = await page.evaluate(scrollDeficit);
627
- if (deficit === 0) break;
677
+ const ignored = new Set<number>();
678
+ let previous = new Map<number, number>();
679
+ for (let round = 0; ; round++) {
680
+ const measurement = await page.evaluate(scrollDeficit);
681
+ for (const [index, deficit] of measurement.containers) {
682
+ if (previous.get(index) === deficit) ignored.add(index);
683
+ }
684
+ previous = new Map(measurement.containers);
685
+ const deficit = Math.max(
686
+ measurement.documentDeficit,
687
+ ...measurement.containers.filter(([index]) => !ignored.has(index)).map(([, d]) => d),
688
+ );
689
+ // skip: everything fits, no further growth needed
690
+ if (deficit === 0) return;
691
+ if (round === CONTENT_FIT_MAX_ROUNDS) {
692
+ throw new Error(
693
+ `captureScreenshot(${name}): viewport growth did not converge after ${CONTENT_FIT_MAX_ROUNDS} rounds, ${deficit}px still overflow`,
694
+ );
695
+ }
628
696
  const height = page.viewportSize()?.height ?? 0;
629
697
  await page.setViewportSize({ width, height: height + deficit });
630
698
  }
631
- const remaining = await page.evaluate(scrollDeficit);
632
- if (remaining > 0) {
633
- throw new Error(
634
- `captureScreenshot(${name}): viewport growth did not converge after ${CONTENT_FIT_MAX_ROUNDS} rounds, ${remaining}px still overflow`,
635
- );
636
- }
637
699
  }
638
700
 
639
701
  // Inline mid-flow screenshot for a single point in an existing e2e/real-provider
@@ -657,8 +719,12 @@ export async function captureScreenshot(
657
719
  mkdirSync(dirname(path), { recursive: true });
658
720
  // Before the content fit too: a presented value of another length can change the overflow.
659
721
  await presentIdentitiesOnPage(page, identities);
660
- if (fit === "content") await captureGrownToContent(page, name, path, identities);
661
- else await page.screenshot({ path, animations: "disabled", fullPage: fit === "fullPage" });
722
+ try {
723
+ if (fit === "content") await captureGrownToContent(page, name, path, identities);
724
+ else await page.screenshot({ path, animations: "disabled", fullPage: fit === "fullPage" });
725
+ } finally {
726
+ await restoreIdentitiesOnPage(page, identities);
727
+ }
662
728
  }
663
729
 
664
730
  async function captureGrownToContent(
@@ -100,7 +100,11 @@ export async function provideSeedTenant(
100
100
  if (cached !== undefined) return cached;
101
101
  let opened: Promise<BoundApi> | undefined;
102
102
  const bound = (): Promise<BoundApi> => {
103
- opened ??= openLoggedInApi(user);
103
+ // A rejected login must not stay cached, or every later call replays the same stale error.
104
+ opened ??= openLoggedInApi(user).catch((error: unknown) => {
105
+ opened = undefined;
106
+ throw error;
107
+ });
104
108
  return opened;
105
109
  };
106
110
  const api: BoundApi = {
@@ -11,6 +11,20 @@ export type IntegrationRunOptions = {
11
11
 
12
12
  const EXCLUDED_SEGMENTS: ReadonlySet<string> = new Set(["node_modules", "dist", "e2e"]);
13
13
 
14
+ // Walks `cwd` without descending into excluded or dot directories, so a
15
+ // hoisted node_modules is never traversed (a glob scan would walk it in full).
16
+ export function listIntegrationTestFiles(cwd: string, relativeDir = ""): string[] {
17
+ const entries = readdirSync(join(cwd, relativeDir), { withFileTypes: true });
18
+ return entries.flatMap((entry) => {
19
+ const relativePath = relativeDir === "" ? entry.name : `${relativeDir}/${entry.name}`;
20
+ if (entry.isDirectory()) {
21
+ if (EXCLUDED_SEGMENTS.has(entry.name) || entry.name.startsWith(".")) return [];
22
+ return listIntegrationTestFiles(cwd, relativePath);
23
+ }
24
+ return relativePath.endsWith(".integration.test.ts") ? [relativePath] : [];
25
+ });
26
+ }
27
+
14
28
  export function selectIntegrationFiles(paths: readonly string[]): string[] {
15
29
  return paths
16
30
  .filter((path) => path.endsWith(".integration.test.ts"))