@junoflow/testkit 0.3.0 → 0.9.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.
@@ -0,0 +1,726 @@
1
+ import { fail } from './assert.js';
2
+ import { setFinding } from './findings.js';
3
+ import { names } from './names.js';
4
+ import {
5
+ assertISO,
6
+ assertTimezone,
7
+ resolveInstant,
8
+ timeResult,
9
+ } from './time.js';
10
+
11
+ /** The verbs each kind of state binding grants (StateBinding in internal/manifest). */
12
+ const STATE_VERBS = {
13
+ read: ['get'],
14
+ read_write: ['get', 'set'],
15
+ slot: ['claim', 'release'],
16
+ signal: ['signal', 'retract'],
17
+ };
18
+ const ALL_STATE_VERBS = Object.values(STATE_VERBS).flat();
19
+
20
+ /** Every control handle carries all four; the host refuses one the type does not take. */
21
+ const CONTROL_VERBS = ['press', 'activate', 'extend', 'release'];
22
+
23
+ /** Integrations the broker serves itself; none of their actions is read-only. */
24
+ const BUILT_IN_INTEGRATIONS = ['homeassistant', 'mqtt'];
25
+
26
+ /**
27
+ * The binding half of flowHarness and appHarness: the state handles, ctx.targets,
28
+ * ctx.controls, ctx.findings and ctx.credentials, and the h.* calls that seed,
29
+ * answer and read them back.
30
+ *
31
+ * Every handle is keyed by what the source says: a binding name,
32
+ * `binding.method`, `binding.verb`, a slug.
33
+ * Never a state id or a control id — the test speaks the source's vocabulary,
34
+ * and the host's resolution of it is not under test.
35
+ *
36
+ * `statesRoot` is the ctx member the state handles hang off, as refusals
37
+ * spell it (a flow's `state`, an app's `states`); `scoped` says whether that
38
+ * member carries scoped(), which only a flow has.
39
+ */
40
+ export function createBindings(
41
+ manifest,
42
+ harness,
43
+ { statesRoot = 'state', scoped = true } = {}
44
+ ) {
45
+ const states = manifest.states ?? {};
46
+ const targets = manifest.targets ?? {};
47
+ const controls = manifest.controls ?? {};
48
+ const credentialIDs = manifest.credentials ?? [];
49
+ // A flow manifest lists findings; setFinding looks them up by slug.
50
+ const findingDecls = {
51
+ findings: Array.isArray(manifest.findings)
52
+ ? Object.fromEntries(manifest.findings.map((f) => [f.slug, f]))
53
+ : (manifest.findings ?? {}),
54
+ };
55
+
56
+ // Credentials are a fixture, like the clock: kept across reset().
57
+ const credentials = {};
58
+
59
+ const rec = {
60
+ values: new Map(),
61
+ stateCalls: [],
62
+ scoped: new Map(),
63
+ claims: new Set(),
64
+ signals: new Set(),
65
+ targetAnswers: createAnswers(),
66
+ targetCalls: [],
67
+ controlAnswers: createAnswers(),
68
+ controlCalls: [],
69
+ findings: [],
70
+ };
71
+
72
+ const h = {
73
+ /** Seeds read and read_write bindings by name. */
74
+ states(values = {}) {
75
+ for (const [name, value] of Object.entries(values)) {
76
+ valueBinding(name, 'h.states');
77
+ rec.values.set(name, jsonCopy(value, `h.states — "${name}"`));
78
+ }
79
+ return harness();
80
+ },
81
+
82
+ /** A value binding's current value: seeded, or the last set(). */
83
+ state(name) {
84
+ valueBinding(name, 'h.state');
85
+ return jsonCopy(rec.values.get(name));
86
+ },
87
+
88
+ /** Every set, claim, release, signal and retract, in order. */
89
+ stateCalls: () => rec.stateCalls.map((c) => ({ ...c })),
90
+
91
+ targets: {
92
+ /**
93
+ * What `binding.method` answers: a value, or a function of the params
94
+ * (the input, for invoke) whose return is the result.
95
+ */
96
+ answer(key, answer) {
97
+ const [binding, method] = splitKey(key, 'h.targets.answer', 'method');
98
+ if (!targets[binding]) {
99
+ fail(
100
+ `h.targets.answer('${key}') — target "${binding}" is not declared in manifest.json (have: ${names(targets)})`
101
+ );
102
+ }
103
+ const methods = targetMethods(targets[binding]);
104
+ if (!methods.includes(method)) {
105
+ fail(
106
+ `h.targets.answer('${key}') — target "${binding}" has no method "${method}" (have: ${methods.join(', ') || '(none)'})`
107
+ );
108
+ }
109
+ rec.targetAnswers.set(key, answer, 'h.targets.answer');
110
+ return h.targets;
111
+ },
112
+ /** Every target call, in order, inside step.run or not. */
113
+ calls: () => rec.targetCalls.map((c) => ({ ...c })),
114
+ },
115
+
116
+ controls: {
117
+ /** What `binding.verb` answers: a ControlResult, or a function returning one. */
118
+ answer(key, answer) {
119
+ const [binding, verb] = splitKey(key, 'h.controls.answer', 'verb');
120
+ if (!controls[binding]) {
121
+ fail(
122
+ `h.controls.answer('${key}') — control "${binding}" is not declared in manifest.json (have: ${names(controls)})`
123
+ );
124
+ }
125
+ if (!CONTROL_VERBS.includes(verb)) {
126
+ fail(
127
+ `h.controls.answer('${key}') — "${verb}" is not a control verb (have: ${CONTROL_VERBS.join(', ')})`
128
+ );
129
+ }
130
+ rec.controlAnswers.set(key, answer, 'h.controls.answer');
131
+ return h.controls;
132
+ },
133
+ calls: () => rec.controlCalls.map((c) => ({ ...c })),
134
+ },
135
+
136
+ /** Findings as the host collapses them; a cleared instance stays, not standing. */
137
+ findings: () => rec.findings.map((f) => ({ ...f })),
138
+
139
+ /** Sets ctx.credentials by declared id. */
140
+ credentials(values = {}) {
141
+ for (const id of Object.keys(values)) {
142
+ if (!credentialIDs.includes(id)) {
143
+ fail(
144
+ `h.credentials — "${id}" is not declared in manifest.json credentials (have: ${credentialIDs.join(', ') || '(none)'})`
145
+ );
146
+ }
147
+ }
148
+ Object.assign(credentials, values);
149
+ return harness();
150
+ },
151
+ };
152
+
153
+ /**
154
+ * The ctx members for one run; refuse is the run's recording fail().
155
+ *
156
+ * policy is what the host refuses on this run. `{ readOnly: true, handler }`
157
+ * is an app handler declared read_only, and refuses what the host refuses
158
+ * it, in the host's words: claim, release, a control command, and a call on
159
+ * a built-in target. Everything else is the caller's to refuse.
160
+ */
161
+ function ctx(refuse, policy = {}) {
162
+ const readOnly = policy.readOnly
163
+ ? (what) =>
164
+ refuse(
165
+ `read-only handler ${JSON.stringify(policy.handler)} cannot ${what}`
166
+ )
167
+ : () => {};
168
+ return {
169
+ [statesRoot]: stateProxy(refuse, readOnly),
170
+ targets: namedProxy(
171
+ targets,
172
+ (name) =>
173
+ `ctx.targets.${name} — "${name}" is not declared in manifest.json (have: ${names(targets)})`,
174
+ (name, target) => targetHandle(refuse, name, target, policy),
175
+ refuse
176
+ ),
177
+ controls: namedProxy(
178
+ controls,
179
+ (name) =>
180
+ `ctx.controls.${name} — "${name}" is not declared in manifest.json (have: ${names(controls)})`,
181
+ (name) => controlHandle(refuse, name, readOnly),
182
+ refuse
183
+ ),
184
+ findings: {
185
+ open: async (slug, opts) => finding(refuse, slug, true, opts),
186
+ clear: async (slug, opts) => finding(refuse, slug, false, opts),
187
+ set: async (slug, standing, opts) =>
188
+ finding(refuse, slug, standing === true, opts),
189
+ },
190
+ credentials: credentialsProxy(refuse),
191
+ };
192
+ }
193
+
194
+ function reset() {
195
+ rec.values.clear();
196
+ rec.stateCalls.length = 0;
197
+ rec.scoped.clear();
198
+ rec.claims.clear();
199
+ rec.signals.clear();
200
+ rec.targetAnswers.clear();
201
+ rec.targetCalls.length = 0;
202
+ rec.controlAnswers.clear();
203
+ rec.controlCalls.length = 0;
204
+ rec.findings.length = 0;
205
+ }
206
+
207
+ // ─── states ────────────────────────────────────────────────────────────────
208
+
209
+ /** Fails unless name is a declared read or read_write binding; returns its kind. */
210
+ function valueBinding(name, where) {
211
+ const binding = states[name];
212
+ if (!binding) {
213
+ fail(
214
+ `${where} — "${name}" is not declared in manifest.json states (have: ${names(states)})`
215
+ );
216
+ }
217
+ const kind = bindingKind(binding);
218
+ if (kind !== 'read' && kind !== 'read_write') {
219
+ fail(
220
+ `${where} — "${name}" is a ${kind} binding (${grant(binding)} in manifest.json) and holds no value; only a read or read_write binding has one`
221
+ );
222
+ }
223
+ return kind;
224
+ }
225
+
226
+ function stateProxy(refuse, readOnly) {
227
+ const own = {};
228
+ if (scoped) {
229
+ own.scoped = async (name, initial) => {
230
+ const held = rec.scoped.get(name);
231
+ const current =
232
+ held !== undefined && held !== null ? jsonCopy(held) : initial;
233
+ const setter = async (value) => {
234
+ rec.scoped.set(name, jsonCopy(value, `scoped "${name}"`, refuse));
235
+ return value;
236
+ };
237
+ return [current, setter];
238
+ };
239
+ }
240
+ return new Proxy(own, {
241
+ get(own, prop) {
242
+ if (typeof prop !== 'string' || prop === 'then') return undefined;
243
+ if (Object.hasOwn(own, prop)) return own[prop];
244
+ const binding = states[prop];
245
+ if (!binding) {
246
+ refuse(
247
+ `ctx.${statesRoot}.${prop} — "${prop}" is not declared in manifest.json (have: ${names(states)})`
248
+ );
249
+ }
250
+ return stateHandle(refuse, readOnly, prop, binding);
251
+ },
252
+ });
253
+ }
254
+
255
+ /**
256
+ * All six verbs, as the SDK's bindingHandle has them; a verb the binding was
257
+ * not granted is refused here, as the host refuses it, rather than left for
258
+ * the generated types to have caught.
259
+ */
260
+ function stateHandle(refuse, readOnly, name, binding) {
261
+ const where = `ctx.${statesRoot}.${name}`;
262
+ const kind = bindingKind(binding);
263
+ const granted = STATE_VERBS[kind];
264
+ const guard = (verb) => {
265
+ if (!granted.includes(verb)) {
266
+ refuse(
267
+ `${where}.${verb}() — "${name}" is a ${kind} binding (${grant(binding)} in manifest.json) and grants ${granted.join(', ')} only`
268
+ );
269
+ }
270
+ };
271
+ const record = (call) => rec.stateCalls.push({ binding: name, ...call });
272
+ const needTTL = (verb, opts) => {
273
+ if (typeof opts?.ttl !== 'string' || opts.ttl === '') {
274
+ refuse(
275
+ `${where}.${verb}() needs { ttl }, e.g. ${verb}(${verb === 'claim' ? 'value, ' : ''}{ ttl: '30m' })`
276
+ );
277
+ }
278
+ };
279
+
280
+ const handle = {
281
+ async get() {
282
+ guard('get');
283
+ if (!rec.values.has(name)) {
284
+ refuse(
285
+ `${where}.get() — the test gave "${name}" no value; seed it with h.states({ ${name}: … })`
286
+ );
287
+ }
288
+ return jsonCopy(rec.values.get(name));
289
+ },
290
+ async set(value) {
291
+ guard('set');
292
+ const copy = jsonCopy(value, `${where}.set()`, refuse);
293
+ rec.values.set(name, copy);
294
+ record({ verb: 'set', value: copy });
295
+ },
296
+ // The layer stack is the server's, so a claim or a signal changes no
297
+ // value here. What it answers is computed from what this harness saw.
298
+ async claim(value, opts) {
299
+ // The host refuses a read-only handler's claim before it looks at
300
+ // the binding, so this comes first.
301
+ readOnly('claim states');
302
+ guard('claim');
303
+ needTTL('claim', opts);
304
+ rec.claims.add(name);
305
+ record({
306
+ verb: 'claim',
307
+ value: jsonCopy(value, `${where}.claim()`, refuse),
308
+ opts: { ...opts },
309
+ });
310
+ return { status: 'winning' };
311
+ },
312
+ async release() {
313
+ readOnly('release states');
314
+ guard('release');
315
+ const released = rec.claims.delete(name) ? 1 : 0;
316
+ record({ verb: 'release' });
317
+ return { released };
318
+ },
319
+ async signal(opts) {
320
+ guard('signal');
321
+ needTTL('signal', opts);
322
+ rec.signals.add(name);
323
+ record({ verb: 'signal', opts: { ...opts } });
324
+ },
325
+ async retract() {
326
+ guard('retract');
327
+ const retracted = rec.signals.delete(name) ? 1 : 0;
328
+ record({ verb: 'retract' });
329
+ return { retracted };
330
+ },
331
+ };
332
+ return verbProxy(
333
+ handle,
334
+ ALL_STATE_VERBS,
335
+ (prop) =>
336
+ `${where}.${prop} — not a state verb; "${name}" grants ${granted.join(', ')}`,
337
+ refuse
338
+ );
339
+ }
340
+
341
+ // ─── targets ───────────────────────────────────────────────────────────────
342
+
343
+ function targetHandle(refuse, name, target, policy) {
344
+ const handle = {};
345
+ for (const method of targetMethods(target)) {
346
+ handle[method] = async (params, opts) => {
347
+ // The broker's refusal. An integration action's read_only is the
348
+ // integration manifest's to say, which the harness does not have, so
349
+ // only a built-in target can be refused here.
350
+ if (
351
+ policy.readOnly &&
352
+ BUILT_IN_INTEGRATIONS.includes(target?.integration)
353
+ ) {
354
+ refuse(
355
+ `read-only handler ${JSON.stringify(policy.handler)} cannot invoke target ${JSON.stringify(name)}: a built-in ${target.integration} target is never read-only`
356
+ );
357
+ }
358
+ const isInvoke = method === 'invoke' || method === 'invokeAll';
359
+ // What the SDK sends: an action's params default to {}, an invoke's
360
+ // input to null.
361
+ const sent = jsonCopy(
362
+ isInvoke ? (params ?? null) : (params ?? {}),
363
+ `ctx.targets.${name}.${method}()`,
364
+ refuse
365
+ );
366
+ const call = { target: name, action: method, params: sent };
367
+ if (isInvoke && opts !== undefined) call.opts = { ...opts };
368
+ rec.targetCalls.push(call);
369
+ return answerFor(
370
+ refuse,
371
+ rec.targetAnswers,
372
+ `${name}.${method}`,
373
+ `target call ${name}.${method} was not answered — h.targets.answer('${name}.${method}', result)`,
374
+ sent
375
+ );
376
+ };
377
+ }
378
+ return verbProxy(
379
+ handle,
380
+ Object.keys(handle),
381
+ (prop) =>
382
+ `ctx.targets.${name}.${prop} — target "${name}" has no method "${prop}" (have: ${Object.keys(handle).join(', ') || '(none)'})`,
383
+ refuse
384
+ );
385
+ }
386
+
387
+ // ─── controls ──────────────────────────────────────────────────────────────
388
+
389
+ function controlHandle(refuse, name, readOnly) {
390
+ const command = async (verb, fields) => {
391
+ readOnly('command controls');
392
+ const call = { binding: name, verb };
393
+ for (const [k, v] of Object.entries(fields)) {
394
+ if (v !== undefined) call[k] = v;
395
+ }
396
+ rec.controlCalls.push(call);
397
+ const { binding: _b, verb: _v, ...args } = call;
398
+ const r = await answerFor(
399
+ refuse,
400
+ rec.controlAnswers,
401
+ `${name}.${verb}`,
402
+ `control command ${name}.${verb} was not answered — h.controls.answer('${name}.${verb}', result)`,
403
+ args
404
+ );
405
+ // controlHandle's normalisation of the host's answer.
406
+ return {
407
+ outcome: r?.outcome ?? 'unchanged',
408
+ active: r?.active === true,
409
+ value: r?.value ?? null,
410
+ expires_at: r?.expires_at ?? null,
411
+ };
412
+ };
413
+ const handle = {
414
+ press: () => command('press', {}),
415
+ // One runtime signature for a select's activate(option, opts) and a
416
+ // switch's activate(opts), as in the SDK.
417
+ activate: (valueOrOpts, opts) => {
418
+ const value = typeof valueOrOpts === 'string' ? valueOrOpts : undefined;
419
+ const o = typeof valueOrOpts === 'string' ? opts : valueOrOpts;
420
+ return command('activate', { value, for: o?.for, until: o?.until });
421
+ },
422
+ extend: () => command('extend', {}),
423
+ release: () => command('release', {}),
424
+ };
425
+ return verbProxy(
426
+ handle,
427
+ CONTROL_VERBS,
428
+ (prop) =>
429
+ `ctx.controls.${name}.${prop} — not a control verb (have: ${CONTROL_VERBS.join(', ')})`,
430
+ refuse
431
+ );
432
+ }
433
+
434
+ // ─── findings and credentials ──────────────────────────────────────────────
435
+
436
+ function finding(refuse, slug, standing, opts) {
437
+ try {
438
+ return setFinding(findingDecls, rec, slug, standing, opts ?? {});
439
+ } catch (err) {
440
+ refuse(err.message);
441
+ }
442
+ }
443
+
444
+ function credentialsProxy(refuse) {
445
+ // The target is the live map, so Object.keys and `in` see what is set.
446
+ return new Proxy(credentials, {
447
+ get(held, id) {
448
+ if (typeof id !== 'string' || id === 'then') return undefined;
449
+ if (!credentialIDs.includes(id)) {
450
+ refuse(
451
+ `ctx.credentials.${id} — "${id}" is not declared in manifest.json (have: ${credentialIDs.join(', ') || '(none)'})`
452
+ );
453
+ }
454
+ if (!Object.hasOwn(held, id)) {
455
+ refuse(
456
+ `ctx.credentials.${id} — the test set no value for it; set it with h.credentials({ ${id}: '…' })`
457
+ );
458
+ }
459
+ return held[id];
460
+ },
461
+ });
462
+ }
463
+
464
+ return { h, ctx, reset };
465
+ }
466
+
467
+ /** The fields ctx.solar() answers with, and the SDK's default for each. */
468
+ const SOLAR_DEFAULTS = {
469
+ above_horizon: false,
470
+ elevation: 0,
471
+ azimuth: 0,
472
+ rising: false,
473
+ next_rising: null,
474
+ next_setting: null,
475
+ };
476
+
477
+ /**
478
+ * The world half both harnesses share: h.now, h.timezone and h.solar, and the
479
+ * ctx.now() and ctx.solar() that answer from them and from nothing else.
480
+ *
481
+ * `harnessName` is what a refusal tells the author to pass the fixture to
482
+ * (`flowHarness({ now })`). What the test said about the world is kept across
483
+ * reset(): a clock set once for a file is a fixture, not a recording.
484
+ */
485
+ export function createWorld(harnessName, harness) {
486
+ const world = { now: undefined, timezone: 'UTC', solar: undefined };
487
+
488
+ const h = {
489
+ /** Freezes ctx.now() at iso: an instant, or wall time in h.timezone(). */
490
+ now(iso) {
491
+ assertISO(iso);
492
+ world.now = iso;
493
+ return harness();
494
+ },
495
+
496
+ /** The site timezone ctx.now() reads in; UTC unless set. */
497
+ timezone(tz) {
498
+ assertTimezone(tz);
499
+ world.timezone = tz;
500
+ return harness();
501
+ },
502
+
503
+ /** What ctx.solar() answers. Fields left out take the SDK's defaults. */
504
+ solar(values) {
505
+ for (const key of Object.keys(values ?? {})) {
506
+ if (!(key in SOLAR_DEFAULTS)) {
507
+ fail(
508
+ `h.solar — "${key}" is not a SolarResult field (have: ${names(SOLAR_DEFAULTS)})`
509
+ );
510
+ }
511
+ }
512
+ world.solar = { ...SOLAR_DEFAULTS, ...values };
513
+ return harness();
514
+ },
515
+ };
516
+
517
+ /** ctx.now and ctx.solar for one run; refuse is the run's recording fail(). */
518
+ function ctx(refuse) {
519
+ return {
520
+ now() {
521
+ if (world.now === undefined) {
522
+ refuse(
523
+ "ctx.now() was called but the test set no clock — h.now('2026-10-04T18:42:00') " +
524
+ `(wall time in h.timezone(), default UTC), or ${harnessName}({ now })`
525
+ );
526
+ }
527
+ return timeResult(
528
+ resolveInstant(world.now, world.timezone),
529
+ world.timezone
530
+ );
531
+ },
532
+
533
+ async solar() {
534
+ if (world.solar === undefined) {
535
+ refuse(
536
+ 'ctx.solar() was called but the test said nothing about the sun — ' +
537
+ `h.solar({ above_horizon: true, elevation: 12, … }), or ${harnessName}({ solar })`
538
+ );
539
+ }
540
+ return { ...world.solar };
541
+ },
542
+ };
543
+ }
544
+
545
+ /** Applies the harness options' now, timezone and solar, in that order. */
546
+ function init({ now, timezone, solar }) {
547
+ if (now !== undefined) h.now(now);
548
+ if (timezone !== undefined) h.timezone(timezone);
549
+ if (solar !== undefined) h.solar(solar);
550
+ }
551
+
552
+ return { h, ctx, init };
553
+ }
554
+
555
+ /**
556
+ * The methods buildTargets in sdk/src/context.ts gives a target, in its order
557
+ * of checks: a flow target by flow_id or label first, then mqtt, then Home
558
+ * Assistant (getState plus one camelCased method per service), then anything
559
+ * else (each action verbatim).
560
+ */
561
+ export function targetMethods(target) {
562
+ if (target?.flow?.flow_id) return ['invoke'];
563
+ if (target?.flow?.label) return ['invokeAll'];
564
+ if (target?.integration === 'mqtt') return ['publish'];
565
+ if (target?.integration === 'homeassistant') {
566
+ return ['getState', ...(target.actions ?? []).map(actionToMethodName)];
567
+ }
568
+ return [...(target?.actions ?? [])];
569
+ }
570
+
571
+ /** actionToMethodName in sdk/src/context.ts: "tts.cloud_say" → "ttsCloudSay". */
572
+ export function actionToMethodName(action) {
573
+ return action.replace(/[._]([a-zA-Z])/g, (_, c) => c.toUpperCase());
574
+ }
575
+
576
+ function bindingKind(binding) {
577
+ if (binding.slot) return 'slot';
578
+ if (binding.signal) return 'signal';
579
+ return binding.access === 'read_write' ? 'read_write' : 'read';
580
+ }
581
+
582
+ /** The manifest field that made the grant, as the author wrote it. */
583
+ function grant(binding) {
584
+ if (binding.slot) return `slot: "${binding.slot}"`;
585
+ if (binding.signal) return `signal: "${binding.signal}"`;
586
+ return `access: "${binding.access ?? 'read'}"`;
587
+ }
588
+
589
+ /** A Proxy over a manifest map: a declared name builds its handle, any other is refused. */
590
+ function namedProxy(declared, message, build, refuse) {
591
+ return new Proxy(
592
+ {},
593
+ {
594
+ get(_own, prop) {
595
+ if (typeof prop !== 'string' || prop === 'then') return undefined;
596
+ if (!Object.hasOwn(declared, prop)) refuse(message(prop));
597
+ return build(prop, declared[prop]);
598
+ },
599
+ }
600
+ );
601
+ }
602
+
603
+ /** A handle whose unknown members are refused rather than undefined. */
604
+ function verbProxy(handle, verbs, message, refuse) {
605
+ return new Proxy(handle, {
606
+ get(own, prop) {
607
+ if (typeof prop !== 'string' || prop === 'then') return undefined;
608
+ if (!verbs.includes(prop)) refuse(message(prop));
609
+ return own[prop];
610
+ },
611
+ });
612
+ }
613
+
614
+ /** The test's answer for key, called with args when it is a function. */
615
+ async function answerFor(refuse, answers, key, unanswered, args) {
616
+ if (!answers.has(key)) refuse(unanswered);
617
+ const result = await answers.resolve(key, args, refuse);
618
+ // A result crosses the socket as JSON; what the handler gets is a copy.
619
+ return jsonCopy(result, `the answer for ${key}`, refuse);
620
+ }
621
+
622
+ /**
623
+ * A registry of answers by key (`binding.method`, `binding.verb`, a step id)
624
+ * that knows the scenario's two reserved forms, wherever the answer came from:
625
+ *
626
+ * - `{ $replay: [a, b, …] }` answers calls in order, and refuses one past the
627
+ * end;
628
+ * - `{ $error: message }` makes the call throw `new Error(message)`, as the
629
+ * shim throws for a capability call the host answered with an error. It is
630
+ * the handler's to catch, not a refusal.
631
+ *
632
+ * Only an object whose sole key is `$replay` or `$error` is either; anything
633
+ * else, including a function, is an answer as before. Setting a key again
634
+ * starts its $replay over.
635
+ */
636
+ export function createAnswers() {
637
+ const answers = new Map();
638
+ // How many calls each key has answered since it was set; $replay reads it.
639
+ const calls = new Map();
640
+
641
+ return {
642
+ set(key, answer, where) {
643
+ checkAnswerForm(answer, `${where}('${key}')`);
644
+ answers.set(key, answer);
645
+ calls.delete(key);
646
+ },
647
+ has: (key) => answers.has(key),
648
+ /** The answer for one call: its $replay item, $error thrown, a function called. */
649
+ async resolve(key, args, refuse) {
650
+ let answer = answers.get(key);
651
+ const n = (calls.get(key) ?? 0) + 1;
652
+ calls.set(key, n);
653
+ if (reservedForm(answer) === '$replay') {
654
+ const items = answer.$replay;
655
+ if (n > items.length) {
656
+ refuse(
657
+ `${key} answered ${items.length} call${items.length === 1 ? '' : 's'} in the scenario; this was call ${n}`
658
+ );
659
+ }
660
+ answer = items[n - 1];
661
+ }
662
+ if (reservedForm(answer) === '$error') throw new Error(answer.$error);
663
+ return typeof answer === 'function' ? await answer(args) : answer;
664
+ },
665
+ clear() {
666
+ answers.clear();
667
+ calls.clear();
668
+ },
669
+ };
670
+ }
671
+
672
+ /** '$replay' or '$error' when answer is that reserved form, otherwise undefined. */
673
+ function reservedForm(answer) {
674
+ if (answer === null || typeof answer !== 'object' || Array.isArray(answer)) {
675
+ return undefined;
676
+ }
677
+ const keys = Object.keys(answer);
678
+ if (keys.length !== 1) return undefined;
679
+ return keys[0] === '$replay' || keys[0] === '$error' ? keys[0] : undefined;
680
+ }
681
+
682
+ /** Fails, at the setter, on a $replay or $error that cannot answer anything. */
683
+ function checkAnswerForm(answer, where) {
684
+ const form = reservedForm(answer);
685
+ if (form === '$error' && typeof answer.$error !== 'string') {
686
+ fail(
687
+ `${where} — $error takes the error's message, a string; got ${JSON.stringify(answer.$error)}`
688
+ );
689
+ }
690
+ if (form === '$replay') {
691
+ if (!Array.isArray(answer.$replay)) {
692
+ fail(
693
+ `${where} — $replay takes an array of answers, one per call; got ${JSON.stringify(answer.$replay)}`
694
+ );
695
+ }
696
+ answer.$replay.forEach((item, i) => {
697
+ if (reservedForm(item) === '$replay') {
698
+ fail(`${where} — $replay item ${i} is itself a $replay`);
699
+ }
700
+ checkAnswerForm(item, `${where} $replay item ${i}`);
701
+ });
702
+ }
703
+ }
704
+
705
+ /** A JSON round trip, as the wire does it; undefined stays undefined. */
706
+ export function jsonCopy(value, what, failWith = fail) {
707
+ if (value === undefined) return undefined;
708
+ let json;
709
+ try {
710
+ json = JSON.stringify(value);
711
+ } catch (err) {
712
+ failWith(`${what ?? 'value'} is not JSON-serializable: ${err}`);
713
+ }
714
+ return json === undefined ? undefined : JSON.parse(json);
715
+ }
716
+
717
+ /** Splits 'binding.rest' at the first dot. */
718
+ function splitKey(key, where, what) {
719
+ const dot = typeof key === 'string' ? key.indexOf('.') : -1;
720
+ if (dot <= 0 || dot === key.length - 1) {
721
+ fail(
722
+ `${where}(${JSON.stringify(key)}) — expected 'binding.${what}', e.g. 'kitchenSpeaker.ttsCloudSay'`
723
+ );
724
+ }
725
+ return [key.slice(0, dot), key.slice(dot + 1)];
726
+ }