@cosmicdrift/kumiko-testing 0.334.0 → 0.336.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 +7 -3
- package/bin/kumiko-testing.ts +2 -4
- package/package.json +9 -4
- package/src/bunfig.ts +13 -1
- package/src/changes.json +18 -0
- package/src/e2e/define-app-e2e-config.ts +4 -1
- package/src/e2e/screenshots.ts +111 -45
- package/src/e2e/seeded-tenant-fixture.ts +5 -1
- package/src/integration-runner.ts +14 -0
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
|
-
|
|
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) =>
|
|
97
|
+
beforeCapture: async (page) => {
|
|
98
|
+
await page.addStyleTag({ content: ".live-clock { visibility: hidden }" });
|
|
99
|
+
},
|
|
96
100
|
}
|
|
97
101
|
```
|
|
98
102
|
|
package/bin/kumiko-testing.ts
CHANGED
|
@@ -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.
|
|
3
|
+
"version": "0.336.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.
|
|
79
|
-
"@cosmicdrift/kumiko-dev-server": "0.
|
|
80
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
78
|
+
"@cosmicdrift/kumiko-bundled-features": "0.336.0",
|
|
79
|
+
"@cosmicdrift/kumiko-dev-server": "0.336.0",
|
|
80
|
+
"@cosmicdrift/kumiko-framework": "0.336.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
|
|
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,
|
package/src/e2e/screenshots.ts
CHANGED
|
@@ -155,24 +155,53 @@ function assertPresentableMappings(mappings: readonly PresentIdentity[]): void {
|
|
|
155
155
|
}
|
|
156
156
|
}
|
|
157
157
|
|
|
158
|
-
|
|
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)
|
|
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
|
|
171
|
-
|
|
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
|
|
260
|
-
// set
|
|
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
|
|
361
|
-
|
|
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
|
-
|
|
463
|
-
|
|
464
|
-
|
|
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
|
|
493
|
-
//
|
|
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
|
-
|
|
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():
|
|
603
|
-
const
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
document.documentElement.scrollHeight - window.innerHeight,
|
|
617
|
-
|
|
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
|
|
623
|
-
//
|
|
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
|
-
|
|
626
|
-
|
|
627
|
-
|
|
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
|
-
|
|
661
|
-
|
|
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
|
-
|
|
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"))
|