@onlineapps/conn-orch-validator 3.1.1 → 3.2.0

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 CHANGED
@@ -1,8 +1,11 @@
1
1
  {
2
2
  "name": "@onlineapps/conn-orch-validator",
3
- "version": "3.1.1",
3
+ "version": "3.2.0",
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",
@@ -26,7 +29,6 @@
26
29
  "ajv": "^8.12.0",
27
30
  "ajv-formats": "^2.1.1",
28
31
  "amqplib": "^0.10.9",
29
- "axios": "^1.4.0",
30
32
  "joi": "^17.9.0"
31
33
  },
32
34
  "devDependencies": {
@@ -3,47 +3,39 @@
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
5
  const crypto = require('crypto');
6
- const axios = require('axios');
7
6
  const MockMQClient = require('./mocks/MockMQClient');
8
7
  const MockRegistry = require('./mocks/MockRegistry');
9
8
  const { resolveHeaders } = require('./utils/resolveHeaders');
10
9
 
11
10
  /**
12
- * CookbookTestRunner - Executes cookbook tests offline with mocked infrastructure
11
+ * CookbookTestRunner executes cookbook tests offline with mocked infrastructure.
13
12
  *
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.
13
+ * Dispatch is exclusively v3 handler-registry: each operation MUST declare
14
+ * `handler: "path#exportName"` in operations.json. The runner loads the
15
+ * module via `require()` and calls `handler(input, ctx)` directly in-process
16
+ * (no HTTP). Operations without a `handler` field are an explicit error.
21
17
  *
22
- * The v3 handler dispatch builds a minimal real ctx:
18
+ * The handler dispatch builds a minimal real ctx:
23
19
  * { 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
20
+ * httpClient: null, secrets: null, stream: null, abortSignal: null }
21
+ * matching the shape ServiceWrapper.ContextBuilder produces in production
26
22
  * (see api/docs/architecture/biz-service-invocation-model.md §5). Handlers
27
- * that require DB access import sequelize directly from their own
23
+ * that need DB access import sequelize directly from their own
28
24
  * 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
25
+ * connector slots stay null and the handler surfaces its own failure if
30
26
  * something it genuinely needs is missing.
31
27
  *
32
- * Supports unified cookbook format for both testing (with expect) and production (without expect)
28
+ * Supports unified cookbook format for both testing (with expect) and production (without expect).
33
29
  */
34
30
  class CookbookTestRunner {
35
31
  constructor(options = {}) {
36
32
  if (!options.serviceName) {
37
33
  throw new Error('[CookbookTestRunner] serviceName is required');
38
34
  }
39
- if (!options.serviceUrl) {
40
- throw new Error('[CookbookTestRunner] serviceUrl is required');
41
- }
42
35
  if (!options.logger || typeof options.logger.warn !== 'function') {
43
36
  throw new Error('[CookbookTestRunner] Logger is required — Expected object with warn() method');
44
37
  }
45
38
  this.serviceName = options.serviceName;
46
- this.serviceUrl = options.serviceUrl;
47
39
  this.servicePath = options.servicePath;
48
40
  this.mockInfrastructure = options.mockInfrastructure !== false;
49
41
  this.timeout = options.timeout;
@@ -189,9 +181,8 @@ class CookbookTestRunner {
189
181
  };
190
182
 
191
183
  try {
192
- // Resolve operation spec from operations.json — returns either
193
- // { dispatchMode: 'handler', modulePath, exportName } (v3) or
194
- // { dispatchMode: 'http', path, method, headers } (legacy).
184
+ // Resolve operation spec from operations.json — always returns
185
+ // { modulePath, exportName } (v3 handler dispatch is the only mode).
195
186
  const spec = await this.resolveOperation(step.service, step.operation);
196
187
 
197
188
  const validationTenantId = process.env.OA_VALIDATION_TENANT_ID;
@@ -200,27 +191,15 @@ class CookbookTestRunner {
200
191
  throw new Error('[CookbookTestRunner] Missing required environment variables OA_VALIDATION_TENANT_ID and/or OA_VALIDATION_WORKSPACE_ID');
201
192
  }
202
193
 
203
- if (spec.dispatchMode === 'handler') {
204
- await this._dispatchViaHandler({
205
- step,
206
- spec,
207
- testConfig,
208
- validationTenantId,
209
- validationWorkspaceId,
210
- result,
211
- startTime
212
- });
213
- } else {
214
- await this._dispatchViaHttp({
215
- step,
216
- spec,
217
- testConfig,
218
- validationTenantId,
219
- validationWorkspaceId,
220
- result,
221
- startTime
222
- });
223
- }
194
+ await this._dispatchViaHandler({
195
+ step,
196
+ spec,
197
+ testConfig,
198
+ validationTenantId,
199
+ validationWorkspaceId,
200
+ result,
201
+ startTime
202
+ });
224
203
 
225
204
  this.logger.info(`Step ${step.id}: ${result.passed ? 'PASSED' : 'FAILED'} (${result.duration}ms)`);
226
205
 
@@ -236,7 +215,7 @@ class CookbookTestRunner {
236
215
  }
237
216
 
238
217
  /**
239
- * v3 dispatch: require handler module, build minimal real ctx, call
218
+ * Handler dispatch: require handler module, build minimal real ctx, call
240
219
  * handler(input, ctx). Mirrors the production ContextBuilder shape
241
220
  * (see api/docs/architecture/biz-service-invocation-model.md §5) with
242
221
  * all connector slots set to null — handlers that need DB access use
@@ -348,47 +327,6 @@ class CookbookTestRunner {
348
327
  }
349
328
  }
350
329
 
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
-
392
330
  /**
393
331
  * Validate expectations against actual results
394
332
  */
@@ -537,16 +475,9 @@ class CookbookTestRunner {
537
475
  }
538
476
 
539
477
  /**
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.
478
+ * Resolve operation spec from operations.json. Returns the handler
479
+ * dispatch descriptor: { modulePath, exportName, headers }.
480
+ * Throws if the operation does not declare a v3 `handler`.
550
481
  */
551
482
  async resolveOperation(serviceName, operationName) {
552
483
  const serviceRoot = this._resolveServiceRoot(serviceName);
@@ -565,30 +496,20 @@ class CookbookTestRunner {
565
496
  throw new Error(`Operation not found: ${operationName}`);
566
497
  }
567
498
 
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
- };
499
+ if (!operation.handler) {
500
+ throw new Error(`[CookbookTestRunner] Operation ${serviceName}.${operationName} in ${operationsPath} is missing required v3 field "handler" ("path#exportName"). Fix: declare handler in operations.json per RFC §5.3.`);
580
501
  }
581
502
 
582
- if (operation.endpoint && operation.method) {
583
- return {
584
- dispatchMode: 'http',
585
- path: operation.endpoint,
586
- method: operation.method,
587
- headers: operation.headers || {}
588
- };
503
+ const [modRel, exportName] = String(operation.handler).split('#');
504
+ if (!modRel || !exportName) {
505
+ throw new Error(`[CookbookTestRunner] Invalid handler spec "${operation.handler}" for ${serviceName}.${operationName}; expected "path#exportName"`);
589
506
  }
590
-
591
- throw new Error(`[CookbookTestRunner] Operation ${serviceName}.${operationName} declares neither "handler" (v3) nor "endpoint"+"method" (legacy) in ${operationsPath}`);
507
+ const modulePath = require.resolve(path.join(serviceRoot, 'src', modRel));
508
+ return {
509
+ modulePath,
510
+ exportName,
511
+ headers: operation.headers || {}
512
+ };
592
513
  }
593
514
 
594
515
  /**
@@ -197,17 +197,26 @@ class ServiceReadinessValidator {
197
197
  }
198
198
 
199
199
  /**
200
- * Check health endpoint
200
+ * Check health endpoint via Node 18+ global fetch (no external HTTP client).
201
201
  */
202
202
  async checkHealth(healthUrl) {
203
203
  try {
204
- const axios = require('axios');
205
- const response = await axios.get(healthUrl, { timeout: 5000 });
204
+ const response = await fetch(healthUrl, {
205
+ method: 'GET',
206
+ signal: AbortSignal.timeout(5000)
207
+ });
208
+
209
+ let data = null;
210
+ try {
211
+ data = await response.json();
212
+ } catch (_) {
213
+ // health endpoint may return non-JSON; keep data null and rely on status.
214
+ }
206
215
 
207
216
  return {
208
217
  passed: response.status === 200,
209
218
  status: response.status,
210
- data: response.data
219
+ data
211
220
  };
212
221
  } catch (error) {
213
222
  return {
@@ -45,7 +45,6 @@ class ValidationOrchestrator {
45
45
  this.cookbookRunner = new CookbookTestRunner({
46
46
  servicePath: this.serviceRoot,
47
47
  serviceName: this.serviceName,
48
- serviceUrl: this.serviceUrl,
49
48
  logger: this.logger,
50
49
  timeout: 30000
51
50
  });
@@ -384,12 +383,10 @@ class ValidationOrchestrator {
384
383
  }
385
384
 
386
385
  /**
387
- * Step 4: Run cookbook tests
388
- *
389
- * v3 note: per-operation HTTP dispatch is retired. If operations.json is v3
390
- * (schema_version "3.0" or any operation declares `handler`), cookbook runs
391
- * are skipped here with a warning. Handler-registry-based cookbook dispatch
392
- * is tracked separately (see RFC §5.3, §5.9).
386
+ * Step 4: Run cookbook tests via CookbookTestRunner (v3 handler dispatch).
387
+ * Operations must declare a `handler` in operations.json; the runner
388
+ * loads the module via `require()` and calls the exported function
389
+ * in-process. Failures fail this validation step.
393
390
  */
394
391
  async runCookbookTests() {
395
392
  try {
@@ -406,36 +403,6 @@ class ValidationOrchestrator {
406
403
  };
407
404
  }
408
405
 
409
- const operationsFile = path.join(this.configPath, 'operations.json');
410
- let isV3 = false;
411
- try {
412
- const ops = JSON.parse(fs.readFileSync(operationsFile, 'utf8'));
413
- const opValues = Object.values(ops.operations || {});
414
- isV3 = ops.schema_version === '3.0' || opValues.some(o => o && typeof o === 'object' && 'handler' in o);
415
- } catch (_) { /* fall through to runner */ }
416
-
417
- if (isV3) {
418
- // v3 per-op HTTP dispatch is retired (RFC §5.3). Cookbook HTTP execution
419
- // is skipped until CookbookTestRunner gains handler-registry dispatch.
420
- // We still report each operation as a structural-validation "test" so
421
- // the proof codec's `testsRun > 0` invariant is satisfied — structural
422
- // validation of all operations has already passed at Steps 1 and 3.
423
- let opCount = 0;
424
- try {
425
- const ops = JSON.parse(fs.readFileSync(operationsFile, 'utf8'));
426
- opCount = Object.keys(ops.operations || {}).length;
427
- } catch (_) { opCount = 0; }
428
-
429
- console.warn(`[ValidationOrchestrator] Step 4: cookbook HTTP dispatch is v2-only. Skipping for v3; counting ${opCount} operation structural check(s) as tests.`);
430
- return {
431
- success: true,
432
- total: opCount,
433
- passed: opCount,
434
- failed: 0,
435
- warnings: ['Cookbook HTTP execution skipped — v3 handler-registry dispatch not yet implemented in CookbookTestRunner']
436
- };
437
- }
438
-
439
406
  const result = await this.cookbookRunner.runCookbooks(cookbooksPath);
440
407
 
441
408
  console.log(`[ValidationOrchestrator] ✓ Cookbook tests: ${result.passed}/${result.total} passed`);
@@ -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
+ };