@junoflow/testkit 0.1.0 → 0.1.1

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,8 +1,8 @@
1
1
  # @junoflow/testkit
2
2
 
3
3
  Test harness for [Juno](https://github.com/boivie/juno) integrations. It runs
4
- your handlers with the same `ctx` the sandbox builds, and **checks every
5
- crossing against your manifest**.
4
+ your handlers with the same `ctx` the sandbox builds, but with a different
5
+ implementation for tests.
6
6
 
7
7
  ```bash
8
8
  npm install --save-dev @junoflow/testkit
@@ -10,17 +10,23 @@ npm install --save-dev @junoflow/testkit
10
10
 
11
11
  ```ts
12
12
  import { harness } from '@junoflow/testkit';
13
- import manifest from '../manifest.json' with { type: 'json' };
13
+ import manifest from '../manifest.json';
14
14
  import integration from '../src/integration';
15
15
 
16
16
  const h = harness({ manifest, integration });
17
17
 
18
- test('notify posts to the API and returns the receipt', async () => {
19
- h.credentials({ appToken: 'tok' });
20
- h.http.post('https://api.pushover.net/1/messages.json', { receipt: 'r1' });
18
+ // Mocked routes, the store, problems, events and logs otherwise outlive an
19
+ // `it`, and the test that fails is whichever one ran second.
20
+ beforeEach(() => h.reset());
21
21
 
22
- expect(await h.action('notify', { message: 'hi' })).toEqual({
23
- receipt: 'r1',
22
+ test('sendNotification posts to the API and returns the receipt', async () => {
23
+ h.credentials({ appToken: 'tok', userKey: 'usr' });
24
+ h.http.post('https://api.pushover.net/1/messages.json', {
25
+ json: { status: 1, request: 'r1' },
26
+ });
27
+
28
+ expect(await h.action('sendNotification', { message: 'hi' })).toEqual({
29
+ request: 'r1',
24
30
  });
25
31
  expect(h.problems()).toEqual([]);
26
32
  });
@@ -30,25 +36,10 @@ test('conforms to its manifest', () => {
30
36
  });
31
37
  ```
32
38
 
33
- Runner-agnostic: plain functions and thrown `AssertionError`s, no runner
34
- imported. vitest is what the scaffold uses; `node:test` and jest work unchanged.
35
-
36
- ## Why the manifest checking matters
37
-
38
- Juno checks the contract when it **runs** an integration, which is too late to
39
- be a test — an installer finds out instead. The harness moves that forward:
40
-
41
- | Crossing | Checked against |
42
- | ---------------------------- | ------------------------------------ |
43
- | action params in | the action's `params` schema |
44
- | action result out | the action's `response` schema |
45
- | `ctx.emit(name, …)` | `events` — an undeclared name throws |
46
- | emitted payload | the event's `payload` schema |
47
- | webhook return value | `{ status?, headers?, body? }` |
48
- | `h.config` / `h.credentials` | the declared slot, and its kind |
49
-
50
- The failure mode this catches is the one that actually bites installers: the
51
- manifest says one thing and the code does another.
39
+ Note the plain `import manifest from '../manifest.json'` — the shared
40
+ `tsconfig.base.json` compiles as CommonJS, where an import attribute
41
+ (`with { type: 'json' }`) is a TS2823 error. `resolveJsonModule` is on, so the
42
+ plain form works.
52
43
 
53
44
  ## API
54
45
 
@@ -62,7 +53,7 @@ manifest says one thing and the code does another.
62
53
  | `h.webhook(name, request?)` | run a webhook; `request` defaults to `POST /` with an empty body |
63
54
  | `h.poll(name)` | run a poll |
64
55
  | `h.listen({ until, events, timeout })` | run the listener until the condition holds, then abort |
65
- | `h.store` | the KV, as a plain `Map` |
56
+ | `h.store` | the KV, as a plain `Map` (`get`/`set`/`delete` all land here) |
66
57
  | `h.problems()` | what `ctx.problems.report` recorded |
67
58
  | `h.emitted()` | `[{ event, payload }]`, in order |
68
59
  | `h.logs()` | `[{ level, message, data }]` |
@@ -83,9 +74,14 @@ h.http.post('https://api.example.com/send', (req) => ({ echoed: req.body }));
83
74
  h.http.requests; // every request a handler made, in order
84
75
  ```
85
76
 
86
- An **unmatched request throws**, naming the URL and listing what is mocked. A
87
- test that silently reaches the real internet is the worst outcome here, so there
88
- is no passthrough mode.
77
+ An **unmatched request throws**, to ensure that all HTTP calls are expected.
78
+
79
+ A canned answer is either the upstream's JSON body verbatim, or the envelope
80
+ `{ status?, headers?, json?, body? }`. The two are told apart by an _exclusive_
81
+ key match: an object is an envelope only when **every** key it has is one of
82
+ those four. So an upstream payload that happens to contain `status` — Pushover's
83
+ `{ status: 1, request: '…' }` — is a body, as written. When a payload consists
84
+ only of envelope keys, say so: `{ json: { status: 1 } }`.
89
85
 
90
86
  ### `h.listen`
91
87
 
@@ -113,3 +109,7 @@ is one fix, not four runs.
113
109
  - Config defaults declared in the manifest are applied before your test runs,
114
110
  because the runtime always supplies them.
115
111
  - `ctx.problems.report` upserts by key, as the server does.
112
+ - A required slot must be set before a handler runs. An install cannot start
113
+ with one empty, so a handler never sees one — a test that skipped
114
+ `h.credentials(...)` would be green for a state production forbids, with
115
+ `undefined` sitting where the generated types promise a `string`.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@junoflow/testkit",
3
- "version": "0.1.0",
4
- "description": "Test harness for Juno integrations: runs your handlers with a real context and checks every crossing against your manifest.",
3
+ "version": "0.1.1",
4
+ "description": "Test harness for Juno integrations",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
@@ -20,12 +20,6 @@
20
20
  "src",
21
21
  "README.md"
22
22
  ],
23
- "keywords": [
24
- "juno",
25
- "junoflow",
26
- "testing",
27
- "integration"
28
- ],
29
23
  "scripts": {
30
24
  "test": "node --test \"test/**/*.test.js\""
31
25
  },
package/src/http.js CHANGED
@@ -74,19 +74,31 @@ function matches(pattern, url) {
74
74
  return pattern === url;
75
75
  }
76
76
 
77
+ /** The keys an envelope is allowed to have, and nothing else. */
78
+ const ENVELOPE_KEYS = ['status', 'headers', 'json', 'body'];
79
+
77
80
  /**
78
81
  * Accepts the two shapes a test wants to write: a bare object (200 JSON, the
79
82
  * overwhelmingly common case) or a full {status, headers, json|body}.
83
+ *
84
+ * The two are told apart by an *exclusive* key match — every key must be an
85
+ * envelope key — rather than by asking whether any envelope key is present.
86
+ * `status` is one of the most common keys in a REST payload (Pushover answers
87
+ * `{"status": 1, "request": "…"}`), and sniffing for it meant the natural mock
88
+ * for such an API was silently read as an envelope: `{status: 1}` threw a
89
+ * RangeError from undici pointing at the author's fetch call, and `{status:
90
+ * 200, …}` was worse, quietly delivering an empty body to correct code. An
91
+ * upstream payload almost never consists solely of envelope keys; when it does,
92
+ * the range guard below names the fix rather than letting undici do it.
80
93
  */
81
94
  function toResponse(answer) {
82
95
  if (answer instanceof Response) return answer;
83
96
  const isEnvelope =
84
97
  answer !== null &&
85
98
  typeof answer === 'object' &&
86
- ('status' in answer ||
87
- 'json' in answer ||
88
- 'body' in answer ||
89
- 'headers' in answer);
99
+ !Array.isArray(answer) &&
100
+ Object.keys(answer).length > 0 &&
101
+ Object.keys(answer).every((k) => ENVELOPE_KEYS.includes(k));
90
102
 
91
103
  const {
92
104
  status = 200,
@@ -94,6 +106,14 @@ function toResponse(answer) {
94
106
  json,
95
107
  body,
96
108
  } = isEnvelope ? answer : { json: answer };
109
+ if (!Number.isInteger(status) || status < 100 || status > 599) {
110
+ fail(
111
+ `mock response has status ${JSON.stringify(status)}, which is not an HTTP status.\n` +
112
+ `Every key of ${JSON.stringify(answer)} happens to be an envelope key, so it was read as ` +
113
+ `{status, headers, json, body} rather than as a body.\n` +
114
+ `If it is the upstream's payload, wrap it: { json: ${JSON.stringify(answer)} }.`
115
+ );
116
+ }
97
117
  if (json !== undefined) {
98
118
  return new Response(JSON.stringify(json), {
99
119
  status,
package/src/index.d.ts CHANGED
@@ -20,7 +20,7 @@ export interface ManifestConfigSlot {
20
20
  type: string;
21
21
  title?: string;
22
22
  description?: string;
23
- docs_url?: string;
23
+ docsUrl?: string;
24
24
  default?: unknown;
25
25
  required?: boolean;
26
26
  credential?: boolean;
@@ -72,7 +72,12 @@ export interface RecordedRequest {
72
72
  body: BodyInit | null;
73
73
  }
74
74
 
75
- /** A canned answer: a bare object is 200 JSON, the common case. */
75
+ /**
76
+ * A canned answer: the upstream's JSON body verbatim (the common case), or the
77
+ * `{status, headers, json, body}` envelope — recognised only when *every* key
78
+ * is one of those four, so a payload that merely contains `status` stays a
79
+ * body. Wrap one that does not: `{ json: { status: 1 } }`.
80
+ */
76
81
  export type MockResponse =
77
82
  | object
78
83
  | Response
@@ -110,7 +115,7 @@ export interface EmittedEvent {
110
115
  }
111
116
 
112
117
  export interface LogEntry {
113
- level: 'debug' | 'warn' | 'error';
118
+ level: 'debug' | 'info' | 'warn' | 'error';
114
119
  message: string;
115
120
  data?: unknown;
116
121
  }
package/src/index.js CHANGED
@@ -35,6 +35,10 @@ export function harness({ manifest, integration } = {}) {
35
35
  problems: [],
36
36
  emitted: [],
37
37
  logs: [],
38
+ // ctx.signal for every surface but the listener, which brings its own.
39
+ // Never aborted here: the host aborts it on a timeout or a shutdown, and a
40
+ // test has neither.
41
+ abort: new AbortController(),
38
42
  };
39
43
 
40
44
  const http = createHttpMock();
@@ -63,6 +67,7 @@ export function harness({ manifest, integration } = {}) {
63
67
  );
64
68
  }
65
69
  const handler = requireHandler(integration, 'actions', name, 'action');
70
+ assertRequiredSlots(manifest, state);
66
71
  assertValid(declared.params, params, `params for action "${name}"`);
67
72
 
68
73
  const result = await withFetch(http.fetch, () => handler(params, ctx));
@@ -79,6 +84,7 @@ export function harness({ manifest, integration } = {}) {
79
84
  );
80
85
  }
81
86
  const handler = requireHandler(integration, 'webhooks', name, 'webhook');
87
+ assertRequiredSlots(manifest, state);
82
88
  const req = buildWebhookRequest(request);
83
89
 
84
90
  const result = await withFetch(http.fetch, () =>
@@ -95,6 +101,7 @@ export function harness({ manifest, integration } = {}) {
95
101
  );
96
102
  }
97
103
  const handler = requireHandler(integration, 'polls', name, 'poll');
104
+ assertRequiredSlots(manifest, state);
98
105
  const result = await withFetch(http.fetch, () =>
99
106
  handler(emitting(ctx, manifest, state))
100
107
  );
@@ -114,6 +121,7 @@ export function harness({ manifest, integration } = {}) {
114
121
  if (typeof integration.listener !== 'function') {
115
122
  fail('the default export has no listener function');
116
123
  }
124
+ assertRequiredSlots(manifest, state);
117
125
  const controller = new AbortController();
118
126
  const listenerCtx = {
119
127
  ...emitting(ctx, manifest, state),
@@ -190,6 +198,8 @@ export function harness({ manifest, integration } = {}) {
190
198
  function buildContext(manifest, state) {
191
199
  const log = (message, data) =>
192
200
  state.logs.push({ level: 'debug', message, data });
201
+ log.info = (message, data) =>
202
+ state.logs.push({ level: 'info', message, data });
193
203
  log.warn = (message, data) =>
194
204
  state.logs.push({ level: 'warn', message, data });
195
205
  log.error = (message, data) =>
@@ -199,6 +209,10 @@ function buildContext(manifest, state) {
199
209
  config: state.config,
200
210
  credentials: state.credentials,
201
211
  log,
212
+ // Present on every surface, as in production. A non-listener test never
213
+ // aborts it, which is also true of an invocation that finishes in time;
214
+ // h.listen replaces it with one it controls.
215
+ signal: state.abort.signal,
202
216
  store: {
203
217
  async get(key) {
204
218
  return state.store.has(key) ? state.store.get(key) : null;
@@ -209,6 +223,12 @@ function buildContext(manifest, state) {
209
223
  // that passes on an object production never sees.
210
224
  state.store.set(key, JSON.parse(JSON.stringify(value ?? null)));
211
225
  },
226
+ async delete(key) {
227
+ // Deleting an absent key is not an error here either — the host
228
+ // answers {ok} for both, and a handler must not learn to branch on a
229
+ // difference that does not exist.
230
+ state.store.delete(key);
231
+ },
212
232
  },
213
233
  problems: {
214
234
  async report(key, message, opts = {}) {
@@ -257,6 +277,29 @@ function defaultConfig(manifest) {
257
277
  return out;
258
278
  }
259
279
 
280
+ /**
281
+ * Fails when a required slot is unset, before the handler runs.
282
+ *
283
+ * An install cannot start with a required slot empty, so a handler never sees
284
+ * one — but nothing stopped a test from running that way, and the generated
285
+ * types say `string` while the value is `undefined`. That combination produces
286
+ * a green test for a state production forbids, and a crash on the first
287
+ * `token.trim()` the types promised was safe.
288
+ */
289
+ function assertRequiredSlots(manifest, state) {
290
+ for (const [name, slot] of Object.entries(manifest.config ?? {})) {
291
+ if (!slot.required) continue;
292
+ const held = slot.credential ? state.credentials : state.config;
293
+ if (held[name] !== undefined) continue;
294
+ const setter = slot.credential ? 'credentials' : 'config';
295
+ fail(
296
+ `required ${slot.credential ? 'credential' : 'config'} slot "${name}" is not set — ` +
297
+ `an install with it empty is rejected, so a handler never runs without it. ` +
298
+ `Set it with h.${setter}({ ${name}: … }).`
299
+ );
300
+ }
301
+ }
302
+
260
303
  /** Rejects a slot name the manifest never declared, or one of the wrong kind. */
261
304
  function assertSlots(manifest, values, wantCredential) {
262
305
  const kind = wantCredential ? 'credential' : 'config';
@@ -264,7 +307,7 @@ function assertSlots(manifest, values, wantCredential) {
264
307
  const slot = manifest.config?.[name];
265
308
  if (!slot) {
266
309
  fail(
267
- `"${name}" is not a config slot declared in manifest.json (have: ${names(manifest.config)})`
310
+ `"${name}" is not a ${kind} slot declared in manifest.json (have: ${names(manifest.config)})`
268
311
  );
269
312
  }
270
313
  if (Boolean(slot.credential) !== wantCredential) {