@aloma.io/integration-sdk 3.8.66 → 3.8.68

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.
Files changed (42) hide show
  1. package/README.md +60 -0
  2. package/build/builder/runtime-context.mjs +2 -2
  3. package/build/cli.mjs +0 -0
  4. package/build/controller/index.d.mts +3 -2
  5. package/build/controller/index.mjs +4 -3
  6. package/build/internal/dispatcher/index.d.mts +1 -4
  7. package/build/internal/dispatcher/index.mjs +3 -2
  8. package/build/testing/fake-aloma.d.mts +87 -0
  9. package/build/testing/fake-aloma.mjs +248 -0
  10. package/build/testing/harness.d.mts +51 -0
  11. package/build/testing/harness.mjs +139 -0
  12. package/build/testing/index.d.mts +15 -0
  13. package/build/testing/index.mjs +13 -0
  14. package/build/testing/keys.d.mts +9 -0
  15. package/build/testing/keys.mjs +15 -0
  16. package/build/testing/network-guard.d.mts +3 -0
  17. package/build/testing/network-guard.mjs +49 -0
  18. package/build/testing/runtime-connector.d.mts +11 -0
  19. package/build/testing/runtime-connector.mjs +38 -0
  20. package/build/testing/vendor-mock.d.mts +59 -0
  21. package/build/testing/vendor-mock.mjs +121 -0
  22. package/package.json +8 -4
  23. package/src/builder/runtime-context.mts +2 -2
  24. package/src/controller/index.mts +4 -3
  25. package/src/internal/dispatcher/index.mts +3 -2
  26. package/src/testing/fake-aloma.mts +306 -0
  27. package/src/testing/harness.mts +172 -0
  28. package/src/testing/index.mts +15 -0
  29. package/src/testing/keys.mts +19 -0
  30. package/src/testing/network-guard.mts +57 -0
  31. package/src/testing/runtime-connector.mts +50 -0
  32. package/src/testing/vendor-mock.mts +166 -0
  33. package/test/MUTATION-entry.md +21 -0
  34. package/test/MUTATION.md +43 -0
  35. package/test/dispatcher-ctx-forwarding.test.mjs +161 -0
  36. package/test/harness-connector-smoke.test.mjs +72 -0
  37. package/test/harness.test.mjs +375 -0
  38. package/test/openapi-to-connector.test.mts +25 -7
  39. package/test/scenarios.test.mts +15 -7
  40. package/tools/mutation-run-entry.mjs +52 -0
  41. package/tools/mutation-run.mjs +120 -0
  42. package/tools/run-tests.mjs +64 -0
package/README.md CHANGED
@@ -106,6 +106,66 @@ The SDK includes comprehensive scenario tests that verify:
106
106
 
107
107
  All tests use real OpenAPI specifications and compare generated output against fixtures to ensure deterministic, high-quality code generation.
108
108
 
109
+ ### Testing a connector: handshake smoke test
110
+
111
+ The SDK ships a test harness (`build/testing/`, source in `src/testing/`). It runs your real connector in-process
112
+ against a fake ALOMA endpoint on `127.0.0.1`, performs the real handshake (connect, 401, register, websocket,
113
+ configuration), sends method calls like ALOMA does, and mocks the vendor HTTP API. Any request that is not
114
+ mocked fails the test, and raw connections to non-loopback hosts are blocked, so a test never touches the network.
115
+ No real token or key is needed: the harness generates a throwaway RSA key pair and fake tokens.
116
+
117
+ A connector repo adds one dev dependency on the SDK (already a dependency) and one file, `test/handshake.smoke.test.mjs`
118
+ (run it with `mocha --exit test/handshake.smoke.test.mjs` after `yarn build`):
119
+
120
+ ```js
121
+ import {describe, it, afterEach} from 'mocha';
122
+ import {expect} from 'chai';
123
+ import {Builder} from '@aloma.io/integration-sdk';
124
+ import {ConnectorHarness, connectorFromRuntimeContext} from '@aloma.io/integration-sdk/build/testing/index.mjs';
125
+
126
+ describe('handshake smoke test', function () {
127
+ this.timeout(15000);
128
+ let harness;
129
+
130
+ // stop() also fails the test if the connector made a request you did not mock
131
+ afterEach(async () => harness && (await harness.stop()));
132
+
133
+ it('registers, connects, starts and reports healthy', async () => {
134
+ // use the same Builder configuration as src/index.mts (config, options, auth)
135
+ const context = await new Builder().config({fields: {apiKey: {name: 'API Key', type: 'line'}}}).build();
136
+
137
+ harness = await ConnectorHarness.start({
138
+ connector: connectorFromRuntimeContext(context),
139
+ config: {apiKey: 'fake-api-key-not-real'}, // encrypted for the connector unless the field is `plain`
140
+ });
141
+
142
+ const health = await harness.aloma.waitForPacket('connector.health.check');
143
+ expect(health.a).to.deep.equal({ok: true, error: null});
144
+ });
145
+
146
+ it('calls a method against a mocked vendor API', async () => {
147
+ harness = await ConnectorHarness.start({/* same as above */});
148
+ harness.vendor.get('https://api.vendor.example/things/1', {json: {id: 1}});
149
+
150
+ expect(await harness.callOk('getThing', {id: 1})).to.deep.equal({id: 1});
151
+ });
152
+ });
153
+ ```
154
+
155
+ Useful pieces: `harness.call(method, variables, ctx)` (raw answer, `{error}` on failure), `harness.callOk(...)`,
156
+ `harness.vendor.get/post/put/delete(url | RegExp | fn, {status, json, text, headers} | fn(request))`,
157
+ `harness.vendor.requests` (what the connector sent), `harness.aloma.handle('connector.blob.create', fn)` to answer
158
+ requests the connector makes to ALOMA, `harness.aloma.registration` (what the connector registered).
159
+ `test/harness-connector-smoke.test.mjs` in this repo is a runnable version of the example.
160
+
161
+ Not covered: the Prometheus metrics server (fixed port 4050), process signal handlers, configuration through
162
+ environment variables and OAuth flows against a real provider. `connectorFromRuntimeContext` repeats the wiring of
163
+ `RuntimeContext.start()`, so keep the two in step.
164
+
165
+ SDK maintainers: `yarn test:harness` runs the harness tests (CI runs it on Node 18, 20, 22 and 24). `yarn test:mutation`
166
+ applies 30+ deliberate bugs to the harness and the SDK handshake, dispatch and result paths and checks the tests
167
+ catch each one; the result is recorded in `test/MUTATION.md`.
168
+
109
169
  ## 📚 Documentation
110
170
 
111
171
  - **[OPENAPI_TO_CONNECTOR.md](./OPENAPI_TO_CONNECTOR.md)** - OpenAPI generator details
@@ -14,10 +14,10 @@ import { Connector } from '../internal/index.mjs';
14
14
  export function buildResolvers(methods, controller) {
15
15
  const resolvers = {};
16
16
  methods.forEach((method) => {
17
- const handler = async (args) => {
17
+ const handler = async (args, ctx) => {
18
18
  if (!methods.includes(method))
19
19
  throw new Error(`${method} not found`);
20
- return controller[method](args);
20
+ return controller[method](args, ctx);
21
21
  };
22
22
  if (method.includes('.')) {
23
23
  // Register nested tree for array-based resolution: ["crm", "contacts", "getPage"]
package/build/cli.mjs CHANGED
File without changes
@@ -44,8 +44,9 @@ export declare abstract class AbstractController {
44
44
  /**
45
45
  * called, when the remote public method is not found on the controller
46
46
  * @param arg
47
+ * @param ctx optional invocation context (taskId, namespace, sticky)
47
48
  */
48
- protected fallback(arg: any): Promise<any>;
49
+ protected fallback(arg: any, ctx?: any): Promise<any>;
49
50
  /**
50
51
  * will be invoked, when the connector has an endpoint enabled
51
52
  * will receive the data and can e.g. create a new task from it
@@ -134,7 +135,7 @@ export declare abstract class AbstractController {
134
135
  /**
135
136
  * @ignore
136
137
  **/
137
- __default(arg: any): Promise<any | null>;
138
+ __default(arg: any, ctx?: any): Promise<any | null>;
138
139
  /**
139
140
  * @ignore
140
141
  **/
@@ -41,8 +41,9 @@ export class AbstractController {
41
41
  /**
42
42
  * called, when the remote public method is not found on the controller
43
43
  * @param arg
44
+ * @param ctx optional invocation context (taskId, namespace, sticky)
44
45
  */
45
- fallback(arg) {
46
+ fallback(arg, ctx) {
46
47
  throw new Error('method not found');
47
48
  }
48
49
  /**
@@ -136,8 +137,8 @@ export class AbstractController {
136
137
  /**
137
138
  * @ignore
138
139
  **/
139
- async __default(arg) {
140
- return this.fallback(arg);
140
+ async __default(arg, ctx) {
141
+ return this.fallback(arg, ctx);
141
142
  }
142
143
  /**
143
144
  * @ignore
@@ -26,10 +26,7 @@ export default class Dispatcher {
26
26
  build(): {
27
27
  introspect: () => any;
28
28
  configSchema: () => any;
29
- execute: ({ query, variables }: {
30
- query: any;
31
- variables: any;
32
- }) => Promise<any>;
29
+ execute: ({ query, variables, taskId, namespace, sticky }: any) => Promise<any>;
33
30
  processPacket: (packet: any) => Promise<any>;
34
31
  start: (arg: any) => Promise<void>;
35
32
  };
@@ -133,7 +133,8 @@ ${arg.configurableClientScope}
133
133
  }
134
134
  return current;
135
135
  };
136
- const execute = async ({ query, variables }) => {
136
+ const execute = async ({ query, variables, taskId, namespace, sticky }) => {
137
+ const ctx = { taskId, namespace, sticky };
137
138
  if (!Array.isArray(query))
138
139
  query = [query];
139
140
  query = query
@@ -143,7 +144,7 @@ ${arg.configurableClientScope}
143
144
  const method = resolveMethod(query);
144
145
  if (!method && !_resolvers.__default)
145
146
  throw new Error(`${originalQuery} not found`);
146
- return method ? method(variables) : _resolvers.__default(variables ? { ...variables, __method: originalQuery } : variables);
147
+ return method ? method(variables, ctx) : _resolvers.__default(variables ? { ...variables, __method: originalQuery } : variables, ctx);
147
148
  };
148
149
  const introspect = () => local._types;
149
150
  const configSchema = () => local._config;
@@ -0,0 +1,87 @@
1
+ /// <reference types="node" resolution-mode="require"/>
2
+ import http from 'node:http';
3
+ type Packet = {
4
+ id?: string;
5
+ m?: string;
6
+ c?: string;
7
+ a?: any;
8
+ e?: any;
9
+ };
10
+ /**
11
+ * A fake ALOMA endpoint that speaks the connector protocol, in-process on a loopback port.
12
+ *
13
+ * Protocol (taken from src/internal/websocket and src/internal/dispatcher):
14
+ * 1. POST {url}connect with `Authorization: Connector <token>`. 401 means "register first", 200 + json means connected.
15
+ * 2. POST {url}register with `Authorization: Connector <registrationToken>` and
16
+ * {name, version, id, publicKey, schema:{configSchema, introspect}, icon}; answer 200 {key: <token>}.
17
+ * 3. The connector opens a websocket at wsUrl (subprotocol "connector", same Authorization header).
18
+ * 4. Both sides exchange text frames {p: [packet, ...]}; a packet is {id, m: method, c: callback key, a: args}.
19
+ * 5. ALOMA sends connector.set-config {secrets} (no reply), then connector.query {query, variables} with a
20
+ * callback key; the connector answers {c: key, a: result} or {c: key, a: {error}}.
21
+ *
22
+ * Anything that does not look like the real protocol is recorded in `protocolErrors`.
23
+ */
24
+ export declare class FakeAloma {
25
+ readonly registrationToken: string;
26
+ readonly sessionToken: string;
27
+ /** bodies of POST /register */
28
+ readonly registrations: any[];
29
+ /** headers of POST /connect */
30
+ readonly connects: Array<{
31
+ authorized: boolean;
32
+ headers: http.IncomingHttpHeaders;
33
+ }>;
34
+ /** every packet the connector sent over the websocket, in order */
35
+ readonly received: Packet[];
36
+ readonly protocolErrors: string[];
37
+ private server;
38
+ private wss;
39
+ private socket?;
40
+ private pending;
41
+ private handlers;
42
+ private waiters;
43
+ private port;
44
+ private id;
45
+ private version;
46
+ static start(): Promise<FakeAloma>;
47
+ /** value for the connector's endpoint setting (env.DEVICE_ENDPOINT) */
48
+ get url(): string;
49
+ /** value for the connector's websocket setting (env.WEBSOCKET_ENDPOINT) */
50
+ get wsUrl(): string;
51
+ get connected(): boolean;
52
+ get registration(): any;
53
+ private listen;
54
+ private readJson;
55
+ private onHttp;
56
+ private onSocket;
57
+ private onPacket;
58
+ /** answer connector -> ALOMA requests such as connector.blob.get or connector.task.new */
59
+ handle(method: string, fn: (args: any) => any | Promise<any>): this;
60
+ /** resolves when the connector's websocket is open (the handshake is finished) */
61
+ waitForConnection(timeoutMs?: number): Promise<void>;
62
+ /** resolves with the first packet the connector sent for `method` (e.g. connector.health.check), now or later */
63
+ waitForPacket(method: string, timeoutMs?: number): Promise<Packet>;
64
+ /** send one raw packet to the connector */
65
+ send(packet: Packet): void;
66
+ /** send a packet with a callback key and wait for the connector's answer (its `a` field) */
67
+ request(method: string, args: any, timeoutMs?: number): Promise<any>;
68
+ /** ask the connector for its schema and method list (connector.introspect) */
69
+ introspect(): Promise<any>;
70
+ /**
71
+ * Send the configuration the way ALOMA does: every field that is not `plain` is a JWE encrypted with the
72
+ * public key the connector registered, audience = connector id. Resolves after the packet is sent;
73
+ * the ConnectorHarness waits for the connector's start.
74
+ */
75
+ setConfig(values: Record<string, any>): Promise<void>;
76
+ /**
77
+ * Call a connector method like ALOMA does (connector.query). Resolves with the connector's raw answer:
78
+ * an object result is passed through, any other value is wrapped as {[method]: value}, a failure is {error}.
79
+ */
80
+ call(method: string | string[], variables?: any, ctx?: {
81
+ taskId?: string;
82
+ namespace?: string;
83
+ sticky?: boolean;
84
+ }): Promise<any>;
85
+ close(): Promise<void>;
86
+ }
87
+ export {};
@@ -0,0 +1,248 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import http from 'node:http';
3
+ import * as jose from 'jose';
4
+ import { WebSocket, WebSocketServer } from 'ws';
5
+ /**
6
+ * A fake ALOMA endpoint that speaks the connector protocol, in-process on a loopback port.
7
+ *
8
+ * Protocol (taken from src/internal/websocket and src/internal/dispatcher):
9
+ * 1. POST {url}connect with `Authorization: Connector <token>`. 401 means "register first", 200 + json means connected.
10
+ * 2. POST {url}register with `Authorization: Connector <registrationToken>` and
11
+ * {name, version, id, publicKey, schema:{configSchema, introspect}, icon}; answer 200 {key: <token>}.
12
+ * 3. The connector opens a websocket at wsUrl (subprotocol "connector", same Authorization header).
13
+ * 4. Both sides exchange text frames {p: [packet, ...]}; a packet is {id, m: method, c: callback key, a: args}.
14
+ * 5. ALOMA sends connector.set-config {secrets} (no reply), then connector.query {query, variables} with a
15
+ * callback key; the connector answers {c: key, a: result} or {c: key, a: {error}}.
16
+ *
17
+ * Anything that does not look like the real protocol is recorded in `protocolErrors`.
18
+ */
19
+ export class FakeAloma {
20
+ registrationToken = `fake-registration-${randomUUID()}`;
21
+ sessionToken = `fake-session-${randomUUID()}`;
22
+ /** bodies of POST /register */
23
+ registrations = [];
24
+ /** headers of POST /connect */
25
+ connects = [];
26
+ /** every packet the connector sent over the websocket, in order */
27
+ received = [];
28
+ protocolErrors = [];
29
+ server;
30
+ wss;
31
+ socket;
32
+ pending = new Map();
33
+ handlers = new Map();
34
+ waiters = [];
35
+ port = 0;
36
+ id = '';
37
+ version = '';
38
+ static async start() {
39
+ const fake = new FakeAloma();
40
+ await fake.listen();
41
+ return fake;
42
+ }
43
+ /** value for the connector's endpoint setting (env.DEVICE_ENDPOINT) */
44
+ get url() {
45
+ return `http://127.0.0.1:${this.port}/`;
46
+ }
47
+ /** value for the connector's websocket setting (env.WEBSOCKET_ENDPOINT) */
48
+ get wsUrl() {
49
+ return `ws://127.0.0.1:${this.port}/transport/`;
50
+ }
51
+ get connected() {
52
+ return this.socket?.readyState === WebSocket.OPEN;
53
+ }
54
+ get registration() {
55
+ return this.registrations[this.registrations.length - 1];
56
+ }
57
+ async listen() {
58
+ this.server = http.createServer((req, res) => {
59
+ this.onHttp(req, res).catch((e) => {
60
+ this.protocolErrors.push(`http handler failed: ${e}`);
61
+ res.writeHead(500).end();
62
+ });
63
+ });
64
+ this.wss = new WebSocketServer({
65
+ noServer: true,
66
+ handleProtocols: (protocols) => (protocols.has('connector') ? 'connector' : false),
67
+ });
68
+ this.server.on('upgrade', (req, socket, head) => {
69
+ const problems = [];
70
+ if (req.headers.authorization !== `Connector ${this.sessionToken}`)
71
+ problems.push('bad or missing Authorization on websocket');
72
+ if (!String(req.headers['sec-websocket-protocol'] || '').split(/,\s*/).includes('connector')) {
73
+ problems.push('websocket subprotocol "connector" not requested');
74
+ }
75
+ if (problems.length) {
76
+ this.protocolErrors.push(...problems);
77
+ socket.write('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n');
78
+ socket.destroy();
79
+ return;
80
+ }
81
+ this.wss.handleUpgrade(req, socket, head, (ws) => this.onSocket(ws));
82
+ });
83
+ await new Promise((resolve) => this.server.listen(0, '127.0.0.1', () => resolve()));
84
+ this.port = this.server.address().port;
85
+ }
86
+ async readJson(req) {
87
+ let text = '';
88
+ for await (const chunk of req)
89
+ text += chunk;
90
+ return text ? JSON.parse(text) : {};
91
+ }
92
+ async onHttp(req, res) {
93
+ const send = (status, body) => {
94
+ res.writeHead(status, { 'Content-Type': 'application/json' });
95
+ res.end(body === undefined ? '' : JSON.stringify(body));
96
+ };
97
+ const body = await this.readJson(req);
98
+ const auth = req.headers.authorization;
99
+ if (req.method === 'POST' && req.url === '/connect') {
100
+ const authorized = auth === `Connector ${this.sessionToken}`;
101
+ this.connects.push({ authorized, headers: req.headers });
102
+ return authorized ? send(200, {}) : send(401);
103
+ }
104
+ if (req.method === 'POST' && req.url === '/register') {
105
+ if (auth !== `Connector ${this.registrationToken}`) {
106
+ this.protocolErrors.push('register called with a wrong registration token');
107
+ return send(401);
108
+ }
109
+ const missing = ['name', 'version', 'id', 'publicKey', 'schema'].filter((key) => body[key] == null);
110
+ if (missing.length)
111
+ this.protocolErrors.push(`register body is missing: ${missing.join(', ')}`);
112
+ this.id = body.id;
113
+ this.version = body.version;
114
+ if (req.headers['user-agent'] !== `${body.id}/${body.version}`) {
115
+ this.protocolErrors.push(`register User-Agent should be id/version, got ${req.headers['user-agent']}`);
116
+ }
117
+ this.registrations.push(body);
118
+ return send(200, { key: this.sessionToken });
119
+ }
120
+ if (req.method === 'POST' && req.url === '/disconnect')
121
+ return send(200, {});
122
+ this.protocolErrors.push(`unexpected request ${req.method} ${req.url}`);
123
+ send(404);
124
+ }
125
+ onSocket(ws) {
126
+ this.socket = ws;
127
+ ws.on('message', (raw) => {
128
+ const frame = JSON.parse(raw.toString());
129
+ const packets = frame.p;
130
+ if (!Array.isArray(packets)) {
131
+ this.protocolErrors.push('frame without a "p" array');
132
+ return;
133
+ }
134
+ packets.forEach((packet) => this.onPacket(packet));
135
+ });
136
+ this.waiters.splice(0).forEach((fn) => fn());
137
+ }
138
+ onPacket(packet) {
139
+ this.received.push(packet);
140
+ // an answer to something ALOMA sent
141
+ if (packet.c && !packet.m && this.pending.has(packet.c)) {
142
+ const done = this.pending.get(packet.c);
143
+ this.pending.delete(packet.c);
144
+ done(packet.a);
145
+ return;
146
+ }
147
+ // the connector asks ALOMA for something (blob, task, ...) and expects an answer on its callback key
148
+ if (packet.m && this.handlers.has(packet.m)) {
149
+ Promise.resolve(this.handlers.get(packet.m)(packet.a)).then((answer) => packet.c && this.send({ c: packet.c, a: answer }), (e) => packet.c && this.send({ c: packet.c, a: { error: String(e) } }));
150
+ }
151
+ }
152
+ /** answer connector -> ALOMA requests such as connector.blob.get or connector.task.new */
153
+ handle(method, fn) {
154
+ this.handlers.set(method, fn);
155
+ return this;
156
+ }
157
+ /** resolves when the connector's websocket is open (the handshake is finished) */
158
+ async waitForConnection(timeoutMs = 5000) {
159
+ if (this.connected)
160
+ return;
161
+ await new Promise((resolve, reject) => {
162
+ const timer = setTimeout(() => {
163
+ reject(new Error(`connector did not connect within ${timeoutMs}ms. registrations=${this.registrations.length} ` +
164
+ `connects=${this.connects.length} protocolErrors=${JSON.stringify(this.protocolErrors)}`));
165
+ }, timeoutMs);
166
+ this.waiters.push(() => {
167
+ clearTimeout(timer);
168
+ resolve();
169
+ });
170
+ });
171
+ }
172
+ /** resolves with the first packet the connector sent for `method` (e.g. connector.health.check), now or later */
173
+ async waitForPacket(method, timeoutMs = 5000) {
174
+ const deadline = Date.now() + timeoutMs;
175
+ for (;;) {
176
+ const found = this.received.find((packet) => packet.m === method);
177
+ if (found)
178
+ return found;
179
+ if (Date.now() > deadline)
180
+ throw new Error(`connector did not send ${method} within ${timeoutMs}ms`);
181
+ await new Promise((resolve) => setTimeout(resolve, 10));
182
+ }
183
+ }
184
+ /** send one raw packet to the connector */
185
+ send(packet) {
186
+ if (!this.connected)
187
+ throw new Error('connector is not connected');
188
+ this.socket.send(JSON.stringify({ p: [{ id: randomUUID(), ...packet }] }));
189
+ }
190
+ /** send a packet with a callback key and wait for the connector's answer (its `a` field) */
191
+ request(method, args, timeoutMs = 5000) {
192
+ const key = `_req-${randomUUID()}`;
193
+ return new Promise((resolve, reject) => {
194
+ const timer = setTimeout(() => {
195
+ this.pending.delete(key);
196
+ reject(new Error(`no answer to ${method} within ${timeoutMs}ms`));
197
+ }, timeoutMs);
198
+ this.pending.set(key, (answer) => {
199
+ clearTimeout(timer);
200
+ resolve(answer);
201
+ });
202
+ this.send({ m: method, c: key, a: args });
203
+ });
204
+ }
205
+ /** ask the connector for its schema and method list (connector.introspect) */
206
+ introspect() {
207
+ return this.request('connector.introspect', {});
208
+ }
209
+ /**
210
+ * Send the configuration the way ALOMA does: every field that is not `plain` is a JWE encrypted with the
211
+ * public key the connector registered, audience = connector id. Resolves after the packet is sent;
212
+ * the ConnectorHarness waits for the connector's start.
213
+ */
214
+ async setConfig(values) {
215
+ if (!this.registration)
216
+ throw new Error('connector has not registered yet');
217
+ const fields = this.registration.schema?.configSchema?.fields || {};
218
+ const publicKey = await jose.importSPKI(Buffer.from(this.registration.publicKey, 'base64').toString(), 'RSA-OAEP-256');
219
+ const secrets = {};
220
+ for (const [key, value] of Object.entries(values)) {
221
+ if (fields[key]?.plain || key === 'endpointUrl') {
222
+ secrets[key] = value;
223
+ continue;
224
+ }
225
+ secrets[key] = await new jose.EncryptJWT({ _data: value })
226
+ .setProtectedHeader({ alg: 'RSA-OAEP-256', enc: 'A256GCM' })
227
+ .setIssuedAt()
228
+ .setIssuer('home.aloma.io')
229
+ .setAudience(this.id)
230
+ .encrypt(publicKey);
231
+ }
232
+ this.send({ m: 'connector.set-config', a: { secrets } });
233
+ }
234
+ /**
235
+ * Call a connector method like ALOMA does (connector.query). Resolves with the connector's raw answer:
236
+ * an object result is passed through, any other value is wrapped as {[method]: value}, a failure is {error}.
237
+ */
238
+ call(method, variables, ctx = {}) {
239
+ return this.request('connector.query', { query: method, variables, ...ctx });
240
+ }
241
+ async close() {
242
+ this.pending.clear();
243
+ this.wss.clients.forEach((client) => client.terminate());
244
+ this.wss.close();
245
+ this.server.closeAllConnections?.();
246
+ await new Promise((resolve) => this.server.close(() => resolve()));
247
+ }
248
+ }
@@ -0,0 +1,51 @@
1
+ import type { Connector } from '../internal/connector/index.mjs';
2
+ import { FakeAloma } from './fake-aloma.mjs';
3
+ import { VendorMock } from './vendor-mock.mjs';
4
+ export type HarnessOptions = {
5
+ /**
6
+ * The connector under test, already configured:
7
+ * `const c = new Connector({id, version, name}); c.configure().types(...).resolvers(...).main(...)`
8
+ */
9
+ connector: Connector;
10
+ /** vendor mock; a fresh one is created when omitted */
11
+ vendor?: VendorMock;
12
+ /**
13
+ * configuration values ALOMA sends after the handshake (encrypted unless the field is `plain`).
14
+ * `null` skips sending any configuration (the connector then never starts its main()).
15
+ */
16
+ config?: Record<string, any> | null;
17
+ /** how long to wait for each step, ms */
18
+ timeoutMs?: number;
19
+ };
20
+ /**
21
+ * Runs a real connector against a fake ALOMA, in-process.
22
+ *
23
+ * It wires the same SDK parts as Connector.run() and makeServer(): Config, WebsocketConnector (connection,
24
+ * registration, transport), onConnect (config decryption, main()), onMessage (dispatch and reply).
25
+ * Left out on purpose: the Prometheus metrics server (fixed port 4050), process signal handlers and
26
+ * env-var configuration, which are global side effects that cannot run once per test.
27
+ */
28
+ export declare class ConnectorHarness {
29
+ readonly aloma: FakeAloma;
30
+ readonly vendor: VendorMock;
31
+ private readonly server;
32
+ private readonly started;
33
+ private constructor();
34
+ static start(options: HarnessOptions): Promise<ConnectorHarness>;
35
+ private static shutdown;
36
+ /** call a connector method and return the raw answer (`{error}` when it failed) */
37
+ call(method: string | string[], variables?: any, ctx?: {
38
+ taskId?: string;
39
+ namespace?: string;
40
+ sticky?: boolean;
41
+ }): Promise<any>;
42
+ /** call a connector method; throws when the connector answered with an error */
43
+ callOk(method: string | string[], variables?: any): Promise<any>;
44
+ /**
45
+ * Shut down the connector and the fake endpoint, restore fetch and the network guard.
46
+ * Then fails if the connector made any request that was not mocked or the fake endpoint saw a protocol error.
47
+ */
48
+ stop({ verify }?: {
49
+ verify?: boolean;
50
+ }): Promise<void>;
51
+ }