@cosmicdrift/kumiko-testing 0.367.0 → 0.368.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.
Files changed (44) hide show
  1. package/package.json +31 -6
  2. package/src/bunfig.ts +1 -1
  3. package/src/e2e/define-app-e2e-config.ts +2 -2
  4. package/src/e2e/index.ts +3 -3
  5. package/src/e2e/poll.ts +1 -1
  6. package/src/e2e/screenshots.ts +2 -2
  7. package/src/e2e/seed-route.ts +5 -4
  8. package/src/helpers/access-assertions.ts +15 -0
  9. package/src/helpers/assertions.ts +35 -0
  10. package/src/helpers/boot-validator-fixture.ts +127 -0
  11. package/src/helpers/closed-connection-error.ts +59 -0
  12. package/src/helpers/db-cleanup.ts +41 -0
  13. package/src/helpers/e2e-generator.ts +496 -0
  14. package/src/helpers/expect-error.ts +25 -0
  15. package/src/helpers/file-provider-contract.ts +123 -0
  16. package/src/helpers/http-cookies.ts +52 -0
  17. package/src/helpers/index.ts +67 -0
  18. package/src/helpers/late-bound.ts +41 -0
  19. package/src/helpers/manual-clock.ts +28 -0
  20. package/src/helpers/multipart-helper.ts +96 -0
  21. package/src/helpers/mutable-master-key-provider.ts +57 -0
  22. package/src/helpers/observability-recorder.ts +54 -0
  23. package/src/helpers/real-providers.ts +30 -0
  24. package/src/helpers/seed.ts +49 -0
  25. package/src/helpers/shared-entities.ts +65 -0
  26. package/src/helpers/utils.ts +1 -0
  27. package/src/helpers/wait-for.ts +50 -0
  28. package/src/helpers/without-ambient-temporal.ts +14 -0
  29. package/src/helpers/zip-fixture.ts +41 -0
  30. package/src/preload/real.ts +1 -1
  31. package/src/scaffold.ts +2 -2
  32. package/src/seed-tenant.ts +1 -1
  33. package/src/setup-app-test-stack.ts +1 -1
  34. package/src/stack/db.ts +94 -0
  35. package/src/stack/drain-event-consumers.ts +81 -0
  36. package/src/stack/drain-jobs.ts +188 -0
  37. package/src/stack/event-collector.ts +24 -0
  38. package/src/stack/index.ts +28 -0
  39. package/src/stack/push-entity-projection-tables.ts +102 -0
  40. package/src/stack/redis.ts +78 -0
  41. package/src/stack/request-helper.ts +298 -0
  42. package/src/stack/table-helpers.ts +47 -0
  43. package/src/stack/test-stack.ts +715 -0
  44. /package/src/{e2e/timeouts.ts → e2e-timeouts.ts} +0 -0
@@ -0,0 +1,496 @@
1
+ // @runtime tooling
2
+ //
3
+ // E2E generator: derives structured TestSpecs from the registry.
4
+ //
5
+ // Every r.screen(...) is a testable surface. The generator iterates
6
+ // getAllScreens() and builds one set of TestSpecs per screen (four kinds, one
7
+ // per "what must work at minimum"). The specs are JSON-serializable: consumers
8
+ // write them to disk as JSON via a bun script, and a Playwright globalSetup
9
+ // triggers that before every run. The Playwright runner reads the JSON and
10
+ // iterates it with kind-specific handlers against the real renderer.
11
+ //
12
+ // Why two processes: the framework runtime (registry, drizzle, ioredis types,
13
+ // etc.) must NEVER load inside the Playwright worker. It would collide with
14
+ // Playwright's Object.prototype symbols ($$jest-matchers-object) and crash the
15
+ // worker on spec import.
16
+ //
17
+ // Two stages, each testable on its own:
18
+ // 1. generateE2ESpec(registry) — Registry → TestSpec[]
19
+ // 2. generateZodFixture(schema) — ZodType → plausible value
20
+ //
21
+ // Stage 2 is intentionally minimal: string/number/boolean/date/enum/uuid plus
22
+ // ZodOptional/ZodDefault unwrapping. Everything else throws "not supported
23
+ // yet" and gets added when a real caller needs it; faked complex values would
24
+ // only produce false-green tests.
25
+
26
+ import {
27
+ type EntityDefinition,
28
+ type EntityEditScreenDefinition,
29
+ type EntityListScreenDefinition,
30
+ type FieldDefinition,
31
+ isFieldsEditSection,
32
+ normalizeEditField,
33
+ normalizeListColumn,
34
+ parseQn,
35
+ qn,
36
+ type Registry,
37
+ sectionFieldSpecs,
38
+ toKebab,
39
+ } from "@cosmicdrift/kumiko-framework/engine";
40
+ import type * as z from "zod";
41
+
42
+ // --- Spec-Shape ---
43
+
44
+ // Each TestSpec variant carries everything the renderer needs for templating:
45
+ // no registry reference, no lazy lookups. That keeps specs JSON-serializable
46
+ // (debug dump, snapshot comparison) and the renderer pure template.
47
+ export type E2ETestSpec =
48
+ | {
49
+ readonly kind: "list-renders";
50
+ readonly screenQn: string;
51
+ readonly title: string;
52
+ readonly urlPath: string;
53
+ }
54
+ | {
55
+ readonly kind: "list-has-fixture-row";
56
+ readonly screenQn: string;
57
+ readonly title: string;
58
+ readonly urlPath: string;
59
+ readonly writeHandlerQn: string;
60
+ readonly fixture: Readonly<Record<string, unknown>>;
61
+ readonly identifyingValue: string;
62
+ }
63
+ | {
64
+ readonly kind: "edit-validates-required";
65
+ readonly screenQn: string;
66
+ readonly title: string;
67
+ readonly urlPath: string;
68
+ readonly requiredFields: readonly string[];
69
+ }
70
+ | {
71
+ readonly kind: "edit-save-persists";
72
+ readonly screenQn: string;
73
+ readonly title: string;
74
+ readonly urlPath: string;
75
+ readonly listUrlPath?: string;
76
+ readonly fills: readonly EditFillOp[];
77
+ readonly identifyingValue: string;
78
+ readonly identifyingField: string;
79
+ };
80
+
81
+ // Structured instruction for the edit-save-persists renderer. The fixture alone
82
+ // cannot say whether a string belongs in a text field (`.fill`) or a dropdown
83
+ // (`.selectOption`); the generator decides from the FieldDefinition, the
84
+ // renderer only templates.
85
+ export type EditFillOp =
86
+ | { readonly kind: "fill"; readonly field: string; readonly value: string }
87
+ | { readonly kind: "check"; readonly field: string; readonly value: boolean }
88
+ | { readonly kind: "select"; readonly field: string; readonly value: string };
89
+
90
+ export type E2EGeneratorOptions = {
91
+ // Tenant slug/UUID as template placeholder. Defaults to "{tenant}", which the
92
+ // test runner replaces at run time. Set a slug here for fixed tenant-scoped URLs.
93
+ readonly tenantPlaceholder?: string;
94
+ };
95
+
96
+ // --- Stage 1: spec derivation from the registry ---
97
+
98
+ export function generateE2ESpec(
99
+ registry: Registry,
100
+ options: E2EGeneratorOptions = {},
101
+ ): readonly E2ETestSpec[] {
102
+ const tenant = options.tenantPlaceholder ?? "{tenant}";
103
+ const specs: E2ETestSpec[] = [];
104
+
105
+ for (const [screenQn, screen] of registry.getAllScreens()) {
106
+ if (screen.type === "custom") continue; // no generic assumption possible
107
+ // actionForm: no generic E2E spec; the author-defined handler needs
108
+ // author-defined test data the generator does not know.
109
+ if (screen.type === "actionForm") continue;
110
+ // secretMint: same reasoning — author-defined write handler, no generic spec.
111
+ if (screen.type === "secretMint") continue;
112
+ // configEdit: same reasoning. Values are written per field via
113
+ // config:write:set, so a spec without CRUD state would be worthless.
114
+ // Branding/SMTP/etc. are author-specific.
115
+ if (screen.type === "configEdit") continue;
116
+ // secretsEdit: dito — writes go through secrets:write:set with
117
+ // plaintext values the generator has no author-defined test data for.
118
+ if (screen.type === "secretsEdit") continue;
119
+ // projectionList: query-driven, author-specific projection; no generic
120
+ // CRUD spec derivable (like actionForm/configEdit).
121
+ if (screen.type === "projectionList") continue;
122
+ // projectionDetail: same reasoning; read-only single-row inspector, no CRUD state.
123
+ if (screen.type === "projectionDetail") continue;
124
+ // dashboard: panel-driven (stat/chart/list queries); same reasoning, no
125
+ // generic CRUD assumption possible.
126
+ if (screen.type === "dashboard") continue;
127
+ const { scope: feature, name: short } = parseQn(screenQn);
128
+ const urlPath = `/t/${tenant}/${feature}/${short}`;
129
+ const title = `${feature}/${short}`;
130
+
131
+ if (screen.type === "entityList") {
132
+ specs.push(...buildListSpecs(screen, screenQn, title, urlPath, registry));
133
+ } else {
134
+ specs.push(...buildEditSpecs(screen, screenQn, title, urlPath, registry, tenant));
135
+ }
136
+ }
137
+
138
+ return specs;
139
+ }
140
+
141
+ function buildListSpecs(
142
+ screen: EntityListScreenDefinition,
143
+ screenQn: string,
144
+ title: string,
145
+ urlPath: string,
146
+ registry: Registry,
147
+ ): E2ETestSpec[] {
148
+ const out: E2ETestSpec[] = [{ kind: "list-renders", screenQn, title, urlPath }];
149
+
150
+ const entity = registry.getEntity(screen.entity);
151
+ if (!entity) return out;
152
+
153
+ const writeHandlerQn = findCreateHandlerQn(registry, screen.entity);
154
+ if (!writeHandlerQn) return out;
155
+
156
+ const fixture = buildEntityFixture(entity);
157
+ const identifyingValue = pickIdentifyingValue(fixture, screen, entity);
158
+ if (identifyingValue === undefined) return out;
159
+
160
+ out.push({
161
+ kind: "list-has-fixture-row",
162
+ screenQn,
163
+ title,
164
+ urlPath,
165
+ writeHandlerQn,
166
+ fixture,
167
+ identifyingValue,
168
+ });
169
+
170
+ return out;
171
+ }
172
+
173
+ function buildEditSpecs(
174
+ screen: EntityEditScreenDefinition,
175
+ screenQn: string,
176
+ title: string,
177
+ urlPath: string,
178
+ registry: Registry,
179
+ tenant: string,
180
+ ): E2ETestSpec[] {
181
+ const out: E2ETestSpec[] = [];
182
+ const entity = registry.getEntity(screen.entity);
183
+ if (!entity) return out;
184
+
185
+ const requiredFields = collectRequiredEditFields(screen, entity);
186
+ if (requiredFields.length > 0) {
187
+ out.push({ kind: "edit-validates-required", screenQn, title, urlPath, requiredFields });
188
+ }
189
+
190
+ const fixture = buildEntityFixture(entity);
191
+ const listScreen = findListScreenForEntity(registry, screen.entity);
192
+ const listUrlPath = listScreen ? buildScreenUrlPath(listScreen.qn, tenant) : undefined;
193
+
194
+ const identifying = pickIdentifyingForEdit(fixture, screen, entity, listScreen?.def);
195
+ if (identifying) {
196
+ const fills = buildEditFillOps(screen, entity, fixture);
197
+ out.push({
198
+ kind: "edit-save-persists",
199
+ screenQn,
200
+ title,
201
+ urlPath,
202
+ listUrlPath,
203
+ fills,
204
+ identifyingValue: identifying.value,
205
+ identifyingField: identifying.field,
206
+ });
207
+ }
208
+
209
+ return out;
210
+ }
211
+
212
+ function buildScreenUrlPath(qn: string, tenant: string): string {
213
+ const { scope, name } = parseQn(qn);
214
+ return `/t/${tenant}/${scope}/${name}`;
215
+ }
216
+
217
+ function findCreateHandlerQn(registry: Registry, entityName: string): string | undefined {
218
+ // feature.writeHandlers is keyed by short name ("task:create"), but the
219
+ // registry lookup needs the qualified form ("tasks:write:task:create").
220
+ // Qualify via qn() + toKebab like the registry itself; that is the contract
221
+ // from qualified-name.ts.
222
+ for (const feature of registry.features.values()) {
223
+ for (const shortName of Object.keys(feature.writeHandlers ?? {})) {
224
+ if (!shortName.endsWith(":create")) continue;
225
+ const qualified = qn(toKebab(feature.name), "write", toKebab(shortName));
226
+ if (registry.getHandlerEntity(qualified) === entityName) return qualified;
227
+ }
228
+ }
229
+ return undefined;
230
+ }
231
+
232
+ function findListScreenForEntity(
233
+ registry: Registry,
234
+ entityName: string,
235
+ ): { qn: string; def: EntityListScreenDefinition } | undefined {
236
+ for (const [qn, screen] of registry.getAllScreens()) {
237
+ if (screen.type === "entityList" && screen.entity === entityName) {
238
+ return { qn, def: screen };
239
+ }
240
+ }
241
+ return undefined;
242
+ }
243
+
244
+ function collectRequiredEditFields(
245
+ screen: EntityEditScreenDefinition,
246
+ entity: EntityDefinition,
247
+ ): string[] {
248
+ const out: string[] = [];
249
+ for (const section of screen.layout.sections) {
250
+ if (!isFieldsEditSection(section)) continue;
251
+ for (const rawField of sectionFieldSpecs(section)) {
252
+ const { field } = normalizeEditField(rawField);
253
+ const def = entity.fields[field];
254
+ if (def && "required" in def && def.required === true) {
255
+ out.push(field);
256
+ }
257
+ }
258
+ }
259
+ return out;
260
+ }
261
+
262
+ function pickIdentifyingValue(
263
+ fixture: Readonly<Record<string, unknown>>,
264
+ screen: EntityListScreenDefinition,
265
+ entity: EntityDefinition,
266
+ ): string | undefined {
267
+ // First text column with a fixture value: the visually most reliable
268
+ // identification in a generic table.
269
+ for (const raw of screen.columns) {
270
+ const { field } = normalizeListColumn(raw);
271
+ if (entity.fields[field]?.type !== "text") continue;
272
+ const v = fixture[field];
273
+ if (typeof v === "string" && v.length > 0) return v;
274
+ }
275
+ return undefined;
276
+ }
277
+
278
+ function buildEditFillOps(
279
+ screen: EntityEditScreenDefinition,
280
+ entity: EntityDefinition,
281
+ fixture: Readonly<Record<string, unknown>>,
282
+ ): EditFillOp[] {
283
+ // Walk the layout order so the result mirrors what a user actually fills in.
284
+ // The interaction kind is decided per field type; fields without a fixture
285
+ // value (file/image/…) are skipped.
286
+ const ops: EditFillOp[] = [];
287
+ for (const section of screen.layout.sections) {
288
+ if (!isFieldsEditSection(section)) continue;
289
+ for (const raw of sectionFieldSpecs(section)) {
290
+ const { field } = normalizeEditField(raw);
291
+ const def = entity.fields[field];
292
+ if (!def) continue;
293
+ const v = fixture[field];
294
+ if (v === undefined) continue;
295
+
296
+ switch (def.type) {
297
+ case "boolean":
298
+ if (typeof v === "boolean") ops.push({ kind: "check", field, value: v });
299
+ break;
300
+ case "select":
301
+ if (typeof v === "string") ops.push({ kind: "select", field, value: v });
302
+ break;
303
+ case "text":
304
+ case "longText":
305
+ case "number":
306
+ case "date":
307
+ case "tz":
308
+ // date: a text input that accepts typed ISO values directly.
309
+ ops.push({ kind: "fill", field, value: String(v) });
310
+ break;
311
+ // embedded/money/timestamp/locatedTimestamp/file/image/files/images:
312
+ // no generic interaction. Since #369 timestamp renders two inputs
313
+ // (date + time) that a single .fill() cannot drive; the test author
314
+ // supplies a hand override or skips it.
315
+ default:
316
+ break;
317
+ }
318
+ }
319
+ }
320
+ return ops;
321
+ }
322
+
323
+ function pickIdentifyingForEdit(
324
+ fixture: Readonly<Record<string, unknown>>,
325
+ editScreen: EntityEditScreenDefinition,
326
+ entity: EntityDefinition,
327
+ listScreen: EntityListScreenDefinition | undefined,
328
+ ): { field: string; value: string } | undefined {
329
+ // Prefer a field that appears as a column in the list screen; otherwise the
330
+ // "shows up in the list" assertion cannot be performed.
331
+ const columns = listScreen
332
+ ? new Set(listScreen.columns.map((c) => normalizeListColumn(c).field))
333
+ : undefined;
334
+
335
+ for (const section of editScreen.layout.sections) {
336
+ if (!isFieldsEditSection(section)) continue;
337
+ for (const rawField of sectionFieldSpecs(section)) {
338
+ const { field } = normalizeEditField(rawField);
339
+ if (entity.fields[field]?.type !== "text") continue;
340
+ if (columns && !columns.has(field)) continue;
341
+ const v = fixture[field];
342
+ if (typeof v === "string" && v.length > 0) return { field, value: v };
343
+ }
344
+ }
345
+ return undefined;
346
+ }
347
+
348
+ // --- Stage 2: Zod fixture ---
349
+
350
+ // Zod 4 stabilizes introspection on `._def.type` (lowercase discriminator),
351
+ // `._def.format` for string formats (email/url/uuid/datetime), `._def.entries`
352
+ // for enums and `._def.innerType` for optional/default/nullable. We access
353
+ // exactly these fields, the shape our buildInsertSchema emits. Other types
354
+ // throw "not supported yet" until a sample concretely needs them.
355
+
356
+ type ZodInternals = {
357
+ readonly type?: string;
358
+ readonly format?: string;
359
+ readonly innerType?: z.ZodTypeAny;
360
+ readonly entries?: Record<string, string>;
361
+ readonly out?: z.ZodTypeAny;
362
+ };
363
+
364
+ function readZodInternals(schema: z.ZodTypeAny): ZodInternals | undefined {
365
+ return (schema as unknown as { _def?: ZodInternals })._def;
366
+ }
367
+
368
+ export function generateZodFixture(schema: z.ZodTypeAny): unknown {
369
+ const def = readZodInternals(schema);
370
+ const typeName = def?.type;
371
+
372
+ switch (typeName) {
373
+ case "optional":
374
+ case "nullable":
375
+ case "default": {
376
+ if (!def?.innerType) throw new Error(`zod ${typeName} without innerType`);
377
+ return generateZodFixture(def.innerType);
378
+ }
379
+ case "string":
380
+ return fixtureString(def?.format);
381
+ case "number":
382
+ return 1;
383
+ case "boolean":
384
+ return true;
385
+ case "enum": {
386
+ const first = def?.entries ? Object.values(def.entries)[0] : undefined;
387
+ return first ?? "";
388
+ }
389
+ case "date":
390
+ return new Date("2026-01-01T00:00:00Z");
391
+ case "pipe": {
392
+ if (!def?.out) throw new Error("zod pipe without out");
393
+ return generateZodFixture(def.out);
394
+ }
395
+ default:
396
+ throw new Error(`generateZodFixture: not supported yet: ${typeName ?? "<unknown>"}`);
397
+ }
398
+ }
399
+
400
+ function fixtureString(format: string | undefined): string {
401
+ if (format === "email") return "e2e@example.com";
402
+ if (format === "url") return "https://example.com";
403
+ if (format === "uuid" || format === "guid") return "00000000-0000-4000-8000-000000000000";
404
+ if (format === "datetime" || format === "date") return "2026-01-01T00:00:00Z";
405
+ return "e2e-fixture";
406
+ }
407
+
408
+ // --- Fixture from FieldDefinition (stage 1, instead of a Zod schema) ---
409
+ //
410
+ // Deliberate duplicate of generateZodFixture; the two have different jobs:
411
+ // generateZodFixture — public, generic, knows only Zod primitives
412
+ // buildEntityFixture — internal, Kumiko domain, knows file skipping,
413
+ // embedded shape, money/tz objects and uses the field
414
+ // name for readable prefixes ("e2e-email@...")
415
+ //
416
+ // Merging them via buildInsertSchema + generateZodFixture would either lose the
417
+ // field-name hint (all email fixtures would get the same value, making unique
418
+ // constraints inconsistent) or push a hint parameter into the public API of
419
+ // generateZodFixture that external callers never need.
420
+ function buildEntityFixture(entity: EntityDefinition): Record<string, unknown> {
421
+ const out: Record<string, unknown> = {};
422
+ for (const [name, field] of Object.entries(entity.fields)) {
423
+ const value = fieldToFixture(name, field);
424
+ if (value !== undefined) out[name] = value;
425
+ }
426
+ return out;
427
+ }
428
+
429
+ function fieldToFixture(name: string, field: FieldDefinition): unknown {
430
+ switch (field.type) {
431
+ case "text": {
432
+ if (field.format === "email") return `e2e-${name}@example.com`;
433
+ if (field.format === "url") return "https://example.com";
434
+ return `e2e ${name}`;
435
+ }
436
+ case "longText":
437
+ // longText has no format options; a generic placeholder is enough.
438
+ return `e2e ${name} (long-form content)`;
439
+ case "boolean":
440
+ return true;
441
+ case "select": {
442
+ const conditional = new Set(field.conditionalOptions?.flatMap((rule) => rule.options));
443
+ return (
444
+ field.default ??
445
+ field.options.find((option) => !conditional.has(option)) ??
446
+ field.options[0] ??
447
+ ""
448
+ );
449
+ }
450
+ case "multiSelect": {
451
+ const first = field.options[0];
452
+ return first ? [first] : [];
453
+ }
454
+ case "number":
455
+ return 1;
456
+ case "money":
457
+ return { amount: 1, currency: "EUR" };
458
+ case "date":
459
+ return "2026-01-01";
460
+ case "timestamp":
461
+ return field.locatedBy !== undefined ? "2026-01-01T00:00:00" : "2026-01-01T00:00:00Z";
462
+ case "tz":
463
+ return "Europe/Berlin";
464
+ case "locatedTimestamp":
465
+ return { at: "2026-01-01T00:00:00", tz: "Europe/Berlin" };
466
+ case "embedded": {
467
+ const sub: Record<string, unknown> = {};
468
+ for (const [subName, subDef] of Object.entries(field.schema)) {
469
+ sub[subName] =
470
+ subDef.type === "text"
471
+ ? `e2e ${subName}`
472
+ : subDef.type === "number" || subDef.type === "decimal"
473
+ ? 1
474
+ : subDef.type === "money"
475
+ ? 100
476
+ : subDef.type === "boolean"
477
+ ? true
478
+ : "2026-01-01";
479
+ }
480
+ return field.multiple === true ? [sub] : sub;
481
+ }
482
+ case "jsonb":
483
+ // Free-form jsonb — e2e-generator returns empty-object.
484
+ return {};
485
+ case "file":
486
+ case "image":
487
+ case "files":
488
+ case "images":
489
+ // File-upload fixtures are not generic yet: Playwright would have to supply
490
+ // file contents, and the write API already expects uploaded fileRef UUIDs.
491
+ // Can be added once a sample settles the paths (M4).
492
+ return undefined;
493
+ default:
494
+ return undefined;
495
+ }
496
+ }
@@ -0,0 +1,25 @@
1
+ import { expect } from "bun:test";
2
+ import type { WriteErrorInfo } from "@cosmicdrift/kumiko-framework/errors";
3
+
4
+ // Vitest's toContain doesn't operate on plain objects, so after the move from
5
+ // string errors to typed WriteErrorInfo the legacy `expect(error).toContain(x)`
6
+ // assertions break. This helper concatenates code, message, and serialized
7
+ // details so existing substring checks against short reason strings keep
8
+ // working without each call site needing to know the new shape.
9
+ //
10
+ // Accepts `string | null` too, so call sites that still get raw strings
11
+ // (e.g. helpers that haven't moved to typed errors yet) keep working.
12
+ export function expectErrorIncludes(
13
+ err: WriteErrorInfo | string | null | undefined,
14
+ substring: string,
15
+ ): void {
16
+ let haystack: string;
17
+ if (err === null || err === undefined) {
18
+ haystack = String(err);
19
+ } else if (typeof err === "string") {
20
+ haystack = err;
21
+ } else {
22
+ haystack = `${err.code} ${err.message} ${JSON.stringify(err.details ?? {})}`;
23
+ }
24
+ expect(haystack).toContain(substring);
25
+ }
@@ -0,0 +1,123 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import type { FileStorageProvider } from "@cosmicdrift/kumiko-framework/files";
3
+
4
+ const bytes = (s: string) => new TextEncoder().encode(s);
5
+ const decode = (u: Uint8Array) => new TextDecoder().decode(u);
6
+
7
+ async function* fromChunks(chunks: Uint8Array[]): AsyncIterable<Uint8Array> {
8
+ for (const c of chunks) yield c;
9
+ }
10
+
11
+ // Mirrors describeKmsAdapterContract (crypto/__tests__/kms-adapter-contract.ts):
12
+ // pins the FileStorageProvider contract against whatever implementation the
13
+ // factory returns. Provider-specific behavior (error messages, buffer
14
+ // copies, multipart details) stays in each provider's dedicated tests.
15
+ export function describeFileProviderContract(
16
+ name: string,
17
+ factory: () => FileStorageProvider | Promise<FileStorageProvider>,
18
+ ): void {
19
+ describe(`${name} — FileStorageProvider contract`, () => {
20
+ let provider: FileStorageProvider;
21
+ // Self-cleaning: every key this contract creates gets deleted in
22
+ // afterEach, so a persistent backend (local `kumiko dev` against a
23
+ // long-lived Minio, as opposed to CI's ephemeral one) doesn't leak
24
+ // `contract/<uuid>` objects into the next developer's session. delete()
25
+ // on a key that was never actually written is covered by the
26
+ // "no-op on a missing key" contract test above, so tracking generated
27
+ // keys unconditionally (even ones a test never got around to writing)
28
+ // is safe.
29
+ let writtenKeys: string[];
30
+
31
+ function trackedKey(ext: string): string {
32
+ const key = `contract/${crypto.randomUUID()}.${ext}`;
33
+ writtenKeys.push(key);
34
+ return key;
35
+ }
36
+
37
+ beforeEach(async () => {
38
+ provider = await factory();
39
+ writtenKeys = [];
40
+ });
41
+
42
+ afterEach(async () => {
43
+ for (const key of writtenKeys) await provider.delete(key);
44
+ });
45
+
46
+ test("write + read roundtrip preserves bytes", async () => {
47
+ const key = trackedKey("bin");
48
+ await provider.write(key, bytes("hello contract"));
49
+ expect(decode(await provider.read(key))).toBe("hello contract");
50
+ });
51
+
52
+ test("write on an existing key overwrites it (last-write-wins)", async () => {
53
+ const key = trackedKey("bin");
54
+ await provider.write(key, bytes("first"));
55
+ await provider.write(key, bytes("second"));
56
+ expect(decode(await provider.read(key))).toBe("second");
57
+ });
58
+
59
+ test("read throws for a missing key", async () => {
60
+ await expect(provider.read(`contract/missing-${crypto.randomUUID()}`)).rejects.toThrow();
61
+ });
62
+
63
+ test("exists reflects write + delete", async () => {
64
+ const key = trackedKey("txt");
65
+ expect(await provider.exists(key)).toBe(false);
66
+ await provider.write(key, bytes("x"));
67
+ expect(await provider.exists(key)).toBe(true);
68
+ await provider.delete(key);
69
+ expect(await provider.exists(key)).toBe(false);
70
+ });
71
+
72
+ test("delete on a missing key is a no-op", async () => {
73
+ await expect(
74
+ provider.delete(`contract/never-existed-${crypto.randomUUID()}`),
75
+ ).resolves.toBeUndefined();
76
+ });
77
+
78
+ test("writeStream + readStream roundtrip preserves bytes", async () => {
79
+ const key = trackedKey("stream");
80
+ await provider.writeStream(key, fromChunks([bytes("foo"), bytes("bar")]));
81
+ let out = "";
82
+ for await (const chunk of provider.readStream(key)) out += decode(chunk);
83
+ expect(out).toBe("foobar");
84
+ });
85
+
86
+ test("readStream on a missing key throws on the first chunk pull", async () => {
87
+ const it = provider
88
+ .readStream(`contract/missing-${crypto.randomUUID()}`)
89
+ [Symbol.asyncIterator]();
90
+ await expect(it.next()).rejects.toThrow();
91
+ });
92
+
93
+ test("list returns only keys under the given prefix", async () => {
94
+ const group = `contract/list-${crypto.randomUUID()}`;
95
+ const keyA = `${group}/a.txt`;
96
+ const keyB = `${group}/b.txt`;
97
+ const outsideKey = `contract/list-${crypto.randomUUID()}/c.txt`;
98
+ writtenKeys.push(keyA, keyB, outsideKey);
99
+ await provider.write(keyA, bytes("a"));
100
+ await provider.write(keyB, bytes("b"));
101
+ await provider.write(outsideKey, bytes("c"));
102
+
103
+ const listed = await provider.list(`${group}/`);
104
+ expect(new Set(listed)).toEqual(new Set([keyA, keyB]));
105
+ });
106
+
107
+ test("list on a prefix with no matches returns an empty array", async () => {
108
+ const listed = await provider.list(`contract/list-missing-${crypto.randomUUID()}/`);
109
+ expect(listed).toEqual([]);
110
+ });
111
+
112
+ test("getSignedUrl, when implemented, returns a URL string", async () => {
113
+ // skip: getSignedUrl is optional on the contract — feature-detected
114
+ if (!provider.getSignedUrl) return;
115
+
116
+ const key = trackedKey("txt");
117
+ await provider.write(key, bytes("signed"));
118
+ const url = await provider.getSignedUrl(key, 60);
119
+ expect(typeof url).toBe("string");
120
+ expect(url.length).toBeGreaterThan(0);
121
+ });
122
+ });
123
+ }
@@ -0,0 +1,52 @@
1
+ // Shared Set-Cookie parser for tests. Cookie-auth tests (auth-middleware,
2
+ // csrf-middleware, auth-routes, auth.integration) all need the same
3
+ // primitive: "given a Response, give me the cookies the server tried to
4
+ // set". Before this helper each test file rolled its own — three near-
5
+ // duplicates that drift the moment one adds Domain/Partitioned/Priority
6
+ // parsing the others don't.
7
+ //
8
+ // The helper returns both the parsed value AND the raw string so callers
9
+ // can assert on attributes like SameSite, HttpOnly, Max-Age without a
10
+ // second parse step.
11
+
12
+ export type ParsedSetCookie = {
13
+ readonly value: string;
14
+ readonly raw: string;
15
+ };
16
+
17
+ // Pull every Set-Cookie the response carries. Prefers the standard
18
+ // `Headers.getSetCookie()` (Node 20+, Bun, modern undici/whatwg-fetch);
19
+ // falls back to the single-header-value for environments that don't
20
+ // expose it. The fallback path only sees the FIRST cookie if multiple
21
+ // were set in one response — a limitation of RFC 7230 headers — which
22
+ // is acceptable here because tests that set multiple cookies run on a
23
+ // runtime that supports getSetCookie.
24
+ export function getSetCookies(res: Response): Map<string, ParsedSetCookie> {
25
+ const getter = (res.headers as { getSetCookie?: () => string[] }).getSetCookie; // @cast-boundary engine-bridge
26
+ const raws = getter ? getter.call(res.headers) : [res.headers.get("set-cookie") ?? ""];
27
+ const out = new Map<string, ParsedSetCookie>();
28
+ for (const raw of raws) {
29
+ if (!raw) continue;
30
+ const first = raw.split(";")[0];
31
+ if (!first) continue;
32
+ const eq = first.indexOf("=");
33
+ if (eq === -1) continue;
34
+ const name = first.slice(0, eq).trim();
35
+ const value = first.slice(eq + 1).trim();
36
+ out.set(name, { value, raw });
37
+ }
38
+ return out;
39
+ }
40
+
41
+ // Convenience for the common case: "give me the value of cookie X".
42
+ // Returns undefined when the cookie isn't set — no throw, so negative
43
+ // assertions read cleanly (`expect(cookies.get("foo")).toBeUndefined()`).
44
+ export function getSetCookieValue(res: Response, name: string): string | undefined {
45
+ return getSetCookies(res).get(name)?.value;
46
+ }
47
+
48
+ // Raw Set-Cookie header for attribute assertions
49
+ // (`expect(raw).toMatch(/SameSite=Lax/)`). Returns undefined when missing.
50
+ export function getSetCookieRaw(res: Response, name: string): string | undefined {
51
+ return getSetCookies(res).get(name)?.raw;
52
+ }