@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 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, and **checks every
5
- crossing against your manifest**.
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' with { type: '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
- test('notify posts to the API and returns the receipt', async () => {
19
- h.credentials({ appToken: 'tok' });
20
- h.http.post('https://api.pushover.net/1/messages.json', { receipt: 'r1' });
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
- expect(await h.action('notify', { message: 'hi' })).toEqual({
23
- receipt: 'r1',
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
- expect(h.problems()).toEqual([]);
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
- Runner-agnostic: plain functions and thrown `AssertionError`s, no runner
34
- imported. vitest is what the scaffold uses; `node:test` and jest work unchanged.
35
-
36
- ## Why the manifest checking matters
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.problems()` | what `ctx.problems.report` recorded |
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**, naming the URL and listing what is mocked. A
87
- test that silently reaches the real internet is the worst outcome here, so there
88
- is no passthrough mode.
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.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.
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.0",
4
- "description": "Test harness for Juno integrations: runs your handlers with a real context and checks every crossing against your manifest.",
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
- ('status' in answer ||
87
- 'json' in answer ||
88
- 'body' in answer ||
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
- docs_url?: string;
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
- /** A canned answer: a bare object is 200 JSON, the common case. */
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
- 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;
102
118
  key: string;
103
- message: string;
104
- severity: 'error' | 'warning';
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
- problems(): ReportedProblem[];
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
- problems: [],
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
- /** Problems the handler reported through ctx.problems. */
162
- 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 })),
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.problems.length = 0;
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
- problems: {
214
- async report(key, message, opts = {}) {
215
- const severity = opts.severity ?? 'error';
216
- if (severity !== 'error' && severity !== 'warning') {
217
- fail(
218
- `ctx.problems.report severity must be "error" or "warning", got ${JSON.stringify(severity)}`
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 clear(key) {
226
- const at = state.problems.findIndex((p) => p.key === key);
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 config slot declared in manifest.json (have: ${names(manifest.config)})`
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) {