@cosmicdrift/kumiko-testing 0.299.0 → 0.305.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -44,6 +44,9 @@ test("member sees the note", async ({ seedTenant, page }) => {
44
44
  workers (`KUMIKO_E2E_WORKERS` overrides) are fixed by the template; projects cannot set them.
45
45
  - The seed routes answer only with `KUMIKO_TEST_SEED=1`, outside `NODE_ENV=production`, and with the
46
46
  per-run `KUMIKO_TEST_SEED_TOKEN` in the `x-kumiko-test-seed` header. `defineAppE2eConfig` sets all three.
47
+ - The template also sets `KUMIKO_DEV_STYLESHEET_WATCH=0`, so the dev-server builds the app's CSS once and
48
+ never starts a Tailwind `--watch` process — that watcher would otherwise treat every Playwright artifact
49
+ write under `test-results/` as a rebuild trigger.
47
50
  - App roles: `createE2eSeedRoutes({ extraRoles: ["TenantMember"] })` lets `tenant.addUser(["TenantMember"])` seed them;
48
51
  `SystemAdmin` is never seedable and makes the route builder throw.
49
52
  - A `SeedPart` receives `{ tenant }` and works against the in-process `seedTenant` and the HTTP tenant alike.
@@ -52,6 +55,26 @@ test("member sees the note", async ({ seedTenant, page }) => {
52
55
  when the seed condition holds: `...(isE2eSeedingEnabled() ? createE2eSeedRoutes() : [])` in its own
53
56
  `extraRoutes` — prod then never registers the routes at all, not just a per-request 404.
54
57
  `isE2eSeedingEnabled` is exported from `@cosmicdrift/kumiko-testing/e2e/seed-route`.
55
- - App-wide defaults after tenant creation (e.g. a completed onboarding) belong in a `postSave` hook on
56
- the `"tenant"` entity (`r.hook("postSave", { allOf: "tenant" }, ...)`), fired via
57
- `seedTenant(db, options, { registry, context })` — not in `seedTenant()` itself, which stays a blank tenant.
58
+ - App-wide defaults for tests (e.g. a completed onboarding) belong in a `SeedPart` passed to
59
+ `seedTenant({ with: [defaults] })` — never in a `postSave` hook on the `"tenant"` entity, since that
60
+ hook also fires on every real signup in prod.
61
+
62
+ ## Screenshots (`./e2e`)
63
+
64
+ `runScreenshots`/`runMatrix` register their tests on the same `test` as above, so a scenario's `flow`
65
+ receives the seeded-tenant fixture too:
66
+
67
+ ```ts
68
+ { name: "dispatch", flow: async (page, { seedTenant }) => {
69
+ const tenant = await seedTenant({ users: 1 });
70
+ await tenant.loginAs(page, tenant.members[0]!);
71
+ await page.goto("/dispatch");
72
+ },
73
+ beforeCapture: async (page) => page.addStyleTag({ content: ".live-clock { visibility: hidden }" }),
74
+ }
75
+ ```
76
+
77
+ `beforeCapture?: (page) => Promise<void>` runs after the viewport is set and the page has settled,
78
+ right before the screenshot. `runMatrix` calls it once per theme × viewport for the same scenario, so
79
+ it must be idempotent — hide or mask an element rather than a one-shot action like clicking a button,
80
+ which would only succeed on the first capture and time out on every one after.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-testing",
3
- "version": "0.299.0",
3
+ "version": "0.305.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.299.0",
67
- "@cosmicdrift/kumiko-dev-server": "0.299.0",
68
- "@cosmicdrift/kumiko-framework": "0.299.0",
66
+ "@cosmicdrift/kumiko-bundled-features": "0.305.0",
67
+ "@cosmicdrift/kumiko-dev-server": "0.305.0",
68
+ "@cosmicdrift/kumiko-framework": "0.305.0",
69
69
  "zod": "^4.4.3"
70
70
  },
71
71
  "peerDependencies": {
package/src/changes.json CHANGED
@@ -1,4 +1,16 @@
1
1
  [
2
+ {
3
+ "version": "0.305.0",
4
+ "type": "fix",
5
+ "title": "defineAppE2eConfig starts the E2E web server without a Tailwind --watch process, so Playwright artifact writes no longer trigger hundreds of CSS rebuilds",
6
+ "detail": "`defineAppE2eConfig` sets `KUMIKO_DEV_STYLESHEET_WATCH=0`, so the dev-server it boots builds CSS once and stops instead of keeping a Tailwind `--watch` process alive. Tailwind v4's watcher subscribes to the whole app cwd recursively with no gitignore filter, so every Playwright artifact write under `test-results/` previously counted as a rebuild trigger."
7
+ },
8
+ {
9
+ "version": "0.304.0",
10
+ "type": "improvement",
11
+ "title": "Screenshot scenarios get { seedTenant } in flow and a beforeCapture hook; the README no longer recommends a postSave hook on tenant for test defaults",
12
+ "detail": "`runScreenshots`/`runMatrix` register their tests on the same `test` as `@cosmicdrift/kumiko-testing/e2e`'s seeded-tenant fixture, so `Scenario.flow` now receives `(page, { seedTenant })` — existing single-parameter `flow(page)` scenarios keep working unchanged. `Scenario.beforeCapture?: (page) => Promise<void>` runs after the viewport is set and the page has settled, right before each screenshot. `seedTenant()`'s \"no baseURL\" error now only fires when a scenario actually calls it, not for every screenshot spec's fixture setup."
13
+ },
2
14
  {
3
15
  "version": "0.299.0",
4
16
  "type": "fix",
@@ -18,6 +18,11 @@ export const E2E_WORKERS_ENV = "KUMIKO_E2E_WORKERS";
18
18
  // Same literal as the framework's requireRealProviders(), which does not export it.
19
19
  export const REAL_PROVIDERS_ENV = "KUMIKO_REAL_PROVIDERS";
20
20
 
21
+ // Same literal as kumiko-dev-server's createKumikoServer (STYLESHEET_WATCH_ENV),
22
+ // duplicated because the Playwright config runs under Node and can't import
23
+ // that Bun-toolchain-adjacent module.
24
+ export const STYLESHEET_WATCH_ENV = "KUMIKO_DEV_STYLESHEET_WATCH";
25
+
21
26
  export const SEED_ROUTE_PREFIX = "/__test";
22
27
 
23
28
  export const SEED_ROUTES = {
@@ -14,6 +14,7 @@ import {
14
14
  REAL_PROVIDERS_ENV,
15
15
  SEED_ENABLE_ENV,
16
16
  SEED_TOKEN_ENV,
17
+ STYLESHEET_WATCH_ENV,
17
18
  } from "./constants";
18
19
  import { screenshotSpecsIgnore } from "./screenshot-dir";
19
20
  import { E2E_TIMEOUT_MS } from "./timeouts";
@@ -27,7 +28,7 @@ const TEMPLATE_OWNED_PROJECT_KEYS = [
27
28
  ] as const;
28
29
  const TEMPLATE_OWNED_USE_KEYS = ["actionTimeout", "navigationTimeout"] as const;
29
30
  const REAL_SPEC_GLOB = "**/*.real.spec.ts";
30
- const RESERVED_ENV_KEYS = ["PORT", SEED_ENABLE_ENV, SEED_TOKEN_ENV] as const;
31
+ const RESERVED_ENV_KEYS = ["PORT", SEED_ENABLE_ENV, SEED_TOKEN_ENV, STYLESHEET_WATCH_ENV] as const;
31
32
 
32
33
  type ProjectUse = Partial<PlaywrightTestOptions & PlaywrightWorkerOptions>;
33
34
 
@@ -142,6 +143,10 @@ export function defineAppE2eConfig(input: AppE2eConfigInput): PlaywrightTestConf
142
143
  PORT: String(port),
143
144
  [SEED_ENABLE_ENV]: "1",
144
145
  [SEED_TOKEN_ENV]: seedTokenForRun(),
146
+ // A Tailwind --watch process subscribes to the whole app cwd
147
+ // recursively (Tailwind v4, no gitignore filter); every Playwright
148
+ // artifact write under test-results/ would then count as a rebuild.
149
+ [STYLESHEET_WATCH_ENV]: "0",
145
150
  },
146
151
  reuseExistingServer: false,
147
152
  timeout: E2E_TIMEOUT_MS.webServer,
package/src/e2e/index.ts CHANGED
@@ -51,6 +51,7 @@ export type { CapturedMail } from "./seed-contract";
51
51
  export {
52
52
  type E2eSeededTenant,
53
53
  type E2eSeedTenantOptions,
54
+ type SeedTenantFixture,
54
55
  test,
55
56
  } from "./seeded-tenant-fixture";
56
57
  export { E2E_TIMEOUT_MS } from "./timeouts";
@@ -1,8 +1,9 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { mkdirSync, statSync } from "node:fs";
3
- import { expect, type Page, type Request, test } from "@playwright/test";
3
+ import { expect, type Page, type Request } from "@playwright/test";
4
4
  import { pinEnglishLocale } from "./pin-english-locale";
5
5
  import { requireScreenshotDir } from "./screenshot-dir";
6
+ import { type SeedTenantFixture, test } from "./seeded-tenant-fixture";
6
7
 
7
8
  // runScreenshots: one image per scenario → $SCREENSHOT_DIR/<name>.png.
8
9
  // runMatrix: every scenario × locale × theme × viewport in ONE run →
@@ -30,10 +31,16 @@ export interface Scenario {
30
31
  readonly name: string;
31
32
  readonly description?: string;
32
33
  readonly url?: string;
33
- readonly flow?: (page: Page) => Promise<void>;
34
+ readonly flow?: (page: Page, fixtures: { seedTenant: SeedTenantFixture }) => Promise<void>;
34
35
  readonly waitFor?: string;
35
36
  readonly fullPage?: boolean;
36
37
  readonly viewport?: { readonly width: number; readonly height: number };
38
+ // Runs after the viewport is set and the page has settled, right before the
39
+ // screenshot. runMatrix calls this once per theme × viewport combination for
40
+ // the scenario — it must be idempotent (e.g. hide/mask an element) rather
41
+ // than a one-shot action like clicking a dismiss button, which would only
42
+ // succeed on the first capture and time out on every one after.
43
+ readonly beforeCapture?: (page: Page) => Promise<void>;
37
44
  // runMatrix only: opt out of the identical-theme-screenshot check for
38
45
  // scenarios that legitimately render the same pixels across themes — e.g.
39
46
  // a plain server-rendered content page with no client-side theme wiring.
@@ -93,8 +100,9 @@ async function openScenario(
93
100
  page: Page,
94
101
  s: Scenario,
95
102
  inFlightDataRequests: () => number,
103
+ fixtures: { seedTenant: SeedTenantFixture },
96
104
  ): Promise<void> {
97
- if (s.flow) await s.flow(page);
105
+ if (s.flow) await s.flow(page, fixtures);
98
106
  else if (s.url) await page.goto(s.url);
99
107
  else throw new Error(`Scenario "${s.name}" needs either url or flow`);
100
108
 
@@ -140,15 +148,19 @@ export function runScreenshots(scenarios: readonly Scenario[], opts: FlatOptions
140
148
  // unreachable from Playwright (context.locale and --lang both no-op there). #1851
141
149
  if (opts.pinLocale) test.use({ locale: "en-US" });
142
150
  for (const s of scenarios) {
143
- test(s.description ? `${s.name} — ${s.description}` : s.name, async ({ page }) => {
144
- if (opts.pinLocale) await pinEnglishLocale(page);
145
- const inFlightDataRequests = countInFlightDataRequests(page);
146
- if (s.viewport) await page.setViewportSize(s.viewport);
147
- await openScenario(page, s, inFlightDataRequests);
148
- const path = `${outDir}/${s.name}.png`;
149
- await page.screenshot({ path, fullPage: s.fullPage ?? false });
150
- expect.soft(statSync(path).size).toBeGreaterThan(MIN_BYTES);
151
- });
151
+ test(
152
+ s.description ? `${s.name} — ${s.description}` : s.name,
153
+ async ({ page, seedTenant }) => {
154
+ if (opts.pinLocale) await pinEnglishLocale(page);
155
+ const inFlightDataRequests = countInFlightDataRequests(page);
156
+ if (s.viewport) await page.setViewportSize(s.viewport);
157
+ await openScenario(page, s, inFlightDataRequests, { seedTenant });
158
+ if (s.beforeCapture) await s.beforeCapture(page);
159
+ const path = `${outDir}/${s.name}.png`;
160
+ await page.screenshot({ path, fullPage: s.fullPage ?? false });
161
+ expect.soft(statSync(path).size).toBeGreaterThan(MIN_BYTES);
162
+ },
163
+ );
152
164
  }
153
165
  });
154
166
  }
@@ -260,7 +272,7 @@ export function runMatrix<T extends string>(
260
272
 
261
273
  for (const s of scenarios) {
262
274
  if (only !== undefined && only !== s.name) continue;
263
- test(s.name, async ({ page }) => {
275
+ test(s.name, async ({ page, seedTenant }) => {
264
276
  // kumiko:locale drives the boot-time language (before goto); kumiko:theme
265
277
  // is cleared so the mode is decided solely by applyTheme.
266
278
  await page.addInitScript((lng) => {
@@ -268,7 +280,7 @@ export function runMatrix<T extends string>(
268
280
  localStorage.removeItem("kumiko:theme");
269
281
  }, locale);
270
282
  const inFlightDataRequests = countInFlightDataRequests(page);
271
- await openScenario(page, s, inFlightDataRequests);
283
+ await openScenario(page, s, inFlightDataRequests, { seedTenant });
272
284
 
273
285
  const digests: ThemeScreenshotDigest<T>[] = [];
274
286
 
@@ -277,6 +289,7 @@ export function runMatrix<T extends string>(
277
289
  for (const vp of viewports) {
278
290
  await page.setViewportSize(VIEWPORTS[vp]);
279
291
  await waitForSettledPage(page, inFlightDataRequests);
292
+ if (s.beforeCapture) await s.beforeCapture(page);
280
293
  const dir = `${baseDir}/${s.name}/${locale}/${theme}`;
281
294
  mkdirSync(dir, { recursive: true });
282
295
  const path = `${dir}/${vp}.png`;
@@ -1,5 +1,12 @@
1
1
  import { ROLES } from "@cosmicdrift/kumiko-framework/auth";
2
- import { type APIRequestContext, test as base, type Page } from "@playwright/test";
2
+ import {
3
+ type APIRequestContext,
4
+ test as base,
5
+ type Page,
6
+ type PlaywrightTestArgs,
7
+ type PlaywrightTestOptions,
8
+ type PlaywrightWorkerArgs,
9
+ } from "@playwright/test";
3
10
  import type { z } from "zod";
4
11
  import {
5
12
  type BoundApi,
@@ -24,10 +31,20 @@ export type E2eSeededTenant = SeededTenant & {
24
31
  readonly loginAs: (page: Page, user: SeededUser) => Promise<void>;
25
32
  };
26
33
 
34
+ export type SeedTenantFixture = (opts?: E2eSeedTenantOptions) => Promise<E2eSeededTenant>;
35
+
27
36
  type E2eFixtures = {
28
- seedTenant: (opts?: E2eSeedTenantOptions) => Promise<E2eSeededTenant>;
37
+ seedTenant: SeedTenantFixture;
29
38
  };
30
39
 
40
+ // Narrowed to the four fixtures the body below actually reads, so a unit
41
+ // test can drive it with plain stubs instead of a full Playwright run.
42
+ export type ProvideSeedTenantDeps = Pick<
43
+ PlaywrightTestArgs & PlaywrightWorkerArgs,
44
+ "request" | "context" | "playwright"
45
+ > &
46
+ Pick<PlaywrightTestOptions, "baseURL">;
47
+
31
48
  async function postSeedRoute<S extends z.ZodType>(
32
49
  request: APIRequestContext,
33
50
  path: string,
@@ -41,83 +58,90 @@ async function postSeedRoute<S extends z.ZodType>(
41
58
  return schema.parse(await response.json());
42
59
  }
43
60
 
44
- export const test = base.extend<E2eFixtures>({
45
- seedTenant: async ({ request, context, playwright, baseURL }, use) => {
46
- if (baseURL === undefined) throw new Error("seedTenant: the Playwright config has no baseURL");
47
- const openedContexts: APIRequestContext[] = [];
48
- const apiByUserId = new Map<string, BoundApi>();
49
-
50
- // One cookie jar per user: two tenants (or admin + member) in one test must not clobber each other's session.
51
- const openLoggedInApi = async (user: SeededUser): Promise<BoundApi> => {
52
- const ctx = await playwright.request.newContext({ baseURL });
53
- openedContexts.push(ctx);
54
- await loginViaApi(ctx, user);
55
- return createHttpApi(ctx);
56
- };
61
+ export async function provideSeedTenant(
62
+ { request, context, playwright, baseURL }: ProvideSeedTenantDeps,
63
+ use: (fixture: SeedTenantFixture) => Promise<void>,
64
+ ): Promise<void> {
65
+ const openedContexts: APIRequestContext[] = [];
66
+ const apiByUserId = new Map<string, BoundApi>();
57
67
 
58
- const httpApiFor = (user: SeededUser): BoundApi => {
59
- const cached = apiByUserId.get(user.id);
60
- if (cached !== undefined) return cached;
61
- let opened: Promise<BoundApi> | undefined;
62
- const bound = (): Promise<BoundApi> => {
63
- opened ??= openLoggedInApi(user);
64
- return opened;
65
- };
66
- const api: BoundApi = {
67
- writeOk: async (type, payload, requestId) =>
68
- (await bound()).writeOk(type, payload, requestId),
69
- writeErr: async (type, payload) => (await bound()).writeErr(type, payload),
70
- queryOk: async (type, payload) => (await bound()).queryOk(type, payload),
71
- queryErr: async (type, payload) => (await bound()).queryErr(type, payload),
72
- };
73
- apiByUserId.set(user.id, api);
74
- return api;
75
- };
68
+ // One cookie jar per user: two tenants (or admin + member) in one test must not clobber each other's session.
69
+ const openLoggedInApi = async (user: SeededUser): Promise<BoundApi> => {
70
+ const ctx = await playwright.request.newContext({ baseURL });
71
+ openedContexts.push(ctx);
72
+ await loginViaApi(ctx, user);
73
+ return createHttpApi(ctx);
74
+ };
76
75
 
77
- const loginAs = async (page: Page, user: SeededUser): Promise<void> => {
78
- await page.context().clearCookies();
79
- await loginViaApi(page.context().request, user);
76
+ const httpApiFor = (user: SeededUser): BoundApi => {
77
+ const cached = apiByUserId.get(user.id);
78
+ if (cached !== undefined) return cached;
79
+ let opened: Promise<BoundApi> | undefined;
80
+ const bound = (): Promise<BoundApi> => {
81
+ opened ??= openLoggedInApi(user);
82
+ return opened;
83
+ };
84
+ const api: BoundApi = {
85
+ writeOk: async (type, payload, requestId) =>
86
+ (await bound()).writeOk(type, payload, requestId),
87
+ writeErr: async (type, payload) => (await bound()).writeErr(type, payload),
88
+ queryOk: async (type, payload) => (await bound()).queryOk(type, payload),
89
+ queryErr: async (type, payload) => (await bound()).queryErr(type, payload),
80
90
  };
91
+ apiByUserId.set(user.id, api);
92
+ return api;
93
+ };
81
94
 
82
- await use(async (opts = {}) => {
83
- const seeded = await postSeedRoute(
84
- request,
85
- SEED_ROUTES.seedTenant,
86
- { name: opts.name, members: opts.users },
87
- seedTenantResponseSchema,
88
- );
89
- const tenantId = seeded.id;
90
- const toUser = (credentials: SeededCredentials, roles: readonly string[]): SeededUser =>
91
- withSession(credentials, tenantId, roles);
92
-
93
- const admin = toUser(seeded.admin, [ROLES.TenantAdmin]);
94
- await loginViaApi(context.request, admin);
95
-
96
- const tenant: E2eSeededTenant = {
97
- id: tenantId,
98
- key: seeded.key,
99
- name: seeded.name,
100
- admin,
101
- members: seeded.members.map((member) => toUser(member, [ROLES.Member])),
102
- addUser: async (roles = [ROLES.Member]) =>
103
- toUser(
104
- await postSeedRoute(
105
- request,
106
- SEED_ROUTES.seedUser,
107
- { tenantId, roles: [...roles] } satisfies SeedUserRequest,
108
- seedUserResponseSchema,
109
- ),
110
- roles,
95
+ const loginAs = async (page: Page, user: SeededUser): Promise<void> => {
96
+ await page.context().clearCookies();
97
+ await loginViaApi(page.context().request, user);
98
+ };
99
+
100
+ await use(async (opts = {}) => {
101
+ if (baseURL === undefined) {
102
+ throw new Error("seedTenant: the Playwright config has no baseURL");
103
+ }
104
+ const seeded = await postSeedRoute(
105
+ request,
106
+ SEED_ROUTES.seedTenant,
107
+ { name: opts.name, members: opts.users },
108
+ seedTenantResponseSchema,
109
+ );
110
+ const tenantId = seeded.id;
111
+ const toUser = (credentials: SeededCredentials, roles: readonly string[]): SeededUser =>
112
+ withSession(credentials, tenantId, roles);
113
+
114
+ const admin = toUser(seeded.admin, [ROLES.TenantAdmin]);
115
+ await loginViaApi(context.request, admin);
116
+
117
+ const tenant: E2eSeededTenant = {
118
+ id: tenantId,
119
+ key: seeded.key,
120
+ name: seeded.name,
121
+ admin,
122
+ members: seeded.members.map((member) => toUser(member, [ROLES.Member])),
123
+ addUser: async (roles = [ROLES.Member]) =>
124
+ toUser(
125
+ await postSeedRoute(
126
+ request,
127
+ SEED_ROUTES.seedUser,
128
+ { tenantId, roles: [...roles] } satisfies SeedUserRequest,
129
+ seedUserResponseSchema,
111
130
  ),
112
- api: httpApiFor(admin),
113
- apiAs: httpApiFor,
114
- loginAs,
115
- };
131
+ roles,
132
+ ),
133
+ api: httpApiFor(admin),
134
+ apiAs: httpApiFor,
135
+ loginAs,
136
+ };
116
137
 
117
- for (const part of opts.with ?? []) await part({ tenant });
118
- return tenant;
119
- });
138
+ for (const part of opts.with ?? []) await part({ tenant });
139
+ return tenant;
140
+ });
120
141
 
121
- await Promise.all(openedContexts.map((ctx) => ctx.dispose()));
122
- },
142
+ await Promise.all(openedContexts.map((ctx) => ctx.dispose()));
143
+ }
144
+
145
+ export const test = base.extend<E2eFixtures>({
146
+ seedTenant: provideSeedTenant,
123
147
  });
@@ -1,2 +1,2 @@
1
1
  // biome-ignore-all lint/suspicious/noConsole: the preload exists to print this line.
2
- console.log("[kumiko-test] CI profile active — config=bunfig.ci.toml concurrency=1");
2
+ console.log("[kumiko-test] CI profile active — config=bunfig.ci.toml");