@aloma.io/integration-sdk 3.8.67 → 3.8.69

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 (65) hide show
  1. package/README.md +60 -0
  2. package/build/testing/fake-aloma.d.mts +87 -0
  3. package/build/testing/fake-aloma.mjs +248 -0
  4. package/build/testing/harness.d.mts +51 -0
  5. package/build/testing/harness.mjs +139 -0
  6. package/build/testing/index.d.mts +15 -0
  7. package/build/testing/index.mjs +13 -0
  8. package/build/testing/keys.d.mts +9 -0
  9. package/build/testing/keys.mjs +15 -0
  10. package/build/testing/network-guard.d.mts +3 -0
  11. package/build/testing/network-guard.mjs +49 -0
  12. package/build/testing/runtime-connector.d.mts +11 -0
  13. package/build/testing/runtime-connector.mjs +38 -0
  14. package/build/testing/vendor-mock.d.mts +59 -0
  15. package/build/testing/vendor-mock.mjs +121 -0
  16. package/build/transform/index.d.mts +2 -2
  17. package/build/transform/index.mjs +493 -78
  18. package/package.json +8 -4
  19. package/src/testing/fake-aloma.mts +306 -0
  20. package/src/testing/harness.mts +172 -0
  21. package/src/testing/index.mts +15 -0
  22. package/src/testing/keys.mts +19 -0
  23. package/src/testing/network-guard.mts +57 -0
  24. package/src/testing/runtime-connector.mts +50 -0
  25. package/src/testing/vendor-mock.mts +166 -0
  26. package/src/transform/index.mts +530 -96
  27. package/test/MUTATION-entry.md +21 -0
  28. package/test/MUTATION.md +43 -0
  29. package/test/fixtures/transform/basic/expected.json +9 -0
  30. package/test/fixtures/transform/basic/src/controller/abstract.mts +5 -0
  31. package/test/fixtures/transform/basic/src/controller/index.mts +30 -0
  32. package/test/fixtures/transform/basic/tsconfig.json +1 -0
  33. package/test/fixtures/transform/interface-param/src/controller/abstract.mts +5 -0
  34. package/test/fixtures/transform/interface-param/src/controller/index.mts +12 -0
  35. package/test/fixtures/transform/interface-param/tsconfig.json +1 -0
  36. package/test/fixtures/transform/overloads/expected.json +6 -0
  37. package/test/fixtures/transform/overloads/src/controller/abstract.mts +5 -0
  38. package/test/fixtures/transform/overloads/src/controller/index.mts +11 -0
  39. package/test/fixtures/transform/overloads/tsconfig.json +1 -0
  40. package/test/fixtures/transform/tdd-stub/expected.json +8 -0
  41. package/test/fixtures/transform/tdd-stub/src/controller/abstract.mts +6 -0
  42. package/test/fixtures/transform/tdd-stub/src/controller/index.mts +204 -0
  43. package/test/fixtures/transform/tdd-stub/tsconfig.json +1 -0
  44. package/test/fixtures/transform/two-exports/src/controller/abstract.mts +5 -0
  45. package/test/fixtures/transform/two-exports/src/controller/index.mts +9 -0
  46. package/test/fixtures/transform/two-exports/tsconfig.json +1 -0
  47. package/test/fixtures/transform/type-error/src/controller/abstract.mts +5 -0
  48. package/test/fixtures/transform/type-error/src/controller/index.mts +7 -0
  49. package/test/fixtures/transform/type-error/tsconfig.json +1 -0
  50. package/test/fixtures/transform/typed/expected.json +8 -0
  51. package/test/fixtures/transform/typed/src/controller/abstract.mts +5 -0
  52. package/test/fixtures/transform/typed/src/controller/index.mts +13 -0
  53. package/test/fixtures/transform/typed/tsconfig.json +1 -0
  54. package/test/fixtures/transform/visibility/expected.json +9 -0
  55. package/test/fixtures/transform/visibility/src/controller/abstract.mts +5 -0
  56. package/test/fixtures/transform/visibility/src/controller/index.mts +19 -0
  57. package/test/fixtures/transform/visibility/tsconfig.json +1 -0
  58. package/test/harness-connector-smoke.test.mjs +72 -0
  59. package/test/harness.test.mjs +375 -0
  60. package/test/openapi-to-connector.test.mts +25 -7
  61. package/test/scenarios.test.mts +15 -7
  62. package/test/transform.test.mjs +68 -0
  63. package/tools/mutation-run-entry.mjs +52 -0
  64. package/tools/mutation-run.mjs +120 -0
  65. package/tools/run-tests.mjs +64 -0
@@ -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,68 @@
1
+ // The transform reads a connector's controller source and returns the method declarations (`text`) and names
2
+ // (`methods`) that `cli build` writes to build/.controller.json. It replaced @ts-ast-parser/core (engines stop at
3
+ // Node 20), so most cases below are PARITY cases: expected.json was produced by the previous implementation (the
4
+ // ts-ast-parser one, SDK 3.8.68) on the same source, and the new one must print exactly the same. The same check
5
+ // ran on 58 real connector controllers (all of aloma-io/connector-*) with no difference.
6
+ // Needs `yarn build` first. Offline.
7
+ import assert from 'node:assert/strict';
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+ import {fileURLToPath} from 'node:url';
11
+ import {describe, it} from 'mocha';
12
+ import transform from '../build/transform/index.mjs';
13
+
14
+ const fixtures = path.join(path.dirname(fileURLToPath(import.meta.url)), 'fixtures', 'transform');
15
+
16
+ // the transform reads tsconfig.json from the working directory, as `cli build` runs it from the connector folder
17
+ const run = async (name) => {
18
+ const before = process.cwd();
19
+ process.chdir(path.join(fixtures, name));
20
+ try {
21
+ return await transform('./src/controller/index.mts');
22
+ } finally {
23
+ process.chdir(before);
24
+ }
25
+ };
26
+
27
+ const expected = (name) => JSON.parse(fs.readFileSync(path.join(fixtures, name, 'expected.json'), 'utf8'));
28
+
29
+ describe('transform (controller source to declarations)', function () {
30
+ this.timeout(60000);
31
+
32
+ for (const [name, what] of [
33
+ ['basic', 'JSDoc description, @param, @example, @namespace, defaults, return types'],
34
+ ['visibility', 'public, private, protected, static, underscore, quoted names, arrow property, getter, inherited'],
35
+ ['typed', 'typed object parameters, unions, arrays, tuples, optional'],
36
+ ['overloads', 'one declaration per overload signature'],
37
+ ['tdd-stub', 'the real connector-tdd-stub controller'],
38
+ ]) {
39
+ it(`prints exactly what the previous implementation printed: ${name} (${what})`, async () => {
40
+ assert.deepEqual(await run(name), expected(name));
41
+ });
42
+ }
43
+
44
+ it('lists the public methods the class declares or overrides, not inherited, private, static or underscore ones', async () => {
45
+ const {methods} = await run('visibility');
46
+ assert.deepEqual(methods, ['visible', 'quoted-name', 'arrow', 'inheritedPublic']);
47
+ });
48
+
49
+ it('lists the properties of a parameter typed with an interface (the previous implementation crashed)', async () => {
50
+ const {text, methods} = await run('interface-param');
51
+ assert.deepEqual(methods, ['viaInterface']);
52
+ assert.match(text, /declare function viaInterface\(\{id; n\}: \{/);
53
+ assert.match(text, /\n id: string; /);
54
+ assert.match(text, /\n n: number \| undefined\}\): \{ id: string; n: number \| undefined; \};/);
55
+ });
56
+
57
+ it('fails when the file exports more than the default class', async () => {
58
+ await assert.rejects(run('two-exports'), /connector file needs to export default class/);
59
+ });
60
+
61
+ it('fails with the TypeScript error text when the source does not compile (the previous one died on a circular JSON error)', async () => {
62
+ await assert.rejects(run('type-error'), (err) => {
63
+ assert.match(err.message, /has TypeScript errors/);
64
+ assert.match(err.message, /index\.mts\(5,5\).*TS2322.*'string' is not assignable to type 'number'/);
65
+ return true;
66
+ });
67
+ });
68
+ });
@@ -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);