@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/src/index.d.ts CHANGED
@@ -67,6 +67,18 @@ export interface Manifest {
67
67
  string,
68
68
  { description?: string; timeout?: string; [k: string]: unknown }
69
69
  >;
70
+ /** Ways this integration offers to reach a person, by channel key. */
71
+ channels?: Record<
72
+ string,
73
+ {
74
+ name?: string;
75
+ description?: string;
76
+ kind?: string;
77
+ address?: string;
78
+ answers?: string[];
79
+ [k: string]: unknown;
80
+ }
81
+ >;
70
82
  [k: string]: unknown;
71
83
  }
72
84
 
@@ -85,6 +97,52 @@ export interface IntegrationModule {
85
97
  schedules?: Record<string, (event: ScheduleEvent, ctx: never) => unknown>;
86
98
  listener?: (ctx: never) => unknown;
87
99
  hooks?: Record<string, (ctx: never) => unknown>;
100
+ channels?: Record<
101
+ string,
102
+ {
103
+ deliver: (attempt: never, ctx: never) => unknown;
104
+ withdraw?: (attempt: never, ctx: never) => unknown;
105
+ }
106
+ >;
107
+ }
108
+
109
+ /**
110
+ * The attempt a channel's deliver/withdraw receives — the integration's own
111
+ * `Attempt` type, restated loosely here as the rest of this file is.
112
+ */
113
+ export interface ChannelAttempt {
114
+ id: string;
115
+ kind: 'ask' | 'notify';
116
+ address: string;
117
+ mode: 'keypad' | 'buttons' | 'text' | 'acknowledge' | 'link' | 'notify';
118
+ urgency: 'urgent' | 'normal' | 'low';
119
+ message: string;
120
+ options: { key: string; label: string; digit: number }[];
121
+ render: { text: string; speech: string };
122
+ link: string | null;
123
+ deadline: string | null;
124
+ }
125
+
126
+ /** One ctx.channels.report call, as the harness recorded it. */
127
+ export interface RecordedReport {
128
+ attemptId: string;
129
+ status: 'reached' | 'failed' | 'ended' | 'answered';
130
+ digit?: number;
131
+ option?: string;
132
+ reason?: string;
133
+ }
134
+
135
+ /** One ctx.channels.reply call, as the harness recorded it. */
136
+ export interface RecordedReply {
137
+ channel: string;
138
+ address: string;
139
+ text: string;
140
+ }
141
+
142
+ /** What ctx.channels.reply answers; set with h.replyAnswer. */
143
+ export interface ReplyAnswer {
144
+ matched: boolean;
145
+ reply: string | null;
88
146
  }
89
147
 
90
148
  export interface WebhookRequestInit {
@@ -203,6 +261,24 @@ export interface Harness {
203
261
  schedule(name: string, event?: ScheduleEvent): Promise<void>;
204
262
  /** Runs a declared lifecycle hook (e.g. `activated`) with the same context a schedule gets. */
205
263
  hook(name: string): Promise<void>;
264
+ /**
265
+ * Runs a declared channel's deliver with an attempt and resolves to the
266
+ * attempt used. The fixture: `id` `'attempt-1'`, `mode` the channel's first
267
+ * `answers` (a notification, with no options and no deadline, when it
268
+ * declares none), one option `{key: 'ack', label: 'OK', digit: 1}`, a
269
+ * placeholder address of the channel's address kind, rendered text equal to
270
+ * the message, `link: null`, `deadline` ten minutes from now. Any field can
271
+ * be overridden.
272
+ */
273
+ deliver(
274
+ channel: string,
275
+ attempt?: Partial<ChannelAttempt>
276
+ ): Promise<ChannelAttempt>;
277
+ /** Runs a declared channel's withdraw; a no-op when it has none, as in production. */
278
+ withdraw(
279
+ channel: string,
280
+ attempt?: Partial<ChannelAttempt>
281
+ ): Promise<ChannelAttempt>;
206
282
  listen(options?: ListenOptions): Promise<void>;
207
283
  /** The integration's KV, as an ordinary Map. */
208
284
  readonly store: Map<string, unknown>;
@@ -211,6 +287,15 @@ export interface Harness {
211
287
  states(): Record<string, unknown>;
212
288
  /** The last ctx.status.set report, or null when the handler never reported. */
213
289
  status(): RecordedStatus | null;
290
+ /** What ctx.channels.report sent, in order. Cleared by reset(). */
291
+ reports(): RecordedReport[];
292
+ /** What ctx.channels.reply sent, in order. Cleared by reset(). */
293
+ replies(): RecordedReply[];
294
+ /**
295
+ * Sets what ctx.channels.reply answers from now on; the default is
296
+ * {matched: false, reply: null}. Cleared by reset().
297
+ */
298
+ replyAnswer(answer?: Partial<ReplyAnswer>): Harness;
214
299
  emitted(): EmittedEvent[];
215
300
  logs(): LogEntry[];
216
301
  /** Manifest-vs-module problems; empty when they agree. */
@@ -275,6 +360,8 @@ export interface FlowManifest {
275
360
  [k: string]: unknown;
276
361
  }>;
277
362
  credentials?: string[];
363
+ /** The people ctx.ask and ctx.notify may name. */
364
+ people?: string[];
278
365
  [k: string]: unknown;
279
366
  }
280
367
 
@@ -356,10 +443,10 @@ export interface RecordedStateCall {
356
443
  export interface RecordedTargetCall {
357
444
  target: string;
358
445
  action: string;
359
- /** What the SDK sends: `{}` when an action is called bare, `null` for an invoke without input. */
446
+ /** What the SDK sends: `{}` when an action is called bare, `null` for an invoke or start without input. */
360
447
  params: unknown;
361
- /** invoke/invokeAll only, when given. */
362
- opts?: { timeout?: string };
448
+ /** invoke/invokeAll/start/startAll only, when given. */
449
+ opts?: { timeout?: string; label?: string };
363
450
  }
364
451
 
365
452
  export interface RecordedControlCall {
@@ -383,6 +470,13 @@ export interface FlowTargets {
383
470
  * Answers `binding.method` (`kitchenSpeaker.ttsCloudSay`, `child.invoke`):
384
471
  * a value, or a function of the params (an invoke's input) returning one.
385
472
  * An action with no result is answered with `undefined`, explicitly.
473
+ *
474
+ * A flow target's `child.start` answer is the callee's value (its start
475
+ * handle fires at open with it as an InvokeResult; `{ $error }` makes a
476
+ * failed one), and a label target's `notifiers.startAll` answer is the
477
+ * results array. invoke and invokeAll answer from their own key when set,
478
+ * else from start's and startAll's. A start answer must not be a promise:
479
+ * the handle opens at once.
386
480
  */
387
481
  answer(
388
482
  key: string,
@@ -448,6 +542,102 @@ export interface Scenario {
448
542
  expect?: unknown;
449
543
  /** Flows: the version a recorded run ran. Not applied. */
450
544
  flow_version?: number;
545
+ /** Flows only: as h.questions.answer, keyed by label or `ask#N`. */
546
+ questions?: Record<string, QuestionAnswer>;
547
+ /** Flows only: as h.unreachable. */
548
+ unreachable?: string[];
549
+ }
550
+
551
+ /**
552
+ * A handle a handler opened with ctx.after, ctx.at, ctx.all, ctx.ask,
553
+ * state.<b>.when|becomes|changes or targets.<i>.untilEvent. The harness has no
554
+ * clock that moves and no bus, so a handle fires only where the engine fires
555
+ * it at open: an `at` at or before h.now(), a `when` without `for` whose
556
+ * binding's seeded value already matches (fired at h.now(), or the wall clock
557
+ * with none set), a question the test answered, and an `all` whose members
558
+ * all fired at open. An `after`, a `becomes`, a `changes`, a `for` hold, an
559
+ * `untilEvent` and an unanswered question stay `open` until cancelled or the
560
+ * run ends; cancelling an `all`'s member cancels the `all`.
561
+ */
562
+ export interface RecordedHandle {
563
+ /** `__h_N__`, from 0 in each run, as the shim numbers them. */
564
+ id: string;
565
+ /**
566
+ * What the host records: `timer` for ctx.after and ctx.at, `state` for a
567
+ * state binding's when/becomes/changes, `event` for untilEvent, `all` for
568
+ * ctx.all, `run` for a flow target's start/startAll (and so the child of an
569
+ * invoke/invokeAll, whose timer is a `timer`), `press` for a control
570
+ * binding's untilPressed, `question` for ctx.ask.
571
+ */
572
+ kind: 'timer' | 'state' | 'event' | 'all' | 'run' | 'press' | 'question';
573
+ label: string | undefined;
574
+ status: 'open' | 'fired' | 'cancelled';
575
+ }
576
+
577
+ /**
578
+ * How a question is answered in a test: by a recipient, with one of its option
579
+ * keys, through `channel` (default `'test:channel'`) — or `'unanswered'`, every
580
+ * round over.
581
+ */
582
+ export type QuestionAnswer =
583
+ | {
584
+ by: string;
585
+ answer: string;
586
+ /**
587
+ * `<install>:<channel>` the answer came through, `'page'` for the
588
+ * answer page; `test:channel` when omitted.
589
+ */
590
+ channel?: string;
591
+ /** A written reply, as the person wrote it. */
592
+ text?: string;
593
+ /** The site's LLM mapped `text` onto `answer` (freeText: 'interpret'). */
594
+ interpreted?: true;
595
+ }
596
+ | 'unanswered';
597
+
598
+ /** A question a handler asked, as the host stores it: defaults applied. */
599
+ export interface AskedQuestion {
600
+ /** The question's handle, `__h_N__`. */
601
+ id: string;
602
+ label: string | undefined;
603
+ to: string[];
604
+ question: string;
605
+ /** Key → label, in the order written (the keypad's order). */
606
+ options: Record<string, string>;
607
+ mode: 'sequence' | 'all';
608
+ rounds: number;
609
+ escalateAfter: string;
610
+ urgency: 'urgent' | 'normal' | 'low';
611
+ /** null: every channel kind. */
612
+ via: string[] | null;
613
+ }
614
+
615
+ /** A notification a handler sent, defaults applied. */
616
+ export interface RecordedNotification {
617
+ to: string[];
618
+ message: string;
619
+ urgency: 'urgent' | 'normal' | 'low';
620
+ /** null: every channel kind. */
621
+ via: string[] | null;
622
+ link: string | null;
623
+ }
624
+
625
+ export interface FlowQuestions {
626
+ /**
627
+ * Answers the question whose `label` is key — or `ask#N`, the Nth ask of the
628
+ * trigger (from 1), for one without a label. An answer fires the question at
629
+ * open with `{ outcome: 'answered', answer, by, channel, text?, interpreted?, at }`
630
+ * (text and interpreted passed through when given), `at` being
631
+ * h.now() (or the wall clock with none set); `by` must be one of its `to`
632
+ * and `answer` one of its option keys. `'unanswered'` fires it with
633
+ * `{ outcome: 'unanswered', attempts: to.length * rounds }` — the host does
634
+ * not count a recipient no contact could reach, and the harness has no
635
+ * contacts, so it counts every one. A question left unanswered stays open,
636
+ * so a wait on it alone refuses, as every wait nothing can answer does.
637
+ */
638
+ answer(key: string, value: QuestionAnswer): FlowHarness;
639
+ /** Every question asked, in order. A refused ask is not one. */
640
+ asked(): AskedQuestion[];
451
641
  }
452
642
 
453
643
  export interface FlowHarness {
@@ -482,6 +672,11 @@ export interface FlowHarness {
482
672
  ): FlowHarness;
483
673
  /** Every step.run and step.llm, in order. */
484
674
  steps(): RecordedStep[];
675
+ /**
676
+ * The last h.trigger's handles, in open order. One still open when the run
677
+ * ended reads `cancelled`, as the engine closes it.
678
+ */
679
+ handles(): RecordedHandle[];
485
680
  logs(): FlowLogEntry[];
486
681
  /** Triggers declared vs implemented, both ways; empty when they agree. */
487
682
  conformance(): string[];
@@ -496,6 +691,15 @@ export interface FlowHarness {
496
691
  findings(): RecordedFinding[];
497
692
  /** Sets ctx.credentials by declared id. Kept across reset(). */
498
693
  credentials(values: Record<string, string>): FlowHarness;
694
+ /**
695
+ * ctx.ask, answered. ask applies the host's refusals that need only the
696
+ * manifest (the people grant, every field's limits) with the host's words.
697
+ */
698
+ readonly questions: FlowQuestions;
699
+ /** Every ctx.notify, in order. Each answers `{ unreachable: [] }` unless h.unreachable named someone. */
700
+ notifications(): RecordedNotification[];
701
+ /** People ctx.notify reports unreachable (no contact the message may use). */
702
+ unreachable(...ids: string[]): FlowHarness;
499
703
  /**
500
704
  * Sets ctx.webhooks['<id>'].url for a webhook id a trigger names. Each starts
501
705
  * at a stand-in URL (https://juno.test/webhooks/test-<id>); null is what the
@@ -503,9 +707,9 @@ export interface FlowHarness {
503
707
  */
504
708
  webhookUrls(values: Record<string, string | null>): FlowHarness;
505
709
  /**
506
- * Clears recordings, answers, seeded states, the scenario's event and
507
- * scoped memory; keeps the clock, timezone, solar, credentials and webhook
508
- * URLs.
710
+ * Clears recordings, answers (questions and unreachable people included),
711
+ * seeded states, the scenario's event and scoped memory; keeps the clock,
712
+ * timezone, solar, credentials and webhook URLs.
509
713
  */
510
714
  reset(): FlowHarness;
511
715
  }
package/src/index.js CHANGED
@@ -45,6 +45,15 @@ export function harness({ manifest, integration } = {}) {
45
45
  // The last ctx.status.set report, or null before the first. One slot, not
46
46
  // a list: the host replaces the report on every write.
47
47
  status: null,
48
+ // What ctx.channels.report sent, in order. Cleared by reset().
49
+ reports: [],
50
+ // What ctx.channels.reply sent, in order, and what it answers: the
51
+ // harness has no questions to match against, so the test says.
52
+ replies: [],
53
+ replyAnswer: { matched: false, reply: null },
54
+ // The attempts h.deliver/h.withdraw handed a channel, by id: a report on
55
+ // one of them is checked against its options the way the host checks it.
56
+ attempts: new Map(),
48
57
  // ctx.signal for every surface but the listener, which brings its own.
49
58
  // Never aborted here: the host aborts it on a timeout or a shutdown, and a
50
59
  // test has neither.
@@ -162,6 +171,23 @@ export function harness({ manifest, integration } = {}) {
162
171
  }
163
172
  },
164
173
 
174
+ /**
175
+ * Runs a declared channel's deliver with an attempt — a fixture one, with
176
+ * any field overridden — exactly as the host would, and resolves to the
177
+ * attempt it used. Reports land in h.reports().
178
+ */
179
+ async deliver(channel, attempt) {
180
+ return runChannelHook(channel, 'deliver', attempt);
181
+ },
182
+
183
+ /**
184
+ * Runs a declared channel's withdraw, as when the question the attempt
185
+ * carried closes. A channel without withdraw is a no-op, as in production.
186
+ */
187
+ async withdraw(channel, attempt) {
188
+ return runChannelHook(channel, 'withdraw', attempt);
189
+ },
190
+
165
191
  /**
166
192
  * Runs the listener until `until` holds, then aborts and waits for it to
167
193
  * unwind. A listener that ignores its abort signal fails here rather than
@@ -235,6 +261,19 @@ export function harness({ manifest, integration } = {}) {
235
261
  ...state.status,
236
262
  ...(state.status.details && { details: { ...state.status.details } }),
237
263
  },
264
+ /** What ctx.channels.report sent, in order. */
265
+ reports: () => state.reports.map((r) => ({ ...r })),
266
+ /** What ctx.channels.reply sent, as {channel, address, text}, in order. */
267
+ replies: () => state.replies.map((r) => ({ ...r })),
268
+ /**
269
+ * Sets what ctx.channels.reply answers from now on (default
270
+ * {matched: false, reply: null}): Juno matches a reply against the
271
+ * questions it holds, which a test does not have.
272
+ */
273
+ replyAnswer({ matched = false, reply = null } = {}) {
274
+ state.replyAnswer = { matched: matched === true, reply: reply ?? null };
275
+ return h;
276
+ },
238
277
  /** Events the handler put on the bus, in order. */
239
278
  emitted: () => state.emitted.slice(),
240
279
  /** Everything the handler logged, with its level. */
@@ -250,15 +289,155 @@ export function harness({ manifest, integration } = {}) {
250
289
  state.logs.length = 0;
251
290
  state.publishedStates.clear();
252
291
  state.status = null;
292
+ state.reports.length = 0;
293
+ state.replies.length = 0;
294
+ state.replyAnswer = { matched: false, reply: null };
295
+ state.attempts.clear();
253
296
  state.store.clear();
254
297
  http.reset();
255
298
  return h;
256
299
  },
257
300
  };
258
301
 
302
+ async function runChannelHook(channel, hook, overrides) {
303
+ const declared = manifest.channels?.[channel];
304
+ if (!declared) {
305
+ fail(
306
+ `channel "${channel}" is not declared in manifest.json (have: ${names(manifest.channels)})`
307
+ );
308
+ }
309
+ const handlers = integration.channels?.[channel];
310
+ if (typeof handlers?.deliver !== 'function') {
311
+ fail(
312
+ `channel "${channel}" is declared in manifest.json but the default export does not implement its deliver`
313
+ );
314
+ }
315
+ assertRequiredSlots(manifest, state);
316
+ const attempt = { ...fixtureAttempt(declared), ...overrides };
317
+ state.attempts.set(attempt.id, attempt);
318
+
319
+ const handler = handlers[hook];
320
+ // withdraw is optional: the SDK completes a missing one as a no-op.
321
+ if (handler === undefined && hook === 'withdraw') return attempt;
322
+ if (typeof handler !== 'function') {
323
+ fail(`channel "${channel}"'s ${hook} is not a function`);
324
+ }
325
+ const result = await withFetch(http.fetch, () =>
326
+ handler(attempt, emitting(ctx, manifest, state))
327
+ );
328
+ if (result !== undefined) {
329
+ fail(
330
+ `channel "${channel}"'s ${hook} returned a value; a channel handler reports through ctx and returns nothing`
331
+ );
332
+ }
333
+ return attempt;
334
+ }
335
+
259
336
  return h;
260
337
  }
261
338
 
339
+ /** A placeholder address per manifest.AddressKinds. */
340
+ const ADDRESS_PLACEHOLDERS = {
341
+ phone: '+46700000000',
342
+ email: 'person@example.test',
343
+ text: 'test-address',
344
+ };
345
+
346
+ /**
347
+ * The attempt h.deliver hands a channel unless the test says otherwise: a
348
+ * one-option question in the channel's first answer mode, or a notification
349
+ * when the channel declares no answers — the only thing such a channel can
350
+ * carry. Rendered text is the message itself, as it is for every mode but
351
+ * keypad.
352
+ */
353
+ function fixtureAttempt(channel) {
354
+ const mode = channel.answers?.[0] ?? 'notify';
355
+ const notify = mode === 'notify';
356
+ const message = notify ? 'This is a test notification.' : 'Is this a test?';
357
+ return {
358
+ id: 'attempt-1',
359
+ kind: notify ? 'notify' : 'ask',
360
+ address: ADDRESS_PLACEHOLDERS[channel.address] ?? 'test-address',
361
+ mode,
362
+ urgency: 'normal',
363
+ message,
364
+ options: notify ? [] : [{ key: 'ack', label: 'OK', digit: 1 }],
365
+ render: { text: message, speech: message },
366
+ link: null,
367
+ deadline: notify ? null : new Date(Date.now() + 10 * 60_000).toISOString(),
368
+ };
369
+ }
370
+
371
+ const REPORT_STATUSES = ['reached', 'failed', 'ended', 'answered'];
372
+
373
+ /**
374
+ * The one call behind ctx.channels.report, applying the host's refusals that
375
+ * need no database: an install with no channels, the status vocabulary,
376
+ * exactly one of digit or option, and — for an attempt this harness handed out
377
+ * — a digit or option the attempt has. An id the harness never issued is
378
+ * recorded, not refused: a webhook test reports on a reference it made up, and
379
+ * the host's "was not issued" check needs the attempt rows a test does not
380
+ * have.
381
+ */
382
+ function reportChannel(manifest, state, attemptId, report) {
383
+ const install = manifest.id ?? manifest.name;
384
+ if (Object.keys(manifest.channels ?? {}).length === 0) {
385
+ fail(`channel.report: ${install} declares no channels`);
386
+ }
387
+ const { status, digit, option, reason } = report ?? {};
388
+ if (!REPORT_STATUSES.includes(status)) {
389
+ fail(
390
+ `channel.report: status "${status}" must be reached, failed, ended or answered`
391
+ );
392
+ }
393
+ if (status === 'answered') {
394
+ if ((digit === undefined) === (option === undefined)) {
395
+ fail('channel.report: answered needs exactly one of digit or option');
396
+ }
397
+ const options = state.attempts.get(attemptId)?.options;
398
+ if (options && digit !== undefined) {
399
+ if (!Number.isInteger(digit) || digit < 1 || digit > options.length) {
400
+ fail(
401
+ `channel.report: digit ${digit} is not an option (1–${options.length})`
402
+ );
403
+ }
404
+ }
405
+ if (options && option !== undefined) {
406
+ if (!options.some((o) => o.key === option)) {
407
+ fail(
408
+ `channel.report: option "${option}" is not one of ${options.map((o) => o.key).join(', ')}`
409
+ );
410
+ }
411
+ }
412
+ }
413
+ state.reports.push({
414
+ attemptId,
415
+ status,
416
+ ...(digit !== undefined && { digit }),
417
+ ...(option !== undefined && { option }),
418
+ ...(reason !== undefined && { reason }),
419
+ });
420
+ }
421
+
422
+ /**
423
+ * The one call behind ctx.channels.reply, applying the host's refusals that
424
+ * need no database: an install with no channels, and a channel that is not
425
+ * declared or does not take text. It records the message (only the fields the
426
+ * SDK sends) and answers what h.replyAnswer set.
427
+ */
428
+ function replyChannel(manifest, state, message) {
429
+ const install = manifest.id ?? manifest.name;
430
+ if (Object.keys(manifest.channels ?? {}).length === 0) {
431
+ fail(`channel.reply: ${install} declares no channels`);
432
+ }
433
+ const { channel, address, text } = message ?? {};
434
+ if (!manifest.channels[channel]?.answers?.includes('text')) {
435
+ fail(`channel.reply: ${install} has no text channel "${channel}"`);
436
+ }
437
+ state.replies.push({ channel, address, text });
438
+ return { ...state.replyAnswer };
439
+ }
440
+
262
441
  /**
263
442
  * The base context, matching what integration-entrypoint.ts builds. Handlers
264
443
  * cannot tell the difference, which is the only way a test here means anything
@@ -325,6 +504,14 @@ function buildContext(manifest, state, webhooks) {
325
504
  setStatus(state, report);
326
505
  },
327
506
  },
507
+ channels: {
508
+ async report(attemptId, report) {
509
+ reportChannel(manifest, state, attemptId, report);
510
+ },
511
+ async reply(message) {
512
+ return replyChannel(manifest, state, message);
513
+ },
514
+ },
328
515
  };
329
516
  }
330
517
 
package/src/scenario.js CHANGED
@@ -11,9 +11,10 @@ import { fail } from './assert.js';
11
11
  * - `event` is kept by flowHarness for an event-less `h.trigger(name)`.
12
12
  * - `flow_version` is the version a recorded run ran, which a fixture made
13
13
  * from that run carries.
14
- * - An app has no trigger, event or step.llm. It ignores `trigger` and
15
- * `event`, so one scenario can describe both, and `llm` is unknown to it:
16
- * an answer it would never read is a mistake, not a no-op.
14
+ * - An app has no trigger, event, step.llm, ask or notify. It ignores
15
+ * `trigger` and `event`, so one scenario can describe both, and `llm`,
16
+ * `questions` and `unreachable` are unknown to it: an answer it would never
17
+ * read is a mistake, not a no-op.
17
18
  */
18
19
  const SHARED = {
19
20
  trigger: null,
@@ -33,6 +34,16 @@ const KEYS = {
33
34
  flow: {
34
35
  ...SHARED,
35
36
  llm: (h, v) => answerAll(v, 'llm', (k, a) => h.llm(k, a)),
37
+ questions: (h, v) =>
38
+ answerAll(v, 'questions', (k, a) => h.questions.answer(k, a)),
39
+ unreachable: (h, v) => {
40
+ if (!Array.isArray(v)) {
41
+ fail(
42
+ `h.scenario — "unreachable" lists person ids; got ${JSON.stringify(v)}`
43
+ );
44
+ }
45
+ h.unreachable(...v);
46
+ },
36
47
  flow_version: null,
37
48
  },
38
49
  app: SHARED,