@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/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: ${Object.keys(handle).join(', ') || '(none)'})`,
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
- CONTROL_VERBS,
625
+ verbs,
428
626
  (prop) =>
429
- `ctx.controls.${name}.${prop} — not a control verb (have: ${CONTROL_VERBS.join(', ')})`,
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
- return { h, ctx, init };
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
- let answer = answers.get(key);
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();
@@ -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 = ['actions', 'webhooks', 'schedules', 'listener', 'hooks'];
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,8 +7,10 @@ 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';
13
+ import { createWebhookEndpoints } from './webhooks.js';
11
14
 
12
15
  /**
13
16
  * flowHarness({ manifest, flow }) builds the ctx the flow shim builds, in
@@ -41,11 +44,26 @@ export function flowHarness({ manifest, flow, now, timezone, solar } = {}) {
41
44
  steps: [],
42
45
  logs: [],
43
46
  llm: createAnswers(),
47
+ // The last run's handle table, for h.handles().
48
+ handles: null,
44
49
  // The last scenario's event, for an h.trigger(name) that gives none.
45
50
  event: undefined,
46
51
  };
47
52
  // Chainable setters on the binding half return the harness itself.
48
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);
56
+ // ctx.webhooks is keyed by the webhook ids the triggers name, each once.
57
+ const webhooks = createWebhookEndpoints(
58
+ [
59
+ ...new Set(
60
+ Object.values(triggers)
61
+ .filter((t) => t?.type === 'webhook' && t.webhook?.id)
62
+ .map((t) => t.webhook.id)
63
+ ),
64
+ ],
65
+ 'a webhook id a trigger in manifest.json names'
66
+ );
49
67
 
50
68
  const h = {
51
69
  /**
@@ -86,8 +104,14 @@ export function flowHarness({ manifest, flow, now, timezone, solar } = {}) {
86
104
  result = await handler(ctx);
87
105
  } catch (err) {
88
106
  // A refusal the handler caught and turned into something else is
89
- // 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.
90
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();
91
115
  }
92
116
  // A handler that swallowed a refusal (try/catch around a target call,
93
117
  // say) must not turn it into a green test.
@@ -111,7 +135,7 @@ export function flowHarness({ manifest, flow, now, timezone, solar } = {}) {
111
135
 
112
136
  /**
113
137
  * Applies a scenario through the setters: now, timezone, solar, states,
114
- * controls, targets, credentials and llm, and keeps its event for an
138
+ * controls, targets, credentials, llm, questions and unreachable, and keeps its event for an
115
139
  * h.trigger(name) that gives none. Ignores trigger, expect and
116
140
  * flow_version; fails on any other key.
117
141
  */
@@ -135,21 +159,41 @@ export function flowHarness({ manifest, flow, now, timezone, solar } = {}) {
135
159
 
136
160
  /** Every step.run and step.llm, in order, as the journal records them. */
137
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() : []),
138
168
  /** Everything handlers logged, with its level. */
139
169
  logs: () => rec.logs.slice(),
140
170
  /** Manifest-vs-module problems; empty when they agree. */
141
171
  conformance: () => flowConformance(manifest, flow),
142
172
 
143
173
  ...bindings.h,
174
+ ...people.h,
144
175
 
145
176
  /**
146
- * Clears recordings, answers, seeded states, the scenario's event and
147
- * scoped memory; keeps the clock, timezone, solar and credentials.
177
+ * Sets ctx.webhooks['<id>'].url. Every webhook a trigger names starts at a
178
+ * stand-in URL; null is what the host sends with no public URL. Kept
179
+ * across reset().
180
+ */
181
+ webhookUrls(values = {}) {
182
+ webhooks.set(values);
183
+ return h;
184
+ },
185
+
186
+ /**
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.
148
190
  */
149
191
  reset() {
192
+ people.reset();
150
193
  rec.steps.length = 0;
151
194
  rec.logs.length = 0;
152
195
  rec.llm.clear();
196
+ rec.handles = null;
153
197
  rec.event = undefined;
154
198
  bindings.reset();
155
199
  return h;
@@ -183,6 +227,14 @@ export function flowHarness({ manifest, flow, now, timezone, solar } = {}) {
183
227
  return seen === 0 ? id : `${id}#${seen}`;
184
228
  };
185
229
 
230
+ const handles = createHandleTable({
231
+ refuse,
232
+ instant: world.instant,
233
+ render: renderArgs,
234
+ harnessName: 'flowHarness',
235
+ });
236
+ rec.handles = handles;
237
+
186
238
  const log = (message, data) =>
187
239
  rec.logs.push({ level: 'debug', message, data });
188
240
  log.warn = (message, data) =>
@@ -192,7 +244,8 @@ export function flowHarness({ manifest, flow, now, timezone, solar } = {}) {
192
244
 
193
245
  return {
194
246
  event: flattenTriggerEvent({ trigger, payload }),
195
- ...bindings.ctx(refuse),
247
+ ...bindings.ctx(refuse, {}, handles),
248
+ webhooks: webhooks.ctx(),
196
249
 
197
250
  step: {
198
251
  async run(id, fn) {
@@ -240,16 +293,16 @@ export function flowHarness({ manifest, flow, now, timezone, solar } = {}) {
240
293
  }),
241
294
  },
242
295
 
243
- async sleep(...args) {
244
- refuse(
245
- `a handler cannot suspend in a test: sleep(${renderArgs(args)})`
246
- );
247
- },
296
+ after: handles.after,
297
+ at: handles.at,
298
+ all: handles.all,
299
+ wait: handles.wait,
300
+ ...people.ctx(refuse, handles, world.instant),
248
301
 
249
- async waitForEvent(...args) {
250
- refuse(
251
- `a handler cannot suspend in a test: waitForEvent(${renderArgs(args)})`
252
- );
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);
253
306
  },
254
307
 
255
308
  log,