@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 +221 -2
- package/package.json +2 -2
- package/src/app.js +274 -0
- package/src/bindings.js +726 -0
- package/src/findings.js +49 -0
- package/src/flow-conformance.js +43 -0
- package/src/flow.js +290 -0
- package/src/index.d.ts +399 -0
- package/src/index.js +52 -69
- package/src/names.js +9 -0
- package/src/scenario.js +72 -0
- package/src/schema.js +8 -4
- package/src/time.js +131 -0
package/README.md
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
# @junoflow/testkit
|
|
2
2
|
|
|
3
|
-
Test
|
|
4
|
-
|
|
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
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
|
+
}
|