@junoflow/testkit
v0.17.0
Published
Test harnesses for Juno integrations, flows and apps
Readme
@junoflow/testkit
Test harnesses for Juno integrations
(harness), flows (flowHarness) and apps (appHarness). Each runs your
handlers with the same ctx the sandbox builds, but with a different
implementation for tests.
npm install --save-dev @junoflow/testkitimport { harness } from '@junoflow/testkit';
import manifest from '../manifest.json';
import integration from '../src/integration';
const h = harness({ manifest, integration });
// Mocked routes, the store, findings, events and logs otherwise outlive an
// `it`, and the test that fails is whichever one ran second.
beforeEach(() => h.reset());
test('sendNotification posts to the API and returns the receipt', async () => {
h.credentials({ appToken: 'tok', userKey: 'usr' });
h.http.post('https://api.pushover.net/1/messages.json', {
json: { status: 1, request: 'r1' },
});
expect(await h.action('sendNotification', { message: 'hi' })).toEqual({
request: 'r1',
});
// Nothing standing: the success path proved the credentials work.
expect(h.findings()).toEqual([]);
});
test('conforms to its manifest', () => {
expect(h.conformance()).toEqual([]);
});Note the plain import manifest from '../manifest.json' — the shared
tsconfig.base.json compiles as CommonJS, where an import attribute
(with { type: 'json' }) is a TS2823 error. resolveJsonModule is on, so the
plain form works.
API
harness({ manifest, integration }) returns:
| Member | Does |
| -------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
| h.config(values) | set non-credential slots (manifest defaults are already applied) |
| h.credentials(values) | set credential slots |
| h.webhookUrls(values) | set ctx.webhooks.<slot>.url; each slot starts at https://juno.test/webhooks/test-<slot>, null is "no public URL" |
| h.action(name, params) | run an action, returns its result |
| h.webhook(name, request?) | run a webhook; request defaults to a POST with an empty body, to the slot's URL |
| h.schedule(name, event?) | run a schedule handler |
| h.hook(name) | run a declared lifecycle hook (e.g. activated) |
| h.deliver(channel, attempt?) | run a declared channel's deliver with a fixture attempt (any field overridable); resolves to the attempt |
| h.withdraw(channel, attempt?) | run its withdraw; a no-op when the channel has none |
| h.listen({ until, events, timeout }) | run the listener until the condition holds, then abort |
| h.store | the KV, as a plain Map (get/set/delete all land here) |
| h.findings() | what ctx.findings asserted, as {slug, key, severity, text, standing} |
| h.states() | values ctx.states.publish has sent, keyed by local name |
| h.status() | the last ctx.status.set report, or null if it never reported |
| h.reports() | what ctx.channels.report sent, as {attemptId, status, digit?, option?, reason?} |
| h.replyAnswer({ matched, reply }) | set what ctx.channels.reply answers (default { matched: false, reply: null }) |
| h.replies() | what ctx.channels.reply sent, as {channel, address, text} |
| h.emitted() | [{ event, payload }], in order |
| h.logs() | [{ level, message, data }] |
| h.conformance() | manifest-vs-module problems; [] when they agree |
| h.http | the request mock, below |
| h.reset() | clear recorded state, keeping config and credentials |
h.http
Replaces global fetch for the duration of a handler and restores it after,
including when the handler throws.
h.http.post('https://api.example.com/send', { receipt: 'r1' }); // 200 JSON
h.http.get(/\/status$/, { status: 503, json: { down: true } }); // RegExp matching
h.http.post('https://api.example.com/send', (req) => ({ echoed: req.body }));
h.http.requests; // every request a handler made, in orderAn unmatched request throws, to ensure that all HTTP calls are expected.
A canned answer is either the upstream's JSON body verbatim, or the envelope
{ status?, headers?, json?, body? }. The two are told apart by an exclusive
key match: an object is an envelope only when every key it has is one of
those four. So an upstream payload that happens to contain status — Pushover's
{ status: 1, request: '…' } — is a body, as written. When a payload consists
only of envelope keys, say so: { json: { status: 1 } }.
h.listen
await h.listen({ events: 3 }); // until three events are emitted
await h.listen({ until: (h) => h.store.get('cursor') === '42' });After the condition holds, ctx.signal aborts and the harness waits for the
listener to return. A listener that ignores its signal fails the test — in
production it hangs the supervisor on shutdown instead.
h.conformance()
Checks the manifest and the module against each other: every declared surface
implemented (actions, webhooks, schedules, hooks, and a deliver for every
channel), nothing undeclared exported, a listener function exactly when the
manifest declares a listener section, every hook name inside Juno's closed
vocabulary, and every schema compiling. states is a manifest declaration, not
a handler surface, so it plays no part here — see h.states()
instead. Returns all problems rather than the first — "you declared six actions
and implemented four" is one fix, not four runs.
h.findings()
expect(h.findings()).toEqual([
{
slug: 'badCredentials',
key: '',
severity: 'error',
text: 'Pushover rejected the credentials',
standing: true,
},
]);open, clear and set all land here, each returning the transition it made
({ opened, cleared }, both false when it refreshed or retracted nothing). An
undeclared name fails the test, naming the declared ones — Juno rejects it
at runtime, and hearing that from an installer is not a test. An instance the
handler retracted stays in the list with standing: false; one that was never
open records nothing at all, so expect(h.findings()).toEqual([]) is the
success-path assertion for a handler written with set().
h.states()
await h.schedule('refresh');
expect(h.states()).toEqual({ workday: true, 'day-kind': 'workday' });ctx.states.publish(values) is on the base context, so an action, webhook,
schedule or hook can all call it. h.states() is the current picture — a second
publish of the same name overwrites, it does not accumulate — keyed by the local
name the handler used, not the <install>:<name> id Juno scopes it to on the
wire. An undeclared name fails the test, the same way an undeclared finding
or event does.
h.status()
await h.schedule('fetchForecast');
expect(h.status()).toEqual({
summary: '2.4 °C, cloudy — 96 intervals to 2026-09-27',
details: { intervals: 96 },
});ctx.status.set is on the base context, like ctx.states.publish. The report
is replaced on every write, so h.status() is the last one sent, and null
when the handler never reported. That is a result worth asserting too: an
integration with nothing to say should leave the status absent. details is
left out when the report had none or had an empty map. A report Juno would
reject fails the test: an empty or multi-line summary, a summary longer than
200 characters, or a detail that is not a string, number or boolean. The error
names the key.
h.hook(name)
await h.hook('activated');Runs a declared lifecycle hook with the same context a schedule gets. An
undeclared hook, or one whose handler returns a value, fails the test — "a
hook reports through ctx and returns nothing" is what production says too.
h.conformance() also rejects a hook name outside Juno's closed vocabulary
(today, only activated), which is the check that catches a typo before deploy.
h.deliver(channel, attempt?) and h.reports()
const attempt = await h.deliver('call');
expect(h.http.requests[0].body).toContain(attempt.id);
await h.webhook('callEvents', { body: { ref: attempt.id, digit: 1 } });
expect(h.reports()).toEqual([
{ attemptId: 'attempt-1', status: 'answered', digit: 1 },
]);The fixture attempt is id: 'attempt-1', in the channel's first answers mode,
with one option { key: 'ack', label: 'OK', digit: 1 }, a placeholder address
of the channel's address kind, render.text/render.speech equal to the
message, link: null and a deadline ten minutes out. A channel that declares no
answers gets a notification instead (kind and mode notify, no options,
no deadline). Pass any field to override it.
A deliver or withdraw that returns a value fails the test, as it fails
in production. ctx.channels.report is on every surface; the refusals Juno
applies without its attempt rows fail the test too — an install with no
channels, an unknown status, answered without exactly one of digit and
option, and, for an attempt h.deliver handed out, a digit or option the
attempt does not have. A report on an id the harness never issued is recorded
rather than refused, so a webhook test can report on a reference it made up.
h.replyAnswer({ matched, reply }) and h.replies()
h.replyAnswer({ matched: false, reply: 'Reply 1 for Yes or 2 for No.' });
await h.webhook('inboundSms', {
body: { From: '+46700000001', Body: 'maybe' },
});
expect(h.replies()).toEqual([
{ channel: 'sms', address: '+46700000001', text: 'maybe' },
]);
expect(h.emitted()).toHaveLength(1); // not an answer: the integration's own eventJuno matches a written reply against the questions it holds, which a test does
not have, so the test says what ctx.channels.reply answers: by default
{ matched: false, reply: null } — the message was not an answer. The refusals
that need no questions fail the test: an install with no channels, and a channel
that is not declared or does not list text in its answers. reset() clears
the recorded replies and the answer.
What the harness is faithful about
ctx.storeround-trips values through JSON, because the real store does. ADatecomes back as a string here exactly as it would in production.- Config defaults declared in the manifest are applied before your test runs, because the runtime always supplies them.
ctx.findingsrefreshes in place on(slug, key), as the store does, and rejects a name the manifest does not declare, as the host does.ctx.status.setdetails round-trip through JSON before validation, as they do on the wire, so aNaNshows up asnulland is rejected there as well.- A required slot must be set before a handler runs. An install cannot start
with one empty, so a handler never sees one — a test that skipped
h.credentials(...)would be green for a state production forbids, withundefinedsitting where the generated types promise astring.
flowHarness
The flow half. It builds the ctx the flow sandbox builds from the flow's own
manifest, runs one trigger handler against it, and answers every outward call
from what the test supplies. A flow working copy's npm test runs it under
vitest, and juno flow init writes a starter test/flow.test.ts for a version
that stores no tests.
import { beforeEach, expect, it } from 'vitest';
import { flowHarness } from '@junoflow/testkit';
import manifest from '../manifest.json';
import flow from '../src/flow';
const h = flowHarness({ manifest, flow });
beforeEach(() => h.reset());
it('greets when someone is home', async () => {
h.now('2026-10-04T18:42:00').timezone('Europe/Stockholm');
h.states({ houseMode: 'home' });
h.targets.answer('kitchenSpeaker.ttsCloudSay', undefined);
const status = await h.trigger('goodMorning', {
event: {
entity_id: 'input_boolean.good_morning',
new_state: { state: 'on' },
},
});
expect(status).toEqual({ kind: 'ok', text: 'greeted' });
expect(h.targets.calls()).toEqual([
{
target: 'kitchenSpeaker',
action: 'ttsCloudSay',
params: { message: 'Good morning' },
},
]);
});flowHarness({ manifest, flow, now?, timezone?, solar?, series? }) returns:
| Member | Does |
| ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| h.trigger(name, { event }) | run a declared trigger; resolves to the handler's return value |
| h.scenario(s) | set the world from one JSON object (below) |
| h.now(iso) / h.timezone(tz) / h.solar(v) | what ctx.now() and ctx.solar() answer; the timezone is UTC unless set |
| h.states(values) / h.state(name) | seed a read or read_write binding; read its current value |
| h.stateCalls() | set, claim, release, signal, retract, as {binding, verb, value?, opts?} |
| h.targets.answer(key, a) / .calls() | answer binding.method with a value or a function of the params; the calls made |
| h.controls.answer(key, a) / .calls() | answer binding.verb; the commands made |
| h.llm(id, answer) | answer step.llm(id, …); id#N answers only that occurrence |
| h.steps() | every step.run and step.llm, as {id, output}; repeats are id, id#1, id#2 |
| h.findings() | {slug, key, severity, text, standing}, collapsed by (slug, key) as the host does |
| h.credentials(values) | set ctx.credentials by declared id |
| h.webhookUrls(values) | set ctx.webhooks['<id>'].url; each starts at https://juno.test/webhooks/test-<id>, null is "no public URL" |
| h.questions.answer(key, v) / .asked() | answer ask by label (or ask#N); the questions asked, defaults applied (below) |
| h.notifications() / h.unreachable(...ids) | every notify as {to, message, urgency, via, link}; the people notify reports unreachable |
| 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 |
| h.series(key, fixture) | what series.<key>.get() reads; replaces the key's fixture (see Series) |
| h.seriesView(key, spec, result) | what series.<key>.view(spec) answers, for a spec equal by value |
| h.logs() | [{ level, message, data }] |
| h.conformance() | triggers declared vs implemented, both ways; [] when they agree |
| h.reset() | clear recordings, answers, seeds and scoped state; keep clock, timezone, solar, credentials, webhook URLs, series |
h.trigger(name, { event }) sets event.trigger and spreads the payload you
give onto the event, as the sandbox does. An array (a batch run) stays under
event.payload. Nothing else is filled in, and the trigger's filter and
modifiers are not evaluated. h.now(iso) without an offset is wall time in
h.timezone(); a wall time the timezone skips or repeats is refused, so give
one near a DST change an offset.
Series
A series binding reads from a fixture, given per key as the series option or
with h.series(key, fixture):
const h = flowHarness({
manifest,
flow,
series: {
temperature: [
{ from: '2026-11-01T12:00:00Z', to: '2026-11-01T13:00:00Z', value: 14 },
{ from: '2026-11-01T13:00:00Z', to: '2026-11-01T14:00:00Z', value: 16 },
],
},
});
h.seriesView(
'temperature',
{
highest: {
for: '2h',
contiguous: true,
from: { at: '12:00' },
to: { at: '18:00' },
},
},
{
available: true,
value: false,
spans: [{ from: '2026-11-01T13:00:00Z', to: '2026-11-01T15:00:00Z' }],
}
);A fixture is an array of {from, to, value} (a Date or an RFC 3339 string for
each bound, sorted and non-overlapping), or
{intervals, published_at?, issued_at?, covered_until?}. get(range?) answers
the intervals overlapping [from, to), the instants spelled as the host spells
them (RFC 3339 UTC, 2026-11-01T12:00:00Z), and the stamps from the fixture or
null. A get() of a key with no fixture fails, naming h.series.
view() is stubbed, never evaluated. The host evaluates a view with cron
and solar edges in the site's timezone, and a copy here would be a second
implementation of it. h.seriesView(key, spec, result) says what a view
answers: result is a SeriesViewResult (fields left out read false, null
and []) or a function of the spec returning one, and it matches a spec equal
by value (key order does not matter). A view() nothing stubbed fails naming
the spec and the binding. Fixtures and stubs are kept across reset().
A key the manifest does not bind fails in the host's words:
series binding "cost" is not declared in this flow's series; it declares "temperature"
(this app's in an app), as do a bound that is not an instant
(series "price": from "yesterday" is not an RFC 3339 instant) and an empty
range (series "price": from must be before to).
Scenarios
h.scenario(s) applies a whole world at once, through the setters above and
their checks:
import fixture from './fixtures/porch-42.json';
it('replays run 42', async () => {
h.scenario(fixture);
const status = await h.trigger(fixture.trigger);
expect(status).toEqual(fixture.expect.status);
expect(h.steps()).toEqual(fixture.expect.steps);
expect(h.targets.calls()).toEqual(fixture.expect.calls);
});Its keys are now, timezone, solar, states, credentials, targets
("binding.method": answer), controls ("binding.verb": answer), llm
("stepId": answer), questions ("label": answer), unreachable (person
ids) and event, which an h.trigger(name) with no { event } runs with; an
explicit event, even null, wins. trigger, expect and flow_version are
carried and not applied. A key left out sets nothing; an unknown key fails. The
Scenario type is exported.
Two answer forms are reserved, in a scenario and in h.targets.answer,
h.controls.answer and h.llm alike:
{ "$replay": [a, b, …] }answers calls in order; one too many fails withporchLight.getState answered 2 calls in the scenario; this was call 3;{ "$error": "message" }makes the call thrownew Error("message"), which, unlike a refusal, the handler may catch. A$replayitem may be an$error.
juno flow fixture --run <seq> writes a scenario from a recorded run, with
expect filled in from what the run did; see the flow testing docs ("Replaying
a run").
Handles
ctx.after, ctx.at and ctx.wait behave as on the server, except that the
harness's clock does not move: it cannot suspend, so a timer never fires while a
handler waits. An at whose instant is at or before h.now() fires the moment
it opens, with value null and firedAt that instant; an after never does.
A wait listing a fired handle returns it — the earliest firedAt, ties to the
earlier position — so a flow that waits on something already due runs to
completion:
it('skips the wait when the morning has already come', async () => {
h.now('2026-10-26T07:00:00Z');
await h.trigger('nightly');
expect(h.handles()).toEqual([
{ id: '__h_0__', kind: 'timer', label: 'morning', status: 'fired' },
]);
});State and event handles follow the same rule, firing only where the server would
fire them as they open. state.<b>.when(match) without for fires at once when
the binding's seeded value already matches, with that value and firedAt
h.now(); becomes, changes, a for hold and targets.<i>.untilEvent never
fire, since the harness has no history of changes or events to look back on:
h.states({ door: 'closed' });
// await wait([state.door.when('closed'), after('1m')]) returns the whencontrols.<b>.untilPressed() never fires either: nothing presses in a test.
A child-run handle fires at open from its answer, because the harness starts no
child. targets.<f>.start(input) fires with the <f>.start answer wrapped as
{ flow_id, run_id: 'harness', ok: true, value }, or ok: false with the
answer's $error as error; targets.<l>.startAll(input) fires with the
<l>.startAll answer whole, an array of those results. An unanswered start or
startAll fails like an unanswered target call. invoke and invokeAll answer
from their own key when the test set one, and otherwise from the .start /
.startAll answer, so one answer serves a flow that switches between them:
h.targets.answer('report.start', { lines: 3 });
// using r = targets.report.start({ day: 'mon' }) → r fires at open, r.value.ok
// await targets.report.invoke({ day: 'mon' }) → { lines: 3 }all([...]) fires at open when every member already has, with their values in
order, and is cancelled when a member is.
h.handles() lists the last run's handles in open order as
{ id, kind, label, status } (kind is timer, state, event, run,
press, question or all); one still open when the run ended reads
cancelled, as the engine closes it. An at with no clock set fails, as
ctx.now() does. A bad duration or instant, and a 101st open handle, fail with
the server's message at the next wait or when the handler returns.
Questions and notifications
ctx.ask opens a question handle, which fires at open when the test answered
it: by its label, or ask#N (the Nth ask of the trigger, from 1) for one
without. { by, answer, channel? } fires it with
{ outcome: 'answered', answer, by, channel, at } — channel defaults to
'test:channel', at is h.now() — and 'unanswered' with
{ outcome: 'unanswered', attempts: to.length * rounds }. The server does not
count a recipient none of whose contacts could carry the question; the harness
has no contacts, so it counts them all. A question with no answer stays open, so
a wait on it alone fails as any wait nothing can answer does. There is no
timeline: an answer that arrives after a call has rung needs a clock that moves.
h.questions.answer('samtal', { by: 'partner', answer: 'ack' });
// using called = ask({ …, label: 'samtal' }) → called fires at open
expect(h.questions.asked()[0]).toMatchObject({ label: 'samtal', rounds: 5 });ctx.notify records { to, message, urgency, via, link } (defaults applied;
via and link null when absent) and answers { unreachable: [] }, or the
recipients h.unreachable(...ids) named, in the manifest's order. Both refuse
what the server refuses from the manifest alone — a person not in people, a
field out of range — with the server's words.
What fails
Strict where a permissive double would pass a broken flow. Each of these fails the test, naming what was asked for and how to supply it:
- a trigger, state binding, target, target method, control, finding or
credential the manifest does not declare, in the handler or in the
h.*call that seeds or answers it; - a state verb the binding was not granted (
seton areadbinding,geton a slot), andgeton a binding the test did not seed; - a
ctx.wait(…)none of whose handles has fired, and so everyctx.sleep(…)(sleep(d)iswait([after(d)])):a handler cannot suspend in a test: wait([after("5m")]); ctx.now(),ctx.solar(), a target call, a control command or astep.llmthe test did not answer, e.g.target call kitchenSpeaker.ttsCloudSay was not answered — h.targets.answer('kitchenSpeaker.ttsCloudSay', result). An action with no result is answered withundefined, explicitly.
A refusal the handler catches still fails the test. Answers and state values cross a JSON round trip, as on the wire.
Two things are not modelled: claim always resolves { status: 'winning' },
because the layer stack is the server's, and scoped state cannot be seeded from
a test.
appHarness
The app half. It builds the ctx the app sandbox builds from the app's own
manifest and calls one handler with it, as the page's
juno.handlers.<name>(input) does. An app working copy's npm test runs it
under vitest, and juno app init writes a starter test/app.test.ts for a
version that stores no tests.
import { beforeEach, expect, it } from 'vitest';
import { appHarness } from '@junoflow/testkit';
import manifest from '../manifest.json';
import app from '../src/app';
const h = appHarness({ manifest, app });
beforeEach(() => h.reset());
it('rates a meal', async () => {
h.now('2026-10-04T18:42:00').timezone('Europe/Stockholm');
expect(await h.call('rate', { meal: 'tacos', stars: 5 })).toEqual({
saved: true,
});
expect(h.events()).toEqual([
{ event: 'rated', payload: { meal: 'tacos', stars: 5 } },
]);
});
it('a read-only handler cannot claim', async () => {
await expect(h.call('peek', {})).rejects.toThrow(
'read-only handler "peek" cannot claim states'
);
});appHarness({ manifest, app, now?, timezone?, solar?, series? }) returns:
| Member | Does |
| ---------------------------------------------- | ------------------------------------------------------------------------------------- |
| h.call(name, input?) | call a declared handler; resolves to its result after a JSON round trip |
| h.events() | every emit that passed, as {event, payload}, in emit order |
| h.scenario(s) | set the world from one JSON object, as flowHarness's |
| h.now(iso) / h.timezone(tz) / h.solar(v) | what ctx.now() and ctx.solar() answer; the timezone is UTC unless set |
| h.states(values) | seed a read binding |
| h.stateCalls() | claim and release, as {binding, verb, value?, opts?} |
| h.targets.answer(key, a) / .calls() | answer binding.method with a value or a function of the params; the calls made |
| h.controls.answer(key, a) / .calls() | answer binding.verb; the commands made |
| h.credentials(values) | set ctx.credentials by declared id |
| h.series(key, f) / h.seriesView(key, s, r) | a series fixture and a view stub, as flowHarness's (Series) |
| h.logs() | [{ level, message, data }] |
| h.conformance() | handlers declared vs implemented, both ways; [] when they agree |
| h.reset() | clear recordings, answers and seeds; keep clock, timezone, solar, credentials, series |
h.scenario(s) takes the flow scenario's keys except llm, questions,
unreachable and flow_version, which it refuses, and ignores trigger,
event and expect, so one scenario can serve both. $replay and $error
answer targets and controls as they do in a flow test.
The handler's ctx is the app sandbox's: log, now, solar, signal,
states, controls, series, targets, credentials and events.
ctx.signal is never aborted. The binding handles, answers and clock are the
ones flowHarness uses, with the same strictness.
What fails
Everything flowHarness refuses for a binding, an answer or the clock, plus
what the host refuses on a handler call, in the host's words:
- a handler the manifest does not declare:
app "meal-probe" has no handler "nope" (have: peek, rate); - an event the manifest does not declare:
event "x" not declared in app "meal-probe" (have: rated), or(it declares no events); - a payload that fails the event's
payloadschema:event "rated" payload invalid:followed by one line per problem; - in a handler declared
read_only: true, aclaim, arelease, a control verb or an emit:read-only handler "peek" cannot claim states,… cannot release states,… cannot command controls,… cannot emit events; - in a
read_onlyhandler, a call on ahomeassistantormqtttarget:read-only handler "peek" cannot invoke target "kitchenSpeaker": a built-in homeassistant target is never read-only.
The input must be JSON-serialisable, and an omitted or null input arrives as
{}. The result comes back as the page receives it: a Date becomes a string
and an undefined field disappears. Neither is checked against a schema,
because the host checks neither: a handler's input and output are its
TypeScript.
One host check is not made. A read_only handler may call an integration action
only when the integration's manifest declares that action read_only, and the
harness does not have that manifest. It allows every integration action from a
read_only handler; the host refuses one that is not read-only, so try the
handler against a draft with npm run check.
