@junoflow/testkit 0.1.0

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 ADDED
@@ -0,0 +1,115 @@
1
+ # @junoflow/testkit
2
+
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**.
6
+
7
+ ```bash
8
+ npm install --save-dev @junoflow/testkit
9
+ ```
10
+
11
+ ```ts
12
+ import { harness } from '@junoflow/testkit';
13
+ import manifest from '../manifest.json' with { type: 'json' };
14
+ import integration from '../src/integration';
15
+
16
+ const h = harness({ manifest, integration });
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' });
21
+
22
+ expect(await h.action('notify', { message: 'hi' })).toEqual({
23
+ receipt: 'r1',
24
+ });
25
+ expect(h.problems()).toEqual([]);
26
+ });
27
+
28
+ test('conforms to its manifest', () => {
29
+ expect(h.conformance()).toEqual([]);
30
+ });
31
+ ```
32
+
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.
52
+
53
+ ## API
54
+
55
+ `harness({ manifest, integration })` returns:
56
+
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 |
72
+
73
+ ### `h.http`
74
+
75
+ Replaces global `fetch` for the duration of a handler and restores it after,
76
+ including when the handler throws.
77
+
78
+ ```ts
79
+ h.http.post('https://api.example.com/send', { receipt: 'r1' }); // 200 JSON
80
+ h.http.get(/\/status$/, { status: 503, json: { down: true } }); // RegExp matching
81
+ h.http.post('https://api.example.com/send', (req) => ({ echoed: req.body }));
82
+
83
+ h.http.requests; // every request a handler made, in order
84
+ ```
85
+
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.
89
+
90
+ ### `h.listen`
91
+
92
+ ```ts
93
+ await h.listen({ events: 3 }); // until three events are emitted
94
+ await h.listen({ until: (h) => h.store.get('cursor') === '42' });
95
+ ```
96
+
97
+ After the condition holds, `ctx.signal` aborts and the harness waits for the
98
+ listener to return. A listener that ignores its signal **fails the test** — in
99
+ production it hangs the supervisor on shutdown instead.
100
+
101
+ ### `h.conformance()`
102
+
103
+ Checks the manifest and the module against each other: every declared surface
104
+ implemented, nothing undeclared exported, a listener exactly when events have no
105
+ webhook or poll to produce them, and every schema compiling. Returns all
106
+ problems rather than the first — "you declared six actions and implemented four"
107
+ is one fix, not four runs.
108
+
109
+ ## What the harness is faithful about
110
+
111
+ - `ctx.store` round-trips values through JSON, because the real store does. A
112
+ `Date` comes back as a string here exactly as it would in production.
113
+ - Config defaults declared in the manifest are applied before your test runs,
114
+ because the runtime always supplies them.
115
+ - `ctx.problems.report` upserts by key, as the server does.
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
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.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/boivie/juno.git",
9
+ "directory": "tools/npm/testkit"
10
+ },
11
+ "type": "module",
12
+ "exports": {
13
+ ".": {
14
+ "types": "./src/index.d.ts",
15
+ "default": "./src/index.js"
16
+ },
17
+ "./package.json": "./package.json"
18
+ },
19
+ "files": [
20
+ "src",
21
+ "README.md"
22
+ ],
23
+ "keywords": [
24
+ "juno",
25
+ "junoflow",
26
+ "testing",
27
+ "integration"
28
+ ],
29
+ "scripts": {
30
+ "test": "node --test \"test/**/*.test.js\""
31
+ },
32
+ "dependencies": {
33
+ "ajv": "^8.17.1"
34
+ },
35
+ "engines": {
36
+ "node": ">=20"
37
+ },
38
+ "types": "./src/index.d.ts"
39
+ }
package/src/assert.js ADDED
@@ -0,0 +1,19 @@
1
+ import { AssertionError } from 'node:assert';
2
+
3
+ /**
4
+ * The harness reports every violation as a thrown AssertionError and nothing
5
+ * else. That is what keeps it runner-agnostic: vitest, node:test and jest all
6
+ * render an AssertionError natively, and importing any one of them would make
7
+ * the package pick a runner for its users.
8
+ */
9
+ export function fail(message, { actual, expected } = {}) {
10
+ throw new AssertionError({
11
+ message,
12
+ actual,
13
+ expected,
14
+ operator: 'juno-testkit',
15
+ stackStartFn: fail,
16
+ });
17
+ }
18
+
19
+ export { AssertionError };
@@ -0,0 +1,85 @@
1
+ import { schemaError } from './schema.js';
2
+
3
+ /** The only keys a default export may carry. */
4
+ const SURFACES = ['actions', 'webhooks', 'polls', 'listener'];
5
+
6
+ /**
7
+ * Checks the manifest and the module against each other and returns a list of
8
+ * problems, empty when they agree.
9
+ *
10
+ * It returns rather than throws so a test can render all of them at once:
11
+ * "you declared six actions and implemented four" is one fix, not four runs.
12
+ */
13
+ export function conformance(manifest, integration) {
14
+ const problems = [];
15
+
16
+ for (const key of Object.keys(integration ?? {})) {
17
+ if (!SURFACES.includes(key)) {
18
+ problems.push(
19
+ `default export has an unknown key "${key}" (expected one of ${SURFACES.join(', ')})`
20
+ );
21
+ }
22
+ }
23
+
24
+ for (const [surface, kind] of [
25
+ ['actions', 'action'],
26
+ ['webhooks', 'webhook'],
27
+ ['polls', 'poll'],
28
+ ]) {
29
+ const declared = Object.keys(manifest[surface] ?? {});
30
+ const implemented = Object.keys(integration?.[surface] ?? {});
31
+
32
+ for (const name of declared) {
33
+ const impl = integration?.[surface]?.[name];
34
+ if (typeof impl !== 'function') {
35
+ problems.push(
36
+ `${kind} "${name}" is declared in manifest.json but not implemented`
37
+ );
38
+ }
39
+ }
40
+ for (const name of implemented) {
41
+ if (!declared.includes(name)) {
42
+ problems.push(
43
+ `${kind} "${name}" is implemented but not declared in manifest.json — Juno will never call it`
44
+ );
45
+ }
46
+ }
47
+ }
48
+
49
+ // A listener is expected exactly when events are declared with nothing else
50
+ // to produce them; the server derives it the same way.
51
+ const hasEvents = Object.keys(manifest.events ?? {}).length > 0;
52
+ const hasProducer =
53
+ Object.keys(manifest.webhooks ?? {}).length > 0 ||
54
+ Object.keys(manifest.polls ?? {}).length > 0;
55
+ const wantsListener = hasEvents && !hasProducer;
56
+ const hasListener = typeof integration?.listener === 'function';
57
+ if (wantsListener && !hasListener) {
58
+ problems.push(
59
+ 'manifest.json declares events with no webhook or poll to produce them, so a listener is required, but the default export has none'
60
+ );
61
+ }
62
+ if (!wantsListener && hasListener) {
63
+ problems.push(
64
+ 'the default export has a listener, but manifest.json declares no events for it to emit'
65
+ );
66
+ }
67
+
68
+ // Every schema has to compile: an invalid one would silently validate
69
+ // nothing here and be rejected on deploy.
70
+ for (const [name, action] of Object.entries(manifest.actions ?? {})) {
71
+ pushSchemaError(problems, `action "${name}" params`, action.params);
72
+ pushSchemaError(problems, `action "${name}" response`, action.response);
73
+ }
74
+ for (const [name, event] of Object.entries(manifest.events ?? {})) {
75
+ pushSchemaError(problems, `event "${name}" payload`, event.payload);
76
+ }
77
+
78
+ return problems;
79
+ }
80
+
81
+ function pushSchemaError(problems, what, schema) {
82
+ if (!schema) return;
83
+ const err = schemaError(schema);
84
+ if (err) problems.push(`${what} is not a valid JSON Schema: ${err}`);
85
+ }
package/src/http.js ADDED
@@ -0,0 +1,115 @@
1
+ import { fail } from './assert.js';
2
+
3
+ /**
4
+ * A recorder that stands in for global fetch while a handler runs.
5
+ *
6
+ * It is a few dozen lines rather than a wrapper over undici's MockAgent
7
+ * because that is all this needs: the template should require no mocking
8
+ * library, and an unmatched request must fail loudly naming the URL — a
9
+ * silently-passing test against a real endpoint is the worst outcome here.
10
+ */
11
+ export function createHttpMock() {
12
+ const routes = [];
13
+ const requests = [];
14
+
15
+ function on(method, url, response) {
16
+ routes.push({ method: method.toUpperCase(), url, response });
17
+ return api;
18
+ }
19
+
20
+ const api = {
21
+ get: (url, response) => on('GET', url, response),
22
+ post: (url, response) => on('POST', url, response),
23
+ put: (url, response) => on('PUT', url, response),
24
+ patch: (url, response) => on('PATCH', url, response),
25
+ delete: (url, response) => on('DELETE', url, response),
26
+ on,
27
+ /** Every request a handler made, in order, with its parsed body. */
28
+ requests,
29
+ reset() {
30
+ routes.length = 0;
31
+ requests.length = 0;
32
+ return api;
33
+ },
34
+ };
35
+
36
+ /** The fetch implementation installed while a handler runs. */
37
+ api.fetch = async (input, init = {}) => {
38
+ const url =
39
+ typeof input === 'string' ? input : (input?.url ?? String(input));
40
+ const method = (init.method ?? input?.method ?? 'GET').toUpperCase();
41
+ const record = {
42
+ method,
43
+ url,
44
+ headers: init.headers ?? {},
45
+ body: init.body ?? null,
46
+ };
47
+ requests.push(record);
48
+
49
+ const route = routes.find(
50
+ (r) => r.method === method && matches(r.url, url)
51
+ );
52
+ if (!route) {
53
+ const known =
54
+ routes.map((r) => ` ${r.method} ${r.url}`).join('\n') || ' (nothing)';
55
+ fail(
56
+ `unmocked request: ${method} ${url}\nMocked routes:\n${known}\n` +
57
+ `Add one with h.http.${method.toLowerCase()}(${JSON.stringify(url)}, { … }).`
58
+ );
59
+ }
60
+
61
+ const answer =
62
+ typeof route.response === 'function'
63
+ ? await route.response(record)
64
+ : route.response;
65
+ return toResponse(answer);
66
+ };
67
+
68
+ return api;
69
+ }
70
+
71
+ function matches(pattern, url) {
72
+ if (pattern instanceof RegExp) return pattern.test(url);
73
+ if (typeof pattern === 'function') return pattern(url);
74
+ return pattern === url;
75
+ }
76
+
77
+ /**
78
+ * Accepts the two shapes a test wants to write: a bare object (200 JSON, the
79
+ * overwhelmingly common case) or a full {status, headers, json|body}.
80
+ */
81
+ function toResponse(answer) {
82
+ if (answer instanceof Response) return answer;
83
+ const isEnvelope =
84
+ answer !== null &&
85
+ typeof answer === 'object' &&
86
+ ('status' in answer ||
87
+ 'json' in answer ||
88
+ 'body' in answer ||
89
+ 'headers' in answer);
90
+
91
+ const {
92
+ status = 200,
93
+ headers = {},
94
+ json,
95
+ body,
96
+ } = isEnvelope ? answer : { json: answer };
97
+ if (json !== undefined) {
98
+ return new Response(JSON.stringify(json), {
99
+ status,
100
+ headers: { 'content-type': 'application/json', ...headers },
101
+ });
102
+ }
103
+ return new Response(body ?? '', { status, headers });
104
+ }
105
+
106
+ /** Installs the mock as globalThis.fetch for the duration of fn. */
107
+ export async function withFetch(fetchImpl, fn) {
108
+ const original = globalThis.fetch;
109
+ globalThis.fetch = fetchImpl;
110
+ try {
111
+ return await fn();
112
+ } finally {
113
+ globalThis.fetch = original;
114
+ }
115
+ }
package/src/index.d.ts ADDED
@@ -0,0 +1,158 @@
1
+ // Type declarations for @junoflow/testkit.
2
+ //
3
+ // Hand-written rather than generated: the package is plain JavaScript so it can
4
+ // be read and patched without a build step, and the surface is small enough
5
+ // that a declaration file is cheaper than a toolchain. It is deliberately loose
6
+ // about the manifest and the integration — an author's real types come from
7
+ // their generated types/, and re-deriving them here would only be a second,
8
+ // weaker copy.
9
+
10
+ /**
11
+ * One config or credential slot, as declared in manifest.json.
12
+ *
13
+ * `type` is a plain string rather than the union
14
+ * `'string' | 'number' | 'boolean' | 'enum' | 'string[]'` on purpose: a test
15
+ * imports manifest.json, and a JSON import widens every literal to its base
16
+ * type, so the union would reject the author's own manifest. The server is
17
+ * where that vocabulary is enforced; the harness does not branch on it.
18
+ */
19
+ export interface ManifestConfigSlot {
20
+ type: string;
21
+ title?: string;
22
+ description?: string;
23
+ docs_url?: string;
24
+ default?: unknown;
25
+ required?: boolean;
26
+ credential?: boolean;
27
+ enum?: string[];
28
+ }
29
+
30
+ /** The parsed contents of manifest.json. */
31
+ export interface Manifest {
32
+ name: string;
33
+ description?: string;
34
+ config?: Record<string, ManifestConfigSlot>;
35
+ actions?: Record<
36
+ string,
37
+ { params?: object; response?: object; [k: string]: unknown }
38
+ >;
39
+ events?: Record<string, { payload?: object; [k: string]: unknown }>;
40
+ webhooks?: Record<string, unknown>;
41
+ polls?: Record<string, unknown>;
42
+ [k: string]: unknown;
43
+ }
44
+
45
+ /** The module's default export, as Juno loads it. */
46
+ export interface IntegrationModule {
47
+ actions?: Record<string, (params: never, ctx: never) => unknown>;
48
+ webhooks?: Record<string, (req: never, ctx: never) => unknown>;
49
+ polls?: Record<string, (ctx: never) => unknown>;
50
+ listener?: (ctx: never) => unknown;
51
+ }
52
+
53
+ export interface WebhookRequestInit {
54
+ method?: string;
55
+ headers?: Record<string, string>;
56
+ query?: Record<string, string>;
57
+ /** A string is sent as-is; anything else is JSON.stringify'd. */
58
+ body?: unknown;
59
+ url?: string;
60
+ }
61
+
62
+ export interface WebhookResult {
63
+ status?: number;
64
+ headers?: Record<string, string>;
65
+ body?: string;
66
+ }
67
+
68
+ export interface RecordedRequest {
69
+ method: string;
70
+ url: string;
71
+ headers: HeadersInit;
72
+ body: BodyInit | null;
73
+ }
74
+
75
+ /** A canned answer: a bare object is 200 JSON, the common case. */
76
+ export type MockResponse =
77
+ | object
78
+ | Response
79
+ | {
80
+ status?: number;
81
+ headers?: Record<string, string>;
82
+ json?: unknown;
83
+ body?: string;
84
+ }
85
+ | ((req: RecordedRequest) => unknown | Promise<unknown>);
86
+
87
+ export type UrlMatcher = string | RegExp | ((url: string) => boolean);
88
+
89
+ export interface HttpMock {
90
+ get(url: UrlMatcher, response: MockResponse): HttpMock;
91
+ post(url: UrlMatcher, response: MockResponse): HttpMock;
92
+ put(url: UrlMatcher, response: MockResponse): HttpMock;
93
+ patch(url: UrlMatcher, response: MockResponse): HttpMock;
94
+ delete(url: UrlMatcher, response: MockResponse): HttpMock;
95
+ on(method: string, url: UrlMatcher, response: MockResponse): HttpMock;
96
+ /** Every request a handler made, in order. */
97
+ readonly requests: RecordedRequest[];
98
+ reset(): HttpMock;
99
+ }
100
+
101
+ export interface ReportedProblem {
102
+ key: string;
103
+ message: string;
104
+ severity: 'error' | 'warning';
105
+ }
106
+
107
+ export interface EmittedEvent {
108
+ event: string;
109
+ payload: unknown;
110
+ }
111
+
112
+ export interface LogEntry {
113
+ level: 'debug' | 'warn' | 'error';
114
+ message: string;
115
+ data?: unknown;
116
+ }
117
+
118
+ export interface ListenOptions {
119
+ /** Stop when this holds. Checked after every emit. */
120
+ until?: (h: Harness) => boolean;
121
+ /** Stop once this many events have been emitted. */
122
+ events?: number;
123
+ /** Milliseconds before giving up, default 1000. */
124
+ timeout?: number;
125
+ }
126
+
127
+ export interface Harness {
128
+ config(values: Record<string, unknown>): Harness;
129
+ credentials(values: Record<string, string>): Harness;
130
+ action(name: string, params?: unknown): Promise<unknown>;
131
+ webhook(
132
+ name: string,
133
+ request?: WebhookRequestInit
134
+ ): Promise<WebhookResult | void>;
135
+ poll(name: string): Promise<void>;
136
+ listen(options?: ListenOptions): Promise<void>;
137
+ /** The integration's KV, as an ordinary Map. */
138
+ readonly store: Map<string, unknown>;
139
+ problems(): ReportedProblem[];
140
+ emitted(): EmittedEvent[];
141
+ logs(): LogEntry[];
142
+ /** Manifest-vs-module problems; empty when they agree. */
143
+ conformance(): string[];
144
+ readonly http: HttpMock;
145
+ reset(): Harness;
146
+ }
147
+
148
+ export function harness(options: {
149
+ manifest: Manifest;
150
+ integration: IntegrationModule;
151
+ }): Harness;
152
+
153
+ export function conformance(
154
+ manifest: Manifest,
155
+ integration: IntegrationModule
156
+ ): string[];
157
+
158
+ export { AssertionError } from 'node:assert';
package/src/index.js ADDED
@@ -0,0 +1,354 @@
1
+ import { fail } from './assert.js';
2
+ import { conformance } from './conformance.js';
3
+ import { createHttpMock, withFetch } from './http.js';
4
+ import { assertValid } from './schema.js';
5
+
6
+ /** How long h.listen waits before aborting, when nothing else says. */
7
+ const DEFAULT_LISTEN_TIMEOUT_MS = 1000;
8
+
9
+ /**
10
+ * harness({ manifest, integration }) builds the same ctx the sandbox builds and
11
+ * validates every crossing against the manifest: params on the way in, results
12
+ * on the way out, emitted event names and payloads, webhook return shape.
13
+ *
14
+ * That validation is the point. Juno checks the contract when it *runs* an
15
+ * integration, which is too late to be a test — an installer finds out instead.
16
+ * Here, an undeclared event is a thrown assertion.
17
+ */
18
+ export function harness({ manifest, integration } = {}) {
19
+ if (!manifest || typeof manifest !== 'object') {
20
+ fail('harness({ manifest, integration }): manifest is required');
21
+ }
22
+ if (!integration || typeof integration !== 'object') {
23
+ fail(
24
+ "harness({ manifest, integration }): integration is required — import your module's default export"
25
+ );
26
+ }
27
+
28
+ const state = {
29
+ // Non-credential slots start at their manifest defaults, exactly as the
30
+ // runtime supplies them: a defaulted slot is never absent at handler time,
31
+ // so a test that never called config() must still see the default.
32
+ config: defaultConfig(manifest),
33
+ credentials: {},
34
+ store: new Map(),
35
+ problems: [],
36
+ emitted: [],
37
+ logs: [],
38
+ };
39
+
40
+ const http = createHttpMock();
41
+ const ctx = buildContext(manifest, state);
42
+
43
+ const h = {
44
+ /** Merge values into ctx.config. Rejects a slot the manifest never declared. */
45
+ config(values = {}) {
46
+ assertSlots(manifest, values, false);
47
+ Object.assign(state.config, values);
48
+ return h;
49
+ },
50
+
51
+ /** Merge values into ctx.credentials. Rejects a non-credential slot. */
52
+ credentials(values = {}) {
53
+ assertSlots(manifest, values, true);
54
+ Object.assign(state.credentials, values);
55
+ return h;
56
+ },
57
+
58
+ async action(name, params) {
59
+ const declared = manifest.actions?.[name];
60
+ if (!declared) {
61
+ fail(
62
+ `action "${name}" is not declared in manifest.json (have: ${names(manifest.actions)})`
63
+ );
64
+ }
65
+ const handler = requireHandler(integration, 'actions', name, 'action');
66
+ assertValid(declared.params, params, `params for action "${name}"`);
67
+
68
+ const result = await withFetch(http.fetch, () => handler(params, ctx));
69
+ if (declared.response) {
70
+ assertValid(declared.response, result, `result of action "${name}"`);
71
+ }
72
+ return result;
73
+ },
74
+
75
+ async webhook(name, request = {}) {
76
+ if (!manifest.webhooks?.[name]) {
77
+ fail(
78
+ `webhook "${name}" is not declared in manifest.json (have: ${names(manifest.webhooks)})`
79
+ );
80
+ }
81
+ const handler = requireHandler(integration, 'webhooks', name, 'webhook');
82
+ const req = buildWebhookRequest(request);
83
+
84
+ const result = await withFetch(http.fetch, () =>
85
+ handler(req, emitting(ctx, manifest, state))
86
+ );
87
+ assertWebhookResponse(name, result);
88
+ return result;
89
+ },
90
+
91
+ async poll(name) {
92
+ if (!manifest.polls?.[name]) {
93
+ fail(
94
+ `poll "${name}" is not declared in manifest.json (have: ${names(manifest.polls)})`
95
+ );
96
+ }
97
+ const handler = requireHandler(integration, 'polls', name, 'poll');
98
+ const result = await withFetch(http.fetch, () =>
99
+ handler(emitting(ctx, manifest, state))
100
+ );
101
+ if (result !== undefined && result !== null) {
102
+ fail(
103
+ `poll "${name}" returned a value; a poll reports through ctx.emit and returns nothing`
104
+ );
105
+ }
106
+ },
107
+
108
+ /**
109
+ * Runs the listener until `until` holds, then aborts and waits for it to
110
+ * unwind. A listener that ignores its abort signal fails here rather than
111
+ * hanging a supervisor in production.
112
+ */
113
+ async listen({ until, events, timeout = DEFAULT_LISTEN_TIMEOUT_MS } = {}) {
114
+ if (typeof integration.listener !== 'function') {
115
+ fail('the default export has no listener function');
116
+ }
117
+ const controller = new AbortController();
118
+ const listenerCtx = {
119
+ ...emitting(ctx, manifest, state),
120
+ signal: controller.signal,
121
+ };
122
+
123
+ // The listener's own failure is captured rather than left to reject an
124
+ // unawaited promise; it is rethrown below, after the abort has had its
125
+ // chance to be the explanation.
126
+ let listenerError = null;
127
+ const done = withFetch(http.fetch, () =>
128
+ integration.listener(listenerCtx)
129
+ ).catch((err) => {
130
+ listenerError = err;
131
+ });
132
+
133
+ const settled = await waitFor(() => {
134
+ if (typeof until === 'function') return until(h);
135
+ if (typeof events === 'number') return state.emitted.length >= events;
136
+ return false;
137
+ }, timeout);
138
+ controller.abort();
139
+
140
+ const returned = await withTimeout(done, timeout);
141
+ if (listenerError && listenerError.name !== 'AbortError')
142
+ throw listenerError;
143
+ if (!returned) {
144
+ fail(
145
+ `the listener did not return within ${timeout}ms of ctx.signal aborting — ` +
146
+ 'a listener that ignores its signal hangs the supervisor on shutdown'
147
+ );
148
+ }
149
+ if (
150
+ !settled &&
151
+ (typeof until === 'function' || typeof events === 'number')
152
+ ) {
153
+ fail(
154
+ `the listener's stop condition was not met within ${timeout}ms (emitted ${state.emitted.length} event(s))`
155
+ );
156
+ }
157
+ },
158
+
159
+ /** The integration's KV, as an ordinary Map. */
160
+ store: state.store,
161
+ /** Problems the handler reported through ctx.problems. */
162
+ problems: () => state.problems.slice(),
163
+ /** Events the handler put on the bus, in order. */
164
+ emitted: () => state.emitted.slice(),
165
+ /** Everything the handler logged, with its level. */
166
+ logs: () => state.logs.slice(),
167
+ /** Manifest-vs-module problems; empty when they agree. */
168
+ conformance: () => conformance(manifest, integration),
169
+ http,
170
+
171
+ /** Clears everything a handler recorded, keeping config and credentials. */
172
+ reset() {
173
+ state.problems.length = 0;
174
+ state.emitted.length = 0;
175
+ state.logs.length = 0;
176
+ state.store.clear();
177
+ http.reset();
178
+ return h;
179
+ },
180
+ };
181
+
182
+ return h;
183
+ }
184
+
185
+ /**
186
+ * The base context, matching what integration-entrypoint.ts builds. Handlers
187
+ * cannot tell the difference, which is the only way a test here means anything
188
+ * about how the code runs there.
189
+ */
190
+ function buildContext(manifest, state) {
191
+ const log = (message, data) =>
192
+ state.logs.push({ level: 'debug', message, data });
193
+ log.warn = (message, data) =>
194
+ state.logs.push({ level: 'warn', message, data });
195
+ log.error = (message, data) =>
196
+ state.logs.push({ level: 'error', message, data });
197
+
198
+ return {
199
+ config: state.config,
200
+ credentials: state.credentials,
201
+ log,
202
+ store: {
203
+ async get(key) {
204
+ return state.store.has(key) ? state.store.get(key) : null;
205
+ },
206
+ async set(key, value) {
207
+ // The real store round-trips through JSON on the wire, so a Date or a
208
+ // Map would come back as something else. Failing here beats a test
209
+ // that passes on an object production never sees.
210
+ state.store.set(key, JSON.parse(JSON.stringify(value ?? null)));
211
+ },
212
+ },
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 });
224
+ },
225
+ async clear(key) {
226
+ const at = state.problems.findIndex((p) => p.key === key);
227
+ if (at >= 0) state.problems.splice(at, 1);
228
+ },
229
+ },
230
+ };
231
+ }
232
+
233
+ /** Adds ctx.emit, which every surface but an action has. */
234
+ function emitting(baseCtx, manifest, state) {
235
+ return {
236
+ ...baseCtx,
237
+ async emit(event, payload) {
238
+ const declared = manifest.events?.[event];
239
+ if (!declared) {
240
+ fail(
241
+ `ctx.emit("${event}") — that event is not declared in manifest.json (have: ${names(manifest.events)}). ` +
242
+ 'Juno rejects an undeclared event at runtime.'
243
+ );
244
+ }
245
+ assertValid(declared.payload, payload, `payload of event "${event}"`);
246
+ state.emitted.push({ event, payload });
247
+ },
248
+ };
249
+ }
250
+
251
+ function defaultConfig(manifest) {
252
+ const out = {};
253
+ for (const [name, slot] of Object.entries(manifest.config ?? {})) {
254
+ if (!slot.credential && slot.default !== undefined)
255
+ out[name] = slot.default;
256
+ }
257
+ return out;
258
+ }
259
+
260
+ /** Rejects a slot name the manifest never declared, or one of the wrong kind. */
261
+ function assertSlots(manifest, values, wantCredential) {
262
+ const kind = wantCredential ? 'credential' : 'config';
263
+ for (const name of Object.keys(values)) {
264
+ const slot = manifest.config?.[name];
265
+ if (!slot) {
266
+ fail(
267
+ `"${name}" is not a config slot declared in manifest.json (have: ${names(manifest.config)})`
268
+ );
269
+ }
270
+ if (Boolean(slot.credential) !== wantCredential) {
271
+ const actual = slot.credential ? 'credential' : 'config';
272
+ fail(
273
+ `"${name}" is a ${actual} slot — set it with h.${actual === 'credential' ? 'credentials' : 'config'}(), not h.${kind === 'credential' ? 'credentials' : 'config'}()`
274
+ );
275
+ }
276
+ }
277
+ }
278
+
279
+ function requireHandler(integration, surface, name, kind) {
280
+ const handler = integration?.[surface]?.[name];
281
+ if (typeof handler !== 'function') {
282
+ fail(
283
+ `${kind} "${name}" is declared in manifest.json but the default export does not implement it`
284
+ );
285
+ }
286
+ return handler;
287
+ }
288
+
289
+ function buildWebhookRequest(request) {
290
+ return {
291
+ method: (request.method ?? 'POST').toUpperCase(),
292
+ headers: request.headers ?? {},
293
+ query: request.query ?? {},
294
+ body:
295
+ typeof request.body === 'string'
296
+ ? request.body
297
+ : JSON.stringify(request.body ?? ''),
298
+ url: request.url ?? '/',
299
+ };
300
+ }
301
+
302
+ /** A webhook returns nothing, or the {status, headers, body} control shape. */
303
+ function assertWebhookResponse(name, result) {
304
+ if (result === undefined || result === null) return;
305
+ if (typeof result !== 'object') {
306
+ fail(
307
+ `webhook "${name}" returned ${typeof result}; return nothing, or { status?, headers?, body? }`
308
+ );
309
+ }
310
+ for (const key of Object.keys(result)) {
311
+ if (!['status', 'headers', 'body'].includes(key)) {
312
+ fail(
313
+ `webhook "${name}" returned an unknown key "${key}"; only status, headers and body are sent`
314
+ );
315
+ }
316
+ }
317
+ if ('status' in result && !Number.isInteger(result.status)) {
318
+ fail(
319
+ `webhook "${name}" returned a non-integer status: ${JSON.stringify(result.status)}`
320
+ );
321
+ }
322
+ if ('body' in result && typeof result.body !== 'string') {
323
+ fail(`webhook "${name}" returned a non-string body; serialize it yourself`);
324
+ }
325
+ }
326
+
327
+ function names(obj) {
328
+ const keys = Object.keys(obj ?? {});
329
+ return keys.length ? keys.join(', ') : '(none)';
330
+ }
331
+
332
+ /** Polls a condition, resolving true when it holds or false at the deadline. */
333
+ async function waitFor(condition, timeout) {
334
+ const deadline = Date.now() + timeout;
335
+ while (Date.now() < deadline) {
336
+ if (condition()) return true;
337
+ await new Promise((r) => setTimeout(r, 5));
338
+ }
339
+ return condition();
340
+ }
341
+
342
+ /** Resolves true when promise settled within ms, false when it did not. */
343
+ async function withTimeout(promise, ms) {
344
+ let timer;
345
+ const timeout = new Promise((resolve) => {
346
+ timer = setTimeout(() => resolve(false), ms);
347
+ });
348
+ const settled = await Promise.race([promise.then(() => true), timeout]);
349
+ clearTimeout(timer);
350
+ return settled;
351
+ }
352
+
353
+ export { conformance } from './conformance.js';
354
+ export { AssertionError } from './assert.js';
package/src/schema.js ADDED
@@ -0,0 +1,54 @@
1
+ import Ajv from 'ajv/dist/2020.js';
2
+
3
+ import { fail } from './assert.js';
4
+
5
+ // 2020-12 is what the server's compiler defaults to for a schema with no
6
+ // $schema key, which every manifest schema is. strict:false because a manifest
7
+ // may carry annotation keywords (title, examples) that mean nothing to a
8
+ // validator and must not be an error.
9
+ const ajv = new Ajv({ strict: false, allErrors: true });
10
+
11
+ const cache = new Map();
12
+
13
+ /** Compiles and caches a validator. Throws when the schema itself is bad. */
14
+ export function compile(schema) {
15
+ const key = JSON.stringify(schema);
16
+ let validate = cache.get(key);
17
+ if (!validate) {
18
+ validate = ajv.compile(schema);
19
+ cache.set(key, validate);
20
+ }
21
+ return validate;
22
+ }
23
+
24
+ /**
25
+ * Checks a value against a manifest schema and throws an assertion naming what
26
+ * is wrong. This is the layer that catches the failure mode that actually bites
27
+ * an installer: the manifest says one thing and the code does another. Nothing
28
+ * checks it in production, so it has to be checked here.
29
+ */
30
+ export function assertValid(schema, value, what) {
31
+ if (!schema) return value; // nothing declared, nothing to check
32
+ const validate = compile(schema);
33
+ if (validate(value)) return value;
34
+ const detail = (validate.errors ?? [])
35
+ .map((e) => ` ${e.instancePath || '(root)'} ${e.message}`)
36
+ .join('\n');
37
+ fail(
38
+ `${what} does not match the schema declared in manifest.json:\n${detail}`,
39
+ {
40
+ actual: value,
41
+ expected: schema,
42
+ }
43
+ );
44
+ }
45
+
46
+ /** Reports whether a schema compiles, for conformance(). */
47
+ export function schemaError(schema) {
48
+ try {
49
+ compile(schema);
50
+ return null;
51
+ } catch (err) {
52
+ return err.message;
53
+ }
54
+ }