@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 +170 -51
- package/package.json +1 -1
- package/src/ask.js +338 -0
- package/src/bindings.js +254 -28
- package/src/conformance.js +26 -1
- package/src/flow.js +67 -14
- package/src/handles.js +472 -0
- package/src/index.d.ts +193 -5
- package/src/index.js +182 -5
- package/src/scenario.js +14 -3
- package/src/webhooks.js +41 -0
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.
|
|
55
|
-
| `h.
|
|
56
|
-
| `h.
|
|
57
|
-
| `h.
|
|
58
|
-
| `h.
|
|
59
|
-
| `h.
|
|
60
|
-
| `h.
|
|
61
|
-
| `h.
|
|
62
|
-
| `h.
|
|
63
|
-
| `h.
|
|
64
|
-
| `h.
|
|
65
|
-
| `h.
|
|
66
|
-
| `h.
|
|
67
|
-
| `h.
|
|
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
|
|
106
|
-
a `listener` function exactly when the
|
|
107
|
-
every hook name inside Juno's closed
|
|
108
|
-
`states` is a manifest declaration, not
|
|
109
|
-
here — see [`h.states()`](#hstates)
|
|
110
|
-
the first — "you declared six actions
|
|
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.
|
|
251
|
-
| `h.
|
|
252
|
-
| `h.
|
|
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`)
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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.
|
|
308
|
-
`
|
|
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
|
|
373
|
-
which it refuses, and ignores `trigger`,
|
|
374
|
-
can serve both. `$replay` and `$error`
|
|
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
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
|
+
}
|