@junoflow/testkit 0.3.0 → 0.9.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
@@ -1,7 +1,8 @@
1
1
  # @junoflow/testkit
2
2
 
3
- Test harness for [Juno](https://github.com/boivie/juno) integrations. It runs
4
- your handlers with the same `ctx` the sandbox builds, but with a different
3
+ Test harnesses for [Juno](https://github.com/boivie/juno) integrations
4
+ (`harness`), flows (`flowHarness`) and apps (`appHarness`). Each runs your
5
+ handlers with the same `ctx` the sandbox builds, but with a different
5
6
  implementation for tests.
6
7
 
7
8
  ```bash
@@ -190,3 +191,221 @@ hook reports through ctx and returns nothing" is what production says too.
190
191
  with one empty, so a handler never sees one — a test that skipped
191
192
  `h.credentials(...)` would be green for a state production forbids, with
192
193
  `undefined` sitting where the generated types promise a `string`.
194
+
195
+ ## `flowHarness`
196
+
197
+ The flow half. It builds the `ctx` the flow sandbox builds from the flow's own
198
+ manifest, runs one trigger handler against it, and answers every outward call
199
+ from what the test supplies. A flow working copy's `npm test` runs it under
200
+ vitest, and `juno flow init` writes a starter `test/flow.test.ts` for a version
201
+ that stores no tests.
202
+
203
+ ```ts
204
+ import { beforeEach, expect, it } from 'vitest';
205
+ import { flowHarness } from '@junoflow/testkit';
206
+ import manifest from '../manifest.json';
207
+ import flow from '../src/flow';
208
+
209
+ const h = flowHarness({ manifest, flow });
210
+ beforeEach(() => h.reset());
211
+
212
+ it('greets when someone is home', async () => {
213
+ h.now('2026-10-04T18:42:00').timezone('Europe/Stockholm');
214
+ h.states({ houseMode: 'home' });
215
+ h.targets.answer('kitchenSpeaker.ttsCloudSay', undefined);
216
+
217
+ const status = await h.trigger('goodMorning', {
218
+ event: {
219
+ entity_id: 'input_boolean.good_morning',
220
+ new_state: { state: 'on' },
221
+ },
222
+ });
223
+
224
+ expect(status).toEqual({ kind: 'ok', text: 'greeted' });
225
+ expect(h.targets.calls()).toEqual([
226
+ {
227
+ target: 'kitchenSpeaker',
228
+ action: 'ttsCloudSay',
229
+ params: { message: 'Good morning' },
230
+ },
231
+ ]);
232
+ });
233
+ ```
234
+
235
+ `flowHarness({ manifest, flow, now?, timezone?, solar? })` returns:
236
+
237
+ | Member | Does |
238
+ | ---------------------------------------------- | ------------------------------------------------------------------------------------------- |
239
+ | `h.trigger(name, { event })` | run a declared trigger; resolves to the handler's return value |
240
+ | `h.scenario(s)` | set the world from one JSON object (below) |
241
+ | `h.now(iso)` / `h.timezone(tz)` / `h.solar(v)` | what `ctx.now()` and `ctx.solar()` answer; the timezone is `UTC` unless set |
242
+ | `h.states(values)` / `h.state(name)` | seed a `read` or `read_write` binding; read its current value |
243
+ | `h.stateCalls()` | `set`, `claim`, `release`, `signal`, `retract`, as `{binding, verb, value?, opts?}` |
244
+ | `h.targets.answer(key, a)` / `.calls()` | answer `binding.method` with a value or a function of the params; the calls made |
245
+ | `h.controls.answer(key, a)` / `.calls()` | answer `binding.verb`; the commands made |
246
+ | `h.llm(id, answer)` | answer `step.llm(id, …)`; `id#N` answers only that occurrence |
247
+ | `h.steps()` | every `step.run` and `step.llm`, as `{id, output}`; repeats are `id`, `id#1`, `id#2` |
248
+ | `h.findings()` | `{slug, key, severity, text, standing}`, collapsed by `(slug, key)` as the host does |
249
+ | `h.credentials(values)` | set `ctx.credentials` by declared id |
250
+ | `h.logs()` | `[{ level, message, data }]` |
251
+ | `h.conformance()` | triggers declared vs implemented, both ways; `[]` when they agree |
252
+ | `h.reset()` | clear recordings, answers, seeds and scoped state; keep clock, timezone, solar, credentials |
253
+
254
+ `h.trigger(name, { event })` sets `event.trigger` and spreads the payload you
255
+ give onto the event, as the sandbox does. An array (a `batch` run) stays under
256
+ `event.payload`. Nothing else is filled in, and the trigger's filter and
257
+ modifiers are not evaluated. `h.now(iso)` without an offset is wall time in
258
+ `h.timezone()`; a wall time the timezone skips or repeats is refused, so give
259
+ one near a DST change an offset.
260
+
261
+ ### Scenarios
262
+
263
+ `h.scenario(s)` applies a whole world at once, through the setters above and
264
+ their checks:
265
+
266
+ ```ts
267
+ import fixture from './fixtures/porch-42.json';
268
+
269
+ it('replays run 42', async () => {
270
+ h.scenario(fixture);
271
+ const status = await h.trigger(fixture.trigger);
272
+ expect(status).toEqual(fixture.expect.status);
273
+ expect(h.steps()).toEqual(fixture.expect.steps);
274
+ expect(h.targets.calls()).toEqual(fixture.expect.calls);
275
+ });
276
+ ```
277
+
278
+ Its keys are `now`, `timezone`, `solar`, `states`, `credentials`, `targets`
279
+ (`"binding.method": answer`), `controls` (`"binding.verb": answer`), `llm`
280
+ (`"stepId": answer`) and `event`, which an `h.trigger(name)` with no `{ event }`
281
+ runs with; an explicit event, even `null`, wins. `trigger`, `expect` and
282
+ `flow_version` are carried and not applied. A key left out sets nothing; an
283
+ unknown key fails. The `Scenario` type is exported.
284
+
285
+ Two answer forms are reserved, in a scenario and in `h.targets.answer`,
286
+ `h.controls.answer` and `h.llm` alike:
287
+
288
+ - `{ "$replay": [a, b, …] }` answers calls in order; one too many fails with
289
+ `porchLight.getState answered 2 calls in the scenario; this was call 3`;
290
+ - `{ "$error": "message" }` makes the call throw `new Error("message")`, which,
291
+ unlike a refusal, the handler may catch. A `$replay` item may be an `$error`.
292
+
293
+ `juno flow fixture --run <seq>` writes a scenario from a recorded run, with
294
+ `expect` filled in from what the run did; see the flow testing docs ("Replaying
295
+ a run").
296
+
297
+ ### What fails
298
+
299
+ Strict where a permissive double would pass a broken flow. Each of these fails
300
+ the test, naming what was asked for and how to supply it:
301
+
302
+ - a trigger, state binding, target, target method, control, finding or
303
+ credential the manifest does not declare, in the handler or in the `h.*` call
304
+ that seeds or answers it;
305
+ - a state verb the binding was not granted (`set` on a `read` binding, `get` on
306
+ a slot), and `get` on a binding the test did not seed;
307
+ - `ctx.sleep(…)` and `ctx.waitForEvent(…)`:
308
+ `a handler cannot suspend in a test: sleep("5m")`;
309
+ - `ctx.now()`, `ctx.solar()`, a target call, a control command or a `step.llm`
310
+ the test did not answer, e.g.
311
+ `target call kitchenSpeaker.ttsCloudSay was not answered — h.targets.answer('kitchenSpeaker.ttsCloudSay', result)`.
312
+ An action with no result is answered with `undefined`, explicitly.
313
+
314
+ A refusal the handler catches still fails the test. Answers and state values
315
+ cross a JSON round trip, as on the wire.
316
+
317
+ Two things are not modelled: `claim` always resolves `{ status: 'winning' }`,
318
+ because the layer stack is the server's, and scoped state cannot be seeded from
319
+ a test.
320
+
321
+ ## `appHarness`
322
+
323
+ The app half. It builds the `ctx` the app sandbox builds from the app's own
324
+ manifest and calls one handler with it, as the page's
325
+ `juno.handlers.<name>(input)` does. An app working copy's `npm test` runs it
326
+ under vitest, and `juno app init` writes a starter `test/app.test.ts` for a
327
+ version that stores no tests.
328
+
329
+ ```ts
330
+ import { beforeEach, expect, it } from 'vitest';
331
+ import { appHarness } from '@junoflow/testkit';
332
+ import manifest from '../manifest.json';
333
+ import app from '../src/app';
334
+
335
+ const h = appHarness({ manifest, app });
336
+ beforeEach(() => h.reset());
337
+
338
+ it('rates a meal', async () => {
339
+ h.now('2026-10-04T18:42:00').timezone('Europe/Stockholm');
340
+ expect(await h.call('rate', { meal: 'tacos', stars: 5 })).toEqual({
341
+ saved: true,
342
+ });
343
+ expect(h.events()).toEqual([
344
+ { event: 'rated', payload: { meal: 'tacos', stars: 5 } },
345
+ ]);
346
+ });
347
+
348
+ it('a read-only handler cannot claim', async () => {
349
+ await expect(h.call('peek', {})).rejects.toThrow(
350
+ 'read-only handler "peek" cannot claim states'
351
+ );
352
+ });
353
+ ```
354
+
355
+ `appHarness({ manifest, app, now?, timezone?, solar? })` returns:
356
+
357
+ | Member | Does |
358
+ | ---------------------------------------------- | -------------------------------------------------------------------------------- |
359
+ | `h.call(name, input?)` | call a declared handler; resolves to its result after a JSON round trip |
360
+ | `h.events()` | every emit that passed, as `{event, payload}`, in emit order |
361
+ | `h.scenario(s)` | set the world from one JSON object, as `flowHarness`'s |
362
+ | `h.now(iso)` / `h.timezone(tz)` / `h.solar(v)` | what `ctx.now()` and `ctx.solar()` answer; the timezone is `UTC` unless set |
363
+ | `h.states(values)` | seed a `read` binding |
364
+ | `h.stateCalls()` | `claim` and `release`, as `{binding, verb, value?, opts?}` |
365
+ | `h.targets.answer(key, a)` / `.calls()` | answer `binding.method` with a value or a function of the params; the calls made |
366
+ | `h.controls.answer(key, a)` / `.calls()` | answer `binding.verb`; the commands made |
367
+ | `h.credentials(values)` | set `ctx.credentials` by declared id |
368
+ | `h.logs()` | `[{ level, message, data }]` |
369
+ | `h.conformance()` | handlers declared vs implemented, both ways; `[]` when they agree |
370
+ | `h.reset()` | clear recordings, answers and seeds; keep clock, timezone, solar, credentials |
371
+
372
+ `h.scenario(s)` takes the flow scenario's keys except `llm` and `flow_version`,
373
+ which it refuses, and ignores `trigger`, `event` and `expect`, so one scenario
374
+ can serve both. `$replay` and `$error` answer targets and controls as they do in
375
+ a flow test.
376
+
377
+ The handler's `ctx` is the app sandbox's: `log`, `now`, `solar`, `signal`,
378
+ `states`, `controls`, `targets`, `credentials` and `events`. `ctx.signal` is
379
+ never aborted. The binding handles, answers and clock are the ones `flowHarness`
380
+ uses, with the same strictness.
381
+
382
+ ### What fails
383
+
384
+ Everything `flowHarness` refuses for a binding, an answer or the clock, plus
385
+ what the host refuses on a handler call, in the host's words:
386
+
387
+ - a handler the manifest does not declare:
388
+ `app "meal-probe" has no handler "nope" (have: peek, rate)`;
389
+ - an event the manifest does not declare:
390
+ `event "x" not declared in app "meal-probe" (have: rated)`, or
391
+ `(it declares no events)`;
392
+ - a payload that fails the event's `payload` schema:
393
+ `event "rated" payload invalid:` followed by one line per problem;
394
+ - in a handler declared `read_only: true`, a `claim`, a `release`, a control
395
+ verb or an emit: `read-only handler "peek" cannot claim states`,
396
+ `… cannot release states`, `… cannot command controls`,
397
+ `… cannot emit events`;
398
+ - in a `read_only` handler, a call on a `homeassistant` or `mqtt` target:
399
+ `read-only handler "peek" cannot invoke target "kitchenSpeaker": a built-in homeassistant target is never read-only`.
400
+
401
+ The input must be JSON-serialisable, and an omitted or `null` input arrives as
402
+ `{}`. The result comes back as the page receives it: a `Date` becomes a string
403
+ and an `undefined` field disappears. Neither is checked against a schema,
404
+ because the host checks neither: a handler's input and output are its
405
+ TypeScript.
406
+
407
+ One host check is not made. A `read_only` handler may call an integration action
408
+ only when the integration's manifest declares that action `read_only`, and the
409
+ harness does not have that manifest. It allows every integration action from a
410
+ `read_only` handler; the host refuses one that is not read-only, so try the
411
+ handler against a draft with `npm run check`.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@junoflow/testkit",
3
- "version": "0.3.0",
4
- "description": "Test harness for Juno integrations",
3
+ "version": "0.9.0",
4
+ "description": "Test harnesses for Juno integrations, flows and apps",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
package/src/app.js ADDED
@@ -0,0 +1,274 @@
1
+ import { fail } from './assert.js';
2
+ import { createBindings, createWorld, jsonCopy } from './bindings.js';
3
+ import { applyScenario } from './scenario.js';
4
+ import { compile, describeErrors } from './schema.js';
5
+
6
+ /** The only key an app's default export carries (BaseApp in app-api.d.ts). */
7
+ const APP_KEYS = ['handlers'];
8
+
9
+ /**
10
+ * appHarness({ manifest, app }) builds the ctx the app shim builds
11
+ * (sdk/src/app-entrypoint.ts), in process, and calls one handler with it.
12
+ *
13
+ * It refuses what the host refuses on a handler call, in the host's words:
14
+ * the invoker's undeclared handler and event checks and the event's payload
15
+ * schema (internal/apps/invoker.go), the read_only refusals
16
+ * (internal/executor/inprocess.go) and the broker's refusal of a built-in
17
+ * target (internal/broker/broker.go). It checks nothing the host does not —
18
+ * a handler's input and output are its TypeScript, never a schema.
19
+ *
20
+ * And, like flowHarness, it is strict where production asks the world: the
21
+ * clock, the sun, a target call and a control command fail unless the test
22
+ * answered them.
23
+ */
24
+ export function appHarness({ manifest, app, now, timezone, solar } = {}) {
25
+ if (!manifest || typeof manifest !== 'object') {
26
+ fail(
27
+ "appHarness({ manifest, app }): manifest is required — import manifest from '../manifest.json'"
28
+ );
29
+ }
30
+ if (!app || typeof app !== 'object') {
31
+ fail(
32
+ "appHarness({ manifest, app }): app is required — import app from '../src/app'"
33
+ );
34
+ }
35
+ const handlers = manifest.handlers ?? {};
36
+ const events = manifest.events ?? {};
37
+ const appID = manifest.id ?? '';
38
+
39
+ const world = createWorld('appHarness', () => h);
40
+ const bindings = createBindings(manifest, () => h, {
41
+ statesRoot: 'states',
42
+ scoped: false,
43
+ });
44
+
45
+ // What handlers did. Cleared by reset().
46
+ const rec = { logs: [], events: [] };
47
+
48
+ const h = {
49
+ /**
50
+ * Calls a declared handler as the page's juno.handlers.<name>(input)
51
+ * does, and resolves to what the page would receive: the result after a
52
+ * JSON round trip.
53
+ */
54
+ async call(name, input) {
55
+ if (!Object.hasOwn(handlers, name)) {
56
+ // invoker.go, Invoker.Call: a name off the URL the manifest does not
57
+ // declare.
58
+ fail(
59
+ `app ${JSON.stringify(appID)} has no handler ${JSON.stringify(name)} (have: ${sorted(handlers)})`
60
+ );
61
+ }
62
+ const fns = app.handlers;
63
+ if (typeof fns !== 'object' || fns === null) {
64
+ fail('the app\'s default export must have a "handlers" object');
65
+ }
66
+ const handler = fns[name];
67
+ if (typeof handler !== 'function') {
68
+ // app-entrypoint.ts's refusal, for a handler declared and not
69
+ // implemented.
70
+ fail(
71
+ `no handler ${JSON.stringify(name)} (have: ${Object.keys(fns).join(', ') || '(none)'})`
72
+ );
73
+ }
74
+
75
+ // The body is JSON, and an empty one is {}; the shim also reads a null
76
+ // input as {}.
77
+ const arrived =
78
+ jsonCopy(input, `h.call(${JSON.stringify(name)}, …) — the input`) ?? {};
79
+
80
+ const run = { failures: [] };
81
+ const ctx = buildContext(run, name, handlers[name]?.read_only === true);
82
+
83
+ let result;
84
+ try {
85
+ result = await handler(ctx, arrived);
86
+ } catch (err) {
87
+ // A refusal the handler caught and turned into something else is
88
+ // still the cause; report it rather than whatever it became.
89
+ throw run.failures[0] ?? err;
90
+ }
91
+ // A handler that swallowed a refusal must not turn it into a green test.
92
+ if (run.failures.length) throw run.failures[0];
93
+
94
+ // app-entrypoint.ts: undefined crosses as null; anything JSON.stringify
95
+ // cannot render is the handler's error.
96
+ let json;
97
+ try {
98
+ json = JSON.stringify(result ?? null);
99
+ } catch (err) {
100
+ throw new Error(
101
+ `handler return value is not JSON-serializable: ${err}`
102
+ );
103
+ }
104
+ return JSON.parse(json);
105
+ },
106
+
107
+ /** Every ctx.events.<e>.emit that passed, in order. */
108
+ events: () => rec.events.map((e) => ({ ...e })),
109
+ /** Everything handlers logged, with its level. */
110
+ logs: () => rec.logs.slice(),
111
+ /** Manifest-vs-module problems; empty when they agree. */
112
+ conformance: () => appConformance(manifest, app),
113
+
114
+ ...world.h,
115
+
116
+ /**
117
+ * Applies a scenario through the setters: now, timezone, solar, states,
118
+ * controls, targets and credentials. Ignores trigger, event and expect;
119
+ * fails on any other key, llm included — an app has no step.llm.
120
+ */
121
+ scenario(s) {
122
+ return applyScenario(h, s, { kind: 'app' });
123
+ },
124
+
125
+ states: bindings.h.states,
126
+ stateCalls: bindings.h.stateCalls,
127
+ targets: bindings.h.targets,
128
+ controls: bindings.h.controls,
129
+ credentials: bindings.h.credentials,
130
+
131
+ /**
132
+ * Clears recordings, answers and seeded states; keeps the clock,
133
+ * timezone, solar and credentials.
134
+ */
135
+ reset() {
136
+ rec.logs.length = 0;
137
+ rec.events.length = 0;
138
+ bindings.reset();
139
+ return h;
140
+ },
141
+ };
142
+
143
+ world.init({ now, timezone, solar });
144
+
145
+ /** One call's ctx, exactly the members app-entrypoint.ts builds. */
146
+ function buildContext(run, handler, readOnly) {
147
+ const refuse = (message) => {
148
+ try {
149
+ fail(message);
150
+ } catch (err) {
151
+ run.failures.push(err);
152
+ throw err;
153
+ }
154
+ };
155
+ const b = bindings.ctx(refuse, { readOnly, handler });
156
+
157
+ const log = (message, data) =>
158
+ rec.logs.push({ level: 'debug', message, data });
159
+ log.warn = (message, data) =>
160
+ rec.logs.push({ level: 'warn', message, data });
161
+ log.error = (message, data) =>
162
+ rec.logs.push({ level: 'error', message, data });
163
+
164
+ return {
165
+ log,
166
+ ...world.ctx(refuse),
167
+ // The host aborts on timeout or shutdown; a test has neither.
168
+ signal: new AbortController().signal,
169
+ states: b.states,
170
+ controls: b.controls,
171
+ targets: b.targets,
172
+ credentials: b.credentials,
173
+ events: eventsProxy(refuse, handler, readOnly),
174
+ };
175
+ }
176
+
177
+ /**
178
+ * ctx.events, as buildEvents builds it: a handle for any name, checked when
179
+ * it emits. The checks are the host's, in its order: read_only
180
+ * (inprocess.go's refuseReadOnly), then Invoker.publish's declared event and
181
+ * payload schema.
182
+ */
183
+ function eventsProxy(refuse, handler, readOnly) {
184
+ return new Proxy(
185
+ {},
186
+ {
187
+ get(_own, event) {
188
+ if (typeof event !== 'string' || event === 'then') return undefined;
189
+ return {
190
+ async emit(payload) {
191
+ if (readOnly) {
192
+ refuse(
193
+ `read-only handler ${JSON.stringify(handler)} cannot emit events`
194
+ );
195
+ }
196
+ const q = JSON.stringify(event);
197
+ const def = events[event];
198
+ if (!Object.hasOwn(events, event)) {
199
+ refuse(
200
+ Object.keys(events).length === 0
201
+ ? `event ${q} not declared in app ${JSON.stringify(appID)} (it declares no events)`
202
+ : `event ${q} not declared in app ${JSON.stringify(appID)} (have: ${sorted(events)})`
203
+ );
204
+ }
205
+ // What the bus carries: the payload after the wire's JSON, an
206
+ // omitted one as null.
207
+ let sent;
208
+ try {
209
+ sent = JSON.parse(JSON.stringify({ payload })).payload ?? null;
210
+ } catch (err) {
211
+ refuse(`event ${q} payload invalid: ${err}`);
212
+ }
213
+ if (def?.payload !== undefined) {
214
+ const validate = compile(def.payload);
215
+ if (!validate(sent)) {
216
+ refuse(
217
+ `event ${q} payload invalid:\n${describeErrors(validate.errors)}`
218
+ );
219
+ }
220
+ }
221
+ rec.events.push({ event, payload: sent });
222
+ },
223
+ };
224
+ },
225
+ }
226
+ );
227
+ }
228
+
229
+ return h;
230
+ }
231
+
232
+ /**
233
+ * Checks an app's manifest and module against each other and returns a list
234
+ * of problems, empty when they agree — flowConformance's pairing, for
235
+ * handlers. A handler declared and not implemented fails the page's first
236
+ * call to it; one implemented and not declared is never reachable.
237
+ */
238
+ export function appConformance(manifest, app) {
239
+ const problems = [];
240
+
241
+ for (const key of Object.keys(app ?? {})) {
242
+ if (!APP_KEYS.includes(key)) {
243
+ problems.push(
244
+ `default export has an unknown key "${key}" (expected ${APP_KEYS.join(', ')})`
245
+ );
246
+ }
247
+ }
248
+ if (typeof app?.handlers !== 'object' || app.handlers === null) {
249
+ problems.push('default export has no "handlers" object');
250
+ }
251
+
252
+ const declared = Object.keys(manifest?.handlers ?? {});
253
+ for (const name of declared) {
254
+ if (typeof app?.handlers?.[name] !== 'function') {
255
+ problems.push(
256
+ `handler "${name}" is declared in manifest.json but not implemented`
257
+ );
258
+ }
259
+ }
260
+ for (const name of Object.keys(app?.handlers ?? {})) {
261
+ if (!declared.includes(name)) {
262
+ problems.push(
263
+ `handler "${name}" is implemented but not declared in manifest.json — the page can never call it`
264
+ );
265
+ }
266
+ }
267
+
268
+ return problems;
269
+ }
270
+
271
+ /** A manifest map's names as the invoker lists them: sorted, comma-joined. */
272
+ function sorted(obj) {
273
+ return Object.keys(obj).sort().join(', ');
274
+ }