@junoflow/testkit 0.1.4 → 0.3.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
@@ -52,10 +52,13 @@ plain form works.
52
52
  | `h.credentials(values)` | set credential slots |
53
53
  | `h.action(name, params)` | run an action, returns its result |
54
54
  | `h.webhook(name, request?)` | run a webhook; `request` defaults to `POST /` with an empty body |
55
- | `h.poll(name)` | run a poll |
55
+ | `h.schedule(name, event?)` | run a schedule handler |
56
+ | `h.hook(name)` | run a declared lifecycle hook (e.g. `activated`) |
56
57
  | `h.listen({ until, events, timeout })` | run the listener until the condition holds, then abort |
57
58
  | `h.store` | the KV, as a plain `Map` (`get`/`set`/`delete` all land here) |
58
59
  | `h.findings()` | what `ctx.findings` asserted, as `{slug, key, severity, text, standing}` |
60
+ | `h.states()` | values `ctx.states.publish` has sent, keyed by local name |
61
+ | `h.status()` | the last `ctx.status.set` report, or `null` if it never reported |
59
62
  | `h.emitted()` | `[{ event, payload }]`, in order |
60
63
  | `h.logs()` | `[{ level, message, data }]` |
61
64
  | `h.conformance()` | manifest-vs-module problems; `[]` when they agree |
@@ -98,10 +101,13 @@ production it hangs the supervisor on shutdown instead.
98
101
  ### `h.conformance()`
99
102
 
100
103
  Checks the manifest and the module against each other: every declared surface
101
- implemented, nothing undeclared exported, a `listener` function exactly when the
102
- manifest declares a `listener` section, and every schema compiling. Returns all
103
- problems rather than the first — "you declared six actions and implemented four"
104
- is one fix, not four runs.
104
+ implemented (actions, webhooks, schedules, hooks), nothing undeclared exported,
105
+ a `listener` function exactly when the manifest declares a `listener` section,
106
+ every hook name inside Juno's closed vocabulary, and every schema compiling.
107
+ `states` is a manifest declaration, not a handler surface, so it plays no part
108
+ here — see [`h.states()`](#hstates) instead. Returns all problems rather than
109
+ the first — "you declared six actions and implemented four" is one fix, not four
110
+ runs.
105
111
 
106
112
  ### `h.findings()`
107
113
 
@@ -125,6 +131,51 @@ handler retracted stays in the list with `standing: false`; one that was never
125
131
  open records nothing at all, so `expect(h.findings()).toEqual([])` is the
126
132
  success-path assertion for a handler written with `set()`.
127
133
 
134
+ ### `h.states()`
135
+
136
+ ```ts
137
+ await h.schedule('refresh');
138
+ expect(h.states()).toEqual({ workday: true, 'day-kind': 'workday' });
139
+ ```
140
+
141
+ `ctx.states.publish(values)` is on the base context, so an action, webhook,
142
+ schedule or hook can all call it. `h.states()` is the current picture — a second
143
+ publish of the same name overwrites, it does not accumulate — keyed by the local
144
+ name the handler used, not the `<install>:<name>` id Juno scopes it to on the
145
+ wire. An undeclared name **fails the test**, the same way an undeclared finding
146
+ or event does.
147
+
148
+ ### `h.status()`
149
+
150
+ ```ts
151
+ await h.schedule('fetchForecast');
152
+ expect(h.status()).toEqual({
153
+ summary: '2.4 °C, cloudy — 96 intervals to 2026-09-27',
154
+ details: { intervals: 96 },
155
+ });
156
+ ```
157
+
158
+ `ctx.status.set` is on the base context, like `ctx.states.publish`. The report
159
+ is replaced on every write, so `h.status()` is the last one sent, and `null`
160
+ when the handler never reported. That is a result worth asserting too: an
161
+ integration with nothing to say should leave the status absent. `details` is
162
+ left out when the report had none or had an empty map. A report Juno would
163
+ reject **fails the test**: an empty or multi-line summary, a summary longer than
164
+ 200 characters, or a detail that is not a string, number or boolean. The error
165
+ names the key.
166
+
167
+ ### `h.hook(name)`
168
+
169
+ ```ts
170
+ await h.hook('activated');
171
+ ```
172
+
173
+ Runs a declared lifecycle hook with the same context a schedule gets. An
174
+ undeclared hook, or one whose handler returns a value, **fails the test** — "a
175
+ hook reports through ctx and returns nothing" is what production says too.
176
+ `h.conformance()` also rejects a hook name outside Juno's closed vocabulary
177
+ (today, only `activated`), which is the check that catches a typo before deploy.
178
+
128
179
  ## What the harness is faithful about
129
180
 
130
181
  - `ctx.store` round-trips values through JSON, because the real store does. A
@@ -133,6 +184,8 @@ success-path assertion for a handler written with `set()`.
133
184
  because the runtime always supplies them.
134
185
  - `ctx.findings` refreshes in place on `(slug, key)`, as the store does, and
135
186
  rejects a name the manifest does not declare, as the host does.
187
+ - `ctx.status.set` details round-trip through JSON before validation, as they do
188
+ on the wire, so a `NaN` shows up as `null` and is rejected there as well.
136
189
  - A required slot must be set before a handler runs. An install cannot start
137
190
  with one empty, so a handler never sees one — a test that skipped
138
191
  `h.credentials(...)` would be green for a state production forbids, with
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@junoflow/testkit",
3
- "version": "0.1.4",
3
+ "version": "0.3.0",
4
4
  "description": "Test harness for Juno integrations",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,7 +1,22 @@
1
1
  import { schemaError } from './schema.js';
2
2
 
3
- /** The only keys a default export may carry. */
4
- const SURFACES = ['actions', 'webhooks', 'polls', 'listener'];
3
+ /**
4
+ * The only keys a default export may carry. `states` is deliberately absent:
5
+ * it is a manifest declaration published through ctx.states.publish, not a
6
+ * map of handlers, so it never appears here and never enters the
7
+ * declared/implemented pairing loop below the way actions/webhooks/schedules/
8
+ * hooks do.
9
+ */
10
+ const SURFACES = ['actions', 'webhooks', 'schedules', 'listener', 'hooks'];
11
+
12
+ /**
13
+ * The lifecycle moments the host knows how to reach — mirrors
14
+ * manifest.HookNames in internal/manifest/manifest.go. A hook name is not
15
+ * author-chosen, unlike a webhook's or a schedule's: an unrecognised one could
16
+ * only ever be a handler that never runs, which is exactly the typo this check
17
+ * catches before deploy.
18
+ */
19
+ const HOOK_NAMES = ['activated'];
5
20
 
6
21
  /**
7
22
  * Checks the manifest and the module against each other and returns a list of
@@ -24,7 +39,8 @@ export function conformance(manifest, integration) {
24
39
  for (const [surface, kind] of [
25
40
  ['actions', 'action'],
26
41
  ['webhooks', 'webhook'],
27
- ['polls', 'poll'],
42
+ ['schedules', 'schedule'],
43
+ ['hooks', 'hook'],
28
44
  ]) {
29
45
  const declared = Object.keys(manifest[surface] ?? {});
30
46
  const implemented = Object.keys(integration?.[surface] ?? {});
@@ -63,6 +79,14 @@ export function conformance(manifest, integration) {
63
79
  );
64
80
  }
65
81
 
82
+ for (const name of Object.keys(manifest.hooks ?? {})) {
83
+ if (!HOOK_NAMES.includes(name)) {
84
+ problems.push(
85
+ `hook "${name}" is not a lifecycle moment Juno knows; available: ${HOOK_NAMES.join(', ')}`
86
+ );
87
+ }
88
+ }
89
+
66
90
  // Every schema has to compile: an invalid one would silently validate
67
91
  // nothing here and be rejected on deploy.
68
92
  for (const [name, action] of Object.entries(manifest.actions ?? {})) {
package/src/index.d.ts CHANGED
@@ -42,16 +42,49 @@ export interface Manifest {
42
42
  { title?: string; severity?: string; ttl?: string; [k: string]: unknown }
43
43
  >;
44
44
  webhooks?: Record<string, unknown>;
45
- polls?: Record<string, unknown>;
45
+ schedules?: Record<string, unknown>;
46
+ /**
47
+ * Scalar levels this integration publishes with ctx.states.publish. Keyed
48
+ * by the local name; the host scopes it to `<install>:<name>`.
49
+ */
50
+ states?: Record<
51
+ string,
52
+ {
53
+ type?: string;
54
+ name?: string;
55
+ description?: string;
56
+ keywords?: string[];
57
+ values?: string[];
58
+ min?: number;
59
+ max?: number;
60
+ step?: number;
61
+ unit?: string;
62
+ [k: string]: unknown;
63
+ }
64
+ >;
65
+ /** Lifecycle hooks, from the closed vocabulary Juno defines — today, `activated`. */
66
+ hooks?: Record<
67
+ string,
68
+ { description?: string; timeout?: string; [k: string]: unknown }
69
+ >;
46
70
  [k: string]: unknown;
47
71
  }
48
72
 
73
+ /** The occurrence a schedule handler runs for. */
74
+ export interface ScheduleEvent {
75
+ /** ISO 8601. May be in the past after a coalesced catch-up. */
76
+ scheduled_at: string;
77
+ /** ISO 8601. */
78
+ fired_at: string;
79
+ }
80
+
49
81
  /** The module's default export, as Juno loads it. */
50
82
  export interface IntegrationModule {
51
83
  actions?: Record<string, (params: never, ctx: never) => unknown>;
52
84
  webhooks?: Record<string, (req: never, ctx: never) => unknown>;
53
- polls?: Record<string, (ctx: never) => unknown>;
85
+ schedules?: Record<string, (event: ScheduleEvent, ctx: never) => unknown>;
54
86
  listener?: (ctx: never) => unknown;
87
+ hooks?: Record<string, (ctx: never) => unknown>;
55
88
  }
56
89
 
57
90
  export interface WebhookRequestInit {
@@ -121,6 +154,13 @@ export interface RecordedFinding {
121
154
  standing: boolean;
122
155
  }
123
156
 
157
+ /** The install's status report, as ctx.status.set last replaced it. */
158
+ export interface RecordedStatus {
159
+ summary: string;
160
+ /** Absent when the report carried none, or an empty map. */
161
+ details?: Record<string, string | number | boolean>;
162
+ }
163
+
124
164
  export interface EmittedEvent {
125
165
  event: string;
126
166
  payload: unknown;
@@ -149,11 +189,17 @@ export interface Harness {
149
189
  name: string,
150
190
  request?: WebhookRequestInit
151
191
  ): Promise<WebhookResult | void>;
152
- poll(name: string): Promise<void>;
192
+ schedule(name: string, event?: ScheduleEvent): Promise<void>;
193
+ /** Runs a declared lifecycle hook (e.g. `activated`) with the same context a schedule gets. */
194
+ hook(name: string): Promise<void>;
153
195
  listen(options?: ListenOptions): Promise<void>;
154
196
  /** The integration's KV, as an ordinary Map. */
155
197
  readonly store: Map<string, unknown>;
156
198
  findings(): RecordedFinding[];
199
+ /** Values ctx.states.publish has sent so far, keyed by local name. */
200
+ states(): Record<string, unknown>;
201
+ /** The last ctx.status.set report, or null when the handler never reported. */
202
+ status(): RecordedStatus | null;
157
203
  emitted(): EmittedEvent[];
158
204
  logs(): LogEntry[];
159
205
  /** Manifest-vs-module problems; empty when they agree. */
package/src/index.js CHANGED
@@ -35,6 +35,13 @@ export function harness({ manifest, integration } = {}) {
35
35
  findings: [],
36
36
  emitted: [],
37
37
  logs: [],
38
+ // Values ctx.states.publish sent, keyed by local name. A Map so a second
39
+ // publish of the same name overwrites rather than accumulates — h.states()
40
+ // is the current picture, not a log.
41
+ publishedStates: new Map(),
42
+ // The last ctx.status.set report, or null before the first. One slot, not
43
+ // a list: the host replaces the report on every write.
44
+ status: null,
38
45
  // ctx.signal for every surface but the listener, which brings its own.
39
46
  // Never aborted here: the host aborts it on a timeout or a shutdown, and a
40
47
  // test has neither.
@@ -94,20 +101,46 @@ export function harness({ manifest, integration } = {}) {
94
101
  return result;
95
102
  },
96
103
 
97
- async poll(name) {
98
- if (!manifest.polls?.[name]) {
104
+ async schedule(name, event) {
105
+ if (!manifest.schedules?.[name]) {
99
106
  fail(
100
- `poll "${name}" is not declared in manifest.json (have: ${names(manifest.polls)})`
107
+ `schedule "${name}" is not declared in manifest.json (have: ${names(manifest.schedules)})`
101
108
  );
102
109
  }
103
- const handler = requireHandler(integration, 'polls', name, 'poll');
110
+ const handler = requireHandler(
111
+ integration,
112
+ 'schedules',
113
+ name,
114
+ 'schedule'
115
+ );
116
+ assertRequiredSlots(manifest, state);
117
+ const now = new Date().toISOString();
118
+ const ev = event ?? { scheduled_at: now, fired_at: now };
119
+ const result = await withFetch(http.fetch, () =>
120
+ handler(ev, emitting(ctx, manifest, state))
121
+ );
122
+ if (result !== undefined && result !== null) {
123
+ fail(
124
+ `schedule "${name}" returned a value; a schedule reports through ctx.emit and returns nothing`
125
+ );
126
+ }
127
+ },
128
+
129
+ /** Runs a declared lifecycle hook (e.g. `activated`) with the same context a schedule gets. */
130
+ async hook(name) {
131
+ if (!manifest.hooks?.[name]) {
132
+ fail(
133
+ `hook "${name}" is not declared in manifest.json (have: ${names(manifest.hooks)})`
134
+ );
135
+ }
136
+ const handler = requireHandler(integration, 'hooks', name, 'hook');
104
137
  assertRequiredSlots(manifest, state);
105
138
  const result = await withFetch(http.fetch, () =>
106
139
  handler(emitting(ctx, manifest, state))
107
140
  );
108
141
  if (result !== undefined && result !== null) {
109
142
  fail(
110
- `poll "${name}" returned a value; a poll reports through ctx.emit and returns nothing`
143
+ `hook "${name}" returned a value; a hook reports through ctx and returns nothing`
111
144
  );
112
145
  }
113
146
  },
@@ -174,6 +207,17 @@ export function harness({ manifest, integration } = {}) {
174
207
  * nothing, exactly as the host no-ops it.
175
208
  */
176
209
  findings: () => state.findings.map((f) => ({ ...f })),
210
+ /** Values ctx.states.publish has sent so far, keyed by local name. */
211
+ states: () => Object.fromEntries(state.publishedStates),
212
+ /**
213
+ * The last report ctx.status.set sent, or null when the handler never
214
+ * reported — absence is a legitimate outcome, not an empty report.
215
+ */
216
+ status: () =>
217
+ state.status && {
218
+ ...state.status,
219
+ ...(state.status.details && { details: { ...state.status.details } }),
220
+ },
177
221
  /** Events the handler put on the bus, in order. */
178
222
  emitted: () => state.emitted.slice(),
179
223
  /** Everything the handler logged, with its level. */
@@ -187,6 +231,8 @@ export function harness({ manifest, integration } = {}) {
187
231
  state.findings.length = 0;
188
232
  state.emitted.length = 0;
189
233
  state.logs.length = 0;
234
+ state.publishedStates.clear();
235
+ state.status = null;
190
236
  state.store.clear();
191
237
  http.reset();
192
238
  return h;
@@ -247,6 +293,16 @@ function buildContext(manifest, state) {
247
293
  return setFinding(manifest, state, slug, standing === true, opts);
248
294
  },
249
295
  },
296
+ states: {
297
+ async publish(values) {
298
+ publishStates(manifest, state, values);
299
+ },
300
+ },
301
+ status: {
302
+ async set(report) {
303
+ setStatus(state, report);
304
+ },
305
+ },
250
306
  };
251
307
  }
252
308
 
@@ -297,6 +353,75 @@ function setFinding(manifest, state, slug, standing, opts) {
297
353
  return { opened: true, cleared: false };
298
354
  }
299
355
 
356
+ /**
357
+ * The one call behind ctx.states.publish. Every name is checked against the
358
+ * manifest before any is recorded — a batch is all-or-nothing, mirroring
359
+ * PublishOwned's one lock hold, so a test can never observe a partial
360
+ * publish. The name is checked for the same reason emit's and a finding's
361
+ * slug are: Juno rejects an undeclared state at runtime, and finding that out
362
+ * from an installer is not a test.
363
+ */
364
+ function publishStates(manifest, state, values) {
365
+ for (const name of Object.keys(values ?? {})) {
366
+ if (!manifest.states?.[name]) {
367
+ fail(
368
+ `ctx.states.publish — "${name}" is not declared in manifest.json (have: ${names(manifest.states)}). ` +
369
+ 'Juno rejects an undeclared state at runtime.'
370
+ );
371
+ }
372
+ }
373
+ for (const [name, value] of Object.entries(values ?? {})) {
374
+ state.publishedStates.set(name, value);
375
+ }
376
+ }
377
+
378
+ /** The longest summary the host accepts, in characters (registry.MaxStatusSummary). */
379
+ const MAX_STATUS_SUMMARY = 200;
380
+
381
+ /**
382
+ * The one call behind ctx.status.set, applying the checks registry.ValidateStatus
383
+ * applies: a non-empty one-line summary of at most 200 characters, and details
384
+ * whose values are all scalars. Details round-trip through JSON first, as they
385
+ * do on the wire — a NaN arrives as null and is rejected there too. An empty
386
+ * details map is stored as none, the way the host writes NULL for it.
387
+ */
388
+ function setStatus(state, report) {
389
+ const summary = report?.summary;
390
+ if (typeof summary !== 'string' || summary.trim() === '') {
391
+ fail('ctx.status.set — status summary is required');
392
+ }
393
+ if (/[\r\n]/.test(summary)) {
394
+ fail(
395
+ 'ctx.status.set — status summary must be one line; put the rest in details'
396
+ );
397
+ }
398
+ // Counted in code points, as the host counts runes.
399
+ const length = [...summary].length;
400
+ if (length > MAX_STATUS_SUMMARY) {
401
+ fail(
402
+ `ctx.status.set — status summary is ${length} characters, the limit is ${MAX_STATUS_SUMMARY}; put the rest in details`
403
+ );
404
+ }
405
+ const details =
406
+ report.details == null ? {} : JSON.parse(JSON.stringify(report.details));
407
+ for (const [key, value] of Object.entries(details)) {
408
+ if (!['string', 'number', 'boolean'].includes(typeof value)) {
409
+ fail(
410
+ `ctx.status.set — status detail ${JSON.stringify(key)} must be a string, number or boolean, got ${describeJSON(value)}`
411
+ );
412
+ }
413
+ }
414
+ state.status = Object.keys(details).length
415
+ ? { summary, details }
416
+ : { summary };
417
+ }
418
+
419
+ function describeJSON(value) {
420
+ if (value === null) return 'null';
421
+ if (Array.isArray(value)) return 'an array';
422
+ return typeof value === 'object' ? 'an object' : typeof value;
423
+ }
424
+
300
425
  /** Adds ctx.emit, which every surface but an action has. */
301
426
  function emitting(baseCtx, manifest, state) {
302
427
  return {
@@ -377,18 +502,31 @@ function requireHandler(integration, surface, name, kind) {
377
502
  }
378
503
 
379
504
  function buildWebhookRequest(request) {
505
+ const body =
506
+ typeof request.body === 'string'
507
+ ? request.body
508
+ : JSON.stringify(request.body ?? '');
380
509
  return {
381
510
  method: (request.method ?? 'POST').toUpperCase(),
382
511
  headers: request.headers ?? {},
383
512
  query: request.query ?? {},
384
- body:
385
- typeof request.body === 'string'
386
- ? request.body
387
- : JSON.stringify(request.body ?? ''),
513
+ body,
514
+ json: parseJSONOrNull(body),
388
515
  url: request.url ?? '/',
389
516
  };
390
517
  }
391
518
 
519
+ // The host's rule (cmd/control-plane readWebhookRequest): whatever parses is
520
+ // parsed, whatever does not is null — content-type is not consulted.
521
+ function parseJSONOrNull(body) {
522
+ if (body === '') return null;
523
+ try {
524
+ return JSON.parse(body);
525
+ } catch {
526
+ return null;
527
+ }
528
+ }
529
+
392
530
  /** A webhook returns nothing, or the {status, headers, body} control shape. */
393
531
  function assertWebhookResponse(name, result) {
394
532
  if (result === undefined || result === null) return;