@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 +42 -18
- package/package.json +1 -1
- package/src/index.d.ts +16 -4
- package/src/index.js +65 -18
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,
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
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
|
-
|
|
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
|
-
|
|
109
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
170
|
-
|
|
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.
|
|
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
|
-
|
|
234
|
-
async
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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
|
|
246
|
-
|
|
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 {
|