@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 +58 -5
- package/package.json +1 -1
- package/src/conformance.js +27 -3
- package/src/index.d.ts +49 -3
- package/src/index.js +147 -9
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.
|
|
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,
|
|
102
|
-
manifest declares a `listener` section,
|
|
103
|
-
|
|
104
|
-
is
|
|
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
package/src/conformance.js
CHANGED
|
@@ -1,7 +1,22 @@
|
|
|
1
1
|
import { schemaError } from './schema.js';
|
|
2
2
|
|
|
3
|
-
/**
|
|
4
|
-
|
|
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
|
-
['
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
98
|
-
if (!manifest.
|
|
104
|
+
async schedule(name, event) {
|
|
105
|
+
if (!manifest.schedules?.[name]) {
|
|
99
106
|
fail(
|
|
100
|
-
`
|
|
107
|
+
`schedule "${name}" is not declared in manifest.json (have: ${names(manifest.schedules)})`
|
|
101
108
|
);
|
|
102
109
|
}
|
|
103
|
-
const handler = requireHandler(
|
|
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
|
-
`
|
|
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
|
-
|
|
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;
|