@junoflow/testkit 0.14.0 → 0.15.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 +99 -40
- package/package.json +1 -1
- package/src/app.js +17 -2
- package/src/ask.js +30 -11
- package/src/flow.js +22 -3
- package/src/handles.js +9 -0
- package/src/index.d.ts +134 -7
- package/src/index.js +16 -0
- package/src/series-read.js +262 -0
- package/src/series.js +168 -0
- package/src/tasks.js +126 -0
package/README.md
CHANGED
|
@@ -285,27 +285,30 @@ it('greets when someone is home', async () => {
|
|
|
285
285
|
});
|
|
286
286
|
```
|
|
287
287
|
|
|
288
|
-
`flowHarness({ manifest, flow, now?, timezone?, solar? })` returns:
|
|
289
|
-
|
|
290
|
-
| Member | Does
|
|
291
|
-
| ---------------------------------------------- |
|
|
292
|
-
| `h.trigger(name, { event })` | run a declared trigger; resolves to the handler's return value
|
|
293
|
-
| `h.scenario(s)` | set the world from one JSON object (below)
|
|
294
|
-
| `h.now(iso)` / `h.timezone(tz)` / `h.solar(v)` | what `ctx.now()` and `ctx.solar()` answer; the timezone is `UTC` unless set
|
|
295
|
-
| `h.states(values)` / `h.state(name)` | seed a `read` or `read_write` binding; read its current value
|
|
296
|
-
| `h.stateCalls()` | `set`, `claim`, `release`, `signal`, `retract`, as `{binding, verb, value?, opts?}`
|
|
297
|
-
| `h.targets.answer(key, a)` / `.calls()` | answer `binding.method` with a value or a function of the params; the calls made
|
|
298
|
-
| `h.controls.answer(key, a)` / `.calls()` | answer `binding.verb`; the commands made
|
|
299
|
-
| `h.llm(id, answer)` | answer `step.llm(id, …)`; `id#N` answers only that occurrence
|
|
300
|
-
| `h.steps()` | every `step.run` and `step.llm`, as `{id, output}`; repeats are `id`, `id#1`, `id#2`
|
|
301
|
-
| `h.findings()` | `{slug, key, severity, text, standing}`, collapsed by `(slug, key)` as the host does
|
|
302
|
-
| `h.credentials(values)` | set `ctx.credentials` by declared id
|
|
303
|
-
| `h.webhookUrls(values)` | set `ctx.webhooks['<id>'].url`; each starts at `https://juno.test/webhooks/test-<id>`, `null` is "no public URL"
|
|
304
|
-
| `h.questions.answer(key, v)` / `.asked()` | answer `ask` by label (or `ask#N`); the questions asked, defaults applied (below)
|
|
305
|
-
| `h.notifications()` / `h.unreachable(...ids)` | every `notify` as `{to, message, urgency, via, link}`; the people `notify` reports unreachable
|
|
306
|
-
| `h.
|
|
307
|
-
| `h.
|
|
308
|
-
| `h.
|
|
288
|
+
`flowHarness({ manifest, flow, now?, timezone?, solar?, series? })` returns:
|
|
289
|
+
|
|
290
|
+
| Member | Does |
|
|
291
|
+
| ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
292
|
+
| `h.trigger(name, { event })` | run a declared trigger; resolves to the handler's return value |
|
|
293
|
+
| `h.scenario(s)` | set the world from one JSON object (below) |
|
|
294
|
+
| `h.now(iso)` / `h.timezone(tz)` / `h.solar(v)` | what `ctx.now()` and `ctx.solar()` answer; the timezone is `UTC` unless set |
|
|
295
|
+
| `h.states(values)` / `h.state(name)` | seed a `read` or `read_write` binding; read its current value |
|
|
296
|
+
| `h.stateCalls()` | `set`, `claim`, `release`, `signal`, `retract`, as `{binding, verb, value?, opts?}` |
|
|
297
|
+
| `h.targets.answer(key, a)` / `.calls()` | answer `binding.method` with a value or a function of the params; the calls made |
|
|
298
|
+
| `h.controls.answer(key, a)` / `.calls()` | answer `binding.verb`; the commands made |
|
|
299
|
+
| `h.llm(id, answer)` | answer `step.llm(id, …)`; `id#N` answers only that occurrence |
|
|
300
|
+
| `h.steps()` | every `step.run` and `step.llm`, as `{id, output}`; repeats are `id`, `id#1`, `id#2` |
|
|
301
|
+
| `h.findings()` | `{slug, key, severity, text, standing}`, collapsed by `(slug, key)` as the host does |
|
|
302
|
+
| `h.credentials(values)` | set `ctx.credentials` by declared id |
|
|
303
|
+
| `h.webhookUrls(values)` | set `ctx.webhooks['<id>'].url`; each starts at `https://juno.test/webhooks/test-<id>`, `null` is "no public URL" |
|
|
304
|
+
| `h.questions.answer(key, v)` / `.asked()` | answer `ask` by label (or `ask#N`); the questions asked, defaults applied (below) |
|
|
305
|
+
| `h.notifications()` / `h.unreachable(...ids)` | every `notify` as `{to, message, urgency, via, link}`; the people `notify` reports unreachable |
|
|
306
|
+
| `h.tasks()` | every `tasks.<b>` command; `open` answers `opened: true`, rid `test-rid-<n>`, the first time a key opens in a run; `untilDone` never fires |
|
|
307
|
+
| `h.series(key, fixture)` | what `series.<key>.get()` reads; replaces the key's fixture (see [Series](#series)) |
|
|
308
|
+
| `h.seriesView(key, spec, result)` | what `series.<key>.view(spec)` answers, for a spec equal by value |
|
|
309
|
+
| `h.logs()` | `[{ level, message, data }]` |
|
|
310
|
+
| `h.conformance()` | triggers declared vs implemented, both ways; `[]` when they agree |
|
|
311
|
+
| `h.reset()` | clear recordings, answers, seeds and scoped state; keep clock, timezone, solar, credentials, webhook URLs, series |
|
|
309
312
|
|
|
310
313
|
`h.trigger(name, { event })` sets `event.trigger` and spreads the payload you
|
|
311
314
|
give onto the event, as the sandbox does. An array (a `batch` run) stays under
|
|
@@ -314,6 +317,61 @@ modifiers are not evaluated. `h.now(iso)` without an offset is wall time in
|
|
|
314
317
|
`h.timezone()`; a wall time the timezone skips or repeats is refused, so give
|
|
315
318
|
one near a DST change an offset.
|
|
316
319
|
|
|
320
|
+
### Series
|
|
321
|
+
|
|
322
|
+
A `series` binding reads from a fixture, given per key as the `series` option or
|
|
323
|
+
with `h.series(key, fixture)`:
|
|
324
|
+
|
|
325
|
+
```ts
|
|
326
|
+
const h = flowHarness({
|
|
327
|
+
manifest,
|
|
328
|
+
flow,
|
|
329
|
+
series: {
|
|
330
|
+
temperature: [
|
|
331
|
+
{ from: '2026-11-01T12:00:00Z', to: '2026-11-01T13:00:00Z', value: 14 },
|
|
332
|
+
{ from: '2026-11-01T13:00:00Z', to: '2026-11-01T14:00:00Z', value: 16 },
|
|
333
|
+
],
|
|
334
|
+
},
|
|
335
|
+
});
|
|
336
|
+
h.seriesView(
|
|
337
|
+
'temperature',
|
|
338
|
+
{
|
|
339
|
+
highest: {
|
|
340
|
+
for: '2h',
|
|
341
|
+
contiguous: true,
|
|
342
|
+
from: { at: '12:00' },
|
|
343
|
+
to: { at: '18:00' },
|
|
344
|
+
},
|
|
345
|
+
},
|
|
346
|
+
{
|
|
347
|
+
available: true,
|
|
348
|
+
value: false,
|
|
349
|
+
spans: [{ from: '2026-11-01T13:00:00Z', to: '2026-11-01T15:00:00Z' }],
|
|
350
|
+
}
|
|
351
|
+
);
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
A fixture is an array of `{from, to, value}` (a `Date` or an RFC 3339 string for
|
|
355
|
+
each bound, sorted and non-overlapping), or
|
|
356
|
+
`{intervals, published_at?, issued_at?, covered_until?}`. `get(range?)` answers
|
|
357
|
+
the intervals overlapping `[from, to)`, the instants spelled as the host spells
|
|
358
|
+
them (RFC 3339 UTC, `2026-11-01T12:00:00Z`), and the stamps from the fixture or
|
|
359
|
+
`null`. A `get()` of a key with no fixture fails, naming `h.series`.
|
|
360
|
+
|
|
361
|
+
**`view()` is stubbed, never evaluated.** The host evaluates a view with cron
|
|
362
|
+
and solar edges in the site's timezone, and a copy here would be a second
|
|
363
|
+
implementation of it. `h.seriesView(key, spec, result)` says what a view
|
|
364
|
+
answers: `result` is a `SeriesViewResult` (fields left out read `false`, `null`
|
|
365
|
+
and `[]`) or a function of the spec returning one, and it matches a spec equal
|
|
366
|
+
by value (key order does not matter). A `view()` nothing stubbed fails naming
|
|
367
|
+
the spec and the binding. Fixtures and stubs are kept across `reset()`.
|
|
368
|
+
|
|
369
|
+
A key the manifest does not bind fails in the host's words:
|
|
370
|
+
`series binding "cost" is not declared in this flow's series; it declares "temperature"`
|
|
371
|
+
(`this app's` in an app), as do a bound that is not an instant
|
|
372
|
+
(`series "price": from "yesterday" is not an RFC 3339 instant`) and an empty
|
|
373
|
+
range (`series "price": from must be before to`).
|
|
374
|
+
|
|
317
375
|
### Scenarios
|
|
318
376
|
|
|
319
377
|
`h.scenario(s)` applies a whole world at once, through the setters above and
|
|
@@ -493,22 +551,23 @@ it('a read-only handler cannot claim', async () => {
|
|
|
493
551
|
});
|
|
494
552
|
```
|
|
495
553
|
|
|
496
|
-
`appHarness({ manifest, app, now?, timezone?, solar? })` returns:
|
|
497
|
-
|
|
498
|
-
| Member | Does
|
|
499
|
-
| ---------------------------------------------- |
|
|
500
|
-
| `h.call(name, input?)` | call a declared handler; resolves to its result after a JSON round trip
|
|
501
|
-
| `h.events()` | every emit that passed, as `{event, payload}`, in emit order
|
|
502
|
-
| `h.scenario(s)` | set the world from one JSON object, as `flowHarness`'s
|
|
503
|
-
| `h.now(iso)` / `h.timezone(tz)` / `h.solar(v)` | what `ctx.now()` and `ctx.solar()` answer; the timezone is `UTC` unless set
|
|
504
|
-
| `h.states(values)` | seed a `read` binding
|
|
505
|
-
| `h.stateCalls()` | `claim` and `release`, as `{binding, verb, value?, opts?}`
|
|
506
|
-
| `h.targets.answer(key, a)` / `.calls()` | answer `binding.method` with a value or a function of the params; the calls made
|
|
507
|
-
| `h.controls.answer(key, a)` / `.calls()` | answer `binding.verb`; the commands made
|
|
508
|
-
| `h.credentials(values)` | set `ctx.credentials` by declared id
|
|
509
|
-
| `h.
|
|
510
|
-
| `h.
|
|
511
|
-
| `h.
|
|
554
|
+
`appHarness({ manifest, app, now?, timezone?, solar?, series? })` returns:
|
|
555
|
+
|
|
556
|
+
| Member | Does |
|
|
557
|
+
| ---------------------------------------------- | ------------------------------------------------------------------------------------- |
|
|
558
|
+
| `h.call(name, input?)` | call a declared handler; resolves to its result after a JSON round trip |
|
|
559
|
+
| `h.events()` | every emit that passed, as `{event, payload}`, in emit order |
|
|
560
|
+
| `h.scenario(s)` | set the world from one JSON object, as `flowHarness`'s |
|
|
561
|
+
| `h.now(iso)` / `h.timezone(tz)` / `h.solar(v)` | what `ctx.now()` and `ctx.solar()` answer; the timezone is `UTC` unless set |
|
|
562
|
+
| `h.states(values)` | seed a `read` binding |
|
|
563
|
+
| `h.stateCalls()` | `claim` and `release`, as `{binding, verb, value?, opts?}` |
|
|
564
|
+
| `h.targets.answer(key, a)` / `.calls()` | answer `binding.method` with a value or a function of the params; the calls made |
|
|
565
|
+
| `h.controls.answer(key, a)` / `.calls()` | answer `binding.verb`; the commands made |
|
|
566
|
+
| `h.credentials(values)` | set `ctx.credentials` by declared id |
|
|
567
|
+
| `h.series(key, f)` / `h.seriesView(key, s, r)` | a series fixture and a view stub, as `flowHarness`'s ([Series](#series)) |
|
|
568
|
+
| `h.logs()` | `[{ level, message, data }]` |
|
|
569
|
+
| `h.conformance()` | handlers declared vs implemented, both ways; `[]` when they agree |
|
|
570
|
+
| `h.reset()` | clear recordings, answers and seeds; keep clock, timezone, solar, credentials, series |
|
|
512
571
|
|
|
513
572
|
`h.scenario(s)` takes the flow scenario's keys except `llm`, `questions`,
|
|
514
573
|
`unreachable` and `flow_version`, which it refuses, and ignores `trigger`,
|
|
@@ -516,9 +575,9 @@ it('a read-only handler cannot claim', async () => {
|
|
|
516
575
|
answer targets and controls as they do in a flow test.
|
|
517
576
|
|
|
518
577
|
The handler's `ctx` is the app sandbox's: `log`, `now`, `solar`, `signal`,
|
|
519
|
-
`states`, `controls`, `targets`, `credentials` and `events`.
|
|
520
|
-
never aborted. The binding handles, answers and clock are the
|
|
521
|
-
uses, with the same strictness.
|
|
578
|
+
`states`, `controls`, `series`, `targets`, `credentials` and `events`.
|
|
579
|
+
`ctx.signal` is never aborted. The binding handles, answers and clock are the
|
|
580
|
+
ones `flowHarness` uses, with the same strictness.
|
|
522
581
|
|
|
523
582
|
### What fails
|
|
524
583
|
|
package/package.json
CHANGED
package/src/app.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { fail } from './assert.js';
|
|
2
2
|
import { createBindings, createWorld, jsonCopy } from './bindings.js';
|
|
3
3
|
import { applyScenario } from './scenario.js';
|
|
4
|
+
import { createSeriesBindings } from './series-read.js';
|
|
4
5
|
import { compile, describeErrors } from './schema.js';
|
|
5
6
|
|
|
6
7
|
/** The only key an app's default export carries (BaseApp in app-api.d.ts). */
|
|
@@ -21,7 +22,14 @@ const APP_KEYS = ['handlers'];
|
|
|
21
22
|
* clock, the sun, a target call and a control command fail unless the test
|
|
22
23
|
* answered them.
|
|
23
24
|
*/
|
|
24
|
-
export function appHarness({
|
|
25
|
+
export function appHarness({
|
|
26
|
+
manifest,
|
|
27
|
+
app,
|
|
28
|
+
now,
|
|
29
|
+
timezone,
|
|
30
|
+
solar,
|
|
31
|
+
series,
|
|
32
|
+
} = {}) {
|
|
25
33
|
if (!manifest || typeof manifest !== 'object') {
|
|
26
34
|
fail(
|
|
27
35
|
"appHarness({ manifest, app }): manifest is required — import manifest from '../manifest.json'"
|
|
@@ -42,6 +50,9 @@ export function appHarness({ manifest, app, now, timezone, solar } = {}) {
|
|
|
42
50
|
scoped: false,
|
|
43
51
|
});
|
|
44
52
|
|
|
53
|
+
// ctx.series, answered from h.series fixtures and h.seriesView stubs.
|
|
54
|
+
const seriesBindings = createSeriesBindings(manifest, () => h, { app: true });
|
|
55
|
+
|
|
45
56
|
// What handlers did. Cleared by reset().
|
|
46
57
|
const rec = { logs: [], events: [] };
|
|
47
58
|
|
|
@@ -127,10 +138,12 @@ export function appHarness({ manifest, app, now, timezone, solar } = {}) {
|
|
|
127
138
|
targets: bindings.h.targets,
|
|
128
139
|
controls: bindings.h.controls,
|
|
129
140
|
credentials: bindings.h.credentials,
|
|
141
|
+
series: seriesBindings.h.series,
|
|
142
|
+
seriesView: seriesBindings.h.seriesView,
|
|
130
143
|
|
|
131
144
|
/**
|
|
132
145
|
* Clears recordings, answers and seeded states; keeps the clock,
|
|
133
|
-
* timezone, solar and
|
|
146
|
+
* timezone, solar, credentials, series and view stubs.
|
|
134
147
|
*/
|
|
135
148
|
reset() {
|
|
136
149
|
rec.logs.length = 0;
|
|
@@ -141,6 +154,7 @@ export function appHarness({ manifest, app, now, timezone, solar } = {}) {
|
|
|
141
154
|
};
|
|
142
155
|
|
|
143
156
|
world.init({ now, timezone, solar });
|
|
157
|
+
seriesBindings.init(series);
|
|
144
158
|
|
|
145
159
|
/** One call's ctx, exactly the members app-entrypoint.ts builds. */
|
|
146
160
|
function buildContext(run, handler, readOnly) {
|
|
@@ -170,6 +184,7 @@ export function appHarness({ manifest, app, now, timezone, solar } = {}) {
|
|
|
170
184
|
controls: b.controls,
|
|
171
185
|
targets: b.targets,
|
|
172
186
|
credentials: b.credentials,
|
|
187
|
+
series: seriesBindings.ctx(refuse),
|
|
173
188
|
events: eventsProxy(refuse, handler, readOnly),
|
|
174
189
|
};
|
|
175
190
|
}
|
package/src/ask.js
CHANGED
|
@@ -24,7 +24,14 @@ const OPTION_KEY = /^[a-z][a-zA-Z0-9]*$/;
|
|
|
24
24
|
export const TEST_CHANNEL = 'test:channel';
|
|
25
25
|
|
|
26
26
|
/** What h.questions.answer takes for an answer. */
|
|
27
|
-
const ANSWER_FIELDS = [
|
|
27
|
+
const ANSWER_FIELDS = [
|
|
28
|
+
'by',
|
|
29
|
+
'answer',
|
|
30
|
+
'channel',
|
|
31
|
+
'text',
|
|
32
|
+
'interpreted',
|
|
33
|
+
'user',
|
|
34
|
+
];
|
|
28
35
|
|
|
29
36
|
const runes = (s) => [...s].length;
|
|
30
37
|
|
|
@@ -152,9 +159,6 @@ export function parseQuestion(opts, people) {
|
|
|
152
159
|
) {
|
|
153
160
|
return { error: 'ask: freeText must be "exact" or "interpret"' };
|
|
154
161
|
}
|
|
155
|
-
if (o.requireSignIn === true) {
|
|
156
|
-
return { error: 'ask: requireSignIn is not available yet' };
|
|
157
|
-
}
|
|
158
162
|
return { question: q };
|
|
159
163
|
}
|
|
160
164
|
|
|
@@ -217,9 +221,11 @@ export function createPeople(manifest, harness) {
|
|
|
217
221
|
/**
|
|
218
222
|
* Answers the question whose label is `key` — or `ask#N`, the Nth ask
|
|
219
223
|
* of the trigger (from 1), for an unlabelled one — with
|
|
220
|
-
* `{ by, answer, channel?, text?, interpreted? }` or
|
|
221
|
-
* `channel: 'page'` is an answer from the answer page;
|
|
222
|
-
* reply as written, and `interpreted: true` one the
|
|
224
|
+
* `{ by, answer, channel?, text?, interpreted?, user? }` or
|
|
225
|
+
* `'unanswered'`. `channel: 'page'` is an answer from the answer page;
|
|
226
|
+
* `text` a written reply as written, and `interpreted: true` one the
|
|
227
|
+
* site's LLM mapped; `channel: 'inbox'` an answer given in Juno, whose
|
|
228
|
+
* `user` is who pressed the button (on a recipient's behalf, not `by`).
|
|
223
229
|
*/
|
|
224
230
|
answer(key, value) {
|
|
225
231
|
if (typeof key !== 'string' || key === '') {
|
|
@@ -236,7 +242,7 @@ export function createPeople(manifest, harness) {
|
|
|
236
242
|
typeof value.answer === 'string';
|
|
237
243
|
if (!ok) {
|
|
238
244
|
fail(
|
|
239
|
-
`h.questions.answer('${key}', …) — expected { by, answer, channel?, text?, interpreted? } or 'unanswered', got ${JSON.stringify(value)}`
|
|
245
|
+
`h.questions.answer('${key}', …) — expected { by, answer, channel?, text?, interpreted?, user? } or 'unanswered', got ${JSON.stringify(value)}`
|
|
240
246
|
);
|
|
241
247
|
}
|
|
242
248
|
for (const k of Object.keys(value)) {
|
|
@@ -246,7 +252,7 @@ export function createPeople(manifest, harness) {
|
|
|
246
252
|
);
|
|
247
253
|
}
|
|
248
254
|
}
|
|
249
|
-
for (const k of ['channel', 'text']) {
|
|
255
|
+
for (const k of ['channel', 'text', 'user']) {
|
|
250
256
|
if (value[k] !== undefined && typeof value[k] !== 'string') {
|
|
251
257
|
fail(
|
|
252
258
|
`h.questions.answer('${key}', …) — ${k} is a string; got ${JSON.stringify(value[k])}`
|
|
@@ -263,6 +269,11 @@ export function createPeople(manifest, harness) {
|
|
|
263
269
|
`h.questions.answer('${key}', …) — interpreted needs the text the LLM read`
|
|
264
270
|
);
|
|
265
271
|
}
|
|
272
|
+
if (value.user !== undefined && value.channel !== 'inbox') {
|
|
273
|
+
fail(
|
|
274
|
+
`h.questions.answer('${key}', …) — user is set only for an answer given in Juno; pass channel: 'inbox' with it`
|
|
275
|
+
);
|
|
276
|
+
}
|
|
266
277
|
declared(`h.questions.answer('${key}', …)`, value.by);
|
|
267
278
|
value = { ...value };
|
|
268
279
|
}
|
|
@@ -313,13 +324,20 @@ export function createPeople(manifest, harness) {
|
|
|
313
324
|
`h.questions.answer('${key}', …) — "${answer.by}" was not asked (to: ${q.to.join(', ')})`
|
|
314
325
|
);
|
|
315
326
|
}
|
|
327
|
+
// Only an answer given in Juno counts (plan-ask-3 Decision 7): the
|
|
328
|
+
// host refuses a channel's answered report and the answer page.
|
|
329
|
+
if (opts.requireSignIn === true && answer.channel !== 'inbox') {
|
|
330
|
+
refuse(
|
|
331
|
+
`h.questions.answer('${key}', …) — this question needs a signed-in answer in Juno; pass channel: 'inbox'`
|
|
332
|
+
);
|
|
333
|
+
}
|
|
316
334
|
if (!Object.hasOwn(q.options, answer.answer)) {
|
|
317
335
|
refuse(
|
|
318
336
|
`h.questions.answer('${key}', …) — "${answer.answer}" is not one of the question's options (have: ${Object.keys(q.options).join(', ')})`
|
|
319
337
|
);
|
|
320
338
|
}
|
|
321
|
-
// The host's AnsweredValue, in its field order: text
|
|
322
|
-
// interpreted only when set.
|
|
339
|
+
// The host's AnsweredValue, in its field order: text,
|
|
340
|
+
// interpreted and user only when set.
|
|
323
341
|
value = {
|
|
324
342
|
outcome: 'answered',
|
|
325
343
|
answer: answer.answer,
|
|
@@ -327,6 +345,7 @@ export function createPeople(manifest, harness) {
|
|
|
327
345
|
channel: answer.channel ?? TEST_CHANNEL,
|
|
328
346
|
...(answer.text !== undefined && { text: answer.text }),
|
|
329
347
|
...(answer.interpreted && { interpreted: true }),
|
|
348
|
+
...(answer.user !== undefined && { user: answer.user }),
|
|
330
349
|
at: rfc3339(instant() ?? Date.now()),
|
|
331
350
|
};
|
|
332
351
|
}
|
package/src/flow.js
CHANGED
|
@@ -10,6 +10,8 @@ import { flowConformance } from './flow-conformance.js';
|
|
|
10
10
|
import { createHandleTable } from './handles.js';
|
|
11
11
|
import { names } from './names.js';
|
|
12
12
|
import { applyScenario } from './scenario.js';
|
|
13
|
+
import { createSeriesBindings } from './series-read.js';
|
|
14
|
+
import { createTasks } from './tasks.js';
|
|
13
15
|
import { createWebhookEndpoints } from './webhooks.js';
|
|
14
16
|
|
|
15
17
|
/**
|
|
@@ -22,7 +24,14 @@ import { createWebhookEndpoints } from './webhooks.js';
|
|
|
22
24
|
* not declare fails here, and so does any read of the world — the clock, the
|
|
23
25
|
* sun, a step.llm — that the test did not answer.
|
|
24
26
|
*/
|
|
25
|
-
export function flowHarness({
|
|
27
|
+
export function flowHarness({
|
|
28
|
+
manifest,
|
|
29
|
+
flow,
|
|
30
|
+
now,
|
|
31
|
+
timezone,
|
|
32
|
+
solar,
|
|
33
|
+
series,
|
|
34
|
+
} = {}) {
|
|
26
35
|
if (!manifest || typeof manifest !== 'object') {
|
|
27
36
|
fail(
|
|
28
37
|
"flowHarness({ manifest, flow }): manifest is required — import manifest from '../manifest.json'"
|
|
@@ -51,8 +60,12 @@ export function flowHarness({ manifest, flow, now, timezone, solar } = {}) {
|
|
|
51
60
|
};
|
|
52
61
|
// Chainable setters on the binding half return the harness itself.
|
|
53
62
|
const bindings = createBindings(manifest, () => h);
|
|
63
|
+
// ctx.series, answered from h.series fixtures and h.seriesView stubs.
|
|
64
|
+
const seriesBindings = createSeriesBindings(manifest, () => h);
|
|
54
65
|
// ctx.ask and ctx.notify, and h.questions / h.notifications / h.unreachable.
|
|
55
66
|
const people = createPeople(manifest, () => h);
|
|
67
|
+
// ctx.tasks, and h.tasks.
|
|
68
|
+
const tasks = createTasks(manifest);
|
|
56
69
|
// ctx.webhooks is keyed by the webhook ids the triggers name, each once.
|
|
57
70
|
const webhooks = createWebhookEndpoints(
|
|
58
71
|
[
|
|
@@ -172,6 +185,8 @@ export function flowHarness({ manifest, flow, now, timezone, solar } = {}) {
|
|
|
172
185
|
|
|
173
186
|
...bindings.h,
|
|
174
187
|
...people.h,
|
|
188
|
+
...seriesBindings.h,
|
|
189
|
+
...tasks.h,
|
|
175
190
|
|
|
176
191
|
/**
|
|
177
192
|
* Sets ctx.webhooks['<id>'].url. Every webhook a trigger names starts at a
|
|
@@ -184,12 +199,13 @@ export function flowHarness({ manifest, flow, now, timezone, solar } = {}) {
|
|
|
184
199
|
},
|
|
185
200
|
|
|
186
201
|
/**
|
|
187
|
-
* Clears recordings, answers (questions and unreachable people included),
|
|
202
|
+
* Clears recordings (task commands included), answers (questions and unreachable people included),
|
|
188
203
|
* seeded states, the scenario's event and scoped memory; keeps the clock,
|
|
189
|
-
* timezone, solar, credentials and
|
|
204
|
+
* timezone, solar, credentials, webhook URLs, series and view stubs.
|
|
190
205
|
*/
|
|
191
206
|
reset() {
|
|
192
207
|
people.reset();
|
|
208
|
+
tasks.reset();
|
|
193
209
|
rec.steps.length = 0;
|
|
194
210
|
rec.logs.length = 0;
|
|
195
211
|
rec.llm.clear();
|
|
@@ -201,6 +217,7 @@ export function flowHarness({ manifest, flow, now, timezone, solar } = {}) {
|
|
|
201
217
|
};
|
|
202
218
|
|
|
203
219
|
world.init({ now, timezone, solar });
|
|
220
|
+
seriesBindings.init(series);
|
|
204
221
|
|
|
205
222
|
/**
|
|
206
223
|
* One run's ctx, as buildContext in sdk/src/context.ts builds it. Built per
|
|
@@ -245,6 +262,8 @@ export function flowHarness({ manifest, flow, now, timezone, solar } = {}) {
|
|
|
245
262
|
return {
|
|
246
263
|
event: flattenTriggerEvent({ trigger, payload }),
|
|
247
264
|
...bindings.ctx(refuse, {}, handles),
|
|
265
|
+
series: seriesBindings.ctx(refuse),
|
|
266
|
+
tasks: tasks.ctx(refuse, handles),
|
|
248
267
|
webhooks: webhooks.ctx(),
|
|
249
268
|
|
|
250
269
|
step: {
|
package/src/handles.js
CHANGED
|
@@ -230,6 +230,15 @@ export function createHandleTable({ refuse, instant, render, harnessName }) {
|
|
|
230
230
|
return open(call, args, { label: opts?.label }, 'press');
|
|
231
231
|
},
|
|
232
232
|
|
|
233
|
+
/**
|
|
234
|
+
* tasks.<b>.untilDone: never fires, like after — no task closes in a
|
|
235
|
+
* test.
|
|
236
|
+
*/
|
|
237
|
+
taskHandle(call, args) {
|
|
238
|
+
const [opts] = args;
|
|
239
|
+
return open(call, args, { label: opts?.label }, 'task');
|
|
240
|
+
},
|
|
241
|
+
|
|
233
242
|
/**
|
|
234
243
|
* targets.<flow>.start|startAll. The harness runs no child, so a run
|
|
235
244
|
* handle fires at open with the value the test's answer makes, or is the
|
package/src/index.d.ts
CHANGED
|
@@ -27,6 +27,15 @@ export interface ManifestConfigSlot {
|
|
|
27
27
|
enum?: string[];
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
/** One interval of a published series, as h.series() shows it. */
|
|
31
|
+
export interface SeriesStep {
|
|
32
|
+
/** ISO 8601 UTC, inclusive. */
|
|
33
|
+
from: string;
|
|
34
|
+
/** ISO 8601 UTC, exclusive. */
|
|
35
|
+
to: string;
|
|
36
|
+
value: unknown;
|
|
37
|
+
}
|
|
38
|
+
|
|
30
39
|
/** The parsed contents of manifest.json. */
|
|
31
40
|
export interface Manifest {
|
|
32
41
|
name: string;
|
|
@@ -62,6 +71,27 @@ export interface Manifest {
|
|
|
62
71
|
[k: string]: unknown;
|
|
63
72
|
}
|
|
64
73
|
>;
|
|
74
|
+
/**
|
|
75
|
+
* Series this integration publishes with ctx.series.publish. Keyed by the
|
|
76
|
+
* local name; the host scopes it to `<install>:<name>`.
|
|
77
|
+
*/
|
|
78
|
+
series?: Record<
|
|
79
|
+
string,
|
|
80
|
+
{
|
|
81
|
+
type?: string;
|
|
82
|
+
name?: string;
|
|
83
|
+
description?: string;
|
|
84
|
+
keywords?: string[];
|
|
85
|
+
values?: string[];
|
|
86
|
+
min?: number;
|
|
87
|
+
max?: number;
|
|
88
|
+
unit?: string;
|
|
89
|
+
accumulates?: boolean;
|
|
90
|
+
forecast?: boolean;
|
|
91
|
+
keep?: string;
|
|
92
|
+
[k: string]: unknown;
|
|
93
|
+
}
|
|
94
|
+
>;
|
|
65
95
|
/** Lifecycle hooks, from the closed vocabulary Juno defines — today, `activated`. */
|
|
66
96
|
hooks?: Record<
|
|
67
97
|
string,
|
|
@@ -285,6 +315,12 @@ export interface Harness {
|
|
|
285
315
|
findings(): RecordedFinding[];
|
|
286
316
|
/** Values ctx.states.publish has sent so far, keyed by local name. */
|
|
287
317
|
states(): Record<string, unknown>;
|
|
318
|
+
/**
|
|
319
|
+
* Each series ctx.series.publish has written, keyed by local name: the
|
|
320
|
+
* merged step function the host would hold, sorted by `from`. A series
|
|
321
|
+
* never published is absent. Cleared by reset().
|
|
322
|
+
*/
|
|
323
|
+
series(): Record<string, SeriesStep[]>;
|
|
288
324
|
/** The last ctx.status.set report, or null when the handler never reported. */
|
|
289
325
|
status(): RecordedStatus | null;
|
|
290
326
|
/** What ctx.channels.report sent, in order. Cleared by reset(). */
|
|
@@ -362,6 +398,8 @@ export interface FlowManifest {
|
|
|
362
398
|
credentials?: string[];
|
|
363
399
|
/** The people ctx.ask and ctx.notify may name. */
|
|
364
400
|
people?: string[];
|
|
401
|
+
/** Keyed by binding name: the series `<install>:<name>` it reads. */
|
|
402
|
+
series?: Record<string, { series: string }>;
|
|
365
403
|
[k: string]: unknown;
|
|
366
404
|
}
|
|
367
405
|
|
|
@@ -379,6 +417,38 @@ export interface FlowModule<
|
|
|
379
417
|
triggers: Handlers;
|
|
380
418
|
}
|
|
381
419
|
|
|
420
|
+
/** One interval of a series fixture: [from, to), a Date or RFC 3339 bound. */
|
|
421
|
+
export interface SeriesFixtureInterval {
|
|
422
|
+
from: Date | string;
|
|
423
|
+
to: Date | string;
|
|
424
|
+
value: unknown;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* What series.<key>.get() reads: the intervals (sorted, non-overlapping), or
|
|
429
|
+
* those with the stamps get() reports — each null when left out.
|
|
430
|
+
*/
|
|
431
|
+
export type SeriesFixture =
|
|
432
|
+
| SeriesFixtureInterval[]
|
|
433
|
+
| {
|
|
434
|
+
intervals: SeriesFixtureInterval[];
|
|
435
|
+
published_at?: string | Date | null;
|
|
436
|
+
issued_at?: string | Date | null;
|
|
437
|
+
covered_until?: string | Date | null;
|
|
438
|
+
};
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* What series.<key>.view(spec) answers when h.seriesView stubs it. Fields
|
|
442
|
+
* left out read false, null and [] as the SDK fills them.
|
|
443
|
+
*/
|
|
444
|
+
export interface SeriesViewAnswer {
|
|
445
|
+
available?: boolean;
|
|
446
|
+
value?: number | string | boolean | null;
|
|
447
|
+
reason?: string | null;
|
|
448
|
+
until?: string | null;
|
|
449
|
+
spans?: Array<{ from: string; to: string }>;
|
|
450
|
+
}
|
|
451
|
+
|
|
382
452
|
/** What ctx.solar() answers; see SolarResult in the flow's types. */
|
|
383
453
|
export interface SolarAnswer {
|
|
384
454
|
above_horizon?: boolean;
|
|
@@ -401,8 +471,8 @@ export interface FlowHarnessOptions<
|
|
|
401
471
|
now?: string;
|
|
402
472
|
/** As h.timezone(); UTC when omitted. */
|
|
403
473
|
timezone?: string;
|
|
404
|
-
/** As h.
|
|
405
|
-
|
|
474
|
+
/** As h.series(), per binding key. */
|
|
475
|
+
series?: Record<string, SeriesFixture>;
|
|
406
476
|
}
|
|
407
477
|
|
|
408
478
|
export interface TriggerOptions {
|
|
@@ -457,6 +527,23 @@ export interface RecordedControlCall {
|
|
|
457
527
|
until?: string;
|
|
458
528
|
}
|
|
459
529
|
|
|
530
|
+
/**
|
|
531
|
+
* One ctx.tasks command, as the SDK sends it: a Date as its ISO string,
|
|
532
|
+
* absent options left out.
|
|
533
|
+
*/
|
|
534
|
+
export interface RecordedTaskCommand {
|
|
535
|
+
binding: string;
|
|
536
|
+
verb: 'open' | 'snooze' | 'complete' | 'cancel';
|
|
537
|
+
key?: string;
|
|
538
|
+
at?: string;
|
|
539
|
+
within?: string;
|
|
540
|
+
until?: string;
|
|
541
|
+
reason?: string;
|
|
542
|
+
data?: unknown;
|
|
543
|
+
outcome?: 'success' | 'failed';
|
|
544
|
+
note?: string;
|
|
545
|
+
}
|
|
546
|
+
|
|
460
547
|
/** What a control command answers. Fields left out take the SDK's defaults. */
|
|
461
548
|
export interface ControlAnswer {
|
|
462
549
|
outcome?: string;
|
|
@@ -585,13 +672,19 @@ export type QuestionAnswer =
|
|
|
585
672
|
answer: string;
|
|
586
673
|
/**
|
|
587
674
|
* `<install>:<channel>` the answer came through, `'page'` for the
|
|
588
|
-
* answer page; `test:channel`
|
|
675
|
+
* answer page, `'inbox'` for an answer given in Juno; `test:channel`
|
|
676
|
+
* when omitted.
|
|
589
677
|
*/
|
|
590
678
|
channel?: string;
|
|
591
679
|
/** A written reply, as the person wrote it. */
|
|
592
680
|
text?: string;
|
|
593
681
|
/** The site's LLM mapped `text` onto `answer` (freeText: 'interpret'). */
|
|
594
682
|
interpreted?: true;
|
|
683
|
+
/**
|
|
684
|
+
* Who pressed the button, for `channel: 'inbox'` only: a username. On an
|
|
685
|
+
* answer given on a recipient's behalf it is not `by`.
|
|
686
|
+
*/
|
|
687
|
+
user?: string;
|
|
595
688
|
}
|
|
596
689
|
| 'unanswered';
|
|
597
690
|
|
|
@@ -626,8 +719,8 @@ export interface FlowQuestions {
|
|
|
626
719
|
/**
|
|
627
720
|
* Answers the question whose `label` is key — or `ask#N`, the Nth ask of the
|
|
628
721
|
* trigger (from 1), for one without a label. An answer fires the question at
|
|
629
|
-
* open with `{ outcome: 'answered', answer, by, channel, text?, interpreted?, at }`
|
|
630
|
-
* (text and
|
|
722
|
+
* open with `{ outcome: 'answered', answer, by, channel, text?, interpreted?, user?, at }`
|
|
723
|
+
* (text, interpreted and user passed through when given), `at` being
|
|
631
724
|
* h.now() (or the wall clock with none set); `by` must be one of its `to`
|
|
632
725
|
* and `answer` one of its option keys. `'unanswered'` fires it with
|
|
633
726
|
* `{ outcome: 'unanswered', attempts: to.length * rounds }` — the host does
|
|
@@ -700,16 +793,39 @@ export interface FlowHarness {
|
|
|
700
793
|
notifications(): RecordedNotification[];
|
|
701
794
|
/** People ctx.notify reports unreachable (no contact the message may use). */
|
|
702
795
|
unreachable(...ids: string[]): FlowHarness;
|
|
796
|
+
/**
|
|
797
|
+
* Every ctx.tasks command, in order. open answers `{opened: true, key, rid:
|
|
798
|
+
* 'test-rid-<n>', url: 'test:ping'}` the first time a key is opened in a
|
|
799
|
+
* run and `opened: false` with the same rid after; the other verbs answer
|
|
800
|
+
* nothing. Cleared by reset().
|
|
801
|
+
*/
|
|
802
|
+
tasks(): RecordedTaskCommand[];
|
|
703
803
|
/**
|
|
704
804
|
* Sets ctx.webhooks['<id>'].url for a webhook id a trigger names. Each starts
|
|
705
805
|
* at a stand-in URL (https://juno.test/webhooks/test-<id>); null is what the
|
|
706
806
|
* host sends with no public URL. Kept across reset().
|
|
707
807
|
*/
|
|
708
808
|
webhookUrls(values: Record<string, string | null>): FlowHarness;
|
|
809
|
+
/**
|
|
810
|
+
* Sets what series.<key>.get(range?) reads, replacing the key's fixture.
|
|
811
|
+
* get() answers the intervals overlapping the range, instants spelled as
|
|
812
|
+
* the host spells them (RFC 3339 UTC). Kept across reset().
|
|
813
|
+
*/
|
|
814
|
+
series(key: string, fixture: SeriesFixture): FlowHarness;
|
|
815
|
+
/**
|
|
816
|
+
* Says what series.<key>.view(spec) answers, for a spec equal by value.
|
|
817
|
+
* Views are stubbed, never evaluated: an unstubbed view() fails naming the
|
|
818
|
+
* spec and the binding. Kept across reset().
|
|
819
|
+
*/
|
|
820
|
+
seriesView(
|
|
821
|
+
key: string,
|
|
822
|
+
spec: object,
|
|
823
|
+
result: SeriesViewAnswer | ((spec: any) => SeriesViewAnswer)
|
|
824
|
+
): FlowHarness;
|
|
709
825
|
/**
|
|
710
826
|
* Clears recordings, answers (questions and unreachable people included),
|
|
711
827
|
* seeded states, the scenario's event and scoped memory; keeps the clock,
|
|
712
|
-
* timezone, solar, credentials and
|
|
828
|
+
* timezone, solar, credentials, webhook URLs, series and view stubs.
|
|
713
829
|
*/
|
|
714
830
|
reset(): FlowHarness;
|
|
715
831
|
}
|
|
@@ -739,6 +855,7 @@ export interface AppManifest {
|
|
|
739
855
|
credentials?: string[];
|
|
740
856
|
/** Keyed by event name; `payload` is the JSON Schema an emit is held to. */
|
|
741
857
|
events?: Record<string, { payload?: object; [k: string]: unknown }>;
|
|
858
|
+
series?: FlowManifest['series'];
|
|
742
859
|
[k: string]: unknown;
|
|
743
860
|
}
|
|
744
861
|
|
|
@@ -770,6 +887,8 @@ export interface AppHarnessOptions<
|
|
|
770
887
|
timezone?: string;
|
|
771
888
|
/** As h.solar(). */
|
|
772
889
|
solar?: SolarAnswer;
|
|
890
|
+
/** As h.series(), per binding key. */
|
|
891
|
+
series?: Record<string, SeriesFixture>;
|
|
773
892
|
}
|
|
774
893
|
|
|
775
894
|
/** One ctx.events.<event>.emit that passed the host's checks. */
|
|
@@ -826,9 +945,17 @@ export interface AppHarness {
|
|
|
826
945
|
readonly controls: FlowControls;
|
|
827
946
|
/** Sets ctx.credentials by declared id. Kept across reset(). */
|
|
828
947
|
credentials(values: Record<string, string>): AppHarness;
|
|
948
|
+
/** As FlowHarness.series. */
|
|
949
|
+
series(key: string, fixture: SeriesFixture): AppHarness;
|
|
950
|
+
/** As FlowHarness.seriesView. */
|
|
951
|
+
seriesView(
|
|
952
|
+
key: string,
|
|
953
|
+
spec: object,
|
|
954
|
+
result: SeriesViewAnswer | ((spec: any) => SeriesViewAnswer)
|
|
955
|
+
): AppHarness;
|
|
829
956
|
/**
|
|
830
957
|
* Clears recordings, answers and seeded states; keeps the clock, timezone,
|
|
831
|
-
* solar and
|
|
958
|
+
* solar, credentials, series and view stubs.
|
|
832
959
|
*/
|
|
833
960
|
reset(): AppHarness;
|
|
834
961
|
}
|
package/src/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { fail } from './assert.js';
|
|
2
2
|
import { setFinding } from './findings.js';
|
|
3
|
+
import { publishSeries, renderSeries } from './series.js';
|
|
3
4
|
import { names } from './names.js';
|
|
4
5
|
import { conformance } from './conformance.js';
|
|
5
6
|
import { createHttpMock, withFetch } from './http.js';
|
|
@@ -42,6 +43,9 @@ export function harness({ manifest, integration } = {}) {
|
|
|
42
43
|
// publish of the same name overwrites rather than accumulates — h.states()
|
|
43
44
|
// is the current picture, not a log.
|
|
44
45
|
publishedStates: new Map(),
|
|
46
|
+
// The step function each series holds after what ctx.series.publish sent,
|
|
47
|
+
// keyed by local name — merged as the host merges it, not a log.
|
|
48
|
+
publishedSeries: new Map(),
|
|
45
49
|
// The last ctx.status.set report, or null before the first. One slot, not
|
|
46
50
|
// a list: the host replaces the report on every write.
|
|
47
51
|
status: null,
|
|
@@ -252,6 +256,12 @@ export function harness({ manifest, integration } = {}) {
|
|
|
252
256
|
findings: () => state.findings.map((f) => ({ ...f })),
|
|
253
257
|
/** Values ctx.states.publish has sent so far, keyed by local name. */
|
|
254
258
|
states: () => Object.fromEntries(state.publishedStates),
|
|
259
|
+
/**
|
|
260
|
+
* Each series ctx.series.publish has written, keyed by local name: the
|
|
261
|
+
* merged step function the host would hold, intervals sorted by from, with
|
|
262
|
+
* from/to as ISO UTC strings. A series never published is absent.
|
|
263
|
+
*/
|
|
264
|
+
series: () => renderSeries(state.publishedSeries),
|
|
255
265
|
/**
|
|
256
266
|
* The last report ctx.status.set sent, or null when the handler never
|
|
257
267
|
* reported — absence is a legitimate outcome, not an empty report.
|
|
@@ -288,6 +298,7 @@ export function harness({ manifest, integration } = {}) {
|
|
|
288
298
|
state.emitted.length = 0;
|
|
289
299
|
state.logs.length = 0;
|
|
290
300
|
state.publishedStates.clear();
|
|
301
|
+
state.publishedSeries.clear();
|
|
291
302
|
state.status = null;
|
|
292
303
|
state.reports.length = 0;
|
|
293
304
|
state.replies.length = 0;
|
|
@@ -499,6 +510,11 @@ function buildContext(manifest, state, webhooks) {
|
|
|
499
510
|
publishStates(manifest, state, values);
|
|
500
511
|
},
|
|
501
512
|
},
|
|
513
|
+
series: {
|
|
514
|
+
async publish(values, options) {
|
|
515
|
+
publishSeries(manifest, state.publishedSeries, values, options);
|
|
516
|
+
},
|
|
517
|
+
},
|
|
502
518
|
status: {
|
|
503
519
|
async set(report) {
|
|
504
520
|
setStatus(state, report);
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import { fail } from './assert.js';
|
|
2
|
+
import { jsonCopy } from './bindings.js';
|
|
3
|
+
import { goTime, parseTime } from './series.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A flow's or an app's `series` bindings, in process: ctx.series.<key>.get()
|
|
7
|
+
* answered from the test's fixture and ctx.series.<key>.view() from its stubs.
|
|
8
|
+
*
|
|
9
|
+
* get() copies the host (internal/executor/series.go's SeriesGet): the range
|
|
10
|
+
* bounds' refusals, word for word, and the overlap filter — an interval is in
|
|
11
|
+
* [from, to) unless it ends at or before from or starts at or after to. The
|
|
12
|
+
* instants come back as the host spells them, RFC 3339 in UTC with no trailing
|
|
13
|
+
* zero fraction. published_at, issued_at and covered_until are the fixture's
|
|
14
|
+
* metadata, or null: the harness has no store to stamp a publish and no clock
|
|
15
|
+
* it may read unasked.
|
|
16
|
+
*
|
|
17
|
+
* view() is stubbed, never evaluated: a view with a cron or solar edge in a
|
|
18
|
+
* site zone is the host's code, and a second implementation here would
|
|
19
|
+
* disagree with it the first time either changed. h.seriesView(key, spec,
|
|
20
|
+
* result) says what a view answers, matched by deep equality on the spec as it
|
|
21
|
+
* crosses the wire (after JSON), and an unstubbed view() fails naming it.
|
|
22
|
+
*
|
|
23
|
+
* The refusal for a key the manifest does not bind is ResolveSeriesBinding's,
|
|
24
|
+
* byte for byte (`this flow's` / `this app's`).
|
|
25
|
+
*/
|
|
26
|
+
export function createSeriesBindings(manifest, harness, { app = false } = {}) {
|
|
27
|
+
const bindings = manifest.series ?? {};
|
|
28
|
+
const harnessName = app ? 'appHarness' : 'flowHarness';
|
|
29
|
+
// key → { intervals: [{from, to, value}] in ms, published_at, issued_at, covered_until }
|
|
30
|
+
const fixtures = new Map();
|
|
31
|
+
// key → [{ spec (JSON text), result }]
|
|
32
|
+
const stubs = new Map();
|
|
33
|
+
|
|
34
|
+
function undeclared(name) {
|
|
35
|
+
const whose = app ? "this app's" : "this flow's";
|
|
36
|
+
const keys = Object.keys(bindings).sort();
|
|
37
|
+
const declared = keys.length
|
|
38
|
+
? keys.map((k) => JSON.stringify(k)).join(', ')
|
|
39
|
+
: 'none';
|
|
40
|
+
return `series binding ${JSON.stringify(name)} is not declared in ${whose} series; it declares ${declared}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function declaredKey(name, where) {
|
|
44
|
+
if (!Object.hasOwn(bindings, name)) fail(`${where} — ${undeclared(name)}`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const h = {
|
|
48
|
+
/**
|
|
49
|
+
* Sets what series.<key>.get() reads: an array of intervals
|
|
50
|
+
* `{from, to, value}` (Date or RFC 3339 bounds, sorted, non-overlapping),
|
|
51
|
+
* or `{intervals, published_at?, issued_at?, covered_until?}`. Replaces
|
|
52
|
+
* the key's fixture; kept across reset().
|
|
53
|
+
*/
|
|
54
|
+
series(key, fixture) {
|
|
55
|
+
declaredKey(key, `h.series('${key}', …)`);
|
|
56
|
+
fixtures.set(key, parseFixture(key, fixture));
|
|
57
|
+
return harness();
|
|
58
|
+
},
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Says what series.<key>.view(spec) answers: a SeriesViewResult (fields
|
|
62
|
+
* left out read false / null / []) or a function of the spec returning
|
|
63
|
+
* one. Matched by deep equality on the spec; a later stub for an equal
|
|
64
|
+
* spec replaces the earlier. Kept across reset().
|
|
65
|
+
*/
|
|
66
|
+
seriesView(key, spec, result) {
|
|
67
|
+
declaredKey(key, `h.seriesView('${key}', …)`);
|
|
68
|
+
const text = canonical(
|
|
69
|
+
spec ?? {},
|
|
70
|
+
`h.seriesView('${key}', …) — the spec`
|
|
71
|
+
);
|
|
72
|
+
const list = (stubs.get(key) ?? []).filter((s) => s.spec !== text);
|
|
73
|
+
list.push({ spec: text, result });
|
|
74
|
+
stubs.set(key, list);
|
|
75
|
+
return harness();
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/** ctx.series for one run; refuse is the run's recording fail(). */
|
|
80
|
+
function ctx(refuse) {
|
|
81
|
+
return new Proxy(
|
|
82
|
+
{},
|
|
83
|
+
{
|
|
84
|
+
get(_own, prop) {
|
|
85
|
+
if (typeof prop !== 'string') return undefined;
|
|
86
|
+
return handle(prop, refuse);
|
|
87
|
+
},
|
|
88
|
+
}
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function handle(binding, refuse) {
|
|
93
|
+
const q = JSON.stringify(binding);
|
|
94
|
+
return {
|
|
95
|
+
async get(range) {
|
|
96
|
+
if (!Object.hasOwn(bindings, binding)) refuse(undeclared(binding));
|
|
97
|
+
// seriesHandle in sdk/src/context.ts: a Date sent as its ISO string.
|
|
98
|
+
const fromText = boundText(range?.from);
|
|
99
|
+
const toText = boundText(range?.to);
|
|
100
|
+
const from = bound(binding, 'from', fromText, refuse);
|
|
101
|
+
const to = bound(binding, 'to', toText, refuse);
|
|
102
|
+
if (from !== null && to !== null && !(from < to)) {
|
|
103
|
+
refuse(`series ${q}: from must be before to`);
|
|
104
|
+
}
|
|
105
|
+
const fx = fixtures.get(binding);
|
|
106
|
+
if (!fx) {
|
|
107
|
+
refuse(
|
|
108
|
+
`series.${binding}.get() was called but the test gave no series — ` +
|
|
109
|
+
`h.series('${binding}', [{ from, to, value }, …]), or ${harnessName}({ series: { ${binding}: […] } })`
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
return {
|
|
113
|
+
series_id: bindings[binding].series ?? '',
|
|
114
|
+
published_at: fx.published_at,
|
|
115
|
+
issued_at: fx.issued_at,
|
|
116
|
+
covered_until: fx.covered_until,
|
|
117
|
+
intervals: fx.intervals
|
|
118
|
+
.filter(
|
|
119
|
+
(iv) =>
|
|
120
|
+
!(from !== null && iv.to <= from) &&
|
|
121
|
+
!(to !== null && iv.from >= to)
|
|
122
|
+
)
|
|
123
|
+
.map((iv) => ({
|
|
124
|
+
from: goTime(iv.from),
|
|
125
|
+
to: goTime(iv.to),
|
|
126
|
+
value: jsonCopy(iv.value),
|
|
127
|
+
})),
|
|
128
|
+
};
|
|
129
|
+
},
|
|
130
|
+
|
|
131
|
+
async view(spec) {
|
|
132
|
+
if (!Object.hasOwn(bindings, binding)) refuse(undeclared(binding));
|
|
133
|
+
const text = canonical(
|
|
134
|
+
spec ?? {},
|
|
135
|
+
`series.${binding}.view(…) — the spec`,
|
|
136
|
+
refuse
|
|
137
|
+
);
|
|
138
|
+
const stub = (stubs.get(binding) ?? []).find((s) => s.spec === text);
|
|
139
|
+
if (!stub) {
|
|
140
|
+
refuse(
|
|
141
|
+
`series.${binding}.view(${text}) was not stubbed — ` +
|
|
142
|
+
`h.seriesView('${binding}', ${text}, { available: true, value, … }). ` +
|
|
143
|
+
'The harness does not evaluate views; say what this one answers'
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
const r =
|
|
147
|
+
typeof stub.result === 'function'
|
|
148
|
+
? await stub.result(JSON.parse(text))
|
|
149
|
+
: stub.result;
|
|
150
|
+
const out =
|
|
151
|
+
jsonCopy(r ?? {}, `h.seriesView('${binding}', …) — the result`) ?? {};
|
|
152
|
+
// seriesHandle's defaults for a field the host sent as null.
|
|
153
|
+
return {
|
|
154
|
+
available: out.available ?? false,
|
|
155
|
+
value: out.value ?? null,
|
|
156
|
+
reason: out.reason ?? null,
|
|
157
|
+
until: out.until ?? null,
|
|
158
|
+
spans: out.spans ?? [],
|
|
159
|
+
};
|
|
160
|
+
},
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Applies the harness option `series`, through h.series. */
|
|
165
|
+
function init(series) {
|
|
166
|
+
for (const [key, fixture] of Object.entries(series ?? {})) {
|
|
167
|
+
h.series(key, fixture);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return { h, ctx, init };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function boundText(v) {
|
|
175
|
+
if (v === undefined) return '';
|
|
176
|
+
return v instanceof Date ? v.toISOString() : v;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// seriesBound in internal/executor/series.go: empty is unbounded.
|
|
180
|
+
function bound(binding, field, text, refuse) {
|
|
181
|
+
if (text === '') return null;
|
|
182
|
+
const ms = parseTime(text);
|
|
183
|
+
if (ms === null) {
|
|
184
|
+
refuse(
|
|
185
|
+
`series ${JSON.stringify(binding)}: ${field} ${JSON.stringify(String(text))} is not an RFC 3339 instant`
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
return ms;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* The spec as the wire carries it, with object keys sorted so two specs equal
|
|
193
|
+
* by value compare equal as text.
|
|
194
|
+
*/
|
|
195
|
+
function canonical(spec, what, refuse = fail) {
|
|
196
|
+
const copy = jsonCopy(spec, what, refuse);
|
|
197
|
+
return JSON.stringify(sortKeys(copy));
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function sortKeys(v) {
|
|
201
|
+
if (Array.isArray(v)) return v.map(sortKeys);
|
|
202
|
+
if (v && typeof v === 'object') {
|
|
203
|
+
return Object.fromEntries(
|
|
204
|
+
Object.keys(v)
|
|
205
|
+
.sort()
|
|
206
|
+
.map((k) => [k, sortKeys(v[k])])
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
return v;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const FIXTURE_META = ['published_at', 'issued_at', 'covered_until'];
|
|
213
|
+
|
|
214
|
+
function parseFixture(key, fixture) {
|
|
215
|
+
const where = `h.series('${key}', …)`;
|
|
216
|
+
let list = fixture;
|
|
217
|
+
const meta = { published_at: null, issued_at: null, covered_until: null };
|
|
218
|
+
if (!Array.isArray(fixture)) {
|
|
219
|
+
if (!fixture || typeof fixture !== 'object') {
|
|
220
|
+
fail(
|
|
221
|
+
`${where} takes an array of { from, to, value }, or { intervals, published_at?, issued_at?, covered_until? }`
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
for (const k of Object.keys(fixture)) {
|
|
225
|
+
if (k !== 'intervals' && !FIXTURE_META.includes(k)) {
|
|
226
|
+
fail(
|
|
227
|
+
`${where} — "${k}" is not a fixture field (have: intervals, ${FIXTURE_META.join(', ')})`
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
for (const k of FIXTURE_META) {
|
|
232
|
+
if (fixture[k] === undefined || fixture[k] === null) continue;
|
|
233
|
+
const ms = parseTime(fixture[k]);
|
|
234
|
+
if (ms === null) fail(`${where} — ${k} is not an RFC 3339 timestamp`);
|
|
235
|
+
meta[k] = goTime(ms);
|
|
236
|
+
}
|
|
237
|
+
list = fixture.intervals ?? [];
|
|
238
|
+
}
|
|
239
|
+
if (!Array.isArray(list)) fail(`${where} — intervals must be an array`);
|
|
240
|
+
const intervals = list.map((iv, i) => {
|
|
241
|
+
const from = parseTime(iv?.from);
|
|
242
|
+
if (from === null) {
|
|
243
|
+
fail(`${where} — interval ${i}: from is not an RFC 3339 timestamp`);
|
|
244
|
+
}
|
|
245
|
+
const to = parseTime(iv?.to);
|
|
246
|
+
if (to === null) {
|
|
247
|
+
fail(`${where} — interval ${i}: to is not an RFC 3339 timestamp`);
|
|
248
|
+
}
|
|
249
|
+
if (!(from < to)) fail(`${where} — interval ${i}: from must be before to`);
|
|
250
|
+
return {
|
|
251
|
+
from,
|
|
252
|
+
to,
|
|
253
|
+
value: jsonCopy(iv.value, `${where} — interval ${i}'s value`),
|
|
254
|
+
};
|
|
255
|
+
});
|
|
256
|
+
intervals.forEach((iv, i) => {
|
|
257
|
+
if (i > 0 && iv.from < intervals[i - 1].to) {
|
|
258
|
+
fail(`${where} — interval ${i} starts before interval ${i - 1} ends`);
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
return { ...meta, intervals };
|
|
262
|
+
}
|
package/src/series.js
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { fail } from './assert.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ctx.series.publish, in process: the refusals and the span arithmetic the
|
|
5
|
+
* host applies (internal/integrations/external's seriesBatch and
|
|
6
|
+
* internal/series.Store.Publish), copied rather than shared because the
|
|
7
|
+
* testkit cannot import the Go side. Every refusal is the host's sentence,
|
|
8
|
+
* byte for byte, so a test that expects one sees what production throws.
|
|
9
|
+
*
|
|
10
|
+
* `published` maps a local name to its step function: intervals
|
|
11
|
+
* `{from, to, value}` with from/to in epoch milliseconds, sorted and
|
|
12
|
+
* non-overlapping. The whole batch is validated before any series changes.
|
|
13
|
+
*
|
|
14
|
+
* Not copied: value validation against the declaration (its wording names Go
|
|
15
|
+
* types), the interval-count limits, and retention — the harness has no clock
|
|
16
|
+
* that moves, so nothing ages out.
|
|
17
|
+
*/
|
|
18
|
+
export function publishSeries(manifest, published, values, options = {}) {
|
|
19
|
+
const declared = manifest.series ?? {};
|
|
20
|
+
const install = manifest.id ?? manifest.name;
|
|
21
|
+
|
|
22
|
+
let issuedAt = null;
|
|
23
|
+
if (options?.issuedAt !== undefined) {
|
|
24
|
+
issuedAt = parseTime(options.issuedAt);
|
|
25
|
+
if (issuedAt === null) fail('issued_at is not an RFC 3339 timestamp');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// The adapter: names, then timestamps, in sorted name order.
|
|
29
|
+
const batch = new Map();
|
|
30
|
+
const entries = Object.entries(values ?? {})
|
|
31
|
+
.filter(([, data]) => data !== undefined)
|
|
32
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
33
|
+
for (const [name, data] of entries) {
|
|
34
|
+
if (!Object.prototype.hasOwnProperty.call(declared, name)) {
|
|
35
|
+
fail(`series "${name}" is not declared by ${install}`);
|
|
36
|
+
}
|
|
37
|
+
const intervals = (data.intervals ?? []).map((iv, i) => {
|
|
38
|
+
const from = parseTime(iv.from);
|
|
39
|
+
if (from === null) {
|
|
40
|
+
fail(
|
|
41
|
+
`series "${name}": interval ${i}: from is not an RFC 3339 timestamp`
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
const to = parseTime(iv.to);
|
|
45
|
+
if (to === null) {
|
|
46
|
+
fail(
|
|
47
|
+
`series "${name}": interval ${i}: to is not an RFC 3339 timestamp`
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
return { from, to, value: iv.value };
|
|
51
|
+
});
|
|
52
|
+
let span = null;
|
|
53
|
+
if (data.span !== undefined) {
|
|
54
|
+
const from = parseTime(data.span.from);
|
|
55
|
+
if (from === null) {
|
|
56
|
+
fail(`series "${name}": span: from is not an RFC 3339 timestamp`);
|
|
57
|
+
}
|
|
58
|
+
const to = parseTime(data.span.to);
|
|
59
|
+
if (to === null) {
|
|
60
|
+
fail(`series "${name}": span: to is not an RFC 3339 timestamp`);
|
|
61
|
+
}
|
|
62
|
+
span = { from, to };
|
|
63
|
+
}
|
|
64
|
+
batch.set(name, { intervals, span });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// The store: the forecast rule over the whole batch first, then each series.
|
|
68
|
+
for (const name of batch.keys()) {
|
|
69
|
+
if (declared[name].forecast === true && issuedAt === null) {
|
|
70
|
+
fail(`series "${name}" is a forecast: the publish needs issuedAt`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const next = new Map();
|
|
74
|
+
for (const [name, { intervals, span: explicit }] of batch) {
|
|
75
|
+
intervals.forEach((iv, i) => {
|
|
76
|
+
if (!(iv.from < iv.to)) {
|
|
77
|
+
fail(`series "${name}": interval ${i}: from must be before to`);
|
|
78
|
+
}
|
|
79
|
+
if (i > 0 && iv.from < intervals[i - 1].to) {
|
|
80
|
+
fail(
|
|
81
|
+
`series "${name}": interval ${i} starts before interval ${i - 1} ends`
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
let span;
|
|
86
|
+
if (explicit) {
|
|
87
|
+
if (!(explicit.from < explicit.to)) {
|
|
88
|
+
fail(`series "${name}": span: from must be before to`);
|
|
89
|
+
}
|
|
90
|
+
intervals.forEach((iv, i) => {
|
|
91
|
+
if (iv.from < explicit.from || iv.to > explicit.to) {
|
|
92
|
+
fail(
|
|
93
|
+
`series "${name}": interval ${i} is outside the span ` +
|
|
94
|
+
`${goTime(explicit.from)} → ${goTime(explicit.to)}`
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
span = explicit;
|
|
99
|
+
} else if (intervals.length === 0) {
|
|
100
|
+
fail(`series "${name}": no intervals and no span: nothing to replace`);
|
|
101
|
+
} else if (declared[name].forecast === true) {
|
|
102
|
+
span = { from: intervals[0].from, to: Infinity };
|
|
103
|
+
} else {
|
|
104
|
+
span = {
|
|
105
|
+
from: intervals[0].from,
|
|
106
|
+
to: intervals[intervals.length - 1].to,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
next.set(name, replace(published.get(name) ?? [], span, intervals));
|
|
110
|
+
}
|
|
111
|
+
for (const [name, steps] of next) published.set(name, steps);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* replace is Store.Publish's: everything outside span kept, an interval
|
|
116
|
+
* straddling an edge cut there keeping its outside part, then the incoming
|
|
117
|
+
* intervals, sorted by from. `span.to` is Infinity for an unbounded span.
|
|
118
|
+
*/
|
|
119
|
+
export function replace(steps, span, incoming) {
|
|
120
|
+
const out = [];
|
|
121
|
+
for (const iv of steps) {
|
|
122
|
+
if (iv.to <= span.from || iv.from >= span.to) {
|
|
123
|
+
out.push(iv);
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
if (iv.from < span.from)
|
|
127
|
+
out.push({ from: iv.from, to: span.from, value: iv.value });
|
|
128
|
+
if (iv.to > span.to)
|
|
129
|
+
out.push({ from: span.to, to: iv.to, value: iv.value });
|
|
130
|
+
}
|
|
131
|
+
out.push(...incoming.map((iv) => ({ ...iv })));
|
|
132
|
+
out.sort((a, b) => a.from - b.from);
|
|
133
|
+
return out;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** A step function as h.series() shows it: ISO UTC strings, copies. */
|
|
137
|
+
export function renderSeries(published) {
|
|
138
|
+
const out = {};
|
|
139
|
+
for (const [name, steps] of published) {
|
|
140
|
+
out[name] = steps.map((iv) => ({
|
|
141
|
+
from: new Date(iv.from).toISOString(),
|
|
142
|
+
to: new Date(iv.to).toISOString(),
|
|
143
|
+
value: iv.value,
|
|
144
|
+
}));
|
|
145
|
+
}
|
|
146
|
+
return out;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// What Go's time.Parse(time.RFC3339Nano, …) accepts, near enough: a full date,
|
|
150
|
+
// a 'T', a time with optional fraction, and 'Z' or a numeric offset.
|
|
151
|
+
const RFC3339 =
|
|
152
|
+
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/;
|
|
153
|
+
|
|
154
|
+
/** A Date or RFC 3339 string → epoch ms, or null when it is neither. */
|
|
155
|
+
export function parseTime(t) {
|
|
156
|
+
if (t instanceof Date) {
|
|
157
|
+
const ms = t.getTime();
|
|
158
|
+
return Number.isNaN(ms) ? null : ms;
|
|
159
|
+
}
|
|
160
|
+
if (typeof t !== 'string' || !RFC3339.test(t)) return null;
|
|
161
|
+
const ms = Date.parse(t);
|
|
162
|
+
return Number.isNaN(ms) ? null : ms;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** An instant as Go's RFC3339Nano renders it in UTC: no trailing zero fraction. */
|
|
166
|
+
export function goTime(ms) {
|
|
167
|
+
return new Date(ms).toISOString().replace(/\.?0+Z$/, 'Z');
|
|
168
|
+
}
|
package/src/tasks.js
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { jsonCopy } from './bindings.js';
|
|
2
|
+
import { names } from './names.js';
|
|
3
|
+
|
|
4
|
+
// The tasks half of the flow harness: ctx.tasks.<binding>.open / snooze /
|
|
5
|
+
// complete / cancel, and h.tasks(), which reads back what was sent.
|
|
6
|
+
//
|
|
7
|
+
// There is no task here, so nothing is opened: open answers as the host would
|
|
8
|
+
// for work nobody has opened yet — `{opened: true, key, rid: 'test-rid-<n>',
|
|
9
|
+
// url: 'test:ping'}` the first time a key is opened in a run, and `opened:
|
|
10
|
+
// false` with the same rid after — and the other verbs answer nothing, as
|
|
11
|
+
// they do on the host, which makes them idempotent on key. The rid counts
|
|
12
|
+
// opens that opened something, from 1, per run.
|
|
13
|
+
|
|
14
|
+
const VERBS = ['open', 'snooze', 'complete', 'cancel'];
|
|
15
|
+
// A flow's handle also waits: untilDone, on the run's handle table.
|
|
16
|
+
const FLOW_VERBS = [...VERBS, 'untilDone'];
|
|
17
|
+
|
|
18
|
+
/** An instant as the SDK sends it: a Date as its ISO string. */
|
|
19
|
+
const instant = (t) => (t instanceof Date ? t.toISOString() : t);
|
|
20
|
+
|
|
21
|
+
export function createTasks(manifest) {
|
|
22
|
+
const tasks = manifest.tasks ?? {};
|
|
23
|
+
const records = [];
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* One run's ctx.tasks. Built per run: which keys are open is per run.
|
|
27
|
+
* handles is the run's handle table, which gives every binding untilDone.
|
|
28
|
+
*/
|
|
29
|
+
function ctx(refuse, handles) {
|
|
30
|
+
const opened = new Map(); // key → rid
|
|
31
|
+
const handle = (binding) => {
|
|
32
|
+
const command = (verb, fields) => {
|
|
33
|
+
const call = { binding, verb };
|
|
34
|
+
for (const [k, v] of Object.entries(fields)) {
|
|
35
|
+
if (v !== undefined) call[k] = v;
|
|
36
|
+
}
|
|
37
|
+
if (Object.hasOwn(call, 'data')) {
|
|
38
|
+
call.data = jsonCopy(
|
|
39
|
+
call.data,
|
|
40
|
+
`tasks.${binding}.${verb}() data`,
|
|
41
|
+
refuse
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
records.push(call);
|
|
45
|
+
return call;
|
|
46
|
+
};
|
|
47
|
+
const h = {
|
|
48
|
+
async open(opts) {
|
|
49
|
+
const call = command('open', {
|
|
50
|
+
key: opts?.key,
|
|
51
|
+
at: instant(opts?.at),
|
|
52
|
+
within: opts?.within,
|
|
53
|
+
reason: opts?.reason,
|
|
54
|
+
data: opts?.data,
|
|
55
|
+
});
|
|
56
|
+
const key = call.key ?? '';
|
|
57
|
+
const first = !opened.has(key);
|
|
58
|
+
if (first) opened.set(key, `test-rid-${opened.size + 1}`);
|
|
59
|
+
return { opened: first, key, rid: opened.get(key), url: 'test:ping' };
|
|
60
|
+
},
|
|
61
|
+
async snooze(opts) {
|
|
62
|
+
// The host's refusal, worded as the host words it.
|
|
63
|
+
if (opts?.until === undefined) {
|
|
64
|
+
refuse(`tasks.${binding}.snooze(): until is required`);
|
|
65
|
+
}
|
|
66
|
+
command('snooze', { key: opts?.key, until: instant(opts.until) });
|
|
67
|
+
},
|
|
68
|
+
async complete(opts) {
|
|
69
|
+
command('complete', {
|
|
70
|
+
key: opts?.key,
|
|
71
|
+
outcome: opts?.outcome,
|
|
72
|
+
note: opts?.note,
|
|
73
|
+
});
|
|
74
|
+
},
|
|
75
|
+
async cancel(opts) {
|
|
76
|
+
command('cancel', { key: opts?.key, reason: opts?.reason });
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
if (handles) {
|
|
80
|
+
h.untilDone = (...args) =>
|
|
81
|
+
handles.taskHandle(`tasks.${binding}.untilDone`, args);
|
|
82
|
+
}
|
|
83
|
+
const verbs = handles ? FLOW_VERBS : VERBS;
|
|
84
|
+
return new Proxy(h, {
|
|
85
|
+
get(own, prop) {
|
|
86
|
+
if (typeof prop !== 'string' || prop === 'then') return undefined;
|
|
87
|
+
if (!verbs.includes(prop)) {
|
|
88
|
+
refuse(
|
|
89
|
+
`ctx.tasks.${binding}.${prop} — not a task verb (have: ${verbs.join(', ')})`
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
return own[prop];
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
};
|
|
96
|
+
return new Proxy(
|
|
97
|
+
{},
|
|
98
|
+
{
|
|
99
|
+
get(_own, prop) {
|
|
100
|
+
if (typeof prop !== 'string' || prop === 'then') return undefined;
|
|
101
|
+
if (!Object.hasOwn(tasks, prop)) {
|
|
102
|
+
refuse(
|
|
103
|
+
`ctx.tasks.${prop} — "${prop}" is not declared in manifest.json (have: ${names(tasks)})`
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
return handle(prop);
|
|
107
|
+
},
|
|
108
|
+
}
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
ctx,
|
|
114
|
+
h: {
|
|
115
|
+
/**
|
|
116
|
+
* Every task command, in order: `{binding, verb, key?, at?, within?,
|
|
117
|
+
* until?, reason?, data?, outcome?, note?}`, the options as the SDK
|
|
118
|
+
* sends them (a Date as its ISO string, absent ones left out).
|
|
119
|
+
*/
|
|
120
|
+
tasks: () => records.map((r) => jsonCopy(r, 'a task command')),
|
|
121
|
+
},
|
|
122
|
+
reset() {
|
|
123
|
+
records.length = 0;
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
}
|