@junoflow/testkit 0.10.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 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.action(name, params)` | run an action, returns its result |
55
- | `h.webhook(name, request?)` | run a webhook; `request` defaults to `POST /` with an empty body |
56
- | `h.schedule(name, event?)` | run a schedule handler |
57
- | `h.hook(name)` | run a declared lifecycle hook (e.g. `activated`) |
58
- | `h.listen({ until, events, timeout })` | run the listener until the condition holds, then abort |
59
- | `h.store` | the KV, as a plain `Map` (`get`/`set`/`delete` all land here) |
60
- | `h.findings()` | what `ctx.findings` asserted, as `{slug, key, severity, text, standing}` |
61
- | `h.states()` | values `ctx.states.publish` has sent, keyed by local name |
62
- | `h.status()` | the last `ctx.status.set` report, or `null` if it never reported |
63
- | `h.emitted()` | `[{ event, payload }]`, in order |
64
- | `h.logs()` | `[{ level, message, data }]` |
65
- | `h.conformance()` | manifest-vs-module problems; `[]` when they agree |
66
- | `h.http` | the request mock, below |
67
- | `h.reset()` | clear recorded state, keeping config and credentials |
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.logs()` | `[{ level, message, data }]` |
251
- | `h.conformance()` | triggers declared vs implemented, both ways; `[]` when they agree |
252
- | `h.reset()` | clear recordings, answers, seeds and scoped state; keep clock, timezone, solar, credentials |
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@junoflow/testkit",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "Test harnesses for Juno integrations, flows and apps",
5
5
  "license": "MIT",
6
6
  "repository": {
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 credentials.
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 credentials.
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 ctx = buildContext(manifest, state);
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
- function buildWebhookRequest(request) {
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.
@@ -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
+ }