@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 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.poll(name)` | run a poll |
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, a `listener` function exactly when the
102
- manifest declares a `listener` section, and every schema compiling. Returns all
103
- problems rather than the first — "you declared six actions and implemented four"
104
- is one fix, not four runs.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@junoflow/testkit",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "Test harness for Juno integrations",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,7 +1,22 @@
1
1
  import { schemaError } from './schema.js';
2
2
 
3
- /** The only keys a default export may carry. */
4
- const SURFACES = ['actions', 'webhooks', 'polls', 'listener'];
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
- ['polls', 'poll'],
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
- polls?: Record<string, unknown>;
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
- polls?: Record<string, (ctx: never) => unknown>;
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
- poll(name: string): Promise<void>;
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 poll(name) {
98
- if (!manifest.polls?.[name]) {
101
+ async schedule(name, event) {
102
+ if (!manifest.schedules?.[name]) {
99
103
  fail(
100
- `poll "${name}" is not declared in manifest.json (have: ${names(manifest.polls)})`
104
+ `schedule "${name}" is not declared in manifest.json (have: ${names(manifest.schedules)})`
101
105
  );
102
106
  }
103
- const handler = requireHandler(integration, 'polls', name, 'poll');
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
- `poll "${name}" returned a value; a poll reports through ctx.emit and returns nothing`
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 {