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