@aloma.io/integration-sdk 3.8.67 → 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.
@@ -0,0 +1,375 @@
1
+ // Tests for the connector test harness (src/testing) and, through it, the SDK's
2
+ // handshake, dispatch and result-return paths. No test reaches the network:
3
+ // the only sockets are on 127.0.0.1 and vendor HTTP is mocked.
4
+ import http from 'node:http';
5
+ import {describe, it, afterEach} from 'mocha';
6
+ import {expect} from 'chai';
7
+ import {Transport} from '../build/internal/websocket/transport/index.mjs';
8
+ import {Connector} from '../build/internal/connector/index.mjs';
9
+ import Fetcher from '../build/internal/fetcher/fetcher.mjs';
10
+ import {ConnectorHarness, FakeAloma, VendorMock} from '../build/testing/index.mjs';
11
+
12
+ const VENDOR = 'https://api.vendor.example.test';
13
+
14
+ // A tiny connector, wired the way the SDK wires a connector repo's controller.
15
+ function exampleConnector(seen = {}) {
16
+ const connector = new Connector({id: 'example-connector', version: '1.2.3', name: 'Example Connector'});
17
+
18
+ connector
19
+ .configure()
20
+ .config({
21
+ fields: {
22
+ apiKey: {name: 'API Key', type: 'line'},
23
+ region: {name: 'Region', type: 'line', plain: true},
24
+ },
25
+ })
26
+ .types('type Query { echo: String }')
27
+ .resolvers({
28
+ echo: async (args) => args,
29
+ shout: async ({text}) => String(text).toUpperCase(),
30
+ whoami: async (_args, ctx) => ({ctx}),
31
+ nested: {deep: {method: async () => ({found: true})}},
32
+ getItem: async ({id}) => {
33
+ const client = seen.getClient({
34
+ baseUrl: VENDOR,
35
+ retry: 1,
36
+ customize: (o) => {
37
+ o.headers = {...o.headers, Authorization: `Bearer ${seen.config.apiKey}`};
38
+ },
39
+ });
40
+ return client.fetch(`/items/${id}`, {method: 'GET'});
41
+ },
42
+ createItem: async (body) => {
43
+ const client = seen.getClient({baseUrl: VENDOR, retry: 1});
44
+ return client.fetch('/items', {method: 'POST', body});
45
+ },
46
+ forgottenMock: async () => new Fetcher({retry: 1}).fetch('https://not-mocked.example.test/x'),
47
+ rawSocket: async () =>
48
+ new Promise((resolve, reject) => {
49
+ http.get('http://203.0.113.9/', () => resolve({reached: true})).on('error', reject);
50
+ }),
51
+ boom: async () => {
52
+ throw new Error('kaput');
53
+ },
54
+ makeBlob: async () => ({id: await seen.createBlob({name: 'a.txt'})}),
55
+ })
56
+ .main((arg) => {
57
+ seen.config = arg.config;
58
+ seen.getClient = arg.getClient;
59
+ seen.createBlob = arg.createBlob;
60
+ seen.starts = (seen.starts || 0) + 1;
61
+ });
62
+
63
+ return connector;
64
+ }
65
+
66
+ describe('connector test harness', function () {
67
+ this.timeout(15000);
68
+
69
+ let harness;
70
+ let seen;
71
+ const realFetch = globalThis.fetch;
72
+
73
+ const start = async (extra = {}) => {
74
+ seen = {};
75
+ harness = await ConnectorHarness.start({
76
+ connector: exampleConnector(seen),
77
+ config: {apiKey: 'fake-api-key-not-real', region: 'eu-test'},
78
+ ...extra,
79
+ });
80
+ return harness;
81
+ };
82
+
83
+ afterEach(async () => {
84
+ if (harness) await harness.stop({verify: false});
85
+ harness = undefined;
86
+ });
87
+
88
+ describe('handshake', () => {
89
+ it('registers, connects and opens the websocket', async () => {
90
+ await start();
91
+ const {aloma} = harness;
92
+
93
+ expect(aloma.connected).to.equal(true);
94
+ expect(aloma.registrations).to.have.length(1);
95
+ expect(aloma.protocolErrors).to.deep.equal([]);
96
+ // first connect has no token and is told to register (401), the second carries the issued token
97
+ expect(aloma.connects.map((c) => c.authorized)).to.deep.equal([false, true]);
98
+ });
99
+
100
+ it('registers id, version, name, a public key and the schema', async () => {
101
+ await start();
102
+ const body = harness.aloma.registration;
103
+
104
+ expect(body.id).to.equal('example-connector');
105
+ expect(body.version).to.equal('1.2.3');
106
+ expect(body.name).to.equal('Example Connector');
107
+ expect(body.publicKey).to.be.a('string').with.length.greaterThan(100);
108
+ expect(body.schema.configSchema.fields).to.have.keys('apiKey', 'region');
109
+ expect(body.schema.introspect).to.equal('type Query { echo: String }');
110
+ expect(harness.aloma.connects[1].headers['user-agent']).to.equal('example-connector/1.2.3');
111
+ });
112
+
113
+ it('delivers encrypted config decrypted and plain config as is, then runs main() once', async () => {
114
+ await start();
115
+
116
+ expect(seen.config).to.deep.equal({apiKey: 'fake-api-key-not-real', region: 'eu-test'});
117
+ expect(seen.starts).to.equal(1);
118
+ });
119
+
120
+ it('answers connector.introspect with the schema and types', async () => {
121
+ await start();
122
+ const answer = await harness.aloma.introspect();
123
+
124
+ expect(answer.introspect).to.equal('type Query { echo: String }');
125
+ expect(answer.configSchema.fields.apiKey.name).to.equal('API Key');
126
+ });
127
+
128
+ it('can be started and stopped repeatedly without leaking state', async () => {
129
+ for (let i = 0; i < 3; i++) {
130
+ await start();
131
+ expect(await harness.call('shout', {text: 'x'})).to.deep.equal({shout: 'X'});
132
+ await harness.stop();
133
+ harness = undefined;
134
+ }
135
+ expect(globalThis.fetch).to.equal(realFetch);
136
+ });
137
+ });
138
+
139
+ describe('method calls', () => {
140
+ it('round-trips an object result unchanged', async () => {
141
+ await start();
142
+ expect(await harness.call('echo', {a: 1, b: ['x']})).to.deep.equal({a: 1, b: ['x']});
143
+ });
144
+
145
+ it('wraps a non-object result under the method name', async () => {
146
+ await start();
147
+ expect(await harness.call('shout', {text: 'hello'})).to.deep.equal({shout: 'HELLO'});
148
+ });
149
+
150
+ it('resolves nested methods addressed by a path array', async () => {
151
+ await start();
152
+ expect(await harness.call(['nested', 'deep', 'method'])).to.deep.equal({found: true});
153
+ });
154
+
155
+ it('forwards taskId, namespace and sticky as ctx', async () => {
156
+ await start();
157
+ const answer = await harness.call('whoami', {}, {taskId: 't-1', namespace: 'ns', sticky: true});
158
+
159
+ expect(answer.ctx).to.deep.equal({taskId: 't-1', namespace: 'ns', sticky: true});
160
+ });
161
+
162
+ it('answers concurrent calls independently', async () => {
163
+ await start();
164
+ const results = await Promise.all([1, 2, 3, 4, 5].map((n) => harness.call('echo', {n})));
165
+
166
+ expect(results.map((r) => r.n)).to.deep.equal([1, 2, 3, 4, 5]);
167
+ });
168
+
169
+ it('serves connector-initiated requests through the fake endpoint (blobs)', async () => {
170
+ await start();
171
+ harness.aloma.handle('connector.blob.create', (args) => (args.name === 'a.txt' ? {id: 'blob-1'} : {error: 'bad'}));
172
+
173
+ expect(await harness.call('makeBlob')).to.deep.equal({id: 'blob-1'});
174
+ });
175
+ });
176
+
177
+ describe('vendor HTTP mock', () => {
178
+ it('answers the connector vendor call from a recorded response', async () => {
179
+ await start();
180
+ harness.vendor.get(`${VENDOR}/items/7`, {json: {id: 7, name: 'recorded'}});
181
+
182
+ expect(await harness.call('getItem', {id: 7})).to.deep.equal({id: 7, name: 'recorded'});
183
+ expect(harness.vendor.requests).to.have.length(1);
184
+ expect(harness.vendor.requests[0].headers.authorization).to.equal('Bearer fake-api-key-not-real');
185
+ });
186
+
187
+ it('lets a responder function see the request body and choose the reply', async () => {
188
+ await start();
189
+ harness.vendor.post(`${VENDOR}/items`, (req) => ({status: 201, json: {created: JSON.parse(req.body)}}));
190
+
191
+ expect(await harness.call('createItem', {name: 'new'})).to.deep.equal({created: {name: 'new'}});
192
+ });
193
+
194
+ it('records the url the Fetcher really sends', async () => {
195
+ await start();
196
+ harness.vendor.get(`${VENDOR}/items/1`, {json: {}});
197
+ await harness.call('getItem', {id: 1});
198
+
199
+ // PINS SUSPECT BEHAVIOUR: Fetcher joins baseUrl + path and then collapses every run of slashes with
200
+ // .replace(/\/\/+/g, '/'), so "https://host" becomes "https:/host". Node's URL parser repairs
201
+ // this for http(s), but the same replace would also damage a "//" inside a query value.
202
+ expect(harness.vendor.requests[0].url).to.equal('https://api.vendor.example.test/items/1');
203
+ expect(harness.vendor.requests[0].rawUrl).to.equal('https:/api.vendor.example.test/items/1');
204
+ });
205
+
206
+ it('turns a vendor error status into a method error', async () => {
207
+ await start();
208
+ harness.vendor.get(/\/items\/9$/, {status: 404, text: 'no such item'});
209
+
210
+ const answer = await harness.call('getItem', {id: 9});
211
+ expect(answer.error).to.match(/404 no such item/);
212
+ });
213
+
214
+ it('rejects an unmocked request, reports it to the connector and fails the test on stop()', async () => {
215
+ await start();
216
+ const answer = await harness.call('forgottenMock');
217
+
218
+ expect(answer.error).to.match(/UNMOCKED vendor request: GET https:\/\/not-mocked\.example\.test\/x/);
219
+ expect(harness.vendor.unmocked).to.have.length(1);
220
+
221
+ let failure;
222
+ try {
223
+ await harness.stop();
224
+ } catch (e) {
225
+ failure = e;
226
+ }
227
+ harness = undefined;
228
+ expect(failure, 'stop() must fail when an unmocked request happened').to.be.instanceOf(Error);
229
+ expect(failure.message).to.match(/UNMOCKED vendor requests/);
230
+ });
231
+
232
+ it('blocks raw sockets to non-loopback hosts even when fetch is bypassed', async () => {
233
+ await start();
234
+ const answer = await harness.call('rawSocket');
235
+
236
+ expect(answer.error).to.match(/NETWORK BLOCKED/);
237
+ });
238
+
239
+ it('restores fetch and the network guard on stop()', async () => {
240
+ await start();
241
+ expect(globalThis.fetch).to.not.equal(realFetch);
242
+ await harness.stop();
243
+ harness = undefined;
244
+
245
+ expect(globalThis.fetch).to.equal(realFetch);
246
+ });
247
+
248
+ it('limits a route with times and can assert every route was used', async () => {
249
+ const vendor = new VendorMock();
250
+ vendor.get('https://a.example.test/x', {json: {n: 1}}, {times: 1});
251
+ vendor.get('https://b.example.test/never', {json: {}});
252
+ vendor.install();
253
+ try {
254
+ const first = await globalThis.fetch('https://a.example.test/x');
255
+ expect(await first.json()).to.deep.equal({n: 1});
256
+
257
+ let failure;
258
+ try {
259
+ await globalThis.fetch('https://a.example.test/x');
260
+ } catch (e) {
261
+ failure = e;
262
+ }
263
+ expect(failure && failure.message).to.match(/UNMOCKED/);
264
+ expect(() => vendor.verifyAllRoutesUsed()).to.throw(/never called/);
265
+ expect(() => vendor.verify()).to.throw(/UNMOCKED/);
266
+ } finally {
267
+ vendor.uninstall();
268
+ }
269
+ });
270
+ });
271
+
272
+ describe('failing connectors', () => {
273
+ it('reports a throwing method as an error answer and keeps serving', async () => {
274
+ await start();
275
+
276
+ expect(await harness.call('boom')).to.deep.equal({error: 'Error: kaput'});
277
+ expect(await harness.call('shout', {text: 'still up'})).to.deep.equal({shout: 'STILL UP'});
278
+ });
279
+
280
+ it('reports an unknown method as an error answer', async () => {
281
+ await start();
282
+ const answer = await harness.call('doesNotExist');
283
+
284
+ expect(answer.error).to.match(/doesNotExist not found/);
285
+ });
286
+
287
+ it('callOk() throws when the connector answered with an error', async () => {
288
+ await start();
289
+ let failure;
290
+ try {
291
+ await harness.callOk('boom');
292
+ } catch (e) {
293
+ failure = e;
294
+ }
295
+ expect(failure.message).to.match(/boom failed: Error: kaput/);
296
+ });
297
+
298
+ it('fails start() with the cause when main() throws', async () => {
299
+ const connector = new Connector({id: 'broken-main', version: '0.0.1', name: 'Broken'});
300
+ connector
301
+ .configure()
302
+ .config({fields: {k: {name: 'K', type: 'line'}}})
303
+ .types('type Query { x: String }')
304
+ .resolvers({x: async () => 1})
305
+ .main(() => {
306
+ throw new Error('cannot start');
307
+ });
308
+
309
+ let failure;
310
+ try {
311
+ await ConnectorHarness.start({connector, config: {k: 'v'}});
312
+ } catch (e) {
313
+ failure = e;
314
+ }
315
+ expect(failure && failure.message).to.equal('cannot start');
316
+ });
317
+ });
318
+
319
+ describe('pinned SDK behaviour', () => {
320
+ it('Transport.close() never terminates the websocket', () => {
321
+ // PINS SUSPECT BEHAVIOUR: Transport.close() calls local.ws.terminate(), but `local` is not defined in
322
+ // close() (it is only defined in the other methods). The ReferenceError is swallowed by the try/catch,
323
+ // so close() clears its timers and flags but leaves the socket open. Today a closed connector relies on
324
+ // the server side (or process exit) to drop the connection.
325
+ const transport = new Transport({config: {}, onMessage() {}, onConnect() {}});
326
+ let terminated = false;
327
+ transport.ws = {terminate: () => (terminated = true)};
328
+
329
+ transport.close();
330
+
331
+ expect(transport.running).to.equal(false);
332
+ expect(transport.connected).to.equal(false);
333
+ expect(terminated).to.equal(false);
334
+ });
335
+ });
336
+
337
+ describe('fake ALOMA strictness', () => {
338
+ let aloma;
339
+ afterEach(async () => aloma && (await aloma.close()));
340
+
341
+ it('refuses registration with a wrong registration token and records it', async () => {
342
+ aloma = await FakeAloma.start();
343
+ const response = await fetch(`${aloma.url}register`, {
344
+ method: 'POST',
345
+ body: '{}',
346
+ headers: {Authorization: 'Connector wrong-token'},
347
+ });
348
+
349
+ expect(response.status).to.equal(401);
350
+ expect(aloma.protocolErrors.join()).to.match(/wrong registration token/);
351
+ });
352
+
353
+ it('answers /connect with 401 until a token was issued by registering', async () => {
354
+ aloma = await FakeAloma.start();
355
+ const response = await fetch(`${aloma.url}connect`, {
356
+ method: 'POST',
357
+ body: '{}',
358
+ headers: {Authorization: 'Connector null'},
359
+ });
360
+
361
+ expect(response.status).to.equal(401);
362
+ });
363
+
364
+ it('times out with a diagnostic when no connector connects', async () => {
365
+ aloma = await FakeAloma.start();
366
+ let failure;
367
+ try {
368
+ await aloma.waitForConnection(50);
369
+ } catch (e) {
370
+ failure = e;
371
+ }
372
+ expect(failure.message).to.match(/did not connect within 50ms/);
373
+ });
374
+ });
375
+ });
@@ -141,7 +141,9 @@ paths:
141
141
  it('should reject invalid JSON/YAML', () => {
142
142
  expect(() => {
143
143
  OpenAPIToConnector.parseSpec('invalid json {');
144
- }).to.throw('Failed to parse OpenAPI spec');
144
+ // PINS: expectation updated to today's output on 24 Oct; original expected 'Failed to parse OpenAPI spec'.
145
+ // 'invalid json {' is not JSON, but js-yaml loads it as a plain string, so it is rejected by the schema check.
146
+ }).to.throw('Invalid OpenAPI 3.x specification');
145
147
  });
146
148
  });
147
149
 
@@ -152,9 +154,12 @@ paths:
152
154
 
153
155
  expect(controllerCode).to.include('import {AbstractController}');
154
156
  expect(controllerCode).to.include('export default class Controller extends AbstractController');
155
- expect(controllerCode).to.include('async getUsers(args: any)');
156
- expect(controllerCode).to.include('async createUser(args: any)');
157
- expect(controllerCode).to.include('async get_users_{id}(args: any)');
157
+ // PINS: expectation updated to today's output on 24 Oct; original expected 'async getUsers(args: any)',
158
+ // 'async createUser(args: any)' and 'async get_users_{id}(args: any)' (an older args-object signature; the
159
+ // third name never matched the spec's operationId getUserById, path params are now positional).
160
+ expect(controllerCode).to.include('async getUsers(options?: {headers?: Record<string, string>})');
161
+ expect(controllerCode).to.include('async createUser(options');
162
+ expect(controllerCode).to.include('async getUserById(id: string, options?: {headers?: Record<string, string>})');
158
163
  });
159
164
 
160
165
  it('should generate proper JSDoc comments', () => {
@@ -163,8 +168,20 @@ paths:
163
168
 
164
169
  expect(controllerCode).to.include('* Get all users');
165
170
  expect(controllerCode).to.include('* Retrieve a list of all users');
166
- expect(controllerCode).to.include('* @param args.body - Request body');
167
- expect(controllerCode).to.include('* @param args.id - User ID');
171
+ // PINS: expectation updated to today's output on 24 Oct; original expected '* @param args.body - Request body'
172
+ // and '* @param args.id - User ID' (older args-object JSDoc; the object body is now expanded into its properties).
173
+ expect(controllerCode).to.include('* @param {string} options.name (required)');
174
+ expect(controllerCode).to.include('* @param {string} id User ID');
175
+ });
176
+
177
+ it('should substitute path parameters into the url', () => {
178
+ // Added on 24 Oct with the entry-point fix: nothing asserted the generated path parameter handling before.
179
+ const generator = new OpenAPIToConnector(validOpenAPISpec, 'Test Connector');
180
+ const controllerCode = generator.generateController();
181
+
182
+ expect(controllerCode).to.include("let url = '/users/{id}';");
183
+ expect(controllerCode).to.include('if (id) {');
184
+ expect(controllerCode).to.include("url = url.replace('{id}', id);");
168
185
  });
169
186
 
170
187
  it('should handle operations without operationId', () => {
@@ -187,7 +204,8 @@ paths:
187
204
  const generator = new OpenAPIToConnector(specWithoutOperationId, 'Test Connector');
188
205
  const controllerCode = generator.generateController();
189
206
 
190
- expect(controllerCode).to.include('async get_test(args: any)');
207
+ // PINS: expectation updated to today's output on 24 Oct; original expected 'async get_test(args: any)'.
208
+ expect(controllerCode).to.include('async get_test(options?: {headers?: Record<string, string>})');
191
209
  });
192
210
 
193
211
  it('should throw error for empty spec', () => {
@@ -44,8 +44,13 @@ describe('Scenario Tests - Full Code Generation', () => {
44
44
  const actualOutput = generator.generateController();
45
45
 
46
46
  // Verify methods without options don't have unnecessary code
47
- expect(actualOutput).to.include('async getProducts() {'); // No options parameter
48
- expect(actualOutput).to.not.include('options = options || {}'); // No options initialization for getProducts
47
+ // PINS: expectation updated to today's output on 24 Oct; original expected 'async getProducts() {'.
48
+ // Since the headers type fix (f2d55a0, Bug C) every method takes an optional options with headers.
49
+ expect(actualOutput).to.include('async getProducts(options?: {headers?: Record<string, string>}) {');
50
+ // PINS: expectation updated to today's output on 24 Oct; original asserted this on the whole output, which also
51
+ // contains createProduct's (legitimate) initialization, so it is now scoped to the getProducts method only.
52
+ const getProductsBody = actualOutput.slice(actualOutput.indexOf('async getProducts('), actualOutput.indexOf('async createProduct('));
53
+ expect(getProductsBody).to.not.include('options = options || {}'); // No options initialization for getProducts
49
54
  expect(actualOutput).to.include('async createProduct(options:'); // Has options parameter
50
55
  expect(actualOutput).to.include('options = options || {};'); // Has options initialization for createProduct
51
56
  });
@@ -163,13 +168,15 @@ describe('Scenario Tests - Full Code Generation', () => {
163
168
  const actualOutput = generator.generateResourceClass('OrdersResource');
164
169
 
165
170
  // Verify path parameters are handled correctly
166
- expect(actualOutput).to.include('export function getOrder(this: any, orderId: string)');
171
+ // PINS: expectation updated to today's output on 24 Oct; original expected 'getOrder(this: any, orderId: string)'
172
+ // and 'cancelOrder(this: any, orderId: string)' with no trailing options (stale since the headers type fix, Bug C).
173
+ expect(actualOutput).to.include('export function getOrder(this: any, orderId: string, options?: {headers?: Record<string, string>})');
167
174
  expect(actualOutput).to.include('export function updateOrderStatus(this: any, orderId: string, options');
168
- expect(actualOutput).to.include('export function cancelOrder(this: any, orderId: string)');
175
+ expect(actualOutput).to.include('export function cancelOrder(this: any, orderId: string, options?: {headers?: Record<string, string>})');
169
176
 
170
177
  // Verify methods without options don't have unnecessary code
171
- expect(actualOutput).to.match(/export function getOrder\(this: any, orderId: string\) \{/);
172
- expect(actualOutput).to.match(/export function cancelOrder\(this: any, orderId: string\) \{/);
178
+ expect(actualOutput).to.match(/export function getOrder\(this: any, orderId: string, options\?: \{headers\?: Record<string, string>\}\) \{/);
179
+ expect(actualOutput).to.match(/export function cancelOrder\(this: any, orderId: string, options\?: \{headers\?: Record<string, string>\}\) \{/);
173
180
  });
174
181
 
175
182
  it('should correctly expose resource methods in main controller', () => {
@@ -278,7 +285,8 @@ describe('Scenario Tests - Full Code Generation', () => {
278
285
  const output = generator.generateController();
279
286
 
280
287
  // Should not have options parameter or initialization
281
- expect(output).to.include('async getSimple() {');
288
+ // PINS: expectation updated to today's output on 24 Oct; original expected 'async getSimple() {' (stale since Bug C).
289
+ expect(output).to.include('async getSimple(options?: {headers?: Record<string, string>}) {');
282
290
  expect(output).to.not.include('options = options || {}');
283
291
  expect(output).to.not.include('headers: options');
284
292
  });
@@ -0,0 +1,52 @@
1
+ // Mutation check for the SDK tests that `yarn test` now runs (openapi-to-connector generator tests, scenarios).
2
+ //
3
+ // yarn build && node tools/mutation-run-entry.mjs
4
+ //
5
+ // Applies each single-point bug to src/openapi-to-connector.mts, rebuilds, runs `yarn test` and expects it to FAIL.
6
+ // The source file is restored after every mutant (and on exit). Prints a markdown table.
7
+ import {spawnSync} from 'node:child_process';
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+ import {fileURLToPath} from 'node:url';
11
+
12
+ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
13
+ const file = path.join(root, 'src/openapi-to-connector.mts');
14
+ const original = fs.readFileSync(file, 'utf8');
15
+ const restore = () => fs.writeFileSync(file, original);
16
+ process.on('exit', restore);
17
+ process.on('SIGINT', () => process.exit(130));
18
+
19
+ // [id, exact text (must occur once), replacement, bug]
20
+ const MUTANTS = [
21
+ ['E01', "method: method.toUpperCase(),\n path,", "method: method.toLowerCase(),\n path,", 'the HTTP verb is taken in lower case'],
22
+ ['E02', "lines.push(` if (${paramName}) {`);\n lines.push(` url = url.replace('{${paramName}}', ${paramName});`);\n lines.push(` }`);\n }", "lines.push(` if (!${paramName}) {`);\n lines.push(` url = url.replace('{${paramName}}', ${paramName});`);\n lines.push(` }`);\n }", 'path parameter substitution is guarded by the wrong condition'],
23
+ ['E03', "return `${methodPrefix}_${pathSuffix}`;", "return `${methodPrefix}${pathSuffix}`;", 'fallback method name drops the separator'],
24
+ ['E04', "const requiredText = propRequired ? '(required)' : '(optional)';", "const requiredText = propRequired ? '(optional)' : '(required)';", 'body properties are documented with the wrong required flag'],
25
+ ['E05', " // Always include headers in options type (generated code references options.headers)\n optionProps.push('headers?: Record<string, string>');", " // Always include headers in options type (generated code references options.headers)", 'headers are dropped from the generated options type'],
26
+ ['E06', "lines.push(` options = options || {};`);", "lines.push(` // no options default`);", 'options is not defaulted in generated methods'],
27
+ ['E07', "if (criticalErrors.length > 0) {", "if (false) {", 'invalid specs are no longer rejected'],
28
+ ['E08', "const propRequired = (schema.required && schema.required.includes(propName)) || requestBody.required;", "const propRequired = (schema.required && schema.required.includes(propName)) && requestBody.required;", 'a property is required only if both the schema and the body say so'],
29
+ ];
30
+
31
+ const sh = (args) => spawnSync('npx', ['--yes', 'yarn@1.22.22', '-s', ...args], {cwd: root, encoding: 'utf8'});
32
+ const rows = [];
33
+ let survived = 0;
34
+ for (const [id, find, repl, bug] of MUTANTS) {
35
+ const n = original.split(find).length - 1;
36
+ if (n !== 1) { console.error(`${id}: pattern occurs ${n} times, expected 1`); process.exit(2); }
37
+ fs.writeFileSync(file, original.replace(find, repl));
38
+ const b = sh(['build']);
39
+ let result;
40
+ if (b.status !== 0) result = 'build failed (not counted)';
41
+ else {
42
+ const t = sh(['test']);
43
+ result = t.status !== 0 ? 'killed' : 'SURVIVED';
44
+ if (t.status === 0) survived++;
45
+ }
46
+ restore();
47
+ rows.push(`| ${id} | ${bug} | ${result} |`);
48
+ console.log(rows.at(-1));
49
+ }
50
+ sh(['build']);
51
+ console.log(`\n${MUTANTS.length - survived}/${MUTANTS.length} killed`);
52
+ process.exit(survived ? 1 : 0);
@@ -0,0 +1,120 @@
1
+ // Mutation check for the connector test harness.
2
+ //
3
+ // yarn build && yarn test:mutation
4
+ //
5
+ // Applies each deliberate single-point bug below to src/, rebuilds, runs the harness tests and expects them
6
+ // to FAIL. The original file is restored after every mutant (and on Ctrl-C). Writes test/MUTATION.md.
7
+ // Not part of CI: it rebuilds the SDK ~30 times. Run it when the harness or the SDK paths it covers change.
8
+ import {spawnSync} from 'node:child_process';
9
+ import fs from 'node:fs';
10
+ import path from 'node:path';
11
+ import {fileURLToPath} from 'node:url';
12
+
13
+ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
14
+ const src = (p) => path.join(root, 'src', p);
15
+
16
+ // [id, area, file (under src/), exact text to find (must occur once), replacement, what the bug is]
17
+ const MUTANTS = [
18
+ ['M01', 'sdk handshake', 'internal/websocket/connection/index.mjs', 'response.status === 401', 'response.status === 403', 'a 401 from /connect no longer triggers registration'],
19
+ ['M02', 'sdk handshake', 'internal/websocket/connection/index.mjs', 'config.setToken(await new Registration(local.config).run());', 'await new Registration(local.config).run();', 'the token from registration is not stored'],
20
+ ['M03', 'sdk handshake', 'internal/websocket/connection/registration.mjs', 'return (await response.json()).key;', 'return (await response.json()).token;', 'registration reads the wrong field of the answer'],
21
+ ['M04', 'sdk handshake', 'internal/websocket/connection/registration.mjs', 'id: config.id(),', 'id: config.name(),', 'registration sends the name as the connector id'],
22
+ ['M05', 'sdk handshake', 'internal/websocket/connection/registration.mjs', 'publicKey: config.publicKey(),', 'publicKey: undefined,', 'registration omits the public key'],
23
+ ['M06', 'sdk handshake', 'internal/websocket/connection/constants.mjs', "'User-Agent': config.id() + '/' + config.version(),\n };\n\n what.headers[AUTHORIZATION] = `Connector ${config.token()}`;", "'User-Agent': config.id(),\n };\n\n what.headers[AUTHORIZATION] = `Connector ${config.token()}`;", 'authenticated requests drop the version from the User-Agent'],
24
+ ['M07', 'sdk handshake', 'internal/websocket/connection/constants.mjs', 'what.headers[AUTHORIZATION] = `Connector ${config.token()}`;', 'what.headers[AUTHORIZATION] = `Bearer ${config.token()}`;', 'wrong authorization scheme for the session token'],
25
+ ['M08', 'sdk handshake', 'internal/websocket/transport/index.mjs', "config.wsUrl(), ['connector']", 'config.wsUrl(), []', 'websocket opened without the "connector" subprotocol'],
26
+ ['M09', 'sdk transport', 'internal/websocket/transport/index.mjs', 'msgs.p?.forEach((item) => {', 'msgs.p?.slice(1).forEach((item) => {', 'the first packet of every incoming frame is dropped'],
27
+ ['M10', 'sdk transport', 'internal/websocket/transport/index.mjs', 'local.ws.send(JSON.stringify({p: packets}));', 'local.ws.send(JSON.stringify({packets}));', 'outgoing frames use the wrong envelope'],
28
+ ['M11', 'sdk transport', 'internal/websocket/transport/index.mjs', 'if (packet.cb() && this.callbacks[packet.cb()]) {', 'if (false && packet.cb() && this.callbacks[packet.cb()]) {', 'answers to connector-initiated requests are never routed to their callback'],
29
+ ['M12', 'sdk dispatch', 'internal/dispatcher/index.mts', 'case \'connector.set-config\':\n await local.onConfig({...packet.args().secrets});', 'case \'connector.set-config\':\n await local.onConfig({});', 'configuration secrets are dropped before onConfig'],
30
+ ['M13', 'sdk dispatch', 'internal/dispatcher/index.mts', 'const ctx = {taskId, namespace, sticky};', 'const ctx = {taskId, namespace};', 'sticky is not forwarded to the method'],
31
+ ['M14', 'sdk dispatch', 'internal/dispatcher/index.mts', 'if (!method && !_resolvers.__default) throw new Error(`${originalQuery} not found`);', 'if (false) throw new Error(`${originalQuery} not found`);', 'unknown methods are no longer reported as "not found"'],
32
+ ['M15', 'sdk result return', 'internal/dispatcher/index.mts', 'return typeof ret === \'object\' && !Array.isArray(ret) ? ret : {[packet.args().query]: ret};', 'return typeof ret === \'object\' && !Array.isArray(ret) ? ret : {result: ret};', 'scalar results are wrapped under the wrong key'],
33
+ ['M16', 'sdk result return', 'internal/util/index.mts', 'transport.send(transport.newPacket({c: packet.cb(), a: {...arg}}));', 'transport.send(transport.newPacket({c: packet.cb(), a: {}}));', 'successful results are replied empty'],
34
+ ['M17', 'sdk result return', 'internal/util/index.mts', "a: {error: '' + e}", "a: {error: 'failed'}", 'errors lose their message'],
35
+ ['M18', 'sdk result return', 'internal/connector/server/on-message.mts', 'if (ret) reply(ret, packet, transport);', 'if (false) reply(ret, packet, transport);', 'results are never sent back'],
36
+ ['M19', 'sdk config', 'internal/connector/server/on-connect/decrypt-config.mts', 'jwe.decrypt(value, config.id())', "jwe.decrypt(value, 'other-audience')", 'config is decrypted for the wrong audience'],
37
+ ['M20', 'sdk config', 'internal/connector/server/on-connect/decrypt-config.mts', 'if (fields[key]?.plain ||', 'if (false ||', 'plain fields are decrypted like secrets'],
38
+ ['M21', 'sdk config', 'internal/connector/server/on-connect/index.mts', 'config: decrypted,\n oauth: theOAuth,', 'config: {},\n oauth: theOAuth,', 'main() receives an empty config'],
39
+ ['M22', 'harness vendor mock', 'testing/vendor-mock.mts', 'error.alomaHarnessUnmocked = true;\n throw error;', 'error.alomaHarnessUnmocked = true;\n return new Response(\'{}\');', 'an unmocked request is answered instead of rejected'],
40
+ ['M23', 'harness vendor mock', 'testing/vendor-mock.mts', 'if (this.unmocked.length) {', 'if (false) {', 'verify() never fails'],
41
+ ['M24', 'harness vendor mock', 'testing/vendor-mock.mts', 'const status = spec.status ?? 200;', 'const status = 200;', 'mocked status codes are ignored'],
42
+ ['M25', 'harness vendor mock', 'testing/vendor-mock.mts', 'r.used < r.times && ', '', 'the times limit on a route is ignored'],
43
+ ['M26', 'harness network guard', 'testing/network-guard.mts', 'if (!local && !LOOPBACK.has(host)) {', 'if (false) {', 'the network guard lets everything through (the mutant may try one TCP connect to the TEST-NET-3 documentation address 203.0.113.9)'],
44
+ ['M27', 'harness fake ALOMA', 'testing/fake-aloma.mts', 'const authorized = auth === `Connector ${this.sessionToken}`;', 'const authorized = true;', 'the fake accepts a connect without registration'],
45
+ ['M28', 'harness fake ALOMA', 'testing/fake-aloma.mts', '.setAudience(this.id)', ".setAudience('wrong')", 'the fake encrypts config for the wrong audience'],
46
+ ['M29', 'harness fake ALOMA', 'testing/fake-aloma.mts', ' done(packet.a);', ' done(undefined);', 'the fake drops the connector\'s answer'],
47
+ ['M30', 'harness fake ALOMA', 'testing/fake-aloma.mts', "{query: method, variables, ...ctx}", "{query: method, variables}", 'call() drops the ctx fields'],
48
+ ['M31', 'harness', 'testing/harness.mts', 'this.vendor.verify();', '', 'stop() no longer fails on unmocked requests'],
49
+ ['M32', 'harness', 'testing/harness.mts', "await aloma.setConfig(options.config || {});", "", 'the harness never sends configuration'],
50
+ ];
51
+
52
+ const run = (cmd, args, timeout) =>
53
+ spawnSync(cmd, args, {cwd: root, encoding: 'utf8', timeout, env: {...process.env, FORCE_COLOR: '0'}});
54
+
55
+ const build = () => {
56
+ const r = run('node', ['./node_modules/typescript/bin/tsc'], 120000);
57
+ if (r.status !== 0) throw new Error('build failed:\n' + r.stdout + r.stderr);
58
+ };
59
+ const mocha = () => run('node', ['./node_modules/mocha/bin/_mocha', '--bail', '--exit', 'test/harness.test.mjs', 'test/harness-connector-smoke.test.mjs'], 150000);
60
+
61
+ let current = null;
62
+ const restore = () => {
63
+ if (current) fs.writeFileSync(current.file, current.original);
64
+ current = null;
65
+ };
66
+ process.on('SIGINT', () => {
67
+ restore();
68
+ process.exit(130);
69
+ });
70
+
71
+ const results = [];
72
+ try {
73
+ build();
74
+ const baseline = mocha();
75
+ if (baseline.status !== 0) throw new Error('baseline run must pass before mutating:\n' + baseline.stdout.slice(-2000));
76
+
77
+ for (const [id, area, rel, find, replace, bug] of MUTANTS) {
78
+ const file = src(rel);
79
+ const original = fs.readFileSync(file, 'utf8');
80
+ const count = original.split(find).length - 1;
81
+ if (count !== 1) throw new Error(`${id}: expected exactly one match in ${rel}, found ${count}`);
82
+
83
+ current = {file, original};
84
+ let outcome;
85
+ try {
86
+ fs.writeFileSync(file, original.replace(find, () => replace));
87
+ build();
88
+ const r = mocha();
89
+ const failing = (r.stdout.match(/^\s+\d+\) .+$/gm) || [])[0]?.trim() || '';
90
+ outcome = r.error?.code === 'ETIMEDOUT' ? 'killed (timeout)' : r.status !== 0 ? 'killed' : 'SURVIVED';
91
+ results.push({id, area, rel, bug, outcome, by: failing});
92
+ } finally {
93
+ restore();
94
+ }
95
+ console.log(id, results[results.length - 1].outcome, '-', bug);
96
+ }
97
+ } finally {
98
+ restore();
99
+ build();
100
+ }
101
+
102
+ const killed = results.filter((r) => r.outcome.startsWith('killed')).length;
103
+ const md = [
104
+ '# Mutation check for the connector test harness',
105
+ '',
106
+ `Result: **${killed}/${results.length} mutants killed**${killed === results.length ? '' : ' (survivors listed below)'}.`,
107
+ '',
108
+ 'Each row is one deliberate single-point bug in the SDK code paths the harness exercises (handshake, transport,',
109
+ 'dispatch, result return, config) or in the harness itself. `tools/mutation-run.mjs` applies it to `src/`, rebuilds,',
110
+ 'runs `test/harness.test.mjs` with `--bail`, expects a failure, and restores the file. Re-run with',
111
+ '`yarn build && yarn test:mutation` after changing the harness or those SDK paths.',
112
+ '',
113
+ '| # | Area | File | Bug | Result |',
114
+ '|---|------|------|-----|--------|',
115
+ ...results.map((r) => `| ${r.id} | ${r.area} | \`src/${r.rel}\` | ${r.bug} | ${r.outcome} |`),
116
+ '',
117
+ ].join('\n');
118
+ fs.writeFileSync(path.join(root, 'test', 'MUTATION.md'), md);
119
+ console.log(`\n${killed}/${results.length} killed`);
120
+ process.exit(killed === results.length ? 0 : 1);