@junoflow/testkit 0.12.0 → 0.14.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 +22 -0
- package/package.json +1 -1
- package/src/ask.js +30 -4
- package/src/index.d.ts +35 -2
- package/src/index.js +39 -0
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
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 (!
|
|
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}', …) —
|
|
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
|
-
| {
|
|
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 }
|
|
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
|
}
|