@junoflow/testkit 0.13.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 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.logs()` | `[{ level, message, data }]` |
307
- | `h.conformance()` | triggers declared vs implemented, both ways; `[]` when they agree |
308
- | `h.reset()` | clear recordings, answers, seeds and scoped state; keep clock, timezone, solar, credentials, webhook URLs |
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.logs()` | `[{ level, message, data }]` |
510
- | `h.conformance()` | handlers declared vs implemented, both ways; `[]` when they agree |
511
- | `h.reset()` | clear recordings, answers and seeds; keep clock, timezone, solar, credentials |
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`. `ctx.signal` is
520
- never aborted. The binding handles, answers and clock are the ones `flowHarness`
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@junoflow/testkit",
3
- "version": "0.13.0",
3
+ "version": "0.15.0",
4
4
  "description": "Test harnesses for Juno integrations, flows and apps",
5
5
  "license": "MIT",
6
6
  "repository": {
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({ manifest, app, now, timezone, solar } = {}) {
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 credentials.
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 = ['by', 'answer', 'channel', 'text', 'interpreted'];
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 `'unanswered'`.
221
- * `channel: 'page'` is an answer from the answer page; `text` a written
222
- * reply as written, and `interpreted: true` one the site's LLM mapped.
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 and
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({ manifest, flow, now, timezone, solar } = {}) {
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 webhook URLs.
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.solar(). */
405
- solar?: SolarAnswer;
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` when omitted.
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 interpreted passed through when given), `at` being
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 webhook URLs.
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 credentials.
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
+ }