@cosmicdrift/kumiko-testing 0.309.0 → 0.311.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -4
- package/src/changes.json +32 -0
- package/src/e2e/constants.ts +9 -0
- package/src/e2e/define-app-e2e-config.ts +29 -3
- package/src/e2e/index.ts +2 -0
- package/src/e2e/screenshot-dir.ts +1 -1
- package/src/e2e/screenshots.ts +80 -8
- package/src/scaffold.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-testing",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.311.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>",
|
|
@@ -63,9 +63,9 @@
|
|
|
63
63
|
"kumiko-testing": "./bin/kumiko-testing.ts"
|
|
64
64
|
},
|
|
65
65
|
"dependencies": {
|
|
66
|
-
"@cosmicdrift/kumiko-bundled-features": "0.
|
|
67
|
-
"@cosmicdrift/kumiko-dev-server": "0.
|
|
68
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
66
|
+
"@cosmicdrift/kumiko-bundled-features": "0.311.0",
|
|
67
|
+
"@cosmicdrift/kumiko-dev-server": "0.311.0",
|
|
68
|
+
"@cosmicdrift/kumiko-framework": "0.311.0",
|
|
69
69
|
"zod": "^4.4.3"
|
|
70
70
|
},
|
|
71
71
|
"peerDependencies": {
|
package/src/changes.json
CHANGED
|
@@ -1,4 +1,36 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.311.0",
|
|
4
|
+
"type": "breaking",
|
|
5
|
+
"title": "defineAppE2eConfig's chromium project renders screenshot runs at deviceScaleFactor 2",
|
|
6
|
+
"detail": "With SCREENSHOT_DIR set, the chromium project renders at 2x so runMatrix and\ncaptureScreenshot images stay sharp on HiDPI displays (desktop files are\n3840×2160). Plain e2e runs stay at 1x. Device projects keep their own scale.",
|
|
7
|
+
"migration": "Drop app-level deviceScaleFactor overrides (test.use or project use).\nCommitted screenshots taken in the chromium project regenerate at 2x."
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"version": "0.310.0",
|
|
11
|
+
"type": "improvement",
|
|
12
|
+
"title": "captureScreenshot accepts fit viewport, fullPage or content",
|
|
13
|
+
"detail": "`fit: \"viewport\"` (default) keeps the previous behaviour. `fit: \"fullPage\"`\ncaptures the whole document, so offlot's inline mid-flow shots\n(channel-request, channel-prompts, vehicle-channel-texts) can move to\ncaptureScreenshot. `fit: \"content\"` grows the viewport until neither the\ndocument nor a visible overflow-auto/scroll container (WorkspaceShell's\ninner scroll area) overflows, captures, and restores the viewport; it\nthrows instead of writing a cropped image when growth does not converge\nwithin 4 rounds. A 1px container overflow counts as sub-pixel rounding\n(overflow-x-auto table wrappers), not content. This replaces solon's own `e2e/_helpers/shot.ts`; its\nimages pick up the `reducedMotion: \"reduce\"` default on the switch, so a\none-time pixel drift in the handbook PNGs is expected."
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"version": "0.310.0",
|
|
17
|
+
"type": "breaking",
|
|
18
|
+
"title": "defineAppE2eConfig's chromium project runs at 1920×1080 instead of Desktop Chrome's 1280×720",
|
|
19
|
+
"detail": "The template took Playwright's \"Desktop Chrome\" device unchanged, so every\ne2e run and every inline captureScreenshot rendered at 1280×720 while\nrunMatrix's desktop screenshots used 1920×1080. Both now share\nDESKTOP_VIEWPORT (1920×1080). A root `use.viewport` override in an app's\nplaywright config never reached the chromium project anyway (project `use`\nwins), so solon's 1920 override was silently ineffective.",
|
|
20
|
+
"migration": "Drop app-level desktop viewport overrides. Specs asserting a layout that\nonly exists below 1920px (collapsed sidebar, stacked panes) set their own\nviewport via test.use({ viewport }). Committed inline screenshots taken\nin the chromium project regenerate at 1920 width."
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"version": "0.310.0",
|
|
24
|
+
"type": "fix",
|
|
25
|
+
"title": "defineAppE2eConfig's webServer env no longer defaults MEILI_URL and MEILI_MASTER_KEY",
|
|
26
|
+
"detail": "Since the infra env defaults landed, every e2e webServer got\nMEILI_URL=http://localhost:17700. Apps that choose Meilisearch over\ntheir in-memory search adapter when MEILI_URL is set then pointed at an\nunreachable host in CI without a Meili service. Meili is opt-in again:\nthe two vars reach the webServer only when the environment sets them or\nthe app passes them via `env`. All other infra defaults are unchanged.\nMigration: an app whose e2e needs Meilisearch sets MEILI_URL (and\nMEILI_MASTER_KEY) in `defineAppE2eConfig({ env })` or in the CI env; an\napp that worked around it with `MEILI_URL: \"\"` can drop that override."
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"version": "0.310.0",
|
|
30
|
+
"type": "fix",
|
|
31
|
+
"title": "Screenshot scenario waitFor uses the template timeout budget instead of a fixed 10s",
|
|
32
|
+
"detail": "runScreenshots/runMatrix wait for a scenario's `waitFor` selector with\n`E2E_TIMEOUT_MS.navigation`, or `E2E_TIMEOUT_MS.real` under\nKUMIKO_REAL_PROVIDERS=1, so real-provider screenshot scenarios no longer\ntime out on LLM/OCR latency and apps don't need their own wait timeouts."
|
|
33
|
+
},
|
|
2
34
|
{
|
|
3
35
|
"version": "0.309.0",
|
|
4
36
|
"type": "breaking",
|
package/src/e2e/constants.ts
CHANGED
|
@@ -43,3 +43,12 @@ export const SEED_ROUTES = {
|
|
|
43
43
|
seedUser: `${SEED_ROUTE_PREFIX}/seed-user`,
|
|
44
44
|
inbox: `${SEED_ROUTE_PREFIX}/inbox`,
|
|
45
45
|
} as const;
|
|
46
|
+
|
|
47
|
+
// Desktop default for e2e runs and screenshots alike. Playwright's "Desktop
|
|
48
|
+
// Chrome" device ships 1280×720: handbook and doc shots at 1280 visibly soften
|
|
49
|
+
// on a HiDPI display, and a list next to a reading pane looks cramped.
|
|
50
|
+
export const DESKTOP_VIEWPORT = { width: 1920, height: 1080 } as const;
|
|
51
|
+
|
|
52
|
+
// Screenshot runs (SCREENSHOT_DIR set) render at 2x so doc images stay sharp
|
|
53
|
+
// on HiDPI displays; plain e2e runs keep 1x. Device projects keep their own.
|
|
54
|
+
export const SCREENSHOT_DEVICE_SCALE_FACTOR = 2;
|
|
@@ -10,23 +10,39 @@ import {
|
|
|
10
10
|
} from "@playwright/test";
|
|
11
11
|
import { SERVICE_ENV_DEFAULTS } from "../preload/service-env-defaults-values";
|
|
12
12
|
import {
|
|
13
|
+
DESKTOP_VIEWPORT,
|
|
13
14
|
E2E_WORKERS_ENV,
|
|
14
15
|
isRealProviderRun,
|
|
15
16
|
PLAYWRIGHT_DEMO_ENV,
|
|
16
17
|
PROD_BUNDLES_ENV,
|
|
18
|
+
SCREENSHOT_DEVICE_SCALE_FACTOR,
|
|
17
19
|
SEED_ENABLE_ENV,
|
|
18
20
|
SEED_TOKEN_ENV,
|
|
19
21
|
STYLESHEET_WATCH_ENV,
|
|
20
22
|
} from "./constants";
|
|
21
|
-
import { screenshotSpecsIgnore } from "./screenshot-dir";
|
|
23
|
+
import { isScreenshotRun, screenshotSpecsIgnore } from "./screenshot-dir";
|
|
22
24
|
import { E2E_TIMEOUT_MS } from "./timeouts";
|
|
23
25
|
|
|
26
|
+
// Apps pick Meilisearch over their in-memory search adapter just because
|
|
27
|
+
// MEILI_URL is set, so a default here would point every consumer without a
|
|
28
|
+
// Meili service at an unreachable host. Meili stays opt-in: environment or `env`.
|
|
29
|
+
const OPT_IN_SERVICE_ENV_KEYS: readonly string[] = [
|
|
30
|
+
"MEILI_URL",
|
|
31
|
+
"MEILI_MASTER_KEY",
|
|
32
|
+
] satisfies readonly (keyof typeof SERVICE_ENV_DEFAULTS)[];
|
|
33
|
+
|
|
24
34
|
// A `??` merge per key, not a raw process.env spread — CI or the shell
|
|
25
35
|
// environment wins over the local-dev default without leaking unrelated
|
|
26
36
|
// host env vars into the webServer process.
|
|
27
37
|
function infraEnvDefaults(): Record<string, string> {
|
|
28
38
|
return Object.fromEntries(
|
|
29
|
-
Object.entries(SERVICE_ENV_DEFAULTS).
|
|
39
|
+
Object.entries(SERVICE_ENV_DEFAULTS).flatMap(([key, value]) => {
|
|
40
|
+
const fromEnvironment = process.env[key];
|
|
41
|
+
if (OPT_IN_SERVICE_ENV_KEYS.includes(key)) {
|
|
42
|
+
return fromEnvironment === undefined ? [] : [[key, fromEnvironment]];
|
|
43
|
+
}
|
|
44
|
+
return [[key, fromEnvironment ?? value]];
|
|
45
|
+
}),
|
|
30
46
|
);
|
|
31
47
|
}
|
|
32
48
|
|
|
@@ -154,7 +170,17 @@ export function defineAppE2eConfig(input: AppE2eConfigInput): PlaywrightTestConf
|
|
|
154
170
|
actionTimeout: E2E_TIMEOUT_MS.action,
|
|
155
171
|
navigationTimeout: E2E_TIMEOUT_MS.navigation,
|
|
156
172
|
},
|
|
157
|
-
projects: [
|
|
173
|
+
projects: [
|
|
174
|
+
{
|
|
175
|
+
name: "chromium",
|
|
176
|
+
use: {
|
|
177
|
+
...devices["Desktop Chrome"],
|
|
178
|
+
viewport: DESKTOP_VIEWPORT,
|
|
179
|
+
deviceScaleFactor: isScreenshotRun() ? SCREENSHOT_DEVICE_SCALE_FACTOR : 1,
|
|
180
|
+
},
|
|
181
|
+
},
|
|
182
|
+
...projects,
|
|
183
|
+
],
|
|
158
184
|
webServer: {
|
|
159
185
|
command: `bun run ${serverEntry}`,
|
|
160
186
|
url: baseURL,
|
package/src/e2e/index.ts
CHANGED
|
@@ -8,7 +8,7 @@ const NORMAL_RUN_IGNORE: readonly string[] = [
|
|
|
8
8
|
|
|
9
9
|
type Env = Readonly<Record<string, string | undefined>>;
|
|
10
10
|
|
|
11
|
-
function isScreenshotRun(env: Env): boolean {
|
|
11
|
+
export function isScreenshotRun(env: Env = process.env): boolean {
|
|
12
12
|
const dir = env[SCREENSHOT_DIR_ENV];
|
|
13
13
|
return dir !== undefined && dir !== "";
|
|
14
14
|
}
|
package/src/e2e/screenshots.ts
CHANGED
|
@@ -2,9 +2,11 @@ import { createHash } from "node:crypto";
|
|
|
2
2
|
import { mkdirSync, statSync } from "node:fs";
|
|
3
3
|
import { dirname } from "node:path";
|
|
4
4
|
import { expect, type Page, type Request } from "@playwright/test";
|
|
5
|
+
import { DESKTOP_VIEWPORT, isRealProviderRun } from "./constants";
|
|
5
6
|
import { pinEnglishLocale } from "./pin-english-locale";
|
|
6
7
|
import { requireScreenshotDir, SCREENSHOT_DIR_ENV } from "./screenshot-dir";
|
|
7
8
|
import { type SeedTenantFixture, test } from "./seeded-tenant-fixture";
|
|
9
|
+
import { E2E_TIMEOUT_MS } from "./timeouts";
|
|
8
10
|
|
|
9
11
|
export const DEFAULT_REDUCED_MOTION = "reduce" as const;
|
|
10
12
|
|
|
@@ -121,7 +123,9 @@ async function openScenario(
|
|
|
121
123
|
else throw new Error(`Scenario "${s.name}" needs either url or flow`);
|
|
122
124
|
|
|
123
125
|
if (s.waitFor) {
|
|
124
|
-
|
|
126
|
+
// Real-provider scenarios wait on LLM/OCR latency before the page is ready.
|
|
127
|
+
const timeout = isRealProviderRun() ? E2E_TIMEOUT_MS.real : E2E_TIMEOUT_MS.navigation;
|
|
128
|
+
await expect(page.locator(s.waitFor).first()).toBeVisible({ timeout });
|
|
125
129
|
}
|
|
126
130
|
await waitForSettledPage(page, inFlightDataRequests);
|
|
127
131
|
}
|
|
@@ -194,11 +198,7 @@ export function runScreenshots(scenarios: readonly Scenario[], opts: FlatOptions
|
|
|
194
198
|
const VIEWPORT_IDS = ["desktop", "tablet", "mobile"] as const;
|
|
195
199
|
export type ViewportId = (typeof VIEWPORT_IDS)[number];
|
|
196
200
|
const VIEWPORTS: Record<ViewportId, { readonly width: number; readonly height: number }> = {
|
|
197
|
-
|
|
198
|
-
// handbook and doc pages, where a 1280 image visibly softens on a HiDPI
|
|
199
|
-
// display. Wider also shows what a two-column layout actually does — at
|
|
200
|
-
// 1280 any list next to a reading pane looks cramped.
|
|
201
|
-
desktop: { width: 1920, height: 1080 },
|
|
201
|
+
desktop: DESKTOP_VIEWPORT,
|
|
202
202
|
// Landscape: portrait tablet shots collapsed two-column layouts into the mobile stack.
|
|
203
203
|
tablet: { width: 1112, height: 834 },
|
|
204
204
|
mobile: { width: 390, height: 844 },
|
|
@@ -461,6 +461,62 @@ function inFlightTrackerFor(page: Page): () => number {
|
|
|
461
461
|
return tracker;
|
|
462
462
|
}
|
|
463
463
|
|
|
464
|
+
// "viewport": what the window shows. "fullPage": Playwright's document-height
|
|
465
|
+
// capture, which renders a sticky sidebar floating mid-page. "content": grows the
|
|
466
|
+
// viewport until nothing overflows, including WorkspaceShell's internally
|
|
467
|
+
// scrolling `overflow-auto` containers that document.scrollHeight never sees.
|
|
468
|
+
export type CaptureFit = "viewport" | "fullPage" | "content";
|
|
469
|
+
|
|
470
|
+
export interface CaptureScreenshotOptions {
|
|
471
|
+
readonly reducedMotion?: ReducedMotionOption;
|
|
472
|
+
readonly fit?: CaptureFit;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
const CONTENT_FIT_MAX_ROUNDS = 4;
|
|
476
|
+
|
|
477
|
+
// Runs in the browser: the largest vertical overflow of the document or of any
|
|
478
|
+
// visible scrolling container. Serialized into the page, so it may not reference
|
|
479
|
+
// module scope. A 1px container deficit is sub-pixel rounding, not content: an
|
|
480
|
+
// `overflow-x-auto` table wrapper computes overflow-y to `auto` as well and
|
|
481
|
+
// reported scrollHeight one pixel above clientHeight in solon at 1280px, so
|
|
482
|
+
// growth never converged.
|
|
483
|
+
function scrollDeficit(): number {
|
|
484
|
+
const containerDeficits = [...document.querySelectorAll("*")]
|
|
485
|
+
.filter((el) => {
|
|
486
|
+
const style = getComputedStyle(el);
|
|
487
|
+
return (
|
|
488
|
+
(style.overflowY === "auto" || style.overflowY === "scroll") &&
|
|
489
|
+
el.scrollHeight - el.clientHeight > 1 &&
|
|
490
|
+
el.getClientRects().length > 0 &&
|
|
491
|
+
style.visibility !== "hidden"
|
|
492
|
+
);
|
|
493
|
+
})
|
|
494
|
+
.map((el) => el.scrollHeight - el.clientHeight);
|
|
495
|
+
return Math.max(
|
|
496
|
+
0,
|
|
497
|
+
document.documentElement.scrollHeight - window.innerHeight,
|
|
498
|
+
...containerDeficits,
|
|
499
|
+
);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// Growing the viewport re-flows flex-1 regions under an h-svh shell, which can
|
|
503
|
+
// reveal more content, hence rounds. A deficit left after the last round throws
|
|
504
|
+
// instead of writing a silently cropped screenshot.
|
|
505
|
+
async function growViewportToContent(page: Page, name: string, width: number): Promise<void> {
|
|
506
|
+
for (let round = 0; round < CONTENT_FIT_MAX_ROUNDS; round++) {
|
|
507
|
+
const deficit = await page.evaluate(scrollDeficit);
|
|
508
|
+
if (deficit === 0) break;
|
|
509
|
+
const height = page.viewportSize()?.height ?? 0;
|
|
510
|
+
await page.setViewportSize({ width, height: height + deficit });
|
|
511
|
+
}
|
|
512
|
+
const remaining = await page.evaluate(scrollDeficit);
|
|
513
|
+
if (remaining > 0) {
|
|
514
|
+
throw new Error(
|
|
515
|
+
`captureScreenshot(${name}): viewport growth did not converge after ${CONTENT_FIT_MAX_ROUNDS} rounds, ${remaining}px still overflow`,
|
|
516
|
+
);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
464
520
|
// Inline mid-flow screenshot for a single point in an existing e2e/real-provider
|
|
465
521
|
// spec (solon's `shot(page, id)`, offlot's inline docs/screenshots/e2e/* writes) —
|
|
466
522
|
// reuses the runner's settle logic and reduced-motion default, and is a no-op
|
|
@@ -468,14 +524,30 @@ function inFlightTrackerFor(page: Page): () => number {
|
|
|
468
524
|
export async function captureScreenshot(
|
|
469
525
|
page: Page,
|
|
470
526
|
name: string,
|
|
471
|
-
opts:
|
|
527
|
+
opts: CaptureScreenshotOptions = {},
|
|
472
528
|
): Promise<void> {
|
|
473
529
|
const dir = process.env[SCREENSHOT_DIR_ENV];
|
|
474
530
|
// skip: a plain e2e run without SCREENSHOT_DIR must not write screenshots.
|
|
475
531
|
if (dir === undefined || dir === "") return;
|
|
532
|
+
const fit = opts.fit ?? "viewport";
|
|
476
533
|
await page.emulateMedia({ reducedMotion: opts.reducedMotion ?? DEFAULT_REDUCED_MOTION });
|
|
477
534
|
await waitForSettledPage(page, inFlightTrackerFor(page));
|
|
478
535
|
const path = `${dir}/${name}.png`;
|
|
479
536
|
mkdirSync(dirname(path), { recursive: true });
|
|
480
|
-
|
|
537
|
+
if (fit === "content") await captureGrownToContent(page, name, path);
|
|
538
|
+
else await page.screenshot({ path, animations: "disabled", fullPage: fit === "fullPage" });
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
async function captureGrownToContent(page: Page, name: string, path: string): Promise<void> {
|
|
542
|
+
const viewport = page.viewportSize();
|
|
543
|
+
if (viewport === null) {
|
|
544
|
+
throw new Error(`captureScreenshot(${name}): fit "content" needs a page with a viewport`);
|
|
545
|
+
}
|
|
546
|
+
try {
|
|
547
|
+
await growViewportToContent(page, name, viewport.width);
|
|
548
|
+
await page.screenshot({ path, animations: "disabled" });
|
|
549
|
+
} finally {
|
|
550
|
+
// Later assertions in the same test must run against the original window.
|
|
551
|
+
await page.setViewportSize(viewport);
|
|
552
|
+
}
|
|
481
553
|
}
|
package/src/scaffold.ts
CHANGED
|
@@ -36,6 +36,7 @@ const RULES_MARKDOWN = [
|
|
|
36
36
|
"- Tests run in parallel by default. Timeouts, retries and workers live in the scripts, the bunfig files and `playwright.config.ts`; never raise them inside a test. The only exception carries a marker: `// @timeout-exception: #<issue> <reason>`.",
|
|
37
37
|
"- Every flow seeds its own tenant: `seedTenant(stack)` in integration tests, the `seedTenant` fixture in e2e. No shared tenant, no widened seed helper.",
|
|
38
38
|
"- Red or flaky? Diagnose the cause first (https://github.com/CosmicDriftGameStudio/kumiko-framework/blob/main/docs/guides/test-failures.md). A longer timeout, a retry or `serial` is not a fix.",
|
|
39
|
+
"- Why the setup looks this way, and the old-pattern → new-pattern table: https://github.com/CosmicDriftGameStudio/kumiko-framework/blob/main/docs/guides/testing-standard.md.",
|
|
39
40
|
"- Run Playwright as `bunx --bun playwright`, as the scripts do.",
|
|
40
41
|
`- Playwright configs and e2e files start with \`${TEST_RUNTIME_DIRECTIVE}\`; without it the runtime-isolation guard flags their import of the test package.`,
|
|
41
42
|
"",
|