@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/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 {
|
|
@@ -93,6 +138,10 @@ export interface WebhookRequestInit {
|
|
|
93
138
|
query?: Record<string, string>;
|
|
94
139
|
/** A string is sent as-is; anything else is JSON.stringify'd. */
|
|
95
140
|
body?: unknown;
|
|
141
|
+
/**
|
|
142
|
+
* Defaults to what the host builds: the slot's ctx.webhooks URL, then
|
|
143
|
+
* subpath and query — or "/" when the slot's URL is null.
|
|
144
|
+
*/
|
|
96
145
|
url?: string;
|
|
97
146
|
subpath?: string;
|
|
98
147
|
}
|
|
@@ -185,6 +234,12 @@ export interface ListenOptions {
|
|
|
185
234
|
export interface Harness {
|
|
186
235
|
config(values: Record<string, unknown>): Harness;
|
|
187
236
|
credentials(values: Record<string, string>): Harness;
|
|
237
|
+
/**
|
|
238
|
+
* Sets ctx.webhooks.<slot>.url by declared slot. Every slot starts at a
|
|
239
|
+
* stand-in URL (https://juno.test/webhooks/test-<slot>); null is what the
|
|
240
|
+
* host sends with no public URL or no webhook bound. Kept across reset().
|
|
241
|
+
*/
|
|
242
|
+
webhookUrls(values: Record<string, string | null>): Harness;
|
|
188
243
|
action(name: string, params?: unknown): Promise<unknown>;
|
|
189
244
|
webhook(
|
|
190
245
|
name: string,
|
|
@@ -193,6 +248,24 @@ export interface Harness {
|
|
|
193
248
|
schedule(name: string, event?: ScheduleEvent): Promise<void>;
|
|
194
249
|
/** Runs a declared lifecycle hook (e.g. `activated`) with the same context a schedule gets. */
|
|
195
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>;
|
|
196
269
|
listen(options?: ListenOptions): Promise<void>;
|
|
197
270
|
/** The integration's KV, as an ordinary Map. */
|
|
198
271
|
readonly store: Map<string, unknown>;
|
|
@@ -201,6 +274,8 @@ export interface Harness {
|
|
|
201
274
|
states(): Record<string, unknown>;
|
|
202
275
|
/** The last ctx.status.set report, or null when the handler never reported. */
|
|
203
276
|
status(): RecordedStatus | null;
|
|
277
|
+
/** What ctx.channels.report sent, in order. Cleared by reset(). */
|
|
278
|
+
reports(): RecordedReport[];
|
|
204
279
|
emitted(): EmittedEvent[];
|
|
205
280
|
logs(): LogEntry[];
|
|
206
281
|
/** Manifest-vs-module problems; empty when they agree. */
|
|
@@ -265,6 +340,8 @@ export interface FlowManifest {
|
|
|
265
340
|
[k: string]: unknown;
|
|
266
341
|
}>;
|
|
267
342
|
credentials?: string[];
|
|
343
|
+
/** The people ctx.ask and ctx.notify may name. */
|
|
344
|
+
people?: string[];
|
|
268
345
|
[k: string]: unknown;
|
|
269
346
|
}
|
|
270
347
|
|
|
@@ -346,10 +423,10 @@ export interface RecordedStateCall {
|
|
|
346
423
|
export interface RecordedTargetCall {
|
|
347
424
|
target: string;
|
|
348
425
|
action: string;
|
|
349
|
-
/** 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. */
|
|
350
427
|
params: unknown;
|
|
351
|
-
/** invoke/invokeAll only, when given. */
|
|
352
|
-
opts?: { timeout?: string };
|
|
428
|
+
/** invoke/invokeAll/start/startAll only, when given. */
|
|
429
|
+
opts?: { timeout?: string; label?: string };
|
|
353
430
|
}
|
|
354
431
|
|
|
355
432
|
export interface RecordedControlCall {
|
|
@@ -373,6 +450,13 @@ export interface FlowTargets {
|
|
|
373
450
|
* Answers `binding.method` (`kitchenSpeaker.ttsCloudSay`, `child.invoke`):
|
|
374
451
|
* a value, or a function of the params (an invoke's input) returning one.
|
|
375
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.
|
|
376
460
|
*/
|
|
377
461
|
answer(
|
|
378
462
|
key: string,
|
|
@@ -438,6 +522,89 @@ export interface Scenario {
|
|
|
438
522
|
expect?: unknown;
|
|
439
523
|
/** Flows: the version a recorded run ran. Not applied. */
|
|
440
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[];
|
|
441
608
|
}
|
|
442
609
|
|
|
443
610
|
export interface FlowHarness {
|
|
@@ -472,6 +639,11 @@ export interface FlowHarness {
|
|
|
472
639
|
): FlowHarness;
|
|
473
640
|
/** Every step.run and step.llm, in order. */
|
|
474
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[];
|
|
475
647
|
logs(): FlowLogEntry[];
|
|
476
648
|
/** Triggers declared vs implemented, both ways; empty when they agree. */
|
|
477
649
|
conformance(): string[];
|
|
@@ -487,8 +659,24 @@ export interface FlowHarness {
|
|
|
487
659
|
/** Sets ctx.credentials by declared id. Kept across reset(). */
|
|
488
660
|
credentials(values: Record<string, string>): FlowHarness;
|
|
489
661
|
/**
|
|
490
|
-
*
|
|
491
|
-
*
|
|
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;
|
|
670
|
+
/**
|
|
671
|
+
* Sets ctx.webhooks['<id>'].url for a webhook id a trigger names. Each starts
|
|
672
|
+
* at a stand-in URL (https://juno.test/webhooks/test-<id>); null is what the
|
|
673
|
+
* host sends with no public URL. Kept across reset().
|
|
674
|
+
*/
|
|
675
|
+
webhookUrls(values: Record<string, string | null>): FlowHarness;
|
|
676
|
+
/**
|
|
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.
|
|
492
680
|
*/
|
|
493
681
|
reset(): FlowHarness;
|
|
494
682
|
}
|
package/src/index.js
CHANGED
|
@@ -4,6 +4,7 @@ import { names } from './names.js';
|
|
|
4
4
|
import { conformance } from './conformance.js';
|
|
5
5
|
import { createHttpMock, withFetch } from './http.js';
|
|
6
6
|
import { assertValid } from './schema.js';
|
|
7
|
+
import { createWebhookEndpoints } from './webhooks.js';
|
|
7
8
|
|
|
8
9
|
/** How long h.listen waits before aborting, when nothing else says. */
|
|
9
10
|
const DEFAULT_LISTEN_TIMEOUT_MS = 1000;
|
|
@@ -44,6 +45,11 @@ export function harness({ manifest, integration } = {}) {
|
|
|
44
45
|
// The last ctx.status.set report, or null before the first. One slot, not
|
|
45
46
|
// a list: the host replaces the report on every write.
|
|
46
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(),
|
|
47
53
|
// ctx.signal for every surface but the listener, which brings its own.
|
|
48
54
|
// Never aborted here: the host aborts it on a timeout or a shutdown, and a
|
|
49
55
|
// test has neither.
|
|
@@ -51,7 +57,11 @@ export function harness({ manifest, integration } = {}) {
|
|
|
51
57
|
};
|
|
52
58
|
|
|
53
59
|
const http = createHttpMock();
|
|
54
|
-
const
|
|
60
|
+
const webhooks = createWebhookEndpoints(
|
|
61
|
+
Object.keys(manifest.webhooks ?? {}),
|
|
62
|
+
'a webhook slot in manifest.json'
|
|
63
|
+
);
|
|
64
|
+
const ctx = buildContext(manifest, state, webhooks);
|
|
55
65
|
|
|
56
66
|
const h = {
|
|
57
67
|
/** Merge values into ctx.config. Rejects a slot the manifest never declared. */
|
|
@@ -68,6 +78,16 @@ export function harness({ manifest, integration } = {}) {
|
|
|
68
78
|
return h;
|
|
69
79
|
},
|
|
70
80
|
|
|
81
|
+
/**
|
|
82
|
+
* Sets ctx.webhooks.<slot>.url. Every declared slot starts at a stand-in
|
|
83
|
+
* URL; null is what the host sends with no public URL or no webhook bound.
|
|
84
|
+
* Kept across reset().
|
|
85
|
+
*/
|
|
86
|
+
webhookUrls(values = {}) {
|
|
87
|
+
webhooks.set(values);
|
|
88
|
+
return h;
|
|
89
|
+
},
|
|
90
|
+
|
|
71
91
|
async action(name, params) {
|
|
72
92
|
const declared = manifest.actions?.[name];
|
|
73
93
|
if (!declared) {
|
|
@@ -94,7 +114,7 @@ export function harness({ manifest, integration } = {}) {
|
|
|
94
114
|
}
|
|
95
115
|
const handler = requireHandler(integration, 'webhooks', name, 'webhook');
|
|
96
116
|
assertRequiredSlots(manifest, state);
|
|
97
|
-
const req = buildWebhookRequest(request);
|
|
117
|
+
const req = buildWebhookRequest(request, webhooks.url(name));
|
|
98
118
|
|
|
99
119
|
const result = await withFetch(http.fetch, () =>
|
|
100
120
|
handler(req, emitting(ctx, manifest, state))
|
|
@@ -147,6 +167,23 @@ export function harness({ manifest, integration } = {}) {
|
|
|
147
167
|
}
|
|
148
168
|
},
|
|
149
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
|
+
|
|
150
187
|
/**
|
|
151
188
|
* Runs the listener until `until` holds, then aborts and waits for it to
|
|
152
189
|
* unwind. A listener that ignores its abort signal fails here rather than
|
|
@@ -220,6 +257,8 @@ export function harness({ manifest, integration } = {}) {
|
|
|
220
257
|
...state.status,
|
|
221
258
|
...(state.status.details && { details: { ...state.status.details } }),
|
|
222
259
|
},
|
|
260
|
+
/** What ctx.channels.report sent, in order. */
|
|
261
|
+
reports: () => state.reports.map((r) => ({ ...r })),
|
|
223
262
|
/** Events the handler put on the bus, in order. */
|
|
224
263
|
emitted: () => state.emitted.slice(),
|
|
225
264
|
/** Everything the handler logged, with its level. */
|
|
@@ -235,21 +274,140 @@ export function harness({ manifest, integration } = {}) {
|
|
|
235
274
|
state.logs.length = 0;
|
|
236
275
|
state.publishedStates.clear();
|
|
237
276
|
state.status = null;
|
|
277
|
+
state.reports.length = 0;
|
|
278
|
+
state.attempts.clear();
|
|
238
279
|
state.store.clear();
|
|
239
280
|
http.reset();
|
|
240
281
|
return h;
|
|
241
282
|
},
|
|
242
283
|
};
|
|
243
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
|
+
|
|
244
319
|
return h;
|
|
245
320
|
}
|
|
246
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
|
+
|
|
247
405
|
/**
|
|
248
406
|
* The base context, matching what integration-entrypoint.ts builds. Handlers
|
|
249
407
|
* cannot tell the difference, which is the only way a test here means anything
|
|
250
408
|
* about how the code runs there.
|
|
251
409
|
*/
|
|
252
|
-
function buildContext(manifest, state) {
|
|
410
|
+
function buildContext(manifest, state, webhooks) {
|
|
253
411
|
const log = (message, data) =>
|
|
254
412
|
state.logs.push({ level: 'debug', message, data });
|
|
255
413
|
log.info = (message, data) =>
|
|
@@ -262,6 +420,11 @@ function buildContext(manifest, state) {
|
|
|
262
420
|
return {
|
|
263
421
|
config: state.config,
|
|
264
422
|
credentials: state.credentials,
|
|
423
|
+
// A getter, so a URL set after the harness was built is what the next
|
|
424
|
+
// invocation reads — the host resolves it per invocation too.
|
|
425
|
+
get webhooks() {
|
|
426
|
+
return webhooks.ctx();
|
|
427
|
+
},
|
|
265
428
|
log,
|
|
266
429
|
// Present on every surface, as in production. A non-listener test never
|
|
267
430
|
// aborts it, which is also true of an invocation that finishes in time;
|
|
@@ -305,6 +468,11 @@ function buildContext(manifest, state) {
|
|
|
305
468
|
setStatus(state, report);
|
|
306
469
|
},
|
|
307
470
|
},
|
|
471
|
+
channels: {
|
|
472
|
+
async report(attemptId, report) {
|
|
473
|
+
reportChannel(manifest, state, attemptId, report);
|
|
474
|
+
},
|
|
475
|
+
},
|
|
308
476
|
};
|
|
309
477
|
}
|
|
310
478
|
|
|
@@ -468,7 +636,10 @@ function requireHandler(integration, surface, name, kind) {
|
|
|
468
636
|
return handler;
|
|
469
637
|
}
|
|
470
638
|
|
|
471
|
-
|
|
639
|
+
// url defaults to what the host would build: the slot's URL, then the subpath
|
|
640
|
+
// and the query string — so a signature over the URL verifies in a test as it
|
|
641
|
+
// does in production. With no URL for the slot it stays '/'.
|
|
642
|
+
function buildWebhookRequest(request, slotURL) {
|
|
472
643
|
const body =
|
|
473
644
|
typeof request.body === 'string'
|
|
474
645
|
? request.body
|
|
@@ -480,12 +651,18 @@ function buildWebhookRequest(request) {
|
|
|
480
651
|
query: request.query ?? {},
|
|
481
652
|
body,
|
|
482
653
|
json: parseJSONOrNull(body),
|
|
483
|
-
url: request.url ??
|
|
654
|
+
url: request.url ?? requestURL(slotURL, request),
|
|
484
655
|
subpath: request.subpath ?? '',
|
|
485
656
|
form: parseFormOrNull(headers, body),
|
|
486
657
|
};
|
|
487
658
|
}
|
|
488
659
|
|
|
660
|
+
function requestURL(slotURL, request) {
|
|
661
|
+
if (slotURL === null) return '/';
|
|
662
|
+
const query = new URLSearchParams(request.query ?? {}).toString();
|
|
663
|
+
return slotURL + (request.subpath ?? '') + (query ? `?${query}` : '');
|
|
664
|
+
}
|
|
665
|
+
|
|
489
666
|
// The host's rule: lower-cased content-type, media type
|
|
490
667
|
// application/x-www-form-urlencoded (a charset parameter is ignored), first
|
|
491
668
|
// value per key — else null.
|
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,
|
package/src/webhooks.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { fail } from './assert.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The URL a webhook has until a test sets one: shaped like Juno's
|
|
5
|
+
* (`<public URL>/webhooks/<token>`), so a handler's happy path — the one where
|
|
6
|
+
* the site has a public URL and the webhook is bound — is what runs by default.
|
|
7
|
+
*/
|
|
8
|
+
export function defaultWebhookURL(key) {
|
|
9
|
+
return `https://juno.test/webhooks/test-${key}`;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* ctx.webhooks and the h.webhookUrls() that sets it. keys are what the host
|
|
14
|
+
* would send: an integration's declared slots, or the webhook ids a flow's
|
|
15
|
+
* triggers name. Like credentials, the URLs are a fixture: kept across reset().
|
|
16
|
+
*/
|
|
17
|
+
export function createWebhookEndpoints(keys, describe) {
|
|
18
|
+
const urls = new Map(keys.map((k) => [k, defaultWebhookURL(k)]));
|
|
19
|
+
return {
|
|
20
|
+
/** Sets URLs by key; null is the host's answer with no public URL or no binding. */
|
|
21
|
+
set(values = {}) {
|
|
22
|
+
for (const [key, url] of Object.entries(values)) {
|
|
23
|
+
if (!urls.has(key)) {
|
|
24
|
+
fail(
|
|
25
|
+
`h.webhookUrls — "${key}" is not ${describe} (have: ${[...urls.keys()].join(', ') || '(none)'})`
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
if (url !== null && typeof url !== 'string') {
|
|
29
|
+
fail(
|
|
30
|
+
`h.webhookUrls — "${key}" must be a URL string or null, got ${JSON.stringify(url)}`
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
urls.set(key, url);
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
url: (key) => urls.get(key) ?? null,
|
|
37
|
+
/** A fresh object per invocation, as init sends one per run. */
|
|
38
|
+
ctx: () =>
|
|
39
|
+
Object.fromEntries([...urls].map(([key, url]) => [key, { url }])),
|
|
40
|
+
};
|
|
41
|
+
}
|