@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/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
- * Clears recordings, answers, seeded states, the scenario's event and
491
- * scoped memory; keeps the clock, timezone, solar and credentials.
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 ctx = buildContext(manifest, state);
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
- function buildWebhookRequest(request) {
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 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,
@@ -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
+ }