@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/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,39 @@ 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;
|
|
88
133
|
}
|
|
89
134
|
|
|
90
135
|
export interface WebhookRequestInit {
|
|
@@ -203,6 +248,24 @@ export interface Harness {
|
|
|
203
248
|
schedule(name: string, event?: ScheduleEvent): Promise<void>;
|
|
204
249
|
/** Runs a declared lifecycle hook (e.g. `activated`) with the same context a schedule gets. */
|
|
205
250
|
hook(name: string): Promise<void>;
|
|
251
|
+
/**
|
|
252
|
+
* Runs a declared channel's deliver with an attempt and resolves to the
|
|
253
|
+
* attempt used. The fixture: `id` `'attempt-1'`, `mode` the channel's first
|
|
254
|
+
* `answers` (a notification, with no options and no deadline, when it
|
|
255
|
+
* declares none), one option `{key: 'ack', label: 'OK', digit: 1}`, a
|
|
256
|
+
* placeholder address of the channel's address kind, rendered text equal to
|
|
257
|
+
* the message, `link: null`, `deadline` ten minutes from now. Any field can
|
|
258
|
+
* be overridden.
|
|
259
|
+
*/
|
|
260
|
+
deliver(
|
|
261
|
+
channel: string,
|
|
262
|
+
attempt?: Partial<ChannelAttempt>
|
|
263
|
+
): Promise<ChannelAttempt>;
|
|
264
|
+
/** Runs a declared channel's withdraw; a no-op when it has none, as in production. */
|
|
265
|
+
withdraw(
|
|
266
|
+
channel: string,
|
|
267
|
+
attempt?: Partial<ChannelAttempt>
|
|
268
|
+
): Promise<ChannelAttempt>;
|
|
206
269
|
listen(options?: ListenOptions): Promise<void>;
|
|
207
270
|
/** The integration's KV, as an ordinary Map. */
|
|
208
271
|
readonly store: Map<string, unknown>;
|
|
@@ -211,6 +274,8 @@ export interface Harness {
|
|
|
211
274
|
states(): Record<string, unknown>;
|
|
212
275
|
/** The last ctx.status.set report, or null when the handler never reported. */
|
|
213
276
|
status(): RecordedStatus | null;
|
|
277
|
+
/** What ctx.channels.report sent, in order. Cleared by reset(). */
|
|
278
|
+
reports(): RecordedReport[];
|
|
214
279
|
emitted(): EmittedEvent[];
|
|
215
280
|
logs(): LogEntry[];
|
|
216
281
|
/** Manifest-vs-module problems; empty when they agree. */
|
|
@@ -275,6 +340,8 @@ export interface FlowManifest {
|
|
|
275
340
|
[k: string]: unknown;
|
|
276
341
|
}>;
|
|
277
342
|
credentials?: string[];
|
|
343
|
+
/** The people ctx.ask and ctx.notify may name. */
|
|
344
|
+
people?: string[];
|
|
278
345
|
[k: string]: unknown;
|
|
279
346
|
}
|
|
280
347
|
|
|
@@ -356,10 +423,10 @@ export interface RecordedStateCall {
|
|
|
356
423
|
export interface RecordedTargetCall {
|
|
357
424
|
target: string;
|
|
358
425
|
action: string;
|
|
359
|
-
/** What the SDK sends: `{}` when an action is called bare, `null` for an invoke without input. */
|
|
426
|
+
/** What the SDK sends: `{}` when an action is called bare, `null` for an invoke or start without input. */
|
|
360
427
|
params: unknown;
|
|
361
|
-
/** invoke/invokeAll only, when given. */
|
|
362
|
-
opts?: { timeout?: string };
|
|
428
|
+
/** invoke/invokeAll/start/startAll only, when given. */
|
|
429
|
+
opts?: { timeout?: string; label?: string };
|
|
363
430
|
}
|
|
364
431
|
|
|
365
432
|
export interface RecordedControlCall {
|
|
@@ -383,6 +450,13 @@ export interface FlowTargets {
|
|
|
383
450
|
* Answers `binding.method` (`kitchenSpeaker.ttsCloudSay`, `child.invoke`):
|
|
384
451
|
* a value, or a function of the params (an invoke's input) returning one.
|
|
385
452
|
* An action with no result is answered with `undefined`, explicitly.
|
|
453
|
+
*
|
|
454
|
+
* A flow target's `child.start` answer is the callee's value (its start
|
|
455
|
+
* handle fires at open with it as an InvokeResult; `{ $error }` makes a
|
|
456
|
+
* failed one), and a label target's `notifiers.startAll` answer is the
|
|
457
|
+
* results array. invoke and invokeAll answer from their own key when set,
|
|
458
|
+
* else from start's and startAll's. A start answer must not be a promise:
|
|
459
|
+
* the handle opens at once.
|
|
386
460
|
*/
|
|
387
461
|
answer(
|
|
388
462
|
key: string,
|
|
@@ -448,6 +522,89 @@ export interface Scenario {
|
|
|
448
522
|
expect?: unknown;
|
|
449
523
|
/** Flows: the version a recorded run ran. Not applied. */
|
|
450
524
|
flow_version?: number;
|
|
525
|
+
/** Flows only: as h.questions.answer, keyed by label or `ask#N`. */
|
|
526
|
+
questions?: Record<string, QuestionAnswer>;
|
|
527
|
+
/** Flows only: as h.unreachable. */
|
|
528
|
+
unreachable?: string[];
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* A handle a handler opened with ctx.after, ctx.at, ctx.all, ctx.ask,
|
|
533
|
+
* state.<b>.when|becomes|changes or targets.<i>.untilEvent. The harness has no
|
|
534
|
+
* clock that moves and no bus, so a handle fires only where the engine fires
|
|
535
|
+
* it at open: an `at` at or before h.now(), a `when` without `for` whose
|
|
536
|
+
* binding's seeded value already matches (fired at h.now(), or the wall clock
|
|
537
|
+
* with none set), a question the test answered, and an `all` whose members
|
|
538
|
+
* all fired at open. An `after`, a `becomes`, a `changes`, a `for` hold, an
|
|
539
|
+
* `untilEvent` and an unanswered question stay `open` until cancelled or the
|
|
540
|
+
* run ends; cancelling an `all`'s member cancels the `all`.
|
|
541
|
+
*/
|
|
542
|
+
export interface RecordedHandle {
|
|
543
|
+
/** `__h_N__`, from 0 in each run, as the shim numbers them. */
|
|
544
|
+
id: string;
|
|
545
|
+
/**
|
|
546
|
+
* What the host records: `timer` for ctx.after and ctx.at, `state` for a
|
|
547
|
+
* state binding's when/becomes/changes, `event` for untilEvent, `all` for
|
|
548
|
+
* ctx.all, `run` for a flow target's start/startAll (and so the child of an
|
|
549
|
+
* invoke/invokeAll, whose timer is a `timer`), `press` for a control
|
|
550
|
+
* binding's untilPressed, `question` for ctx.ask.
|
|
551
|
+
*/
|
|
552
|
+
kind: 'timer' | 'state' | 'event' | 'all' | 'run' | 'press' | 'question';
|
|
553
|
+
label: string | undefined;
|
|
554
|
+
status: 'open' | 'fired' | 'cancelled';
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* How a question is answered in a test: by a recipient, with one of its option
|
|
559
|
+
* keys, through `channel` (default `'test:channel'`) — or `'unanswered'`, every
|
|
560
|
+
* round over.
|
|
561
|
+
*/
|
|
562
|
+
export type QuestionAnswer =
|
|
563
|
+
| { by: string; answer: string; channel?: string }
|
|
564
|
+
| 'unanswered';
|
|
565
|
+
|
|
566
|
+
/** A question a handler asked, as the host stores it: defaults applied. */
|
|
567
|
+
export interface AskedQuestion {
|
|
568
|
+
/** The question's handle, `__h_N__`. */
|
|
569
|
+
id: string;
|
|
570
|
+
label: string | undefined;
|
|
571
|
+
to: string[];
|
|
572
|
+
question: string;
|
|
573
|
+
/** Key → label, in the order written (the keypad's order). */
|
|
574
|
+
options: Record<string, string>;
|
|
575
|
+
mode: 'sequence' | 'all';
|
|
576
|
+
rounds: number;
|
|
577
|
+
escalateAfter: string;
|
|
578
|
+
urgency: 'urgent' | 'normal' | 'low';
|
|
579
|
+
/** null: every channel kind. */
|
|
580
|
+
via: string[] | null;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/** A notification a handler sent, defaults applied. */
|
|
584
|
+
export interface RecordedNotification {
|
|
585
|
+
to: string[];
|
|
586
|
+
message: string;
|
|
587
|
+
urgency: 'urgent' | 'normal' | 'low';
|
|
588
|
+
/** null: every channel kind. */
|
|
589
|
+
via: string[] | null;
|
|
590
|
+
link: string | null;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
export interface FlowQuestions {
|
|
594
|
+
/**
|
|
595
|
+
* Answers the question whose `label` is key — or `ask#N`, the Nth ask of the
|
|
596
|
+
* trigger (from 1), for one without a label. An answer fires the question at
|
|
597
|
+
* open with `{ outcome: 'answered', answer, by, channel, at }`, `at` being
|
|
598
|
+
* h.now() (or the wall clock with none set); `by` must be one of its `to`
|
|
599
|
+
* and `answer` one of its option keys. `'unanswered'` fires it with
|
|
600
|
+
* `{ outcome: 'unanswered', attempts: to.length * rounds }` — the host does
|
|
601
|
+
* not count a recipient no contact could reach, and the harness has no
|
|
602
|
+
* contacts, so it counts every one. A question left unanswered stays open,
|
|
603
|
+
* so a wait on it alone refuses, as every wait nothing can answer does.
|
|
604
|
+
*/
|
|
605
|
+
answer(key: string, value: QuestionAnswer): FlowHarness;
|
|
606
|
+
/** Every question asked, in order. A refused ask is not one. */
|
|
607
|
+
asked(): AskedQuestion[];
|
|
451
608
|
}
|
|
452
609
|
|
|
453
610
|
export interface FlowHarness {
|
|
@@ -482,6 +639,11 @@ export interface FlowHarness {
|
|
|
482
639
|
): FlowHarness;
|
|
483
640
|
/** Every step.run and step.llm, in order. */
|
|
484
641
|
steps(): RecordedStep[];
|
|
642
|
+
/**
|
|
643
|
+
* The last h.trigger's handles, in open order. One still open when the run
|
|
644
|
+
* ended reads `cancelled`, as the engine closes it.
|
|
645
|
+
*/
|
|
646
|
+
handles(): RecordedHandle[];
|
|
485
647
|
logs(): FlowLogEntry[];
|
|
486
648
|
/** Triggers declared vs implemented, both ways; empty when they agree. */
|
|
487
649
|
conformance(): string[];
|
|
@@ -496,6 +658,15 @@ export interface FlowHarness {
|
|
|
496
658
|
findings(): RecordedFinding[];
|
|
497
659
|
/** Sets ctx.credentials by declared id. Kept across reset(). */
|
|
498
660
|
credentials(values: Record<string, string>): FlowHarness;
|
|
661
|
+
/**
|
|
662
|
+
* ctx.ask, answered. ask applies the host's refusals that need only the
|
|
663
|
+
* manifest (the people grant, every field's limits) with the host's words.
|
|
664
|
+
*/
|
|
665
|
+
readonly questions: FlowQuestions;
|
|
666
|
+
/** Every ctx.notify, in order. Each answers `{ unreachable: [] }` unless h.unreachable named someone. */
|
|
667
|
+
notifications(): RecordedNotification[];
|
|
668
|
+
/** People ctx.notify reports unreachable (no contact the message may use). */
|
|
669
|
+
unreachable(...ids: string[]): FlowHarness;
|
|
499
670
|
/**
|
|
500
671
|
* Sets ctx.webhooks['<id>'].url for a webhook id a trigger names. Each starts
|
|
501
672
|
* at a stand-in URL (https://juno.test/webhooks/test-<id>); null is what the
|
|
@@ -503,9 +674,9 @@ export interface FlowHarness {
|
|
|
503
674
|
*/
|
|
504
675
|
webhookUrls(values: Record<string, string | null>): FlowHarness;
|
|
505
676
|
/**
|
|
506
|
-
* Clears recordings, answers
|
|
507
|
-
* scoped memory; keeps the clock,
|
|
508
|
-
* URLs.
|
|
677
|
+
* Clears recordings, answers (questions and unreachable people included),
|
|
678
|
+
* seeded states, the scenario's event and scoped memory; keeps the clock,
|
|
679
|
+
* timezone, solar, credentials and webhook URLs.
|
|
509
680
|
*/
|
|
510
681
|
reset(): FlowHarness;
|
|
511
682
|
}
|
package/src/index.js
CHANGED
|
@@ -45,6 +45,11 @@ 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
|
+
// The attempts h.deliver/h.withdraw handed a channel, by id: a report on
|
|
51
|
+
// one of them is checked against its options the way the host checks it.
|
|
52
|
+
attempts: new Map(),
|
|
48
53
|
// ctx.signal for every surface but the listener, which brings its own.
|
|
49
54
|
// Never aborted here: the host aborts it on a timeout or a shutdown, and a
|
|
50
55
|
// test has neither.
|
|
@@ -162,6 +167,23 @@ export function harness({ manifest, integration } = {}) {
|
|
|
162
167
|
}
|
|
163
168
|
},
|
|
164
169
|
|
|
170
|
+
/**
|
|
171
|
+
* Runs a declared channel's deliver with an attempt — a fixture one, with
|
|
172
|
+
* any field overridden — exactly as the host would, and resolves to the
|
|
173
|
+
* attempt it used. Reports land in h.reports().
|
|
174
|
+
*/
|
|
175
|
+
async deliver(channel, attempt) {
|
|
176
|
+
return runChannelHook(channel, 'deliver', attempt);
|
|
177
|
+
},
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Runs a declared channel's withdraw, as when the question the attempt
|
|
181
|
+
* carried closes. A channel without withdraw is a no-op, as in production.
|
|
182
|
+
*/
|
|
183
|
+
async withdraw(channel, attempt) {
|
|
184
|
+
return runChannelHook(channel, 'withdraw', attempt);
|
|
185
|
+
},
|
|
186
|
+
|
|
165
187
|
/**
|
|
166
188
|
* Runs the listener until `until` holds, then aborts and waits for it to
|
|
167
189
|
* unwind. A listener that ignores its abort signal fails here rather than
|
|
@@ -235,6 +257,8 @@ export function harness({ manifest, integration } = {}) {
|
|
|
235
257
|
...state.status,
|
|
236
258
|
...(state.status.details && { details: { ...state.status.details } }),
|
|
237
259
|
},
|
|
260
|
+
/** What ctx.channels.report sent, in order. */
|
|
261
|
+
reports: () => state.reports.map((r) => ({ ...r })),
|
|
238
262
|
/** Events the handler put on the bus, in order. */
|
|
239
263
|
emitted: () => state.emitted.slice(),
|
|
240
264
|
/** Everything the handler logged, with its level. */
|
|
@@ -250,15 +274,134 @@ export function harness({ manifest, integration } = {}) {
|
|
|
250
274
|
state.logs.length = 0;
|
|
251
275
|
state.publishedStates.clear();
|
|
252
276
|
state.status = null;
|
|
277
|
+
state.reports.length = 0;
|
|
278
|
+
state.attempts.clear();
|
|
253
279
|
state.store.clear();
|
|
254
280
|
http.reset();
|
|
255
281
|
return h;
|
|
256
282
|
},
|
|
257
283
|
};
|
|
258
284
|
|
|
285
|
+
async function runChannelHook(channel, hook, overrides) {
|
|
286
|
+
const declared = manifest.channels?.[channel];
|
|
287
|
+
if (!declared) {
|
|
288
|
+
fail(
|
|
289
|
+
`channel "${channel}" is not declared in manifest.json (have: ${names(manifest.channels)})`
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
const handlers = integration.channels?.[channel];
|
|
293
|
+
if (typeof handlers?.deliver !== 'function') {
|
|
294
|
+
fail(
|
|
295
|
+
`channel "${channel}" is declared in manifest.json but the default export does not implement its deliver`
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
assertRequiredSlots(manifest, state);
|
|
299
|
+
const attempt = { ...fixtureAttempt(declared), ...overrides };
|
|
300
|
+
state.attempts.set(attempt.id, attempt);
|
|
301
|
+
|
|
302
|
+
const handler = handlers[hook];
|
|
303
|
+
// withdraw is optional: the SDK completes a missing one as a no-op.
|
|
304
|
+
if (handler === undefined && hook === 'withdraw') return attempt;
|
|
305
|
+
if (typeof handler !== 'function') {
|
|
306
|
+
fail(`channel "${channel}"'s ${hook} is not a function`);
|
|
307
|
+
}
|
|
308
|
+
const result = await withFetch(http.fetch, () =>
|
|
309
|
+
handler(attempt, emitting(ctx, manifest, state))
|
|
310
|
+
);
|
|
311
|
+
if (result !== undefined) {
|
|
312
|
+
fail(
|
|
313
|
+
`channel "${channel}"'s ${hook} returned a value; a channel handler reports through ctx and returns nothing`
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
return attempt;
|
|
317
|
+
}
|
|
318
|
+
|
|
259
319
|
return h;
|
|
260
320
|
}
|
|
261
321
|
|
|
322
|
+
/** A placeholder address per manifest.AddressKinds. */
|
|
323
|
+
const ADDRESS_PLACEHOLDERS = {
|
|
324
|
+
phone: '+46700000000',
|
|
325
|
+
email: 'person@example.test',
|
|
326
|
+
text: 'test-address',
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* The attempt h.deliver hands a channel unless the test says otherwise: a
|
|
331
|
+
* one-option question in the channel's first answer mode, or a notification
|
|
332
|
+
* when the channel declares no answers — the only thing such a channel can
|
|
333
|
+
* carry. Rendered text is the message itself, as it is for every mode but
|
|
334
|
+
* keypad.
|
|
335
|
+
*/
|
|
336
|
+
function fixtureAttempt(channel) {
|
|
337
|
+
const mode = channel.answers?.[0] ?? 'notify';
|
|
338
|
+
const notify = mode === 'notify';
|
|
339
|
+
const message = notify ? 'This is a test notification.' : 'Is this a test?';
|
|
340
|
+
return {
|
|
341
|
+
id: 'attempt-1',
|
|
342
|
+
kind: notify ? 'notify' : 'ask',
|
|
343
|
+
address: ADDRESS_PLACEHOLDERS[channel.address] ?? 'test-address',
|
|
344
|
+
mode,
|
|
345
|
+
urgency: 'normal',
|
|
346
|
+
message,
|
|
347
|
+
options: notify ? [] : [{ key: 'ack', label: 'OK', digit: 1 }],
|
|
348
|
+
render: { text: message, speech: message },
|
|
349
|
+
link: null,
|
|
350
|
+
deadline: notify ? null : new Date(Date.now() + 10 * 60_000).toISOString(),
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const REPORT_STATUSES = ['reached', 'failed', 'ended', 'answered'];
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* The one call behind ctx.channels.report, applying the host's refusals that
|
|
358
|
+
* need no database: an install with no channels, the status vocabulary,
|
|
359
|
+
* exactly one of digit or option, and — for an attempt this harness handed out
|
|
360
|
+
* — a digit or option the attempt has. An id the harness never issued is
|
|
361
|
+
* recorded, not refused: a webhook test reports on a reference it made up, and
|
|
362
|
+
* the host's "was not issued" check needs the attempt rows a test does not
|
|
363
|
+
* have.
|
|
364
|
+
*/
|
|
365
|
+
function reportChannel(manifest, state, attemptId, report) {
|
|
366
|
+
const install = manifest.id ?? manifest.name;
|
|
367
|
+
if (Object.keys(manifest.channels ?? {}).length === 0) {
|
|
368
|
+
fail(`channel.report: ${install} declares no channels`);
|
|
369
|
+
}
|
|
370
|
+
const { status, digit, option, reason } = report ?? {};
|
|
371
|
+
if (!REPORT_STATUSES.includes(status)) {
|
|
372
|
+
fail(
|
|
373
|
+
`channel.report: status "${status}" must be reached, failed, ended or answered`
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
if (status === 'answered') {
|
|
377
|
+
if ((digit === undefined) === (option === undefined)) {
|
|
378
|
+
fail('channel.report: answered needs exactly one of digit or option');
|
|
379
|
+
}
|
|
380
|
+
const options = state.attempts.get(attemptId)?.options;
|
|
381
|
+
if (options && digit !== undefined) {
|
|
382
|
+
if (!Number.isInteger(digit) || digit < 1 || digit > options.length) {
|
|
383
|
+
fail(
|
|
384
|
+
`channel.report: digit ${digit} is not an option (1–${options.length})`
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
if (options && option !== undefined) {
|
|
389
|
+
if (!options.some((o) => o.key === option)) {
|
|
390
|
+
fail(
|
|
391
|
+
`channel.report: option "${option}" is not one of ${options.map((o) => o.key).join(', ')}`
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
state.reports.push({
|
|
397
|
+
attemptId,
|
|
398
|
+
status,
|
|
399
|
+
...(digit !== undefined && { digit }),
|
|
400
|
+
...(option !== undefined && { option }),
|
|
401
|
+
...(reason !== undefined && { reason }),
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
|
|
262
405
|
/**
|
|
263
406
|
* The base context, matching what integration-entrypoint.ts builds. Handlers
|
|
264
407
|
* cannot tell the difference, which is the only way a test here means anything
|
|
@@ -325,6 +468,11 @@ function buildContext(manifest, state, webhooks) {
|
|
|
325
468
|
setStatus(state, report);
|
|
326
469
|
},
|
|
327
470
|
},
|
|
471
|
+
channels: {
|
|
472
|
+
async report(attemptId, report) {
|
|
473
|
+
reportChannel(manifest, state, attemptId, report);
|
|
474
|
+
},
|
|
475
|
+
},
|
|
328
476
|
};
|
|
329
477
|
}
|
|
330
478
|
|
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
|
|
15
|
-
* `event`, so one scenario can describe both, and `llm
|
|
16
|
-
*
|
|
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,
|