@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/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,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 llm, and keeps its event for an
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, seeded states, the scenario's event and
169
- * scoped memory; keeps the clock, timezone, solar, credentials and
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
- async sleep(...args) {
268
- refuse(
269
- `a handler cannot suspend in a test: sleep(${renderArgs(args)})`
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
- async waitForEvent(...args) {
274
- refuse(
275
- `a handler cannot suspend in a test: waitForEvent(${renderArgs(args)})`
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,