@human-synthesis/norns 0.1.0 → 0.2.1

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.
@@ -151,6 +151,128 @@ const Component = v.strictObject({
151
151
  slots: v.optional(v.array(ident))
152
152
  });
153
153
 
154
+ // Snippet (U-07) — a typed render fragment for a palette slot (cell
155
+ // renderers, empty states). Declared args are the slot's calling
156
+ // convention; the body lives in `src/<m>/snippets/<name>.n` and the page
157
+ // emitter wraps it in a `+snippet` forwarding the args as props.
158
+ const Snippet = v.strictObject({
159
+ uid,
160
+ args: v.optional(v.array(ident)),
161
+ description: v.optional(v.string())
162
+ });
163
+
164
+ // Service (D15) — an external system as a typed operation manifest.
165
+ // Credentials never appear here: `auth.binding` is an env binding *name*
166
+ // (UPPER_SNAKE); a literal secret anywhere in a service is refused at
167
+ // generate time (SECRET_IN_SPEC, generate.js).
168
+ const bindingName = v.pipe(
169
+ v.string(),
170
+ v.regex(/^[A-Z][A-Z0-9_]*$/, 'must be an UPPER_SNAKE env binding name, never a secret value')
171
+ );
172
+
173
+ const ServiceAuth = v.pipe(
174
+ v.strictObject({
175
+ mode: v.picklist(['none', 'bearer', 'basic', 'hmac', 'header']),
176
+ binding: v.optional(bindingName),
177
+ header: v.optional(v.pipe(v.string(), v.regex(/^[A-Za-z][A-Za-z0-9-]*$/, 'must be a header name')))
178
+ }),
179
+ v.check(
180
+ (a) => (a.mode === 'none' ? a.binding === undefined : a.binding !== undefined),
181
+ "auth modes other than 'none' require a `binding` name; 'none' must not have one"
182
+ ),
183
+ v.check((a) => a.mode === 'header' || a.header === undefined, "`header` is only valid with mode 'header'"),
184
+ v.check((a) => a.mode !== 'header' || a.header !== undefined, "auth mode 'header' requires `header`")
185
+ );
186
+
187
+ const ServiceOperation = v.strictObject({
188
+ method: v.optional(v.picklist(['GET', 'POST', 'PUT', 'PATCH', 'DELETE'])),
189
+ path: v.optional(v.pipe(v.string(), v.regex(/^\//, 'path must start with "/"'))),
190
+ input: v.optional(v.record(ident, v.union([v.string(), v.record(v.string(), v.unknown())]))),
191
+ output: v.optional(v.unknown())
192
+ });
193
+
194
+ const Service = v.strictObject({
195
+ uid,
196
+ base: v.pipe(v.string(), v.url('base must be an absolute URL')),
197
+ auth: ServiceAuth,
198
+ operations: v.pipe(
199
+ v.record(ident, ServiceOperation),
200
+ v.check((ops) => Object.keys(ops).length > 0, 'services require at least one operation')
201
+ )
202
+ });
203
+
204
+ // Job (D14/K-22) — durable work. Retry policy is required by guardrail:
205
+ // a job without declared failure behavior is refused at the shape level.
206
+ // Jobs run from `enqueue` steps via the events bus (`job:<address>`
207
+ // messages) — Cloudflare Queues in production, inline in dev.
208
+ const queueName = v.pipe(
209
+ v.string(),
210
+ v.regex(/^[a-z][a-z0-9-]*$/, 'must be a queue name (lowercase, digits, dashes)')
211
+ );
212
+
213
+ const Job = v.pipe(
214
+ v.strictObject({
215
+ uid,
216
+ input: v.optional(v.record(ident, v.union([v.string(), v.record(v.string(), v.unknown())]))),
217
+ retry: v.strictObject({
218
+ attempts: v.pipe(v.number(), v.integer(), v.minValue(1), v.maxValue(20)),
219
+ backoff: v.picklist(['none', 'fixed', 'exponential']),
220
+ baseMs: v.optional(v.pipe(v.number(), v.integer(), v.minValue(1)))
221
+ }),
222
+ dlq: v.optional(queueName),
223
+ concurrency: v.optional(v.pipe(v.number(), v.integer(), v.minValue(1), v.maxValue(100))),
224
+ steps: v.optional(v.array(v.record(v.string(), v.unknown()))),
225
+ emits: v.optional(v.array(v.string())),
226
+ examples: v.optional(v.array(example)),
227
+ impl: v.optional(v.picklist(['generated', 'custom']))
228
+ }),
229
+ v.check(requiresExamplesWhenCustom, CUSTOM_NEEDS_EXAMPLES),
230
+ v.check(
231
+ (j) => j.impl === 'custom' || (Array.isArray(j.steps) && j.steps.length > 0),
232
+ 'generated jobs need at least one step (or `impl: custom`)'
233
+ )
234
+ );
235
+
236
+ // Endpoint (D14/K-23) — a Route grown up: declared route/method/auth and
237
+ // IO contract in spec, body in `src/<m>/endpoints/<name>.c`. `stream`
238
+ // declares an SSE output mode with typed frames (the chat/AI-token path).
239
+ // `auth` is required — public endpoints declare `{ mode: 'none' }` explicitly.
240
+ const Endpoint = v.pipe(
241
+ v.strictObject({
242
+ uid,
243
+ route: v.pipe(v.string(), v.regex(/^\//, 'route must start with "/"')),
244
+ method: v.optional(v.picklist(['GET', 'POST', 'PUT', 'PATCH', 'DELETE'])),
245
+ auth: ServiceAuth,
246
+ input: v.optional(v.record(ident, v.union([v.string(), v.record(v.string(), v.unknown())]))),
247
+ output: v.optional(v.record(ident, v.unknown())),
248
+ stream: v.optional(
249
+ v.strictObject({
250
+ frame: v.pipe(
251
+ v.record(ident, v.unknown()),
252
+ v.check((f) => Object.keys(f).length > 0, 'stream frames need at least one field')
253
+ )
254
+ })
255
+ ),
256
+ impl: v.optional(v.literal('custom')),
257
+ examples: v.optional(v.array(example))
258
+ }),
259
+ v.check((e) => !(e.output && e.stream), 'declare either `output` or `stream`, not both'),
260
+ v.check(requiresExamplesWhenCustom, CUSTOM_NEEDS_EXAMPLES)
261
+ );
262
+
263
+ // Room contract (D14) — Workers with `room: true` may declare state and
264
+ // message schemas plus script examples: message sequences driven against
265
+ // the Room class headless, checked as expected state + broadcasts (K-25).
266
+ const roomScriptStep = v.strictObject({
267
+ send: ident,
268
+ with: v.optional(v.record(v.string(), v.unknown()))
269
+ });
270
+
271
+ const roomExample = v.strictObject({
272
+ script: v.pipe(v.array(roomScriptStep), v.minLength(1, 'room examples need at least one script step')),
273
+ expect: v.optional(v.unknown())
274
+ });
275
+
154
276
  // L3 kinds: whole Civet files with declared auth + capabilities.
155
277
  // `validate` refuses them without an auth declaration (PLAN §6).
156
278
  const level3 = (extra = {}) =>
@@ -179,8 +301,26 @@ export const UNIT_SCHEMAS = {
179
301
  Trigger,
180
302
  Function,
181
303
  Component,
304
+ Snippet,
305
+ Service,
306
+ Job,
307
+ Endpoint,
182
308
  Route: level3(),
183
- Worker: level3({ room: v.optional(v.boolean()) }),
309
+ Worker: level3({
310
+ room: v.optional(v.boolean()),
311
+ state: v.optional(v.record(ident, v.string())),
312
+ messages: v.optional(
313
+ v.record(
314
+ ident,
315
+ v.strictObject({
316
+ in: v.optional(v.record(v.string(), v.unknown())),
317
+ out: v.optional(v.record(v.string(), v.unknown()))
318
+ })
319
+ )
320
+ ),
321
+ tickMs: v.optional(v.pipe(v.number(), v.integer(), v.minValue(0))),
322
+ examples: v.optional(v.array(roomExample))
323
+ }),
184
324
  Adapter: level3(),
185
325
  Middleware: level3(),
186
326
  Plugin
@@ -201,6 +341,10 @@ export const MODULE_SCHEMA = v.strictObject({
201
341
  triggers: collection,
202
342
  functions: collection,
203
343
  components: collection,
344
+ snippets: collection,
345
+ services: collection,
346
+ jobs: collection,
347
+ endpoints: collection,
204
348
  routes: collection,
205
349
  workers: collection,
206
350
  adapters: collection,
@@ -69,6 +69,54 @@ export function refineSpecs(specs) {
69
69
  }
70
70
  }
71
71
 
72
+ /**
73
+ * `call` steps may target a service operation
74
+ * (`<module>.Service.<name>.<op>`) — the service and operation must both
75
+ * exist. Other call targets are container tokens, resolved at runtime.
76
+ */
77
+ function checkServiceCall(at, fromModule, call) {
78
+ const m = /^([a-z][a-z0-9_]*)\.Service\.([A-Za-z_][A-Za-z0-9_]*)\.([A-Za-z_][A-Za-z0-9_]*)$/.exec(
79
+ call
80
+ );
81
+ if (!m) return;
82
+ const [, module, service, op] = m;
83
+ if (!(module in modules)) {
84
+ if (dependsOf(fromModule).includes(module)) return;
85
+ issues.push({
86
+ level: 'error',
87
+ address: at,
88
+ message: `call "${call}" points into unknown module "${module}" (not loaded, not in depends)`
89
+ });
90
+ return;
91
+ }
92
+ const svc = modules[module]?.services?.[service];
93
+ if (!svc) {
94
+ issues.push({
95
+ level: 'error',
96
+ address: at,
97
+ message: `call "${call}" does not resolve (no ${module}.Service.${service})`
98
+ });
99
+ return;
100
+ }
101
+ if (!svc.operations?.[op]) {
102
+ issues.push({
103
+ level: 'error',
104
+ address: at,
105
+ message: `call "${call}": service "${service}" has no operation "${op}"`
106
+ });
107
+ }
108
+ }
109
+
110
+ /** Shared step checks for Actions and Jobs: service calls and job enqueues resolve. */
111
+ function checkFlowSteps(at, fromModule, unitValue) {
112
+ for (const step of Array.isArray(unitValue?.steps) ? unitValue.steps : []) {
113
+ if (typeof step?.call === 'string') checkServiceCall(at, fromModule, step.call);
114
+ if (typeof step?.enqueue === 'string') {
115
+ checkRef(at, fromModule, step.enqueue, 'Job', 'enqueue');
116
+ }
117
+ }
118
+ }
119
+
72
120
  // depends: modules exist (or are external-by-convention? no — depends
73
121
  // names must be loaded or the well-known platform module "core") and form a DAG.
74
122
  for (const [name, spec] of Object.entries(modules)) {
@@ -144,6 +192,7 @@ export function refineSpecs(specs) {
144
192
  for (const ref of Array.isArray(value?.refresh) ? value.refresh : []) {
145
193
  checkRef(at, mod, ref, 'Query', 'refresh');
146
194
  }
195
+ checkFlowSteps(at, mod, value);
147
196
  if (value?.transport === 'remote' && !remoteEnabled) {
148
197
  issues.push({
149
198
  level: 'error',
@@ -186,6 +235,35 @@ export function refineSpecs(specs) {
186
235
  checkRef(at, mod, action, 'Action', 'trigger action');
187
236
  break;
188
237
  }
238
+ case 'Job': {
239
+ checkFlowSteps(at, mod, value);
240
+ break;
241
+ }
242
+ case 'Worker': {
243
+ if (value?.messages) {
244
+ for (const [i, ex] of (value.examples ?? []).entries()) {
245
+ for (const step of ex.script ?? []) {
246
+ if (!(step.send in value.messages)) {
247
+ issues.push({
248
+ level: 'error',
249
+ address: at,
250
+ message: `example ${i}: script sends undeclared message "${step.send}"`
251
+ });
252
+ }
253
+ }
254
+ }
255
+ }
256
+ break;
257
+ }
258
+ case 'Route': {
259
+ issues.push({
260
+ level: 'warning',
261
+ address: at,
262
+ message:
263
+ 'schema-less Route is deprecated — declare an Endpoint (route/method/auth/input/output) instead; v3.1 refuses bare Routes'
264
+ });
265
+ break;
266
+ }
189
267
  case 'Component': {
190
268
  for (const [event, target] of Object.entries(value?.events ?? {})) {
191
269
  checkRef(at, mod, target, 'Action', `events.${event}`);
@@ -23,6 +23,7 @@ import { dirname, join } from 'node:path';
23
23
  import { pathToFileURL } from 'node:url';
24
24
 
25
25
  import { actionEntity } from './emit-units.js';
26
+ import { shapeIssues } from '../server/service.js';
26
27
  import { normalizeField } from './emit-schema.js';
27
28
  import { generateApp } from './generate.js';
28
29
  import { loadSpecs } from './validate.js';
@@ -53,29 +54,34 @@ function rewriteImports(js, { scratch, appRoot, compileCivet }) {
53
54
  return `${from}${q}${pathToFileURL(target).href}${q}`;
54
55
  }
55
56
  if (spec.startsWith('$custom/')) {
56
- const rel = spec.slice('$custom/'.length);
57
- const target = join(scratch, 'custom', rel.replace(/\.c$/, '.js'));
58
- if (!existsSync(target)) {
59
- const src = join(appRoot, 'src', rel);
60
- mkdirSync(dirname(target), { recursive: true });
61
- if (existsSync(src)) {
62
- writeFileSync(
63
- target,
64
- rewriteImports(compileCivet(readFileSync(src, 'utf-8')), { scratch, appRoot, compileCivet })
65
- );
66
- } else {
67
- writeFileSync(
68
- target,
69
- `export default () => { throw new Error(${JSON.stringify(`missing custom body: src/${rel}`)}) }\n`
70
- );
71
- }
72
- }
57
+ const target = ensureCustom(spec.slice('$custom/'.length), { scratch, appRoot, compileCivet });
73
58
  return `${from}${q}${pathToFileURL(target).href}${q}`;
74
59
  }
75
60
  return `${from}${q}${resolveBare(spec)}${q}`;
76
61
  });
77
62
  }
78
63
 
64
+ /** Compile a custom body `src/<rel>` into the scratch tree; missing bodies throw when invoked. */
65
+ function ensureCustom(rel, { scratch, appRoot, compileCivet }) {
66
+ const target = join(scratch, 'custom', rel.replace(/\.c$/, '.js'));
67
+ if (!existsSync(target)) {
68
+ const src = join(appRoot, 'src', rel);
69
+ mkdirSync(dirname(target), { recursive: true });
70
+ if (existsSync(src)) {
71
+ writeFileSync(
72
+ target,
73
+ rewriteImports(compileCivet(readFileSync(src, 'utf-8')), { scratch, appRoot, compileCivet })
74
+ );
75
+ } else {
76
+ writeFileSync(
77
+ target,
78
+ `export default () => { throw new Error(${JSON.stringify(`missing custom body: src/${rel}`)}) }\n`
79
+ );
80
+ }
81
+ }
82
+ return target;
83
+ }
84
+
79
85
  /** Compile `lib/**` of the generated tree into `<scratch>/lib` as JS. */
80
86
  function buildScratch(genRoot, scratch, appRoot) {
81
87
  const { compile } = require('@danielx/civet');
@@ -166,7 +172,7 @@ function seedRow(id, entitySpec) {
166
172
  /* Runner */
167
173
  /* ------------------------------------------------------------------ */
168
174
 
169
- function recordingContainer(db, { fixtures = {}, events, calls }) {
175
+ function recordingContainer(db, { fixtures = {}, auto = {}, events, calls }) {
170
176
  return {
171
177
  resolve(name) {
172
178
  if (name === 'db') return db;
@@ -177,18 +183,84 @@ function recordingContainer(db, { fixtures = {}, events, calls }) {
177
183
  }
178
184
  };
179
185
  }
186
+ if (name === 'jobs') {
187
+ return {
188
+ enqueue: async (address, input) => {
189
+ calls.push({ name: 'jobs.enqueue', args: [address, input] });
190
+ }
191
+ };
192
+ }
180
193
  if (name in fixtures) return fixtures[name];
194
+ if (name in auto) {
195
+ return async (...args) => {
196
+ calls.push({ name, args });
197
+ return auto[name];
198
+ };
199
+ }
181
200
  return async (...args) => {
182
201
  calls.push({ name, args });
183
202
  };
184
- }
203
+ },
204
+ // `has` makes serviceClient's op-level fixture hook fire for every
205
+ // declared service operation — traced flows never touch the network.
206
+ has: (name) => name in fixtures || name in auto
185
207
  };
186
208
  }
187
209
 
210
+ /** Service op address → response fabricated from the op's declared output shape. */
211
+ function serviceAutoFixtures(specs) {
212
+ const auto = {};
213
+ for (const [moduleName, moduleSpec] of Object.entries(specs.modules)) {
214
+ for (const [serviceName, svc] of Object.entries(moduleSpec.services ?? {})) {
215
+ for (const [opName, op] of Object.entries(svc.operations ?? {})) {
216
+ const out = {};
217
+ for (const [key, t] of Object.entries(op.output && typeof op.output === 'object' ? op.output : {})) {
218
+ const type = (typeof t === 'string' ? t : (t?.type ?? 'json')).replace(/\?$/, '');
219
+ out[key] = SEED_VALUES[type] ?? 'trace';
220
+ }
221
+ auto[`${moduleName}.Service.${serviceName}.${opName}`] = out;
222
+ }
223
+ }
224
+ }
225
+ return auto;
226
+ }
227
+
188
228
  const looseEq = (a, b) => JSON.stringify(a) === JSON.stringify(b);
189
229
 
230
+ function matchCalls(calls, want) {
231
+ return (Array.isArray(want) ? want : []).every((w) => {
232
+ const name = typeof w === 'string' ? w : w?.name;
233
+ return (calls ?? []).some(
234
+ (c) => c.name === name && (typeof w === 'string' || w.with === undefined || looseEq(c.args[0], w.with))
235
+ );
236
+ });
237
+ }
238
+
239
+ /**
240
+ * `expect` semantics shared by every traced kind: plain keys check the
241
+ * entity row then the return value; `calls` checks recorded external
242
+ * calls (by name, optionally `with` args); `frames` the collected SSE
243
+ * frames; `state`/`broadcasts` the Room script outcome.
244
+ */
245
+ function checkExpect(record) {
246
+ record.pass = Object.entries(record.expect).every(([key, want]) => {
247
+ if (key === 'calls') return matchCalls(record.calls, want);
248
+ if (key === 'frames') return looseEq(record.frames, want);
249
+ if (key === 'state') {
250
+ return Object.entries(want ?? {}).every(([field, v2]) => looseEq(record.state?.[field], v2));
251
+ }
252
+ if (key === 'broadcasts') {
253
+ return (Array.isArray(want) ? want : []).every((n) => (record.broadcasts ?? []).some((b) => b?.type === n));
254
+ }
255
+ return looseEq(record.row?.[key], want) || looseEq(record.result?.[key], want);
256
+ });
257
+ }
258
+
190
259
  /**
191
- * Run every Action example in the app's specs.
260
+ * Run every example in the app's specs: Actions against the sandbox DB,
261
+ * plus L3 units (K-25) — Jobs and custom Functions/Endpoints run with the
262
+ * same recording container (Service calls auto-fixtured from their output
263
+ * schemas), Room workers run their script examples headless.
192
264
  *
193
265
  * @param {string} [dir] specs directory, defaults to `<cwd>/specs`
194
266
  * @param {{ out?: string, fixtures?: Record<string, *>, user?: * }} [opts]
@@ -210,6 +282,27 @@ export async function traceApp(dir, opts = {}) {
210
282
  const ddl = await tableDDL(Object.values(tables));
211
283
  const { betterSqlite } = await import('../server/db.js');
212
284
  const { sql, eq } = await import('drizzle-orm');
285
+ const { compile } = require('@danielx/civet');
286
+ const ctxc = { scratch, appRoot, compileCivet: (src) => compile(src, { sync: true, js: true }) };
287
+ const auto = serviceAutoFixtures(specs);
288
+
289
+ const runL3 = async (address, index, example, invoke) => {
290
+ const events = [];
291
+ const calls = [];
292
+ const record = { address, index, input: example.input ?? {}, expect: example.expect ?? {}, events, calls };
293
+ try {
294
+ const db = await betterSqlite(':memory:');
295
+ for (const stmt of ddl) await db.run(sql.raw(stmt));
296
+ const container = recordingContainer(db, { fixtures: opts.fixtures, auto, events, calls });
297
+ record.result = await invoke({ input: record.input, container, user, event: null });
298
+ checkExpect(record);
299
+ } catch (e) {
300
+ record.pass = false;
301
+ record.error = e?.body?.message ?? e?.message ?? String(e);
302
+ record.status = e?.status;
303
+ }
304
+ return record;
305
+ };
213
306
 
214
307
  for (const [moduleName, moduleSpec] of Object.entries(specs.modules)) {
215
308
  const actionsFile = join(scratch, 'lib', moduleName, 'actions.js');
@@ -217,10 +310,10 @@ export async function traceApp(dir, opts = {}) {
217
310
  .filter(([, a]) => (a.examples ?? []).length > 0)
218
311
  .map(([n]) => n)
219
312
  .sort();
220
- if (actionNames.length === 0 || !existsSync(actionsFile)) continue;
221
- const mod = await import(pathToFileURL(actionsFile).href);
313
+ const mod =
314
+ actionNames.length > 0 && existsSync(actionsFile) ? await import(pathToFileURL(actionsFile).href) : null;
222
315
 
223
- for (const name of actionNames) {
316
+ for (const name of mod ? actionNames : []) {
224
317
  const actionSpec = moduleSpec.actions[name];
225
318
  const address = `${moduleName}.Action.${name}`;
226
319
  const target = actionEntity(moduleName, actionSpec, specs);
@@ -248,7 +341,7 @@ export async function traceApp(dir, opts = {}) {
248
341
  if (ref?.endsWith('.id')) seededId = { table, value };
249
342
  }
250
343
 
251
- const container = recordingContainer(db, { fixtures: opts.fixtures, events, calls });
344
+ const container = recordingContainer(db, { fixtures: opts.fixtures, auto, events, calls });
252
345
  record.result = await mod[name].run({ input: example.input ?? {}, container, user });
253
346
 
254
347
  if (seededId && target) {
@@ -257,9 +350,7 @@ export async function traceApp(dir, opts = {}) {
257
350
  )[0];
258
351
  }
259
352
 
260
- record.pass = Object.entries(record.expect).every(
261
- ([k, want]) => looseEq(record.row?.[k], want) || looseEq(record.result?.[k], want)
262
- );
353
+ checkExpect(record);
263
354
  } catch (e) {
264
355
  record.pass = false;
265
356
  record.error = e?.body?.message ?? e?.message ?? String(e);
@@ -267,6 +358,103 @@ export async function traceApp(dir, opts = {}) {
267
358
  }
268
359
  }
269
360
  }
361
+
362
+ // Jobs — run the generated (or custom) `run` inline (K-25)
363
+ const jobsFile = join(scratch, 'lib', moduleName, 'jobs.js');
364
+ const jobNames = Object.entries(moduleSpec.jobs ?? {})
365
+ .filter(([, j]) => (j.examples ?? []).length > 0)
366
+ .map(([n]) => n)
367
+ .sort();
368
+ if (jobNames.length > 0 && existsSync(jobsFile)) {
369
+ const jobsMod = await import(pathToFileURL(jobsFile).href);
370
+ for (const name of jobNames) {
371
+ for (const [index, example] of moduleSpec.jobs[name].examples.entries()) {
372
+ cases.push(
373
+ await runL3(`${moduleName}.Job.${name}`, index, example, (ctx) => jobsMod[name].run(ctx))
374
+ );
375
+ }
376
+ }
377
+ }
378
+
379
+ // Functions — custom bodies with contract examples
380
+ for (const name of Object.keys(moduleSpec.functions ?? {}).sort()) {
381
+ const fnSpec = moduleSpec.functions[name];
382
+ if ((fnSpec.examples ?? []).length === 0) continue;
383
+ const target = ensureCustom(`${moduleName}/functions/${name}.c`, ctxc);
384
+ const body = (await import(pathToFileURL(target).href)).default;
385
+ for (const [index, example] of fnSpec.examples.entries()) {
386
+ cases.push(await runL3(`${moduleName}.Function.${name}`, index, example, (ctx) => body(ctx)));
387
+ }
388
+ }
389
+
390
+ // Endpoints — body-level trace with the declared IO contract enforced
391
+ for (const name of Object.keys(moduleSpec.endpoints ?? {}).sort()) {
392
+ const ep = moduleSpec.endpoints[name];
393
+ if ((ep.examples ?? []).length === 0) continue;
394
+ const target = ensureCustom(`${moduleName}/endpoints/${name}.c`, ctxc);
395
+ const body = (await import(pathToFileURL(target).href)).default;
396
+ for (const [index, example] of ep.examples.entries()) {
397
+ const record = await runL3(`${moduleName}.Endpoint.${name}`, index, example, async (ctx) => {
398
+ let result = body(ctx);
399
+ if (ep.stream) {
400
+ if (!result?.[Symbol.asyncIterator]) result = await result;
401
+ const frames = [];
402
+ for await (const frame of result) {
403
+ const issues = shapeIssues(ep.stream.frame ?? {}, frame, 'frame');
404
+ if (issues.length > 0) throw new Error(issues.join('; '));
405
+ frames.push(frame);
406
+ }
407
+ return frames;
408
+ }
409
+ result = await result;
410
+ if (ep.output) {
411
+ const issues = shapeIssues(ep.output, result, 'output');
412
+ if (issues.length > 0) throw new Error(`output contract: ${issues.join('; ')}`);
413
+ }
414
+ return result;
415
+ });
416
+ if (ep.stream && Array.isArray(record.result)) {
417
+ record.frames = record.result;
418
+ checkExpect(record);
419
+ }
420
+ cases.push(record);
421
+ }
422
+ }
423
+
424
+ // Room workers — script examples driven headless against the class
425
+ for (const name of Object.keys(moduleSpec.workers ?? {}).sort()) {
426
+ const w = moduleSpec.workers[name];
427
+ if (w?.room !== true || (w.examples ?? []).length === 0) continue;
428
+ const rel = w.source.startsWith('src/') ? w.source.slice(4) : w.source;
429
+ const target = ensureCustom(rel, ctxc);
430
+ const RoomClass = (await import(pathToFileURL(target).href)).default;
431
+ for (const [index, example] of w.examples.entries()) {
432
+ const record = {
433
+ address: `${moduleName}.Worker.${name}`,
434
+ index,
435
+ script: example.script ?? [],
436
+ expect: example.expect ?? {},
437
+ broadcasts: []
438
+ };
439
+ cases.push(record);
440
+ try {
441
+ const instance = new RoomClass({}, {});
442
+ instance.tickMs = 0;
443
+ instance.broadcast = (message) => {
444
+ record.broadcasts.push(typeof message === 'string' ? JSON.parse(message) : message);
445
+ return 0;
446
+ };
447
+ for (const step of example.script ?? []) {
448
+ await instance.onMessage(JSON.stringify({ type: step.send, ...(step.with ?? {}) }), null);
449
+ }
450
+ record.state = Object.fromEntries(Object.keys(w.state ?? {}).map((f) => [f, instance[f]]));
451
+ checkExpect(record);
452
+ } catch (e) {
453
+ record.pass = false;
454
+ record.error = e?.message ?? String(e);
455
+ }
456
+ }
457
+ }
270
458
  }
271
459
  } finally {
272
460
  rmSync(scratch, { recursive: true, force: true });