@junoflow/testkit 0.1.3 → 0.1.5
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 +36 -5
- package/package.json +1 -1
- package/src/conformance.js +27 -3
- package/src/index.d.ts +40 -3
- package/src/index.js +65 -5
package/README.md
CHANGED
|
@@ -52,10 +52,12 @@ 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 |
|
|
59
61
|
| `h.emitted()` | `[{ event, payload }]`, in order |
|
|
60
62
|
| `h.logs()` | `[{ level, message, data }]` |
|
|
61
63
|
| `h.conformance()` | manifest-vs-module problems; `[]` when they agree |
|
|
@@ -98,10 +100,13 @@ production it hangs the supervisor on shutdown instead.
|
|
|
98
100
|
### `h.conformance()`
|
|
99
101
|
|
|
100
102
|
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
|
|
103
|
+
implemented (actions, webhooks, schedules, hooks), nothing undeclared exported,
|
|
104
|
+
a `listener` function exactly when the manifest declares a `listener` section,
|
|
105
|
+
every hook name inside Juno's closed vocabulary, and every schema compiling.
|
|
106
|
+
`states` is a manifest declaration, not a handler surface, so it plays no part
|
|
107
|
+
here — see [`h.states()`](#hstates) instead. Returns all problems rather than
|
|
108
|
+
the first — "you declared six actions and implemented four" is one fix, not four
|
|
109
|
+
runs.
|
|
105
110
|
|
|
106
111
|
### `h.findings()`
|
|
107
112
|
|
|
@@ -125,6 +130,32 @@ handler retracted stays in the list with `standing: false`; one that was never
|
|
|
125
130
|
open records nothing at all, so `expect(h.findings()).toEqual([])` is the
|
|
126
131
|
success-path assertion for a handler written with `set()`.
|
|
127
132
|
|
|
133
|
+
### `h.states()`
|
|
134
|
+
|
|
135
|
+
```ts
|
|
136
|
+
await h.schedule('refresh');
|
|
137
|
+
expect(h.states()).toEqual({ workday: true, 'day-kind': 'workday' });
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
`ctx.states.publish(values)` is on the base context, so an action, webhook,
|
|
141
|
+
schedule or hook can all call it. `h.states()` is the current picture — a second
|
|
142
|
+
publish of the same name overwrites, it does not accumulate — keyed by the local
|
|
143
|
+
name the handler used, not the `<install>:<name>` id Juno scopes it to on the
|
|
144
|
+
wire. An undeclared name **fails the test**, the same way an undeclared finding
|
|
145
|
+
or event does.
|
|
146
|
+
|
|
147
|
+
### `h.hook(name)`
|
|
148
|
+
|
|
149
|
+
```ts
|
|
150
|
+
await h.hook('activated');
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Runs a declared lifecycle hook with the same context a schedule gets. An
|
|
154
|
+
undeclared hook, or one whose handler returns a value, **fails the test** — "a
|
|
155
|
+
hook reports through ctx and returns nothing" is what production says too.
|
|
156
|
+
`h.conformance()` also rejects a hook name outside Juno's closed vocabulary
|
|
157
|
+
(today, only `activated`), which is the check that catches a typo before deploy.
|
|
158
|
+
|
|
128
159
|
## What the harness is faithful about
|
|
129
160
|
|
|
130
161
|
- `ctx.store` round-trips values through JSON, because the real store does. A
|
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 {
|
|
@@ -149,11 +182,15 @@ export interface Harness {
|
|
|
149
182
|
name: string,
|
|
150
183
|
request?: WebhookRequestInit
|
|
151
184
|
): Promise<WebhookResult | void>;
|
|
152
|
-
|
|
185
|
+
schedule(name: string, event?: ScheduleEvent): Promise<void>;
|
|
186
|
+
/** Runs a declared lifecycle hook (e.g. `activated`) with the same context a schedule gets. */
|
|
187
|
+
hook(name: string): Promise<void>;
|
|
153
188
|
listen(options?: ListenOptions): Promise<void>;
|
|
154
189
|
/** The integration's KV, as an ordinary Map. */
|
|
155
190
|
readonly store: Map<string, unknown>;
|
|
156
191
|
findings(): RecordedFinding[];
|
|
192
|
+
/** Values ctx.states.publish has sent so far, keyed by local name. */
|
|
193
|
+
states(): Record<string, unknown>;
|
|
157
194
|
emitted(): EmittedEvent[];
|
|
158
195
|
logs(): LogEntry[];
|
|
159
196
|
/** Manifest-vs-module problems; empty when they agree. */
|
package/src/index.js
CHANGED
|
@@ -35,6 +35,10 @@ 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(),
|
|
38
42
|
// ctx.signal for every surface but the listener, which brings its own.
|
|
39
43
|
// Never aborted here: the host aborts it on a timeout or a shutdown, and a
|
|
40
44
|
// test has neither.
|
|
@@ -94,20 +98,46 @@ export function harness({ manifest, integration } = {}) {
|
|
|
94
98
|
return result;
|
|
95
99
|
},
|
|
96
100
|
|
|
97
|
-
async
|
|
98
|
-
if (!manifest.
|
|
101
|
+
async schedule(name, event) {
|
|
102
|
+
if (!manifest.schedules?.[name]) {
|
|
99
103
|
fail(
|
|
100
|
-
`
|
|
104
|
+
`schedule "${name}" is not declared in manifest.json (have: ${names(manifest.schedules)})`
|
|
101
105
|
);
|
|
102
106
|
}
|
|
103
|
-
const handler = requireHandler(
|
|
107
|
+
const handler = requireHandler(
|
|
108
|
+
integration,
|
|
109
|
+
'schedules',
|
|
110
|
+
name,
|
|
111
|
+
'schedule'
|
|
112
|
+
);
|
|
113
|
+
assertRequiredSlots(manifest, state);
|
|
114
|
+
const now = new Date().toISOString();
|
|
115
|
+
const ev = event ?? { scheduled_at: now, fired_at: now };
|
|
116
|
+
const result = await withFetch(http.fetch, () =>
|
|
117
|
+
handler(ev, emitting(ctx, manifest, state))
|
|
118
|
+
);
|
|
119
|
+
if (result !== undefined && result !== null) {
|
|
120
|
+
fail(
|
|
121
|
+
`schedule "${name}" returned a value; a schedule reports through ctx.emit and returns nothing`
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
|
|
126
|
+
/** Runs a declared lifecycle hook (e.g. `activated`) with the same context a schedule gets. */
|
|
127
|
+
async hook(name) {
|
|
128
|
+
if (!manifest.hooks?.[name]) {
|
|
129
|
+
fail(
|
|
130
|
+
`hook "${name}" is not declared in manifest.json (have: ${names(manifest.hooks)})`
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
const handler = requireHandler(integration, 'hooks', name, 'hook');
|
|
104
134
|
assertRequiredSlots(manifest, state);
|
|
105
135
|
const result = await withFetch(http.fetch, () =>
|
|
106
136
|
handler(emitting(ctx, manifest, state))
|
|
107
137
|
);
|
|
108
138
|
if (result !== undefined && result !== null) {
|
|
109
139
|
fail(
|
|
110
|
-
`
|
|
140
|
+
`hook "${name}" returned a value; a hook reports through ctx and returns nothing`
|
|
111
141
|
);
|
|
112
142
|
}
|
|
113
143
|
},
|
|
@@ -174,6 +204,8 @@ export function harness({ manifest, integration } = {}) {
|
|
|
174
204
|
* nothing, exactly as the host no-ops it.
|
|
175
205
|
*/
|
|
176
206
|
findings: () => state.findings.map((f) => ({ ...f })),
|
|
207
|
+
/** Values ctx.states.publish has sent so far, keyed by local name. */
|
|
208
|
+
states: () => Object.fromEntries(state.publishedStates),
|
|
177
209
|
/** Events the handler put on the bus, in order. */
|
|
178
210
|
emitted: () => state.emitted.slice(),
|
|
179
211
|
/** Everything the handler logged, with its level. */
|
|
@@ -187,6 +219,7 @@ export function harness({ manifest, integration } = {}) {
|
|
|
187
219
|
state.findings.length = 0;
|
|
188
220
|
state.emitted.length = 0;
|
|
189
221
|
state.logs.length = 0;
|
|
222
|
+
state.publishedStates.clear();
|
|
190
223
|
state.store.clear();
|
|
191
224
|
http.reset();
|
|
192
225
|
return h;
|
|
@@ -247,6 +280,11 @@ function buildContext(manifest, state) {
|
|
|
247
280
|
return setFinding(manifest, state, slug, standing === true, opts);
|
|
248
281
|
},
|
|
249
282
|
},
|
|
283
|
+
states: {
|
|
284
|
+
async publish(values) {
|
|
285
|
+
publishStates(manifest, state, values);
|
|
286
|
+
},
|
|
287
|
+
},
|
|
250
288
|
};
|
|
251
289
|
}
|
|
252
290
|
|
|
@@ -297,6 +335,28 @@ function setFinding(manifest, state, slug, standing, opts) {
|
|
|
297
335
|
return { opened: true, cleared: false };
|
|
298
336
|
}
|
|
299
337
|
|
|
338
|
+
/**
|
|
339
|
+
* The one call behind ctx.states.publish. Every name is checked against the
|
|
340
|
+
* manifest before any is recorded — a batch is all-or-nothing, mirroring
|
|
341
|
+
* PublishOwned's one lock hold, so a test can never observe a partial
|
|
342
|
+
* publish. The name is checked for the same reason emit's and a finding's
|
|
343
|
+
* slug are: Juno rejects an undeclared state at runtime, and finding that out
|
|
344
|
+
* from an installer is not a test.
|
|
345
|
+
*/
|
|
346
|
+
function publishStates(manifest, state, values) {
|
|
347
|
+
for (const name of Object.keys(values ?? {})) {
|
|
348
|
+
if (!manifest.states?.[name]) {
|
|
349
|
+
fail(
|
|
350
|
+
`ctx.states.publish — "${name}" is not declared in manifest.json (have: ${names(manifest.states)}). ` +
|
|
351
|
+
'Juno rejects an undeclared state at runtime.'
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
for (const [name, value] of Object.entries(values ?? {})) {
|
|
356
|
+
state.publishedStates.set(name, value);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
300
360
|
/** Adds ctx.emit, which every surface but an action has. */
|
|
301
361
|
function emitting(baseCtx, manifest, state) {
|
|
302
362
|
return {
|