@junoflow/testkit
v0.3.0
Published
Test harness for Juno integrations
Readme
@junoflow/testkit
Test harness for Juno integrations. It 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.action(name, params) | run an action, returns its result |
| h.webhook(name, request?) | run a webhook; request defaults to POST / with an empty body |
| h.schedule(name, event?) | run a schedule handler |
| h.hook(name) | run a declared lifecycle hook (e.g. activated) |
| 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.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), 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.
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.
