@junoflow/testkit 0.10.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
@@ -47,24 +47,28 @@ plain form works.
47
47
 
48
48
  `harness({ manifest, integration })` returns:
49
49
 
50
- | Member | Does |
51
- | -------------------------------------- | ------------------------------------------------------------------------ |
52
- | `h.config(values)` | set non-credential slots (manifest defaults are already applied) |
53
- | `h.credentials(values)` | set credential slots |
54
- | `h.action(name, params)` | run an action, returns its result |
55
- | `h.webhook(name, request?)` | run a webhook; `request` defaults to `POST /` with an empty body |
56
- | `h.schedule(name, event?)` | run a schedule handler |
57
- | `h.hook(name)` | run a declared lifecycle hook (e.g. `activated`) |
58
- | `h.listen({ until, events, timeout })` | run the listener until the condition holds, then abort |
59
- | `h.store` | the KV, as a plain `Map` (`get`/`set`/`delete` all land here) |
60
- | `h.findings()` | what `ctx.findings` asserted, as `{slug, key, severity, text, standing}` |
61
- | `h.states()` | values `ctx.states.publish` has sent, keyed by local name |
62
- | `h.status()` | the last `ctx.status.set` report, or `null` if it never reported |
63
- | `h.emitted()` | `[{ event, payload }]`, in order |
64
- | `h.logs()` | `[{ level, message, data }]` |
65
- | `h.conformance()` | manifest-vs-module problems; `[]` when they agree |
66
- | `h.http` | the request mock, below |
67
- | `h.reset()` | clear recorded state, keeping config and credentials |
50
+ | Member | Does |
51
+ | -------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
52
+ | `h.config(values)` | set non-credential slots (manifest defaults are already applied) |
53
+ | `h.credentials(values)` | set credential slots |
54
+ | `h.webhookUrls(values)` | set `ctx.webhooks.<slot>.url`; each slot starts at `https://juno.test/webhooks/test-<slot>`, `null` is "no public URL" |
55
+ | `h.action(name, params)` | run an action, returns its result |
56
+ | `h.webhook(name, request?)` | run a webhook; `request` defaults to a `POST` with an empty body, to the slot's URL |
57
+ | `h.schedule(name, event?)` | run a schedule handler |
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 |
61
+ | `h.listen({ until, events, timeout })` | run the listener until the condition holds, then abort |
62
+ | `h.store` | the KV, as a plain `Map` (`get`/`set`/`delete` all land here) |
63
+ | `h.findings()` | what `ctx.findings` asserted, as `{slug, key, severity, text, standing}` |
64
+ | `h.states()` | values `ctx.states.publish` has sent, keyed by local name |
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.emitted()` | `[{ event, payload }]`, in order |
68
+ | `h.logs()` | `[{ level, message, data }]` |
69
+ | `h.conformance()` | manifest-vs-module problems; `[]` when they agree |
70
+ | `h.http` | the request mock, below |
71
+ | `h.reset()` | clear recorded state, keeping config and credentials |
68
72
 
69
73
  ### `h.http`
70
74
 
@@ -102,13 +106,13 @@ production it hangs the supervisor on shutdown instead.
102
106
  ### `h.conformance()`
103
107
 
104
108
  Checks the manifest and the module against each other: every declared surface
105
- implemented (actions, webhooks, schedules, hooks), nothing undeclared exported,
106
- a `listener` function exactly when the manifest declares a `listener` section,
107
- every hook name inside Juno's closed vocabulary, and every schema compiling.
108
- `states` is a manifest declaration, not a handler surface, so it plays no part
109
- here — see [`h.states()`](#hstates) instead. Returns all problems rather than
110
- the first — "you declared six actions and implemented four" is one fix, not four
111
- 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.
112
116
 
113
117
  ### `h.findings()`
114
118
 
@@ -177,6 +181,33 @@ hook reports through ctx and returns nothing" is what production says too.
177
181
  `h.conformance()` also rejects a hook name outside Juno's closed vocabulary
178
182
  (today, only `activated`), which is the check that catches a typo before deploy.
179
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
+
180
211
  ## What the harness is faithful about
181
212
 
182
213
  - `ctx.store` round-trips values through JSON, because the real store does. A
@@ -234,22 +265,25 @@ it('greets when someone is home', async () => {
234
265
 
235
266
  `flowHarness({ manifest, flow, now?, timezone?, solar? })` returns:
236
267
 
237
- | Member | Does |
238
- | ---------------------------------------------- | ------------------------------------------------------------------------------------------- |
239
- | `h.trigger(name, { event })` | run a declared trigger; resolves to the handler's return value |
240
- | `h.scenario(s)` | set the world from one JSON object (below) |
241
- | `h.now(iso)` / `h.timezone(tz)` / `h.solar(v)` | what `ctx.now()` and `ctx.solar()` answer; the timezone is `UTC` unless set |
242
- | `h.states(values)` / `h.state(name)` | seed a `read` or `read_write` binding; read its current value |
243
- | `h.stateCalls()` | `set`, `claim`, `release`, `signal`, `retract`, as `{binding, verb, value?, opts?}` |
244
- | `h.targets.answer(key, a)` / `.calls()` | answer `binding.method` with a value or a function of the params; the calls made |
245
- | `h.controls.answer(key, a)` / `.calls()` | answer `binding.verb`; the commands made |
246
- | `h.llm(id, answer)` | answer `step.llm(id, …)`; `id#N` answers only that occurrence |
247
- | `h.steps()` | every `step.run` and `step.llm`, as `{id, output}`; repeats are `id`, `id#1`, `id#2` |
248
- | `h.findings()` | `{slug, key, severity, text, standing}`, collapsed by `(slug, key)` as the host does |
249
- | `h.credentials(values)` | set `ctx.credentials` by declared id |
250
- | `h.logs()` | `[{ level, message, data }]` |
251
- | `h.conformance()` | triggers declared vs implemented, both ways; `[]` when they agree |
252
- | `h.reset()` | clear recordings, answers, seeds and scoped state; keep clock, timezone, solar, credentials |
268
+ | Member | Does |
269
+ | ---------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
270
+ | `h.trigger(name, { event })` | run a declared trigger; resolves to the handler's return value |
271
+ | `h.scenario(s)` | set the world from one JSON object (below) |
272
+ | `h.now(iso)` / `h.timezone(tz)` / `h.solar(v)` | what `ctx.now()` and `ctx.solar()` answer; the timezone is `UTC` unless set |
273
+ | `h.states(values)` / `h.state(name)` | seed a `read` or `read_write` binding; read its current value |
274
+ | `h.stateCalls()` | `set`, `claim`, `release`, `signal`, `retract`, as `{binding, verb, value?, opts?}` |
275
+ | `h.targets.answer(key, a)` / `.calls()` | answer `binding.method` with a value or a function of the params; the calls made |
276
+ | `h.controls.answer(key, a)` / `.calls()` | answer `binding.verb`; the commands made |
277
+ | `h.llm(id, answer)` | answer `step.llm(id, …)`; `id#N` answers only that occurrence |
278
+ | `h.steps()` | every `step.run` and `step.llm`, as `{id, output}`; repeats are `id`, `id#1`, `id#2` |
279
+ | `h.findings()` | `{slug, key, severity, text, standing}`, collapsed by `(slug, key)` as the host does |
280
+ | `h.credentials(values)` | set `ctx.credentials` by declared id |
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 |
284
+ | `h.logs()` | `[{ level, message, data }]` |
285
+ | `h.conformance()` | triggers declared vs implemented, both ways; `[]` when they agree |
286
+ | `h.reset()` | clear recordings, answers, seeds and scoped state; keep clock, timezone, solar, credentials, webhook URLs |
253
287
 
254
288
  `h.trigger(name, { event })` sets `event.trigger` and spreads the payload you
255
289
  give onto the event, as the sandbox does. An array (a `batch` run) stays under
@@ -277,10 +311,11 @@ it('replays run 42', async () => {
277
311
 
278
312
  Its keys are `now`, `timezone`, `solar`, `states`, `credentials`, `targets`
279
313
  (`"binding.method": answer`), `controls` (`"binding.verb": answer`), `llm`
280
- (`"stepId": answer`) and `event`, which an `h.trigger(name)` with no `{ event }`
281
- runs with; an explicit event, even `null`, wins. `trigger`, `expect` and
282
- `flow_version` are carried and not applied. A key left out sets nothing; an
283
- 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.
284
319
 
285
320
  Two answer forms are reserved, in a scenario and in `h.targets.answer`,
286
321
  `h.controls.answer` and `h.llm` alike:
@@ -294,6 +329,89 @@ Two answer forms are reserved, in a scenario and in `h.targets.answer`,
294
329
  `expect` filled in from what the run did; see the flow testing docs ("Replaying
295
330
  a run").
296
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
+
297
415
  ### What fails
298
416
 
299
417
  Strict where a permissive double would pass a broken flow. Each of these fails
@@ -304,8 +422,9 @@ the test, naming what was asked for and how to supply it:
304
422
  that seeds or answers it;
305
423
  - a state verb the binding was not granted (`set` on a `read` binding, `get` on
306
424
  a slot), and `get` on a binding the test did not seed;
307
- - `ctx.sleep(…)` and `ctx.waitForEvent(…)`:
308
- `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")])`;
309
428
  - `ctx.now()`, `ctx.solar()`, a target call, a control command or a `step.llm`
310
429
  the test did not answer, e.g.
311
430
  `target call kitchenSpeaker.ttsCloudSay was not answered — h.targets.answer('kitchenSpeaker.ttsCloudSay', result)`.
@@ -369,10 +488,10 @@ it('a read-only handler cannot claim', async () => {
369
488
  | `h.conformance()` | handlers declared vs implemented, both ways; `[]` when they agree |
370
489
  | `h.reset()` | clear recordings, answers and seeds; keep clock, timezone, solar, credentials |
371
490
 
372
- `h.scenario(s)` takes the flow scenario's keys except `llm` and `flow_version`,
373
- which it refuses, and ignores `trigger`, `event` and `expect`, so one scenario
374
- can serve both. `$replay` and `$error` answer targets and controls as they do in
375
- 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.
376
495
 
377
496
  The handler's `ctx` is the app sandbox's: `log`, `now`, `solar`, `signal`,
378
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.10.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
+ }