@junoflow/testkit 0.1.0 → 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 +71 -47
- package/package.json +2 -8
- package/src/http.js +24 -4
- package/src/index.d.ts +24 -7
- package/src/index.js +109 -19
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# @junoflow/testkit
|
|
2
2
|
|
|
3
3
|
Test harness for [Juno](https://github.com/boivie/juno) integrations. It runs
|
|
4
|
-
your handlers with the same `ctx` the sandbox builds,
|
|
5
|
-
|
|
4
|
+
your handlers with the same `ctx` the sandbox builds, but with a different
|
|
5
|
+
implementation for tests.
|
|
6
6
|
|
|
7
7
|
```bash
|
|
8
8
|
npm install --save-dev @junoflow/testkit
|
|
@@ -10,19 +10,26 @@ npm install --save-dev @junoflow/testkit
|
|
|
10
10
|
|
|
11
11
|
```ts
|
|
12
12
|
import { harness } from '@junoflow/testkit';
|
|
13
|
-
import manifest from '../manifest.json'
|
|
13
|
+
import manifest from '../manifest.json';
|
|
14
14
|
import integration from '../src/integration';
|
|
15
15
|
|
|
16
16
|
const h = harness({ manifest, integration });
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
18
|
+
// Mocked routes, the store, findings, events and logs otherwise outlive an
|
|
19
|
+
// `it`, and the test that fails is whichever one ran second.
|
|
20
|
+
beforeEach(() => h.reset());
|
|
21
21
|
|
|
22
|
-
|
|
23
|
-
|
|
22
|
+
test('sendNotification posts to the API and returns the receipt', async () => {
|
|
23
|
+
h.credentials({ appToken: 'tok', userKey: 'usr' });
|
|
24
|
+
h.http.post('https://api.pushover.net/1/messages.json', {
|
|
25
|
+
json: { status: 1, request: 'r1' },
|
|
24
26
|
});
|
|
25
|
-
|
|
27
|
+
|
|
28
|
+
expect(await h.action('sendNotification', { message: 'hi' })).toEqual({
|
|
29
|
+
request: 'r1',
|
|
30
|
+
});
|
|
31
|
+
// Nothing standing: the success path proved the credentials work.
|
|
32
|
+
expect(h.findings()).toEqual([]);
|
|
26
33
|
});
|
|
27
34
|
|
|
28
35
|
test('conforms to its manifest', () => {
|
|
@@ -30,45 +37,30 @@ test('conforms to its manifest', () => {
|
|
|
30
37
|
});
|
|
31
38
|
```
|
|
32
39
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
Juno checks the contract when it **runs** an integration, which is too late to
|
|
39
|
-
be a test — an installer finds out instead. The harness moves that forward:
|
|
40
|
-
|
|
41
|
-
| Crossing | Checked against |
|
|
42
|
-
| ---------------------------- | ------------------------------------ |
|
|
43
|
-
| action params in | the action's `params` schema |
|
|
44
|
-
| action result out | the action's `response` schema |
|
|
45
|
-
| `ctx.emit(name, …)` | `events` — an undeclared name throws |
|
|
46
|
-
| emitted payload | the event's `payload` schema |
|
|
47
|
-
| webhook return value | `{ status?, headers?, body? }` |
|
|
48
|
-
| `h.config` / `h.credentials` | the declared slot, and its kind |
|
|
49
|
-
|
|
50
|
-
The failure mode this catches is the one that actually bites installers: the
|
|
51
|
-
manifest says one thing and the code does another.
|
|
40
|
+
Note the plain `import manifest from '../manifest.json'` — the shared
|
|
41
|
+
`tsconfig.base.json` compiles as CommonJS, where an import attribute
|
|
42
|
+
(`with { type: 'json' }`) is a TS2823 error. `resolveJsonModule` is on, so the
|
|
43
|
+
plain form works.
|
|
52
44
|
|
|
53
45
|
## API
|
|
54
46
|
|
|
55
47
|
`harness({ manifest, integration })` returns:
|
|
56
48
|
|
|
57
|
-
| Member | Does
|
|
58
|
-
| -------------------------------------- |
|
|
59
|
-
| `h.config(values)` | set non-credential slots (manifest defaults are already applied)
|
|
60
|
-
| `h.credentials(values)` | set credential slots
|
|
61
|
-
| `h.action(name, params)` | run an action, returns its result
|
|
62
|
-
| `h.webhook(name, request?)` | run a webhook; `request` defaults to `POST /` with an empty body
|
|
63
|
-
| `h.poll(name)` | run a poll
|
|
64
|
-
| `h.listen({ until, events, timeout })` | run the listener until the condition holds, then abort
|
|
65
|
-
| `h.store` | the KV, as a plain `Map`
|
|
66
|
-
| `h.
|
|
67
|
-
| `h.emitted()` | `[{ event, payload }]`, in order
|
|
68
|
-
| `h.logs()` | `[{ level, message, data }]`
|
|
69
|
-
| `h.conformance()` | manifest-vs-module problems; `[]` when they agree
|
|
70
|
-
| `h.http` | the request mock, below
|
|
71
|
-
| `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 |
|
|
72
64
|
|
|
73
65
|
### `h.http`
|
|
74
66
|
|
|
@@ -83,9 +75,14 @@ h.http.post('https://api.example.com/send', (req) => ({ echoed: req.body }));
|
|
|
83
75
|
h.http.requests; // every request a handler made, in order
|
|
84
76
|
```
|
|
85
77
|
|
|
86
|
-
An **unmatched request throws**,
|
|
87
|
-
|
|
88
|
-
is
|
|
78
|
+
An **unmatched request throws**, to ensure that all HTTP calls are expected.
|
|
79
|
+
|
|
80
|
+
A canned answer is either the upstream's JSON body verbatim, or the envelope
|
|
81
|
+
`{ status?, headers?, json?, body? }`. The two are told apart by an _exclusive_
|
|
82
|
+
key match: an object is an envelope only when **every** key it has is one of
|
|
83
|
+
those four. So an upstream payload that happens to contain `status` — Pushover's
|
|
84
|
+
`{ status: 1, request: '…' }` — is a body, as written. When a payload consists
|
|
85
|
+
only of envelope keys, say so: `{ json: { status: 1 } }`.
|
|
89
86
|
|
|
90
87
|
### `h.listen`
|
|
91
88
|
|
|
@@ -106,10 +103,37 @@ webhook or poll to produce them, and every schema compiling. Returns all
|
|
|
106
103
|
problems rather than the first — "you declared six actions and implemented four"
|
|
107
104
|
is one fix, not four runs.
|
|
108
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
|
+
|
|
109
128
|
## What the harness is faithful about
|
|
110
129
|
|
|
111
130
|
- `ctx.store` round-trips values through JSON, because the real store does. A
|
|
112
131
|
`Date` comes back as a string here exactly as it would in production.
|
|
113
132
|
- Config defaults declared in the manifest are applied before your test runs,
|
|
114
133
|
because the runtime always supplies them.
|
|
115
|
-
- `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.
|
|
136
|
+
- A required slot must be set before a handler runs. An install cannot start
|
|
137
|
+
with one empty, so a handler never sees one — a test that skipped
|
|
138
|
+
`h.credentials(...)` would be green for a state production forbids, with
|
|
139
|
+
`undefined` sitting where the generated types promise a `string`.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@junoflow/testkit",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "Test harness for Juno integrations
|
|
3
|
+
"version": "0.1.2",
|
|
4
|
+
"description": "Test harness for Juno integrations",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
@@ -20,12 +20,6 @@
|
|
|
20
20
|
"src",
|
|
21
21
|
"README.md"
|
|
22
22
|
],
|
|
23
|
-
"keywords": [
|
|
24
|
-
"juno",
|
|
25
|
-
"junoflow",
|
|
26
|
-
"testing",
|
|
27
|
-
"integration"
|
|
28
|
-
],
|
|
29
23
|
"scripts": {
|
|
30
24
|
"test": "node --test \"test/**/*.test.js\""
|
|
31
25
|
},
|
package/src/http.js
CHANGED
|
@@ -74,19 +74,31 @@ function matches(pattern, url) {
|
|
|
74
74
|
return pattern === url;
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
+
/** The keys an envelope is allowed to have, and nothing else. */
|
|
78
|
+
const ENVELOPE_KEYS = ['status', 'headers', 'json', 'body'];
|
|
79
|
+
|
|
77
80
|
/**
|
|
78
81
|
* Accepts the two shapes a test wants to write: a bare object (200 JSON, the
|
|
79
82
|
* overwhelmingly common case) or a full {status, headers, json|body}.
|
|
83
|
+
*
|
|
84
|
+
* The two are told apart by an *exclusive* key match — every key must be an
|
|
85
|
+
* envelope key — rather than by asking whether any envelope key is present.
|
|
86
|
+
* `status` is one of the most common keys in a REST payload (Pushover answers
|
|
87
|
+
* `{"status": 1, "request": "…"}`), and sniffing for it meant the natural mock
|
|
88
|
+
* for such an API was silently read as an envelope: `{status: 1}` threw a
|
|
89
|
+
* RangeError from undici pointing at the author's fetch call, and `{status:
|
|
90
|
+
* 200, …}` was worse, quietly delivering an empty body to correct code. An
|
|
91
|
+
* upstream payload almost never consists solely of envelope keys; when it does,
|
|
92
|
+
* the range guard below names the fix rather than letting undici do it.
|
|
80
93
|
*/
|
|
81
94
|
function toResponse(answer) {
|
|
82
95
|
if (answer instanceof Response) return answer;
|
|
83
96
|
const isEnvelope =
|
|
84
97
|
answer !== null &&
|
|
85
98
|
typeof answer === 'object' &&
|
|
86
|
-
(
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
'headers' in answer);
|
|
99
|
+
!Array.isArray(answer) &&
|
|
100
|
+
Object.keys(answer).length > 0 &&
|
|
101
|
+
Object.keys(answer).every((k) => ENVELOPE_KEYS.includes(k));
|
|
90
102
|
|
|
91
103
|
const {
|
|
92
104
|
status = 200,
|
|
@@ -94,6 +106,14 @@ function toResponse(answer) {
|
|
|
94
106
|
json,
|
|
95
107
|
body,
|
|
96
108
|
} = isEnvelope ? answer : { json: answer };
|
|
109
|
+
if (!Number.isInteger(status) || status < 100 || status > 599) {
|
|
110
|
+
fail(
|
|
111
|
+
`mock response has status ${JSON.stringify(status)}, which is not an HTTP status.\n` +
|
|
112
|
+
`Every key of ${JSON.stringify(answer)} happens to be an envelope key, so it was read as ` +
|
|
113
|
+
`{status, headers, json, body} rather than as a body.\n` +
|
|
114
|
+
`If it is the upstream's payload, wrap it: { json: ${JSON.stringify(answer)} }.`
|
|
115
|
+
);
|
|
116
|
+
}
|
|
97
117
|
if (json !== undefined) {
|
|
98
118
|
return new Response(JSON.stringify(json), {
|
|
99
119
|
status,
|
package/src/index.d.ts
CHANGED
|
@@ -20,7 +20,7 @@ export interface ManifestConfigSlot {
|
|
|
20
20
|
type: string;
|
|
21
21
|
title?: string;
|
|
22
22
|
description?: string;
|
|
23
|
-
|
|
23
|
+
docsUrl?: string;
|
|
24
24
|
default?: unknown;
|
|
25
25
|
required?: boolean;
|
|
26
26
|
credential?: boolean;
|
|
@@ -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;
|
|
@@ -72,7 +76,12 @@ export interface RecordedRequest {
|
|
|
72
76
|
body: BodyInit | null;
|
|
73
77
|
}
|
|
74
78
|
|
|
75
|
-
/**
|
|
79
|
+
/**
|
|
80
|
+
* A canned answer: the upstream's JSON body verbatim (the common case), or the
|
|
81
|
+
* `{status, headers, json, body}` envelope — recognised only when *every* key
|
|
82
|
+
* is one of those four, so a payload that merely contains `status` stays a
|
|
83
|
+
* body. Wrap one that does not: `{ json: { status: 1 } }`.
|
|
84
|
+
*/
|
|
76
85
|
export type MockResponse =
|
|
77
86
|
| object
|
|
78
87
|
| Response
|
|
@@ -98,10 +107,18 @@ export interface HttpMock {
|
|
|
98
107
|
reset(): HttpMock;
|
|
99
108
|
}
|
|
100
109
|
|
|
101
|
-
|
|
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;
|
|
102
118
|
key: string;
|
|
103
|
-
|
|
104
|
-
|
|
119
|
+
severity: 'info' | 'warn' | 'error';
|
|
120
|
+
text: string;
|
|
121
|
+
standing: boolean;
|
|
105
122
|
}
|
|
106
123
|
|
|
107
124
|
export interface EmittedEvent {
|
|
@@ -110,7 +127,7 @@ export interface EmittedEvent {
|
|
|
110
127
|
}
|
|
111
128
|
|
|
112
129
|
export interface LogEntry {
|
|
113
|
-
level: 'debug' | 'warn' | 'error';
|
|
130
|
+
level: 'debug' | 'info' | 'warn' | 'error';
|
|
114
131
|
message: string;
|
|
115
132
|
data?: unknown;
|
|
116
133
|
}
|
|
@@ -136,7 +153,7 @@ export interface Harness {
|
|
|
136
153
|
listen(options?: ListenOptions): Promise<void>;
|
|
137
154
|
/** The integration's KV, as an ordinary Map. */
|
|
138
155
|
readonly store: Map<string, unknown>;
|
|
139
|
-
|
|
156
|
+
findings(): RecordedFinding[];
|
|
140
157
|
emitted(): EmittedEvent[];
|
|
141
158
|
logs(): LogEntry[];
|
|
142
159
|
/** Manifest-vs-module problems; empty when they agree. */
|
package/src/index.js
CHANGED
|
@@ -32,9 +32,13 @@ 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
|
+
// ctx.signal for every surface but the listener, which brings its own.
|
|
39
|
+
// Never aborted here: the host aborts it on a timeout or a shutdown, and a
|
|
40
|
+
// test has neither.
|
|
41
|
+
abort: new AbortController(),
|
|
38
42
|
};
|
|
39
43
|
|
|
40
44
|
const http = createHttpMock();
|
|
@@ -63,6 +67,7 @@ export function harness({ manifest, integration } = {}) {
|
|
|
63
67
|
);
|
|
64
68
|
}
|
|
65
69
|
const handler = requireHandler(integration, 'actions', name, 'action');
|
|
70
|
+
assertRequiredSlots(manifest, state);
|
|
66
71
|
assertValid(declared.params, params, `params for action "${name}"`);
|
|
67
72
|
|
|
68
73
|
const result = await withFetch(http.fetch, () => handler(params, ctx));
|
|
@@ -79,6 +84,7 @@ export function harness({ manifest, integration } = {}) {
|
|
|
79
84
|
);
|
|
80
85
|
}
|
|
81
86
|
const handler = requireHandler(integration, 'webhooks', name, 'webhook');
|
|
87
|
+
assertRequiredSlots(manifest, state);
|
|
82
88
|
const req = buildWebhookRequest(request);
|
|
83
89
|
|
|
84
90
|
const result = await withFetch(http.fetch, () =>
|
|
@@ -95,6 +101,7 @@ export function harness({ manifest, integration } = {}) {
|
|
|
95
101
|
);
|
|
96
102
|
}
|
|
97
103
|
const handler = requireHandler(integration, 'polls', name, 'poll');
|
|
104
|
+
assertRequiredSlots(manifest, state);
|
|
98
105
|
const result = await withFetch(http.fetch, () =>
|
|
99
106
|
handler(emitting(ctx, manifest, state))
|
|
100
107
|
);
|
|
@@ -114,6 +121,7 @@ export function harness({ manifest, integration } = {}) {
|
|
|
114
121
|
if (typeof integration.listener !== 'function') {
|
|
115
122
|
fail('the default export has no listener function');
|
|
116
123
|
}
|
|
124
|
+
assertRequiredSlots(manifest, state);
|
|
117
125
|
const controller = new AbortController();
|
|
118
126
|
const listenerCtx = {
|
|
119
127
|
...emitting(ctx, manifest, state),
|
|
@@ -158,8 +166,14 @@ export function harness({ manifest, integration } = {}) {
|
|
|
158
166
|
|
|
159
167
|
/** The integration's KV, as an ordinary Map. */
|
|
160
168
|
store: state.store,
|
|
161
|
-
/**
|
|
162
|
-
|
|
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 })),
|
|
163
177
|
/** Events the handler put on the bus, in order. */
|
|
164
178
|
emitted: () => state.emitted.slice(),
|
|
165
179
|
/** Everything the handler logged, with its level. */
|
|
@@ -170,7 +184,7 @@ export function harness({ manifest, integration } = {}) {
|
|
|
170
184
|
|
|
171
185
|
/** Clears everything a handler recorded, keeping config and credentials. */
|
|
172
186
|
reset() {
|
|
173
|
-
state.
|
|
187
|
+
state.findings.length = 0;
|
|
174
188
|
state.emitted.length = 0;
|
|
175
189
|
state.logs.length = 0;
|
|
176
190
|
state.store.clear();
|
|
@@ -190,6 +204,8 @@ export function harness({ manifest, integration } = {}) {
|
|
|
190
204
|
function buildContext(manifest, state) {
|
|
191
205
|
const log = (message, data) =>
|
|
192
206
|
state.logs.push({ level: 'debug', message, data });
|
|
207
|
+
log.info = (message, data) =>
|
|
208
|
+
state.logs.push({ level: 'info', message, data });
|
|
193
209
|
log.warn = (message, data) =>
|
|
194
210
|
state.logs.push({ level: 'warn', message, data });
|
|
195
211
|
log.error = (message, data) =>
|
|
@@ -199,6 +215,10 @@ function buildContext(manifest, state) {
|
|
|
199
215
|
config: state.config,
|
|
200
216
|
credentials: state.credentials,
|
|
201
217
|
log,
|
|
218
|
+
// Present on every surface, as in production. A non-listener test never
|
|
219
|
+
// aborts it, which is also true of an invocation that finishes in time;
|
|
220
|
+
// h.listen replaces it with one it controls.
|
|
221
|
+
signal: state.abort.signal,
|
|
202
222
|
store: {
|
|
203
223
|
async get(key) {
|
|
204
224
|
return state.store.has(key) ? state.store.get(key) : null;
|
|
@@ -209,27 +229,74 @@ function buildContext(manifest, state) {
|
|
|
209
229
|
// that passes on an object production never sees.
|
|
210
230
|
state.store.set(key, JSON.parse(JSON.stringify(value ?? null)));
|
|
211
231
|
},
|
|
232
|
+
async delete(key) {
|
|
233
|
+
// Deleting an absent key is not an error here either — the host
|
|
234
|
+
// answers {ok} for both, and a handler must not learn to branch on a
|
|
235
|
+
// difference that does not exist.
|
|
236
|
+
state.store.delete(key);
|
|
237
|
+
},
|
|
212
238
|
},
|
|
213
|
-
|
|
214
|
-
async
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
);
|
|
220
|
-
}
|
|
221
|
-
const existing = state.problems.find((p) => p.key === key);
|
|
222
|
-
if (existing) Object.assign(existing, { message, severity });
|
|
223
|
-
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);
|
|
224
245
|
},
|
|
225
|
-
async
|
|
226
|
-
|
|
227
|
-
if (at >= 0) state.problems.splice(at, 1);
|
|
246
|
+
async set(slug, standing, opts = {}) {
|
|
247
|
+
return setFinding(manifest, state, slug, standing === true, opts);
|
|
228
248
|
},
|
|
229
249
|
},
|
|
230
250
|
};
|
|
231
251
|
}
|
|
232
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
|
+
|
|
233
300
|
/** Adds ctx.emit, which every surface but an action has. */
|
|
234
301
|
function emitting(baseCtx, manifest, state) {
|
|
235
302
|
return {
|
|
@@ -257,6 +324,29 @@ function defaultConfig(manifest) {
|
|
|
257
324
|
return out;
|
|
258
325
|
}
|
|
259
326
|
|
|
327
|
+
/**
|
|
328
|
+
* Fails when a required slot is unset, before the handler runs.
|
|
329
|
+
*
|
|
330
|
+
* An install cannot start with a required slot empty, so a handler never sees
|
|
331
|
+
* one — but nothing stopped a test from running that way, and the generated
|
|
332
|
+
* types say `string` while the value is `undefined`. That combination produces
|
|
333
|
+
* a green test for a state production forbids, and a crash on the first
|
|
334
|
+
* `token.trim()` the types promised was safe.
|
|
335
|
+
*/
|
|
336
|
+
function assertRequiredSlots(manifest, state) {
|
|
337
|
+
for (const [name, slot] of Object.entries(manifest.config ?? {})) {
|
|
338
|
+
if (!slot.required) continue;
|
|
339
|
+
const held = slot.credential ? state.credentials : state.config;
|
|
340
|
+
if (held[name] !== undefined) continue;
|
|
341
|
+
const setter = slot.credential ? 'credentials' : 'config';
|
|
342
|
+
fail(
|
|
343
|
+
`required ${slot.credential ? 'credential' : 'config'} slot "${name}" is not set — ` +
|
|
344
|
+
`an install with it empty is rejected, so a handler never runs without it. ` +
|
|
345
|
+
`Set it with h.${setter}({ ${name}: … }).`
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
260
350
|
/** Rejects a slot name the manifest never declared, or one of the wrong kind. */
|
|
261
351
|
function assertSlots(manifest, values, wantCredential) {
|
|
262
352
|
const kind = wantCredential ? 'credential' : 'config';
|
|
@@ -264,7 +354,7 @@ function assertSlots(manifest, values, wantCredential) {
|
|
|
264
354
|
const slot = manifest.config?.[name];
|
|
265
355
|
if (!slot) {
|
|
266
356
|
fail(
|
|
267
|
-
`"${name}" is not a
|
|
357
|
+
`"${name}" is not a ${kind} slot declared in manifest.json (have: ${names(manifest.config)})`
|
|
268
358
|
);
|
|
269
359
|
}
|
|
270
360
|
if (Boolean(slot.credential) !== wantCredential) {
|