@junoflow/testkit 0.1.1 → 0.1.2

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
@@ -15,7 +15,7 @@ import integration from '../src/integration';
15
15
 
16
16
  const h = harness({ manifest, integration });
17
17
 
18
- // Mocked routes, the store, problems, events and logs otherwise outlive an
18
+ // Mocked routes, the store, findings, events and logs otherwise outlive an
19
19
  // `it`, and the test that fails is whichever one ran second.
20
20
  beforeEach(() => h.reset());
21
21
 
@@ -28,7 +28,8 @@ test('sendNotification posts to the API and returns the receipt', async () => {
28
28
  expect(await h.action('sendNotification', { message: 'hi' })).toEqual({
29
29
  request: 'r1',
30
30
  });
31
- expect(h.problems()).toEqual([]);
31
+ // Nothing standing: the success path proved the credentials work.
32
+ expect(h.findings()).toEqual([]);
32
33
  });
33
34
 
34
35
  test('conforms to its manifest', () => {
@@ -45,21 +46,21 @@ plain form works.
45
46
 
46
47
  `harness({ manifest, integration })` returns:
47
48
 
48
- | Member | Does |
49
- | -------------------------------------- | ---------------------------------------------------------------- |
50
- | `h.config(values)` | set non-credential slots (manifest defaults are already applied) |
51
- | `h.credentials(values)` | set credential slots |
52
- | `h.action(name, params)` | run an action, returns its result |
53
- | `h.webhook(name, request?)` | run a webhook; `request` defaults to `POST /` with an empty body |
54
- | `h.poll(name)` | run a poll |
55
- | `h.listen({ until, events, timeout })` | run the listener until the condition holds, then abort |
56
- | `h.store` | the KV, as a plain `Map` (`get`/`set`/`delete` all land here) |
57
- | `h.problems()` | what `ctx.problems.report` recorded |
58
- | `h.emitted()` | `[{ event, payload }]`, in order |
59
- | `h.logs()` | `[{ level, message, data }]` |
60
- | `h.conformance()` | manifest-vs-module problems; `[]` when they agree |
61
- | `h.http` | the request mock, below |
62
- | `h.reset()` | clear recorded state, keeping config and credentials |
49
+ | Member | Does |
50
+ | -------------------------------------- | ------------------------------------------------------------------------ |
51
+ | `h.config(values)` | set non-credential slots (manifest defaults are already applied) |
52
+ | `h.credentials(values)` | set credential slots |
53
+ | `h.action(name, params)` | run an action, returns its result |
54
+ | `h.webhook(name, request?)` | run a webhook; `request` defaults to `POST /` with an empty body |
55
+ | `h.poll(name)` | run a poll |
56
+ | `h.listen({ until, events, timeout })` | run the listener until the condition holds, then abort |
57
+ | `h.store` | the KV, as a plain `Map` (`get`/`set`/`delete` all land here) |
58
+ | `h.findings()` | what `ctx.findings` asserted, as `{slug, key, severity, text, standing}` |
59
+ | `h.emitted()` | `[{ event, payload }]`, in order |
60
+ | `h.logs()` | `[{ level, message, data }]` |
61
+ | `h.conformance()` | manifest-vs-module problems; `[]` when they agree |
62
+ | `h.http` | the request mock, below |
63
+ | `h.reset()` | clear recorded state, keeping config and credentials |
63
64
 
64
65
  ### `h.http`
65
66
 
@@ -102,13 +103,36 @@ webhook or poll to produce them, and every schema compiling. Returns all
102
103
  problems rather than the first — "you declared six actions and implemented four"
103
104
  is one fix, not four runs.
104
105
 
106
+ ### `h.findings()`
107
+
108
+ ```ts
109
+ expect(h.findings()).toEqual([
110
+ {
111
+ slug: 'badCredentials',
112
+ key: '',
113
+ severity: 'error',
114
+ text: 'Pushover rejected the credentials',
115
+ standing: true,
116
+ },
117
+ ]);
118
+ ```
119
+
120
+ `open`, `clear` and `set` all land here, each returning the transition it made
121
+ (`{ opened, cleared }`, both false when it refreshed or retracted nothing). An
122
+ undeclared name **fails the test**, naming the declared ones — Juno rejects it
123
+ at runtime, and hearing that from an installer is not a test. An instance the
124
+ handler retracted stays in the list with `standing: false`; one that was never
125
+ open records nothing at all, so `expect(h.findings()).toEqual([])` is the
126
+ success-path assertion for a handler written with `set()`.
127
+
105
128
  ## What the harness is faithful about
106
129
 
107
130
  - `ctx.store` round-trips values through JSON, because the real store does. A
108
131
  `Date` comes back as a string here exactly as it would in production.
109
132
  - Config defaults declared in the manifest are applied before your test runs,
110
133
  because the runtime always supplies them.
111
- - `ctx.problems.report` upserts by key, as the server does.
134
+ - `ctx.findings` refreshes in place on `(slug, key)`, as the store does, and
135
+ rejects a name the manifest does not declare, as the host does.
112
136
  - A required slot must be set before a handler runs. An install cannot start
113
137
  with one empty, so a handler never sees one — a test that skipped
114
138
  `h.credentials(...)` would be green for a state production forbids, with
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@junoflow/testkit",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Test harness for Juno integrations",
5
5
  "license": "MIT",
6
6
  "repository": {
package/src/index.d.ts CHANGED
@@ -37,6 +37,10 @@ export interface Manifest {
37
37
  { params?: object; response?: object; [k: string]: unknown }
38
38
  >;
39
39
  events?: Record<string, { payload?: object; [k: string]: unknown }>;
40
+ findings?: Record<
41
+ string,
42
+ { title?: string; severity?: string; ttl?: string; [k: string]: unknown }
43
+ >;
40
44
  webhooks?: Record<string, unknown>;
41
45
  polls?: Record<string, unknown>;
42
46
  [k: string]: unknown;
@@ -103,10 +107,18 @@ export interface HttpMock {
103
107
  reset(): HttpMock;
104
108
  }
105
109
 
106
- export interface ReportedProblem {
110
+ /**
111
+ * One instance of a declared finding, as the harness recorded it. A retracted
112
+ * instance stays in the list with `standing: false`; one that was never opened
113
+ * is absent entirely.
114
+ */
115
+ export interface RecordedFinding {
116
+ /** The name as the manifest declares it — the harness does not prefix the install. */
117
+ slug: string;
107
118
  key: string;
108
- message: string;
109
- severity: 'error' | 'warning';
119
+ severity: 'info' | 'warn' | 'error';
120
+ text: string;
121
+ standing: boolean;
110
122
  }
111
123
 
112
124
  export interface EmittedEvent {
@@ -141,7 +153,7 @@ export interface Harness {
141
153
  listen(options?: ListenOptions): Promise<void>;
142
154
  /** The integration's KV, as an ordinary Map. */
143
155
  readonly store: Map<string, unknown>;
144
- problems(): ReportedProblem[];
156
+ findings(): RecordedFinding[];
145
157
  emitted(): EmittedEvent[];
146
158
  logs(): LogEntry[];
147
159
  /** Manifest-vs-module problems; empty when they agree. */
package/src/index.js CHANGED
@@ -32,7 +32,7 @@ export function harness({ manifest, integration } = {}) {
32
32
  config: defaultConfig(manifest),
33
33
  credentials: {},
34
34
  store: new Map(),
35
- problems: [],
35
+ findings: [],
36
36
  emitted: [],
37
37
  logs: [],
38
38
  // ctx.signal for every surface but the listener, which brings its own.
@@ -166,8 +166,14 @@ export function harness({ manifest, integration } = {}) {
166
166
 
167
167
  /** The integration's KV, as an ordinary Map. */
168
168
  store: state.store,
169
- /** Problems the handler reported through ctx.problems. */
170
- problems: () => state.problems.slice(),
169
+ /**
170
+ * Findings the handler asserted through ctx.findings, in the order they
171
+ * were first asserted. An instance the handler retracted stays in the list
172
+ * with `standing: false` — that a handler cleared what it opened is itself
173
+ * worth asserting — while a retraction of something never open records
174
+ * nothing, exactly as the host no-ops it.
175
+ */
176
+ findings: () => state.findings.map((f) => ({ ...f })),
171
177
  /** Events the handler put on the bus, in order. */
172
178
  emitted: () => state.emitted.slice(),
173
179
  /** Everything the handler logged, with its level. */
@@ -178,7 +184,7 @@ export function harness({ manifest, integration } = {}) {
178
184
 
179
185
  /** Clears everything a handler recorded, keeping config and credentials. */
180
186
  reset() {
181
- state.problems.length = 0;
187
+ state.findings.length = 0;
182
188
  state.emitted.length = 0;
183
189
  state.logs.length = 0;
184
190
  state.store.clear();
@@ -230,26 +236,67 @@ function buildContext(manifest, state) {
230
236
  state.store.delete(key);
231
237
  },
232
238
  },
233
- problems: {
234
- async report(key, message, opts = {}) {
235
- const severity = opts.severity ?? 'error';
236
- if (severity !== 'error' && severity !== 'warning') {
237
- fail(
238
- `ctx.problems.report severity must be "error" or "warning", got ${JSON.stringify(severity)}`
239
- );
240
- }
241
- const existing = state.problems.find((p) => p.key === key);
242
- if (existing) Object.assign(existing, { message, severity });
243
- else state.problems.push({ key, message, severity });
239
+ findings: {
240
+ async open(slug, opts = {}) {
241
+ return setFinding(manifest, state, slug, true, opts);
242
+ },
243
+ async clear(slug, opts = {}) {
244
+ return setFinding(manifest, state, slug, false, opts);
244
245
  },
245
- async clear(key) {
246
- const at = state.problems.findIndex((p) => p.key === key);
247
- if (at >= 0) state.problems.splice(at, 1);
246
+ async set(slug, standing, opts = {}) {
247
+ return setFinding(manifest, state, slug, standing === true, opts);
248
248
  },
249
249
  },
250
250
  };
251
251
  }
252
252
 
253
+ /**
254
+ * The one call behind open/clear/set, mirroring the SDK and the host: the verbs
255
+ * differ only in whether the boolean is already decided, and the answer is the
256
+ * transition this call made. Both false means it refreshed an instance that was
257
+ * already standing, or retracted one that was already absent.
258
+ *
259
+ * The name is checked against the manifest for the same reason emit's is: Juno
260
+ * rejects an undeclared finding at runtime, and finding that out from an
261
+ * installer is not a test.
262
+ */
263
+ function setFinding(manifest, state, slug, standing, opts) {
264
+ const declared = manifest.findings?.[slug];
265
+ if (!declared) {
266
+ fail(
267
+ `ctx.findings — "${slug}" is not declared in manifest.json (have: ${names(manifest.findings)}). ` +
268
+ 'Juno rejects an undeclared finding at runtime.'
269
+ );
270
+ }
271
+ const key = opts.key ?? '';
272
+ const existing = state.findings.find((f) => f.slug === slug && f.key === key);
273
+
274
+ if (!standing) {
275
+ // A retraction says nothing about severity or text, and clearing what was
276
+ // never open is a no-op here exactly as it is in the store.
277
+ if (!existing || !existing.standing)
278
+ return { opened: false, cleared: false };
279
+ existing.standing = false;
280
+ return { opened: false, cleared: true };
281
+ }
282
+
283
+ const severity = opts.severity ?? declared.severity ?? 'error';
284
+ if (!['info', 'warn', 'error'].includes(severity)) {
285
+ fail(
286
+ `ctx.findings severity must be "info", "warn" or "error", got ${JSON.stringify(severity)}`
287
+ );
288
+ }
289
+ const text = opts.text ?? '';
290
+ if (existing) {
291
+ // Refresh in place — never a second instance for the same (slug, key).
292
+ const opened = !existing.standing;
293
+ Object.assign(existing, { severity, text, standing: true });
294
+ return { opened, cleared: false };
295
+ }
296
+ state.findings.push({ slug, key, severity, text, standing: true });
297
+ return { opened: true, cleared: false };
298
+ }
299
+
253
300
  /** Adds ctx.emit, which every surface but an action has. */
254
301
  function emitting(baseCtx, manifest, state) {
255
302
  return {