@junoflow/testkit 0.9.0 → 0.11.0
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 +36 -34
- package/package.json +1 -1
- package/src/flow.js +25 -1
- package/src/index.d.ts +18 -1
- package/src/index.js +34 -5
- package/src/webhooks.js +41 -0
package/README.md
CHANGED
|
@@ -47,24 +47,25 @@ plain form works.
|
|
|
47
47
|
|
|
48
48
|
`harness({ manifest, integration })` returns:
|
|
49
49
|
|
|
50
|
-
| Member | Does
|
|
51
|
-
| -------------------------------------- |
|
|
52
|
-
| `h.config(values)` | set non-credential slots (manifest defaults are already applied)
|
|
53
|
-
| `h.credentials(values)` | set credential slots
|
|
54
|
-
| `h.
|
|
55
|
-
| `h.
|
|
56
|
-
| `h.
|
|
57
|
-
| `h.
|
|
58
|
-
| `h.
|
|
59
|
-
| `h.
|
|
60
|
-
| `h.
|
|
61
|
-
| `h.
|
|
62
|
-
| `h.
|
|
63
|
-
| `h.
|
|
64
|
-
| `h.
|
|
65
|
-
| `h.
|
|
66
|
-
| `h.
|
|
67
|
-
| `h.
|
|
50
|
+
| Member | Does |
|
|
51
|
+
| -------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
|
|
52
|
+
| `h.config(values)` | set non-credential slots (manifest defaults are already applied) |
|
|
53
|
+
| `h.credentials(values)` | set credential slots |
|
|
54
|
+
| `h.webhookUrls(values)` | set `ctx.webhooks.<slot>.url`; each slot starts at `https://juno.test/webhooks/test-<slot>`, `null` is "no public URL" |
|
|
55
|
+
| `h.action(name, params)` | run an action, returns its result |
|
|
56
|
+
| `h.webhook(name, request?)` | run a webhook; `request` defaults to a `POST` with an empty body, to the slot's URL |
|
|
57
|
+
| `h.schedule(name, event?)` | run a schedule handler |
|
|
58
|
+
| `h.hook(name)` | run a declared lifecycle hook (e.g. `activated`) |
|
|
59
|
+
| `h.listen({ until, events, timeout })` | run the listener until the condition holds, then abort |
|
|
60
|
+
| `h.store` | the KV, as a plain `Map` (`get`/`set`/`delete` all land here) |
|
|
61
|
+
| `h.findings()` | what `ctx.findings` asserted, as `{slug, key, severity, text, standing}` |
|
|
62
|
+
| `h.states()` | values `ctx.states.publish` has sent, keyed by local name |
|
|
63
|
+
| `h.status()` | the last `ctx.status.set` report, or `null` if it never reported |
|
|
64
|
+
| `h.emitted()` | `[{ event, payload }]`, in order |
|
|
65
|
+
| `h.logs()` | `[{ level, message, data }]` |
|
|
66
|
+
| `h.conformance()` | manifest-vs-module problems; `[]` when they agree |
|
|
67
|
+
| `h.http` | the request mock, below |
|
|
68
|
+
| `h.reset()` | clear recorded state, keeping config and credentials |
|
|
68
69
|
|
|
69
70
|
### `h.http`
|
|
70
71
|
|
|
@@ -234,22 +235,23 @@ it('greets when someone is home', async () => {
|
|
|
234
235
|
|
|
235
236
|
`flowHarness({ manifest, flow, now?, timezone?, solar? })` returns:
|
|
236
237
|
|
|
237
|
-
| Member | Does
|
|
238
|
-
| ---------------------------------------------- |
|
|
239
|
-
| `h.trigger(name, { event })` | run a declared trigger; resolves to the handler's return value
|
|
240
|
-
| `h.scenario(s)` | set the world from one JSON object (below)
|
|
241
|
-
| `h.now(iso)` / `h.timezone(tz)` / `h.solar(v)` | what `ctx.now()` and `ctx.solar()` answer; the timezone is `UTC` unless set
|
|
242
|
-
| `h.states(values)` / `h.state(name)` | seed a `read` or `read_write` binding; read its current value
|
|
243
|
-
| `h.stateCalls()` | `set`, `claim`, `release`, `signal`, `retract`, as `{binding, verb, value?, opts?}`
|
|
244
|
-
| `h.targets.answer(key, a)` / `.calls()` | answer `binding.method` with a value or a function of the params; the calls made
|
|
245
|
-
| `h.controls.answer(key, a)` / `.calls()` | answer `binding.verb`; the commands made
|
|
246
|
-
| `h.llm(id, answer)` | answer `step.llm(id, …)`; `id#N` answers only that occurrence
|
|
247
|
-
| `h.steps()` | every `step.run` and `step.llm`, as `{id, output}`; repeats are `id`, `id#1`, `id#2`
|
|
248
|
-
| `h.findings()` | `{slug, key, severity, text, standing}`, collapsed by `(slug, key)` as the host does
|
|
249
|
-
| `h.credentials(values)` | set `ctx.credentials` by declared id
|
|
250
|
-
| `h.
|
|
251
|
-
| `h.
|
|
252
|
-
| `h.
|
|
238
|
+
| Member | Does |
|
|
239
|
+
| ---------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
|
|
240
|
+
| `h.trigger(name, { event })` | run a declared trigger; resolves to the handler's return value |
|
|
241
|
+
| `h.scenario(s)` | set the world from one JSON object (below) |
|
|
242
|
+
| `h.now(iso)` / `h.timezone(tz)` / `h.solar(v)` | what `ctx.now()` and `ctx.solar()` answer; the timezone is `UTC` unless set |
|
|
243
|
+
| `h.states(values)` / `h.state(name)` | seed a `read` or `read_write` binding; read its current value |
|
|
244
|
+
| `h.stateCalls()` | `set`, `claim`, `release`, `signal`, `retract`, as `{binding, verb, value?, opts?}` |
|
|
245
|
+
| `h.targets.answer(key, a)` / `.calls()` | answer `binding.method` with a value or a function of the params; the calls made |
|
|
246
|
+
| `h.controls.answer(key, a)` / `.calls()` | answer `binding.verb`; the commands made |
|
|
247
|
+
| `h.llm(id, answer)` | answer `step.llm(id, …)`; `id#N` answers only that occurrence |
|
|
248
|
+
| `h.steps()` | every `step.run` and `step.llm`, as `{id, output}`; repeats are `id`, `id#1`, `id#2` |
|
|
249
|
+
| `h.findings()` | `{slug, key, severity, text, standing}`, collapsed by `(slug, key)` as the host does |
|
|
250
|
+
| `h.credentials(values)` | set `ctx.credentials` by declared id |
|
|
251
|
+
| `h.webhookUrls(values)` | set `ctx.webhooks['<id>'].url`; each starts at `https://juno.test/webhooks/test-<id>`, `null` is "no public URL" |
|
|
252
|
+
| `h.logs()` | `[{ level, message, data }]` |
|
|
253
|
+
| `h.conformance()` | triggers declared vs implemented, both ways; `[]` when they agree |
|
|
254
|
+
| `h.reset()` | clear recordings, answers, seeds and scoped state; keep clock, timezone, solar, credentials, webhook URLs |
|
|
253
255
|
|
|
254
256
|
`h.trigger(name, { event })` sets `event.trigger` and spreads the payload you
|
|
255
257
|
give onto the event, as the sandbox does. An array (a `batch` run) stays under
|
package/package.json
CHANGED
package/src/flow.js
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
import { flowConformance } from './flow-conformance.js';
|
|
9
9
|
import { names } from './names.js';
|
|
10
10
|
import { applyScenario } from './scenario.js';
|
|
11
|
+
import { createWebhookEndpoints } from './webhooks.js';
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* flowHarness({ manifest, flow }) builds the ctx the flow shim builds, in
|
|
@@ -46,6 +47,17 @@ export function flowHarness({ manifest, flow, now, timezone, solar } = {}) {
|
|
|
46
47
|
};
|
|
47
48
|
// Chainable setters on the binding half return the harness itself.
|
|
48
49
|
const bindings = createBindings(manifest, () => h);
|
|
50
|
+
// ctx.webhooks is keyed by the webhook ids the triggers name, each once.
|
|
51
|
+
const webhooks = createWebhookEndpoints(
|
|
52
|
+
[
|
|
53
|
+
...new Set(
|
|
54
|
+
Object.values(triggers)
|
|
55
|
+
.filter((t) => t?.type === 'webhook' && t.webhook?.id)
|
|
56
|
+
.map((t) => t.webhook.id)
|
|
57
|
+
),
|
|
58
|
+
],
|
|
59
|
+
'a webhook id a trigger in manifest.json names'
|
|
60
|
+
);
|
|
49
61
|
|
|
50
62
|
const h = {
|
|
51
63
|
/**
|
|
@@ -142,9 +154,20 @@ export function flowHarness({ manifest, flow, now, timezone, solar } = {}) {
|
|
|
142
154
|
|
|
143
155
|
...bindings.h,
|
|
144
156
|
|
|
157
|
+
/**
|
|
158
|
+
* Sets ctx.webhooks['<id>'].url. Every webhook a trigger names starts at a
|
|
159
|
+
* stand-in URL; null is what the host sends with no public URL. Kept
|
|
160
|
+
* across reset().
|
|
161
|
+
*/
|
|
162
|
+
webhookUrls(values = {}) {
|
|
163
|
+
webhooks.set(values);
|
|
164
|
+
return h;
|
|
165
|
+
},
|
|
166
|
+
|
|
145
167
|
/**
|
|
146
168
|
* Clears recordings, answers, seeded states, the scenario's event and
|
|
147
|
-
* scoped memory; keeps the clock, timezone, solar and
|
|
169
|
+
* scoped memory; keeps the clock, timezone, solar, credentials and
|
|
170
|
+
* webhook URLs.
|
|
148
171
|
*/
|
|
149
172
|
reset() {
|
|
150
173
|
rec.steps.length = 0;
|
|
@@ -193,6 +216,7 @@ export function flowHarness({ manifest, flow, now, timezone, solar } = {}) {
|
|
|
193
216
|
return {
|
|
194
217
|
event: flattenTriggerEvent({ trigger, payload }),
|
|
195
218
|
...bindings.ctx(refuse),
|
|
219
|
+
webhooks: webhooks.ctx(),
|
|
196
220
|
|
|
197
221
|
step: {
|
|
198
222
|
async run(id, fn) {
|
package/src/index.d.ts
CHANGED
|
@@ -93,6 +93,10 @@ export interface WebhookRequestInit {
|
|
|
93
93
|
query?: Record<string, string>;
|
|
94
94
|
/** A string is sent as-is; anything else is JSON.stringify'd. */
|
|
95
95
|
body?: unknown;
|
|
96
|
+
/**
|
|
97
|
+
* Defaults to what the host builds: the slot's ctx.webhooks URL, then
|
|
98
|
+
* subpath and query — or "/" when the slot's URL is null.
|
|
99
|
+
*/
|
|
96
100
|
url?: string;
|
|
97
101
|
subpath?: string;
|
|
98
102
|
}
|
|
@@ -185,6 +189,12 @@ export interface ListenOptions {
|
|
|
185
189
|
export interface Harness {
|
|
186
190
|
config(values: Record<string, unknown>): Harness;
|
|
187
191
|
credentials(values: Record<string, string>): Harness;
|
|
192
|
+
/**
|
|
193
|
+
* Sets ctx.webhooks.<slot>.url by declared slot. Every slot starts at a
|
|
194
|
+
* stand-in URL (https://juno.test/webhooks/test-<slot>); null is what the
|
|
195
|
+
* host sends with no public URL or no webhook bound. Kept across reset().
|
|
196
|
+
*/
|
|
197
|
+
webhookUrls(values: Record<string, string | null>): Harness;
|
|
188
198
|
action(name: string, params?: unknown): Promise<unknown>;
|
|
189
199
|
webhook(
|
|
190
200
|
name: string,
|
|
@@ -486,9 +496,16 @@ export interface FlowHarness {
|
|
|
486
496
|
findings(): RecordedFinding[];
|
|
487
497
|
/** Sets ctx.credentials by declared id. Kept across reset(). */
|
|
488
498
|
credentials(values: Record<string, string>): FlowHarness;
|
|
499
|
+
/**
|
|
500
|
+
* Sets ctx.webhooks['<id>'].url for a webhook id a trigger names. Each starts
|
|
501
|
+
* at a stand-in URL (https://juno.test/webhooks/test-<id>); null is what the
|
|
502
|
+
* host sends with no public URL. Kept across reset().
|
|
503
|
+
*/
|
|
504
|
+
webhookUrls(values: Record<string, string | null>): FlowHarness;
|
|
489
505
|
/**
|
|
490
506
|
* Clears recordings, answers, seeded states, the scenario's event and
|
|
491
|
-
* scoped memory; keeps the clock, timezone, solar and
|
|
507
|
+
* scoped memory; keeps the clock, timezone, solar, credentials and webhook
|
|
508
|
+
* URLs.
|
|
492
509
|
*/
|
|
493
510
|
reset(): FlowHarness;
|
|
494
511
|
}
|
package/src/index.js
CHANGED
|
@@ -4,6 +4,7 @@ import { names } from './names.js';
|
|
|
4
4
|
import { conformance } from './conformance.js';
|
|
5
5
|
import { createHttpMock, withFetch } from './http.js';
|
|
6
6
|
import { assertValid } from './schema.js';
|
|
7
|
+
import { createWebhookEndpoints } from './webhooks.js';
|
|
7
8
|
|
|
8
9
|
/** How long h.listen waits before aborting, when nothing else says. */
|
|
9
10
|
const DEFAULT_LISTEN_TIMEOUT_MS = 1000;
|
|
@@ -51,7 +52,11 @@ export function harness({ manifest, integration } = {}) {
|
|
|
51
52
|
};
|
|
52
53
|
|
|
53
54
|
const http = createHttpMock();
|
|
54
|
-
const
|
|
55
|
+
const webhooks = createWebhookEndpoints(
|
|
56
|
+
Object.keys(manifest.webhooks ?? {}),
|
|
57
|
+
'a webhook slot in manifest.json'
|
|
58
|
+
);
|
|
59
|
+
const ctx = buildContext(manifest, state, webhooks);
|
|
55
60
|
|
|
56
61
|
const h = {
|
|
57
62
|
/** Merge values into ctx.config. Rejects a slot the manifest never declared. */
|
|
@@ -68,6 +73,16 @@ export function harness({ manifest, integration } = {}) {
|
|
|
68
73
|
return h;
|
|
69
74
|
},
|
|
70
75
|
|
|
76
|
+
/**
|
|
77
|
+
* Sets ctx.webhooks.<slot>.url. Every declared slot starts at a stand-in
|
|
78
|
+
* URL; null is what the host sends with no public URL or no webhook bound.
|
|
79
|
+
* Kept across reset().
|
|
80
|
+
*/
|
|
81
|
+
webhookUrls(values = {}) {
|
|
82
|
+
webhooks.set(values);
|
|
83
|
+
return h;
|
|
84
|
+
},
|
|
85
|
+
|
|
71
86
|
async action(name, params) {
|
|
72
87
|
const declared = manifest.actions?.[name];
|
|
73
88
|
if (!declared) {
|
|
@@ -94,7 +109,7 @@ export function harness({ manifest, integration } = {}) {
|
|
|
94
109
|
}
|
|
95
110
|
const handler = requireHandler(integration, 'webhooks', name, 'webhook');
|
|
96
111
|
assertRequiredSlots(manifest, state);
|
|
97
|
-
const req = buildWebhookRequest(request);
|
|
112
|
+
const req = buildWebhookRequest(request, webhooks.url(name));
|
|
98
113
|
|
|
99
114
|
const result = await withFetch(http.fetch, () =>
|
|
100
115
|
handler(req, emitting(ctx, manifest, state))
|
|
@@ -249,7 +264,7 @@ export function harness({ manifest, integration } = {}) {
|
|
|
249
264
|
* cannot tell the difference, which is the only way a test here means anything
|
|
250
265
|
* about how the code runs there.
|
|
251
266
|
*/
|
|
252
|
-
function buildContext(manifest, state) {
|
|
267
|
+
function buildContext(manifest, state, webhooks) {
|
|
253
268
|
const log = (message, data) =>
|
|
254
269
|
state.logs.push({ level: 'debug', message, data });
|
|
255
270
|
log.info = (message, data) =>
|
|
@@ -262,6 +277,11 @@ function buildContext(manifest, state) {
|
|
|
262
277
|
return {
|
|
263
278
|
config: state.config,
|
|
264
279
|
credentials: state.credentials,
|
|
280
|
+
// A getter, so a URL set after the harness was built is what the next
|
|
281
|
+
// invocation reads — the host resolves it per invocation too.
|
|
282
|
+
get webhooks() {
|
|
283
|
+
return webhooks.ctx();
|
|
284
|
+
},
|
|
265
285
|
log,
|
|
266
286
|
// Present on every surface, as in production. A non-listener test never
|
|
267
287
|
// aborts it, which is also true of an invocation that finishes in time;
|
|
@@ -468,7 +488,10 @@ function requireHandler(integration, surface, name, kind) {
|
|
|
468
488
|
return handler;
|
|
469
489
|
}
|
|
470
490
|
|
|
471
|
-
|
|
491
|
+
// url defaults to what the host would build: the slot's URL, then the subpath
|
|
492
|
+
// and the query string — so a signature over the URL verifies in a test as it
|
|
493
|
+
// does in production. With no URL for the slot it stays '/'.
|
|
494
|
+
function buildWebhookRequest(request, slotURL) {
|
|
472
495
|
const body =
|
|
473
496
|
typeof request.body === 'string'
|
|
474
497
|
? request.body
|
|
@@ -480,12 +503,18 @@ function buildWebhookRequest(request) {
|
|
|
480
503
|
query: request.query ?? {},
|
|
481
504
|
body,
|
|
482
505
|
json: parseJSONOrNull(body),
|
|
483
|
-
url: request.url ??
|
|
506
|
+
url: request.url ?? requestURL(slotURL, request),
|
|
484
507
|
subpath: request.subpath ?? '',
|
|
485
508
|
form: parseFormOrNull(headers, body),
|
|
486
509
|
};
|
|
487
510
|
}
|
|
488
511
|
|
|
512
|
+
function requestURL(slotURL, request) {
|
|
513
|
+
if (slotURL === null) return '/';
|
|
514
|
+
const query = new URLSearchParams(request.query ?? {}).toString();
|
|
515
|
+
return slotURL + (request.subpath ?? '') + (query ? `?${query}` : '');
|
|
516
|
+
}
|
|
517
|
+
|
|
489
518
|
// The host's rule: lower-cased content-type, media type
|
|
490
519
|
// application/x-www-form-urlencoded (a charset parameter is ignored), first
|
|
491
520
|
// value per key — else null.
|
package/src/webhooks.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { fail } from './assert.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The URL a webhook has until a test sets one: shaped like Juno's
|
|
5
|
+
* (`<public URL>/webhooks/<token>`), so a handler's happy path — the one where
|
|
6
|
+
* the site has a public URL and the webhook is bound — is what runs by default.
|
|
7
|
+
*/
|
|
8
|
+
export function defaultWebhookURL(key) {
|
|
9
|
+
return `https://juno.test/webhooks/test-${key}`;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* ctx.webhooks and the h.webhookUrls() that sets it. keys are what the host
|
|
14
|
+
* would send: an integration's declared slots, or the webhook ids a flow's
|
|
15
|
+
* triggers name. Like credentials, the URLs are a fixture: kept across reset().
|
|
16
|
+
*/
|
|
17
|
+
export function createWebhookEndpoints(keys, describe) {
|
|
18
|
+
const urls = new Map(keys.map((k) => [k, defaultWebhookURL(k)]));
|
|
19
|
+
return {
|
|
20
|
+
/** Sets URLs by key; null is the host's answer with no public URL or no binding. */
|
|
21
|
+
set(values = {}) {
|
|
22
|
+
for (const [key, url] of Object.entries(values)) {
|
|
23
|
+
if (!urls.has(key)) {
|
|
24
|
+
fail(
|
|
25
|
+
`h.webhookUrls — "${key}" is not ${describe} (have: ${[...urls.keys()].join(', ') || '(none)'})`
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
if (url !== null && typeof url !== 'string') {
|
|
29
|
+
fail(
|
|
30
|
+
`h.webhookUrls — "${key}" must be a URL string or null, got ${JSON.stringify(url)}`
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
urls.set(key, url);
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
url: (key) => urls.get(key) ?? null,
|
|
37
|
+
/** A fresh object per invocation, as init sends one per run. */
|
|
38
|
+
ctx: () =>
|
|
39
|
+
Object.fromEntries([...urls].map(([key, url]) => [key, { url }])),
|
|
40
|
+
};
|
|
41
|
+
}
|