@junoflow/testkit 0.12.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -64,6 +64,8 @@ plain form works.
64
64
  | `h.states()` | values `ctx.states.publish` has sent, keyed by local name |
65
65
  | `h.status()` | the last `ctx.status.set` report, or `null` if it never reported |
66
66
  | `h.reports()` | what `ctx.channels.report` sent, as `{attemptId, status, digit?, option?, reason?}` |
67
+ | `h.replyAnswer({ matched, reply })` | set what `ctx.channels.reply` answers (default `{ matched: false, reply: null }`) |
68
+ | `h.replies()` | what `ctx.channels.reply` sent, as `{channel, address, text}` |
67
69
  | `h.emitted()` | `[{ event, payload }]`, in order |
68
70
  | `h.logs()` | `[{ level, message, data }]` |
69
71
  | `h.conformance()` | manifest-vs-module problems; `[]` when they agree |
@@ -208,6 +210,26 @@ channels, an unknown status, `answered` without exactly one of `digit` and
208
210
  attempt does not have. A report on an id the harness never issued is recorded
209
211
  rather than refused, so a webhook test can report on a reference it made up.
210
212
 
213
+ ### `h.replyAnswer({ matched, reply })` and `h.replies()`
214
+
215
+ ```ts
216
+ h.replyAnswer({ matched: false, reply: 'Reply 1 for Yes or 2 for No.' });
217
+ await h.webhook('inboundSms', {
218
+ body: { From: '+46700000001', Body: 'maybe' },
219
+ });
220
+ expect(h.replies()).toEqual([
221
+ { channel: 'sms', address: '+46700000001', text: 'maybe' },
222
+ ]);
223
+ expect(h.emitted()).toHaveLength(1); // not an answer: the integration's own event
224
+ ```
225
+
226
+ Juno matches a written reply against the questions it holds, which a test does
227
+ not have, so the test says what `ctx.channels.reply` answers: by default
228
+ `{ matched: false, reply: null }` — the message was not an answer. The refusals
229
+ that need no questions fail the test: an install with no channels, and a channel
230
+ that is not declared or does not list `text` in its `answers`. `reset()` clears
231
+ the recorded replies and the answer.
232
+
211
233
  ## What the harness is faithful about
212
234
 
213
235
  - `ctx.store` round-trips values through JSON, because the real store does. A
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@junoflow/testkit",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "Test harnesses for Juno integrations, flows and apps",
5
5
  "license": "MIT",
6
6
  "repository": {
package/src/ask.js CHANGED
@@ -23,6 +23,9 @@ const OPTION_KEY = /^[a-z][a-zA-Z0-9]*$/;
23
23
  /** The channel an answer came through when the test names none. */
24
24
  export const TEST_CHANNEL = 'test:channel';
25
25
 
26
+ /** What h.questions.answer takes for an answer. */
27
+ const ANSWER_FIELDS = ['by', 'answer', 'channel', 'text', 'interpreted'];
28
+
26
29
  const runes = (s) => [...s].length;
27
30
 
28
31
  /** handlekind's quotedNames: sorted, quoted, "none" when empty. */
@@ -214,7 +217,9 @@ export function createPeople(manifest, harness) {
214
217
  /**
215
218
  * Answers the question whose label is `key` — or `ask#N`, the Nth ask
216
219
  * of the trigger (from 1), for an unlabelled one — with
217
- * `{ by, answer, channel? }` or `'unanswered'`.
220
+ * `{ by, answer, channel?, text?, interpreted? }` or `'unanswered'`.
221
+ * `channel: 'page'` is an answer from the answer page; `text` a written
222
+ * reply as written, and `interpreted: true` one the site's LLM mapped.
218
223
  */
219
224
  answer(key, value) {
220
225
  if (typeof key !== 'string' || key === '') {
@@ -231,16 +236,33 @@ export function createPeople(manifest, harness) {
231
236
  typeof value.answer === 'string';
232
237
  if (!ok) {
233
238
  fail(
234
- `h.questions.answer('${key}', …) — expected { by, answer, channel? } or 'unanswered', got ${JSON.stringify(value)}`
239
+ `h.questions.answer('${key}', …) — expected { by, answer, channel?, text?, interpreted? } or 'unanswered', got ${JSON.stringify(value)}`
235
240
  );
236
241
  }
237
242
  for (const k of Object.keys(value)) {
238
- if (!['by', 'answer', 'channel'].includes(k)) {
243
+ if (!ANSWER_FIELDS.includes(k)) {
244
+ fail(
245
+ `h.questions.answer('${key}', …) — "${k}" is not part of an answer (have: ${ANSWER_FIELDS.join(', ')})`
246
+ );
247
+ }
248
+ }
249
+ for (const k of ['channel', 'text']) {
250
+ if (value[k] !== undefined && typeof value[k] !== 'string') {
239
251
  fail(
240
- `h.questions.answer('${key}', …) — "${k}" is not part of an answer (have: by, answer, channel)`
252
+ `h.questions.answer('${key}', …) — ${k} is a string; got ${JSON.stringify(value[k])}`
241
253
  );
242
254
  }
243
255
  }
256
+ if (value.interpreted !== undefined && value.interpreted !== true) {
257
+ fail(
258
+ `h.questions.answer('${key}', …) — interpreted is true or absent; got ${JSON.stringify(value.interpreted)}`
259
+ );
260
+ }
261
+ if (value.interpreted && value.text === undefined) {
262
+ fail(
263
+ `h.questions.answer('${key}', …) — interpreted needs the text the LLM read`
264
+ );
265
+ }
244
266
  declared(`h.questions.answer('${key}', …)`, value.by);
245
267
  value = { ...value };
246
268
  }
@@ -296,11 +318,15 @@ export function createPeople(manifest, harness) {
296
318
  `h.questions.answer('${key}', …) — "${answer.answer}" is not one of the question's options (have: ${Object.keys(q.options).join(', ')})`
297
319
  );
298
320
  }
321
+ // The host's AnsweredValue, in its field order: text and
322
+ // interpreted only when set.
299
323
  value = {
300
324
  outcome: 'answered',
301
325
  answer: answer.answer,
302
326
  by: answer.by,
303
327
  channel: answer.channel ?? TEST_CHANNEL,
328
+ ...(answer.text !== undefined && { text: answer.text }),
329
+ ...(answer.interpreted && { interpreted: true }),
304
330
  at: rfc3339(instant() ?? Date.now()),
305
331
  };
306
332
  }
package/src/index.d.ts CHANGED
@@ -132,6 +132,19 @@ export interface RecordedReport {
132
132
  reason?: string;
133
133
  }
134
134
 
135
+ /** One ctx.channels.reply call, as the harness recorded it. */
136
+ export interface RecordedReply {
137
+ channel: string;
138
+ address: string;
139
+ text: string;
140
+ }
141
+
142
+ /** What ctx.channels.reply answers; set with h.replyAnswer. */
143
+ export interface ReplyAnswer {
144
+ matched: boolean;
145
+ reply: string | null;
146
+ }
147
+
135
148
  export interface WebhookRequestInit {
136
149
  method?: string;
137
150
  headers?: Record<string, string>;
@@ -276,6 +289,13 @@ export interface Harness {
276
289
  status(): RecordedStatus | null;
277
290
  /** What ctx.channels.report sent, in order. Cleared by reset(). */
278
291
  reports(): RecordedReport[];
292
+ /** What ctx.channels.reply sent, in order. Cleared by reset(). */
293
+ replies(): RecordedReply[];
294
+ /**
295
+ * Sets what ctx.channels.reply answers from now on; the default is
296
+ * {matched: false, reply: null}. Cleared by reset().
297
+ */
298
+ replyAnswer(answer?: Partial<ReplyAnswer>): Harness;
279
299
  emitted(): EmittedEvent[];
280
300
  logs(): LogEntry[];
281
301
  /** Manifest-vs-module problems; empty when they agree. */
@@ -560,7 +580,19 @@ export interface RecordedHandle {
560
580
  * round over.
561
581
  */
562
582
  export type QuestionAnswer =
563
- | { by: string; answer: string; channel?: string }
583
+ | {
584
+ by: string;
585
+ answer: string;
586
+ /**
587
+ * `<install>:<channel>` the answer came through, `'page'` for the
588
+ * answer page; `test:channel` when omitted.
589
+ */
590
+ channel?: string;
591
+ /** A written reply, as the person wrote it. */
592
+ text?: string;
593
+ /** The site's LLM mapped `text` onto `answer` (freeText: 'interpret'). */
594
+ interpreted?: true;
595
+ }
564
596
  | 'unanswered';
565
597
 
566
598
  /** A question a handler asked, as the host stores it: defaults applied. */
@@ -594,7 +626,8 @@ export interface FlowQuestions {
594
626
  /**
595
627
  * Answers the question whose `label` is key — or `ask#N`, the Nth ask of the
596
628
  * 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
629
+ * open with `{ outcome: 'answered', answer, by, channel, text?, interpreted?, at }`
630
+ * (text and interpreted passed through when given), `at` being
598
631
  * h.now() (or the wall clock with none set); `by` must be one of its `to`
599
632
  * and `answer` one of its option keys. `'unanswered'` fires it with
600
633
  * `{ outcome: 'unanswered', attempts: to.length * rounds }` — the host does
package/src/index.js CHANGED
@@ -47,6 +47,10 @@ export function harness({ manifest, integration } = {}) {
47
47
  status: null,
48
48
  // What ctx.channels.report sent, in order. Cleared by reset().
49
49
  reports: [],
50
+ // What ctx.channels.reply sent, in order, and what it answers: the
51
+ // harness has no questions to match against, so the test says.
52
+ replies: [],
53
+ replyAnswer: { matched: false, reply: null },
50
54
  // The attempts h.deliver/h.withdraw handed a channel, by id: a report on
51
55
  // one of them is checked against its options the way the host checks it.
52
56
  attempts: new Map(),
@@ -259,6 +263,17 @@ export function harness({ manifest, integration } = {}) {
259
263
  },
260
264
  /** What ctx.channels.report sent, in order. */
261
265
  reports: () => state.reports.map((r) => ({ ...r })),
266
+ /** What ctx.channels.reply sent, as {channel, address, text}, in order. */
267
+ replies: () => state.replies.map((r) => ({ ...r })),
268
+ /**
269
+ * Sets what ctx.channels.reply answers from now on (default
270
+ * {matched: false, reply: null}): Juno matches a reply against the
271
+ * questions it holds, which a test does not have.
272
+ */
273
+ replyAnswer({ matched = false, reply = null } = {}) {
274
+ state.replyAnswer = { matched: matched === true, reply: reply ?? null };
275
+ return h;
276
+ },
262
277
  /** Events the handler put on the bus, in order. */
263
278
  emitted: () => state.emitted.slice(),
264
279
  /** Everything the handler logged, with its level. */
@@ -275,6 +290,8 @@ export function harness({ manifest, integration } = {}) {
275
290
  state.publishedStates.clear();
276
291
  state.status = null;
277
292
  state.reports.length = 0;
293
+ state.replies.length = 0;
294
+ state.replyAnswer = { matched: false, reply: null };
278
295
  state.attempts.clear();
279
296
  state.store.clear();
280
297
  http.reset();
@@ -402,6 +419,25 @@ function reportChannel(manifest, state, attemptId, report) {
402
419
  });
403
420
  }
404
421
 
422
+ /**
423
+ * The one call behind ctx.channels.reply, applying the host's refusals that
424
+ * need no database: an install with no channels, and a channel that is not
425
+ * declared or does not take text. It records the message (only the fields the
426
+ * SDK sends) and answers what h.replyAnswer set.
427
+ */
428
+ function replyChannel(manifest, state, message) {
429
+ const install = manifest.id ?? manifest.name;
430
+ if (Object.keys(manifest.channels ?? {}).length === 0) {
431
+ fail(`channel.reply: ${install} declares no channels`);
432
+ }
433
+ const { channel, address, text } = message ?? {};
434
+ if (!manifest.channels[channel]?.answers?.includes('text')) {
435
+ fail(`channel.reply: ${install} has no text channel "${channel}"`);
436
+ }
437
+ state.replies.push({ channel, address, text });
438
+ return { ...state.replyAnswer };
439
+ }
440
+
405
441
  /**
406
442
  * The base context, matching what integration-entrypoint.ts builds. Handlers
407
443
  * cannot tell the difference, which is the only way a test here means anything
@@ -472,6 +508,9 @@ function buildContext(manifest, state, webhooks) {
472
508
  async report(attemptId, report) {
473
509
  reportChannel(manifest, state, attemptId, report);
474
510
  },
511
+ async reply(message) {
512
+ return replyChannel(manifest, state, message);
513
+ },
475
514
  },
476
515
  };
477
516
  }