@onlineapps/conn-orch-validator 3.0.1 → 3.1.2
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/package.json +4 -1
- package/src/CookbookTestRunner.js +266 -50
- package/src/cli/biz-ci-gate.js +221 -0
- package/src/index.js +2 -0
- package/src/utils/bizCiGateContract.js +430 -0
package/package.json
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@onlineapps/conn-orch-validator",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.1.2",
|
|
4
4
|
"description": "Validation orchestrator for OA Drive microservices - coordinates validation across all layers (base, infra, orch, business)",
|
|
5
5
|
"main": "src/index.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"oa-biz-ci-gate": "src/cli/biz-ci-gate.js"
|
|
8
|
+
},
|
|
6
9
|
"scripts": {
|
|
7
10
|
"test": "jest",
|
|
8
11
|
"test:unit": "jest tests/unit",
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
const fs = require('fs');
|
|
4
4
|
const path = require('path');
|
|
5
|
+
const crypto = require('crypto');
|
|
5
6
|
const axios = require('axios');
|
|
6
7
|
const MockMQClient = require('./mocks/MockMQClient');
|
|
7
8
|
const MockRegistry = require('./mocks/MockRegistry');
|
|
@@ -10,6 +11,24 @@ const { resolveHeaders } = require('./utils/resolveHeaders');
|
|
|
10
11
|
/**
|
|
11
12
|
* CookbookTestRunner - Executes cookbook tests offline with mocked infrastructure
|
|
12
13
|
*
|
|
14
|
+
* Dispatch strategy (D5, validator 3.1.0):
|
|
15
|
+
* - v3 operations.json — operation has a `handler` field ("path#exportName")
|
|
16
|
+
* → dispatch via `require()` + direct function call. HTTP is not involved.
|
|
17
|
+
* - legacy operations.json — operation has `endpoint` + `method`
|
|
18
|
+
* → dispatch via axios HTTP request against serviceUrl. Retained for
|
|
19
|
+
* backward-compat with any pre-v3 services that still expose /api/v1/*.
|
|
20
|
+
* - operation with neither `handler` nor `endpoint` → explicit error.
|
|
21
|
+
*
|
|
22
|
+
* The v3 handler dispatch builds a minimal real ctx:
|
|
23
|
+
* { logger, tenant_id, workspace_id, correlation_id, db: null, cache: null,
|
|
24
|
+
* httpClient: null, stream: null, abortSignal: null }
|
|
25
|
+
* — matching the shape ServiceWrapper.ContextBuilder produces in production
|
|
26
|
+
* (see api/docs/architecture/biz-service-invocation-model.md §5). Handlers
|
|
27
|
+
* that require DB access import sequelize directly from their own
|
|
28
|
+
* src/config/database.js module (see standards/biz-service-onboarding.md §9a);
|
|
29
|
+
* connector fields stay null and the handler surfaces its own failure if
|
|
30
|
+
* something it genuinely needs is missing.
|
|
31
|
+
*
|
|
13
32
|
* Supports unified cookbook format for both testing (with expect) and production (without expect)
|
|
14
33
|
*/
|
|
15
34
|
class CookbookTestRunner {
|
|
@@ -32,6 +51,10 @@ class CookbookTestRunner {
|
|
|
32
51
|
throw new Error('[CookbookTestRunner] timeout is required — Expected positive number (ms)');
|
|
33
52
|
}
|
|
34
53
|
this.logger = options.logger;
|
|
54
|
+
this._servicePathByName = new Map();
|
|
55
|
+
if (this.servicePath) {
|
|
56
|
+
this._servicePathByName.set(this.serviceName, this.servicePath);
|
|
57
|
+
}
|
|
35
58
|
|
|
36
59
|
// Initialize mocked infrastructure
|
|
37
60
|
if (this.mockInfrastructure) {
|
|
@@ -166,50 +189,37 @@ class CookbookTestRunner {
|
|
|
166
189
|
};
|
|
167
190
|
|
|
168
191
|
try {
|
|
169
|
-
// Resolve operation
|
|
170
|
-
|
|
192
|
+
// Resolve operation spec from operations.json — returns either
|
|
193
|
+
// { dispatchMode: 'handler', modulePath, exportName } (v3) or
|
|
194
|
+
// { dispatchMode: 'http', path, method, headers } (legacy).
|
|
195
|
+
const spec = await this.resolveOperation(step.service, step.operation);
|
|
171
196
|
|
|
172
197
|
const validationTenantId = process.env.OA_VALIDATION_TENANT_ID;
|
|
173
198
|
const validationWorkspaceId = process.env.OA_VALIDATION_WORKSPACE_ID;
|
|
174
199
|
if (!validationTenantId || !validationWorkspaceId) {
|
|
175
200
|
throw new Error('[CookbookTestRunner] Missing required environment variables OA_VALIDATION_TENANT_ID and/or OA_VALIDATION_WORKSPACE_ID');
|
|
176
201
|
}
|
|
177
|
-
const request = {
|
|
178
|
-
method: endpoint.method,
|
|
179
|
-
url: `${this.serviceUrl}${endpoint.path}`,
|
|
180
|
-
data: step.input,
|
|
181
|
-
timeout: testConfig.timeout || this.timeout,
|
|
182
|
-
headers: {
|
|
183
|
-
'x-validation-request': 'true',
|
|
184
|
-
'x-tenant-id': validationTenantId,
|
|
185
|
-
'x-workspace-id': validationWorkspaceId,
|
|
186
|
-
...resolveHeaders(endpoint.headers),
|
|
187
|
-
...resolveHeaders(step.headers)
|
|
188
|
-
}
|
|
189
|
-
};
|
|
190
|
-
|
|
191
|
-
result.request = request;
|
|
192
|
-
|
|
193
|
-
// Execute request
|
|
194
|
-
const response = await axios(request);
|
|
195
|
-
|
|
196
|
-
result.response = {
|
|
197
|
-
status: response.status,
|
|
198
|
-
statusText: response.statusText,
|
|
199
|
-
data: response.data
|
|
200
|
-
};
|
|
201
202
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
203
|
+
if (spec.dispatchMode === 'handler') {
|
|
204
|
+
await this._dispatchViaHandler({
|
|
205
|
+
step,
|
|
206
|
+
spec,
|
|
207
|
+
testConfig,
|
|
208
|
+
validationTenantId,
|
|
209
|
+
validationWorkspaceId,
|
|
210
|
+
result,
|
|
211
|
+
startTime
|
|
212
|
+
});
|
|
210
213
|
} else {
|
|
211
|
-
|
|
212
|
-
|
|
214
|
+
await this._dispatchViaHttp({
|
|
215
|
+
step,
|
|
216
|
+
spec,
|
|
217
|
+
testConfig,
|
|
218
|
+
validationTenantId,
|
|
219
|
+
validationWorkspaceId,
|
|
220
|
+
result,
|
|
221
|
+
startTime
|
|
222
|
+
});
|
|
213
223
|
}
|
|
214
224
|
|
|
215
225
|
this.logger.info(`Step ${step.id}: ${result.passed ? 'PASSED' : 'FAILED'} (${result.duration}ms)`);
|
|
@@ -225,6 +235,160 @@ class CookbookTestRunner {
|
|
|
225
235
|
return result;
|
|
226
236
|
}
|
|
227
237
|
|
|
238
|
+
/**
|
|
239
|
+
* v3 dispatch: require handler module, build minimal real ctx, call
|
|
240
|
+
* handler(input, ctx). Mirrors the production ContextBuilder shape
|
|
241
|
+
* (see api/docs/architecture/biz-service-invocation-model.md §5) with
|
|
242
|
+
* all connector slots set to null — handlers that need DB access use
|
|
243
|
+
* direct sequelize import per standards/biz-service-onboarding.md §9a.
|
|
244
|
+
*/
|
|
245
|
+
async _dispatchViaHandler({ step, spec, testConfig, validationTenantId, validationWorkspaceId, result, startTime }) {
|
|
246
|
+
const stepTimeout = testConfig.timeout || this.timeout;
|
|
247
|
+
const correlationId = step.correlation_id || crypto.randomUUID();
|
|
248
|
+
|
|
249
|
+
const ctx = {
|
|
250
|
+
logger: this.logger,
|
|
251
|
+
tenant_id: validationTenantId,
|
|
252
|
+
workspace_id: validationWorkspaceId,
|
|
253
|
+
correlation_id: correlationId,
|
|
254
|
+
operation_name: step.operation,
|
|
255
|
+
service_name: step.service,
|
|
256
|
+
db: null,
|
|
257
|
+
cache: null,
|
|
258
|
+
httpClient: null,
|
|
259
|
+
secrets: null,
|
|
260
|
+
stream: null,
|
|
261
|
+
abortSignal: null,
|
|
262
|
+
headers: {
|
|
263
|
+
'x-validation-request': 'true',
|
|
264
|
+
'x-tenant-id': validationTenantId,
|
|
265
|
+
'x-workspace-id': validationWorkspaceId,
|
|
266
|
+
...resolveHeaders(step.headers)
|
|
267
|
+
}
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
const request = {
|
|
271
|
+
mode: 'handler',
|
|
272
|
+
service: step.service,
|
|
273
|
+
operation: step.operation,
|
|
274
|
+
module: spec.modulePath,
|
|
275
|
+
export: spec.exportName,
|
|
276
|
+
input: step.input,
|
|
277
|
+
ctx: {
|
|
278
|
+
tenant_id: ctx.tenant_id,
|
|
279
|
+
workspace_id: ctx.workspace_id,
|
|
280
|
+
correlation_id: ctx.correlation_id
|
|
281
|
+
}
|
|
282
|
+
};
|
|
283
|
+
result.request = request;
|
|
284
|
+
|
|
285
|
+
let handlerFn;
|
|
286
|
+
try {
|
|
287
|
+
const mod = require(spec.modulePath);
|
|
288
|
+
handlerFn = mod[spec.exportName];
|
|
289
|
+
if (typeof handlerFn !== 'function') {
|
|
290
|
+
throw new Error(`[CookbookTestRunner] Handler ${spec.exportName} not exported from ${spec.modulePath}`);
|
|
291
|
+
}
|
|
292
|
+
} catch (error) {
|
|
293
|
+
throw new Error(`[CookbookTestRunner] Failed to load handler ${spec.exportName} from ${spec.modulePath}: ${error.message}`);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
let timeoutId;
|
|
297
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
298
|
+
timeoutId = setTimeout(() => {
|
|
299
|
+
reject(new Error(`[CookbookTestRunner] Handler ${step.operation} exceeded timeout ${stepTimeout}ms`));
|
|
300
|
+
}, stepTimeout);
|
|
301
|
+
if (timeoutId.unref) timeoutId.unref();
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
let handlerError = null;
|
|
305
|
+
let handlerOutput = null;
|
|
306
|
+
try {
|
|
307
|
+
handlerOutput = await Promise.race([
|
|
308
|
+
Promise.resolve().then(() => handlerFn(step.input || {}, ctx)),
|
|
309
|
+
timeoutPromise
|
|
310
|
+
]);
|
|
311
|
+
} catch (error) {
|
|
312
|
+
handlerError = error;
|
|
313
|
+
} finally {
|
|
314
|
+
clearTimeout(timeoutId);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
result.duration = Date.now() - startTime;
|
|
318
|
+
|
|
319
|
+
if (handlerError) {
|
|
320
|
+
// Synthesize an HTTP-like response so existing expect validators
|
|
321
|
+
// continue to work (expect.status: 'error', expect.error.code, ...).
|
|
322
|
+
const errorCode = handlerError.code || handlerError.name || 'HANDLER_ERROR';
|
|
323
|
+
const statusCode = typeof handlerError.statusCode === 'number'
|
|
324
|
+
? handlerError.statusCode
|
|
325
|
+
: 500;
|
|
326
|
+
result.response = {
|
|
327
|
+
status: statusCode,
|
|
328
|
+
statusText: errorCode,
|
|
329
|
+
data: { code: errorCode, message: handlerError.message }
|
|
330
|
+
};
|
|
331
|
+
result.actual = result.response.data;
|
|
332
|
+
result.error = { code: errorCode, message: handlerError.message };
|
|
333
|
+
} else {
|
|
334
|
+
result.response = {
|
|
335
|
+
status: 200,
|
|
336
|
+
statusText: 'OK',
|
|
337
|
+
data: handlerOutput
|
|
338
|
+
};
|
|
339
|
+
result.actual = handlerOutput;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
if (step.expect) {
|
|
343
|
+
const validation = this.validateExpectations(step.expect, result);
|
|
344
|
+
result.passed = validation.passed;
|
|
345
|
+
result.validationErrors = validation.errors;
|
|
346
|
+
} else {
|
|
347
|
+
result.passed = !handlerError;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Legacy dispatch: HTTP request against serviceUrl + endpoint. Retained
|
|
353
|
+
* verbatim (modulo wrapping) for compatibility with pre-v3 services that
|
|
354
|
+
* still expose /api/v1/* business routes.
|
|
355
|
+
*/
|
|
356
|
+
async _dispatchViaHttp({ step, spec, testConfig, validationTenantId, validationWorkspaceId, result, startTime }) {
|
|
357
|
+
const request = {
|
|
358
|
+
method: spec.method,
|
|
359
|
+
url: `${this.serviceUrl}${spec.path}`,
|
|
360
|
+
data: step.input,
|
|
361
|
+
timeout: testConfig.timeout || this.timeout,
|
|
362
|
+
headers: {
|
|
363
|
+
'x-validation-request': 'true',
|
|
364
|
+
'x-tenant-id': validationTenantId,
|
|
365
|
+
'x-workspace-id': validationWorkspaceId,
|
|
366
|
+
...resolveHeaders(spec.headers),
|
|
367
|
+
...resolveHeaders(step.headers)
|
|
368
|
+
}
|
|
369
|
+
};
|
|
370
|
+
result.request = request;
|
|
371
|
+
|
|
372
|
+
const response = await axios(request);
|
|
373
|
+
|
|
374
|
+
result.response = {
|
|
375
|
+
status: response.status,
|
|
376
|
+
statusText: response.statusText,
|
|
377
|
+
data: response.data
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
result.actual = response.data;
|
|
381
|
+
result.duration = Date.now() - startTime;
|
|
382
|
+
|
|
383
|
+
if (step.expect) {
|
|
384
|
+
const validation = this.validateExpectations(step.expect, result);
|
|
385
|
+
result.passed = validation.passed;
|
|
386
|
+
result.validationErrors = validation.errors;
|
|
387
|
+
} else {
|
|
388
|
+
result.passed = response.status >= 200 && response.status < 300;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
228
392
|
/**
|
|
229
393
|
* Validate expectations against actual results
|
|
230
394
|
*/
|
|
@@ -373,33 +537,85 @@ class CookbookTestRunner {
|
|
|
373
537
|
}
|
|
374
538
|
|
|
375
539
|
/**
|
|
376
|
-
* Resolve operation
|
|
540
|
+
* Resolve operation spec from operations.json. Returns a dispatch
|
|
541
|
+
* descriptor depending on which shape the operation declares:
|
|
542
|
+
*
|
|
543
|
+
* v3 (handler-based, post-2026-04):
|
|
544
|
+
* { dispatchMode: 'handler', modulePath, exportName }
|
|
545
|
+
*
|
|
546
|
+
* legacy (HTTP endpoint, pre-v3):
|
|
547
|
+
* { dispatchMode: 'http', path, method, headers }
|
|
548
|
+
*
|
|
549
|
+
* Throws if the operation declares neither.
|
|
377
550
|
*/
|
|
378
551
|
async resolveOperation(serviceName, operationName) {
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
const legacyPath = path.join(this.servicePath, 'conn-config', 'operations.json');
|
|
384
|
-
operationsPath = fs.existsSync(newPath) ? newPath : (fs.existsSync(legacyPath) ? legacyPath : null);
|
|
385
|
-
}
|
|
552
|
+
const serviceRoot = this._resolveServiceRoot(serviceName);
|
|
553
|
+
const newPath = path.join(serviceRoot, 'config', 'service', 'operations.json');
|
|
554
|
+
const legacyPath = path.join(serviceRoot, 'conn-config', 'operations.json');
|
|
555
|
+
const operationsPath = fs.existsSync(newPath) ? newPath : (fs.existsSync(legacyPath) ? legacyPath : null);
|
|
386
556
|
|
|
387
557
|
if (!operationsPath) {
|
|
388
558
|
throw new Error(`Operations file not found for service: ${serviceName} (checked config/service/ and conn-config/)`);
|
|
389
559
|
}
|
|
390
560
|
|
|
391
561
|
const operations = JSON.parse(fs.readFileSync(operationsPath, 'utf8'));
|
|
392
|
-
const operation = operations.operations[operationName];
|
|
562
|
+
const operation = operations.operations && operations.operations[operationName];
|
|
393
563
|
|
|
394
564
|
if (!operation) {
|
|
395
565
|
throw new Error(`Operation not found: ${operationName}`);
|
|
396
566
|
}
|
|
397
567
|
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
568
|
+
if (operation.handler) {
|
|
569
|
+
const [modRel, exportName] = String(operation.handler).split('#');
|
|
570
|
+
if (!modRel || !exportName) {
|
|
571
|
+
throw new Error(`[CookbookTestRunner] Invalid handler spec "${operation.handler}" for ${serviceName}.${operationName}; expected "path#exportName"`);
|
|
572
|
+
}
|
|
573
|
+
const modulePath = require.resolve(path.join(serviceRoot, 'src', modRel));
|
|
574
|
+
return {
|
|
575
|
+
dispatchMode: 'handler',
|
|
576
|
+
modulePath,
|
|
577
|
+
exportName,
|
|
578
|
+
headers: operation.headers || {}
|
|
579
|
+
};
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
if (operation.endpoint && operation.method) {
|
|
583
|
+
return {
|
|
584
|
+
dispatchMode: 'http',
|
|
585
|
+
path: operation.endpoint,
|
|
586
|
+
method: operation.method,
|
|
587
|
+
headers: operation.headers || {}
|
|
588
|
+
};
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
throw new Error(`[CookbookTestRunner] Operation ${serviceName}.${operationName} declares neither "handler" (v3) nor "endpoint"+"method" (legacy) in ${operationsPath}`);
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* Resolve filesystem root for a service. The default resolver assumes
|
|
596
|
+
* every cookbook step targets the runner's own service (this.servicePath).
|
|
597
|
+
* Callers that need cross-service dispatch can subclass and override.
|
|
598
|
+
*/
|
|
599
|
+
_resolveServiceRoot(serviceName) {
|
|
600
|
+
if (this._servicePathByName.has(serviceName)) {
|
|
601
|
+
return this._servicePathByName.get(serviceName);
|
|
602
|
+
}
|
|
603
|
+
if (!this.servicePath) {
|
|
604
|
+
throw new Error(`[CookbookTestRunner] No servicePath known for ${serviceName}; construct with servicePath or register via setServicePath(name, path)`);
|
|
605
|
+
}
|
|
606
|
+
// Cookbooks may reference this.serviceName under a different alias.
|
|
607
|
+
return this.servicePath;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* Register a servicePath for a service name. Only needed when cookbooks
|
|
612
|
+
* dispatch to a service other than the runner's primary service.
|
|
613
|
+
*/
|
|
614
|
+
setServicePath(serviceName, servicePath) {
|
|
615
|
+
if (!serviceName || !servicePath) {
|
|
616
|
+
throw new Error('[CookbookTestRunner] setServicePath requires both serviceName and servicePath');
|
|
617
|
+
}
|
|
618
|
+
this._servicePathByName.set(serviceName, servicePath);
|
|
403
619
|
}
|
|
404
620
|
|
|
405
621
|
/**
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const {
|
|
7
|
+
DEFAULT_CONTRACT_RELATIVE_PATH,
|
|
8
|
+
loadAndValidateIntegrationContract,
|
|
9
|
+
verifyIntegrationMinimum,
|
|
10
|
+
buildCiEnvironment,
|
|
11
|
+
formatEnvironmentOutput,
|
|
12
|
+
buildIntegrationSignalSummary,
|
|
13
|
+
waitRequiredConnectors,
|
|
14
|
+
runConnectorSetup,
|
|
15
|
+
writeJsonFile,
|
|
16
|
+
} = require('../utils/bizCiGateContract');
|
|
17
|
+
|
|
18
|
+
function parseArgs(argv) {
|
|
19
|
+
const parsed = {
|
|
20
|
+
command: null,
|
|
21
|
+
options: {},
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const args = [...argv];
|
|
25
|
+
parsed.command = args.shift() || null;
|
|
26
|
+
|
|
27
|
+
while (args.length > 0) {
|
|
28
|
+
const token = args.shift();
|
|
29
|
+
if (!token.startsWith('--')) {
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
const key = token.slice(2);
|
|
33
|
+
const next = args[0];
|
|
34
|
+
if (next && !next.startsWith('--')) {
|
|
35
|
+
parsed.options[key] = args.shift();
|
|
36
|
+
} else {
|
|
37
|
+
parsed.options[key] = 'true';
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return parsed;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function printHelp() {
|
|
45
|
+
process.stdout.write(`
|
|
46
|
+
Usage:
|
|
47
|
+
oa-biz-ci-gate <command> [--service-root <path>] [--contract <path>] [options]
|
|
48
|
+
|
|
49
|
+
Commands:
|
|
50
|
+
verify-contract Validate integration-contract.json schema and connector flags.
|
|
51
|
+
verify-integration-minimum Validate package scripts + fail on empty integration suite.
|
|
52
|
+
emit-ci-env Emit connector-aware CI environment exports.
|
|
53
|
+
wait-connectors Wait for required connector TCP endpoints.
|
|
54
|
+
run-setup Run optional connector setup commands from contract.
|
|
55
|
+
write-summary Write normalized integration signal artifact JSON.
|
|
56
|
+
|
|
57
|
+
Common options:
|
|
58
|
+
--service-root <path> Service repository root. Default: current working directory.
|
|
59
|
+
--contract <path> Contract path (absolute or relative to service root).
|
|
60
|
+
Default: ${DEFAULT_CONTRACT_RELATIVE_PATH}
|
|
61
|
+
|
|
62
|
+
emit-ci-env options:
|
|
63
|
+
--format <shell|dotenv|json> Output format. Default: shell
|
|
64
|
+
--output <path> Optional output file path
|
|
65
|
+
|
|
66
|
+
wait-connectors options:
|
|
67
|
+
--attempts <number> Maximum connection attempts per connector (default: 60)
|
|
68
|
+
--interval-ms <number> Delay between attempts in milliseconds (default: 1000)
|
|
69
|
+
|
|
70
|
+
write-summary options:
|
|
71
|
+
--output <path> Summary artifact path. Default: <service-root>/ci/integration-signal.json
|
|
72
|
+
--gate-verdict <pass|fail> Override gate verdict
|
|
73
|
+
--gate-reason <text> Override gate reason
|
|
74
|
+
--unit-executed <number> Executed unit suite count
|
|
75
|
+
--integration-executed <number> Executed integration suite count
|
|
76
|
+
`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function boolFromString(value) {
|
|
80
|
+
return value === true || value === 'true' || value === '1';
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function resolveContractOptions(options) {
|
|
84
|
+
return {
|
|
85
|
+
serviceRoot: options['service-root'] || process.cwd(),
|
|
86
|
+
contractPath: options.contract || undefined,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function runVerifyContract(options) {
|
|
91
|
+
const contractInfo = loadAndValidateIntegrationContract(options.serviceRoot, options.contractPath);
|
|
92
|
+
process.stdout.write(`[BizCiGate] OK verify-contract\n`);
|
|
93
|
+
process.stdout.write(`${JSON.stringify({
|
|
94
|
+
serviceRoot: contractInfo.serviceRoot,
|
|
95
|
+
contractPath: contractInfo.contractPath,
|
|
96
|
+
serviceName: contractInfo.contract.serviceName,
|
|
97
|
+
requiredConnectors: contractInfo.contract.requiredConnectors,
|
|
98
|
+
integrationMinimum: contractInfo.contract.integrationMinimum,
|
|
99
|
+
}, null, 2)}\n`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function runVerifyIntegrationMinimum(options) {
|
|
103
|
+
const result = verifyIntegrationMinimum(options.serviceRoot, options.contractPath);
|
|
104
|
+
process.stdout.write(`[BizCiGate] OK verify-integration-minimum\n`);
|
|
105
|
+
process.stdout.write(`${JSON.stringify({
|
|
106
|
+
serviceRoot: result.serviceRoot,
|
|
107
|
+
contractPath: result.contractPath,
|
|
108
|
+
discoveredIntegrationTests: result.discoveredCount,
|
|
109
|
+
requiredIntegrationTests: result.requiredCount,
|
|
110
|
+
packageJsonPath: result.packageScripts.packageJsonPath,
|
|
111
|
+
}, null, 2)}\n`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function runEmitCiEnv(options) {
|
|
115
|
+
const contractInfo = loadAndValidateIntegrationContract(options.serviceRoot, options.contractPath);
|
|
116
|
+
const env = buildCiEnvironment(contractInfo);
|
|
117
|
+
const format = options.format || 'shell';
|
|
118
|
+
const output = formatEnvironmentOutput(env, format);
|
|
119
|
+
const outputPath = options.output;
|
|
120
|
+
|
|
121
|
+
if (outputPath) {
|
|
122
|
+
const resolvedOutputPath = path.resolve(options.serviceRoot, outputPath);
|
|
123
|
+
fs.mkdirSync(path.dirname(resolvedOutputPath), { recursive: true });
|
|
124
|
+
if (format === 'json') {
|
|
125
|
+
fs.writeFileSync(resolvedOutputPath, JSON.stringify(env, null, 2));
|
|
126
|
+
} else {
|
|
127
|
+
fs.writeFileSync(resolvedOutputPath, output);
|
|
128
|
+
}
|
|
129
|
+
process.stdout.write(`[BizCiGate] Wrote CI env output to ${resolvedOutputPath}\n`);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
process.stdout.write(output);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function runWaitConnectors(options) {
|
|
137
|
+
const result = await waitRequiredConnectors(options.serviceRoot, options.contractPath, {
|
|
138
|
+
attempts: options.attempts,
|
|
139
|
+
intervalMs: options['interval-ms'],
|
|
140
|
+
});
|
|
141
|
+
process.stdout.write(`[BizCiGate] OK wait-connectors\n`);
|
|
142
|
+
process.stdout.write(`${JSON.stringify({
|
|
143
|
+
serviceRoot: result.serviceRoot,
|
|
144
|
+
connectorsChecked: result.checks,
|
|
145
|
+
waitPolicy: result.waitPolicy,
|
|
146
|
+
}, null, 2)}\n`);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function runSetup(options) {
|
|
150
|
+
const result = runConnectorSetup(options.serviceRoot, options.contractPath);
|
|
151
|
+
process.stdout.write(`[BizCiGate] OK run-setup\n`);
|
|
152
|
+
process.stdout.write(`${JSON.stringify({
|
|
153
|
+
serviceRoot: result.serviceRoot,
|
|
154
|
+
executed: result.executed,
|
|
155
|
+
}, null, 2)}\n`);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function runWriteSummary(options) {
|
|
159
|
+
const summary = buildIntegrationSignalSummary({
|
|
160
|
+
serviceRoot: options.serviceRoot,
|
|
161
|
+
contractPath: options.contractPath,
|
|
162
|
+
gateVerdict: options['gate-verdict'],
|
|
163
|
+
gateReason: options['gate-reason'],
|
|
164
|
+
unitExecuted: options['unit-executed'],
|
|
165
|
+
integrationExecuted: options['integration-executed'],
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
const outputPath = options.output
|
|
169
|
+
? path.resolve(options.serviceRoot, options.output)
|
|
170
|
+
: path.join(path.resolve(options.serviceRoot), 'ci', 'integration-signal.json');
|
|
171
|
+
|
|
172
|
+
const writtenPath = writeJsonFile(outputPath, summary);
|
|
173
|
+
process.stdout.write(`[BizCiGate] Wrote integration summary to ${writtenPath}\n`);
|
|
174
|
+
process.stdout.write(`${JSON.stringify(summary, null, 2)}\n`);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function main() {
|
|
178
|
+
const parsed = parseArgs(process.argv.slice(2));
|
|
179
|
+
if (!parsed.command || boolFromString(parsed.options.help)) {
|
|
180
|
+
printHelp();
|
|
181
|
+
process.exit(parsed.command ? 0 : 1);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const command = parsed.command;
|
|
185
|
+
const baseOptions = resolveContractOptions(parsed.options);
|
|
186
|
+
const options = { ...baseOptions, ...parsed.options };
|
|
187
|
+
|
|
188
|
+
try {
|
|
189
|
+
if (command === 'verify-contract') {
|
|
190
|
+
runVerifyContract(options);
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
if (command === 'verify-integration-minimum') {
|
|
194
|
+
runVerifyIntegrationMinimum(options);
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
if (command === 'emit-ci-env') {
|
|
198
|
+
runEmitCiEnv(options);
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
if (command === 'wait-connectors') {
|
|
202
|
+
await runWaitConnectors(options);
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
if (command === 'run-setup') {
|
|
206
|
+
runSetup(options);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
if (command === 'write-summary') {
|
|
210
|
+
runWriteSummary(options);
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
throw new Error(`[BizCiGate] Unknown command - ${command}`);
|
|
215
|
+
} catch (error) {
|
|
216
|
+
process.stderr.write(`${error.message}\n`);
|
|
217
|
+
process.exit(1);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
main();
|
package/src/index.js
CHANGED
|
@@ -27,6 +27,7 @@ try {
|
|
|
27
27
|
console.error('[conn-orch-validator] WARNING: Failed to load CookbookTestRunner:', error.message);
|
|
28
28
|
}
|
|
29
29
|
const WorkflowTestRunner = require('./WorkflowTestRunner');
|
|
30
|
+
const BizCiGateContract = require('./utils/bizCiGateContract');
|
|
30
31
|
|
|
31
32
|
const ServiceReadinessValidator = require('./ServiceReadinessValidator');
|
|
32
33
|
const ValidationOrchestrator = require('./ValidationOrchestrator');
|
|
@@ -49,6 +50,7 @@ module.exports = {
|
|
|
49
50
|
|
|
50
51
|
get createServiceReadinessTests() { return createServiceReadinessTests; },
|
|
51
52
|
get createPreValidationTests() { return createPreValidationTests; },
|
|
53
|
+
get BizCiGateContract() { return BizCiGateContract; },
|
|
52
54
|
|
|
53
55
|
createMockMQ: () => new MockMQClient(),
|
|
54
56
|
createMockRegistry: () => new MockRegistry(),
|
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const net = require('net');
|
|
6
|
+
const { execSync } = require('child_process');
|
|
7
|
+
|
|
8
|
+
const CONNECTOR_KEYS = ['db', 'redis', 'mq', 'minio'];
|
|
9
|
+
const DEFAULT_CONTRACT_RELATIVE_PATH = path.join('config', 'service', 'integration-contract.json');
|
|
10
|
+
|
|
11
|
+
function readJsonFile(filePath) {
|
|
12
|
+
if (!fs.existsSync(filePath)) {
|
|
13
|
+
throw new Error(`[BizCiGate] Missing file - ${filePath}`);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
try {
|
|
17
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
18
|
+
} catch (error) {
|
|
19
|
+
throw new Error(`[BizCiGate] Invalid JSON - ${filePath}: ${error.message}`);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function resolveServiceRoot(serviceRoot) {
|
|
24
|
+
return path.resolve(serviceRoot || process.cwd());
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function resolveContractPath(serviceRoot, contractPath) {
|
|
28
|
+
if (!contractPath) {
|
|
29
|
+
return path.join(serviceRoot, DEFAULT_CONTRACT_RELATIVE_PATH);
|
|
30
|
+
}
|
|
31
|
+
return path.isAbsolute(contractPath) ? contractPath : path.join(serviceRoot, contractPath);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function assertBoolean(value, fieldName) {
|
|
35
|
+
if (typeof value !== 'boolean') {
|
|
36
|
+
throw new Error(`[BizCiGate] Invalid contract field - ${fieldName} must be boolean`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function normalizeIntegrationContract(rawContract, contractPath) {
|
|
41
|
+
if (!rawContract || typeof rawContract !== 'object' || Array.isArray(rawContract)) {
|
|
42
|
+
throw new Error(`[BizCiGate] Invalid contract root - Expected object in ${contractPath}`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const requiredConnectors = rawContract.requiredConnectors;
|
|
46
|
+
if (!requiredConnectors || typeof requiredConnectors !== 'object' || Array.isArray(requiredConnectors)) {
|
|
47
|
+
throw new Error('[BizCiGate] Missing requiredConnectors - Expected object with db/redis/mq/minio booleans');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const normalizedConnectors = {};
|
|
51
|
+
for (const key of CONNECTOR_KEYS) {
|
|
52
|
+
const value = requiredConnectors[key];
|
|
53
|
+
assertBoolean(value, `requiredConnectors.${key}`);
|
|
54
|
+
normalizedConnectors[key] = value;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const integrationMinimum = rawContract.integrationMinimum;
|
|
58
|
+
if (!integrationMinimum || typeof integrationMinimum !== 'object' || Array.isArray(integrationMinimum)) {
|
|
59
|
+
throw new Error('[BizCiGate] Missing integrationMinimum - Expected object with minTestFiles');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const minTestFiles = integrationMinimum.minTestFiles;
|
|
63
|
+
if (!Number.isInteger(minTestFiles) || minTestFiles < 1) {
|
|
64
|
+
throw new Error('[BizCiGate] Invalid integrationMinimum.minTestFiles - Expected integer >= 1');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
serviceName: rawContract.serviceName || null,
|
|
69
|
+
requiredConnectors: normalizedConnectors,
|
|
70
|
+
integrationMinimum: {
|
|
71
|
+
minTestFiles,
|
|
72
|
+
},
|
|
73
|
+
setup: rawContract.setup || {},
|
|
74
|
+
raw: rawContract,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function loadAndValidateIntegrationContract(serviceRoot, contractPath) {
|
|
79
|
+
const resolvedServiceRoot = resolveServiceRoot(serviceRoot);
|
|
80
|
+
const resolvedContractPath = resolveContractPath(resolvedServiceRoot, contractPath);
|
|
81
|
+
const rawContract = readJsonFile(resolvedContractPath);
|
|
82
|
+
const normalizedContract = normalizeIntegrationContract(rawContract, resolvedContractPath);
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
serviceRoot: resolvedServiceRoot,
|
|
86
|
+
contractPath: resolvedContractPath,
|
|
87
|
+
contract: normalizedContract,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function listFilesRecursive(rootDir) {
|
|
92
|
+
if (!fs.existsSync(rootDir)) {
|
|
93
|
+
return [];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const stack = [rootDir];
|
|
97
|
+
const files = [];
|
|
98
|
+
|
|
99
|
+
while (stack.length > 0) {
|
|
100
|
+
const current = stack.pop();
|
|
101
|
+
const entries = fs.readdirSync(current, { withFileTypes: true });
|
|
102
|
+
for (const entry of entries) {
|
|
103
|
+
const entryPath = path.join(current, entry.name);
|
|
104
|
+
if (entry.isDirectory()) {
|
|
105
|
+
stack.push(entryPath);
|
|
106
|
+
} else if (entry.isFile()) {
|
|
107
|
+
files.push(entryPath);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return files;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function isTestFile(filePath) {
|
|
116
|
+
return /\.(test|spec)\.(js|cjs|mjs|ts|tsx)$/.test(filePath);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function getIntegrationTestFiles(serviceRoot) {
|
|
120
|
+
const candidateDirs = [
|
|
121
|
+
path.join(serviceRoot, 'tests', 'integration'),
|
|
122
|
+
path.join(serviceRoot, 'tests', 'db', 'integration'),
|
|
123
|
+
];
|
|
124
|
+
|
|
125
|
+
const allFiles = [];
|
|
126
|
+
for (const dir of candidateDirs) {
|
|
127
|
+
allFiles.push(...listFilesRecursive(dir).filter(isTestFile));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return Array.from(new Set(allFiles));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function getUnitTestFiles(serviceRoot) {
|
|
134
|
+
const unitDir = path.join(serviceRoot, 'tests', 'unit');
|
|
135
|
+
return listFilesRecursive(unitDir).filter(isTestFile);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function validatePackageScriptsForIntegrationMinimum(serviceRoot) {
|
|
139
|
+
const packageJsonPath = path.join(serviceRoot, 'package.json');
|
|
140
|
+
const packageJson = readJsonFile(packageJsonPath);
|
|
141
|
+
const scripts = packageJson.scripts || {};
|
|
142
|
+
|
|
143
|
+
const testIntegration = scripts['test:integration'] || '';
|
|
144
|
+
if (!testIntegration) {
|
|
145
|
+
throw new Error('[BizCiGate] Missing package script - test:integration is required');
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (testIntegration.includes('--passWithNoTests')) {
|
|
149
|
+
throw new Error('[BizCiGate] Forbidden --passWithNoTests in test:integration');
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const testAll = scripts['test:all'] || '';
|
|
153
|
+
if (!testAll) {
|
|
154
|
+
throw new Error('[BizCiGate] Missing package script - test:all is required');
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (!/test:integration/.test(testAll)) {
|
|
158
|
+
throw new Error('[BizCiGate] Invalid test:all - must include test:integration');
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
packageJsonPath,
|
|
163
|
+
testIntegration,
|
|
164
|
+
testAll,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function verifyIntegrationMinimum(serviceRoot, contractPath) {
|
|
169
|
+
const contractInfo = loadAndValidateIntegrationContract(serviceRoot, contractPath);
|
|
170
|
+
const packageScripts = validatePackageScriptsForIntegrationMinimum(contractInfo.serviceRoot);
|
|
171
|
+
const integrationTestFiles = getIntegrationTestFiles(contractInfo.serviceRoot);
|
|
172
|
+
const discoveredCount = integrationTestFiles.length;
|
|
173
|
+
const requiredCount = contractInfo.contract.integrationMinimum.minTestFiles;
|
|
174
|
+
|
|
175
|
+
if (discoveredCount < requiredCount) {
|
|
176
|
+
throw new Error(`[BizCiGate] Integration minimum not met - Found ${discoveredCount}, required ${requiredCount}`);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
...contractInfo,
|
|
181
|
+
packageScripts,
|
|
182
|
+
integrationTestFiles,
|
|
183
|
+
discoveredCount,
|
|
184
|
+
requiredCount,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function buildCiEnvironment(contractInfo) {
|
|
189
|
+
const { requiredConnectors } = contractInfo.contract;
|
|
190
|
+
return {
|
|
191
|
+
OA_CI_CONNECTOR_DB_REQUIRED: requiredConnectors.db ? '1' : '0',
|
|
192
|
+
OA_CI_CONNECTOR_REDIS_REQUIRED: requiredConnectors.redis ? '1' : '0',
|
|
193
|
+
OA_CI_CONNECTOR_MQ_REQUIRED: requiredConnectors.mq ? '1' : '0',
|
|
194
|
+
OA_CI_CONNECTOR_MINIO_REQUIRED: requiredConnectors.minio ? '1' : '0',
|
|
195
|
+
OA_CI_INTEGRATION_MIN_TEST_FILES: String(contractInfo.contract.integrationMinimum.minTestFiles),
|
|
196
|
+
OA_CI_CONNECTORS_JSON: JSON.stringify(requiredConnectors),
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function formatEnvironmentOutput(env, format) {
|
|
201
|
+
const keys = Object.keys(env).sort();
|
|
202
|
+
if (format === 'json') {
|
|
203
|
+
return `${JSON.stringify(env, null, 2)}\n`;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (format === 'dotenv') {
|
|
207
|
+
return `${keys.map((key) => `${key}=${env[key]}`).join('\n')}\n`;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (format === 'shell') {
|
|
211
|
+
return `${keys.map((key) => `export ${key}=${JSON.stringify(env[key])}`).join('\n')}\n`;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
throw new Error(`[BizCiGate] Unsupported output format - ${format}`);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function parseIntegerOrNull(value) {
|
|
218
|
+
if (value === undefined || value === null || value === '') {
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
221
|
+
const parsed = Number.parseInt(String(value), 10);
|
|
222
|
+
if (!Number.isFinite(parsed)) {
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
225
|
+
return parsed;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function buildIntegrationSignalSummary(options) {
|
|
229
|
+
const contractInfo = loadAndValidateIntegrationContract(options.serviceRoot, options.contractPath);
|
|
230
|
+
const unitTestFiles = getUnitTestFiles(contractInfo.serviceRoot);
|
|
231
|
+
const integrationTestFiles = getIntegrationTestFiles(contractInfo.serviceRoot);
|
|
232
|
+
|
|
233
|
+
let gateVerdict = options.gateVerdict || 'pass';
|
|
234
|
+
let gateReason = options.gateReason || 'integration minimum satisfied';
|
|
235
|
+
try {
|
|
236
|
+
verifyIntegrationMinimum(contractInfo.serviceRoot, contractInfo.contractPath);
|
|
237
|
+
} catch (error) {
|
|
238
|
+
gateVerdict = options.gateVerdict || 'fail';
|
|
239
|
+
gateReason = options.gateReason || error.message;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const unitExecuted = parseIntegerOrNull(options.unitExecuted || process.env.OA_CI_UNIT_EXECUTED);
|
|
243
|
+
const integrationExecuted = parseIntegerOrNull(options.integrationExecuted || process.env.OA_CI_INTEGRATION_EXECUTED);
|
|
244
|
+
const resolvedUnitExecuted = unitExecuted === null ? unitTestFiles.length : unitExecuted;
|
|
245
|
+
const resolvedIntegrationExecuted = integrationExecuted === null ? integrationTestFiles.length : integrationExecuted;
|
|
246
|
+
|
|
247
|
+
return {
|
|
248
|
+
generatedAt: new Date().toISOString(),
|
|
249
|
+
serviceName: contractInfo.contract.serviceName,
|
|
250
|
+
serviceRoot: contractInfo.serviceRoot,
|
|
251
|
+
contractPath: contractInfo.contractPath,
|
|
252
|
+
connectors: contractInfo.contract.requiredConnectors,
|
|
253
|
+
integrationMinimum: contractInfo.contract.integrationMinimum,
|
|
254
|
+
discovered: {
|
|
255
|
+
unitTestFiles: unitTestFiles.length,
|
|
256
|
+
integrationTestFiles: integrationTestFiles.length,
|
|
257
|
+
},
|
|
258
|
+
executed: {
|
|
259
|
+
unitSuites: resolvedUnitExecuted,
|
|
260
|
+
integrationSuites: resolvedIntegrationExecuted,
|
|
261
|
+
},
|
|
262
|
+
gate: {
|
|
263
|
+
verdict: gateVerdict,
|
|
264
|
+
reason: gateReason,
|
|
265
|
+
},
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function writeJsonFile(filePath, data) {
|
|
270
|
+
const resolvedFilePath = path.resolve(filePath);
|
|
271
|
+
const targetDir = path.dirname(resolvedFilePath);
|
|
272
|
+
fs.mkdirSync(targetDir, { recursive: true });
|
|
273
|
+
fs.writeFileSync(resolvedFilePath, JSON.stringify(data, null, 2));
|
|
274
|
+
return resolvedFilePath;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function waitForTcpService({ name, host, port, attempts, intervalMs }) {
|
|
278
|
+
return new Promise((resolve, reject) => {
|
|
279
|
+
let currentAttempt = 0;
|
|
280
|
+
|
|
281
|
+
function tryConnect() {
|
|
282
|
+
currentAttempt += 1;
|
|
283
|
+
const socket = net.createConnection({ host, port });
|
|
284
|
+
socket.setTimeout(intervalMs);
|
|
285
|
+
|
|
286
|
+
socket.on('connect', () => {
|
|
287
|
+
socket.destroy();
|
|
288
|
+
resolve({ name, host, port, attemptsUsed: currentAttempt });
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
socket.on('timeout', () => {
|
|
292
|
+
socket.destroy();
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
socket.on('error', () => {
|
|
296
|
+
socket.destroy();
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
socket.on('close', () => {
|
|
300
|
+
if (currentAttempt >= attempts) {
|
|
301
|
+
reject(new Error(`[BizCiGate] Connector not reachable - ${name} (${host}:${port}) after ${attempts} attempts`));
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
setTimeout(tryConnect, intervalMs);
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
tryConnect();
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function parseHostPortFromUrl(rawUrl, defaultPort, connectorName) {
|
|
313
|
+
try {
|
|
314
|
+
const parsed = new URL(rawUrl);
|
|
315
|
+
return {
|
|
316
|
+
host: parsed.hostname,
|
|
317
|
+
port: Number.parseInt(parsed.port || String(defaultPort), 10),
|
|
318
|
+
};
|
|
319
|
+
} catch (error) {
|
|
320
|
+
throw new Error(`[BizCiGate] Invalid URL for ${connectorName} - ${rawUrl}`);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
async function waitRequiredConnectors(serviceRoot, contractPath, options = {}) {
|
|
325
|
+
const contractInfo = loadAndValidateIntegrationContract(serviceRoot, contractPath);
|
|
326
|
+
const { requiredConnectors } = contractInfo.contract;
|
|
327
|
+
|
|
328
|
+
const attempts = Number.parseInt(String(options.attempts || process.env.OA_CI_WAIT_ATTEMPTS || 60), 10);
|
|
329
|
+
const intervalMs = Number.parseInt(String(options.intervalMs || process.env.OA_CI_WAIT_INTERVAL_MS || 1000), 10);
|
|
330
|
+
|
|
331
|
+
const checks = [];
|
|
332
|
+
|
|
333
|
+
if (requiredConnectors.db) {
|
|
334
|
+
const host = process.env.DB_HOST;
|
|
335
|
+
const port = Number.parseInt(String(process.env.DB_PORT || '3306'), 10);
|
|
336
|
+
if (!host) {
|
|
337
|
+
throw new Error('[BizCiGate] Missing env for required connector db - DB_HOST');
|
|
338
|
+
}
|
|
339
|
+
checks.push(waitForTcpService({ name: 'db', host, port, attempts, intervalMs }));
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
if (requiredConnectors.redis) {
|
|
343
|
+
const redisUrl = process.env.REDIS_URL;
|
|
344
|
+
if (!redisUrl) {
|
|
345
|
+
throw new Error('[BizCiGate] Missing env for required connector redis - REDIS_URL');
|
|
346
|
+
}
|
|
347
|
+
const { host, port } = parseHostPortFromUrl(redisUrl, 6379, 'redis');
|
|
348
|
+
checks.push(waitForTcpService({ name: 'redis', host, port, attempts, intervalMs }));
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
if (requiredConnectors.mq) {
|
|
352
|
+
const mqUrl = process.env.RABBITMQ_URL;
|
|
353
|
+
if (!mqUrl) {
|
|
354
|
+
throw new Error('[BizCiGate] Missing env for required connector mq - RABBITMQ_URL');
|
|
355
|
+
}
|
|
356
|
+
const { host, port } = parseHostPortFromUrl(mqUrl, 5672, 'mq');
|
|
357
|
+
checks.push(waitForTcpService({ name: 'mq', host, port, attempts, intervalMs }));
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
if (requiredConnectors.minio) {
|
|
361
|
+
const host = process.env.MINIO_ACTUAL_HOST || process.env.MINIO_ENDPOINT || process.env.MINIO_HOST;
|
|
362
|
+
const port = Number.parseInt(String(process.env.MINIO_PORT || '9000'), 10);
|
|
363
|
+
if (!host) {
|
|
364
|
+
throw new Error('[BizCiGate] Missing env for required connector minio - MINIO_ENDPOINT or MINIO_ACTUAL_HOST');
|
|
365
|
+
}
|
|
366
|
+
checks.push(waitForTcpService({ name: 'minio', host, port, attempts, intervalMs }));
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const results = await Promise.all(checks);
|
|
370
|
+
return {
|
|
371
|
+
...contractInfo,
|
|
372
|
+
waitPolicy: { attempts, intervalMs },
|
|
373
|
+
checks: results,
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function runConnectorSetup(serviceRoot, contractPath) {
|
|
378
|
+
const contractInfo = loadAndValidateIntegrationContract(serviceRoot, contractPath);
|
|
379
|
+
const { requiredConnectors } = contractInfo.contract;
|
|
380
|
+
const setup = contractInfo.contract.setup || {};
|
|
381
|
+
const executed = [];
|
|
382
|
+
|
|
383
|
+
const setupMap = [
|
|
384
|
+
{ key: 'db', commandField: 'dbSetupCommand' },
|
|
385
|
+
{ key: 'redis', commandField: 'redisSetupCommand' },
|
|
386
|
+
{ key: 'mq', commandField: 'mqSetupCommand' },
|
|
387
|
+
{ key: 'minio', commandField: 'minioSetupCommand' },
|
|
388
|
+
];
|
|
389
|
+
|
|
390
|
+
for (const entry of setupMap) {
|
|
391
|
+
if (!requiredConnectors[entry.key]) {
|
|
392
|
+
continue;
|
|
393
|
+
}
|
|
394
|
+
const command = setup[entry.commandField];
|
|
395
|
+
if (!command) {
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
execSync(command, {
|
|
399
|
+
cwd: contractInfo.serviceRoot,
|
|
400
|
+
stdio: 'inherit',
|
|
401
|
+
env: process.env,
|
|
402
|
+
});
|
|
403
|
+
executed.push({
|
|
404
|
+
connector: entry.key,
|
|
405
|
+
commandField: entry.commandField,
|
|
406
|
+
command,
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
return {
|
|
411
|
+
...contractInfo,
|
|
412
|
+
executed,
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
module.exports = {
|
|
417
|
+
CONNECTOR_KEYS,
|
|
418
|
+
DEFAULT_CONTRACT_RELATIVE_PATH,
|
|
419
|
+
loadAndValidateIntegrationContract,
|
|
420
|
+
normalizeIntegrationContract,
|
|
421
|
+
getIntegrationTestFiles,
|
|
422
|
+
getUnitTestFiles,
|
|
423
|
+
verifyIntegrationMinimum,
|
|
424
|
+
buildCiEnvironment,
|
|
425
|
+
formatEnvironmentOutput,
|
|
426
|
+
buildIntegrationSignalSummary,
|
|
427
|
+
waitRequiredConnectors,
|
|
428
|
+
runConnectorSetup,
|
|
429
|
+
writeJsonFile,
|
|
430
|
+
};
|