@onlineapps/conn-orch-validator 12.2.0 → 13.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 +597 -0
- package/README.md +126 -19
- package/manifests/biz-service.manifest.json +15 -2
- package/manifests/library.manifest.json +4 -4
- package/package.json +11 -3
- package/src/CookbookTestRunner.js +275 -105
- package/src/CookbookTestUtils.js +79 -68
- package/src/ServiceReadinessValidator.js +42 -52
- package/src/ValidationOrchestrator.js +65 -44
- package/src/cli/biz-ci-gate.js +2 -2
- package/src/cli/oa-sync-template.js +97 -47
- package/src/cli/oa-validate.js +44 -10
- package/src/helpers/README.md +6 -6
- package/src/helpers/createServiceReadinessTests.js +87 -33
- package/src/index.js +14 -5
- package/src/lint/scripts/lintScripts.js +11 -4
- package/src/manifest/checks/libraryContext.js +6 -3
- package/src/manifest/checks/libraryDocs.js +174 -4
- package/src/manifest/checks/libraryTests.js +200 -19
- package/src/manifest/checks/scriptHeaders.js +6 -13
- package/src/manifest/checks/serviceConfig.js +36 -16
- package/src/manifest/checks/serviceConnectors.js +180 -2
- package/src/manifest/checks/serviceDb.js +0 -3
- package/src/manifest/checks/serviceScripts.js +3 -20
- package/src/manifest/runManifest.js +90 -13
- package/src/manifest/workspaceRoot.js +133 -4
- package/src/mocks/MockMQClient.js +2 -2
- package/src/sync/docsRegion.js +2 -2
- package/src/sync/readmeFile.js +30 -0
- package/src/sync/readmeLocation.js +2 -12
- package/src/sync/readmePointer.js +10 -4
- package/src/sync/serviceTemplate.js +9 -11
- package/src/sync/sharedEnv.js +59 -3
- package/src/sync/uniformFiles.js +81 -8
- package/src/utils/bizCiGateContract.js +2 -2
- package/src/utils/connectorContract.js +54 -2
- package/src/utils/cookbookFormat.js +25 -115
- package/src/utils/dbAccountGrants.js +5 -3
- package/src/utils/deployContract.js +153 -28
- package/src/utils/envContract.js +2 -2
- package/src/utils/handlerRef.js +8 -10
- package/src/utils/integrationRun.js +1 -1
- package/src/utils/operationsDocumentRules.js +242 -0
- package/src/utils/operationsRules.js +157 -0
- package/src/utils/resolveHeaders.js +12 -1
- package/src/utils/setupDatabase.js +1 -1
- package/src/utils/stepFailure.js +3 -3
- package/src/utils/stepReferences.js +28 -87
- package/src/utils/throwawaySchema.js +1 -1
- package/src/utils/yamlTopLevel.js +105 -0
- package/src/validators/ServiceStructureValidator.js +67 -152
- package/templates/business-service/README.md +3 -2
- package/templates/business-service/config/env-templates/shared.env +1 -0
- package/templates/business-service/src/config/index.js +15 -0
- package/TESTING_STRATEGY.md +0 -92
- package/jest.config.js +0 -37
|
@@ -4,7 +4,7 @@ const fs = require('fs');
|
|
|
4
4
|
const path = require('path');
|
|
5
5
|
const crypto = require('crypto');
|
|
6
6
|
const { resolveHeaders } = require('./utils/resolveHeaders');
|
|
7
|
-
const {
|
|
7
|
+
const { readCookbookSteps } = require('./utils/cookbookFormat');
|
|
8
8
|
const { getTestNamespace, assertWorkspaceId } = require('./utils/testNamespace');
|
|
9
9
|
const {
|
|
10
10
|
describeStepFailure, describeStepIdentity, KIND_STEP, KIND_COOKBOOK_LOAD_FAILURE
|
|
@@ -13,6 +13,40 @@ const { parseHandlerRef, resolveHandlerModule } = require('./utils/handlerRef');
|
|
|
13
13
|
const { createRunContext, resolveStepInput, recordStepOutcome, checkStepInputExpressions }
|
|
14
14
|
= require('./utils/stepReferences');
|
|
15
15
|
const { assertLogger } = require('@onlineapps/logger-contract');
|
|
16
|
+
const { OperationContext } = require('@onlineapps/handler-contract');
|
|
17
|
+
const { validateCookbook: validateCookbookFormat } = require('@onlineapps/cookbook-core');
|
|
18
|
+
const { ErrorClassifier } = require('@onlineapps/error-handler-core');
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* EPILOGUE OF A RECIPE — the parked `compensate`, woken by owner decision
|
|
22
|
+
* api/docs/governance/confirmations/meditest-manual-trigger.md 009.
|
|
23
|
+
*
|
|
24
|
+
* A recipe may declare `global_error_handler: { strategy: 'compensate',
|
|
25
|
+
* compensationStep: '<last step>' }`. The runner follows the engine
|
|
26
|
+
* (`@onlineapps/conn-orch-orchestrator` `WorkflowOrchestrator`, d.934): the
|
|
27
|
+
* ordinary run never reaches that step; a run that ends unsuccessfully stops at
|
|
28
|
+
* the failed step and runs the epilogue ONCE, with the failure in scope; a
|
|
29
|
+
* failed epilogue starts no second one. The SHAPE of the declaration is
|
|
30
|
+
* `@onlineapps/cookbook-core`'s (`validateCookbook`, which
|
|
31
|
+
* `CookbookTestRunner.validateCookbook` runs first); the runner reads
|
|
32
|
+
* `compensationStep` and nothing else.
|
|
33
|
+
*
|
|
34
|
+
* Where the failure sits in scope — the root and key the engine writes it to,
|
|
35
|
+
* `{{global_error_handler._failure.<field>}}`.
|
|
36
|
+
*/
|
|
37
|
+
const EPILOGUE_FAILURE_ROOT = 'global_error_handler';
|
|
38
|
+
const EPILOGUE_FAILURE_KEY = '_failure';
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The handler's own thrown error behind a step result, kept out of the report.
|
|
42
|
+
*
|
|
43
|
+
* The step record carries `error.code` as `code || name` — a label for the
|
|
44
|
+
* reader of the log. The epilogue's `_failure.error_code` must be what the
|
|
45
|
+
* engine puts there, the thrown `code` or `null`, and `details` likewise; so
|
|
46
|
+
* the raw values are held here, beside the record rather than in it, and the
|
|
47
|
+
* report's shape does not change.
|
|
48
|
+
*/
|
|
49
|
+
const THROWN_ERRORS = new WeakMap();
|
|
16
50
|
|
|
17
51
|
/**
|
|
18
52
|
* The step's stopwatch, spelled out. A single total hid the fact that
|
|
@@ -92,19 +126,60 @@ function resolveOutputField(container, fieldPath) {
|
|
|
92
126
|
* module via `require()` and calls `handler(input, ctx)` directly in-process
|
|
93
127
|
* (no HTTP). Operations without a `handler` field are an explicit error.
|
|
94
128
|
*
|
|
95
|
-
* The handler dispatch builds a minimal real ctx:
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
129
|
+
* The handler dispatch builds a minimal real ctx: an instance of the same
|
|
130
|
+
* `OperationContext` class production hands a handler, imported from
|
|
131
|
+
* `@onlineapps/handler-contract` (never from the wrapper, which sits a layer
|
|
132
|
+
* above — architecture-principles.md §7; conf handler-contract 001). So the
|
|
133
|
+
* frozen instance and its prototype helpers are the class's, not a mirror of
|
|
134
|
+
* them. Its shape is NOT written out here, meaning which slots the instance
|
|
135
|
+
* is filled with. A second enumeration is a copy, and a copy drifts silently
|
|
136
|
+
* (`.claude/rules/doc-code-binding.md` §2): this one named eleven slots while
|
|
137
|
+
* `_dispatchViaHandler` below built seventeen. Three places hold the slot set,
|
|
138
|
+
* and each of them is checked — the fields passed to the constructor in
|
|
139
|
+
* `_dispatchViaHandler`, the slot set asserted by value in
|
|
140
|
+
* tests/unit/CookbookTestRunner.ctxShape.test.js, and the contract node
|
|
141
|
+
* api/docs/biz/40-cookbooks/test-runner-flow.md.
|
|
142
|
+
*
|
|
143
|
+
* Connector slots stay null and the handler surfaces its own failure if
|
|
144
|
+
* something it genuinely needs is missing; a handler that needs DB access
|
|
145
|
+
* imports sequelize directly from its own src/config/database.js module
|
|
146
|
+
* (see api/docs/biz/60-templates/onboarding-checklist.md §9a).
|
|
105
147
|
*
|
|
106
148
|
* Supports unified cookbook format for both testing (with expect) and production (without expect).
|
|
107
149
|
*/
|
|
150
|
+
/**
|
|
151
|
+
* The recipe's epilogue step id, or `null` when it declares none.
|
|
152
|
+
*
|
|
153
|
+
* Only `compensationStep` is read: the declaration was validated by
|
|
154
|
+
* `@onlineapps/cookbook-core` before this runs.
|
|
155
|
+
*
|
|
156
|
+
* @param {Object} cookbook
|
|
157
|
+
* @returns {string|null}
|
|
158
|
+
*/
|
|
159
|
+
function declaredEpilogueStepId(cookbook) {
|
|
160
|
+
const handler = cookbook[EPILOGUE_FAILURE_ROOT];
|
|
161
|
+
return handler && typeof handler === 'object' && typeof handler.compensationStep === 'string'
|
|
162
|
+
? handler.compensationStep
|
|
163
|
+
: null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* What the report says about a failed epilogue: the handler's own `{ code,
|
|
168
|
+
* message }` when it threw, otherwise the reason the step record gives.
|
|
169
|
+
*
|
|
170
|
+
* @param {Object} result - the epilogue's step result
|
|
171
|
+
* @returns {{code?: string, message: string}}
|
|
172
|
+
*/
|
|
173
|
+
function describeEpilogueError(result) {
|
|
174
|
+
if (result.error && typeof result.error === 'object') {
|
|
175
|
+
return { code: result.error.code, message: result.error.message };
|
|
176
|
+
}
|
|
177
|
+
if (typeof result.error === 'string') {
|
|
178
|
+
return { message: result.error };
|
|
179
|
+
}
|
|
180
|
+
return { message: result.validationErrors.join('; ') };
|
|
181
|
+
}
|
|
182
|
+
|
|
108
183
|
class CookbookTestRunner {
|
|
109
184
|
constructor(options = {}) {
|
|
110
185
|
if (!options.serviceName) {
|
|
@@ -115,7 +190,7 @@ class CookbookTestRunner {
|
|
|
115
190
|
// calls info() and error() throughout a run. A logger without them therefore
|
|
116
191
|
// constructed fine and died on the first log line: delayed validation, which
|
|
117
192
|
// architecture-principles.md §4 forbids. Owner confirmation:
|
|
118
|
-
// docs/governance/confirmations/connector-logger-contract.md 001.
|
|
193
|
+
// api/docs/governance/confirmations/connector-logger-contract.md 001.
|
|
119
194
|
assertLogger('CookbookTestRunner', options.logger, 'the runner writes where the service writes');
|
|
120
195
|
|
|
121
196
|
// A RETIRED option is refused, never ignored: a caller that still writes it
|
|
@@ -142,6 +217,12 @@ class CookbookTestRunner {
|
|
|
142
217
|
this._servicePathByName.set(this.serviceName, this.servicePath);
|
|
143
218
|
}
|
|
144
219
|
|
|
220
|
+
// The engine's classifier, not a copy of it: the engine asks the injected
|
|
221
|
+
// `@onlineapps/conn-infra-error-handler` (`classifyError`), which delegates
|
|
222
|
+
// to this class of `@onlineapps/error-handler-core`. Stateless — one per
|
|
223
|
+
// runner. Read by `_runEpilogue` for `_failure.error_type`.
|
|
224
|
+
this.errorClassifier = new ErrorClassifier();
|
|
225
|
+
|
|
145
226
|
// Test results
|
|
146
227
|
this.results = this._emptyResults();
|
|
147
228
|
}
|
|
@@ -171,12 +252,13 @@ class CookbookTestRunner {
|
|
|
171
252
|
try {
|
|
172
253
|
this.validateCookbook(cookbookData);
|
|
173
254
|
|
|
174
|
-
// What the recipe WRITES, before anything of it runs
|
|
175
|
-
//
|
|
176
|
-
//
|
|
177
|
-
//
|
|
178
|
-
//
|
|
179
|
-
// themselves are resolved per step, later and against
|
|
255
|
+
// What the recipe WRITES, before anything of it runs, that only Tier-1
|
|
256
|
+
// must refuse: a helper call. Production evaluates it, this runner has no
|
|
257
|
+
// helper registry, so letting it through as literal text would make a
|
|
258
|
+
// green Tier-1 say more than production will (utils/stepReferences.js).
|
|
259
|
+
// A `{{steps.<id>}}` naming no step is cookbook-core's refusal, above.
|
|
260
|
+
// The step values themselves are resolved per step, later and against
|
|
261
|
+
// what ran.
|
|
180
262
|
const referenceProblem = await checkStepInputExpressions(readCookbookSteps(cookbookData.steps));
|
|
181
263
|
if (referenceProblem !== null) {
|
|
182
264
|
throw new Error(`[CookbookTestRunner] ${referenceProblem}`);
|
|
@@ -198,11 +280,21 @@ class CookbookTestRunner {
|
|
|
198
280
|
this.logger.info(`Mode: ${testConfig.mode || 'production'}, Test: ${isTestMode}`);
|
|
199
281
|
|
|
200
282
|
// Already proved to be the array shape by validateCookbook above.
|
|
201
|
-
const
|
|
283
|
+
const allSteps = readCookbookSteps(cookbookData.steps);
|
|
284
|
+
|
|
285
|
+
// The epilogue step, when the recipe declares one. cookbook-core has proved
|
|
286
|
+
// it is the LAST top-level step, so the ordinary run is every step before
|
|
287
|
+
// it — the runner's form of the engine's `nextStep === compensationStep`
|
|
288
|
+
// stop.
|
|
289
|
+
const epilogueStepId = declaredEpilogueStepId(cookbookData);
|
|
290
|
+
const stepsArray = epilogueStepId === null
|
|
291
|
+
? allSteps
|
|
292
|
+
: allSteps.filter((step) => step.step_id !== epilogueStepId);
|
|
202
293
|
|
|
203
294
|
// Execute steps
|
|
204
295
|
const cookbookName = cookbookData.description || 'Unnamed';
|
|
205
296
|
const stepResults = [];
|
|
297
|
+
let failedStep = null;
|
|
206
298
|
// What the steps of THIS cookbook have produced so far — the scope a
|
|
207
299
|
// `{{steps.<step_id>.output.…}}` reference resolves against
|
|
208
300
|
// (src/utils/stepReferences.js). It is built here, per cookbook, because
|
|
@@ -222,6 +314,14 @@ class CookbookTestRunner {
|
|
|
222
314
|
stepResult.cookbook = cookbookName;
|
|
223
315
|
stepResults.push(stepResult);
|
|
224
316
|
|
|
317
|
+
// A recipe with an epilogue ends at its first failed step, as the engine
|
|
318
|
+
// ends the run there; the epilogue then answers that failure. Without the
|
|
319
|
+
// declaration nothing changes: only a failed step WITH expect stops the run.
|
|
320
|
+
if (!stepResult.passed && epilogueStepId !== null) {
|
|
321
|
+
failedStep = { step, result: stepResult };
|
|
322
|
+
break;
|
|
323
|
+
}
|
|
324
|
+
|
|
225
325
|
// If step failed and has expect, fail immediately
|
|
226
326
|
if (!stepResult.passed && step.expect) {
|
|
227
327
|
break;
|
|
@@ -243,6 +343,19 @@ class CookbookTestRunner {
|
|
|
243
343
|
steps: stepResults
|
|
244
344
|
};
|
|
245
345
|
|
|
346
|
+
// The epilogue is reported beside the steps, never among them: it is not a
|
|
347
|
+
// step of the ordinary run, so it moves none of the counts above.
|
|
348
|
+
if (failedStep !== null) {
|
|
349
|
+
results.epilogue = await this._runEpilogue({
|
|
350
|
+
epilogueStep: allSteps.find((step) => step.step_id === epilogueStepId),
|
|
351
|
+
failedStep,
|
|
352
|
+
cookbookData,
|
|
353
|
+
testConfig,
|
|
354
|
+
runContext,
|
|
355
|
+
stepIndex: allSteps.length - 1
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
|
|
246
359
|
// Update aggregate results
|
|
247
360
|
this.results.total += results.total;
|
|
248
361
|
this.results.passed += results.passedCount;
|
|
@@ -257,6 +370,58 @@ class CookbookTestRunner {
|
|
|
257
370
|
return results;
|
|
258
371
|
}
|
|
259
372
|
|
|
373
|
+
/**
|
|
374
|
+
* Run the recipe's epilogue once, after the step that ended the run failed.
|
|
375
|
+
*
|
|
376
|
+
* The failure goes into scope under `global_error_handler._failure` with all
|
|
377
|
+
* six keys of the engine's record (`@onlineapps/cookbook-core` `RunFailure`),
|
|
378
|
+
* `null` where the runner has no value — never an invented one:
|
|
379
|
+
* - `error_type` is what the engine's classifier answers for the thrown
|
|
380
|
+
* error (`@onlineapps/error-handler-core` `ErrorClassifier.classify`, the
|
|
381
|
+
* core behind the engine's `errorHandler.classifyError`) — always a
|
|
382
|
+
* string, as `RunFailure` requires;
|
|
383
|
+
* - `error_code` and `details` are the thrown error's own, or `null`.
|
|
384
|
+
*
|
|
385
|
+
* A step whose handler succeeded but whose `expect` was not met threw
|
|
386
|
+
* nothing. Tier-1 `expect` has no counterpart in the engine, so there is no
|
|
387
|
+
* engine record to mirror: `error_code` and `details` are `null`, and
|
|
388
|
+
* `error_type` is the classifier's answer for no error (`classify(null)`,
|
|
389
|
+
* `UNKNOWN`) — asked, not written here.
|
|
390
|
+
*
|
|
391
|
+
* @private
|
|
392
|
+
* @returns {Promise<{step_id: string, status: 'passed'|'failed', output?: *, error?: Object}>}
|
|
393
|
+
*/
|
|
394
|
+
async _runEpilogue({ epilogueStep, failedStep, cookbookData, testConfig, runContext, stepIndex }) {
|
|
395
|
+
const thrown = THROWN_ERRORS.get(failedStep.result);
|
|
396
|
+
const failure = {
|
|
397
|
+
step_id: failedStep.step.step_id,
|
|
398
|
+
service: failedStep.step.service,
|
|
399
|
+
operation: typeof failedStep.step.operation === 'string' ? failedStep.step.operation : null,
|
|
400
|
+
error_type: this.errorClassifier.classify(thrown === undefined ? null : thrown),
|
|
401
|
+
error_code: thrown && thrown.code !== undefined && thrown.code !== null ? thrown.code : null,
|
|
402
|
+
details: thrown && thrown.details !== undefined && thrown.details !== null ? thrown.details : null
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
const epilogueContext = {
|
|
406
|
+
...runContext,
|
|
407
|
+
[EPILOGUE_FAILURE_ROOT]: { ...cookbookData[EPILOGUE_FAILURE_ROOT], [EPILOGUE_FAILURE_KEY]: failure }
|
|
408
|
+
};
|
|
409
|
+
|
|
410
|
+
const result = await this.executeStep(epilogueStep, testConfig, stepIndex, cookbookData.defaults, epilogueContext);
|
|
411
|
+
|
|
412
|
+
if (result.passed) {
|
|
413
|
+
this.logger.info(`[CookbookTestRunner] Run ended unsuccessfully at step '${failure.step_id}'; `
|
|
414
|
+
+ `epilogue step '${epilogueStep.step_id}' after step '${failure.step_id}' PASSED`);
|
|
415
|
+
return { step_id: epilogueStep.step_id, status: 'passed', output: result.actual };
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const error = describeEpilogueError(result);
|
|
419
|
+
this.logger.error(`[CookbookTestRunner] Run ended unsuccessfully at step '${failure.step_id}', and `
|
|
420
|
+
+ `epilogue step '${epilogueStep.step_id}' after step '${failure.step_id}' FAILED too — ${error.message}. `
|
|
421
|
+
+ 'No second epilogue runs.');
|
|
422
|
+
return { step_id: epilogueStep.step_id, status: 'failed', error };
|
|
423
|
+
}
|
|
424
|
+
|
|
260
425
|
/**
|
|
261
426
|
* Run all cookbooks in a directory — or the ones running one operation.
|
|
262
427
|
*
|
|
@@ -447,8 +612,15 @@ class CookbookTestRunner {
|
|
|
447
612
|
passed: false,
|
|
448
613
|
// Three separate numbers, because they answer different questions.
|
|
449
614
|
// `duration` is the wall clock of the whole step. `setupDurationMs` is
|
|
450
|
-
// what the RUNNER spent getting to the handler
|
|
451
|
-
//
|
|
615
|
+
// what the RUNNER spent getting to the handler, and that is two costs of
|
|
616
|
+
// different shape: `resolveOperation()` below re-reads
|
|
617
|
+
// `config/service/operations.json` from disk on EVERY step (measured:
|
|
618
|
+
// five steps, five reads), while `require()` of the handler module is
|
|
619
|
+
// served from Node's module registry after the first step that asks for
|
|
620
|
+
// it. Only the second one is a one-off per process — until d.795b this
|
|
621
|
+
// comment said both were, which made the per-step read invisible to
|
|
622
|
+
// anybody reading the number. This is the Tier-1 runner at boot, so the
|
|
623
|
+
// repeated read is per cookbook step, never on a request path. Only
|
|
452
624
|
// `handlerDurationMs` is the handler's own runtime, and only that one is
|
|
453
625
|
// what `expect.duration.max` asserts about (see validateExpectations).
|
|
454
626
|
duration: 0,
|
|
@@ -467,8 +639,8 @@ class CookbookTestRunner {
|
|
|
467
639
|
// The namespace is a safety boundary, so it is resolved before anything
|
|
468
640
|
// else happens — before the operation is even looked up. utils/testNamespace
|
|
469
641
|
// is the single owner of that decision: it reads the TESTING_* env contract
|
|
470
|
-
// (docs/biz/40-cookbooks/test-env-vars.md +
|
|
471
|
-
// docs/biz/60-templates/env-conventions.md — the TESTING_ prefix marks
|
|
642
|
+
// (api/docs/biz/40-cookbooks/test-env-vars.md +
|
|
643
|
+
// api/docs/biz/60-templates/env-conventions.md — the TESTING_ prefix marks
|
|
472
644
|
// values that legitimately ship to production, because this runner fires at
|
|
473
645
|
// every service startup, prod included) AND it returns a namespace only for
|
|
474
646
|
// the non-production environment classes — 96 CI, 97 TESTING, 98 DEVEL,
|
|
@@ -599,22 +771,62 @@ class CookbookTestRunner {
|
|
|
599
771
|
return envWorkspaceId;
|
|
600
772
|
}
|
|
601
773
|
|
|
774
|
+
/**
|
|
775
|
+
* The per-step limit, read from the ONE place a recipe may set it.
|
|
776
|
+
*
|
|
777
|
+
* The cookbook's `test.timeout` overrides the runner default
|
|
778
|
+
* (`api/docs/biz/40-cookbooks/testing.md` § The `test` block): absent means the
|
|
779
|
+
* constructor's `timeout`, which the constructor has already proved positive.
|
|
780
|
+
* Present, it must be a positive finite number of milliseconds and is refused
|
|
781
|
+
* by name otherwise. Until d.1001 this was `testConfig.timeout || this.timeout`,
|
|
782
|
+
* which turned a written `0`, `-1` or `NaN` silently into the runner default and
|
|
783
|
+
* raced the handler against a string `"5"` (`architecture-principles.md` §3).
|
|
784
|
+
*
|
|
785
|
+
* @param {Object} testConfig - the cookbook's `test` block
|
|
786
|
+
* @returns {number} milliseconds
|
|
787
|
+
*/
|
|
788
|
+
_stepTimeout(testConfig) {
|
|
789
|
+
if (testConfig.timeout === undefined) {
|
|
790
|
+
return this.timeout;
|
|
791
|
+
}
|
|
792
|
+
const { timeout } = testConfig;
|
|
793
|
+
if (typeof timeout !== 'number' || !Number.isFinite(timeout) || timeout <= 0) {
|
|
794
|
+
const shown = typeof timeout === 'string' ? JSON.stringify(timeout) : String(timeout);
|
|
795
|
+
throw new Error(
|
|
796
|
+
`[CookbookTestRunner] test.timeout must be a positive number of milliseconds - got ${shown}. `
|
|
797
|
+
+ 'Expected: the per-step limit the recipe sets over the runner default, or no test.timeout '
|
|
798
|
+
+ 'at all to keep that default. '
|
|
799
|
+
+ 'Fix: write a positive number (e.g. "timeout": 5000) in the cookbook\'s test block, or drop the key.'
|
|
800
|
+
);
|
|
801
|
+
}
|
|
802
|
+
return timeout;
|
|
803
|
+
}
|
|
804
|
+
|
|
602
805
|
/**
|
|
603
806
|
* Handler dispatch: require handler module, build minimal real ctx, call
|
|
604
|
-
* handler(input, ctx).
|
|
605
|
-
*
|
|
606
|
-
*
|
|
607
|
-
* direct sequelize import per
|
|
807
|
+
* handler(input, ctx). The fields passed to `new OperationContext(...)` below
|
|
808
|
+
* are the one statement of the runner's slot set — see api/docs/biz/40-cookbooks/test-runner-flow.md and
|
|
809
|
+
* api/docs/biz/10-invocation/operation-context.md. All connector slots are
|
|
810
|
+
* null; handlers that need DB access use a direct sequelize import per
|
|
811
|
+
* api/docs/biz/60-templates/onboarding-checklist.md §9a.
|
|
608
812
|
*/
|
|
609
813
|
async _dispatchViaHandler({ step, input, spec, testConfig, validationTenantId, validationWorkspaceId, result, startTime }) {
|
|
610
|
-
const stepTimeout =
|
|
611
|
-
|
|
612
|
-
//
|
|
613
|
-
//
|
|
614
|
-
|
|
814
|
+
const stepTimeout = this._stepTimeout(testConfig);
|
|
815
|
+
// The run's identity is the RUNNER's to make, never the step's (d.1001). The
|
|
816
|
+
// step format (`api/docs/biz/40-cookbooks/format.md` § Task step, cookbook-core's
|
|
817
|
+
// `TaskStep`) declares neither `correlation_id` nor `workflow_id`, and nothing
|
|
818
|
+
// in production reads them off a step — so a step that still writes them is
|
|
819
|
+
// treated like any other undeclared key: not refused (`TaskStep` keeps
|
|
820
|
+
// `additionalProperties: true`, conf `parked-features` 001), and not read.
|
|
821
|
+
// Production ctx (from ContextBuilder) always carries workflow_id; one step
|
|
822
|
+
// here is one run, so the workflow is named by the step's own correlation id.
|
|
823
|
+
const correlationId = crypto.randomUUID();
|
|
824
|
+
const workflowId = correlationId;
|
|
825
|
+
// `person_id` is still read off the step — whether the runner carries an
|
|
826
|
+
// acting person, and from where, is conf `testing-tenant-identity` 001.
|
|
615
827
|
const personId = step.person_id != null ? step.person_id : null;
|
|
616
828
|
|
|
617
|
-
const ctx = {
|
|
829
|
+
const ctx = new OperationContext({
|
|
618
830
|
logger: this.logger,
|
|
619
831
|
tenant_id: validationTenantId,
|
|
620
832
|
workspace_id: validationWorkspaceId,
|
|
@@ -623,8 +835,7 @@ class CookbookTestRunner {
|
|
|
623
835
|
person_id: personId,
|
|
624
836
|
operation_name: step.operation,
|
|
625
837
|
// WHICH STEP is running, not merely which operation. Production
|
|
626
|
-
// `OperationContext` carries it among its
|
|
627
|
-
// (`@onlineapps/service-wrapper` src/OperationContext.js), and a handler
|
|
838
|
+
// `OperationContext` carries it among its fields, and a handler
|
|
628
839
|
// that stores a file cannot do without it:
|
|
629
840
|
// `@onlineapps/conn-orch-content-resolver` keys every stored object on
|
|
630
841
|
// `content/<workflow_id>/<step_id>` and refuses to invent either half.
|
|
@@ -655,7 +866,7 @@ class CookbookTestRunner {
|
|
|
655
866
|
'x-workspace-id': String(validationWorkspaceId),
|
|
656
867
|
...resolveHeaders(step.headers)
|
|
657
868
|
}
|
|
658
|
-
};
|
|
869
|
+
});
|
|
659
870
|
|
|
660
871
|
const request = {
|
|
661
872
|
mode: 'handler',
|
|
@@ -692,7 +903,11 @@ class CookbookTestRunner {
|
|
|
692
903
|
|
|
693
904
|
// Everything up to here was the runner getting ready: the namespace, the
|
|
694
905
|
// operations.json read, require.resolve and the require() of the handler
|
|
695
|
-
// module.
|
|
906
|
+
// module. None of it is the handler's own time — and only the module load
|
|
907
|
+
// is a one-off per process (Node's module registry serves it after the
|
|
908
|
+
// first step). The operations.json read happens on EVERY step, because
|
|
909
|
+
// `resolveOperation()` opens the file each time it is called; the same
|
|
910
|
+
// sentence one screen up said otherwise until d.795b/d.795c.
|
|
696
911
|
const handlerStart = Date.now();
|
|
697
912
|
result.setupDurationMs = handlerStart - startTime;
|
|
698
913
|
|
|
@@ -705,6 +920,7 @@ class CookbookTestRunner {
|
|
|
705
920
|
]);
|
|
706
921
|
} catch (error) {
|
|
707
922
|
handlerError = error;
|
|
923
|
+
THROWN_ERRORS.set(result, error);
|
|
708
924
|
} finally {
|
|
709
925
|
clearTimeout(timeoutId);
|
|
710
926
|
}
|
|
@@ -1091,34 +1307,32 @@ class CookbookTestRunner {
|
|
|
1091
1307
|
}
|
|
1092
1308
|
|
|
1093
1309
|
/**
|
|
1094
|
-
* Validate cookbook format
|
|
1310
|
+
* Validate cookbook format — ONE owner, then what only the runner knows.
|
|
1095
1311
|
*
|
|
1096
|
-
* `
|
|
1097
|
-
*
|
|
1098
|
-
*
|
|
1099
|
-
*
|
|
1100
|
-
*
|
|
1101
|
-
*
|
|
1102
|
-
*
|
|
1312
|
+
* FIRST, `@onlineapps/cookbook-core` `validateCookbook`: the same function the
|
|
1313
|
+
* receiving side throws on
|
|
1314
|
+
* (api/docs/governance/confirmations/cookbook-validation-placement.md 001).
|
|
1315
|
+
* It refuses a non-object, a missing or pre-2.1 `version`, a missing, empty
|
|
1316
|
+
* or non-array `steps` (the array is the only shape — owner confirmation
|
|
1317
|
+
* `cookbook-steps-shape` 001), a step without `step_id`/`type`/`service`/
|
|
1318
|
+
* `operation`, a step carrying `id` (d.983; the runner's own copy of that ban
|
|
1319
|
+
* went with d.985), a duplicate `step_id`, a `{{steps.<id>}}` or `depends_on`
|
|
1320
|
+
* naming no step, a positional step reference, a non-integer `workspace_id`,
|
|
1321
|
+
* and the epilogue rules (d.933, d.942). Until d.980 the runner held its own
|
|
1322
|
+
* copy of most of these, with its own messages, ahead of this call — one
|
|
1323
|
+
* concern on two rails (`.claude/rules/change-discipline.md` § One rail per
|
|
1324
|
+
* concern). The words a biz service reads in its Tier-1 output for these
|
|
1325
|
+
* cases are cookbook-core's.
|
|
1103
1326
|
*
|
|
1104
|
-
*
|
|
1105
|
-
*
|
|
1106
|
-
*
|
|
1107
|
-
*
|
|
1108
|
-
*
|
|
1109
|
-
*
|
|
1110
|
-
* A check a repo can decline is not a check (automation-gates §1.5).
|
|
1327
|
+
* THEN, what cookbook-core accepts and the runner must not:
|
|
1328
|
+
* `test.mockInfrastructure` — the retired option, refused by name.
|
|
1329
|
+
*
|
|
1330
|
+
* @param {*} cookbook - the parsed cookbook
|
|
1331
|
+
* @returns {true}
|
|
1332
|
+
* @throws {Error} the first refusal, in the words of its owner
|
|
1111
1333
|
*/
|
|
1112
1334
|
validateCookbook(cookbook) {
|
|
1113
|
-
|
|
1114
|
-
throw new Error('[CookbookTestRunner] Cookbook is not a JSON object - Expected an object with "version" and "steps". '
|
|
1115
|
-
+ 'Fix: check the cookbook file content (api/docs/biz/40-cookbooks/format.md § Basic structure).');
|
|
1116
|
-
}
|
|
1117
|
-
|
|
1118
|
-
const versionProblem = checkCookbookFormatVersion(cookbook.version);
|
|
1119
|
-
if (versionProblem) {
|
|
1120
|
-
throw new Error(`[CookbookTestRunner] ${versionProblem}`);
|
|
1121
|
-
}
|
|
1335
|
+
validateCookbookFormat(cookbook);
|
|
1122
1336
|
|
|
1123
1337
|
// The retired option, in the other place a caller could still write it. The
|
|
1124
1338
|
// cookbook schema admits additional properties inside `test`, so nothing
|
|
@@ -1136,50 +1350,6 @@ class CookbookTestRunner {
|
|
|
1136
1350
|
+ '(api/docs/biz/40-cookbooks/format.md § Optional top-level fields).');
|
|
1137
1351
|
}
|
|
1138
1352
|
|
|
1139
|
-
if (cookbook.steps === undefined || cookbook.steps === null) {
|
|
1140
|
-
throw new Error('[CookbookTestRunner] Cookbook has no steps - Expected "steps": [ … ] with at '
|
|
1141
|
-
+ 'least one step object carrying "step_id". '
|
|
1142
|
-
+ 'Fix: add the steps array (api/docs/biz/40-cookbooks/format.md § Required fields).');
|
|
1143
|
-
}
|
|
1144
|
-
|
|
1145
|
-
// Throws unless `steps` is the array the format requires.
|
|
1146
|
-
const stepsArray = readCookbookSteps(cookbook.steps);
|
|
1147
|
-
|
|
1148
|
-
if (stepsArray.length === 0) {
|
|
1149
|
-
throw new Error('[CookbookTestRunner] Cookbook must have at least one step - Expected a non-empty '
|
|
1150
|
-
+ 'steps array. Fix: add at least one step (api/docs/biz/40-cookbooks/format.md § Required fields).');
|
|
1151
|
-
}
|
|
1152
|
-
|
|
1153
|
-
// The three fields `@onlineapps/cookbook-core` 5.0.0 requires of a task step
|
|
1154
|
-
// beyond `type` (`schemas/cookbook.v2.schema.json`
|
|
1155
|
-
// definitions.TaskStep.required). `step_id` comes FIRST, because it is what
|
|
1156
|
-
// the other two messages name the step by: until d.516b this guard checked
|
|
1157
|
-
// only the last two and wrote `Step ${step.step_id || 'unknown'}`, so a
|
|
1158
|
-
// cookbook missing the field the whole format addresses steps by was
|
|
1159
|
-
// accepted here and its diagnostics said `Step unknown`. A stand-in name is
|
|
1160
|
-
// the fallback `.claude/rules/architecture-principles.md` §3 forbids; with
|
|
1161
|
-
// the field required, the case it stood in for cannot occur.
|
|
1162
|
-
stepsArray.forEach((step, index) => {
|
|
1163
|
-
const position = index + 1;
|
|
1164
|
-
if (typeof step.step_id !== 'string' || step.step_id.length === 0) {
|
|
1165
|
-
throw new Error(`[CookbookTestRunner] Step #${position} has no step_id - every step of a v2.1 `
|
|
1166
|
-
+ 'cookbook is addressed by its own step_id (@onlineapps/cookbook-core '
|
|
1167
|
-
+ 'schemas/cookbook.v2.schema.json definitions.TaskStep.required). '
|
|
1168
|
-
+ `Fix: add "step_id" to step #${position} `
|
|
1169
|
-
+ '(api/docs/biz/40-cookbooks/format.md § Required fields).');
|
|
1170
|
-
}
|
|
1171
|
-
if (!step.service) {
|
|
1172
|
-
throw new Error(`[CookbookTestRunner] Step ${step.step_id} must have service - `
|
|
1173
|
-
+ 'Expected the name of the service that runs the step. Fix: add "service" to that step '
|
|
1174
|
-
+ '(api/docs/biz/40-cookbooks/format.md § Step definition).');
|
|
1175
|
-
}
|
|
1176
|
-
if (!step.operation) {
|
|
1177
|
-
throw new Error(`[CookbookTestRunner] Step ${step.step_id} must have operation - `
|
|
1178
|
-
+ 'Expected the operation that service exposes. Fix: add "operation" to that step '
|
|
1179
|
-
+ '(api/docs/biz/40-cookbooks/format.md § Step definition).');
|
|
1180
|
-
}
|
|
1181
|
-
});
|
|
1182
|
-
|
|
1183
1353
|
return true;
|
|
1184
1354
|
}
|
|
1185
1355
|
|