@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 +22 -0
- package/package.json +1 -1
- package/src/index.d.ts +10 -0
- package/src/index.js +102 -5
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
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
|
|
512
|
+
headers,
|
|
443
513
|
query: request.query ?? {},
|
|
444
|
-
body
|
|
445
|
-
|
|
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;
|