@junoflow/testkit 0.1.5 → 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 +22 -0
- package/package.json +1 -1
- package/src/index.d.ts +9 -0
- package/src/index.js +82 -4
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
|
@@ -154,6 +154,13 @@ export interface RecordedFinding {
|
|
|
154
154
|
standing: boolean;
|
|
155
155
|
}
|
|
156
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
|
+
|
|
157
164
|
export interface EmittedEvent {
|
|
158
165
|
event: string;
|
|
159
166
|
payload: unknown;
|
|
@@ -191,6 +198,8 @@ export interface Harness {
|
|
|
191
198
|
findings(): RecordedFinding[];
|
|
192
199
|
/** Values ctx.states.publish has sent so far, keyed by local name. */
|
|
193
200
|
states(): Record<string, unknown>;
|
|
201
|
+
/** The last ctx.status.set report, or null when the handler never reported. */
|
|
202
|
+
status(): RecordedStatus | null;
|
|
194
203
|
emitted(): EmittedEvent[];
|
|
195
204
|
logs(): LogEntry[];
|
|
196
205
|
/** 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,31 @@ 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 ?? '');
|
|
440
509
|
return {
|
|
441
510
|
method: (request.method ?? 'POST').toUpperCase(),
|
|
442
511
|
headers: request.headers ?? {},
|
|
443
512
|
query: request.query ?? {},
|
|
444
|
-
body
|
|
445
|
-
|
|
446
|
-
? request.body
|
|
447
|
-
: JSON.stringify(request.body ?? ''),
|
|
513
|
+
body,
|
|
514
|
+
json: parseJSONOrNull(body),
|
|
448
515
|
url: request.url ?? '/',
|
|
449
516
|
};
|
|
450
517
|
}
|
|
451
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
|
+
|
|
452
530
|
/** A webhook returns nothing, or the {status, headers, body} control shape. */
|
|
453
531
|
function assertWebhookResponse(name, result) {
|
|
454
532
|
if (result === undefined || result === null) return;
|