@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-testing",
3
- "version": "0.309.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.309.0",
67
- "@cosmicdrift/kumiko-dev-server": "0.309.0",
68
- "@cosmicdrift/kumiko-framework": "0.309.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",
@@ -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).map(([key, value]) => [key, process.env[key] ?? value]),
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: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }, ...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
@@ -39,6 +39,8 @@ export {
39
39
  } from "./screenshot-dir";
40
40
  export {
41
41
  applyDefaultTheme,
42
+ type CaptureFit,
43
+ type CaptureScreenshotOptions,
42
44
  captureScreenshot,
43
45
  DEFAULT_THEMES,
44
46
  type DefaultThemeId,
@@ -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
  }
@@ -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
- await expect(page.locator(s.waitFor).first()).toBeVisible({ timeout: 10_000 });
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
- // 1920×1080 instead of the earlier 1280×900: these shots land in the
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: { readonly reducedMotion?: ReducedMotionOption } = {},
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
- await page.screenshot({ path, animations: "disabled" });
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
  "",