@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/README.md +134 -17
- 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 +44 -15
- package/src/handles.js +472 -0
- package/src/index.d.ts +177 -6
- package/src/index.js +148 -0
- package/src/scenario.js +14 -3
package/src/bindings.js
CHANGED
|
@@ -16,12 +16,16 @@ const STATE_VERBS = {
|
|
|
16
16
|
signal: ['signal', 'retract'],
|
|
17
17
|
};
|
|
18
18
|
const ALL_STATE_VERBS = Object.values(STATE_VERBS).flat();
|
|
19
|
+
/** A flow's state handle constructors, on every binding (the host refuses one with no value). */
|
|
20
|
+
const STATE_WAIT_VERBS = ['when', 'becomes', 'changes'];
|
|
19
21
|
|
|
20
22
|
/** Every control handle carries all four; the host refuses one the type does not take. */
|
|
21
23
|
const CONTROL_VERBS = ['press', 'activate', 'extend', 'release'];
|
|
22
24
|
|
|
23
25
|
/** Integrations the broker serves itself; none of their actions is read-only. */
|
|
24
26
|
const BUILT_IN_INTEGRATIONS = ['homeassistant', 'mqtt'];
|
|
27
|
+
/** Target kinds that are not an integration install, and so emit no events. */
|
|
28
|
+
const BUILT_IN_TARGETS = ['homeassistant', 'mqtt', 'flow'];
|
|
25
29
|
|
|
26
30
|
/**
|
|
27
31
|
* The binding half of flowHarness and appHarness: the state handles, ctx.targets,
|
|
@@ -153,12 +157,16 @@ export function createBindings(
|
|
|
153
157
|
/**
|
|
154
158
|
* The ctx members for one run; refuse is the run's recording fail().
|
|
155
159
|
*
|
|
160
|
+
* handles is the run's handle table (handles.js), which gives every state
|
|
161
|
+
* binding when/becomes/changes and every target untilEvent; an app passes
|
|
162
|
+
* none, since an app cannot wait.
|
|
163
|
+
*
|
|
156
164
|
* policy is what the host refuses on this run. `{ readOnly: true, handler }`
|
|
157
165
|
* is an app handler declared read_only, and refuses what the host refuses
|
|
158
166
|
* it, in the host's words: claim, release, a control command, and a call on
|
|
159
167
|
* a built-in target. Everything else is the caller's to refuse.
|
|
160
168
|
*/
|
|
161
|
-
function ctx(refuse, policy = {}) {
|
|
169
|
+
function ctx(refuse, policy = {}, handles = undefined) {
|
|
162
170
|
const readOnly = policy.readOnly
|
|
163
171
|
? (what) =>
|
|
164
172
|
refuse(
|
|
@@ -166,19 +174,19 @@ export function createBindings(
|
|
|
166
174
|
)
|
|
167
175
|
: () => {};
|
|
168
176
|
return {
|
|
169
|
-
[statesRoot]: stateProxy(refuse, readOnly),
|
|
177
|
+
[statesRoot]: stateProxy(refuse, readOnly, handles),
|
|
170
178
|
targets: namedProxy(
|
|
171
179
|
targets,
|
|
172
180
|
(name) =>
|
|
173
181
|
`ctx.targets.${name} — "${name}" is not declared in manifest.json (have: ${names(targets)})`,
|
|
174
|
-
(name, target) => targetHandle(refuse, name, target, policy),
|
|
182
|
+
(name, target) => targetHandle(refuse, name, target, policy, handles),
|
|
175
183
|
refuse
|
|
176
184
|
),
|
|
177
185
|
controls: namedProxy(
|
|
178
186
|
controls,
|
|
179
187
|
(name) =>
|
|
180
188
|
`ctx.controls.${name} — "${name}" is not declared in manifest.json (have: ${names(controls)})`,
|
|
181
|
-
(name) => controlHandle(refuse, name, readOnly),
|
|
189
|
+
(name) => controlHandle(refuse, name, readOnly, handles),
|
|
182
190
|
refuse
|
|
183
191
|
),
|
|
184
192
|
findings: {
|
|
@@ -223,7 +231,7 @@ export function createBindings(
|
|
|
223
231
|
return kind;
|
|
224
232
|
}
|
|
225
233
|
|
|
226
|
-
function stateProxy(refuse, readOnly) {
|
|
234
|
+
function stateProxy(refuse, readOnly, handles) {
|
|
227
235
|
const own = {};
|
|
228
236
|
if (scoped) {
|
|
229
237
|
own.scoped = async (name, initial) => {
|
|
@@ -247,7 +255,7 @@ export function createBindings(
|
|
|
247
255
|
`ctx.${statesRoot}.${prop} — "${prop}" is not declared in manifest.json (have: ${names(states)})`
|
|
248
256
|
);
|
|
249
257
|
}
|
|
250
|
-
return stateHandle(refuse, readOnly, prop, binding);
|
|
258
|
+
return stateHandle(refuse, readOnly, prop, binding, handles);
|
|
251
259
|
},
|
|
252
260
|
});
|
|
253
261
|
}
|
|
@@ -257,7 +265,7 @@ export function createBindings(
|
|
|
257
265
|
* not granted is refused here, as the host refuses it, rather than left for
|
|
258
266
|
* the generated types to have caught.
|
|
259
267
|
*/
|
|
260
|
-
function stateHandle(refuse, readOnly, name, binding) {
|
|
268
|
+
function stateHandle(refuse, readOnly, name, binding, handles) {
|
|
261
269
|
const where = `ctx.${statesRoot}.${name}`;
|
|
262
270
|
const kind = bindingKind(binding);
|
|
263
271
|
const granted = STATE_VERBS[kind];
|
|
@@ -329,20 +337,60 @@ export function createBindings(
|
|
|
329
337
|
return { retracted };
|
|
330
338
|
},
|
|
331
339
|
};
|
|
340
|
+
if (handles) {
|
|
341
|
+
Object.assign(handle, waitConstructors(refuse, name, binding, handles));
|
|
342
|
+
}
|
|
332
343
|
return verbProxy(
|
|
333
344
|
handle,
|
|
334
|
-
ALL_STATE_VERBS,
|
|
345
|
+
handles ? [...ALL_STATE_VERBS, ...STATE_WAIT_VERBS] : ALL_STATE_VERBS,
|
|
335
346
|
(prop) =>
|
|
336
347
|
`${where}.${prop} — not a state verb; "${name}" grants ${granted.join(', ')}`,
|
|
337
348
|
refuse
|
|
338
349
|
);
|
|
339
350
|
}
|
|
340
351
|
|
|
352
|
+
/**
|
|
353
|
+
* when/becomes/changes on one binding. A binding that grants no value
|
|
354
|
+
* access is the host's refusal, in its words, reported as the host's are:
|
|
355
|
+
* at the next wait or when the handler returns.
|
|
356
|
+
*/
|
|
357
|
+
function waitConstructors(refuse, name, binding, handles) {
|
|
358
|
+
const kind = bindingKind(binding);
|
|
359
|
+
const error = (mode) =>
|
|
360
|
+
kind === 'slot' || kind === 'signal'
|
|
361
|
+
? `${mode}: state binding "${name}" grants no value access; it is a ${kind === 'slot' ? 'claim-slot' : 'signal'} binding on "${binding.control ?? binding.state}"`
|
|
362
|
+
: undefined;
|
|
363
|
+
const current = (mode) => () => {
|
|
364
|
+
if (!rec.values.has(name)) {
|
|
365
|
+
refuse(
|
|
366
|
+
`ctx.${statesRoot}.${name}.${mode}() — the test gave "${name}" no value; seed it with h.states({ ${name}: … })`
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
return jsonCopy(rec.values.get(name));
|
|
370
|
+
};
|
|
371
|
+
const open =
|
|
372
|
+
(mode) =>
|
|
373
|
+
(...args) =>
|
|
374
|
+
handles.stateHandle(mode, `${statesRoot}.${name}.${mode}`, args, {
|
|
375
|
+
current: current(mode),
|
|
376
|
+
error: error(mode),
|
|
377
|
+
});
|
|
378
|
+
return {
|
|
379
|
+
when: open('when'),
|
|
380
|
+
becomes: open('becomes'),
|
|
381
|
+
changes: open('changes'),
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
|
|
341
385
|
// ─── targets ───────────────────────────────────────────────────────────────
|
|
342
386
|
|
|
343
|
-
function targetHandle(refuse, name, target, policy) {
|
|
387
|
+
function targetHandle(refuse, name, target, policy, handles) {
|
|
344
388
|
const handle = {};
|
|
389
|
+
if (handles && (target?.flow?.flow_id || target?.flow?.label)) {
|
|
390
|
+
Object.assign(handle, flowTargetMethods(refuse, name, target, handles));
|
|
391
|
+
}
|
|
345
392
|
for (const method of targetMethods(target)) {
|
|
393
|
+
if (handle[method]) continue;
|
|
346
394
|
handle[method] = async (params, opts) => {
|
|
347
395
|
// The broker's refusal. An integration action's read_only is the
|
|
348
396
|
// integration manifest's to say, which the harness does not have, so
|
|
@@ -375,18 +423,160 @@ export function createBindings(
|
|
|
375
423
|
);
|
|
376
424
|
};
|
|
377
425
|
}
|
|
426
|
+
const methods = Object.keys(handle);
|
|
427
|
+
if (handles) {
|
|
428
|
+
// Every target answers untilEvent here, so a non-integration one gets
|
|
429
|
+
// the host's refusal rather than a TypeError.
|
|
430
|
+
handle.untilEvent = (...args) => {
|
|
431
|
+
if (BUILT_IN_TARGETS.includes(target?.integration)) {
|
|
432
|
+
refuse(
|
|
433
|
+
`untilEvent: target "${name}" is a ${target.integration} target; only an integration target emits events`
|
|
434
|
+
);
|
|
435
|
+
}
|
|
436
|
+
return handles.eventHandle(`targets.${name}.untilEvent`, args);
|
|
437
|
+
};
|
|
438
|
+
}
|
|
378
439
|
return verbProxy(
|
|
379
440
|
handle,
|
|
380
441
|
Object.keys(handle),
|
|
381
442
|
(prop) =>
|
|
382
|
-
`ctx.targets.${name}.${prop} — target "${name}" has no method "${prop}" (have: ${
|
|
443
|
+
`ctx.targets.${name}.${prop} — target "${name}" has no method "${prop}" (have: ${methods.join(', ') || '(none)'})`,
|
|
383
444
|
refuse
|
|
384
445
|
);
|
|
385
446
|
}
|
|
386
447
|
|
|
448
|
+
/**
|
|
449
|
+
* A flow target's start/invoke, or a label target's startAll/invokeAll, as
|
|
450
|
+
* buildTargets in sdk/src/context.ts builds them over run handles: invoke
|
|
451
|
+
* opens the child and a timer and cancels both, invokeAll opens one handle.
|
|
452
|
+
* The harness starts no child: a run handle fires at open with the
|
|
453
|
+
* `.start` answer wrapped as an InvokeResult, or the `.startAll` answer
|
|
454
|
+
* whole. invoke and invokeAll answer from their own key when the test set
|
|
455
|
+
* one, else from that one, as today's invoke did.
|
|
456
|
+
*/
|
|
457
|
+
function flowTargetMethods(refuse, name, target, handles) {
|
|
458
|
+
const flowID = target.flow.flow_id;
|
|
459
|
+
const record = (method, input, opts) => {
|
|
460
|
+
const sent = jsonCopy(
|
|
461
|
+
input ?? null,
|
|
462
|
+
`ctx.targets.${name}.${method}()`,
|
|
463
|
+
refuse
|
|
464
|
+
);
|
|
465
|
+
const call = { target: name, action: method, params: sent };
|
|
466
|
+
if (opts !== undefined) call.opts = { ...opts };
|
|
467
|
+
rec.targetCalls.push(call);
|
|
468
|
+
return sent;
|
|
469
|
+
};
|
|
470
|
+
const unanswered = (key) =>
|
|
471
|
+
`target call ${key} was not answered — h.targets.answer('${key}', result)`;
|
|
472
|
+
// The answer for key: its value, or its $error thrown.
|
|
473
|
+
const answer = (key, sent) =>
|
|
474
|
+
answerFor(refuse, rec.targetAnswers, key, unanswered(key), sent);
|
|
475
|
+
|
|
476
|
+
if (flowID) {
|
|
477
|
+
const startKey = `${name}.start`;
|
|
478
|
+
const openChild = (method, input, opts, value) =>
|
|
479
|
+
handles.runHandle(`targets.${name}.${method}`, [input, opts], {
|
|
480
|
+
label: opts?.label,
|
|
481
|
+
value,
|
|
482
|
+
});
|
|
483
|
+
return {
|
|
484
|
+
start(input, opts) {
|
|
485
|
+
const sent = record('start', input, opts);
|
|
486
|
+
if (!rec.targetAnswers.has(startKey)) refuse(unanswered(startKey));
|
|
487
|
+
return openChild('start', input, opts, startValue(startKey, sent));
|
|
488
|
+
},
|
|
489
|
+
async invoke(input, opts) {
|
|
490
|
+
const sent = record('invoke', input, opts);
|
|
491
|
+
const own = `${name}.invoke`;
|
|
492
|
+
const key = rec.targetAnswers.has(own) ? own : startKey;
|
|
493
|
+
if (!rec.targetAnswers.has(key)) refuse(unanswered(own));
|
|
494
|
+
let value;
|
|
495
|
+
let thrown;
|
|
496
|
+
try {
|
|
497
|
+
value = await answer(key, sent);
|
|
498
|
+
} catch (err) {
|
|
499
|
+
thrown = err;
|
|
500
|
+
}
|
|
501
|
+
const child = openChild(
|
|
502
|
+
'invoke',
|
|
503
|
+
input,
|
|
504
|
+
undefined,
|
|
505
|
+
thrown === undefined
|
|
506
|
+
? { flow_id: flowID, run_id: 'harness', ok: true, value }
|
|
507
|
+
: {
|
|
508
|
+
flow_id: flowID,
|
|
509
|
+
run_id: 'harness',
|
|
510
|
+
ok: false,
|
|
511
|
+
error: thrown?.message,
|
|
512
|
+
}
|
|
513
|
+
);
|
|
514
|
+
const timer = handles.after(opts?.timeout ?? '5m');
|
|
515
|
+
child.cancel();
|
|
516
|
+
timer.cancel();
|
|
517
|
+
handles.settle();
|
|
518
|
+
// The answer's $error is what invoke throws, verbatim.
|
|
519
|
+
if (thrown !== undefined) throw thrown;
|
|
520
|
+
return value;
|
|
521
|
+
},
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
const allKey = `${name}.startAll`;
|
|
526
|
+
const openAll = (method, input, opts, fields) =>
|
|
527
|
+
handles.runHandle(`targets.${name}.${method}`, [input, opts], {
|
|
528
|
+
label: opts?.label,
|
|
529
|
+
...fields,
|
|
530
|
+
});
|
|
531
|
+
return {
|
|
532
|
+
startAll(input, opts) {
|
|
533
|
+
const sent = record('startAll', input, opts);
|
|
534
|
+
if (!rec.targetAnswers.has(allKey)) refuse(unanswered(allKey));
|
|
535
|
+
return openAll('startAll', input, opts, startAllFields(allKey, sent));
|
|
536
|
+
},
|
|
537
|
+
async invokeAll(input, opts) {
|
|
538
|
+
const sent = record('invokeAll', input, opts);
|
|
539
|
+
const own = `${name}.invokeAll`;
|
|
540
|
+
const key = rec.targetAnswers.has(own) ? own : allKey;
|
|
541
|
+
if (!rec.targetAnswers.has(key)) refuse(unanswered(own));
|
|
542
|
+
const results = await answer(key, sent);
|
|
543
|
+
const all = openAll('invokeAll', input, undefined, { value: results });
|
|
544
|
+
all.cancel();
|
|
545
|
+
handles.settle();
|
|
546
|
+
return results;
|
|
547
|
+
},
|
|
548
|
+
};
|
|
549
|
+
|
|
550
|
+
// The value a start handle fires with, from an answer resolved now.
|
|
551
|
+
function startValue(key, sent) {
|
|
552
|
+
const r = rec.targetAnswers.resolveSync(key, sent, refuse);
|
|
553
|
+
if ('error' in r) {
|
|
554
|
+
return {
|
|
555
|
+
flow_id: flowID,
|
|
556
|
+
run_id: 'harness',
|
|
557
|
+
ok: false,
|
|
558
|
+
error: r.error,
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
return {
|
|
562
|
+
flow_id: flowID,
|
|
563
|
+
run_id: 'harness',
|
|
564
|
+
ok: true,
|
|
565
|
+
value: jsonCopy(r.value, `the answer for ${key}`, refuse),
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// A startAll's answer is its value whole; its $error is the host's refusal.
|
|
570
|
+
function startAllFields(key, sent) {
|
|
571
|
+
const r = rec.targetAnswers.resolveSync(key, sent, refuse);
|
|
572
|
+
if ('error' in r) return { error: r.error };
|
|
573
|
+
return { value: jsonCopy(r.value, `the answer for ${key}`, refuse) };
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
387
577
|
// ─── controls ──────────────────────────────────────────────────────────────
|
|
388
578
|
|
|
389
|
-
function controlHandle(refuse, name, readOnly) {
|
|
579
|
+
function controlHandle(refuse, name, readOnly, handles) {
|
|
390
580
|
const command = async (verb, fields) => {
|
|
391
581
|
readOnly('command controls');
|
|
392
582
|
const call = { binding: name, verb };
|
|
@@ -422,11 +612,19 @@ export function createBindings(
|
|
|
422
612
|
extend: () => command('extend', {}),
|
|
423
613
|
release: () => command('release', {}),
|
|
424
614
|
};
|
|
615
|
+
// A flow's binding can be waited on for its next press. The harness has
|
|
616
|
+
// no control catalog, so whether the binding is a button is the host's to
|
|
617
|
+
// refuse, as it is for the verbs above; a press never fires in a test.
|
|
618
|
+
const verbs = handles ? [...CONTROL_VERBS, 'untilPressed'] : CONTROL_VERBS;
|
|
619
|
+
if (handles) {
|
|
620
|
+
handle.untilPressed = (...args) =>
|
|
621
|
+
handles.pressHandle(`controls.${name}.untilPressed`, args);
|
|
622
|
+
}
|
|
425
623
|
return verbProxy(
|
|
426
624
|
handle,
|
|
427
|
-
|
|
625
|
+
verbs,
|
|
428
626
|
(prop) =>
|
|
429
|
-
`ctx.controls.${name}.${prop} — not a control verb (have: ${
|
|
627
|
+
`ctx.controls.${name}.${prop} — not a control verb (have: ${verbs.join(', ')})`,
|
|
430
628
|
refuse
|
|
431
629
|
);
|
|
432
630
|
}
|
|
@@ -549,7 +747,14 @@ export function createWorld(harnessName, harness) {
|
|
|
549
747
|
if (solar !== undefined) h.solar(solar);
|
|
550
748
|
}
|
|
551
749
|
|
|
552
|
-
|
|
750
|
+
/** The clock's instant in epoch milliseconds, or undefined with no clock. */
|
|
751
|
+
function instant() {
|
|
752
|
+
return world.now === undefined
|
|
753
|
+
? undefined
|
|
754
|
+
: resolveInstant(world.now, world.timezone);
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
return { h, ctx, init, instant };
|
|
553
758
|
}
|
|
554
759
|
|
|
555
760
|
/**
|
|
@@ -559,8 +764,8 @@ export function createWorld(harnessName, harness) {
|
|
|
559
764
|
* else (each action verbatim).
|
|
560
765
|
*/
|
|
561
766
|
export function targetMethods(target) {
|
|
562
|
-
if (target?.flow?.flow_id) return ['invoke'];
|
|
563
|
-
if (target?.flow?.label) return ['invokeAll'];
|
|
767
|
+
if (target?.flow?.flow_id) return ['start', 'invoke'];
|
|
768
|
+
if (target?.flow?.label) return ['startAll', 'invokeAll'];
|
|
564
769
|
if (target?.integration === 'mqtt') return ['publish'];
|
|
565
770
|
if (target?.integration === 'homeassistant') {
|
|
566
771
|
return ['getState', ...(target.actions ?? []).map(actionToMethodName)];
|
|
@@ -638,6 +843,21 @@ export function createAnswers() {
|
|
|
638
843
|
// How many calls each key has answered since it was set; $replay reads it.
|
|
639
844
|
const calls = new Map();
|
|
640
845
|
|
|
846
|
+
// The answer for the next call to key: its $replay item, or the answer.
|
|
847
|
+
function next(key, refuse) {
|
|
848
|
+
const answer = answers.get(key);
|
|
849
|
+
const n = (calls.get(key) ?? 0) + 1;
|
|
850
|
+
calls.set(key, n);
|
|
851
|
+
if (reservedForm(answer) !== '$replay') return answer;
|
|
852
|
+
const items = answer.$replay;
|
|
853
|
+
if (n > items.length) {
|
|
854
|
+
refuse(
|
|
855
|
+
`${key} answered ${items.length} call${items.length === 1 ? '' : 's'} in the scenario; this was call ${n}`
|
|
856
|
+
);
|
|
857
|
+
}
|
|
858
|
+
return items[n - 1];
|
|
859
|
+
}
|
|
860
|
+
|
|
641
861
|
return {
|
|
642
862
|
set(key, answer, where) {
|
|
643
863
|
checkAnswerForm(answer, `${where}('${key}')`);
|
|
@@ -647,21 +867,27 @@ export function createAnswers() {
|
|
|
647
867
|
has: (key) => answers.has(key),
|
|
648
868
|
/** The answer for one call: its $replay item, $error thrown, a function called. */
|
|
649
869
|
async resolve(key, args, refuse) {
|
|
650
|
-
|
|
651
|
-
const n = (calls.get(key) ?? 0) + 1;
|
|
652
|
-
calls.set(key, n);
|
|
653
|
-
if (reservedForm(answer) === '$replay') {
|
|
654
|
-
const items = answer.$replay;
|
|
655
|
-
if (n > items.length) {
|
|
656
|
-
refuse(
|
|
657
|
-
`${key} answered ${items.length} call${items.length === 1 ? '' : 's'} in the scenario; this was call ${n}`
|
|
658
|
-
);
|
|
659
|
-
}
|
|
660
|
-
answer = items[n - 1];
|
|
661
|
-
}
|
|
870
|
+
const answer = next(key, refuse);
|
|
662
871
|
if (reservedForm(answer) === '$error') throw new Error(answer.$error);
|
|
663
872
|
return typeof answer === 'function' ? await answer(args) : answer;
|
|
664
873
|
},
|
|
874
|
+
/**
|
|
875
|
+
* The answer for one call made by a synchronous constructor (a run
|
|
876
|
+
* handle): `{ value }`, or `{ error }` for a $error. A function answer is
|
|
877
|
+
* called and must not return a promise.
|
|
878
|
+
*/
|
|
879
|
+
resolveSync(key, args, refuse) {
|
|
880
|
+
const answer = next(key, refuse);
|
|
881
|
+
if (reservedForm(answer) === '$error') return { error: answer.$error };
|
|
882
|
+
if (typeof answer !== 'function') return { value: answer };
|
|
883
|
+
const value = answer(args);
|
|
884
|
+
if (typeof value?.then === 'function') {
|
|
885
|
+
refuse(
|
|
886
|
+
`the answer for ${key} returned a promise; ${key} opens its handle at once, so answer it with a value or a function returning one`
|
|
887
|
+
);
|
|
888
|
+
}
|
|
889
|
+
return { value };
|
|
890
|
+
},
|
|
665
891
|
clear() {
|
|
666
892
|
answers.clear();
|
|
667
893
|
calls.clear();
|
package/src/conformance.js
CHANGED
|
@@ -7,7 +7,14 @@ import { schemaError } from './schema.js';
|
|
|
7
7
|
* declared/implemented pairing loop below the way actions/webhooks/schedules/
|
|
8
8
|
* hooks do.
|
|
9
9
|
*/
|
|
10
|
-
const SURFACES = [
|
|
10
|
+
const SURFACES = [
|
|
11
|
+
'actions',
|
|
12
|
+
'webhooks',
|
|
13
|
+
'schedules',
|
|
14
|
+
'listener',
|
|
15
|
+
'hooks',
|
|
16
|
+
'channels',
|
|
17
|
+
];
|
|
11
18
|
|
|
12
19
|
/**
|
|
13
20
|
* The lifecycle moments the host knows how to reach — mirrors
|
|
@@ -79,6 +86,24 @@ export function conformance(manifest, integration) {
|
|
|
79
86
|
);
|
|
80
87
|
}
|
|
81
88
|
|
|
89
|
+
// A channel is an object of handlers rather than one function: deliver is
|
|
90
|
+
// what Juno calls to reach a person and is required, withdraw is optional.
|
|
91
|
+
const implementedChannels = integration?.channels ?? {};
|
|
92
|
+
for (const name of Object.keys(manifest.channels ?? {})) {
|
|
93
|
+
if (typeof implementedChannels[name]?.deliver !== 'function') {
|
|
94
|
+
problems.push(
|
|
95
|
+
`channel "${name}" is declared in manifest.json but its deliver is not implemented`
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
for (const name of Object.keys(implementedChannels)) {
|
|
100
|
+
if (!manifest.channels?.[name]) {
|
|
101
|
+
problems.push(
|
|
102
|
+
`channel "${name}" is implemented but not declared in manifest.json — Juno will never call it`
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
82
107
|
for (const name of Object.keys(manifest.hooks ?? {})) {
|
|
83
108
|
if (!HOOK_NAMES.includes(name)) {
|
|
84
109
|
problems.push(
|
package/src/flow.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createPeople } from './ask.js';
|
|
1
2
|
import { fail } from './assert.js';
|
|
2
3
|
import {
|
|
3
4
|
createAnswers,
|
|
@@ -6,6 +7,7 @@ import {
|
|
|
6
7
|
jsonCopy,
|
|
7
8
|
} from './bindings.js';
|
|
8
9
|
import { flowConformance } from './flow-conformance.js';
|
|
10
|
+
import { createHandleTable } from './handles.js';
|
|
9
11
|
import { names } from './names.js';
|
|
10
12
|
import { applyScenario } from './scenario.js';
|
|
11
13
|
import { createWebhookEndpoints } from './webhooks.js';
|
|
@@ -42,11 +44,15 @@ export function flowHarness({ manifest, flow, now, timezone, solar } = {}) {
|
|
|
42
44
|
steps: [],
|
|
43
45
|
logs: [],
|
|
44
46
|
llm: createAnswers(),
|
|
47
|
+
// The last run's handle table, for h.handles().
|
|
48
|
+
handles: null,
|
|
45
49
|
// The last scenario's event, for an h.trigger(name) that gives none.
|
|
46
50
|
event: undefined,
|
|
47
51
|
};
|
|
48
52
|
// Chainable setters on the binding half return the harness itself.
|
|
49
53
|
const bindings = createBindings(manifest, () => h);
|
|
54
|
+
// ctx.ask and ctx.notify, and h.questions / h.notifications / h.unreachable.
|
|
55
|
+
const people = createPeople(manifest, () => h);
|
|
50
56
|
// ctx.webhooks is keyed by the webhook ids the triggers name, each once.
|
|
51
57
|
const webhooks = createWebhookEndpoints(
|
|
52
58
|
[
|
|
@@ -98,8 +104,14 @@ export function flowHarness({ manifest, flow, now, timezone, solar } = {}) {
|
|
|
98
104
|
result = await handler(ctx);
|
|
99
105
|
} catch (err) {
|
|
100
106
|
// A refusal the handler caught and turned into something else is
|
|
101
|
-
// still the cause; report it rather than whatever it became.
|
|
107
|
+
// still the cause; report it rather than whatever it became. A
|
|
108
|
+
// refused handle open is one of these: the shim reports it when the
|
|
109
|
+
// handler returns, in place of the handler's own error.
|
|
102
110
|
throw run.failures[0] ?? err;
|
|
111
|
+
} finally {
|
|
112
|
+
// The run is over either way, and the engine cancels what it left
|
|
113
|
+
// open.
|
|
114
|
+
rec.handles.close();
|
|
103
115
|
}
|
|
104
116
|
// A handler that swallowed a refusal (try/catch around a target call,
|
|
105
117
|
// say) must not turn it into a green test.
|
|
@@ -123,7 +135,7 @@ export function flowHarness({ manifest, flow, now, timezone, solar } = {}) {
|
|
|
123
135
|
|
|
124
136
|
/**
|
|
125
137
|
* Applies a scenario through the setters: now, timezone, solar, states,
|
|
126
|
-
* controls, targets, credentials and
|
|
138
|
+
* controls, targets, credentials, llm, questions and unreachable, and keeps its event for an
|
|
127
139
|
* h.trigger(name) that gives none. Ignores trigger, expect and
|
|
128
140
|
* flow_version; fails on any other key.
|
|
129
141
|
*/
|
|
@@ -147,12 +159,19 @@ export function flowHarness({ manifest, flow, now, timezone, solar } = {}) {
|
|
|
147
159
|
|
|
148
160
|
/** Every step.run and step.llm, in order, as the journal records them. */
|
|
149
161
|
steps: () => rec.steps.map((s) => ({ ...s })),
|
|
162
|
+
/**
|
|
163
|
+
* The last h.trigger's handles, in open order: `{ id, kind, label,
|
|
164
|
+
* status }`. A handle still open when the run ended reads `cancelled`, as
|
|
165
|
+
* the engine closes it.
|
|
166
|
+
*/
|
|
167
|
+
handles: () => (rec.handles ? rec.handles.list() : []),
|
|
150
168
|
/** Everything handlers logged, with its level. */
|
|
151
169
|
logs: () => rec.logs.slice(),
|
|
152
170
|
/** Manifest-vs-module problems; empty when they agree. */
|
|
153
171
|
conformance: () => flowConformance(manifest, flow),
|
|
154
172
|
|
|
155
173
|
...bindings.h,
|
|
174
|
+
...people.h,
|
|
156
175
|
|
|
157
176
|
/**
|
|
158
177
|
* Sets ctx.webhooks['<id>'].url. Every webhook a trigger names starts at a
|
|
@@ -165,14 +184,16 @@ export function flowHarness({ manifest, flow, now, timezone, solar } = {}) {
|
|
|
165
184
|
},
|
|
166
185
|
|
|
167
186
|
/**
|
|
168
|
-
* Clears recordings, answers
|
|
169
|
-
* scoped memory; keeps the clock,
|
|
170
|
-
* webhook URLs.
|
|
187
|
+
* Clears recordings, answers (questions and unreachable people included),
|
|
188
|
+
* seeded states, the scenario's event and scoped memory; keeps the clock,
|
|
189
|
+
* timezone, solar, credentials and webhook URLs.
|
|
171
190
|
*/
|
|
172
191
|
reset() {
|
|
192
|
+
people.reset();
|
|
173
193
|
rec.steps.length = 0;
|
|
174
194
|
rec.logs.length = 0;
|
|
175
195
|
rec.llm.clear();
|
|
196
|
+
rec.handles = null;
|
|
176
197
|
rec.event = undefined;
|
|
177
198
|
bindings.reset();
|
|
178
199
|
return h;
|
|
@@ -206,6 +227,14 @@ export function flowHarness({ manifest, flow, now, timezone, solar } = {}) {
|
|
|
206
227
|
return seen === 0 ? id : `${id}#${seen}`;
|
|
207
228
|
};
|
|
208
229
|
|
|
230
|
+
const handles = createHandleTable({
|
|
231
|
+
refuse,
|
|
232
|
+
instant: world.instant,
|
|
233
|
+
render: renderArgs,
|
|
234
|
+
harnessName: 'flowHarness',
|
|
235
|
+
});
|
|
236
|
+
rec.handles = handles;
|
|
237
|
+
|
|
209
238
|
const log = (message, data) =>
|
|
210
239
|
rec.logs.push({ level: 'debug', message, data });
|
|
211
240
|
log.warn = (message, data) =>
|
|
@@ -215,7 +244,7 @@ export function flowHarness({ manifest, flow, now, timezone, solar } = {}) {
|
|
|
215
244
|
|
|
216
245
|
return {
|
|
217
246
|
event: flattenTriggerEvent({ trigger, payload }),
|
|
218
|
-
...bindings.ctx(refuse),
|
|
247
|
+
...bindings.ctx(refuse, {}, handles),
|
|
219
248
|
webhooks: webhooks.ctx(),
|
|
220
249
|
|
|
221
250
|
step: {
|
|
@@ -264,16 +293,16 @@ export function flowHarness({ manifest, flow, now, timezone, solar } = {}) {
|
|
|
264
293
|
}),
|
|
265
294
|
},
|
|
266
295
|
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
296
|
+
after: handles.after,
|
|
297
|
+
at: handles.at,
|
|
298
|
+
all: handles.all,
|
|
299
|
+
wait: handles.wait,
|
|
300
|
+
...people.ctx(refuse, handles, world.instant),
|
|
272
301
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
);
|
|
302
|
+
// sleep(d, label) is wait([after(d)], label) in the shim, so it refuses
|
|
303
|
+
// the way that wait does.
|
|
304
|
+
async sleep(duration, label) {
|
|
305
|
+
await handles.wait([handles.after(duration)], label);
|
|
277
306
|
},
|
|
278
307
|
|
|
279
308
|
log,
|