@junoflow/testkit 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -56,11 +56,14 @@ plain form works.
56
56
  | `h.webhook(name, request?)` | run a webhook; `request` defaults to a `POST` with an empty body, to the slot's URL |
57
57
  | `h.schedule(name, event?)` | run a schedule handler |
58
58
  | `h.hook(name)` | run a declared lifecycle hook (e.g. `activated`) |
59
+ | `h.deliver(channel, attempt?)` | run a declared channel's `deliver` with a fixture attempt (any field overridable); resolves to the attempt |
60
+ | `h.withdraw(channel, attempt?)` | run its `withdraw`; a no-op when the channel has none |
59
61
  | `h.listen({ until, events, timeout })` | run the listener until the condition holds, then abort |
60
62
  | `h.store` | the KV, as a plain `Map` (`get`/`set`/`delete` all land here) |
61
63
  | `h.findings()` | what `ctx.findings` asserted, as `{slug, key, severity, text, standing}` |
62
64
  | `h.states()` | values `ctx.states.publish` has sent, keyed by local name |
63
65
  | `h.status()` | the last `ctx.status.set` report, or `null` if it never reported |
66
+ | `h.reports()` | what `ctx.channels.report` sent, as `{attemptId, status, digit?, option?, reason?}` |
64
67
  | `h.emitted()` | `[{ event, payload }]`, in order |
65
68
  | `h.logs()` | `[{ level, message, data }]` |
66
69
  | `h.conformance()` | manifest-vs-module problems; `[]` when they agree |
@@ -103,13 +106,13 @@ production it hangs the supervisor on shutdown instead.
103
106
  ### `h.conformance()`
104
107
 
105
108
  Checks the manifest and the module against each other: every declared surface
106
- implemented (actions, webhooks, schedules, hooks), nothing undeclared exported,
107
- a `listener` function exactly when the manifest declares a `listener` section,
108
- every hook name inside Juno's closed vocabulary, and every schema compiling.
109
- `states` is a manifest declaration, not a handler surface, so it plays no part
110
- here — see [`h.states()`](#hstates) instead. Returns all problems rather than
111
- the first — "you declared six actions and implemented four" is one fix, not four
112
- runs.
109
+ implemented (actions, webhooks, schedules, hooks, and a `deliver` for every
110
+ channel), nothing undeclared exported, a `listener` function exactly when the
111
+ manifest declares a `listener` section, every hook name inside Juno's closed
112
+ vocabulary, and every schema compiling. `states` is a manifest declaration, not
113
+ a handler surface, so it plays no part here — see [`h.states()`](#hstates)
114
+ instead. Returns all problems rather than the first — "you declared six actions
115
+ and implemented four" is one fix, not four runs.
113
116
 
114
117
  ### `h.findings()`
115
118
 
@@ -178,6 +181,33 @@ hook reports through ctx and returns nothing" is what production says too.
178
181
  `h.conformance()` also rejects a hook name outside Juno's closed vocabulary
179
182
  (today, only `activated`), which is the check that catches a typo before deploy.
180
183
 
184
+ ### `h.deliver(channel, attempt?)` and `h.reports()`
185
+
186
+ ```ts
187
+ const attempt = await h.deliver('call');
188
+ expect(h.http.requests[0].body).toContain(attempt.id);
189
+
190
+ await h.webhook('callEvents', { body: { ref: attempt.id, digit: 1 } });
191
+ expect(h.reports()).toEqual([
192
+ { attemptId: 'attempt-1', status: 'answered', digit: 1 },
193
+ ]);
194
+ ```
195
+
196
+ The fixture attempt is `id: 'attempt-1'`, in the channel's first `answers` mode,
197
+ with one option `{ key: 'ack', label: 'OK', digit: 1 }`, a placeholder address
198
+ of the channel's address kind, `render.text`/`render.speech` equal to the
199
+ message, `link: null` and a deadline ten minutes out. A channel that declares no
200
+ `answers` gets a notification instead (`kind` and `mode` `notify`, no options,
201
+ no deadline). Pass any field to override it.
202
+
203
+ A `deliver` or `withdraw` that returns a value **fails the test**, as it fails
204
+ in production. `ctx.channels.report` is on every surface; the refusals Juno
205
+ applies without its attempt rows fail the test too — an install with no
206
+ channels, an unknown status, `answered` without exactly one of `digit` and
207
+ `option`, and, for an attempt `h.deliver` handed out, a digit or option the
208
+ attempt does not have. A report on an id the harness never issued is recorded
209
+ rather than refused, so a webhook test can report on a reference it made up.
210
+
181
211
  ## What the harness is faithful about
182
212
 
183
213
  - `ctx.store` round-trips values through JSON, because the real store does. A
@@ -249,6 +279,8 @@ it('greets when someone is home', async () => {
249
279
  | `h.findings()` | `{slug, key, severity, text, standing}`, collapsed by `(slug, key)` as the host does |
250
280
  | `h.credentials(values)` | set `ctx.credentials` by declared id |
251
281
  | `h.webhookUrls(values)` | set `ctx.webhooks['<id>'].url`; each starts at `https://juno.test/webhooks/test-<id>`, `null` is "no public URL" |
282
+ | `h.questions.answer(key, v)` / `.asked()` | answer `ask` by label (or `ask#N`); the questions asked, defaults applied (below) |
283
+ | `h.notifications()` / `h.unreachable(...ids)` | every `notify` as `{to, message, urgency, via, link}`; the people `notify` reports unreachable |
252
284
  | `h.logs()` | `[{ level, message, data }]` |
253
285
  | `h.conformance()` | triggers declared vs implemented, both ways; `[]` when they agree |
254
286
  | `h.reset()` | clear recordings, answers, seeds and scoped state; keep clock, timezone, solar, credentials, webhook URLs |
@@ -279,10 +311,11 @@ it('replays run 42', async () => {
279
311
 
280
312
  Its keys are `now`, `timezone`, `solar`, `states`, `credentials`, `targets`
281
313
  (`"binding.method": answer`), `controls` (`"binding.verb": answer`), `llm`
282
- (`"stepId": answer`) and `event`, which an `h.trigger(name)` with no `{ event }`
283
- runs with; an explicit event, even `null`, wins. `trigger`, `expect` and
284
- `flow_version` are carried and not applied. A key left out sets nothing; an
285
- unknown key fails. The `Scenario` type is exported.
314
+ (`"stepId": answer`), `questions` (`"label": answer`), `unreachable` (person
315
+ ids) and `event`, which an `h.trigger(name)` with no `{ event }` runs with; an
316
+ explicit event, even `null`, wins. `trigger`, `expect` and `flow_version` are
317
+ carried and not applied. A key left out sets nothing; an unknown key fails. The
318
+ `Scenario` type is exported.
286
319
 
287
320
  Two answer forms are reserved, in a scenario and in `h.targets.answer`,
288
321
  `h.controls.answer` and `h.llm` alike:
@@ -296,6 +329,89 @@ Two answer forms are reserved, in a scenario and in `h.targets.answer`,
296
329
  `expect` filled in from what the run did; see the flow testing docs ("Replaying
297
330
  a run").
298
331
 
332
+ ### Handles
333
+
334
+ `ctx.after`, `ctx.at` and `ctx.wait` behave as on the server, except that the
335
+ harness's clock does not move: it cannot suspend, so a timer never fires while a
336
+ handler waits. An `at` whose instant is at or before `h.now()` fires the moment
337
+ it opens, with `value` `null` and `firedAt` that instant; an `after` never does.
338
+ A `wait` listing a fired handle returns it — the earliest `firedAt`, ties to the
339
+ earlier position — so a flow that waits on something already due runs to
340
+ completion:
341
+
342
+ ```ts
343
+ it('skips the wait when the morning has already come', async () => {
344
+ h.now('2026-10-26T07:00:00Z');
345
+ await h.trigger('nightly');
346
+ expect(h.handles()).toEqual([
347
+ { id: '__h_0__', kind: 'timer', label: 'morning', status: 'fired' },
348
+ ]);
349
+ });
350
+ ```
351
+
352
+ State and event handles follow the same rule, firing only where the server would
353
+ fire them as they open. `state.<b>.when(match)` without `for` fires at once when
354
+ the binding's seeded value already matches, with that value and `firedAt`
355
+ `h.now()`; `becomes`, `changes`, a `for` hold and `targets.<i>.untilEvent` never
356
+ fire, since the harness has no history of changes or events to look back on:
357
+
358
+ ```ts
359
+ h.states({ door: 'closed' });
360
+ // await wait([state.door.when('closed'), after('1m')]) returns the when
361
+ ```
362
+
363
+ `controls.<b>.untilPressed()` never fires either: nothing presses in a test.
364
+
365
+ A child-run handle fires at open from its answer, because the harness starts no
366
+ child. `targets.<f>.start(input)` fires with the `<f>.start` answer wrapped as
367
+ `{ flow_id, run_id: 'harness', ok: true, value }`, or `ok: false` with the
368
+ answer's `$error` as `error`; `targets.<l>.startAll(input)` fires with the
369
+ `<l>.startAll` answer whole, an array of those results. An unanswered `start` or
370
+ `startAll` fails like an unanswered target call. `invoke` and `invokeAll` answer
371
+ from their own key when the test set one, and otherwise from the `.start` /
372
+ `.startAll` answer, so one answer serves a flow that switches between them:
373
+
374
+ ```ts
375
+ h.targets.answer('report.start', { lines: 3 });
376
+ // using r = targets.report.start({ day: 'mon' }) → r fires at open, r.value.ok
377
+ // await targets.report.invoke({ day: 'mon' }) → { lines: 3 }
378
+ ```
379
+
380
+ `all([...])` fires at open when every member already has, with their values in
381
+ order, and is cancelled when a member is.
382
+
383
+ `h.handles()` lists the last run's handles in open order as
384
+ `{ id, kind, label, status }` (`kind` is `timer`, `state`, `event`, `run`,
385
+ `press`, `question` or `all`); one still open when the run ended reads
386
+ `cancelled`, as the engine closes it. An `at` with no clock set fails, as
387
+ `ctx.now()` does. A bad duration or instant, and a 101st open handle, fail with
388
+ the server's message at the next `wait` or when the handler returns.
389
+
390
+ ### Questions and notifications
391
+
392
+ `ctx.ask` opens a `question` handle, which fires at open when the test answered
393
+ it: by its `label`, or `ask#N` (the Nth `ask` of the trigger, from 1) for one
394
+ without. `{ by, answer, channel? }` fires it with
395
+ `{ outcome: 'answered', answer, by, channel, at }` — `channel` defaults to
396
+ `'test:channel'`, `at` is `h.now()` — and `'unanswered'` with
397
+ `{ outcome: 'unanswered', attempts: to.length * rounds }`. The server does not
398
+ count a recipient none of whose contacts could carry the question; the harness
399
+ has no contacts, so it counts them all. A question with no answer stays open, so
400
+ a wait on it alone fails as any wait nothing can answer does. There is no
401
+ timeline: an answer that arrives after a call has rung needs a clock that moves.
402
+
403
+ ```ts
404
+ h.questions.answer('samtal', { by: 'partner', answer: 'ack' });
405
+ // using called = ask({ …, label: 'samtal' }) → called fires at open
406
+ expect(h.questions.asked()[0]).toMatchObject({ label: 'samtal', rounds: 5 });
407
+ ```
408
+
409
+ `ctx.notify` records `{ to, message, urgency, via, link }` (defaults applied;
410
+ `via` and `link` `null` when absent) and answers `{ unreachable: [] }`, or the
411
+ recipients `h.unreachable(...ids)` named, in the manifest's order. Both refuse
412
+ what the server refuses from the manifest alone — a person not in `people`, a
413
+ field out of range — with the server's words.
414
+
299
415
  ### What fails
300
416
 
301
417
  Strict where a permissive double would pass a broken flow. Each of these fails
@@ -306,8 +422,9 @@ the test, naming what was asked for and how to supply it:
306
422
  that seeds or answers it;
307
423
  - a state verb the binding was not granted (`set` on a `read` binding, `get` on
308
424
  a slot), and `get` on a binding the test did not seed;
309
- - `ctx.sleep(…)` and `ctx.waitForEvent(…)`:
310
- `a handler cannot suspend in a test: sleep("5m")`;
425
+ - a `ctx.wait(…)` none of whose handles has fired, and so every `ctx.sleep(…)`
426
+ (`sleep(d)` is `wait([after(d)])`):
427
+ `a handler cannot suspend in a test: wait([after("5m")])`;
311
428
  - `ctx.now()`, `ctx.solar()`, a target call, a control command or a `step.llm`
312
429
  the test did not answer, e.g.
313
430
  `target call kitchenSpeaker.ttsCloudSay was not answered — h.targets.answer('kitchenSpeaker.ttsCloudSay', result)`.
@@ -371,10 +488,10 @@ it('a read-only handler cannot claim', async () => {
371
488
  | `h.conformance()` | handlers declared vs implemented, both ways; `[]` when they agree |
372
489
  | `h.reset()` | clear recordings, answers and seeds; keep clock, timezone, solar, credentials |
373
490
 
374
- `h.scenario(s)` takes the flow scenario's keys except `llm` and `flow_version`,
375
- which it refuses, and ignores `trigger`, `event` and `expect`, so one scenario
376
- can serve both. `$replay` and `$error` answer targets and controls as they do in
377
- a flow test.
491
+ `h.scenario(s)` takes the flow scenario's keys except `llm`, `questions`,
492
+ `unreachable` and `flow_version`, which it refuses, and ignores `trigger`,
493
+ `event` and `expect`, so one scenario can serve both. `$replay` and `$error`
494
+ answer targets and controls as they do in a flow test.
378
495
 
379
496
  The handler's `ctx` is the app sandbox's: `log`, `now`, `solar`, `signal`,
380
497
  `states`, `controls`, `targets`, `credentials` and `events`. `ctx.signal` is
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@junoflow/testkit",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "Test harnesses for Juno integrations, flows and apps",
5
5
  "license": "MIT",
6
6
  "repository": {
package/src/ask.js ADDED
@@ -0,0 +1,338 @@
1
+ import { fail } from './assert.js';
2
+ import { isPositiveDuration, rfc3339 } from './handles.js';
3
+
4
+ // The people half of the flow harness: ctx.ask and ctx.notify, and the h.*
5
+ // calls that answer questions and read back what was asked and sent.
6
+ //
7
+ // The refusals are the host's that need only the manifest — internal/ask's
8
+ // ParseSpec and ParseNotify, in their order and with their strings. What
9
+ // needs the people store (a person deleted since the flow was committed, which
10
+ // contacts serve which urgency) is not under test: there are no contacts here.
11
+
12
+ const MAX_RECIPIENTS = 20;
13
+ const MAX_QUESTION_RUNES = 500;
14
+ const MAX_MESSAGE_RUNES = 1000;
15
+ const MAX_OPTIONS = 9;
16
+ const MAX_LABEL_RUNES = 40;
17
+ const MAX_ROUNDS = 10;
18
+
19
+ const URGENCIES = ['urgent', 'normal', 'low'];
20
+ const VIA_KINDS = ['call', 'sms', 'push', 'chat', 'email', 'inbox'];
21
+ const OPTION_KEY = /^[a-z][a-zA-Z0-9]*$/;
22
+
23
+ /** The channel an answer came through when the test names none. */
24
+ export const TEST_CHANNEL = 'test:channel';
25
+
26
+ const runes = (s) => [...s].length;
27
+
28
+ /** handlekind's quotedNames: sorted, quoted, "none" when empty. */
29
+ function quotedNames(ids) {
30
+ if (!ids.length) return 'none';
31
+ return [...ids]
32
+ .sort()
33
+ .map((n) => JSON.stringify(n))
34
+ .join(', ');
35
+ }
36
+
37
+ /** checkTo: the size, repeats, and that the manifest declares each person. */
38
+ function checkTo(prefix, to, people) {
39
+ if (!Array.isArray(to) || to.length < 1 || to.length > MAX_RECIPIENTS) {
40
+ return `${prefix}: to needs 1 to ${MAX_RECIPIENTS} people`;
41
+ }
42
+ for (let i = 0; i < to.length; i++) {
43
+ if (to.slice(0, i).includes(to[i])) {
44
+ return `${prefix}: to lists ${JSON.stringify(to[i])} twice`;
45
+ }
46
+ }
47
+ for (const id of to) {
48
+ if (!people.includes(id)) {
49
+ return `${prefix}: person ${JSON.stringify(id)} is not declared in this flow's people; it declares ${quotedNames(people)}`;
50
+ }
51
+ }
52
+ return null;
53
+ }
54
+
55
+ function checkUrgency(prefix, u) {
56
+ if (u === undefined) return [null, 'normal'];
57
+ if (!URGENCIES.includes(u)) {
58
+ return [`${prefix}: urgency must be "urgent", "normal" or "low"`];
59
+ }
60
+ return [null, u];
61
+ }
62
+
63
+ /** An empty via reads as absent, as the host reads it: every kind. */
64
+ function checkVia(prefix, via) {
65
+ for (const k of via ?? []) {
66
+ if (!VIA_KINDS.includes(k)) {
67
+ return [
68
+ `${prefix}: via ${JSON.stringify(k)} is not a channel kind; use call, sms, push, chat, email or inbox`,
69
+ ];
70
+ }
71
+ }
72
+ return [null, via?.length ? [...via] : null];
73
+ }
74
+
75
+ /**
76
+ * ParseSpec in internal/ask/spec.go, over what the SDK would send: the
77
+ * refusal, or the question as `h.questions.asked()` reports it, defaults
78
+ * applied.
79
+ */
80
+ export function parseQuestion(opts, people) {
81
+ const o = opts ?? {};
82
+ const toErr = checkTo('ask', o.to, people);
83
+ if (toErr) return { error: toErr };
84
+ const question = typeof o.question === 'string' ? o.question : '';
85
+ const n = runes(question.trim());
86
+ if (n === 0 || n > MAX_QUESTION_RUNES) {
87
+ return {
88
+ error: `ask: question must be 1 to ${MAX_QUESTION_RUNES} characters`,
89
+ };
90
+ }
91
+ // The SDK sends Object.entries(options) as an array; integer-like keys,
92
+ // which that reorders, fail the key pattern below.
93
+ const entries = Object.entries(o.options ?? {});
94
+ if (entries.length < 1 || entries.length > MAX_OPTIONS) {
95
+ return { error: `ask: options needs 1 to ${MAX_OPTIONS} answers` };
96
+ }
97
+ for (let i = 0; i < entries.length; i++) {
98
+ const [key, label] = entries[i];
99
+ if (!OPTION_KEY.test(key)) {
100
+ return {
101
+ error: `ask: option key ${JSON.stringify(key)} must be lowerCamelCase (e.g. "yes", "notNow")`,
102
+ };
103
+ }
104
+ const ln = typeof label === 'string' ? runes(label.trim()) : 0;
105
+ if (ln === 0 || ln > MAX_LABEL_RUNES) {
106
+ return {
107
+ error: `ask: option ${JSON.stringify(key)} needs a label of 1 to ${MAX_LABEL_RUNES} characters`,
108
+ };
109
+ }
110
+ }
111
+
112
+ const q = {
113
+ to: [...o.to],
114
+ question,
115
+ options: Object.fromEntries(entries),
116
+ mode: 'sequence',
117
+ rounds: 1,
118
+ escalateAfter: '10m',
119
+ urgency: 'normal',
120
+ via: null,
121
+ };
122
+ if (o.mode !== undefined) {
123
+ if (o.mode !== 'sequence' && o.mode !== 'all') {
124
+ return { error: 'ask: mode must be "sequence" or "all"' };
125
+ }
126
+ q.mode = o.mode;
127
+ }
128
+ if (o.rounds !== undefined) {
129
+ if (!Number.isInteger(o.rounds) || o.rounds < 1 || o.rounds > MAX_ROUNDS) {
130
+ return { error: `ask: rounds must be 1 to ${MAX_ROUNDS}` };
131
+ }
132
+ q.rounds = o.rounds;
133
+ }
134
+ if (o.escalateAfter !== undefined) q.escalateAfter = o.escalateAfter;
135
+ if (!isPositiveDuration(q.escalateAfter)) {
136
+ return {
137
+ error: `ask: escalateAfter ${JSON.stringify(q.escalateAfter)} is not a duration (e.g. "2m", "1h")`,
138
+ };
139
+ }
140
+ let err;
141
+ [err, q.urgency] = checkUrgency('ask', o.urgency);
142
+ if (err) return { error: err };
143
+ [err, q.via] = checkVia('ask', o.via);
144
+ if (err) return { error: err };
145
+ if (
146
+ o.freeText !== undefined &&
147
+ o.freeText !== 'exact' &&
148
+ o.freeText !== 'interpret'
149
+ ) {
150
+ return { error: 'ask: freeText must be "exact" or "interpret"' };
151
+ }
152
+ if (o.requireSignIn === true) {
153
+ return { error: 'ask: requireSignIn is not available yet' };
154
+ }
155
+ return { question: q };
156
+ }
157
+
158
+ /** ParseNotify in internal/ask/spec.go: the refusal, or the notification. */
159
+ export function parseNotify(opts, people) {
160
+ const o = opts ?? {};
161
+ const toErr = checkTo('notify', o.to, people);
162
+ if (toErr) return { error: toErr };
163
+ const message = typeof o.message === 'string' ? o.message : '';
164
+ const n = runes(message.trim());
165
+ if (n === 0 || n > MAX_MESSAGE_RUNES) {
166
+ return {
167
+ error: `notify: message must be 1 to ${MAX_MESSAGE_RUNES} characters`,
168
+ };
169
+ }
170
+ const note = { to: [...o.to], message, urgency: 'normal', via: null };
171
+ let err;
172
+ [err, note.urgency] = checkUrgency('notify', o.urgency);
173
+ if (err) return { error: err };
174
+ [err, note.via] = checkVia('notify', o.via);
175
+ if (err) return { error: err };
176
+ note.link = null;
177
+ if (o.link !== undefined) {
178
+ let ok = false;
179
+ try {
180
+ const u = new URL(o.link);
181
+ ok = (u.protocol === 'http:' || u.protocol === 'https:') && !!u.host;
182
+ } catch {
183
+ ok = false;
184
+ }
185
+ if (!ok) return { error: 'notify: link must be an http or https URL' };
186
+ note.link = o.link;
187
+ }
188
+ return { notification: note };
189
+ }
190
+
191
+ /**
192
+ * The ask/notify half of a flow harness. `manifest.people` is the grant.
193
+ * Answers and recordings live here and are cleared by reset().
194
+ */
195
+ export function createPeople(manifest, harness) {
196
+ const people = Array.isArray(manifest.people) ? manifest.people : [];
197
+ const rec = {
198
+ answers: new Map(),
199
+ asked: [],
200
+ notifications: [],
201
+ unreachable: new Set(),
202
+ };
203
+
204
+ const declared = (where, id) => {
205
+ if (!people.includes(id)) {
206
+ fail(
207
+ `${where} — person ${JSON.stringify(id)} is not declared in manifest.json's people (have: ${people.join(', ') || '(none)'})`
208
+ );
209
+ }
210
+ };
211
+
212
+ const h = {
213
+ questions: {
214
+ /**
215
+ * Answers the question whose label is `key` — or `ask#N`, the Nth ask
216
+ * of the trigger (from 1), for an unlabelled one — with
217
+ * `{ by, answer, channel? }` or `'unanswered'`.
218
+ */
219
+ answer(key, value) {
220
+ if (typeof key !== 'string' || key === '') {
221
+ fail(
222
+ `h.questions.answer(key, …) — key is a question's label, or ask#N for the Nth unlabelled ask; got ${JSON.stringify(key)}`
223
+ );
224
+ }
225
+ if (value !== 'unanswered') {
226
+ const ok =
227
+ value !== null &&
228
+ typeof value === 'object' &&
229
+ !Array.isArray(value) &&
230
+ typeof value.by === 'string' &&
231
+ typeof value.answer === 'string';
232
+ if (!ok) {
233
+ fail(
234
+ `h.questions.answer('${key}', …) — expected { by, answer, channel? } or 'unanswered', got ${JSON.stringify(value)}`
235
+ );
236
+ }
237
+ for (const k of Object.keys(value)) {
238
+ if (!['by', 'answer', 'channel'].includes(k)) {
239
+ fail(
240
+ `h.questions.answer('${key}', …) — "${k}" is not part of an answer (have: by, answer, channel)`
241
+ );
242
+ }
243
+ }
244
+ declared(`h.questions.answer('${key}', …)`, value.by);
245
+ value = { ...value };
246
+ }
247
+ rec.answers.set(key, value);
248
+ return harness();
249
+ },
250
+
251
+ /** Every question the handlers asked, in order, defaults applied. */
252
+ asked: () => rec.asked.map((q) => structuredClone(q)),
253
+ },
254
+
255
+ /** Every notification the handlers sent, in order, defaults applied. */
256
+ notifications: () => rec.notifications.map((n) => structuredClone(n)),
257
+
258
+ /** The people notify answers as unreachable (no usable contact). */
259
+ unreachable(...ids) {
260
+ for (const id of ids) declared('h.unreachable', id);
261
+ for (const id of ids) rec.unreachable.add(id);
262
+ return harness();
263
+ },
264
+ };
265
+
266
+ /**
267
+ * ctx.ask and ctx.notify for one run. `handles` is the run's table,
268
+ * `refuse` its recording fail(), `instant` the harness clock.
269
+ */
270
+ function ctx(refuse, handles, instant) {
271
+ let asks = 0;
272
+ return {
273
+ ask(opts) {
274
+ asks++;
275
+ const key = opts?.label ?? `ask#${asks}`;
276
+ const parsed = parseQuestion(opts, people);
277
+ if (parsed.error) {
278
+ return handles.questionHandle('ask', [opts], {
279
+ label: opts?.label,
280
+ error: parsed.error,
281
+ });
282
+ }
283
+ const q = parsed.question;
284
+ let value;
285
+ const answer = rec.answers.get(key);
286
+ if (answer === 'unanswered') {
287
+ value = { outcome: 'unanswered', attempts: q.to.length * q.rounds };
288
+ } else if (answer !== undefined) {
289
+ if (!q.to.includes(answer.by)) {
290
+ refuse(
291
+ `h.questions.answer('${key}', …) — "${answer.by}" was not asked (to: ${q.to.join(', ')})`
292
+ );
293
+ }
294
+ if (!Object.hasOwn(q.options, answer.answer)) {
295
+ refuse(
296
+ `h.questions.answer('${key}', …) — "${answer.answer}" is not one of the question's options (have: ${Object.keys(q.options).join(', ')})`
297
+ );
298
+ }
299
+ value = {
300
+ outcome: 'answered',
301
+ answer: answer.answer,
302
+ by: answer.by,
303
+ channel: answer.channel ?? TEST_CHANNEL,
304
+ at: rfc3339(instant() ?? Date.now()),
305
+ };
306
+ }
307
+ const handle = handles.questionHandle('ask', [opts], {
308
+ label: opts.label,
309
+ value,
310
+ });
311
+ rec.asked.push({ id: handle.id, label: opts.label, ...q });
312
+ return handle;
313
+ },
314
+
315
+ async notify(opts) {
316
+ const parsed = parseNotify(opts, people);
317
+ if (parsed.error) refuse(parsed.error);
318
+ const note = parsed.notification;
319
+ rec.notifications.push(note);
320
+ // The host lists the unreachable in the manifest's order.
321
+ return {
322
+ unreachable: people.filter(
323
+ (p) => note.to.includes(p) && rec.unreachable.has(p)
324
+ ),
325
+ };
326
+ },
327
+ };
328
+ }
329
+
330
+ function reset() {
331
+ rec.answers.clear();
332
+ rec.asked.length = 0;
333
+ rec.notifications.length = 0;
334
+ rec.unreachable.clear();
335
+ }
336
+
337
+ return { h, ctx, reset };
338
+ }