@cosmicdrift/kumiko-testing 0.308.0 → 0.309.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.309.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.309.0",
67
+ "@cosmicdrift/kumiko-dev-server": "0.309.0",
68
+ "@cosmicdrift/kumiko-framework": "0.309.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,41 @@
1
1
  [
2
+ {
3
+ "version": "0.309.0",
4
+ "type": "breaking",
5
+ "title": "reducedMotion defaults to \"reduce\" in runScreenshots/runMatrix/captureScreenshot; captureScreenshot(page, name) added",
6
+ "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/.",
7
+ "migration": "Pass `reducedMotion: \"no-preference\"` in runScreenshots/runMatrix's\noptions, or as captureScreenshot's third argument, for a scenario that\nmust keep real motion."
8
+ },
9
+ {
10
+ "version": "0.309.0",
11
+ "type": "fix",
12
+ "title": "test:real now filters to *.real.test.ts, and real-provider runs share one 240s timeout budget",
13
+ "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."
14
+ },
15
+ {
16
+ "version": "0.309.0",
17
+ "type": "improvement",
18
+ "title": "runMatrix locales are app-injectable via localeTags, and gains a namedDevice mode for phone-style device projects",
19
+ "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."
20
+ },
21
+ {
22
+ "version": "0.309.0",
23
+ "type": "improvement",
24
+ "title": "Scenario flow receives runMatrix's locale; Scenario.captureStyle for screenshot-only CSS",
25
+ "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."
26
+ },
27
+ {
28
+ "version": "0.309.0",
29
+ "type": "improvement",
30
+ "title": "seedTenant accepts an admin identity (displayName/email) for demo and screenshot tenants",
31
+ "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."
32
+ },
33
+ {
34
+ "version": "0.309.0",
35
+ "type": "fix",
36
+ "title": "defineAppE2eConfig's webServer env now defaults the infra service vars, environment wins",
37
+ "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."
38
+ },
2
39
  {
3
40
  "version": "0.308.0",
4
41
  "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.
@@ -8,11 +8,12 @@ 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 {
12
13
  E2E_WORKERS_ENV,
14
+ isRealProviderRun,
13
15
  PLAYWRIGHT_DEMO_ENV,
14
16
  PROD_BUNDLES_ENV,
15
- REAL_PROVIDERS_ENV,
16
17
  SEED_ENABLE_ENV,
17
18
  SEED_TOKEN_ENV,
18
19
  STYLESHEET_WATCH_ENV,
@@ -20,6 +21,15 @@ import {
20
21
  import { screenshotSpecsIgnore } from "./screenshot-dir";
21
22
  import { E2E_TIMEOUT_MS } from "./timeouts";
22
23
 
24
+ // A `??` merge per key, not a raw process.env spread — CI or the shell
25
+ // environment wins over the local-dev default without leaking unrelated
26
+ // host env vars into the webServer process.
27
+ function infraEnvDefaults(): Record<string, string> {
28
+ return Object.fromEntries(
29
+ Object.entries(SERVICE_ENV_DEFAULTS).map(([key, value]) => [key, process.env[key] ?? value]),
30
+ );
31
+ }
32
+
23
33
  const TEMPLATE_OWNED_PROJECT_KEYS = [
24
34
  "fullyParallel",
25
35
  "retries",
@@ -121,7 +131,7 @@ export function defineAppE2eConfig(input: AppE2eConfigInput): PlaywrightTestConf
121
131
  assertNoReservedEnv(env);
122
132
  for (const project of projects) assertTemplateOwnedKeysUnset(project);
123
133
  const baseURL = `http://localhost:${port}`;
124
- const realRun = process.env[REAL_PROVIDERS_ENV] === "1";
134
+ const realRun = isRealProviderRun();
125
135
 
126
136
  return defineConfig({
127
137
  testDir,
@@ -134,7 +144,7 @@ export function defineAppE2eConfig(input: AppE2eConfigInput): PlaywrightTestConf
134
144
  retries: 0,
135
145
  workers: resolveE2eWorkers(),
136
146
  reporter: [["list"]],
137
- timeout: E2E_TIMEOUT_MS.test,
147
+ timeout: realRun ? E2E_TIMEOUT_MS.real : E2E_TIMEOUT_MS.test,
138
148
  expect: { timeout: E2E_TIMEOUT_MS.expect },
139
149
  use: {
140
150
  baseURL,
@@ -149,6 +159,7 @@ export function defineAppE2eConfig(input: AppE2eConfigInput): PlaywrightTestConf
149
159
  command: `bun run ${serverEntry}`,
150
160
  url: baseURL,
151
161
  env: {
162
+ ...infraEnvDefaults(),
152
163
  ...PLAYWRIGHT_DEMO_ENV,
153
164
  ...env,
154
165
  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,7 @@ export {
37
39
  } from "./screenshot-dir";
38
40
  export {
39
41
  applyDefaultTheme,
42
+ captureScreenshot,
40
43
  DEFAULT_THEMES,
41
44
  type DefaultThemeId,
42
45
  type FlatOptions,
@@ -45,6 +48,7 @@ export {
45
48
  runMatrix,
46
49
  runScreenshots,
47
50
  type Scenario,
51
+ type ScenarioFixtures,
48
52
  type ThemeScreenshotDigest,
49
53
  validateScenarios,
50
54
  } from "./screenshots";
@@ -1,10 +1,13 @@
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";
4
5
  import { pinEnglishLocale } from "./pin-english-locale";
5
- import { requireScreenshotDir } from "./screenshot-dir";
6
+ import { requireScreenshotDir, SCREENSHOT_DIR_ENV } from "./screenshot-dir";
6
7
  import { type SeedTenantFixture, test } from "./seeded-tenant-fixture";
7
8
 
9
+ export const DEFAULT_REDUCED_MOTION = "reduce" as const;
10
+
8
11
  // runScreenshots: one image per scenario → $SCREENSHOT_DIR/<name>.png.
9
12
  // runMatrix: every scenario × locale × theme × viewport in ONE run →
10
13
  // $SCREENSHOT_DIR/<name>/<locale>/<theme>/<viewport>.png (feeds the preview switcher).
@@ -27,13 +30,24 @@ export async function applyDefaultTheme(page: Page, theme: DefaultThemeId): Prom
27
30
  }, theme);
28
31
  }
29
32
 
33
+ export interface ScenarioFixtures {
34
+ readonly seedTenant: SeedTenantFixture;
35
+ // runMatrix's current locale, for apps whose routes carry the locale in the
36
+ // path (kumiko:locale alone can't change the URL). Undefined in runScreenshots.
37
+ readonly locale?: string;
38
+ }
39
+
30
40
  export interface Scenario {
31
41
  readonly name: string;
32
42
  readonly description?: string;
33
43
  readonly url?: string;
34
- readonly flow?: (page: Page, fixtures: { seedTenant: SeedTenantFixture }) => Promise<void>;
44
+ readonly flow?: (page: Page, fixtures: ScenarioFixtures) => Promise<void>;
35
45
  readonly waitFor?: string;
36
46
  readonly fullPage?: boolean;
47
+ // Playwright's screenshot-only `style`: applied for the capture and removed
48
+ // afterwards, unlike an addStyleTag in beforeCapture that would persist into
49
+ // the next theme × viewport capture.
50
+ readonly captureStyle?: string;
37
51
  readonly viewport?: { readonly width: number; readonly height: number };
38
52
  // Runs after the viewport is set and the page has settled, right before the
39
53
  // screenshot. runMatrix calls this once per theme × viewport combination for
@@ -100,7 +114,7 @@ async function openScenario(
100
114
  page: Page,
101
115
  s: Scenario,
102
116
  inFlightDataRequests: () => number,
103
- fixtures: { seedTenant: SeedTenantFixture },
117
+ fixtures: ScenarioFixtures,
104
118
  ): Promise<void> {
105
119
  if (s.flow) await s.flow(page, fixtures);
106
120
  else if (s.url) await page.goto(s.url);
@@ -112,8 +126,13 @@ async function openScenario(
112
126
  await waitForSettledPage(page, inFlightDataRequests);
113
127
  }
114
128
 
129
+ export type ReducedMotionOption = "reduce" | "no-preference";
130
+
115
131
  export interface FlatOptions {
116
132
  readonly pinLocale?: boolean;
133
+ // rAF-driven chart tweens etc. are invisible to waitForSettledPage's DOM/scroll
134
+ // fingerprint, so "reduce" is the default — pass "no-preference" to capture motion.
135
+ readonly reducedMotion?: ReducedMotionOption;
117
136
  }
118
137
 
119
138
  // Fail at registration time, not mid-run: a url-only scenario with no
@@ -151,13 +170,20 @@ export function runScreenshots(scenarios: readonly Scenario[], opts: FlatOptions
151
170
  test(
152
171
  s.description ? `${s.name} — ${s.description}` : s.name,
153
172
  async ({ page, seedTenant }) => {
173
+ // reducedMotion isn't a PlaywrightTestOptions fixture (test.use can't
174
+ // set it), so it's applied per-page like the rest of emulateMedia.
175
+ await page.emulateMedia({ reducedMotion: opts.reducedMotion ?? DEFAULT_REDUCED_MOTION });
154
176
  if (opts.pinLocale) await pinEnglishLocale(page);
155
177
  const inFlightDataRequests = countInFlightDataRequests(page);
156
178
  if (s.viewport) await page.setViewportSize(s.viewport);
157
179
  await openScenario(page, s, inFlightDataRequests, { seedTenant });
158
180
  if (s.beforeCapture) await s.beforeCapture(page);
159
181
  const path = `${outDir}/${s.name}.png`;
160
- await page.screenshot({ path, fullPage: s.fullPage ?? false });
182
+ await page.screenshot({
183
+ path,
184
+ fullPage: s.fullPage ?? false,
185
+ ...(s.captureStyle !== undefined && { style: s.captureStyle }),
186
+ });
161
187
  expect.soft(statSync(path).size).toBeGreaterThan(MIN_BYTES);
162
188
  },
163
189
  );
@@ -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,36 @@ 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
+ // Inline mid-flow screenshot for a single point in an existing e2e/real-provider
465
+ // spec (solon's `shot(page, id)`, offlot's inline docs/screenshots/e2e/* writes) —
466
+ // reuses the runner's settle logic and reduced-motion default, and is a no-op
467
+ // when SCREENSHOT_DIR is unset so a plain e2e run never writes into the repo.
468
+ export async function captureScreenshot(
469
+ page: Page,
470
+ name: string,
471
+ opts: { readonly reducedMotion?: ReducedMotionOption } = {},
472
+ ): Promise<void> {
473
+ const dir = process.env[SCREENSHOT_DIR_ENV];
474
+ // skip: a plain e2e run without SCREENSHOT_DIR must not write screenshots.
475
+ if (dir === undefined || dir === "") return;
476
+ await page.emulateMedia({ reducedMotion: opts.reducedMotion ?? DEFAULT_REDUCED_MOTION });
477
+ await waitForSettledPage(page, inFlightTrackerFor(page));
478
+ const path = `${dir}/${name}.png`;
479
+ mkdirSync(dirname(path), { recursive: true });
480
+ await page.screenshot({ path, animations: "disabled" });
481
+ }
@@ -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);