@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 +134 -17
- 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 +44 -15
- package/src/handles.js +472 -0
- package/src/index.d.ts +177 -6
- package/src/index.js +148 -0
- package/src/scenario.js +14 -3
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
|
|
107
|
-
a `listener` function exactly when the
|
|
108
|
-
every hook name inside Juno's closed
|
|
109
|
-
`states` is a manifest declaration, not
|
|
110
|
-
here — see [`h.states()`](#hstates)
|
|
111
|
-
the first — "you declared six actions
|
|
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`)
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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.
|
|
310
|
-
`
|
|
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
|
|
375
|
-
which it refuses, and ignores `trigger`,
|
|
376
|
-
can serve both. `$replay` and `$error`
|
|
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
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
|
+
}
|