@onlineapps/conn-orch-validator 6.0.1 → 8.0.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/CHANGELOG.md +2591 -2
- package/README.md +1075 -7
- package/docs/DESIGN.md +3 -1
- package/manifests/biz-service.manifest.json +658 -0
- package/manifests/library.manifest.json +324 -0
- package/package.json +12 -6
- package/src/CookbookTestRunner.js +422 -104
- package/src/CookbookTestUtils.js +7 -8
- package/src/ServiceReadinessValidator.js +78 -42
- package/src/ValidationOrchestrator.js +298 -75
- package/src/cli/biz-ci-gate.js +176 -33
- package/src/cli/oa-lint-scripts.js +221 -0
- package/src/cli/oa-sync-template.js +1020 -0
- package/src/cli/oa-validate.js +474 -0
- package/src/helpers/README.md +12 -2
- package/src/helpers/createServiceReadinessTests.js +75 -6
- package/src/index.js +33 -3
- package/src/lint/scripts/lintScripts.js +298 -0
- package/src/manifest/checks/composeRunnerBlock.js +222 -0
- package/src/manifest/checks/composeShape.js +165 -0
- package/src/manifest/checks/contractBridge.js +181 -0
- package/src/manifest/checks/discoveryOrphan.js +50 -0
- package/src/manifest/checks/docsLintBridge.js +553 -0
- package/src/manifest/checks/fileAbsent.js +35 -0
- package/src/manifest/checks/gitTracked.js +204 -0
- package/src/manifest/checks/index.js +111 -0
- package/src/manifest/checks/libraryContext.js +226 -0
- package/src/manifest/checks/libraryDocs.js +75 -0
- package/src/manifest/checks/libraryPackage.js +272 -0
- package/src/manifest/checks/librarySource.js +274 -0
- package/src/manifest/checks/libraryTests.js +121 -0
- package/src/manifest/checks/libraryWorkspace.js +293 -0
- package/src/manifest/checks/readmeRegion.js +135 -0
- package/src/manifest/checks/scriptHeaders.js +79 -0
- package/src/manifest/checks/serviceConfig.js +390 -0
- package/src/manifest/checks/serviceConnectors.js +81 -0
- package/src/manifest/checks/serviceDb.js +388 -0
- package/src/manifest/checks/serviceFiles.js +754 -0
- package/src/manifest/checks/serviceIdentityRows.js +351 -0
- package/src/manifest/checks/serviceRuntime.js +295 -0
- package/src/manifest/checks/serviceScripts.js +213 -0
- package/src/manifest/deployabilitySignal.js +121 -0
- package/src/manifest/discovery.js +386 -0
- package/src/manifest/loadManifest.js +62 -0
- package/src/manifest/manifestShape.js +446 -0
- package/src/manifest/report.js +245 -0
- package/src/manifest/runManifest.js +449 -0
- package/src/manifest/serviceIdentity.js +140 -0
- package/src/manifest/walk.js +74 -0
- package/src/manifest/workspaceRoot.js +242 -0
- package/src/mocks/MockMQClient.js +13 -30
- package/src/mocks/MockRegistry.js +4 -2
- package/src/mocks/MockStorage.js +4 -2
- package/src/sync/docsRegion.js +463 -0
- package/src/sync/generatedRegion.js +228 -0
- package/src/sync/readmeLocation.js +182 -0
- package/src/sync/readmePointer.js +477 -0
- package/src/sync/serviceTemplate.js +583 -0
- package/src/sync/sharedEnv.js +162 -0
- package/src/sync/uniformFiles.js +474 -0
- package/src/utils/bizCiGateContract.js +131 -7
- package/src/utils/connectorContract.js +97 -7
- package/src/utils/cookbookFormat.js +81 -40
- package/src/utils/deployContract.js +213 -13
- package/src/utils/envContract.js +57 -1
- package/src/utils/handlerRef.js +181 -0
- package/src/utils/installContract.js +287 -41
- package/src/utils/libCompat.js +29 -7
- package/src/utils/migrationOrder.js +163 -0
- package/src/utils/preValidation.js +20 -7
- package/src/utils/setupDatabase.js +194 -13
- package/src/utils/testCoverageContract.js +539 -0
- package/src/utils/testNamespace.js +247 -23
- package/src/utils/throwawaySchema.js +207 -0
- package/src/validators/ServiceStructureValidator.js +21 -20
- package/templates/business-service/.dockerignore +42 -0
- package/templates/business-service/.gitlab-ci.yml +290 -0
- package/templates/business-service/Dockerfile +27 -0
- package/templates/business-service/README.md +213 -0
- package/templates/business-service/config/biz-docs-lint.tree.json +10 -0
- package/templates/business-service/config/env-templates/__SERVICE_NAME__.env +4 -0
- package/templates/business-service/config/env-templates/shared.env +65 -0
- package/templates/business-service/config/service/config.json +14 -0
- package/templates/business-service/config/service/integration-contract.json +12 -0
- package/templates/business-service/config/service/operations.json +41 -0
- package/templates/business-service/docker-compose.production.yml +60 -0
- package/templates/business-service/docker-compose.yml +93 -0
- package/templates/business-service/docs/80-setup/INSTALL.md +101 -0
- package/templates/business-service/docs/80-setup/PLATFORM_MATRIX.md +65 -0
- package/templates/business-service/docs/80-setup/README.md +18 -0
- package/templates/business-service/docs/80-setup/VALIDATION.md +78 -0
- package/templates/business-service/docs/README.md +18 -0
- package/templates/business-service/gitignore +42 -0
- package/templates/business-service/index.js +10 -0
- package/templates/business-service/init.sh +54 -0
- package/templates/business-service/jest.config.js +6 -0
- package/templates/business-service/package.json.template +31 -0
- package/templates/business-service/scripts/verify-deploy-uniform.sh +180 -0
- package/templates/business-service/src/handlers/v3/echo.js +39 -0
- package/templates/business-service/tests/cookbooks/echo.json +36 -0
- package/templates/business-service/tests/unit/handler.test.js +78 -0
- package/src/WorkflowTestRunner.js +0 -402
|
@@ -6,13 +6,11 @@ const crypto = require('crypto');
|
|
|
6
6
|
const MockMQClient = require('./mocks/MockMQClient');
|
|
7
7
|
const MockRegistry = require('./mocks/MockRegistry');
|
|
8
8
|
const { resolveHeaders } = require('./utils/resolveHeaders');
|
|
9
|
-
const { checkCookbookFormatVersion,
|
|
10
|
-
const { getTestNamespace } = require('./utils/testNamespace');
|
|
9
|
+
const { checkCookbookFormatVersion, readCookbookSteps } = require('./utils/cookbookFormat');
|
|
10
|
+
const { getTestNamespace, assertWorkspaceId } = require('./utils/testNamespace');
|
|
11
11
|
const { describeStepFailure, describeStepIdentity } = require('./utils/stepFailure');
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
// message lists them in.
|
|
15
|
-
const LOGGER_METHODS = ['info', 'warn', 'error', 'debug'];
|
|
12
|
+
const { parseHandlerRef, resolveHandlerModule } = require('./utils/handlerRef');
|
|
13
|
+
const { assertLogger } = require('@onlineapps/logger-contract');
|
|
16
14
|
|
|
17
15
|
/**
|
|
18
16
|
* The step's stopwatch, spelled out. A single total hid the fact that
|
|
@@ -28,6 +26,55 @@ function describeStepTiming(result) {
|
|
|
28
26
|
+ ` = ${result.duration}ms total`;
|
|
29
27
|
}
|
|
30
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Resolve one `expect.output` field name against a step's output.
|
|
31
|
+
*
|
|
32
|
+
* The name is a DOT PATH into nested objects, because that is the only way a
|
|
33
|
+
* cookbook can assert anything about a file descriptor: a descriptor lives
|
|
34
|
+
* under its own key inside the output (`{ pdf: <descriptor> }`), never at the
|
|
35
|
+
* root — api/docs/biz/40-cookbooks/variable-references.md § "File descriptors
|
|
36
|
+
* in outputs". A flat `actual[field]` lookup answered `undefined` for
|
|
37
|
+
* `"pdf.size"` and reported it as missing, which fails the step and, in
|
|
38
|
+
* Tier-1, the boot of a service returning exactly what the norm prescribes.
|
|
39
|
+
*
|
|
40
|
+
* Two rules, both decisions rather than consequences:
|
|
41
|
+
*
|
|
42
|
+
* 1. **The exact key wins, at every level.** `{ "a.b": 1 }` is legal output
|
|
43
|
+
* and `"a.b"` is the name its author typed, so it is looked up verbatim
|
|
44
|
+
* before the name is split. Splitting is what happens when the name does
|
|
45
|
+
* not exist as written — nobody has to escape anything.
|
|
46
|
+
* 2. **Objects only.** A segment never indexes an array (`items.0` does not
|
|
47
|
+
* reach the first element) and never reads a property off a scalar. No
|
|
48
|
+
* cookbook asks for indices today; inventing the syntax here would make
|
|
49
|
+
* it this library's invention rather than the norm's.
|
|
50
|
+
*
|
|
51
|
+
* @param {*} container - the output object, or any nested value during the walk
|
|
52
|
+
* @param {string} fieldPath - the field name as the cookbook wrote it
|
|
53
|
+
* @returns {{found: boolean, value?: *, missingSegment?: string}} `missingSegment`
|
|
54
|
+
* is the FIRST segment that could not be resolved, so the caller can
|
|
55
|
+
* say whether `pdf` was absent or `pdf` was there without a `size`.
|
|
56
|
+
*/
|
|
57
|
+
function resolveOutputField(container, fieldPath) {
|
|
58
|
+
const traversable = container !== null && typeof container === 'object' && !Array.isArray(container);
|
|
59
|
+
|
|
60
|
+
if (traversable && Object.prototype.hasOwnProperty.call(container, fieldPath)) {
|
|
61
|
+
return { found: true, value: container[fieldPath] };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const separator = fieldPath.indexOf('.');
|
|
65
|
+
if (separator === -1) {
|
|
66
|
+
return { found: false, missingSegment: fieldPath };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const head = fieldPath.slice(0, separator);
|
|
70
|
+
const rest = fieldPath.slice(separator + 1);
|
|
71
|
+
if (!traversable || !Object.prototype.hasOwnProperty.call(container, head)) {
|
|
72
|
+
return { found: false, missingSegment: head };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return resolveOutputField(container[head], rest);
|
|
76
|
+
}
|
|
77
|
+
|
|
31
78
|
/**
|
|
32
79
|
* CookbookTestRunner — executes cookbook tests offline with mocked infrastructure.
|
|
33
80
|
*
|
|
@@ -60,24 +107,8 @@ class CookbookTestRunner {
|
|
|
60
107
|
// constructed fine and died on the first log line: delayed validation, which
|
|
61
108
|
// architecture-principles.md §4 forbids. Owner confirmation:
|
|
62
109
|
// docs/governance/confirmations/connector-logger-contract.md 001.
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
'[CookbookTestRunner] Logger is required - Expected: a logger with info/warn/error/debug, '
|
|
66
|
-
+ 'so the runner writes where the service writes. '
|
|
67
|
-
+ 'Fix: pass options.logger (e.g. the logger your service already built).'
|
|
68
|
-
);
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
const missingLoggerMethods = LOGGER_METHODS.filter(
|
|
72
|
-
(method) => typeof options.logger[method] !== 'function'
|
|
73
|
-
);
|
|
74
|
-
if (missingLoggerMethods.length > 0) {
|
|
75
|
-
throw new Error(
|
|
76
|
-
'[CookbookTestRunner] Logger is incomplete - Expected: info, warn, error, debug as functions; '
|
|
77
|
-
+ `missing: ${missingLoggerMethods.join(', ')}. `
|
|
78
|
-
+ 'Fix: pass a logger implementing all four.'
|
|
79
|
-
);
|
|
80
|
-
}
|
|
110
|
+
assertLogger('CookbookTestRunner', options.logger, 'the runner writes where the service writes');
|
|
111
|
+
|
|
81
112
|
this.serviceName = options.serviceName;
|
|
82
113
|
this.servicePath = options.servicePath;
|
|
83
114
|
this.mockInfrastructure = options.mockInfrastructure !== false;
|
|
@@ -98,13 +129,7 @@ class CookbookTestRunner {
|
|
|
98
129
|
}
|
|
99
130
|
|
|
100
131
|
// Test results
|
|
101
|
-
this.results =
|
|
102
|
-
total: 0,
|
|
103
|
-
passed: 0,
|
|
104
|
-
failed: 0,
|
|
105
|
-
duration: 0,
|
|
106
|
-
steps: []
|
|
107
|
-
};
|
|
132
|
+
this.results = this._emptyResults();
|
|
108
133
|
}
|
|
109
134
|
|
|
110
135
|
/**
|
|
@@ -125,6 +150,10 @@ class CookbookTestRunner {
|
|
|
125
150
|
// path is appended to whatever the validation rejected — the reader of a
|
|
126
151
|
// boot failure otherwise learns that "a" cookbook is malformed but not
|
|
127
152
|
// which one, and a service can carry a dozen of them.
|
|
153
|
+
//
|
|
154
|
+
// The message opens with the cause's own message, so it INHERITS that
|
|
155
|
+
// error's `[Context]` rather than prefixing a second one; the cause travels
|
|
156
|
+
// along, so the catching layer still sees the original error object.
|
|
128
157
|
try {
|
|
129
158
|
this.validateCookbook(cookbookData);
|
|
130
159
|
} catch (error) {
|
|
@@ -143,14 +172,14 @@ class CookbookTestRunner {
|
|
|
143
172
|
this.logger.info(`Running cookbook: ${cookbookData.description || 'Unnamed'}`);
|
|
144
173
|
this.logger.info(`Mode: ${testConfig.mode || 'production'}, Test: ${isTestMode}`);
|
|
145
174
|
|
|
146
|
-
//
|
|
147
|
-
const stepsArray =
|
|
175
|
+
// Already proved to be the array shape by validateCookbook above.
|
|
176
|
+
const stepsArray = readCookbookSteps(cookbookData.steps);
|
|
148
177
|
|
|
149
178
|
// Execute steps
|
|
150
179
|
const cookbookName = cookbookData.description || 'Unnamed';
|
|
151
180
|
const stepResults = [];
|
|
152
181
|
for (const [stepIndex, step] of stepsArray.entries()) {
|
|
153
|
-
const stepResult = await this.executeStep(step, testConfig, stepIndex);
|
|
182
|
+
const stepResult = await this.executeStep(step, testConfig, stepIndex, cookbookData.defaults);
|
|
154
183
|
// Which cookbook this step came from. Without it a failed step in the
|
|
155
184
|
// aggregate is an id with no file behind it, and a service can carry a
|
|
156
185
|
// dozen cookbooks (utils/stepFailure.js).
|
|
@@ -185,16 +214,24 @@ class CookbookTestRunner {
|
|
|
185
214
|
this.results.duration += results.duration;
|
|
186
215
|
this.results.steps.push(...results.steps);
|
|
187
216
|
|
|
217
|
+
// One cookbook, counted as one — whatever its number of steps.
|
|
218
|
+
this.results.cookbooks.total += 1;
|
|
219
|
+
this.results.cookbooks[results.passed ? 'passed' : 'failed'] += 1;
|
|
220
|
+
|
|
188
221
|
return results;
|
|
189
222
|
}
|
|
190
223
|
|
|
191
224
|
/**
|
|
192
|
-
* Run all cookbooks in a directory
|
|
225
|
+
* Run all cookbooks in a directory — or the ones running one operation.
|
|
193
226
|
*
|
|
194
227
|
* @param {string} cookbooksDir - Path to cookbooks directory
|
|
228
|
+
* @param {{operation?: string}} [options] - `operation` is a JavaScript
|
|
229
|
+
* regular expression; only cookbooks containing a step whose
|
|
230
|
+
* `operation` matches it are run. See `_selectCookbooks` for what
|
|
231
|
+
* "selected" means and why the selection is per cookbook.
|
|
195
232
|
* @returns {Object} Aggregate results
|
|
196
233
|
*/
|
|
197
|
-
async runCookbooks(cookbooksDir) {
|
|
234
|
+
async runCookbooks(cookbooksDir, options = {}) {
|
|
198
235
|
// Every run reports ITS OWN run. `this.results` only ever accumulated, and
|
|
199
236
|
// `ValidationOrchestrator` builds one runner in its constructor while
|
|
200
237
|
// `ServiceWrapper._ensureValidationProof` reuses one orchestrator across
|
|
@@ -210,9 +247,13 @@ class CookbookTestRunner {
|
|
|
210
247
|
// This is that caller.
|
|
211
248
|
this.resetResults();
|
|
212
249
|
|
|
213
|
-
const
|
|
250
|
+
const all = fs.readdirSync(cookbooksDir).filter(f => f.endsWith('.json'));
|
|
251
|
+
const files = this._selectCookbooks(cookbooksDir, all, options.operation);
|
|
214
252
|
|
|
215
|
-
this.logger.info(
|
|
253
|
+
this.logger.info(options.operation === undefined
|
|
254
|
+
? `Found ${files.length} cookbook(s) in ${cookbooksDir}`
|
|
255
|
+
: `Found ${files.length} of ${all.length} cookbook(s) in ${cookbooksDir}`
|
|
256
|
+
+ ` matching operation /${options.operation}/`);
|
|
216
257
|
|
|
217
258
|
for (const file of files) {
|
|
218
259
|
const cookbookPath = path.join(cookbooksDir, file);
|
|
@@ -220,7 +261,22 @@ class CookbookTestRunner {
|
|
|
220
261
|
await this.runCookbook(cookbookPath);
|
|
221
262
|
} catch (error) {
|
|
222
263
|
this.logger.error(`Failed to run cookbook ${file}:`, error.message);
|
|
264
|
+
// ONE definition for the step counters: everything in `results.steps` is
|
|
265
|
+
// counted in `total` exactly once — a real step, or the single entry
|
|
266
|
+
// below standing for a cookbook that never produced any. `failed++`
|
|
267
|
+
// without the matching `total++` is how the aggregate came to report
|
|
268
|
+
// `total 2 / failed 3` (BIZ-converter, 2026-08-26): numbers that do not
|
|
269
|
+
// add up, in the object `utils/preValidation.js` reads to decide whether
|
|
270
|
+
// to write a proof and `ValidationOrchestrator` folds into the run's
|
|
271
|
+
// totals. The invariant is `passed + failed === total === steps.length`.
|
|
272
|
+
this.results.total++;
|
|
223
273
|
this.results.failed++;
|
|
274
|
+
// A cookbook that could not be loaded or validated is a FAILED cookbook,
|
|
275
|
+
// never a cookbook nobody counted: the file is there, it is part of the
|
|
276
|
+
// service's declared coverage, and a total that quietly excludes it
|
|
277
|
+
// reports a smaller run as a complete one.
|
|
278
|
+
this.results.cookbooks.total += 1;
|
|
279
|
+
this.results.cookbooks.failed += 1;
|
|
224
280
|
this.results.steps.push({
|
|
225
281
|
cookbook: file,
|
|
226
282
|
passed: false,
|
|
@@ -232,10 +288,87 @@ class CookbookTestRunner {
|
|
|
232
288
|
return this.results;
|
|
233
289
|
}
|
|
234
290
|
|
|
291
|
+
/**
|
|
292
|
+
* Which cookbook files this run executes.
|
|
293
|
+
*
|
|
294
|
+
* Without a filter: all of them, in the order the directory lists them.
|
|
295
|
+
*
|
|
296
|
+
* With one, three rules, each a decision:
|
|
297
|
+
*
|
|
298
|
+
* 1. **A cookbook is selected WHOLE.** The expression is matched against
|
|
299
|
+
* the `operation` of every step, and a cookbook holding one match runs
|
|
300
|
+
* entirely. Running only the matching steps would be a different
|
|
301
|
+
* cookbook: a later step reads what an earlier one wrote, and the format
|
|
302
|
+
* makes that explicit through `depends_on`
|
|
303
|
+
* (api/docs/biz/40-cookbooks/variable-references.md).
|
|
304
|
+
* 2. **A file this method cannot read is never filtered OUT.** Selection
|
|
305
|
+
* reads JSON; a file that does not parse, or whose steps are not the
|
|
306
|
+
* shape the format requires, is kept and fails in the run where the
|
|
307
|
+
* format gate reports it by name. A filter that quietly dropped
|
|
308
|
+
* unreadable cookbooks would be a way to walk past that gate
|
|
309
|
+
* (`automation-gates.md` §1.5).
|
|
310
|
+
* 3. **A filter matching nothing is an error**, not an empty pass. A run
|
|
311
|
+
* that executes zero cookbooks and reports success is the silent gate of
|
|
312
|
+
* `automation-gates.md` §5; the message names the operations the
|
|
313
|
+
* directory actually runs, so the fix needs no second command.
|
|
314
|
+
*
|
|
315
|
+
* @param {string} cookbooksDir directory being run
|
|
316
|
+
* @param {string[]} files every `.json` in it
|
|
317
|
+
* @param {string|undefined} operation the regular expression, or undefined
|
|
318
|
+
* @returns {string[]} the files to run
|
|
319
|
+
*/
|
|
320
|
+
_selectCookbooks(cookbooksDir, files, operation) {
|
|
321
|
+
if (operation === undefined) return files;
|
|
322
|
+
|
|
323
|
+
let pattern;
|
|
324
|
+
try {
|
|
325
|
+
pattern = new RegExp(operation);
|
|
326
|
+
} catch (error) {
|
|
327
|
+
throw new Error(`[CookbookTestRunner] Invalid --operation expression ${JSON.stringify(operation)} - `
|
|
328
|
+
+ `Expected a JavaScript regular expression. Fix: correct it (${error.message}).`, { cause: error });
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const operationsSeen = new Set();
|
|
332
|
+
const selected = files.filter((file) => {
|
|
333
|
+
const operations = this._operationsOf(path.join(cookbooksDir, file));
|
|
334
|
+
if (operations === null) return true;
|
|
335
|
+
for (const name of operations) operationsSeen.add(name);
|
|
336
|
+
return operations.some((name) => pattern.test(name));
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
if (selected.length === 0) {
|
|
340
|
+
const known = [...operationsSeen].sort().join(', ') || '(none declared)';
|
|
341
|
+
throw new Error(`[CookbookTestRunner] No cookbook matches --operation ${JSON.stringify(operation)} - `
|
|
342
|
+
+ `Expected a JavaScript regular expression matching one of the operations these cookbooks run: `
|
|
343
|
+
+ `${known}. Fix: correct the expression, or run without --operation.`);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
return selected;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* The operations one cookbook file runs, or `null` when the file cannot be
|
|
351
|
+
* read as a cookbook at all — which is rule 2 above: unreadable is not
|
|
352
|
+
* "does not match", it is "runs and reports itself".
|
|
353
|
+
*
|
|
354
|
+
* @param {string} cookbookPath absolute path
|
|
355
|
+
* @returns {string[]|null}
|
|
356
|
+
*/
|
|
357
|
+
_operationsOf(cookbookPath) {
|
|
358
|
+
try {
|
|
359
|
+
const cookbook = JSON.parse(fs.readFileSync(cookbookPath, 'utf8'));
|
|
360
|
+
return readCookbookSteps(cookbook.steps)
|
|
361
|
+
.map((step) => step && step.operation)
|
|
362
|
+
.filter((name) => typeof name === 'string' && name.length > 0);
|
|
363
|
+
} catch {
|
|
364
|
+
return null;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
235
368
|
/**
|
|
236
369
|
* Execute single step
|
|
237
370
|
*/
|
|
238
|
-
async executeStep(step, testConfig, stepIndex = 0) {
|
|
371
|
+
async executeStep(step, testConfig, stepIndex = 0, cookbookDefaults = null) {
|
|
239
372
|
const startTime = Date.now();
|
|
240
373
|
|
|
241
374
|
// ONE label for this step, resolved once. `step.id` alone was undefined for
|
|
@@ -291,7 +424,15 @@ class CookbookTestRunner {
|
|
|
291
424
|
// biz-property incident wrote into the live tenant at every restart.
|
|
292
425
|
// Values arrive already validated as integers, so ctx matches the
|
|
293
426
|
// production ContextBuilder for handlers checking Number.isInteger().
|
|
294
|
-
const { tenant_id: validationTenantId, workspace_id:
|
|
427
|
+
const { tenant_id: validationTenantId, workspace_id: envWorkspaceId } = getTestNamespace();
|
|
428
|
+
|
|
429
|
+
// The WORKSPACE, unlike the tenant, is the cookbook author's to choose.
|
|
430
|
+
const validationWorkspaceId = this._resolveStepWorkspaceId({
|
|
431
|
+
step,
|
|
432
|
+
stepLabel,
|
|
433
|
+
cookbookDefaults,
|
|
434
|
+
envWorkspaceId
|
|
435
|
+
});
|
|
295
436
|
|
|
296
437
|
// Resolve operation spec from operations.json — always returns
|
|
297
438
|
// { modulePath, exportName } (v3 handler dispatch is the only mode).
|
|
@@ -332,6 +473,66 @@ class CookbookTestRunner {
|
|
|
332
473
|
return result;
|
|
333
474
|
}
|
|
334
475
|
|
|
476
|
+
/**
|
|
477
|
+
* The workspace THIS step runs in.
|
|
478
|
+
*
|
|
479
|
+
* TWO LEVELS, and the rule that orders them is not this file's: it belongs to
|
|
480
|
+
* `.claude/rules/workspace-architecture.md` and its single implementation on
|
|
481
|
+
* the invocation path is
|
|
482
|
+
* `@onlineapps/conn-orch-orchestrator` → `WorkflowOrchestrator._resolveStepWorkspaceId`
|
|
483
|
+
* (d.332). A per-step `workspace_id` overrides `defaults.workspace_id` for that
|
|
484
|
+
* step and for no other; it is read here, per step, so it can never leak into
|
|
485
|
+
* the next one. This runner CITES that rule rather than importing it: the
|
|
486
|
+
* package depends on `@onlineapps/logger-contract` and
|
|
487
|
+
* `@onlineapps/service-validator-core` and on nothing that owns cookbook
|
|
488
|
+
* semantics, and the third term below is Tier-1's alone, so a shared body would
|
|
489
|
+
* have to be parameterised to say less than either caller means.
|
|
490
|
+
*
|
|
491
|
+
* THE THIRD TERM — the platform namespace from the environment — is what a
|
|
492
|
+
* cookbook that declares no workspace gets, which is exactly what every such
|
|
493
|
+
* cookbook got before this method existed. It is a precedence chain over two
|
|
494
|
+
* DECLARED sources, not a fallback for a missing one: the env value is itself
|
|
495
|
+
* required (`getTestNamespace()` throws when it is absent), and 32 of the 44
|
|
496
|
+
* cookbooks in the eight live biz repositories declare no workspace at all
|
|
497
|
+
* (measured 2026-09-14), so refusing here would fail the boot of six services
|
|
498
|
+
* the day the rule lands — `automation-gates.md` §3, a gate lands WITH
|
|
499
|
+
* compliance. The orchestrator reached the same conclusion for the same reason
|
|
500
|
+
* (d.332): "missing both → error" holds where the scope of the operation is
|
|
501
|
+
* known, and at boot it is not.
|
|
502
|
+
*
|
|
503
|
+
* WHAT A DECLARED VALUE STILL PASSES: `assertWorkspaceId`, the boundary owner
|
|
504
|
+
* in `utils/testNamespace.js`. A cookbook value that is not an id is refused,
|
|
505
|
+
* naming the key that carries it — never swapped for the environment value,
|
|
506
|
+
* which would hide the misconfiguration (architecture-principles.md §3). The
|
|
507
|
+
* tenant is not resolved here at all: it stays `getTestNamespace()`'s, so no
|
|
508
|
+
* cookbook can move the boot probe out of an allowed environment class
|
|
509
|
+
* (api/docs/standards/tenant-allocation.md § Enforcement).
|
|
510
|
+
*
|
|
511
|
+
* @param {Object} params
|
|
512
|
+
* @param {Object} params.step - the cookbook step, possibly carrying `workspace_id`
|
|
513
|
+
* @param {string} params.stepLabel - the step's name for messages (utils/stepFailure)
|
|
514
|
+
* @param {Object|null} params.cookbookDefaults - the cookbook's `defaults` block
|
|
515
|
+
* @param {number} params.envWorkspaceId - the platform namespace workspace
|
|
516
|
+
* @returns {number} the workspace id for this step
|
|
517
|
+
*/
|
|
518
|
+
_resolveStepWorkspaceId({ step, stepLabel, cookbookDefaults, envWorkspaceId }) {
|
|
519
|
+
if (step && step.workspace_id !== undefined) {
|
|
520
|
+
return assertWorkspaceId(step.workspace_id, {
|
|
521
|
+
purpose: `cookbook step "${stepLabel}" workspace_id`,
|
|
522
|
+
setBy: 'in the cookbook'
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
if (cookbookDefaults && cookbookDefaults.workspace_id !== undefined) {
|
|
527
|
+
return assertWorkspaceId(cookbookDefaults.workspace_id, {
|
|
528
|
+
purpose: 'cookbook defaults.workspace_id',
|
|
529
|
+
setBy: 'in the cookbook'
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
return envWorkspaceId;
|
|
534
|
+
}
|
|
535
|
+
|
|
335
536
|
/**
|
|
336
537
|
* Handler dispatch: require handler module, build minimal real ctx, call
|
|
337
538
|
* handler(input, ctx). Mirrors the production ContextBuilder shape
|
|
@@ -395,16 +596,12 @@ class CookbookTestRunner {
|
|
|
395
596
|
};
|
|
396
597
|
result.request = request;
|
|
397
598
|
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
}
|
|
405
|
-
} catch (error) {
|
|
406
|
-
throw new Error(`[CookbookTestRunner] Failed to load handler ${spec.exportName} from ${spec.modulePath}: ${error.message}`);
|
|
407
|
-
}
|
|
599
|
+
// The module was required and the export checked by `resolveOperation`,
|
|
600
|
+
// through `utils/handlerRef` — the same rule production dispatches by.
|
|
601
|
+
// Requiring it a second time here was the other half of the duplicated
|
|
602
|
+
// resolution, and it reported a failure the resolution had already ruled
|
|
603
|
+
// out.
|
|
604
|
+
const handlerFn = spec.handler;
|
|
408
605
|
|
|
409
606
|
let timeoutId;
|
|
410
607
|
const timeoutPromise = new Promise((_, reject) => {
|
|
@@ -441,11 +638,22 @@ class CookbookTestRunner {
|
|
|
441
638
|
// Synthesize an HTTP-like response so existing expect validators
|
|
442
639
|
// continue to work (expect.status: 'error', expect.error.code, ...).
|
|
443
640
|
const errorCode = handlerError.code || handlerError.name || 'HANDLER_ERROR';
|
|
444
|
-
|
|
445
|
-
|
|
641
|
+
// `status` is the platform's ONE name for the number a thrown error
|
|
642
|
+
// carries: `BusinessError` sets it, `ErrorMapper` reads it
|
|
643
|
+
// (@onlineapps/service-wrapper, src/ErrorMapper.js), and a service that
|
|
644
|
+
// throws its own class declares the same shape.
|
|
645
|
+
// @see api/docs/biz/70-contracts/error-handling.md §1
|
|
646
|
+
//
|
|
647
|
+
// No `statusCode || status` chain. The retired second name was read here
|
|
648
|
+
// and nowhere else, which is why three biz services mirrored the value
|
|
649
|
+
// under both names to satisfy this line; a fallback would keep both alive
|
|
650
|
+
// and let them drift. An error carrying no usable status is a 500 — the
|
|
651
|
+
// handler declared no outcome, so it gets the server fault it earned.
|
|
652
|
+
const status = typeof handlerError.status === 'number'
|
|
653
|
+
? handlerError.status
|
|
446
654
|
: 500;
|
|
447
655
|
result.response = {
|
|
448
|
-
status
|
|
656
|
+
status,
|
|
449
657
|
statusText: errorCode,
|
|
450
658
|
data: { code: errorCode, message: handlerError.message }
|
|
451
659
|
};
|
|
@@ -515,16 +723,45 @@ class CookbookTestRunner {
|
|
|
515
723
|
}
|
|
516
724
|
}
|
|
517
725
|
|
|
518
|
-
// Validate output
|
|
519
|
-
|
|
726
|
+
// Validate output — whenever the cookbook declares expectations, whether or
|
|
727
|
+
// not the handler produced anything.
|
|
728
|
+
//
|
|
729
|
+
// The guard used to read `expect.output && result.actual`, so a step whose
|
|
730
|
+
// handler returned nothing skipped every field assertion and was judged on
|
|
731
|
+
// `status` alone: `validationErrors: []`, a green step over unchecked
|
|
732
|
+
// expectations (measured by BIZ-pdfgen 2026-08-29; `automation-gates.md`
|
|
733
|
+
// §5). `validateOutput` already answers correctly for an absent output — a
|
|
734
|
+
// required field is "missing in output", and an `exists: false` assertion is
|
|
735
|
+
// SATISFIED by no output — so the guard was what made those two cases
|
|
736
|
+
// indistinguishable.
|
|
737
|
+
if (expect.output) {
|
|
520
738
|
const outputErrors = this.validateOutput(expect.output, result.actual);
|
|
521
739
|
errors.push(...outputErrors);
|
|
522
740
|
}
|
|
523
741
|
|
|
524
|
-
// Validate error
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
742
|
+
// Validate error — whenever the cookbook declares one, whether or not the
|
|
743
|
+
// step actually failed.
|
|
744
|
+
//
|
|
745
|
+
// The sister defect of the `expect.output` guard one block up, and the same
|
|
746
|
+
// shape: `if (expect.error && result.error)` meant a step declaring
|
|
747
|
+
// `expect.error` over a handler that returned NORMALLY skipped the whole
|
|
748
|
+
// block. `expect.status` is optional, so a step declaring only `expect.error`
|
|
749
|
+
// came back with `validationErrors: []` — green, over an expectation nothing
|
|
750
|
+
// evaluated (`automation-gates.md` §5).
|
|
751
|
+
//
|
|
752
|
+
// Here the guard alone cannot be the fix: `validateError` reads `code` and
|
|
753
|
+
// `message` off the error, and in this case there is no error. The absence
|
|
754
|
+
// IS the violation, so it gets its own sentence rather than a comparison
|
|
755
|
+
// against undefined.
|
|
756
|
+
if (expect.error) {
|
|
757
|
+
if (result.error) {
|
|
758
|
+
errors.push(...this.validateError(expect.error, result.error));
|
|
759
|
+
} else {
|
|
760
|
+
const declared = expect.error.code === undefined ? '(any)' : expect.error.code;
|
|
761
|
+
errors.push(`[CookbookTestRunner] Expected error ${declared}, step succeeded - `
|
|
762
|
+
+ 'Expected the handler to throw the declared error; it returned a result instead. '
|
|
763
|
+
+ 'Fix: drive the step to the failing path it declares, or drop expect.error from the step.');
|
|
764
|
+
}
|
|
528
765
|
}
|
|
529
766
|
|
|
530
767
|
return {
|
|
@@ -534,13 +771,18 @@ class CookbookTestRunner {
|
|
|
534
771
|
}
|
|
535
772
|
|
|
536
773
|
/**
|
|
537
|
-
* Validate output fields
|
|
774
|
+
* Validate output fields.
|
|
775
|
+
*
|
|
776
|
+
* A field name is a dot path into the output — see `resolveOutputField`
|
|
777
|
+
* above for the two rules it follows (exact key first, objects only) and
|
|
778
|
+
* for why a descriptor cannot be asserted any other way.
|
|
538
779
|
*/
|
|
539
780
|
validateOutput(expected, actual) {
|
|
540
781
|
const errors = [];
|
|
541
782
|
|
|
542
783
|
for (const [field, expectation] of Object.entries(expected)) {
|
|
543
|
-
const
|
|
784
|
+
const resolved = resolveOutputField(actual, field);
|
|
785
|
+
const actualValue = resolved.found ? resolved.value : undefined;
|
|
544
786
|
|
|
545
787
|
// Check existence
|
|
546
788
|
if (expectation.exists !== undefined) {
|
|
@@ -557,7 +799,16 @@ class CookbookTestRunner {
|
|
|
557
799
|
// If field doesn't exist and we're not explicitly checking existence, error
|
|
558
800
|
if (actualValue === undefined || actualValue === null) {
|
|
559
801
|
if (expectation.exists === undefined) {
|
|
560
|
-
|
|
802
|
+
// A path miss names the first segment that could not be resolved:
|
|
803
|
+
// "no pdf at all" and "a pdf without a size" are different defects
|
|
804
|
+
// and get different fixes. A name without a dot keeps the original
|
|
805
|
+
// wording — its first missing segment is the name itself, and
|
|
806
|
+
// repeating it would be noise.
|
|
807
|
+
errors.push(field.includes('.') && resolved.missingSegment !== undefined
|
|
808
|
+
? `Field ${field}: missing in output - segment "${resolved.missingSegment}" not found. `
|
|
809
|
+
+ 'Fix: assert a path that exists in the step output '
|
|
810
|
+
+ '(a dot path walks nested objects, not array indices).'
|
|
811
|
+
: `Field ${field}: missing in output`);
|
|
561
812
|
}
|
|
562
813
|
continue;
|
|
563
814
|
}
|
|
@@ -641,8 +892,24 @@ class CookbookTestRunner {
|
|
|
641
892
|
|
|
642
893
|
/**
|
|
643
894
|
* Resolve operation spec from operations.json. Returns the handler
|
|
644
|
-
* dispatch descriptor: { modulePath, exportName,
|
|
645
|
-
*
|
|
895
|
+
* dispatch descriptor: { modulePath, exportName, handler } — exactly what the
|
|
896
|
+
* dispatch reads, and nothing else. It carried `headers: operation.headers ||
|
|
897
|
+
* {}` until 2026-09-14, which no caller ever read: ctx.headers is built from
|
|
898
|
+
* `step.headers` (see `_dispatchViaHandler`), which is what the contract
|
|
899
|
+
* prescribes — api/docs/biz/10-invocation/operation-context.md § ctx.headers
|
|
900
|
+
* names the STEP, the operations.json rules in `service-validator-core` know
|
|
901
|
+
* no `headers` field, and not one of the eight live services declares it.
|
|
902
|
+
* Throws if the operation does not declare a v3 `handler`, or if that
|
|
903
|
+
* handler does not resolve.
|
|
904
|
+
*
|
|
905
|
+
* The ref is read by the shared rule in `utils/handlerRef` — the same one
|
|
906
|
+
* every shape check in this package uses. This method used to carry its own,
|
|
907
|
+
* weaker copy: it split on `#` without counting the separators, so
|
|
908
|
+
* `handlers/v3/good#run#extra` dispatched as `#run`, and it joined the path
|
|
909
|
+
* onto `<serviceRoot>/src` with no containment check, so `../outside#run`
|
|
910
|
+
* loaded and ran a module from outside the service source root. Cookbooks run
|
|
911
|
+
* at every biz service boot, which made that the loosest resolution on the
|
|
912
|
+
* platform sitting on the hottest path.
|
|
646
913
|
*/
|
|
647
914
|
async resolveOperation(serviceName, operationName) {
|
|
648
915
|
const serviceRoot = this._resolveServiceRoot(serviceName);
|
|
@@ -651,46 +918,67 @@ class CookbookTestRunner {
|
|
|
651
918
|
const operationsPath = path.join(serviceRoot, 'config', 'service', 'operations.json');
|
|
652
919
|
|
|
653
920
|
if (!fs.existsSync(operationsPath)) {
|
|
654
|
-
throw new Error(`Operations file not found for service: ${serviceName}
|
|
921
|
+
throw new Error(`[CookbookTestRunner] Operations file not found for service: ${serviceName} `
|
|
922
|
+
+ `(expected config/service/operations.json) - Expected the declaration every v3 service ships. `
|
|
923
|
+
+ `Fix: create ${operationsPath} (api/docs/biz/30-operations/schema-v3.md § File shape).`);
|
|
655
924
|
}
|
|
656
925
|
|
|
657
926
|
const operations = JSON.parse(fs.readFileSync(operationsPath, 'utf8'));
|
|
658
927
|
const operation = operations.operations && operations.operations[operationName];
|
|
659
928
|
|
|
660
929
|
if (!operation) {
|
|
661
|
-
throw new Error(`Operation not found: ${operationName}`
|
|
930
|
+
throw new Error(`[CookbookTestRunner] Operation not found: ${operationName} - Expected a key of `
|
|
931
|
+
+ `that name under "operations" in ${operationsPath}. Fix: declare the operation there, or `
|
|
932
|
+
+ 'correct the operation name the cookbook step asks for.');
|
|
662
933
|
}
|
|
663
934
|
|
|
664
935
|
if (!operation.handler) {
|
|
665
936
|
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.`);
|
|
666
937
|
}
|
|
667
938
|
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
939
|
+
parseHandlerRef(operation.handler);
|
|
940
|
+
const { modulePath, exportName, handler } = resolveHandlerModule({
|
|
941
|
+
serviceRoot,
|
|
942
|
+
handlerRef: operation.handler
|
|
943
|
+
});
|
|
944
|
+
|
|
673
945
|
return {
|
|
674
946
|
modulePath,
|
|
675
947
|
exportName,
|
|
676
|
-
|
|
948
|
+
handler
|
|
677
949
|
};
|
|
678
950
|
}
|
|
679
951
|
|
|
680
952
|
/**
|
|
681
|
-
* Resolve filesystem root for a service
|
|
682
|
-
*
|
|
683
|
-
*
|
|
953
|
+
* Resolve the filesystem root for a `step.service`.
|
|
954
|
+
*
|
|
955
|
+
* `_servicePathByName` is the ONLY source: the constructor registers the
|
|
956
|
+
* primary service under the name it was constructed with, and
|
|
957
|
+
* `setServicePath` adds every other one. A name that is in neither is an
|
|
958
|
+
* error, never a guess.
|
|
959
|
+
*
|
|
960
|
+
* Until 2026-09-08 an unknown name fell back to `this.servicePath` on the
|
|
961
|
+
* comment "cookbooks may reference this.serviceName under a different
|
|
962
|
+
* alias". No such alias exists in the data — measured across the eight live
|
|
963
|
+
* business repositories, all 37 cookbook steps name their own
|
|
964
|
+
* `package.json` name, which is exactly what `scripts/run-pre-validation.js`
|
|
965
|
+
* passes as `serviceName`. What the fallback actually absorbed was a typo:
|
|
966
|
+
* `"servcie": "biz-hello"` ran biz-hello's handler and reported PASS, so
|
|
967
|
+
* Tier-1 validated a cookbook the production WorkflowOrchestrator would
|
|
968
|
+
* route somewhere else entirely. That is a fallback masking bad input
|
|
969
|
+
* (architecture-principles.md §3), resolved late instead of refused at the
|
|
970
|
+
* gate (§4).
|
|
684
971
|
*/
|
|
685
972
|
_resolveServiceRoot(serviceName) {
|
|
686
973
|
if (this._servicePathByName.has(serviceName)) {
|
|
687
974
|
return this._servicePathByName.get(serviceName);
|
|
688
975
|
}
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
976
|
+
|
|
977
|
+
const registered = [...this._servicePathByName.keys()];
|
|
978
|
+
const known = registered.length > 0 ? registered.join(', ') : '(none registered)';
|
|
979
|
+
throw new Error(`[CookbookTestRunner] Unknown step.service "${serviceName}" - Expected one of: `
|
|
980
|
+
+ `${known}. Fix: fix the cookbook's step.service, or register the service `
|
|
981
|
+
+ 'via setServicePath(name, path).');
|
|
694
982
|
}
|
|
695
983
|
|
|
696
984
|
/**
|
|
@@ -709,7 +997,8 @@ class CookbookTestRunner {
|
|
|
709
997
|
*/
|
|
710
998
|
loadCookbook(cookbookPath) {
|
|
711
999
|
if (!fs.existsSync(cookbookPath)) {
|
|
712
|
-
throw new Error(`Cookbook not found: ${cookbookPath}`
|
|
1000
|
+
throw new Error(`[CookbookTestRunner] Cookbook not found: ${cookbookPath} - Expected a readable `
|
|
1001
|
+
+ 'cookbook file at that path. Fix: create it, or correct the path the caller passed.');
|
|
713
1002
|
}
|
|
714
1003
|
|
|
715
1004
|
const content = fs.readFileSync(cookbookPath, 'utf8');
|
|
@@ -722,20 +1011,16 @@ class CookbookTestRunner {
|
|
|
722
1011
|
}
|
|
723
1012
|
}
|
|
724
1013
|
|
|
725
|
-
/**
|
|
726
|
-
* Normalize steps to array format
|
|
727
|
-
* Supports V1 (steps as array) and V2 (steps as object keyed by step_id)
|
|
728
|
-
*/
|
|
729
|
-
normalizeSteps(steps) {
|
|
730
|
-
// The rule lives in utils/cookbookFormat — the same owner
|
|
731
|
-
// CookbookTestUtils.validateCookbook now reads, so the two entry points
|
|
732
|
-
// cannot disagree about what a cookbook's steps are.
|
|
733
|
-
return normalizeCookbookSteps(steps);
|
|
734
|
-
}
|
|
735
|
-
|
|
736
1014
|
/**
|
|
737
1015
|
* Validate cookbook format
|
|
738
|
-
*
|
|
1016
|
+
*
|
|
1017
|
+
* `steps` is an ARRAY of step objects — the only shape the format allows
|
|
1018
|
+
* (owner confirmation `cookbook-steps-shape` 001 of 2026-09-05). The runner
|
|
1019
|
+
* used to accept an object keyed by `step_id` and convert it, which made this
|
|
1020
|
+
* gate pass cookbooks the production WorkflowOrchestrator threw on. The shape
|
|
1021
|
+
* rule itself lives in `utils/cookbookFormat.readCookbookSteps`, the same
|
|
1022
|
+
* owner `CookbookTestUtils.validateCookbook` reads, so the two entry points
|
|
1023
|
+
* cannot disagree about what a cookbook's steps are.
|
|
739
1024
|
*
|
|
740
1025
|
* The format-version check is FIRST and unconditional. Until 2026-08-27 it
|
|
741
1026
|
* lived only in `CookbookTestUtils.validateCookbook`, reachable exclusively
|
|
@@ -756,20 +1041,31 @@ class CookbookTestRunner {
|
|
|
756
1041
|
throw new Error(`[CookbookTestRunner] ${versionProblem}`);
|
|
757
1042
|
}
|
|
758
1043
|
|
|
759
|
-
if (
|
|
760
|
-
throw new Error('Cookbook
|
|
1044
|
+
if (cookbook.steps === undefined || cookbook.steps === null) {
|
|
1045
|
+
throw new Error('[CookbookTestRunner] Cookbook has no steps - Expected "steps": [ … ] with at '
|
|
1046
|
+
+ 'least one step object carrying "step_id". '
|
|
1047
|
+
+ 'Fix: add the steps array (api/docs/biz/40-cookbooks/format.md § Required fields).');
|
|
761
1048
|
}
|
|
762
1049
|
|
|
763
|
-
//
|
|
764
|
-
const stepsArray =
|
|
1050
|
+
// Throws unless `steps` is the array the format requires.
|
|
1051
|
+
const stepsArray = readCookbookSteps(cookbook.steps);
|
|
765
1052
|
|
|
766
1053
|
if (stepsArray.length === 0) {
|
|
767
|
-
throw new Error('Cookbook must have at least one step'
|
|
1054
|
+
throw new Error('[CookbookTestRunner] Cookbook must have at least one step - Expected a non-empty '
|
|
1055
|
+
+ 'steps array. Fix: add at least one step (api/docs/biz/40-cookbooks/format.md § Required fields).');
|
|
768
1056
|
}
|
|
769
1057
|
|
|
770
1058
|
for (const step of stepsArray) {
|
|
771
|
-
if (!step.service)
|
|
772
|
-
|
|
1059
|
+
if (!step.service) {
|
|
1060
|
+
throw new Error(`[CookbookTestRunner] Step ${step.step_id || 'unknown'} must have service - `
|
|
1061
|
+
+ 'Expected the name of the service that runs the step. Fix: add "service" to that step '
|
|
1062
|
+
+ '(api/docs/biz/40-cookbooks/format.md § Step definition).');
|
|
1063
|
+
}
|
|
1064
|
+
if (!step.operation) {
|
|
1065
|
+
throw new Error(`[CookbookTestRunner] Step ${step.step_id || 'unknown'} must have operation - `
|
|
1066
|
+
+ 'Expected the operation that service exposes. Fix: add "operation" to that step '
|
|
1067
|
+
+ '(api/docs/biz/40-cookbooks/format.md § Step definition).');
|
|
1068
|
+
}
|
|
773
1069
|
}
|
|
774
1070
|
|
|
775
1071
|
return true;
|
|
@@ -777,10 +1073,9 @@ class CookbookTestRunner {
|
|
|
777
1073
|
|
|
778
1074
|
/**
|
|
779
1075
|
* Check if cookbook has expect clauses
|
|
780
|
-
* Supports both V1 (array) and V2 (object) step formats
|
|
781
1076
|
*/
|
|
782
1077
|
hasExpectClauses(cookbook) {
|
|
783
|
-
const stepsArray =
|
|
1078
|
+
const stepsArray = readCookbookSteps(cookbook.steps);
|
|
784
1079
|
return stepsArray.some(step => step.expect !== undefined);
|
|
785
1080
|
}
|
|
786
1081
|
|
|
@@ -795,12 +1090,35 @@ class CookbookTestRunner {
|
|
|
795
1090
|
* Reset results
|
|
796
1091
|
*/
|
|
797
1092
|
resetResults() {
|
|
798
|
-
this.results =
|
|
1093
|
+
this.results = this._emptyResults();
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
/**
|
|
1097
|
+
* The aggregate a run starts from — one definition, read by the constructor
|
|
1098
|
+
* and by `resetResults()`. Two copies is how `cookbooks` would end up counted
|
|
1099
|
+
* in one of them and not the other.
|
|
1100
|
+
*
|
|
1101
|
+
* `total`/`passed`/`failed` count STEPS and always did. `cookbooks` counts
|
|
1102
|
+
* cookbooks, and it exists because the boot line said "Cookbook tests: X/Y"
|
|
1103
|
+
* about step numbers: three cookbooks of one step each read as "3/3", so the
|
|
1104
|
+
* day one of them gained a second step the number rose without a cookbook
|
|
1105
|
+
* being added (measured by BIZ-converter 2026-08-27). Both facts are real and
|
|
1106
|
+
* they answer different questions — how much was exercised, and how much
|
|
1107
|
+
* passed — so both are counted and the line names which is which
|
|
1108
|
+
* (`automation-gates.md` §5: a label that disagrees with its measurement is a
|
|
1109
|
+
* false guarantee, not untidiness).
|
|
1110
|
+
*
|
|
1111
|
+
* @returns {{total: number, passed: number, failed: number, duration: number,
|
|
1112
|
+
* steps: Array, cookbooks: {total: number, passed: number, failed: number}}}
|
|
1113
|
+
*/
|
|
1114
|
+
_emptyResults() {
|
|
1115
|
+
return {
|
|
799
1116
|
total: 0,
|
|
800
1117
|
passed: 0,
|
|
801
1118
|
failed: 0,
|
|
802
1119
|
duration: 0,
|
|
803
|
-
steps: []
|
|
1120
|
+
steps: [],
|
|
1121
|
+
cookbooks: { total: 0, passed: 0, failed: 0 }
|
|
804
1122
|
};
|
|
805
1123
|
}
|
|
806
1124
|
}
|