@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.
- package/README.md +60 -0
- package/build/testing/fake-aloma.d.mts +87 -0
- package/build/testing/fake-aloma.mjs +248 -0
- package/build/testing/harness.d.mts +51 -0
- package/build/testing/harness.mjs +139 -0
- package/build/testing/index.d.mts +15 -0
- package/build/testing/index.mjs +13 -0
- package/build/testing/keys.d.mts +9 -0
- package/build/testing/keys.mjs +15 -0
- package/build/testing/network-guard.d.mts +3 -0
- package/build/testing/network-guard.mjs +49 -0
- package/build/testing/runtime-connector.d.mts +11 -0
- package/build/testing/runtime-connector.mjs +38 -0
- package/build/testing/vendor-mock.d.mts +59 -0
- package/build/testing/vendor-mock.mjs +121 -0
- package/build/transform/index.d.mts +2 -2
- package/build/transform/index.mjs +493 -78
- package/package.json +8 -4
- package/src/testing/fake-aloma.mts +306 -0
- package/src/testing/harness.mts +172 -0
- package/src/testing/index.mts +15 -0
- package/src/testing/keys.mts +19 -0
- package/src/testing/network-guard.mts +57 -0
- package/src/testing/runtime-connector.mts +50 -0
- package/src/testing/vendor-mock.mts +166 -0
- package/src/transform/index.mts +530 -96
- package/test/MUTATION-entry.md +21 -0
- package/test/MUTATION.md +43 -0
- package/test/fixtures/transform/basic/expected.json +9 -0
- package/test/fixtures/transform/basic/src/controller/abstract.mts +5 -0
- package/test/fixtures/transform/basic/src/controller/index.mts +30 -0
- package/test/fixtures/transform/basic/tsconfig.json +1 -0
- package/test/fixtures/transform/interface-param/src/controller/abstract.mts +5 -0
- package/test/fixtures/transform/interface-param/src/controller/index.mts +12 -0
- package/test/fixtures/transform/interface-param/tsconfig.json +1 -0
- package/test/fixtures/transform/overloads/expected.json +6 -0
- package/test/fixtures/transform/overloads/src/controller/abstract.mts +5 -0
- package/test/fixtures/transform/overloads/src/controller/index.mts +11 -0
- package/test/fixtures/transform/overloads/tsconfig.json +1 -0
- package/test/fixtures/transform/tdd-stub/expected.json +8 -0
- package/test/fixtures/transform/tdd-stub/src/controller/abstract.mts +6 -0
- package/test/fixtures/transform/tdd-stub/src/controller/index.mts +204 -0
- package/test/fixtures/transform/tdd-stub/tsconfig.json +1 -0
- package/test/fixtures/transform/two-exports/src/controller/abstract.mts +5 -0
- package/test/fixtures/transform/two-exports/src/controller/index.mts +9 -0
- package/test/fixtures/transform/two-exports/tsconfig.json +1 -0
- package/test/fixtures/transform/type-error/src/controller/abstract.mts +5 -0
- package/test/fixtures/transform/type-error/src/controller/index.mts +7 -0
- package/test/fixtures/transform/type-error/tsconfig.json +1 -0
- package/test/fixtures/transform/typed/expected.json +8 -0
- package/test/fixtures/transform/typed/src/controller/abstract.mts +5 -0
- package/test/fixtures/transform/typed/src/controller/index.mts +13 -0
- package/test/fixtures/transform/typed/tsconfig.json +1 -0
- package/test/fixtures/transform/visibility/expected.json +9 -0
- package/test/fixtures/transform/visibility/src/controller/abstract.mts +5 -0
- package/test/fixtures/transform/visibility/src/controller/index.mts +19 -0
- package/test/fixtures/transform/visibility/tsconfig.json +1 -0
- package/test/harness-connector-smoke.test.mjs +72 -0
- package/test/harness.test.mjs +375 -0
- package/test/openapi-to-connector.test.mts +25 -7
- package/test/scenarios.test.mts +15 -7
- package/test/transform.test.mjs +68 -0
- package/tools/mutation-run-entry.mjs +52 -0
- package/tools/mutation-run.mjs +120 -0
- 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
|
-
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
167
|
-
|
|
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
|
-
|
|
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', () => {
|
package/test/scenarios.test.mts
CHANGED
|
@@ -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
|
-
|
|
48
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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);
|