@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 +31 -31
- package/package.json +2 -8
- package/src/http.js +24 -4
- package/src/index.d.ts +8 -3
- package/src/index.js +44 -1
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,
|
|
5
|
-
|
|
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'
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
23
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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**,
|
|
87
|
-
|
|
88
|
-
is
|
|
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.
|
|
4
|
-
"description": "Test harness for Juno integrations
|
|
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
|
-
(
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
|
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) {
|