@cosmicdrift/kumiko-testing 0.371.0 → 0.372.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
@@ -49,6 +49,12 @@ test("member sees the note", async ({ seedTenant, page }) => {
49
49
 
50
50
  - `defineAppE2eConfig({ port, env?, locale?, projects?, serverEntry?, testDir?, testMatch? })`: parallelism, retries, timeouts and
51
51
  workers (`KUMIKO_E2E_WORKERS` overrides) are fixed by the template; projects cannot set them.
52
+ - `KUMIKO_E2E_REMOTE_BASE_URL=https://staging.example.com` runs the same specs against an already running
53
+ instance: no web server is started and `baseURL` is that URL (http or https only). Add
54
+ `KUMIKO_E2E_STORAGE_STATE=<file>` for a session recorded with `playwright codegen --save-storage`;
55
+ timeouts, viewport and DPR stay with the template. The seed routes do not exist on such a target, so use
56
+ it for specs that only need an existing session. Trace, screenshot and video are off there, because a trace zip holds the target's session cookie and data. `csrfFetch` handles the `__Host-` prefixed csrf cookie
57
+ an https target sets.
52
58
  - The seed routes answer only with `KUMIKO_TEST_SEED=1`, outside `NODE_ENV=production`, and with the
53
59
  per-run `KUMIKO_TEST_SEED_TOKEN` in the `x-kumiko-test-seed` header. `defineAppE2eConfig` sets all three.
54
60
  - The template also sets `KUMIKO_DEV_STYLESHEET_WATCH=0`, so the dev-server builds the app's CSS once and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-testing",
3
- "version": "0.371.0",
3
+ "version": "0.372.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>",
@@ -95,8 +95,8 @@
95
95
  "kumiko-testing": "./bin/kumiko-testing.ts"
96
96
  },
97
97
  "dependencies": {
98
- "@cosmicdrift/kumiko-framework": "0.371.0",
99
- "@cosmicdrift/kumiko-types": "0.371.0",
98
+ "@cosmicdrift/kumiko-framework": "0.372.0",
99
+ "@cosmicdrift/kumiko-types": "0.372.0",
100
100
  "zod": "^4.4.3"
101
101
  },
102
102
  "peerDependencies": {
@@ -128,8 +128,8 @@
128
128
  }
129
129
  },
130
130
  "devDependencies": {
131
- "@cosmicdrift/kumiko-bundled-features": "0.371.0",
132
- "@cosmicdrift/kumiko-dev-server": "0.371.0",
131
+ "@cosmicdrift/kumiko-bundled-features": "0.372.0",
132
+ "@cosmicdrift/kumiko-dev-server": "0.372.0",
133
133
  "@happy-dom/global-registrator": "^20.11.2",
134
134
  "@playwright/test": "^1.62.1",
135
135
  "@testing-library/dom": "^10.4.1",
package/src/changes.json CHANGED
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.372.0",
4
+ "type": "improvement",
5
+ "title": "defineAppE2eConfig runs against a remote instance via KUMIKO_E2E_REMOTE_BASE_URL; csrfFetch handles the __Host- csrf cookie",
6
+ "detail": "KUMIKO_E2E_STORAGE_STATE supplies a recorded session. Without the remote env nothing changes."
7
+ },
2
8
  {
3
9
  "version": "0.371.0",
4
10
  "type": "improvement",
@@ -5,7 +5,7 @@ import type { WriteErrorInfo } from "@cosmicdrift/kumiko-framework/errors";
5
5
  import { type APIRequestContext, type APIResponse, expect, type Page } from "@playwright/test";
6
6
  import * as z from "zod";
7
7
  import type { BoundApi } from "../seed-types";
8
- import { CSRF_COOKIE_NAME, CSRF_HEADER_NAME } from "./constants";
8
+ import { CSRF_COOKIE_NAMES, CSRF_HEADER_NAME } from "./constants";
9
9
 
10
10
  export type LoginCredentials = {
11
11
  readonly email: string;
@@ -37,8 +37,11 @@ const errorEnvelope = z.looseObject({ error: z.looseObject({ code: z.string() })
37
37
  export function csrfHeaderFromCookies(
38
38
  cookies: readonly { readonly name: string; readonly value: string }[],
39
39
  ): Record<string, string> {
40
- const token = cookies.find((cookie) => cookie.name === CSRF_COOKIE_NAME)?.value;
41
- return token === undefined ? {} : { [CSRF_HEADER_NAME]: token };
40
+ for (const name of CSRF_COOKIE_NAMES) {
41
+ const token = cookies.find((cookie) => cookie.name === name)?.value;
42
+ if (token !== undefined) return { [CSRF_HEADER_NAME]: token };
43
+ }
44
+ return {};
42
45
  }
43
46
 
44
47
  export async function csrfFetch(
@@ -1,4 +1,8 @@
1
- import { CSRF_COOKIE_BASE, sessionCookieName } from "@cosmicdrift/kumiko-types/session-cookies";
1
+ import {
2
+ CSRF_COOKIE_BASE,
3
+ sessionCookieName,
4
+ sessionCookieNameCandidates,
5
+ } from "@cosmicdrift/kumiko-types/session-cookies";
2
6
 
3
7
  export const KUMIKO_SECRETS_MASTER_KEY_V1 = "a3VtaWtvLXNjcmVlbnNob3QtZGV2LW1hc3Rlci0zMmI=";
4
8
 
@@ -11,6 +15,9 @@ export const PLAYWRIGHT_DEMO_ENV = {
11
15
 
12
16
  // e2e servers run on plain http, so the cookies are never Secure and keep the unprefixed names.
13
17
  export const CSRF_COOKIE_NAME = sessionCookieName(CSRF_COOKIE_BASE, { secure: false });
18
+ // Against a remote https target the server sets the `__Host-` prefixed cookie, so reading a csrf
19
+ // cookie has to try both names, prefixed first.
20
+ export const CSRF_COOKIE_NAMES = sessionCookieNameCandidates(CSRF_COOKIE_BASE);
14
21
  export const CSRF_HEADER_NAME = "X-CSRF-Token";
15
22
 
16
23
  // Duplicated from kumiko-framework (SEED_MODE_ENV): the Playwright config runs under Node
@@ -19,6 +26,8 @@ export const SEED_ENABLE_ENV = "KUMIKO_TEST_SEED";
19
26
  export const SEED_TOKEN_ENV = "KUMIKO_TEST_SEED_TOKEN";
20
27
  export const SEED_TOKEN_HEADER = "x-kumiko-test-seed";
21
28
  export const E2E_WORKERS_ENV = "KUMIKO_E2E_WORKERS";
29
+ export const E2E_REMOTE_BASE_URL_ENV = "KUMIKO_E2E_REMOTE_BASE_URL";
30
+ export const E2E_STORAGE_STATE_ENV = "KUMIKO_E2E_STORAGE_STATE";
22
31
 
23
32
  // Same literal as kumiko-dev-server's createKumikoServer (STYLESHEET_WATCH_ENV),
24
33
  // duplicated because the Playwright config runs under Node and can't import
@@ -17,6 +17,8 @@ import { isRealProviderRun } from "../helpers/real-providers.ts";
17
17
  import { SERVICE_ENV_DEFAULTS } from "../preload/service-env-defaults-values";
18
18
  import {
19
19
  DESKTOP_VIEWPORT,
20
+ E2E_REMOTE_BASE_URL_ENV,
21
+ E2E_STORAGE_STATE_ENV,
20
22
  E2E_WORKERS_ENV,
21
23
  PLAYWRIGHT_DEMO_ENV,
22
24
  PROD_BUNDLES_ENV,
@@ -187,6 +189,50 @@ function seedTokenForRun(): string {
187
189
  return process.env[SEED_TOKEN_ENV];
188
190
  }
189
191
 
192
+ type RemoteTarget = { readonly baseURL: string; readonly storageState: string | undefined };
193
+
194
+ // A set KUMIKO_E2E_REMOTE_BASE_URL points the run at an already running instance (staging, prod)
195
+ // instead of a locally started server. The session comes from a Playwright storageState file the
196
+ // operator recorded, so no login is automated against the remote.
197
+ function remoteTargetFromEnv(
198
+ env: Readonly<Record<string, string | undefined>>,
199
+ ): RemoteTarget | undefined {
200
+ const rawBaseUrl = env[E2E_REMOTE_BASE_URL_ENV];
201
+ const storageState = env[E2E_STORAGE_STATE_ENV];
202
+ const hasStorageState = storageState !== undefined && storageState !== "";
203
+ if (rawBaseUrl === undefined || rawBaseUrl === "") {
204
+ if (hasStorageState) {
205
+ throw new Error(
206
+ `defineAppE2eConfig: ${E2E_STORAGE_STATE_ENV} is only used together with ${E2E_REMOTE_BASE_URL_ENV}`,
207
+ );
208
+ }
209
+ return undefined;
210
+ }
211
+ let url: URL;
212
+ try {
213
+ url = new URL(rawBaseUrl);
214
+ } catch {
215
+ throw new Error(
216
+ `defineAppE2eConfig: ${E2E_REMOTE_BASE_URL_ENV} must be an http(s) URL, got "${rawBaseUrl}"`,
217
+ );
218
+ }
219
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
220
+ throw new Error(
221
+ `defineAppE2eConfig: ${E2E_REMOTE_BASE_URL_ENV} must be an http(s) URL, got "${rawBaseUrl}"`,
222
+ );
223
+ }
224
+ return { baseURL: rawBaseUrl, storageState: hasStorageState ? storageState : undefined };
225
+ }
226
+
227
+ // A remote target is a real deployment: trace zips hold its session cookie and data, and a
228
+ // failure screenshot or video shows them, so nothing is recorded there.
229
+ function failureRecording(
230
+ remote: RemoteTarget | undefined,
231
+ ): Pick<NonNullable<PlaywrightTestConfig["use"]>, "trace" | "screenshot" | "video"> {
232
+ if (remote === undefined) return { trace: "retain-on-failure", screenshot: "only-on-failure" };
233
+ return { trace: "off", screenshot: "off", video: "off" };
234
+ }
235
+
190
236
  export function defineAppE2eConfig(input: AppE2eConfigInput): PlaywrightTestConfig {
191
237
  const {
192
238
  port,
@@ -208,7 +254,8 @@ export function defineAppE2eConfig(input: AppE2eConfigInput): PlaywrightTestConf
208
254
  if (!isScreenshotRun() && /(?:^|\/)screenshots\/?$/.test(testDir)) {
209
255
  requireScreenshotDir();
210
256
  }
211
- const baseURL = `http://localhost:${port}`;
257
+ const remote = remoteTargetFromEnv(process.env);
258
+ const baseURL = remote?.baseURL ?? `http://localhost:${port}`;
212
259
  const realRun = isRealProviderRun();
213
260
 
214
261
  return defineConfig({
@@ -227,10 +274,10 @@ export function defineAppE2eConfig(input: AppE2eConfigInput): PlaywrightTestConf
227
274
  use: {
228
275
  baseURL,
229
276
  locale,
230
- trace: "retain-on-failure",
231
- screenshot: "only-on-failure",
277
+ ...failureRecording(remote),
232
278
  actionTimeout: E2E_TIMEOUT_MS.action,
233
279
  navigationTimeout: E2E_TIMEOUT_MS.navigation,
280
+ ...(remote?.storageState !== undefined && { storageState: remote.storageState }),
234
281
  ...(permissions !== undefined && { permissions: [...permissions] }),
235
282
  ...(launchOptions !== undefined && { launchOptions }),
236
283
  },
@@ -245,36 +292,38 @@ export function defineAppE2eConfig(input: AppE2eConfigInput): PlaywrightTestConf
245
292
  },
246
293
  ...projects,
247
294
  ],
248
- webServer: {
249
- command: serverCommand,
250
- url: baseURL,
251
- env: {
252
- ...infraEnvDefaults(),
253
- ...PLAYWRIGHT_DEMO_ENV,
254
- ...env,
255
- PORT: String(port),
256
- [SEED_ENABLE_ENV]: "1",
257
- [SEED_TOKEN_ENV]: seedTokenForRun(),
258
- // A Tailwind --watch process subscribes to the whole app cwd
259
- // recursively (Tailwind v4, no gitignore filter); every Playwright
260
- // artifact write under test-results/ would then count as a rebuild.
261
- [STYLESHEET_WATCH_ENV]: "0",
262
- // E2E runs against prod-shaped bundles: splitting, no sourcemap,
263
- // NODE_ENV=production — matches what actually ships.
264
- [PROD_BUNDLES_ENV]: "1",
265
- // E2e clients all share ::1, so the default trustedProxyHops (0,
266
- // socket-only) would collapse every seeded user into one rate-limit
267
- // bucket. loginViaApi sends a synthetic per-user X-Forwarded-For, so
268
- // trust exactly one hop to give each user its own bucket. An explicit
269
- // `trustedProxyHops` / `auth.trustedProxyHops` in the consumer's
270
- // server entrypoint wins over this env and brings the shared bucket
271
- // (and 429s) back — such a server must read the value from the env.
272
- [TRUSTED_PROXY_HOPS_ENV]: "1",
295
+ ...(remote === undefined && {
296
+ webServer: {
297
+ command: serverCommand,
298
+ url: baseURL,
299
+ env: {
300
+ ...infraEnvDefaults(),
301
+ ...PLAYWRIGHT_DEMO_ENV,
302
+ ...env,
303
+ PORT: String(port),
304
+ [SEED_ENABLE_ENV]: "1",
305
+ [SEED_TOKEN_ENV]: seedTokenForRun(),
306
+ // A Tailwind --watch process subscribes to the whole app cwd
307
+ // recursively (Tailwind v4, no gitignore filter); every Playwright
308
+ // artifact write under test-results/ would then count as a rebuild.
309
+ [STYLESHEET_WATCH_ENV]: "0",
310
+ // E2E runs against prod-shaped bundles: splitting, no sourcemap,
311
+ // NODE_ENV=production — matches what actually ships.
312
+ [PROD_BUNDLES_ENV]: "1",
313
+ // E2e clients all share ::1, so the default trustedProxyHops (0,
314
+ // socket-only) would collapse every seeded user into one rate-limit
315
+ // bucket. loginViaApi sends a synthetic per-user X-Forwarded-For, so
316
+ // trust exactly one hop to give each user its own bucket. An explicit
317
+ // `trustedProxyHops` / `auth.trustedProxyHops` in the consumer's
318
+ // server entrypoint wins over this env and brings the shared bucket
319
+ // (and 429s) back — such a server must read the value from the env.
320
+ [TRUSTED_PROXY_HOPS_ENV]: "1",
321
+ },
322
+ reuseExistingServer: false,
323
+ timeout: E2E_TIMEOUT_MS.webServer,
324
+ stdout: "pipe",
325
+ stderr: "pipe",
273
326
  },
274
- reuseExistingServer: false,
275
- timeout: E2E_TIMEOUT_MS.webServer,
276
- stdout: "pipe",
277
- stderr: "pipe",
278
- },
327
+ }),
279
328
  });
280
329
  }
@@ -15,7 +15,7 @@ import {
15
15
  export type RecordingProvider = ObservabilityProvider & {
16
16
  readonly spans: readonly RecordedSpan[];
17
17
  readonly metricEvents: readonly MetricEvent[];
18
- // Returns spans filtered by name — handy for `.find(s => s.name === "http.request")`.
18
+ // Returns spans filtered by name — handy for `.find(s => s.name === "POST /api/write")`.
19
19
  spansByName(name: string): readonly RecordedSpan[];
20
20
  // All spans sharing a trace id — use this to reconstruct a single request's tree.
21
21
  spansByTraceId(traceId: string): readonly RecordedSpan[];
@@ -44,6 +44,6 @@ export function updateRows<TRow = unknown>(
44
44
  return updateMany<TRow>(db, table as EntityTableMeta, set, where);
45
45
  }
46
46
 
47
- export function deleteRows(db: AnyDb, table: SeedTable, where: WhereObject): Promise<void> {
47
+ export function deleteRows(db: AnyDb, table: SeedTable, where: WhereObject): Promise<number> {
48
48
  return deleteMany(db, table as EntityTableMeta, where);
49
49
  }
@@ -1,6 +1,6 @@
1
1
  // @runtime dev
2
2
  import type { JwtHelper, SessionCreator } from "@cosmicdrift/kumiko-framework/api";
3
- import type { SessionUser } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { type SessionUser, SYSTEM_ROLE } from "@cosmicdrift/kumiko-framework/engine";
4
4
  import type { Hono } from "hono";
5
5
 
6
6
  export type BatchCommand = { type: string; payload: unknown };
@@ -132,6 +132,11 @@ export function createRequestHelper(
132
132
  const sidByUserKey = new Map<string, Promise<string>>();
133
133
 
134
134
  async function authHeader(user: SessionUser): Promise<Record<string, string>> {
135
+ if (user.roles.includes(SYSTEM_ROLE)) {
136
+ throw new Error(
137
+ "system identity is in-process only: JWT verify strips the system role; use stack.dispatcher.write/query",
138
+ );
139
+ }
135
140
  let forJwt = user;
136
141
  if (options.sessionCreator && !user.sid) {
137
142
  const key = `${user.id}:${user.tenantId}`;
@@ -368,8 +368,10 @@ export async function setupTestStack(options: TestStackOptions): Promise<TestSta
368
368
  // exist before the first upload. Skipped when no provider — the table
369
369
  // stays off tenant test DBs that never touch files.
370
370
  if (options.files) {
371
- const { fileRefsTable } = await import("@cosmicdrift/kumiko-framework/files");
372
- await unsafePushTables(testDb.db, { fileRefsTable });
371
+ const { fileRefsTable, fileUploadLedgerTable } = await import(
372
+ "@cosmicdrift/kumiko-framework/files"
373
+ );
374
+ await unsafePushTables(testDb.db, { fileRefsTable, fileUploadLedgerTable });
373
375
  }
374
376
 
375
377
  // Same table list as `kumiko schema generate` (collectTableMetas) — divergence
@@ -444,7 +446,8 @@ export async function setupTestStack(options: TestStackOptions): Promise<TestSta
444
446
  | undefined;
445
447
  if (options.files) {
446
448
  const provider = options.files.storageProvider;
447
- fileProviderResolver = () => Promise.resolve(provider);
449
+ const { withUploadLedger } = await import("@cosmicdrift/kumiko-framework/files");
450
+ fileProviderResolver = withUploadLedger(() => Promise.resolve(provider), testDb.db);
448
451
  }
449
452
 
450
453
  const observability = options.observability ?? createNoopProvider();