@cosmicdrift/kumiko-testing 0.367.0 → 0.369.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 +31 -6
- package/src/bunfig.ts +1 -1
- package/src/e2e/define-app-e2e-config.ts +2 -2
- package/src/e2e/index.ts +3 -3
- package/src/e2e/poll.ts +1 -1
- package/src/e2e/screenshots.ts +2 -2
- package/src/e2e/seed-route.ts +5 -4
- package/src/helpers/access-assertions.ts +15 -0
- package/src/helpers/assertions.ts +35 -0
- package/src/helpers/boot-validator-fixture.ts +127 -0
- package/src/helpers/closed-connection-error.ts +59 -0
- package/src/helpers/db-cleanup.ts +41 -0
- package/src/helpers/e2e-generator.ts +496 -0
- package/src/helpers/expect-error.ts +25 -0
- package/src/helpers/file-provider-contract.ts +123 -0
- package/src/helpers/http-cookies.ts +52 -0
- package/src/helpers/index.ts +67 -0
- package/src/helpers/late-bound.ts +41 -0
- package/src/helpers/manual-clock.ts +28 -0
- package/src/helpers/multipart-helper.ts +96 -0
- package/src/helpers/mutable-master-key-provider.ts +57 -0
- package/src/helpers/observability-recorder.ts +54 -0
- package/src/helpers/real-providers.ts +30 -0
- package/src/helpers/seed.ts +49 -0
- package/src/helpers/shared-entities.ts +65 -0
- package/src/helpers/utils.ts +1 -0
- package/src/helpers/wait-for.ts +50 -0
- package/src/helpers/without-ambient-temporal.ts +14 -0
- package/src/helpers/zip-fixture.ts +41 -0
- package/src/preload/real.ts +1 -1
- package/src/scaffold.ts +2 -2
- package/src/seed-tenant.ts +1 -1
- package/src/setup-app-test-stack.ts +1 -1
- package/src/stack/db.ts +94 -0
- package/src/stack/drain-event-consumers.ts +81 -0
- package/src/stack/drain-jobs.ts +188 -0
- package/src/stack/event-collector.ts +24 -0
- package/src/stack/index.ts +28 -0
- package/src/stack/push-entity-projection-tables.ts +102 -0
- package/src/stack/redis.ts +78 -0
- package/src/stack/request-helper.ts +298 -0
- package/src/stack/table-helpers.ts +47 -0
- package/src/stack/test-stack.ts +715 -0
- /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
|
+
}
|