@cosmicdrift/kumiko-testing 0.308.0 → 0.310.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.308.0",
3
+ "version": "0.310.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.308.0",
67
- "@cosmicdrift/kumiko-dev-server": "0.308.0",
68
- "@cosmicdrift/kumiko-framework": "0.308.0",
66
+ "@cosmicdrift/kumiko-bundled-features": "0.310.0",
67
+ "@cosmicdrift/kumiko-dev-server": "0.310.0",
68
+ "@cosmicdrift/kumiko-framework": "0.310.0",
69
69
  "zod": "^4.4.3"
70
70
  },
71
71
  "peerDependencies": {
package/src/bunfig.ts CHANGED
@@ -1,9 +1,12 @@
1
+ import { E2E_TIMEOUT_MS } from "./e2e/timeouts";
2
+
1
3
  export const TEST_TIMEOUT_MS = {
2
4
  unit: 5000,
3
5
  integration: 15000,
4
6
  dom: 15000,
5
- // Real providers add network round-trips and provider-side latency the local stack never has.
6
- real: 120000,
7
+ // Same class budget as Playwright real runs (E2E_TIMEOUT_MS.real) — one
8
+ // constant covers both the bun test:real path and defineAppE2eConfig.
9
+ real: E2E_TIMEOUT_MS.real,
7
10
  } as const;
8
11
 
9
12
  export const BUNFIG_FILES = {
package/src/changes.json CHANGED
@@ -1,4 +1,66 @@
1
1
  [
2
+ {
3
+ "version": "0.310.0",
4
+ "type": "improvement",
5
+ "title": "captureScreenshot accepts fit viewport, fullPage or content",
6
+ "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."
7
+ },
8
+ {
9
+ "version": "0.310.0",
10
+ "type": "breaking",
11
+ "title": "defineAppE2eConfig's chromium project runs at 1920×1080 instead of Desktop Chrome's 1280×720",
12
+ "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.",
13
+ "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."
14
+ },
15
+ {
16
+ "version": "0.310.0",
17
+ "type": "fix",
18
+ "title": "defineAppE2eConfig's webServer env no longer defaults MEILI_URL and MEILI_MASTER_KEY",
19
+ "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."
20
+ },
21
+ {
22
+ "version": "0.310.0",
23
+ "type": "fix",
24
+ "title": "Screenshot scenario waitFor uses the template timeout budget instead of a fixed 10s",
25
+ "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."
26
+ },
27
+ {
28
+ "version": "0.309.0",
29
+ "type": "breaking",
30
+ "title": "reducedMotion defaults to \"reduce\" in runScreenshots/runMatrix/captureScreenshot; captureScreenshot(page, name) added",
31
+ "detail": "rAF-driven chart/tween animations were invisible to the settle-detection\nwait, so screenshots sometimes captured a mid-animation frame. Both matrix\nhelpers now call page.emulateMedia({ reducedMotion: \"reduce\" }) at test\nstart, before the first navigation. The new captureScreenshot(page, name,\nopts?) applies the same media emulation at capture time, so it only affects\nanimations started after that point; set reducedMotion via test.use() for\nmid-flow shots of charts that animate on mount. captureScreenshot reuses the\nmatrix runner's settle logic, writes $SCREENSHOT_DIR/<name>.png, and is a\nno-op when SCREENSHOT_DIR is unset, for solon's mid-flow shot(page, id) and\nofflot's inline page.screenshot writes into docs/screenshots/e2e/.",
32
+ "migration": "Pass `reducedMotion: \"no-preference\"` in runScreenshots/runMatrix's\noptions, or as captureScreenshot's third argument, for a scenario that\nmust keep real motion."
33
+ },
34
+ {
35
+ "version": "0.309.0",
36
+ "type": "fix",
37
+ "title": "test:real now filters to *.real.test.ts, and real-provider runs share one 240s timeout budget",
38
+ "detail": "bunfig.real.toml's pathIgnorePatterns is blacklist-only (no gitignore-style\nnegation), so an unfiltered `bun test --config=bunfig.real.toml` still ran\nthe whole unit suite under the real-provider env. The generated `test:real`\nscript now passes a positional `real.test.ts` filter. TEST_TIMEOUT_MS.real\n(bun --timeout) and E2E_TIMEOUT_MS.real (the Playwright real-run path via\ndefineAppE2eConfig) now both come from the same 240_000 template constant\ninstead of a separately hardcoded 120s, covering solon's real-provider\ndocument-onboarding flow. `isRealProviderRun(env?)` is exported next to\n`REAL_PROVIDERS_ENV`/`requireRealProviders` so apps stop duplicating the\n`KUMIKO_REAL_PROVIDERS === \"1\"` literal."
39
+ },
40
+ {
41
+ "version": "0.309.0",
42
+ "type": "improvement",
43
+ "title": "runMatrix locales are app-injectable via localeTags, and gains a namedDevice mode for phone-style device projects",
44
+ "detail": "runMatrix's locale list was fixed to en/de with hardcoded BCP47 tags.\nopts.localeTags lets an app supply its own locale -> tag map (e.g. \"es\" ->\n\"es-ES\"); locales with neither an app override nor the en/de default fall\nback to Intl.Locale(locale).maximize().region derivation, throwing only\nwhen no tag is derivable. A Playwright project whose name isn't a\nViewportId but is a mobile/device project (offlot's \"phone\" project) now\ngets its own namedDevice output subtree\n(<dir>/<outputPrefix>/<name>/<locale>/<theme>/<viewport>.png) fixed to the mobile\nviewport, instead of incorrectly falling through to the desktop pass."
45
+ },
46
+ {
47
+ "version": "0.309.0",
48
+ "type": "improvement",
49
+ "title": "Scenario flow receives runMatrix's locale; Scenario.captureStyle for screenshot-only CSS",
50
+ "detail": "runMatrix passes the current locale to `flow(page, { seedTenant, locale })`,\nso apps whose routes carry the locale in the path (offlot's public vehicle\npage) can build the URL per locale. `captureStyle` is handed to Playwright's\nscreenshot `style` option in runScreenshots and runMatrix: the CSS applies\nto the capture only and does not leak into the next theme × viewport shot."
51
+ },
52
+ {
53
+ "version": "0.309.0",
54
+ "type": "improvement",
55
+ "title": "seedTenant accepts an admin identity (displayName/email) for demo and screenshot tenants",
56
+ "detail": "SeedTenantOptions gained an optional `admin: { displayName?, email? }`.\nemail supports a `{tenantId}` placeholder, substituted per call, so a\nfixed template (e.g. `admin+{tenantId}@example.test`) stays unique across\nthe global `read_users_email_unique` index instead of colliding across\nper-scenario tenants. Threaded through both the plain seed-tenant.ts path\nand the HTTP seed route/fixture."
57
+ },
58
+ {
59
+ "version": "0.309.0",
60
+ "type": "fix",
61
+ "title": "defineAppE2eConfig's webServer env now defaults the infra service vars, environment wins",
62
+ "detail": "webServer.env is now built as `{ ...infraEnvDefaults(), ...PLAYWRIGHT_DEMO_ENV, ...env }`,\nwhere infraEnvDefaults() reads the same SERVICE_ENV_DEFAULTS the app's own\npreload uses (DATABASE_URL, REDIS_URL, MEILI_URL, MINIO_*, ...), falling\nback to each default only when process.env doesn't already carry a value.\nA CI/shell-set env var still wins over the template default. No\n`--env-file` is loaded here."
63
+ },
2
64
  {
3
65
  "version": "0.308.0",
4
66
  "type": "fix",
@@ -15,9 +15,18 @@ export const SEED_TOKEN_ENV = "KUMIKO_TEST_SEED_TOKEN";
15
15
  export const SEED_TOKEN_HEADER = "x-kumiko-test-seed";
16
16
  export const E2E_WORKERS_ENV = "KUMIKO_E2E_WORKERS";
17
17
 
18
- // Same literal as the framework's requireRealProviders(), which does not export it.
18
+ // Same literal as the framework's requireRealProviders()/isRealProviderRun(),
19
+ // duplicated because the Playwright config runs under Node and importing the
20
+ // framework's "./testing" barrel there would pull in its full Bun-toolchain
21
+ // dependency graph for a single string constant.
19
22
  export const REAL_PROVIDERS_ENV = "KUMIKO_REAL_PROVIDERS";
20
23
 
24
+ export function isRealProviderRun(
25
+ env: Readonly<Record<string, string | undefined>> = process.env,
26
+ ): boolean {
27
+ return env[REAL_PROVIDERS_ENV] === "1";
28
+ }
29
+
21
30
  // Same literal as kumiko-dev-server's createKumikoServer (STYLESHEET_WATCH_ENV),
22
31
  // duplicated because the Playwright config runs under Node and can't import
23
32
  // that Bun-toolchain-adjacent module.
@@ -34,3 +43,8 @@ export const SEED_ROUTES = {
34
43
  seedUser: `${SEED_ROUTE_PREFIX}/seed-user`,
35
44
  inbox: `${SEED_ROUTE_PREFIX}/inbox`,
36
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;
@@ -8,11 +8,13 @@ import {
8
8
  type PlaywrightWorkerOptions,
9
9
  type Project,
10
10
  } from "@playwright/test";
11
+ import { SERVICE_ENV_DEFAULTS } from "../preload/service-env-defaults-values";
11
12
  import {
13
+ DESKTOP_VIEWPORT,
12
14
  E2E_WORKERS_ENV,
15
+ isRealProviderRun,
13
16
  PLAYWRIGHT_DEMO_ENV,
14
17
  PROD_BUNDLES_ENV,
15
- REAL_PROVIDERS_ENV,
16
18
  SEED_ENABLE_ENV,
17
19
  SEED_TOKEN_ENV,
18
20
  STYLESHEET_WATCH_ENV,
@@ -20,6 +22,29 @@ import {
20
22
  import { screenshotSpecsIgnore } from "./screenshot-dir";
21
23
  import { E2E_TIMEOUT_MS } from "./timeouts";
22
24
 
25
+ // Apps pick Meilisearch over their in-memory search adapter just because
26
+ // MEILI_URL is set, so a default here would point every consumer without a
27
+ // Meili service at an unreachable host. Meili stays opt-in: environment or `env`.
28
+ const OPT_IN_SERVICE_ENV_KEYS: readonly string[] = [
29
+ "MEILI_URL",
30
+ "MEILI_MASTER_KEY",
31
+ ] satisfies readonly (keyof typeof SERVICE_ENV_DEFAULTS)[];
32
+
33
+ // A `??` merge per key, not a raw process.env spread — CI or the shell
34
+ // environment wins over the local-dev default without leaking unrelated
35
+ // host env vars into the webServer process.
36
+ function infraEnvDefaults(): Record<string, string> {
37
+ return Object.fromEntries(
38
+ Object.entries(SERVICE_ENV_DEFAULTS).flatMap(([key, value]) => {
39
+ const fromEnvironment = process.env[key];
40
+ if (OPT_IN_SERVICE_ENV_KEYS.includes(key)) {
41
+ return fromEnvironment === undefined ? [] : [[key, fromEnvironment]];
42
+ }
43
+ return [[key, fromEnvironment ?? value]];
44
+ }),
45
+ );
46
+ }
47
+
23
48
  const TEMPLATE_OWNED_PROJECT_KEYS = [
24
49
  "fullyParallel",
25
50
  "retries",
@@ -121,7 +146,7 @@ export function defineAppE2eConfig(input: AppE2eConfigInput): PlaywrightTestConf
121
146
  assertNoReservedEnv(env);
122
147
  for (const project of projects) assertTemplateOwnedKeysUnset(project);
123
148
  const baseURL = `http://localhost:${port}`;
124
- const realRun = process.env[REAL_PROVIDERS_ENV] === "1";
149
+ const realRun = isRealProviderRun();
125
150
 
126
151
  return defineConfig({
127
152
  testDir,
@@ -134,7 +159,7 @@ export function defineAppE2eConfig(input: AppE2eConfigInput): PlaywrightTestConf
134
159
  retries: 0,
135
160
  workers: resolveE2eWorkers(),
136
161
  reporter: [["list"]],
137
- timeout: E2E_TIMEOUT_MS.test,
162
+ timeout: realRun ? E2E_TIMEOUT_MS.real : E2E_TIMEOUT_MS.test,
138
163
  expect: { timeout: E2E_TIMEOUT_MS.expect },
139
164
  use: {
140
165
  baseURL,
@@ -144,11 +169,15 @@ export function defineAppE2eConfig(input: AppE2eConfigInput): PlaywrightTestConf
144
169
  actionTimeout: E2E_TIMEOUT_MS.action,
145
170
  navigationTimeout: E2E_TIMEOUT_MS.navigation,
146
171
  },
147
- projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }, ...projects],
172
+ projects: [
173
+ { name: "chromium", use: { ...devices["Desktop Chrome"], viewport: DESKTOP_VIEWPORT } },
174
+ ...projects,
175
+ ],
148
176
  webServer: {
149
177
  command: `bun run ${serverEntry}`,
150
178
  url: baseURL,
151
179
  env: {
180
+ ...infraEnvDefaults(),
152
181
  ...PLAYWRIGHT_DEMO_ENV,
153
182
  ...env,
154
183
  PORT: String(port),
package/src/e2e/index.ts CHANGED
@@ -16,8 +16,10 @@ export {
16
16
  CSRF_COOKIE_NAME,
17
17
  CSRF_HEADER_NAME,
18
18
  E2E_WORKERS_ENV,
19
+ isRealProviderRun,
19
20
  KUMIKO_SECRETS_MASTER_KEY_V1,
20
21
  PLAYWRIGHT_DEMO_ENV,
22
+ REAL_PROVIDERS_ENV,
21
23
  SEED_ENABLE_ENV,
22
24
  SEED_TOKEN_ENV,
23
25
  } from "./constants";
@@ -37,6 +39,9 @@ export {
37
39
  } from "./screenshot-dir";
38
40
  export {
39
41
  applyDefaultTheme,
42
+ type CaptureFit,
43
+ type CaptureScreenshotOptions,
44
+ captureScreenshot,
40
45
  DEFAULT_THEMES,
41
46
  type DefaultThemeId,
42
47
  type FlatOptions,
@@ -45,6 +50,7 @@ export {
45
50
  runMatrix,
46
51
  runScreenshots,
47
52
  type Scenario,
53
+ type ScenarioFixtures,
48
54
  type ThemeScreenshotDigest,
49
55
  validateScenarios,
50
56
  } from "./screenshots";
@@ -1,9 +1,14 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { mkdirSync, statSync } from "node:fs";
3
+ import { dirname } from "node:path";
3
4
  import { expect, type Page, type Request } from "@playwright/test";
5
+ import { DESKTOP_VIEWPORT, isRealProviderRun } from "./constants";
4
6
  import { pinEnglishLocale } from "./pin-english-locale";
5
- import { requireScreenshotDir } from "./screenshot-dir";
7
+ import { requireScreenshotDir, SCREENSHOT_DIR_ENV } from "./screenshot-dir";
6
8
  import { type SeedTenantFixture, test } from "./seeded-tenant-fixture";
9
+ import { E2E_TIMEOUT_MS } from "./timeouts";
10
+
11
+ export const DEFAULT_REDUCED_MOTION = "reduce" as const;
7
12
 
8
13
  // runScreenshots: one image per scenario → $SCREENSHOT_DIR/<name>.png.
9
14
  // runMatrix: every scenario × locale × theme × viewport in ONE run →
@@ -27,13 +32,24 @@ export async function applyDefaultTheme(page: Page, theme: DefaultThemeId): Prom
27
32
  }, theme);
28
33
  }
29
34
 
35
+ export interface ScenarioFixtures {
36
+ readonly seedTenant: SeedTenantFixture;
37
+ // runMatrix's current locale, for apps whose routes carry the locale in the
38
+ // path (kumiko:locale alone can't change the URL). Undefined in runScreenshots.
39
+ readonly locale?: string;
40
+ }
41
+
30
42
  export interface Scenario {
31
43
  readonly name: string;
32
44
  readonly description?: string;
33
45
  readonly url?: string;
34
- readonly flow?: (page: Page, fixtures: { seedTenant: SeedTenantFixture }) => Promise<void>;
46
+ readonly flow?: (page: Page, fixtures: ScenarioFixtures) => Promise<void>;
35
47
  readonly waitFor?: string;
36
48
  readonly fullPage?: boolean;
49
+ // Playwright's screenshot-only `style`: applied for the capture and removed
50
+ // afterwards, unlike an addStyleTag in beforeCapture that would persist into
51
+ // the next theme × viewport capture.
52
+ readonly captureStyle?: string;
37
53
  readonly viewport?: { readonly width: number; readonly height: number };
38
54
  // Runs after the viewport is set and the page has settled, right before the
39
55
  // screenshot. runMatrix calls this once per theme × viewport combination for
@@ -100,20 +116,27 @@ async function openScenario(
100
116
  page: Page,
101
117
  s: Scenario,
102
118
  inFlightDataRequests: () => number,
103
- fixtures: { seedTenant: SeedTenantFixture },
119
+ fixtures: ScenarioFixtures,
104
120
  ): Promise<void> {
105
121
  if (s.flow) await s.flow(page, fixtures);
106
122
  else if (s.url) await page.goto(s.url);
107
123
  else throw new Error(`Scenario "${s.name}" needs either url or flow`);
108
124
 
109
125
  if (s.waitFor) {
110
- 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 });
111
129
  }
112
130
  await waitForSettledPage(page, inFlightDataRequests);
113
131
  }
114
132
 
133
+ export type ReducedMotionOption = "reduce" | "no-preference";
134
+
115
135
  export interface FlatOptions {
116
136
  readonly pinLocale?: boolean;
137
+ // rAF-driven chart tweens etc. are invisible to waitForSettledPage's DOM/scroll
138
+ // fingerprint, so "reduce" is the default — pass "no-preference" to capture motion.
139
+ readonly reducedMotion?: ReducedMotionOption;
117
140
  }
118
141
 
119
142
  // Fail at registration time, not mid-run: a url-only scenario with no
@@ -151,13 +174,20 @@ export function runScreenshots(scenarios: readonly Scenario[], opts: FlatOptions
151
174
  test(
152
175
  s.description ? `${s.name} — ${s.description}` : s.name,
153
176
  async ({ page, seedTenant }) => {
177
+ // reducedMotion isn't a PlaywrightTestOptions fixture (test.use can't
178
+ // set it), so it's applied per-page like the rest of emulateMedia.
179
+ await page.emulateMedia({ reducedMotion: opts.reducedMotion ?? DEFAULT_REDUCED_MOTION });
154
180
  if (opts.pinLocale) await pinEnglishLocale(page);
155
181
  const inFlightDataRequests = countInFlightDataRequests(page);
156
182
  if (s.viewport) await page.setViewportSize(s.viewport);
157
183
  await openScenario(page, s, inFlightDataRequests, { seedTenant });
158
184
  if (s.beforeCapture) await s.beforeCapture(page);
159
185
  const path = `${outDir}/${s.name}.png`;
160
- await page.screenshot({ path, fullPage: s.fullPage ?? false });
186
+ await page.screenshot({
187
+ path,
188
+ fullPage: s.fullPage ?? false,
189
+ ...(s.captureStyle !== undefined && { style: s.captureStyle }),
190
+ });
161
191
  expect.soft(statSync(path).size).toBeGreaterThan(MIN_BYTES);
162
192
  },
163
193
  );
@@ -168,11 +198,7 @@ export function runScreenshots(scenarios: readonly Scenario[], opts: FlatOptions
168
198
  const VIEWPORT_IDS = ["desktop", "tablet", "mobile"] as const;
169
199
  export type ViewportId = (typeof VIEWPORT_IDS)[number];
170
200
  const VIEWPORTS: Record<ViewportId, { readonly width: number; readonly height: number }> = {
171
- // 1920×1080 instead of the earlier 1280×900: these shots land in the
172
- // handbook and doc pages, where a 1280 image visibly softens on a HiDPI
173
- // display. Wider also shows what a two-column layout actually does — at
174
- // 1280 any list next to a reading pane looks cramped.
175
- desktop: { width: 1920, height: 1080 },
201
+ desktop: DESKTOP_VIEWPORT,
176
202
  // Landscape: portrait tablet shots collapsed two-column layouts into the mobile stack.
177
203
  tablet: { width: 1112, height: 834 },
178
204
  mobile: { width: 390, height: 844 },
@@ -204,13 +230,41 @@ export interface MatrixOptions<T extends string> {
204
230
  readonly themes: readonly T[];
205
231
  readonly applyTheme: (page: Page, theme: T) => Promise<void>;
206
232
  readonly locales?: readonly string[];
233
+ readonly localeTags?: Readonly<Record<string, string>>;
234
+ readonly reducedMotion?: ReducedMotionOption;
207
235
  }
208
236
 
209
237
  // App locale codes ("en"/"de") -> BCP47 tags for Playwright's browser-context
210
238
  // `locale` option, pinning JS-side Intl/navigator.language regardless of the
211
239
  // host's own locale — without it, a screenshot regen on a non-en-US host bakes
212
240
  // in the host's Intl-driven formatting regardless of SCREENSHOT_LOCALES.
213
- const LOCALE_TAGS: Readonly<Record<string, string>> = { en: "en-US", de: "de-DE" };
241
+ const DEFAULT_LOCALE_TAGS: Readonly<Record<string, string>> = { en: "en-US", de: "de-DE" };
242
+
243
+ // Intl.Locale.maximize() derives a region for a bare language code (es -> es-ES)
244
+ // the same way DEFAULT_LOCALE_TAGS was hand-picked for en/de — apps with a
245
+ // locale that needs a different region (e.g. a Swiss "de" build) pass their
246
+ // own `localeTags` instead of relying on the derived default.
247
+ function deriveLocaleTag(locale: string): string | undefined {
248
+ try {
249
+ const region = new Intl.Locale(locale).maximize().region;
250
+ return region === undefined ? undefined : `${locale}-${region}`;
251
+ } catch {
252
+ return undefined;
253
+ }
254
+ }
255
+
256
+ export function resolveLocaleTag(
257
+ locale: string,
258
+ localeTags: Readonly<Record<string, string>> | undefined,
259
+ ): string {
260
+ const tag = localeTags?.[locale] ?? DEFAULT_LOCALE_TAGS[locale] ?? deriveLocaleTag(locale);
261
+ if (tag === undefined) {
262
+ throw new Error(
263
+ `runMatrix(): no BCP47 tag for locale "${locale}" — pass it in opts.localeTags`,
264
+ );
265
+ }
266
+ return tag;
267
+ }
214
268
 
215
269
  export interface ThemeScreenshotDigest<T extends string> {
216
270
  readonly viewport: string;
@@ -252,6 +306,15 @@ export interface MatrixProjectInfo {
252
306
 
253
307
  export type MatrixViewportPlan =
254
308
  | { readonly mode: "device"; readonly viewports: readonly [ViewportId] }
309
+ // A device project whose name is NOT a ViewportId (e.g. offlot's "phone",
310
+ // devices["iPhone 13"]) writes under its own <baseDir>/<projectName>/ subtree
311
+ // instead of the shared matrix directory, so it never collides with the
312
+ // desktop pass's own "mobile" viewport file for the same scenario.
313
+ | {
314
+ readonly mode: "namedDevice";
315
+ readonly viewports: readonly [ViewportId];
316
+ readonly outputPrefix: string;
317
+ }
255
318
  | { readonly mode: "desktop"; readonly viewports: readonly ViewportId[] }
256
319
  | { readonly mode: "skip"; readonly reason: string };
257
320
 
@@ -279,6 +342,9 @@ export function resolveMatrixViewports(
279
342
  }
280
343
  return { mode: "device", viewports: [projectName] };
281
344
  }
345
+ if (isMobileProject) {
346
+ return { mode: "namedDevice", viewports: ["mobile"], outputPrefix: projectName };
347
+ }
282
348
  const deviceProjectIds = new Set(
283
349
  projects.filter((p) => p.isMobile && isViewportId(p.name)).map((p) => p.name),
284
350
  );
@@ -305,17 +371,15 @@ export function runMatrix<T extends string>(
305
371
  // Browser-context locale for JS-side Intl/navigator.language — the
306
372
  // kumiko:locale seed below only drives the app's own i18n strings.
307
373
  // ponytail: see the runScreenshots() locale comment above / #1851
308
- const tag = LOCALE_TAGS[locale];
309
- if (tag === undefined) {
310
- throw new Error(
311
- `runMatrix(): no BCP47 tag mapped for locale "${locale}" — extend LOCALE_TAGS`,
312
- );
313
- }
374
+ const tag = resolveLocaleTag(locale, opts.localeTags);
314
375
  test.use({ locale: tag });
315
376
 
316
377
  for (const s of scenarios) {
317
378
  if (only !== undefined && only !== s.name) continue;
318
379
  test(s.name, async ({ page, seedTenant }) => {
380
+ // reducedMotion isn't a PlaywrightTestOptions fixture (test.use can't set
381
+ // it), so it's applied per-page like the rest of emulateMedia.
382
+ await page.emulateMedia({ reducedMotion: opts.reducedMotion ?? DEFAULT_REDUCED_MOTION });
319
383
  const info = test.info();
320
384
  const plan = resolveMatrixViewports(
321
385
  info.project.name,
@@ -336,9 +400,11 @@ export function runMatrix<T extends string>(
336
400
  localStorage.removeItem("kumiko:theme");
337
401
  }, locale);
338
402
  const inFlightDataRequests = countInFlightDataRequests(page);
339
- await openScenario(page, s, inFlightDataRequests, { seedTenant });
403
+ await openScenario(page, s, inFlightDataRequests, { seedTenant, locale });
340
404
 
341
405
  const digests: ThemeScreenshotDigest<T>[] = [];
406
+ const projectBaseDir =
407
+ plan.mode === "namedDevice" ? `${baseDir}/${plan.outputPrefix}` : baseDir;
342
408
 
343
409
  for (const theme of themes) {
344
410
  await opts.applyTheme(page, theme);
@@ -346,7 +412,7 @@ export function runMatrix<T extends string>(
346
412
  if (plan.mode === "desktop") await page.setViewportSize(VIEWPORTS[vp]);
347
413
  await waitForSettledPage(page, inFlightDataRequests);
348
414
  if (s.beforeCapture) await s.beforeCapture(page);
349
- const dir = `${baseDir}/${s.name}/${locale}/${theme}`;
415
+ const dir = `${projectBaseDir}/${s.name}/${locale}/${theme}`;
350
416
  mkdirSync(dir, { recursive: true });
351
417
  const path = `${dir}/${vp}.png`;
352
418
  // animations: "disabled" jumps to end-state at the engine level — immune to CSS specificity, unlike an addStyleTag injection.
@@ -356,6 +422,7 @@ export function runMatrix<T extends string>(
356
422
  path,
357
423
  fullPage: s.fullPage ?? false,
358
424
  animations: "disabled",
425
+ ...(s.captureStyle !== undefined && { style: s.captureStyle }),
359
426
  });
360
427
  expect.soft(buffer.length).toBeGreaterThan(MIN_BYTES);
361
428
  digests.push({
@@ -379,3 +446,108 @@ export function runMatrix<T extends string>(
379
446
  });
380
447
  }
381
448
  }
449
+
450
+ // Per-page in-flight counter for captureScreenshot() — a call mid-flow attaches
451
+ // its own page.on("request") listener the first time it sees a given page, so a
452
+ // second captureScreenshot() on that page reuses the same tracker instead of
453
+ // missing requests that were already in flight when the listener attached.
454
+ const inFlightTrackersByPage = new WeakMap<Page, () => number>();
455
+
456
+ function inFlightTrackerFor(page: Page): () => number {
457
+ const existing = inFlightTrackersByPage.get(page);
458
+ if (existing !== undefined) return existing;
459
+ const tracker = countInFlightDataRequests(page);
460
+ inFlightTrackersByPage.set(page, tracker);
461
+ return tracker;
462
+ }
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
+
520
+ // Inline mid-flow screenshot for a single point in an existing e2e/real-provider
521
+ // spec (solon's `shot(page, id)`, offlot's inline docs/screenshots/e2e/* writes) —
522
+ // reuses the runner's settle logic and reduced-motion default, and is a no-op
523
+ // when SCREENSHOT_DIR is unset so a plain e2e run never writes into the repo.
524
+ export async function captureScreenshot(
525
+ page: Page,
526
+ name: string,
527
+ opts: CaptureScreenshotOptions = {},
528
+ ): Promise<void> {
529
+ const dir = process.env[SCREENSHOT_DIR_ENV];
530
+ // skip: a plain e2e run without SCREENSHOT_DIR must not write screenshots.
531
+ if (dir === undefined || dir === "") return;
532
+ const fit = opts.fit ?? "viewport";
533
+ await page.emulateMedia({ reducedMotion: opts.reducedMotion ?? DEFAULT_REDUCED_MOTION });
534
+ await waitForSettledPage(page, inFlightTrackerFor(page));
535
+ const path = `${dir}/${name}.png`;
536
+ mkdirSync(dirname(path), { recursive: true });
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
+ }
553
+ }
@@ -5,10 +5,19 @@ export const SEEDABLE_ROLES = [ROLES.TenantAdmin, ROLES.Member] as const;
5
5
  export const MAX_SEED_MEMBERS = 10;
6
6
  export const MAX_TENANT_NAME_LENGTH = 100;
7
7
  const MAX_EMAIL_LENGTH = 320;
8
+ const MAX_DISPLAY_NAME_LENGTH = 100;
9
+
10
+ export const seedAdminIdentitySchema = z.strictObject({
11
+ displayName: z.string().min(1).max(MAX_DISPLAY_NAME_LENGTH).optional(),
12
+ // May contain the literal "{tenantId}" placeholder — substituted server-side
13
+ // in persistTenantRows, since user:create's email uniqueness is global.
14
+ email: z.string().min(1).max(MAX_EMAIL_LENGTH).optional(),
15
+ });
8
16
 
9
17
  export const seedTenantRequestSchema = z.strictObject({
10
18
  name: z.string().min(1).max(MAX_TENANT_NAME_LENGTH).optional(),
11
19
  members: z.number().int().min(0).max(MAX_SEED_MEMBERS).optional(),
20
+ admin: seedAdminIdentitySchema.optional(),
12
21
  });
13
22
 
14
23
  export const seededCredentialsSchema = z.object({
@@ -134,7 +134,11 @@ export function createE2eSeedRoutes(
134
134
  unwrapSavedRow(handlerQn, await deps.dispatchSystemWrite({ handlerQn, payload, tenantId }));
135
135
  try {
136
136
  return c.json(
137
- await persistTenantRows(write, { name: verified.name, users: verified.members }),
137
+ await persistTenantRows(write, {
138
+ name: verified.name,
139
+ users: verified.members,
140
+ admin: verified.admin,
141
+ }),
138
142
  );
139
143
  } catch (error) {
140
144
  return c.json({ error: error instanceof Error ? error.message : "seed failed" }, 500);
@@ -104,7 +104,7 @@ export async function provideSeedTenant(
104
104
  const seeded = await postSeedRoute(
105
105
  request,
106
106
  SEED_ROUTES.seedTenant,
107
- { name: opts.name, members: opts.users },
107
+ { name: opts.name, members: opts.users, admin: opts.admin },
108
108
  seedTenantResponseSchema,
109
109
  );
110
110
  const tenantId = seeded.id;
@@ -1,5 +1,7 @@
1
1
  // Class budget for every app: solon's 30s/5s flows run 4-parallel today; webServer is the
2
2
  // slowest sample boot (Bun.build + Tailwind), poll is phronexsis's 15s projection wait.
3
+ // `real` covers solon's document-onboarding real-provider flow (240s), the slowest
4
+ // consumer today — apps no longer set test.setTimeout/describe.configure for real runs.
3
5
  export const E2E_TIMEOUT_MS = {
4
6
  test: 30_000,
5
7
  expect: 5_000,
@@ -7,4 +9,5 @@ export const E2E_TIMEOUT_MS = {
7
9
  navigation: 10_000,
8
10
  poll: 15_000,
9
11
  webServer: 90_000,
12
+ real: 240_000,
10
13
  } as const;
@@ -0,0 +1,14 @@
1
+ export const SERVICE_ENV_DEFAULTS = {
2
+ DATABASE_URL: "postgresql://kumiko:kumiko@localhost:15432/kumiko_dev",
3
+ TEST_DATABASE_URL: "postgresql://kumiko:kumiko@localhost:15432/kumiko_test",
4
+ REDIS_URL: "redis://localhost:16379",
5
+ MEILI_URL: "http://localhost:17700",
6
+ MEILI_MASTER_KEY: "kumiko-dev-key",
7
+ JWT_SECRET: "test-jwt-secret-at-least-32-characters-long",
8
+ MINIO_ENDPOINT: "http://localhost:19000",
9
+ MINIO_ACCESS_KEY: "kumiko",
10
+ MINIO_SECRET_KEY: "kumiko-dev-secret",
11
+ MINIO_BUCKET: "kumiko-dev",
12
+ MINIO_REGION: "us-east-1",
13
+ LEGACY_DATABASE_URL: "",
14
+ } as const satisfies Record<string, string>;
@@ -1,14 +1,5 @@
1
- process.env["DATABASE_URL"] ??= "postgresql://kumiko:kumiko@localhost:15432/kumiko_dev";
2
- process.env["TEST_DATABASE_URL"] ??= "postgresql://kumiko:kumiko@localhost:15432/kumiko_test";
3
- process.env["REDIS_URL"] ??= "redis://localhost:16379";
4
- process.env["MEILI_URL"] ??= "http://localhost:17700";
5
- process.env["MEILI_MASTER_KEY"] ??= "kumiko-dev-key";
6
- process.env["JWT_SECRET"] ??= "test-jwt-secret-at-least-32-characters-long";
1
+ import { SERVICE_ENV_DEFAULTS } from "./service-env-defaults-values";
7
2
 
8
- process.env["MINIO_ENDPOINT"] ??= "http://localhost:19000";
9
- process.env["MINIO_ACCESS_KEY"] ??= "kumiko";
10
- process.env["MINIO_SECRET_KEY"] ??= "kumiko-dev-secret";
11
- process.env["MINIO_BUCKET"] ??= "kumiko-dev";
12
- process.env["MINIO_REGION"] ??= "us-east-1";
13
-
14
- process.env["LEGACY_DATABASE_URL"] ??= "";
3
+ for (const [key, value] of Object.entries(SERVICE_ENV_DEFAULTS)) {
4
+ process.env[key] ??= value;
5
+ }
package/src/scaffold.ts CHANGED
@@ -189,7 +189,13 @@ export function renderTestSetup(input: RenderTestSetupInput): ScaffoldTestSetup
189
189
  scripts: {
190
190
  test: `bun --config=${BUNFIG_FILES.unit} test --timeout=${TEST_TIMEOUT_MS.unit} --dots`,
191
191
  "test:integration": `bun kumiko-testing integration --parallel ${SCAFFOLD_INTEGRATION_PARALLEL}`,
192
- "test:real": `${REAL_PROVIDERS_ENV}=1 bun --config=${BUNFIG_FILES.real} test --timeout=${TEST_TIMEOUT_MS.real}`,
192
+ // bunfig.real.toml's pathIgnorePatterns can only blacklist (no "!" negation,
193
+ // verified against bun 1.4), so it can't express "match only *.real.test.ts"
194
+ // on its own — every *.test.ts file matches bun's default test glob too,
195
+ // real.test.ts included. The positional filter narrows the run to files
196
+ // whose path contains "real.test.ts", so an unfiltered test:real never
197
+ // picks up the unit suite.
198
+ "test:real": `${REAL_PROVIDERS_ENV}=1 bun --config=${BUNFIG_FILES.real} test --timeout=${TEST_TIMEOUT_MS.real} real.test.ts`,
193
199
  "test:bunfig": "bun kumiko-testing bunfig --hoisted",
194
200
  e2e: "bunx --bun playwright test",
195
201
  "e2e:real": `${REAL_PROVIDERS_ENV}=1 bunx --bun playwright test`,
@@ -13,6 +13,7 @@ import type { TestStack } from "@cosmicdrift/kumiko-framework/stack";
13
13
  import { isPlainObject } from "@cosmicdrift/kumiko-framework/utils";
14
14
  import {
15
15
  type BoundApi,
16
+ type SeedAdminIdentity,
16
17
  type SeededCredentials,
17
18
  type SeededTenant,
18
19
  type SeedTenantOptions,
@@ -21,6 +22,7 @@ import {
21
22
 
22
23
  export type {
23
24
  BoundApi,
25
+ SeedAdminIdentity,
24
26
  SeededCredentials,
25
27
  SeededTenant,
26
28
  SeededUser,
@@ -54,9 +56,14 @@ function bindApi(stack: TestStack, user: SessionUser): BoundApi {
54
56
  };
55
57
  }
56
58
 
57
- function lightCredentials(): SeededCredentials {
59
+ function lightCredentials(email?: string): SeededCredentials {
58
60
  const id = randomUUID();
59
- return { id, email: `user-${id}@example.test`, password: `pw-${randomUUID()}` };
61
+ const resolvedEmail = email ?? `user-${id}@example.test`;
62
+ return { id, email: resolvedEmail, password: `pw-${randomUUID()}` };
63
+ }
64
+
65
+ function substituteTenantId(email: string, tenantId: string): string {
66
+ return email.replaceAll("{tenantId}", tenantId);
60
67
  }
61
68
 
62
69
  function newTenantIdentity(name: string | undefined): {
@@ -100,14 +107,17 @@ export async function persistUserRows(
100
107
  write: SeedWriter,
101
108
  tenantId: TenantId,
102
109
  roles: readonly string[],
110
+ identity: SeedAdminIdentity = {},
103
111
  ): Promise<SeededCredentials> {
104
- const light = lightCredentials();
112
+ const light = lightCredentials(
113
+ identity.email !== undefined ? substituteTenantId(identity.email, tenantId) : undefined,
114
+ );
105
115
  const created = await write(
106
116
  UserHandlers.create,
107
117
  {
108
118
  email: light.email,
109
119
  passwordHash: await hashPassword(light.password),
110
- displayName: `Seed ${light.id.slice(0, 8)}`,
120
+ displayName: identity.displayName ?? `Seed ${light.id.slice(0, 8)}`,
111
121
  },
112
122
  tenantId,
113
123
  );
@@ -122,11 +132,15 @@ export async function persistUserRows(
122
132
 
123
133
  export async function persistTenantRows(
124
134
  write: SeedWriter,
125
- opts: { readonly name?: string; readonly users?: number } = {},
135
+ opts: {
136
+ readonly name?: string;
137
+ readonly users?: number;
138
+ readonly admin?: SeedAdminIdentity;
139
+ } = {},
126
140
  ): Promise<PersistedTenant> {
127
141
  const { id, key, name } = newTenantIdentity(opts.name);
128
142
  await write(TenantHandlers.create, { id, key, name }, id);
129
- const admin = await persistUserRows(write, id, [ROLES.TenantAdmin]);
143
+ const admin = await persistUserRows(write, id, [ROLES.TenantAdmin], opts.admin);
130
144
  const members: SeededCredentials[] = [];
131
145
  for (let i = 0; i < (opts.users ?? 0); i++) {
132
146
  members.push(await persistUserRows(write, id, [ROLES.Member]));
@@ -136,8 +150,10 @@ export async function persistTenantRows(
136
150
 
137
151
  function lightTenantRows(opts: SeedTenantOptions): PersistedTenant {
138
152
  const identity = newTenantIdentity(opts.name);
139
- const members = Array.from({ length: opts.users ?? 0 }, lightCredentials);
140
- return { ...identity, admin: lightCredentials(), members };
153
+ const members = Array.from({ length: opts.users ?? 0 }, () => lightCredentials());
154
+ const adminEmail =
155
+ opts.admin?.email !== undefined ? substituteTenantId(opts.admin.email, identity.id) : undefined;
156
+ return { ...identity, admin: lightCredentials(adminEmail), members };
141
157
  }
142
158
 
143
159
  export async function seedTenant(
package/src/seed-types.ts CHANGED
@@ -22,11 +22,20 @@ export type SeededUser = SeededCredentials & { readonly session: SessionUser };
22
22
 
23
23
  export type SeedPart = (ctx: { readonly tenant: SeededTenant }) => Promise<void>;
24
24
 
25
+ // email may contain the literal "{tenantId}" placeholder, substituted with the
26
+ // seeded tenant's id — user:create's email uniqueness is global (fw#2134/#2593),
27
+ // so a fixed literal email collides across per-scenario tenants.
28
+ export type SeedAdminIdentity = {
29
+ readonly displayName?: string;
30
+ readonly email?: string;
31
+ };
32
+
25
33
  export type SeedTenantOptions = {
26
34
  readonly name?: string;
27
35
  readonly users?: number;
28
36
  readonly with?: readonly SeedPart[];
29
37
  readonly persist?: boolean;
38
+ readonly admin?: SeedAdminIdentity;
30
39
  };
31
40
 
32
41
  export type SeededTenant = {
@@ -14,7 +14,11 @@ export async function setupAppTestStack(
14
14
  options: SetupAppTestStackOptions = {},
15
15
  ): Promise<TestStack> {
16
16
  const { registryTables = true, includeBundled = true, ...stackOptions } = options;
17
- const stack = await setupTestStackFromFeatures(features, { ...stackOptions, includeBundled });
17
+ const stack = await setupTestStackFromFeatures(features, {
18
+ ...stackOptions,
19
+ includeBundled,
20
+ entityTables: registryTables,
21
+ });
18
22
  if (!registryTables) return stack;
19
23
  try {
20
24
  await pushEntityProjectionTables(stack, stack.registry);