npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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/testkit
import { 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 order

An 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 event

Juno 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.store round-trips values through JSON, because the real store does. A Date comes 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.findings refreshes in place on (slug, key), as the store does, and rejects a name the manifest does not declare, as the host does.
  • ctx.status.set details round-trip through JSON before validation, as they do on the wire, so a NaN shows up as null and 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, with undefined sitting where the generated types promise a string.

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 with porchLight.getState answered 2 calls in the scenario; this was call 3;
  • { "$error": "message" } makes the call throw new Error("message"), which, unlike a refusal, the handler may catch. A $replay item 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 when

controls.<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 (set on a read binding, get on a slot), and get on a binding the test did not seed;
  • a ctx.wait(…) none of whose handles has fired, and so every ctx.sleep(…) (sleep(d) is wait([after(d)])): a handler cannot suspend in a test: wait([after("5m")]);
  • ctx.now(), ctx.solar(), a target call, a control command or a step.llm the 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 with undefined, 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 payload schema: event "rated" payload invalid: followed by one line per problem;
  • in a handler declared read_only: true, a claim, a release, 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_only handler, a call on a homeassistant or mqtt target: 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.