@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/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, seeded states, the scenario's event and
507
- * scoped memory; keeps the clock, timezone, solar, credentials and webhook
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 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,