@junoflow/testkit 0.1.5 → 0.6.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
@@ -58,6 +58,7 @@ plain form works.
58
58
  | `h.store` | the KV, as a plain `Map` (`get`/`set`/`delete` all land here) |
59
59
  | `h.findings()` | what `ctx.findings` asserted, as `{slug, key, severity, text, standing}` |
60
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 |
61
62
  | `h.emitted()` | `[{ event, payload }]`, in order |
62
63
  | `h.logs()` | `[{ level, message, data }]` |
63
64
  | `h.conformance()` | manifest-vs-module problems; `[]` when they agree |
@@ -144,6 +145,25 @@ name the handler used, not the `<install>:<name>` id Juno scopes it to on the
144
145
  wire. An undeclared name **fails the test**, the same way an undeclared finding
145
146
  or event does.
146
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
+
147
167
  ### `h.hook(name)`
148
168
 
149
169
  ```ts
@@ -164,6 +184,8 @@ hook reports through ctx and returns nothing" is what production says too.
164
184
  because the runtime always supplies them.
165
185
  - `ctx.findings` refreshes in place on `(slug, key)`, as the store does, and
166
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.
167
189
  - A required slot must be set before a handler runs. An install cannot start
168
190
  with one empty, so a handler never sees one — a test that skipped
169
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.5",
3
+ "version": "0.6.0",
4
4
  "description": "Test harness for Juno integrations",
5
5
  "license": "MIT",
6
6
  "repository": {
package/src/index.d.ts CHANGED
@@ -94,6 +94,7 @@ export interface WebhookRequestInit {
94
94
  /** A string is sent as-is; anything else is JSON.stringify'd. */
95
95
  body?: unknown;
96
96
  url?: string;
97
+ subpath?: string;
97
98
  }
98
99
 
99
100
  export interface WebhookResult {
@@ -154,6 +155,13 @@ export interface RecordedFinding {
154
155
  standing: boolean;
155
156
  }
156
157
 
158
+ /** The install's status report, as ctx.status.set last replaced it. */
159
+ export interface RecordedStatus {
160
+ summary: string;
161
+ /** Absent when the report carried none, or an empty map. */
162
+ details?: Record<string, string | number | boolean>;
163
+ }
164
+
157
165
  export interface EmittedEvent {
158
166
  event: string;
159
167
  payload: unknown;
@@ -191,6 +199,8 @@ export interface Harness {
191
199
  findings(): RecordedFinding[];
192
200
  /** Values ctx.states.publish has sent so far, keyed by local name. */
193
201
  states(): Record<string, unknown>;
202
+ /** The last ctx.status.set report, or null when the handler never reported. */
203
+ status(): RecordedStatus | null;
194
204
  emitted(): EmittedEvent[];
195
205
  logs(): LogEntry[];
196
206
  /** Manifest-vs-module problems; empty when they agree. */
package/src/index.js CHANGED
@@ -39,6 +39,9 @@ export function harness({ manifest, integration } = {}) {
39
39
  // publish of the same name overwrites rather than accumulates — h.states()
40
40
  // is the current picture, not a log.
41
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,
42
45
  // ctx.signal for every surface but the listener, which brings its own.
43
46
  // Never aborted here: the host aborts it on a timeout or a shutdown, and a
44
47
  // test has neither.
@@ -206,6 +209,15 @@ export function harness({ manifest, integration } = {}) {
206
209
  findings: () => state.findings.map((f) => ({ ...f })),
207
210
  /** Values ctx.states.publish has sent so far, keyed by local name. */
208
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
+ },
209
221
  /** Events the handler put on the bus, in order. */
210
222
  emitted: () => state.emitted.slice(),
211
223
  /** Everything the handler logged, with its level. */
@@ -220,6 +232,7 @@ export function harness({ manifest, integration } = {}) {
220
232
  state.emitted.length = 0;
221
233
  state.logs.length = 0;
222
234
  state.publishedStates.clear();
235
+ state.status = null;
223
236
  state.store.clear();
224
237
  http.reset();
225
238
  return h;
@@ -285,6 +298,11 @@ function buildContext(manifest, state) {
285
298
  publishStates(manifest, state, values);
286
299
  },
287
300
  },
301
+ status: {
302
+ async set(report) {
303
+ setStatus(state, report);
304
+ },
305
+ },
288
306
  };
289
307
  }
290
308
 
@@ -357,6 +375,53 @@ function publishStates(manifest, state, values) {
357
375
  }
358
376
  }
359
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
+
360
425
  /** Adds ctx.emit, which every surface but an action has. */
361
426
  function emitting(baseCtx, manifest, state) {
362
427
  return {
@@ -437,18 +502,50 @@ function requireHandler(integration, surface, name, kind) {
437
502
  }
438
503
 
439
504
  function buildWebhookRequest(request) {
505
+ const body =
506
+ typeof request.body === 'string'
507
+ ? request.body
508
+ : JSON.stringify(request.body ?? '');
509
+ const headers = request.headers ?? {};
440
510
  return {
441
511
  method: (request.method ?? 'POST').toUpperCase(),
442
- headers: request.headers ?? {},
512
+ headers,
443
513
  query: request.query ?? {},
444
- body:
445
- typeof request.body === 'string'
446
- ? request.body
447
- : JSON.stringify(request.body ?? ''),
514
+ body,
515
+ json: parseJSONOrNull(body),
448
516
  url: request.url ?? '/',
517
+ subpath: request.subpath ?? '',
518
+ form: parseFormOrNull(headers, body),
449
519
  };
450
520
  }
451
521
 
522
+ // The host's rule: lower-cased content-type, media type
523
+ // application/x-www-form-urlencoded (a charset parameter is ignored), first
524
+ // value per key — else null.
525
+ function parseFormOrNull(headers, body) {
526
+ const contentType = (headers['content-type'] ?? headers['Content-Type'] ?? '')
527
+ .toLowerCase()
528
+ .split(';')[0]
529
+ .trim();
530
+ if (contentType !== 'application/x-www-form-urlencoded') return null;
531
+ const form = {};
532
+ for (const [key, value] of new URLSearchParams(body)) {
533
+ if (!(key in form)) form[key] = value;
534
+ }
535
+ return form;
536
+ }
537
+
538
+ // The host's rule (cmd/control-plane readWebhookRequest): whatever parses is
539
+ // parsed, whatever does not is null — content-type is not consulted.
540
+ function parseJSONOrNull(body) {
541
+ if (body === '') return null;
542
+ try {
543
+ return JSON.parse(body);
544
+ } catch {
545
+ return null;
546
+ }
547
+ }
548
+
452
549
  /** A webhook returns nothing, or the {status, headers, body} control shape. */
453
550
  function assertWebhookResponse(name, result) {
454
551
  if (result === undefined || result === null) return;