@sanoma-ai/workflows 0.0.0-stage → 0.1.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/LICENSE +105 -0
- package/README.md +493 -2
- package/dist/approvals.d.ts +46 -0
- package/dist/approvals.js +111 -0
- package/dist/approvals.js.map +1 -0
- package/dist/ast.d.ts +57 -0
- package/dist/ast.js +81 -0
- package/dist/ast.js.map +1 -0
- package/dist/call.d.ts +33 -0
- package/dist/call.js +402 -0
- package/dist/call.js.map +1 -0
- package/dist/client.d.ts +167 -0
- package/dist/client.js +400 -0
- package/dist/client.js.map +1 -0
- package/dist/config.d.ts +126 -0
- package/dist/config.js +247 -0
- package/dist/config.js.map +1 -0
- package/dist/credentials.d.ts +30 -0
- package/dist/credentials.js +193 -0
- package/dist/credentials.js.map +1 -0
- package/dist/cucumber.d.ts +64 -0
- package/dist/cucumber.js +158 -0
- package/dist/cucumber.js.map +1 -0
- package/dist/datafile.d.ts +62 -0
- package/dist/datafile.js +326 -0
- package/dist/datafile.js.map +1 -0
- package/dist/db.d.ts +58 -0
- package/dist/db.js +179 -0
- package/dist/db.js.map +1 -0
- package/dist/define.d.ts +205 -0
- package/dist/define.js +103 -0
- package/dist/define.js.map +1 -0
- package/dist/describe.d.ts +135 -0
- package/dist/describe.js +127 -0
- package/dist/describe.js.map +1 -0
- package/dist/drift.d.ts +75 -0
- package/dist/drift.js +90 -0
- package/dist/drift.js.map +1 -0
- package/dist/env.d.ts +49 -0
- package/dist/env.js +83 -0
- package/dist/env.js.map +1 -0
- package/dist/errors.d.ts +88 -0
- package/dist/errors.js +148 -0
- package/dist/errors.js.map +1 -0
- package/dist/expectations.d.ts +26 -0
- package/dist/expectations.js +137 -0
- package/dist/expectations.js.map +1 -0
- package/dist/fake.d.ts +55 -0
- package/dist/fake.js +87 -0
- package/dist/fake.js.map +1 -0
- package/dist/fill.d.ts +9 -0
- package/dist/fill.js +17 -0
- package/dist/fill.js.map +1 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +25 -0
- package/dist/index.js.map +1 -0
- package/dist/ledger.d.ts +217 -0
- package/dist/ledger.js +251 -0
- package/dist/ledger.js.map +1 -0
- package/dist/lint.d.ts +32 -0
- package/dist/lint.js +168 -0
- package/dist/lint.js.map +1 -0
- package/dist/log.d.ts +24 -0
- package/dist/log.js +83 -0
- package/dist/log.js.map +1 -0
- package/dist/op.d.ts +257 -0
- package/dist/op.js +188 -0
- package/dist/op.js.map +1 -0
- package/dist/outline.d.ts +179 -0
- package/dist/outline.js +506 -0
- package/dist/outline.js.map +1 -0
- package/dist/paths.d.ts +11 -0
- package/dist/paths.js +30 -0
- package/dist/paths.js.map +1 -0
- package/dist/plugin.d.ts +28 -0
- package/dist/plugin.js +25 -0
- package/dist/plugin.js.map +1 -0
- package/dist/policy.d.ts +114 -0
- package/dist/policy.js +60 -0
- package/dist/policy.js.map +1 -0
- package/dist/resource.d.ts +212 -0
- package/dist/resource.js +285 -0
- package/dist/resource.js.map +1 -0
- package/dist/resources.d.ts +62 -0
- package/dist/resources.js +197 -0
- package/dist/resources.js.map +1 -0
- package/dist/run.d.ts +87 -0
- package/dist/run.js +2 -0
- package/dist/run.js.map +1 -0
- package/dist/sandbox.d.ts +33 -0
- package/dist/sandbox.js +123 -0
- package/dist/sandbox.js.map +1 -0
- package/dist/scenario.d.ts +212 -0
- package/dist/scenario.js +1079 -0
- package/dist/scenario.js.map +1 -0
- package/dist/scenarios.d.ts +55 -0
- package/dist/scenarios.js +71 -0
- package/dist/scenarios.js.map +1 -0
- package/dist/shared.d.ts +61 -0
- package/dist/shared.js +77 -0
- package/dist/shared.js.map +1 -0
- package/dist/steps.d.ts +76 -0
- package/dist/steps.js +23 -0
- package/dist/steps.js.map +1 -0
- package/dist/version.d.ts +29 -0
- package/dist/version.js +76 -0
- package/dist/version.js.map +1 -0
- package/dist/worker.d.ts +34 -0
- package/dist/worker.js +219 -0
- package/dist/worker.js.map +1 -0
- package/oxlint.json +128 -0
- package/package.json +91 -3
- package/src/approvals.ts +157 -0
- package/src/ast.ts +115 -0
- package/src/call.ts +468 -0
- package/src/client.ts +534 -0
- package/src/config.ts +381 -0
- package/src/credentials.ts +211 -0
- package/src/cucumber.ts +230 -0
- package/src/datafile.ts +409 -0
- package/src/db.ts +197 -0
- package/src/define.ts +285 -0
- package/src/describe.ts +262 -0
- package/src/drift.ts +149 -0
- package/src/env.ts +122 -0
- package/src/errors.ts +202 -0
- package/src/expectations.ts +165 -0
- package/src/fake.ts +156 -0
- package/src/fill.ts +24 -0
- package/src/index.ts +120 -0
- package/src/ledger.ts +419 -0
- package/src/lint.ts +208 -0
- package/src/log.ts +82 -0
- package/src/op.ts +399 -0
- package/src/outline.ts +620 -0
- package/src/paths.ts +32 -0
- package/src/plugin.ts +37 -0
- package/src/policy.ts +139 -0
- package/src/resource.ts +447 -0
- package/src/resources.ts +256 -0
- package/src/run.ts +91 -0
- package/src/sandbox.ts +157 -0
- package/src/scenario.ts +1328 -0
- package/src/scenarios.ts +111 -0
- package/src/shared.ts +116 -0
- package/src/steps.ts +90 -0
- package/src/version.ts +86 -0
- package/src/worker.ts +253 -0
package/dist/scenario.js
ADDED
|
@@ -0,0 +1,1079 @@
|
|
|
1
|
+
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
2
|
+
import { basename, dirname, join } from "node:path";
|
|
3
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { ParameterTypeRegistry } from "@cucumber/cucumber-expressions";
|
|
6
|
+
import { PickleStepType, } from "@cucumber/messages";
|
|
7
|
+
import { buildLibrary, lineOf, runFeatures, stepResults, StepStatus, } from "./cucumber.js";
|
|
8
|
+
import { errorInfo, SanomaError } from "./errors.js";
|
|
9
|
+
import { contains, detailOf, isCall } from "./expectations.js";
|
|
10
|
+
import { fill, isBug, seedFrom, shapeOf } from "./fill.js";
|
|
11
|
+
import { outlineWorkflow } from "./outline.js";
|
|
12
|
+
import { DRIFT_WORKFLOW, errorMessage, fitsOp, flatten, isEnded } from "./shared.js";
|
|
13
|
+
const KINDS = {
|
|
14
|
+
[PickleStepType.CONTEXT]: "given",
|
|
15
|
+
[PickleStepType.ACTION]: "when",
|
|
16
|
+
[PickleStepType.OUTCOME]: "then",
|
|
17
|
+
};
|
|
18
|
+
const FAULTS = {
|
|
19
|
+
"fails once": "failNext",
|
|
20
|
+
"is rate limited once": "rateLimit",
|
|
21
|
+
"loses its reply once": "loseReply",
|
|
22
|
+
};
|
|
23
|
+
/** The schema of the field `name` of an object schema (`owner`, for the error); throws naming the fields it has. */
|
|
24
|
+
function fieldOf(schema, name, owner) {
|
|
25
|
+
const shape = shapeOf(schema);
|
|
26
|
+
if (!Object.hasOwn(shape, name)) {
|
|
27
|
+
throw new Error(`no field "${name}" in ${owner}; it has ${Object.keys(shape).join(", ") || "none"}`);
|
|
28
|
+
}
|
|
29
|
+
return shape[name];
|
|
30
|
+
}
|
|
31
|
+
const issues = (error) => error.issues.map((i) => i.message).join("; ");
|
|
32
|
+
/**
|
|
33
|
+
* The value text from a table cell or a phrase gives a field: the text itself when the field
|
|
34
|
+
* takes it, else the text read as JSON (a number, `true`, a list) when the field takes that.
|
|
35
|
+
*/
|
|
36
|
+
function valueFor(field, text, what) {
|
|
37
|
+
const asText = field.safeParse(text);
|
|
38
|
+
if (asText.success)
|
|
39
|
+
return text;
|
|
40
|
+
let json;
|
|
41
|
+
try {
|
|
42
|
+
json = JSON.parse(text);
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
throw new Error(`${what} cannot be ${JSON.stringify(text)}: ${issues(asText.error)}`);
|
|
46
|
+
}
|
|
47
|
+
const asJson = field.safeParse(json);
|
|
48
|
+
if (!asJson.success)
|
|
49
|
+
throw new Error(`${what} cannot be ${text}: ${issues(asJson.error)}`);
|
|
50
|
+
return json;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* The fields the step's doc string (a JSON object) or two-column table gives `owner` (an
|
|
54
|
+
* operation's input, or a resource), each checked against its field in `schema`: a field it
|
|
55
|
+
* lacks, or a value it refuses, is an error.
|
|
56
|
+
*/
|
|
57
|
+
function fieldsFrom(pickle, schema, owner) {
|
|
58
|
+
const { docString, dataTable } = pickle.argument ?? {};
|
|
59
|
+
const what = (name) => `${name} in ${owner}`;
|
|
60
|
+
if (dataTable) {
|
|
61
|
+
return Object.fromEntries(dataTable.rows.map(({ cells }) => {
|
|
62
|
+
if (cells.length !== 2)
|
|
63
|
+
throw new Error(`the table after "${pickle.text}" needs two columns: name | value`);
|
|
64
|
+
const [name, text] = cells.map((c) => c.value);
|
|
65
|
+
return [name, valueFor(fieldOf(schema, name, owner), text, what(name))];
|
|
66
|
+
}));
|
|
67
|
+
}
|
|
68
|
+
if (!docString)
|
|
69
|
+
throw new Error(`"${pickle.text}" needs a JSON doc string or a two-column table after it`);
|
|
70
|
+
let value;
|
|
71
|
+
try {
|
|
72
|
+
value = JSON.parse(docString.content);
|
|
73
|
+
}
|
|
74
|
+
catch (err) {
|
|
75
|
+
throw new Error(`the doc string after "${pickle.text}" is not JSON: ${errorMessage(err)}`, { cause: err });
|
|
76
|
+
}
|
|
77
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
78
|
+
throw new Error(`"${pickle.text}" needs a JSON object`);
|
|
79
|
+
}
|
|
80
|
+
for (const [name, v] of Object.entries(value)) {
|
|
81
|
+
const parsed = fieldOf(schema, name, owner).safeParse(v);
|
|
82
|
+
if (!parsed.success)
|
|
83
|
+
throw new Error(`${what(name)} cannot be ${JSON.stringify(v)}: ${issues(parsed.error)}`);
|
|
84
|
+
}
|
|
85
|
+
return value;
|
|
86
|
+
}
|
|
87
|
+
/** A parameter's transform: the name, when `ids` has it. */
|
|
88
|
+
const known = (what, ids) => (name) => {
|
|
89
|
+
if (!ids.has(name))
|
|
90
|
+
throw new Error(`no ${what} "${name}"; the config has ${[...ids.keys()].toSorted().join(", ")}`);
|
|
91
|
+
return name;
|
|
92
|
+
};
|
|
93
|
+
const TAKES_OBJECT = "a JSON doc string or a two-column table";
|
|
94
|
+
/** What a field belongs to, in errors: an operation's or a workflow's input. */
|
|
95
|
+
const inputOf = (name) => `${name}'s input`;
|
|
96
|
+
/** A step's verdict: passed when there is no `detail`. */
|
|
97
|
+
const verdict = (step, detail) => detail === undefined ? { step, ok: true } : { step, ok: false, detail };
|
|
98
|
+
const expressionOf = (rule) => rule.expression ?? rule.source;
|
|
99
|
+
/** By the scope's operations, which every scope of one resolved config shares. */
|
|
100
|
+
const builtByOps = new WeakMap();
|
|
101
|
+
/**
|
|
102
|
+
* The steps a feature file may use in a scope, and the Cucumber library that runs them: the
|
|
103
|
+
* generic steps, the steps of an operation's own tests, each step library's steps, and each
|
|
104
|
+
* operation's `phrases`, compiled into steps. Throws for a phrase naming a field its operation's
|
|
105
|
+
* input does not have.
|
|
106
|
+
*/
|
|
107
|
+
function builtFor(scope) {
|
|
108
|
+
const cached = builtByOps.get(scope.ops);
|
|
109
|
+
if (cached &&
|
|
110
|
+
cached.scope.workflows === scope.workflows &&
|
|
111
|
+
cached.scope.types === scope.types &&
|
|
112
|
+
cached.scope.steps === scope.steps) {
|
|
113
|
+
return cached;
|
|
114
|
+
}
|
|
115
|
+
const { rules, parameterTypes } = rulesOf(scope);
|
|
116
|
+
const byExpression = Map.groupBy(rules, expressionOf);
|
|
117
|
+
const library = buildLibrary(parameterTypes, byExpression.keys());
|
|
118
|
+
const built = { scope, byExpression, library, known: knownSteps(rules) };
|
|
119
|
+
builtByOps.set(scope.ops, built);
|
|
120
|
+
return built;
|
|
121
|
+
}
|
|
122
|
+
/** A `Then` step of the generic steps: its expectation, checked against the run's records. */
|
|
123
|
+
const expecting = (expect) => ({ expect, verify: (run) => detailOf(expect, run.records) });
|
|
124
|
+
/** The rules a scope has, and the parameter types their expressions use. */
|
|
125
|
+
function rulesOf(scope) {
|
|
126
|
+
const { types } = scope;
|
|
127
|
+
const parameterTypes = [
|
|
128
|
+
{ name: "op", regexp: /\S+/, transformer: known("operation", scope.ops) },
|
|
129
|
+
{ name: "workflow", regexp: /\S+/, transformer: known("workflow", scope.workflows) },
|
|
130
|
+
{ name: "who", regexp: /\S+/, transformer: (s) => s },
|
|
131
|
+
{ name: "resource", regexp: /\S+/, transformer: known("resource type", types) },
|
|
132
|
+
// A declared resource, as a drift report names it: `<file>#<export>`.
|
|
133
|
+
{ name: "declared", regexp: /[^\s#]+#\S+/, transformer: (s) => s },
|
|
134
|
+
{ name: "field", regexp: /\S+/, transformer: (s) => s },
|
|
135
|
+
];
|
|
136
|
+
const opOf = (id) => scope.ops.get(id);
|
|
137
|
+
const workflowOf = (name) => scope.workflows.get(name);
|
|
138
|
+
/** A resource step's object: its type's id, vendor and type, and its import id. */
|
|
139
|
+
const object = (id, name) => {
|
|
140
|
+
const { vendor, type } = types.get(id);
|
|
141
|
+
return { resource: id, vendor, type, name: name };
|
|
142
|
+
};
|
|
143
|
+
const rules = [
|
|
144
|
+
{
|
|
145
|
+
kind: "given",
|
|
146
|
+
source: "{op} was called with",
|
|
147
|
+
takes: TAKES_OBJECT,
|
|
148
|
+
read: ([op], pickle) => [
|
|
149
|
+
{ op: op, input: fill(opOf(op).input, fieldsFrom(pickle, opOf(op).input, inputOf(op))) },
|
|
150
|
+
],
|
|
151
|
+
},
|
|
152
|
+
...Object.entries(FAULTS).map(([words, fault]) => ({
|
|
153
|
+
kind: "given",
|
|
154
|
+
source: `{op} ${words}`,
|
|
155
|
+
read: ([op]) => [{ op: op, fault }],
|
|
156
|
+
})),
|
|
157
|
+
{
|
|
158
|
+
kind: "given",
|
|
159
|
+
source: "{resource} {string} has",
|
|
160
|
+
takes: TAKES_OBJECT,
|
|
161
|
+
read: ([resource, name], pickle) => [
|
|
162
|
+
{
|
|
163
|
+
...object(resource, name),
|
|
164
|
+
fields: fieldsFrom(pickle, types.get(resource).schema, `${resource} "${name}"`),
|
|
165
|
+
},
|
|
166
|
+
],
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
kind: "given",
|
|
170
|
+
source: "{resource} {string} is gone",
|
|
171
|
+
read: ([resource, name]) => [{ ...object(resource, name), gone: true }],
|
|
172
|
+
},
|
|
173
|
+
{
|
|
174
|
+
kind: "when",
|
|
175
|
+
source: "{workflow} runs with",
|
|
176
|
+
takes: TAKES_OBJECT,
|
|
177
|
+
read: ([name], pickle) => {
|
|
178
|
+
const { input } = workflowOf(name);
|
|
179
|
+
return { workflow: name, input: fill(input, fieldsFrom(pickle, input, inputOf(name))) };
|
|
180
|
+
},
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
kind: "when",
|
|
184
|
+
source: "{workflow} runs",
|
|
185
|
+
read: ([name]) => ({ workflow: name, input: fill(workflowOf(name).input, {}) }),
|
|
186
|
+
},
|
|
187
|
+
// An operation's own test: a call made to its driver, or its fake, directly.
|
|
188
|
+
{
|
|
189
|
+
kind: "when",
|
|
190
|
+
source: "{op} is called with",
|
|
191
|
+
takes: TAKES_OBJECT,
|
|
192
|
+
read: ([op], pickle) => ({
|
|
193
|
+
call: op,
|
|
194
|
+
input: fill(opOf(op).input, fieldsFrom(pickle, opOf(op).input, inputOf(op))),
|
|
195
|
+
}),
|
|
196
|
+
},
|
|
197
|
+
...["approve", "reject"].flatMap((decision) => {
|
|
198
|
+
const verb = decision === "approve" ? "approved" : "rejected";
|
|
199
|
+
return [`{string} is ${verb} by {who}`, `{string} is ${verb} by {who} with note {string}`].map((source) => ({
|
|
200
|
+
kind: "any",
|
|
201
|
+
source,
|
|
202
|
+
read: ([approval, by, note]) => ({
|
|
203
|
+
approval: approval,
|
|
204
|
+
decision,
|
|
205
|
+
by: by,
|
|
206
|
+
...(note === undefined ? {} : { note: note }),
|
|
207
|
+
}),
|
|
208
|
+
}));
|
|
209
|
+
}),
|
|
210
|
+
{
|
|
211
|
+
kind: "then",
|
|
212
|
+
source: "{op} was called with",
|
|
213
|
+
takes: TAKES_OBJECT,
|
|
214
|
+
read: ([op], pickle) => expecting({ op: op, input: fieldsFrom(pickle, opOf(op).input, inputOf(op)), called: true }),
|
|
215
|
+
},
|
|
216
|
+
...[true, false].map((called) => ({
|
|
217
|
+
kind: "then",
|
|
218
|
+
source: `{op} was ${called ? "" : "not "}called`,
|
|
219
|
+
read: ([op]) => expecting({ op: op, called }),
|
|
220
|
+
})),
|
|
221
|
+
{
|
|
222
|
+
kind: "then",
|
|
223
|
+
source: "{op} was denied",
|
|
224
|
+
read: ([op]) => expecting({ op: op, decision: "denied" }),
|
|
225
|
+
},
|
|
226
|
+
{
|
|
227
|
+
kind: "then",
|
|
228
|
+
source: "{op} was held for {who}",
|
|
229
|
+
read: ([op, who]) => expecting({ op: op, decision: "held", approver: who }),
|
|
230
|
+
},
|
|
231
|
+
{
|
|
232
|
+
kind: "then",
|
|
233
|
+
source: "step {string} was called with",
|
|
234
|
+
takes: TAKES_OBJECT,
|
|
235
|
+
read: ([name], pickle, workflow) => {
|
|
236
|
+
const node = stepNamed(scope, workflow, name);
|
|
237
|
+
const step = `step "${name}"`;
|
|
238
|
+
if (node.kind !== "op") {
|
|
239
|
+
throw new Error(`${step} is ${node.kind === "sleep" ? "a sleep" : "an approval"}, which takes no input`);
|
|
240
|
+
}
|
|
241
|
+
// An unnamed call with a computed segment is named by the operation it calls.
|
|
242
|
+
const op = scope.ops.get(node.id) ?? (node.name === undefined ? scope.ops.get(name) : undefined);
|
|
243
|
+
if (!op)
|
|
244
|
+
throw new Error(`${step} calls ${node.id}, whose input cannot be read: name its operation in full`);
|
|
245
|
+
return expecting({ name: name, input: fieldsFrom(pickle, op.input, inputOf(op.id)), called: true });
|
|
246
|
+
},
|
|
247
|
+
},
|
|
248
|
+
...[true, false].map((called) => ({
|
|
249
|
+
kind: "then",
|
|
250
|
+
source: `step {string} was ${called ? "" : "not "}called`,
|
|
251
|
+
read: ([name], _, workflow) => {
|
|
252
|
+
stepNamed(scope, workflow, name);
|
|
253
|
+
return expecting({ name: name, called });
|
|
254
|
+
},
|
|
255
|
+
})),
|
|
256
|
+
{
|
|
257
|
+
kind: "then",
|
|
258
|
+
source: "step {string} ran {int} time(s)",
|
|
259
|
+
read: ([name, times], _, workflow) => {
|
|
260
|
+
stepNamed(scope, workflow, name);
|
|
261
|
+
return expecting({ name: name, times: times });
|
|
262
|
+
},
|
|
263
|
+
},
|
|
264
|
+
{
|
|
265
|
+
kind: "then",
|
|
266
|
+
source: "{declared} is drifted in {field}",
|
|
267
|
+
read: ([declared, field]) => expecting({ declared: declared, status: "drifted", field: field }),
|
|
268
|
+
},
|
|
269
|
+
...["clean", "gone"].map((status) => ({
|
|
270
|
+
kind: "then",
|
|
271
|
+
source: `{declared} is ${status}`,
|
|
272
|
+
read: ([declared]) => expecting({ declared: declared, status }),
|
|
273
|
+
})),
|
|
274
|
+
{ kind: "then", source: "the run succeeds", read: () => expecting({ outcome: "finished" }) },
|
|
275
|
+
{ kind: "then", source: "the run fails", read: () => expecting({ outcome: "failed" }) },
|
|
276
|
+
{
|
|
277
|
+
kind: "then",
|
|
278
|
+
source: "the run fails with {string}",
|
|
279
|
+
read: ([code]) => expecting({ outcome: "failed", code: code }),
|
|
280
|
+
},
|
|
281
|
+
// The result of the last call: what an operation's own test checks.
|
|
282
|
+
{
|
|
283
|
+
kind: "then",
|
|
284
|
+
source: "it returns",
|
|
285
|
+
takes: "a JSON doc string or a two-column table, of fields of its output",
|
|
286
|
+
read: (_, pickle) => ({
|
|
287
|
+
expect: { custom: true },
|
|
288
|
+
verify: ({ records }) => {
|
|
289
|
+
const last = lastCall(records);
|
|
290
|
+
if (typeof last === "string")
|
|
291
|
+
return last;
|
|
292
|
+
if (last.error)
|
|
293
|
+
return `it failed: ${last.error.code ?? last.error.name}: ${last.error.message}`;
|
|
294
|
+
const expected = fieldsFrom(pickle, opOf(last.op).output, `${last.op}'s output`);
|
|
295
|
+
return contains(last.output, expected) ? undefined : `it returned ${JSON.stringify(last.output)}`;
|
|
296
|
+
},
|
|
297
|
+
}),
|
|
298
|
+
},
|
|
299
|
+
{
|
|
300
|
+
kind: "then",
|
|
301
|
+
source: "it fails with status {int}",
|
|
302
|
+
read: ([status]) => ({
|
|
303
|
+
expect: { custom: true },
|
|
304
|
+
verify: ({ records }) => {
|
|
305
|
+
const last = lastCall(records);
|
|
306
|
+
if (typeof last === "string")
|
|
307
|
+
return last;
|
|
308
|
+
if (!last.error)
|
|
309
|
+
return `it returned ${JSON.stringify(last.output)}`;
|
|
310
|
+
if (last.error.status === status)
|
|
311
|
+
return undefined;
|
|
312
|
+
return `it failed with ${last.error.status === undefined ? "no status" : `status ${last.error.status}`}: ${last.error.message}`;
|
|
313
|
+
},
|
|
314
|
+
}),
|
|
315
|
+
},
|
|
316
|
+
];
|
|
317
|
+
// Each step library's steps, after the generic ones.
|
|
318
|
+
/** An operation a step library's step names: one of the config's. */
|
|
319
|
+
const opNamed = (op) => known("operation", scope.ops)(op.id);
|
|
320
|
+
for (const { owner, steps } of scope.steps) {
|
|
321
|
+
for (const def of steps) {
|
|
322
|
+
const base = { source: def.expression, group: owner };
|
|
323
|
+
if (def.kind === "given") {
|
|
324
|
+
rules.push({
|
|
325
|
+
...base,
|
|
326
|
+
kind: "given",
|
|
327
|
+
read: (values) => {
|
|
328
|
+
const seed = def.fn(...values);
|
|
329
|
+
const op = opNamed(seed.op);
|
|
330
|
+
if ("fault" in seed)
|
|
331
|
+
return [{ op, fault: seed.fault }];
|
|
332
|
+
let input;
|
|
333
|
+
try {
|
|
334
|
+
input = fill(opOf(op).input, seed.input);
|
|
335
|
+
}
|
|
336
|
+
catch (err) {
|
|
337
|
+
if (isBug(err))
|
|
338
|
+
throw err;
|
|
339
|
+
throw new Error(`${inputOf(op)} cannot be ${JSON.stringify(seed.input)}: ${errorMessage(err)}`, {
|
|
340
|
+
cause: err,
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
return [{ op, input, ...(seed.as === undefined ? {} : { as: seed.as }) }];
|
|
344
|
+
},
|
|
345
|
+
});
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
rules.push({
|
|
349
|
+
...base,
|
|
350
|
+
kind: "then",
|
|
351
|
+
read: (values) => {
|
|
352
|
+
const expected = def.fn(...values);
|
|
353
|
+
return {
|
|
354
|
+
expect: {
|
|
355
|
+
custom: true,
|
|
356
|
+
...(expected.op === undefined ? {} : { op: opNamed(expected.op) }),
|
|
357
|
+
...(expected.called === undefined ? {} : { called: expected.called }),
|
|
358
|
+
},
|
|
359
|
+
verify: (run) => {
|
|
360
|
+
const detail = expected.check(run);
|
|
361
|
+
return typeof detail === "string" ? detail : undefined;
|
|
362
|
+
},
|
|
363
|
+
};
|
|
364
|
+
},
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
// Each operation's phrases, last. A field `{title}` becomes the parameter type `{field_title}`,
|
|
369
|
+
// so a field named like another type (`op`, `string`, `int`) never takes that type's pattern.
|
|
370
|
+
// It matches what `{string}` does, or one word, and unquotes the same way.
|
|
371
|
+
const string = new ParameterTypeRegistry().lookupByTypeName("string");
|
|
372
|
+
const word = /([^\s"']\S*)/;
|
|
373
|
+
const field = (groups) => groups.at(-1) ?? string.transform(null, groups.slice(0, -1));
|
|
374
|
+
const fieldTypes = new Set();
|
|
375
|
+
for (const op of scope.ops.values()) {
|
|
376
|
+
// A phrase's key, and the keyword of the steps it adds.
|
|
377
|
+
for (const [key, kind] of [
|
|
378
|
+
["given", "given"],
|
|
379
|
+
["expect", "then"],
|
|
380
|
+
]) {
|
|
381
|
+
const template = op.phrases?.[key];
|
|
382
|
+
if (template === undefined)
|
|
383
|
+
continue;
|
|
384
|
+
const fields = [...template.matchAll(/\{([^}]*)\}/g)].map(([, name]) => name);
|
|
385
|
+
const schemas = fields.map((name) => {
|
|
386
|
+
try {
|
|
387
|
+
return fieldOf(op.input, name, inputOf(op.id));
|
|
388
|
+
}
|
|
389
|
+
catch {
|
|
390
|
+
throw new Error(`${op.id}: its ${key} phrase "${template}" names {${name}}, which is not a field of its input`);
|
|
391
|
+
}
|
|
392
|
+
});
|
|
393
|
+
for (const name of fields) {
|
|
394
|
+
if (fieldTypes.has(name))
|
|
395
|
+
continue;
|
|
396
|
+
fieldTypes.add(name);
|
|
397
|
+
parameterTypes.push({
|
|
398
|
+
name: `field_${name}`,
|
|
399
|
+
regexp: [...string.regexpStrings, word.source],
|
|
400
|
+
transformer: (...g) => field(g),
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
const given = (values) => Object.fromEntries(fields.map((name, i) => [name, valueFor(schemas[i], values[i], `${name} in ${inputOf(op.id)}`)]));
|
|
404
|
+
const common = { source: template, group: op.id, expression: template.replaceAll(/\{([^}]*)\}/g, "{field_$1}") };
|
|
405
|
+
rules.push(kind === "given"
|
|
406
|
+
? { ...common, kind, read: (values) => [{ op: op.id, input: fill(op.input, given(values)) }] }
|
|
407
|
+
: { ...common, kind, read: (values) => expecting({ op: op.id, input: given(values), called: true }) });
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
return { rules, parameterTypes };
|
|
411
|
+
}
|
|
412
|
+
/** The last call of a run, or why there is none to check. */
|
|
413
|
+
function lastCall(records) {
|
|
414
|
+
return records.findLast(isCall) ?? "no call was made";
|
|
415
|
+
}
|
|
416
|
+
/** Every step the scope knows, for an error: the generic ones by keyword, then each operation's phrases and each step library's steps under its name. */
|
|
417
|
+
function knownSteps(rules) {
|
|
418
|
+
const keyword = (kind) => (kind === "any" ? "Given/When/Then" : kind[0].toUpperCase() + kind.slice(1));
|
|
419
|
+
const line = (r) => `${keyword(r.kind)} ${r.source}${r.takes ? ` (and ${r.takes})` : ""}`;
|
|
420
|
+
const { generic = [], ...groups } = Object.groupBy(rules, (r) => r.group ?? "generic");
|
|
421
|
+
return [
|
|
422
|
+
"Known steps:",
|
|
423
|
+
...generic.map((r) => ` ${line(r)}`),
|
|
424
|
+
...Object.entries(groups).flatMap(([group, list]) => [` ${group}:`, ...(list ?? []).map((r) => ` ${line(r)}`)]),
|
|
425
|
+
].join("\n");
|
|
426
|
+
}
|
|
427
|
+
/**
|
|
428
|
+
* A scenario's own lines of its file's `text`: from its keyword line to the end of its last
|
|
429
|
+
* step (the closing delimiter of a doc string, the last row of a table) or of its examples'
|
|
430
|
+
* last row, each line less the keyword line's indent.
|
|
431
|
+
*/
|
|
432
|
+
function textOf(scenario, text) {
|
|
433
|
+
const lines = text.split(/\r?\n/);
|
|
434
|
+
const endOf = (step) => {
|
|
435
|
+
if (step.dataTable)
|
|
436
|
+
return step.dataTable.rows.at(-1)?.location.line ?? step.location.line;
|
|
437
|
+
if (!step.docString)
|
|
438
|
+
return step.location.line;
|
|
439
|
+
// Line numbers count from 1, so index `line` is the line after the opening delimiter.
|
|
440
|
+
const { location, delimiter } = step.docString;
|
|
441
|
+
const close = lines.findIndex((l, i) => i >= location.line && l.trim() === delimiter);
|
|
442
|
+
return close === -1 ? location.line : close + 1;
|
|
443
|
+
};
|
|
444
|
+
const { line, column = 1 } = scenario.location;
|
|
445
|
+
const end = Math.max(line, ...scenario.steps.map(endOf), ...scenario.examples.map((ex) => (ex.tableBody.at(-1) ?? ex.tableHeader ?? ex).location.line));
|
|
446
|
+
return lines
|
|
447
|
+
.slice(line - 1, end)
|
|
448
|
+
.map((l) => l.slice(Math.min(column - 1, l.length - l.trimStart().length)))
|
|
449
|
+
.join("\n");
|
|
450
|
+
}
|
|
451
|
+
const nodesByWorkflow = new WeakMap();
|
|
452
|
+
/** A workflow's outline, read once: its nodes, or undefined when its code cannot be outlined. */
|
|
453
|
+
function nodesOf(wf) {
|
|
454
|
+
if (!nodesByWorkflow.has(wf)) {
|
|
455
|
+
const outline = outlineWorkflow(wf);
|
|
456
|
+
nodesByWorkflow.set(wf, "nodes" in outline ? outline.nodes : undefined);
|
|
457
|
+
}
|
|
458
|
+
return nodesByWorkflow.get(wf);
|
|
459
|
+
}
|
|
460
|
+
const isStep = (n) => n.kind === "op" || n.kind === "approval" || n.kind === "sleep";
|
|
461
|
+
/**
|
|
462
|
+
* The one step of the workflow's outline named `name` (an operation's call or a sleep by the
|
|
463
|
+
* name it gives or its default, an approval by its title). Throws outside a scenario's workflow
|
|
464
|
+
* (before its `When`, or in an operation's own test), when no step has the name, and when several
|
|
465
|
+
* may: a name a call defaults to (its operation's id) can be several calls', and an unnamed call
|
|
466
|
+
* with a computed segment is named in a run by the operation it calls, which may be this one.
|
|
467
|
+
*/
|
|
468
|
+
function stepNamed(scope, workflow, name) {
|
|
469
|
+
if (workflow === undefined) {
|
|
470
|
+
throw new Error(`step "${name}" names a step of a workflow: it goes after the When that runs one, in scenarios/`);
|
|
471
|
+
}
|
|
472
|
+
const nodes = nodesOf(scope.workflows.get(workflow));
|
|
473
|
+
if (!nodes)
|
|
474
|
+
throw new Error(`${workflow}'s code cannot be outlined, so its steps have no names`);
|
|
475
|
+
const steps = flatten(nodes).filter(isStep);
|
|
476
|
+
const named = steps.filter((n) => n.name === name || (n.kind === "op" && n.name === undefined && (n.ops?.includes(name) ?? fitsOp(n.id, name))));
|
|
477
|
+
if (named.length === 1)
|
|
478
|
+
return named[0];
|
|
479
|
+
if (named.length > 1) {
|
|
480
|
+
throw new Error(`${named.length} steps of ${workflow} are named "${name}": give the one to check a name of its own, such as \`{ name: "Publish the post" }\``);
|
|
481
|
+
}
|
|
482
|
+
const names = [...new Set(steps.flatMap((n) => (n.name === undefined ? [] : [`"${n.name}"`])))];
|
|
483
|
+
throw new Error(`no step of ${workflow} is named "${name}"; its steps are ${names.join(", ") || "none"}`);
|
|
484
|
+
}
|
|
485
|
+
/** The titles of the approvals in an outline, at any depth, where they are string literals. */
|
|
486
|
+
const titles = (nodes) => nodes.flatMap((n) => {
|
|
487
|
+
if (n.kind === "approval")
|
|
488
|
+
return n.title === undefined ? [] : [n.title];
|
|
489
|
+
if (n.kind === "all")
|
|
490
|
+
return n.branches.flatMap(titles);
|
|
491
|
+
if (n.kind === "branch")
|
|
492
|
+
return n.cases.flatMap(titles);
|
|
493
|
+
return n.kind === "each" || n.kind === "repeat" ? titles(n.body) : [];
|
|
494
|
+
});
|
|
495
|
+
/** The literal titles of the approvals a workflow's code asks for, from its outline. */
|
|
496
|
+
const approvalsOf = (wf) => [...new Set(titles(nodesOf(wf) ?? []))];
|
|
497
|
+
const namesByReport = new WeakMap();
|
|
498
|
+
/**
|
|
499
|
+
* The name of each pickle of a run, by its id: its own, or for an outline's rows whose names
|
|
500
|
+
* would be the same, its own with its row's line (`Launch (line 12)`).
|
|
501
|
+
*/
|
|
502
|
+
function namesOf(report) {
|
|
503
|
+
const cached = namesByReport.get(report);
|
|
504
|
+
if (cached)
|
|
505
|
+
return cached;
|
|
506
|
+
const names = new Map();
|
|
507
|
+
namesByReport.set(report, names);
|
|
508
|
+
for (const inFile of Map.groupBy(report.query.findAllPickles(), (p) => p.uri).values()) {
|
|
509
|
+
const counts = Map.groupBy(inFile, (p) => p.name);
|
|
510
|
+
for (const pickle of inFile) {
|
|
511
|
+
const shared = pickle.astNodeIds.length > 1 && counts.get(pickle.name).length > 1;
|
|
512
|
+
names.set(pickle.id, shared ? `${pickle.name} (line ${lineOf(report, pickle)})` : pickle.name);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
return names;
|
|
516
|
+
}
|
|
517
|
+
/** The run's records, as a `Then` step reads them. */
|
|
518
|
+
function viewOf(records) {
|
|
519
|
+
// What the ledger holds a call took and returned is of its operation's types.
|
|
520
|
+
const calls = (op) => records
|
|
521
|
+
.filter((r) => isCall(r) && r.op === op.id && !r.error)
|
|
522
|
+
.map(({ input, output }) => ({ input, output }));
|
|
523
|
+
const seeded = (op) => records.flatMap((r) => r.type === "scenario.seeded"
|
|
524
|
+
? r.seeds.flatMap((s) => "op" in s && s.op === op.id ? [{ input: s.input, output: s.output }] : [])
|
|
525
|
+
: []);
|
|
526
|
+
return { records, calls, seeded, made: (op) => [...seeded(op), ...calls(op)] };
|
|
527
|
+
}
|
|
528
|
+
/** Why no step, or two, match a step's text. */
|
|
529
|
+
const noMatch = (built, text) => `no step matches "${text}"\n${built.known}`;
|
|
530
|
+
const ambiguous = (text, sources) => `"${text}" is ambiguous: it matches ${[...new Set(sources)].map((s) => `"${s}"`).join(" and ")}`;
|
|
531
|
+
/**
|
|
532
|
+
* The rule a step's keyword picks among those registered with the expression it matched:
|
|
533
|
+
* Cucumber matches the text alone, so one expression can be both a `Given` and a `Then`.
|
|
534
|
+
*/
|
|
535
|
+
function ruleFor(built, expression, pickle) {
|
|
536
|
+
const kind = pickle.type && KINDS[pickle.type];
|
|
537
|
+
if (!kind)
|
|
538
|
+
throw new Error(`"${pickle.text}" needs a Given, When or Then before it`);
|
|
539
|
+
const rules = built.byExpression.get(expression).filter((r) => r.kind === kind || r.kind === "any");
|
|
540
|
+
if (rules.length === 0)
|
|
541
|
+
throw new Error(noMatch(built, pickle.text));
|
|
542
|
+
if (rules.length > 1)
|
|
543
|
+
throw new Error(ambiguous(pickle.text, rules.map((r) => r.source)));
|
|
544
|
+
return rules[0];
|
|
545
|
+
}
|
|
546
|
+
/** Each scenario of a run, before its first step: one name always makes up the same values. */
|
|
547
|
+
function seedNames(report, pickle) {
|
|
548
|
+
const name = namesOf(report).get(pickle.id) ?? pickle.name;
|
|
549
|
+
seedFrom(name);
|
|
550
|
+
return name;
|
|
551
|
+
}
|
|
552
|
+
/** A World for compiling: each step records what it says into its scenario's spec, in `specs`. */
|
|
553
|
+
function compileWorld(built, specs) {
|
|
554
|
+
return (report) => {
|
|
555
|
+
let spec;
|
|
556
|
+
return {
|
|
557
|
+
begin(pickle) {
|
|
558
|
+
spec = { name: seedNames(report, pickle), steps: [], given: [], input: undefined, decisions: [], expect: [] };
|
|
559
|
+
specs.set(pickle.id, spec);
|
|
560
|
+
},
|
|
561
|
+
step(expression, args, pickle) {
|
|
562
|
+
const rule = ruleFor(built, expression, pickle);
|
|
563
|
+
const { text } = pickle;
|
|
564
|
+
const step = { text, kind: KINDS[pickle.type] };
|
|
565
|
+
switch (rule.kind) {
|
|
566
|
+
case "given": {
|
|
567
|
+
const items = rule.read(args, pickle);
|
|
568
|
+
spec.given.push(...items.map((item) => ({ step: text, ...item })));
|
|
569
|
+
const [first] = items;
|
|
570
|
+
if (first && "op" in first)
|
|
571
|
+
step.op = first.op;
|
|
572
|
+
if (items.some((i) => "fault" in i))
|
|
573
|
+
step.fault = true;
|
|
574
|
+
break;
|
|
575
|
+
}
|
|
576
|
+
case "then": {
|
|
577
|
+
const { expect } = rule.read(args, pickle, spec.workflow);
|
|
578
|
+
spec.expect.push({ step: text, ...expect });
|
|
579
|
+
if ("op" in expect && expect.op !== undefined)
|
|
580
|
+
step.op = expect.op;
|
|
581
|
+
if ("name" in expect)
|
|
582
|
+
step.name = expect.name;
|
|
583
|
+
if ("called" in expect && expect.called !== undefined)
|
|
584
|
+
step.called = expect.called;
|
|
585
|
+
if ("times" in expect)
|
|
586
|
+
step.called = expect.times > 0;
|
|
587
|
+
if ("decision" in expect)
|
|
588
|
+
step.decision = expect.decision;
|
|
589
|
+
break;
|
|
590
|
+
}
|
|
591
|
+
case "any":
|
|
592
|
+
spec.decisions.push({ step: text, ...rule.read(args, pickle) });
|
|
593
|
+
break;
|
|
594
|
+
case "when": {
|
|
595
|
+
if (spec.workflow !== undefined)
|
|
596
|
+
throw new Error("a second When: a scenario runs its workflow once");
|
|
597
|
+
const item = rule.read(args, pickle);
|
|
598
|
+
if ("call" in item) {
|
|
599
|
+
throw new Error(`"${text}" calls ${item.call} directly: a step for an operation's own test, in its connector's features`);
|
|
600
|
+
}
|
|
601
|
+
Object.assign(spec, item);
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
spec.steps.push(step);
|
|
605
|
+
},
|
|
606
|
+
};
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
/**
|
|
610
|
+
* A World for checking a run: each `Then` step judges the run on its own, and records its
|
|
611
|
+
* verdict (why it does not hold, or undefined) in `verdicts`, by its pickle step's id, rather
|
|
612
|
+
* than failing, so every `Then` runs. The other steps do nothing.
|
|
613
|
+
*/
|
|
614
|
+
function checkWorld(built, workflow, view, verdicts) {
|
|
615
|
+
return (report) => ({
|
|
616
|
+
begin(pickle) {
|
|
617
|
+
seedNames(report, pickle);
|
|
618
|
+
},
|
|
619
|
+
step(expression, args, pickle) {
|
|
620
|
+
const rule = ruleFor(built, expression, pickle);
|
|
621
|
+
if (rule.kind !== "then")
|
|
622
|
+
return;
|
|
623
|
+
const { verify } = rule.read(args, pickle, workflow);
|
|
624
|
+
let detail;
|
|
625
|
+
try {
|
|
626
|
+
detail = verify(view);
|
|
627
|
+
}
|
|
628
|
+
catch (err) {
|
|
629
|
+
if (isBug(err))
|
|
630
|
+
throw err;
|
|
631
|
+
detail = errorMessage(err);
|
|
632
|
+
}
|
|
633
|
+
verdicts.set(pickle.id, detail);
|
|
634
|
+
},
|
|
635
|
+
});
|
|
636
|
+
}
|
|
637
|
+
/**
|
|
638
|
+
* A World for an operation's own test: `Given` steps seed through the target, `When` steps call
|
|
639
|
+
* its operations directly (no worker, no policy), and `Then` steps fail when they do not hold.
|
|
640
|
+
*/
|
|
641
|
+
function testWorld(built, { driver, fake }) {
|
|
642
|
+
return (report) => {
|
|
643
|
+
// What the steps named (`as`), and the calls made, as a run's ledger would hold them.
|
|
644
|
+
const values = new Map();
|
|
645
|
+
const records = [];
|
|
646
|
+
let runId = "";
|
|
647
|
+
/** A record of the test, as a run's ledger would hold it. */
|
|
648
|
+
const record = (body) => {
|
|
649
|
+
const seq = records.length;
|
|
650
|
+
const at = Date.now();
|
|
651
|
+
const common = { v: 1, app: "test", runId, seq, at, actor: { id: "test" }, workflow: "" };
|
|
652
|
+
records.push({ ...common, id: `${runId}:${body.type}:${seq}`, ...body });
|
|
653
|
+
};
|
|
654
|
+
/** Calls an operation on the target, as the runtime would: the input parsed by its schema. */
|
|
655
|
+
const call = (id, given) => {
|
|
656
|
+
const op = built.scope.ops.get(id);
|
|
657
|
+
const input = op.input.parse(resolve(given, values));
|
|
658
|
+
const fn = driver.ops[`${op.resource}.${op.name}`];
|
|
659
|
+
if (!fn)
|
|
660
|
+
throw new Error(`the driver under test does not implement ${id}`);
|
|
661
|
+
const context = { idempotencyKey: `${runId}:${records.length}`, runId, opId: id, attempt: 1 };
|
|
662
|
+
return { op, input, output: () => fn(input, context) };
|
|
663
|
+
};
|
|
664
|
+
return {
|
|
665
|
+
begin(pickle) {
|
|
666
|
+
runId = pickle.id;
|
|
667
|
+
seedNames(report, pickle);
|
|
668
|
+
},
|
|
669
|
+
async step(expression, args, pickle) {
|
|
670
|
+
const rule = ruleFor(built, expression, pickle);
|
|
671
|
+
const { text } = pickle;
|
|
672
|
+
switch (rule.kind) {
|
|
673
|
+
case "given": {
|
|
674
|
+
const seeds = [];
|
|
675
|
+
for (const item of rule.read(args, pickle)) {
|
|
676
|
+
if ("resource" in item)
|
|
677
|
+
throw new Error(`"${text}" seeds a resource: a step for a sandbox run`);
|
|
678
|
+
if ("fault" in item) {
|
|
679
|
+
if (!fake)
|
|
680
|
+
throw new Error(`"${text}" injects a fault, which only a fake can`);
|
|
681
|
+
fake[item.fault](item.op);
|
|
682
|
+
continue;
|
|
683
|
+
}
|
|
684
|
+
const made = call(item.op, item.input);
|
|
685
|
+
const output = await made.output();
|
|
686
|
+
if (item.as !== undefined)
|
|
687
|
+
values.set(item.as, output);
|
|
688
|
+
seeds.push({ op: item.op, input: made.input, output });
|
|
689
|
+
}
|
|
690
|
+
if (seeds.length)
|
|
691
|
+
record({ type: "scenario.seeded", scenario: "", seeds });
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
694
|
+
case "when": {
|
|
695
|
+
const item = rule.read(args, pickle);
|
|
696
|
+
if ("workflow" in item) {
|
|
697
|
+
throw new Error(`"${text}" runs a workflow: a step for a scenario in scenarios/, not an operation's own test`);
|
|
698
|
+
}
|
|
699
|
+
const made = call(item.call, item.input);
|
|
700
|
+
const started = Date.now();
|
|
701
|
+
const result = await made.output().then((output) => ({ output }), (err) => {
|
|
702
|
+
if (isBug(err))
|
|
703
|
+
throw err;
|
|
704
|
+
return { error: errorInfo(err) };
|
|
705
|
+
});
|
|
706
|
+
const { op, input } = made;
|
|
707
|
+
const durationMs = Date.now() - started;
|
|
708
|
+
record({
|
|
709
|
+
type: "op.called",
|
|
710
|
+
op: op.id,
|
|
711
|
+
effect: op.effect,
|
|
712
|
+
input,
|
|
713
|
+
decision: { kind: "allow" },
|
|
714
|
+
durationMs,
|
|
715
|
+
...result,
|
|
716
|
+
});
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
case "any":
|
|
720
|
+
throw new Error(`"${text}" decides an approval: a step for a scenario in scenarios/, not an operation's own test`);
|
|
721
|
+
case "then": {
|
|
722
|
+
const detail = rule.read(args, pickle).verify(viewOf(records));
|
|
723
|
+
if (detail !== undefined)
|
|
724
|
+
throw new Error(detail);
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
},
|
|
728
|
+
};
|
|
729
|
+
};
|
|
730
|
+
}
|
|
731
|
+
/**
|
|
732
|
+
* The value with each string `$name` or `$name.field.field` replaced by what `values` holds
|
|
733
|
+
* under that name (an output a `Given` named with `as`), at any depth.
|
|
734
|
+
*/
|
|
735
|
+
function resolve(value, values) {
|
|
736
|
+
if (typeof value === "string") {
|
|
737
|
+
const ref = /^\$([A-Za-z_][\w-]*)((?:\.[\w-]+)*)$/.exec(value);
|
|
738
|
+
if (!ref)
|
|
739
|
+
return value;
|
|
740
|
+
const [, name, path] = ref;
|
|
741
|
+
if (!values.has(name)) {
|
|
742
|
+
const named = values.size ? `; the steps named ${[...values.keys()].join(", ")}` : "";
|
|
743
|
+
throw new Error(`no value named "${name}" for ${value}${named}`);
|
|
744
|
+
}
|
|
745
|
+
return path
|
|
746
|
+
.split(".")
|
|
747
|
+
.slice(1)
|
|
748
|
+
.reduce((v, key) => v?.[key], values.get(name));
|
|
749
|
+
}
|
|
750
|
+
if (Array.isArray(value))
|
|
751
|
+
return value.map((v) => resolve(v, values));
|
|
752
|
+
if (typeof value === "object" && value !== null) {
|
|
753
|
+
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, resolve(v, values)]));
|
|
754
|
+
}
|
|
755
|
+
return value;
|
|
756
|
+
}
|
|
757
|
+
/** A step's `file:line`. */
|
|
758
|
+
const whereOf = (report, file, step) => `${file}:${report.query.findStepBy(step)?.location.line ?? 1}`;
|
|
759
|
+
/**
|
|
760
|
+
* Why a step that did not pass did not, at `where`: the error it failed with, no step matching
|
|
761
|
+
* it (with the steps there are), or more than one; undefined for a step that passed or did not run.
|
|
762
|
+
*/
|
|
763
|
+
function stepError(built, result, step, where) {
|
|
764
|
+
if (!result)
|
|
765
|
+
return undefined;
|
|
766
|
+
switch (result.status) {
|
|
767
|
+
case StepStatus.PASSED:
|
|
768
|
+
case StepStatus.SKIPPED:
|
|
769
|
+
return undefined;
|
|
770
|
+
case StepStatus.UNDEFINED:
|
|
771
|
+
return step.type && KINDS[step.type]
|
|
772
|
+
? `${where}: ${noMatch(built, step.text)}`
|
|
773
|
+
: `${where}: "${step.text}" needs a Given, When or Then before it`;
|
|
774
|
+
case StepStatus.AMBIGUOUS:
|
|
775
|
+
return `${where}: ${ambiguous(step.text, result.matches.flatMap((e) => built.byExpression.get(e).map((r) => r.source)))}`;
|
|
776
|
+
default:
|
|
777
|
+
return `${where}: ${result.message ?? `the step ended ${result.status}`}`;
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
/** The `.feature` files under `dir`, relative to it, with `/` between directories, in order. */
|
|
781
|
+
function featureFiles(dir) {
|
|
782
|
+
return readdirSync(dir, { recursive: true, encoding: "utf8" })
|
|
783
|
+
.filter((f) => f.endsWith(".feature"))
|
|
784
|
+
.map((f) => f.replaceAll("\\", "/"))
|
|
785
|
+
.toSorted();
|
|
786
|
+
}
|
|
787
|
+
/** A parse error, as a file's error: `file:line: message`. */
|
|
788
|
+
const parseErrorOf = ({ uri, line, message }) => `${uri}${line === undefined ? "" : `:${line}`}: ${message}`;
|
|
789
|
+
/**
|
|
790
|
+
* Compiles the feature files `files` (relative to `dir`) into scenarios: Cucumber runs each,
|
|
791
|
+
* and its steps record what they say. A file with an error has none of its scenarios: the
|
|
792
|
+
* first error in it (a step that fails or matches no step or two, a scenario with no `When`,
|
|
793
|
+
* a name used twice, a file Gherkin cannot parse, no scenarios at all) is its error.
|
|
794
|
+
*/
|
|
795
|
+
async function compile(scope, dir, files) {
|
|
796
|
+
const built = builtFor(scope);
|
|
797
|
+
const errors = new Map();
|
|
798
|
+
const specs = new Map();
|
|
799
|
+
let report;
|
|
800
|
+
let left = files;
|
|
801
|
+
// A file Gherkin cannot parse stops the whole run: it is left out, and the rest run again.
|
|
802
|
+
while (left.length) {
|
|
803
|
+
report = await runFeatures(built.library, compileWorld(built, specs), { cwd: dir, files: left, isBug });
|
|
804
|
+
if (report.parseErrors.length === 0)
|
|
805
|
+
break;
|
|
806
|
+
for (const error of report.parseErrors) {
|
|
807
|
+
if (!errors.has(error.uri))
|
|
808
|
+
errors.set(error.uri, parseErrorOf(error));
|
|
809
|
+
}
|
|
810
|
+
const rest = left.filter((f) => !errors.has(f));
|
|
811
|
+
// An error of no file left: give it to every file, rather than run them again forever.
|
|
812
|
+
if (rest.length === left.length) {
|
|
813
|
+
for (const f of left)
|
|
814
|
+
errors.set(f, report.parseErrors.map(parseErrorOf).join("; "));
|
|
815
|
+
}
|
|
816
|
+
left = rest.length === left.length ? [] : rest;
|
|
817
|
+
report = undefined;
|
|
818
|
+
}
|
|
819
|
+
const scenarios = [];
|
|
820
|
+
if (report) {
|
|
821
|
+
const { query } = report;
|
|
822
|
+
const names = namesOf(report);
|
|
823
|
+
const results = stepResults(report);
|
|
824
|
+
const pickles = Map.groupBy(query.findAllPickles(), (p) => p.uri);
|
|
825
|
+
for (const file of files) {
|
|
826
|
+
if (errors.has(file))
|
|
827
|
+
continue;
|
|
828
|
+
const inFile = [];
|
|
829
|
+
try {
|
|
830
|
+
const list = pickles.get(file) ?? [];
|
|
831
|
+
if (list.length === 0)
|
|
832
|
+
throw new Error(`${file}: no scenarios; add one with "Scenario: <name>"`);
|
|
833
|
+
for (const pickle of list) {
|
|
834
|
+
const line = lineOf(report, pickle);
|
|
835
|
+
const scenario = query.findLineageBy(pickle).scenario;
|
|
836
|
+
const where = `${file}:${scenario.location.line}`;
|
|
837
|
+
for (const step of pickle.steps) {
|
|
838
|
+
const error = stepError(built, results.get(step.id), step, whereOf(report, file, step));
|
|
839
|
+
if (error)
|
|
840
|
+
throw new Error(error);
|
|
841
|
+
}
|
|
842
|
+
const { workflow, ...spec } = specs.get(pickle.id);
|
|
843
|
+
const name = names.get(pickle.id);
|
|
844
|
+
if (inFile.some((s) => s.name === name)) {
|
|
845
|
+
throw new Error(`${where}: a second scenario named "${name}"; give each its own name`);
|
|
846
|
+
}
|
|
847
|
+
if (workflow === undefined) {
|
|
848
|
+
throw new Error(`${where}: scenario "${name}" has no When: say which workflow runs, such as "When <workflow> runs with"`);
|
|
849
|
+
}
|
|
850
|
+
const drift = spec.expect.find((e) => "declared" in e);
|
|
851
|
+
if (drift && workflow !== DRIFT_WORKFLOW) {
|
|
852
|
+
throw new Error(`${where}: "${drift.step}" reads a drift report, so the scenario must run ${DRIFT_WORKFLOW}, not ${workflow}`);
|
|
853
|
+
}
|
|
854
|
+
inFile.push({
|
|
855
|
+
...spec,
|
|
856
|
+
name,
|
|
857
|
+
file,
|
|
858
|
+
line,
|
|
859
|
+
workflow,
|
|
860
|
+
text: textOf(scenario, report.texts.get(file) ?? ""),
|
|
861
|
+
approvals: approvalsOf(scope.workflows.get(workflow)),
|
|
862
|
+
});
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
catch (err) {
|
|
866
|
+
if (isBug(err))
|
|
867
|
+
throw err;
|
|
868
|
+
errors.set(file, errorMessage(err));
|
|
869
|
+
continue;
|
|
870
|
+
}
|
|
871
|
+
scenarios.push(...inFile);
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
// Each file's error, in the files' order, and the scenarios of the rest, one name each across files.
|
|
875
|
+
const loaded = { scenarios: [], errors: [] };
|
|
876
|
+
const seen = new Map();
|
|
877
|
+
for (const file of files) {
|
|
878
|
+
const error = errors.get(file);
|
|
879
|
+
if (error !== undefined) {
|
|
880
|
+
loaded.errors.push({ file, message: error });
|
|
881
|
+
continue;
|
|
882
|
+
}
|
|
883
|
+
for (const s of scenarios.filter((x) => x.file === file)) {
|
|
884
|
+
const other = seen.get(s.name);
|
|
885
|
+
if (other !== undefined) {
|
|
886
|
+
loaded.errors.push({
|
|
887
|
+
file,
|
|
888
|
+
message: `${file}: scenario "${s.name}" is also in ${other}; give each its own name`,
|
|
889
|
+
});
|
|
890
|
+
continue;
|
|
891
|
+
}
|
|
892
|
+
seen.set(s.name, file);
|
|
893
|
+
loaded.scenarios.push(s);
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
return loaded;
|
|
897
|
+
}
|
|
898
|
+
/**
|
|
899
|
+
* Each scope's scenarios as last read, with the stamp of the files they were read from. By
|
|
900
|
+
* scope, since what a file means depends on its operations and workflows.
|
|
901
|
+
*/
|
|
902
|
+
const loadedByScope = new WeakMap();
|
|
903
|
+
/**
|
|
904
|
+
* Every scenario in the `.feature` files under `scenarios` (a `file:` URL to a directory),
|
|
905
|
+
* compiled by Cucumber, with an error for each file that does not compile and each name used
|
|
906
|
+
* twice. No directory, or none given, is no scenarios. Rejects when an operation's phrase names
|
|
907
|
+
* a field it lacks. The files are compiled again only once one is added, removed or written
|
|
908
|
+
* (its name, `mtimeMs` or size changes): until then each call returns the same object, which
|
|
909
|
+
* callers must not change.
|
|
910
|
+
*/
|
|
911
|
+
export async function loadScenarios(scope) {
|
|
912
|
+
if (!scope.scenarios)
|
|
913
|
+
return { scenarios: [], errors: [] };
|
|
914
|
+
const dir = fileURLToPath(scope.scenarios);
|
|
915
|
+
if (!existsSync(dir))
|
|
916
|
+
return { scenarios: [], errors: [] };
|
|
917
|
+
// Built first, so a phrase naming a field its input lacks rejects even with no feature files.
|
|
918
|
+
builtFor(scope);
|
|
919
|
+
const files = featureFiles(dir);
|
|
920
|
+
const stamp = JSON.stringify([
|
|
921
|
+
dir,
|
|
922
|
+
files.map((file) => {
|
|
923
|
+
const { mtimeMs, size } = statSync(join(dir, file));
|
|
924
|
+
return [file, mtimeMs, size];
|
|
925
|
+
}),
|
|
926
|
+
]);
|
|
927
|
+
const cached = loadedByScope.get(scope);
|
|
928
|
+
if (cached?.stamp === stamp)
|
|
929
|
+
return cached.loaded;
|
|
930
|
+
const loaded = files.length ? compile(scope, dir, files) : Promise.resolve({ scenarios: [], errors: [] });
|
|
931
|
+
loadedByScope.set(scope, { stamp, loaded });
|
|
932
|
+
// A compile that throws (a bug) is not kept: the next call tries again.
|
|
933
|
+
loaded.catch(() => {
|
|
934
|
+
if (loadedByScope.get(scope)?.loaded === loaded)
|
|
935
|
+
loadedByScope.delete(scope);
|
|
936
|
+
});
|
|
937
|
+
return loaded;
|
|
938
|
+
}
|
|
939
|
+
/**
|
|
940
|
+
* Checks a run's ledger records against the scenario, as its feature file reads now: Cucumber
|
|
941
|
+
* runs the scenario's `Then` steps, each judging the run on its own, and the rest of its steps
|
|
942
|
+
* do nothing. One check per expectation (`scenario.expect`), in order, a step that does not hold
|
|
943
|
+
* with why as its `detail`. Rejects when the scenario no longer compiles as it did (a step the
|
|
944
|
+
* file no longer matches, say): load the scenarios again first, as the app does.
|
|
945
|
+
*/
|
|
946
|
+
export async function check(scope, scenario, records) {
|
|
947
|
+
if (!scope.scenarios)
|
|
948
|
+
throw new Error(`No scenarios directory to check scenario "${scenario.name}" from`);
|
|
949
|
+
const built = builtFor(scope);
|
|
950
|
+
const { file, line, name } = scenario;
|
|
951
|
+
const verdicts = new Map();
|
|
952
|
+
const report = await runFeatures(built.library, checkWorld(built, scenario.workflow, viewOf(records), verdicts), {
|
|
953
|
+
cwd: fileURLToPath(scope.scenarios),
|
|
954
|
+
files: [file],
|
|
955
|
+
line,
|
|
956
|
+
isBug,
|
|
957
|
+
});
|
|
958
|
+
const where = `${file}:${line}`;
|
|
959
|
+
if (report.parseErrors.length)
|
|
960
|
+
throw new Error(`${where}: ${report.parseErrors.map((e) => e.message).join("; ")}`);
|
|
961
|
+
const names = namesOf(report);
|
|
962
|
+
const pickle = report.query
|
|
963
|
+
.findAllPickles()
|
|
964
|
+
.find((p) => p.uri === file && lineOf(report, p) === line && names.get(p.id) === name);
|
|
965
|
+
if (!pickle)
|
|
966
|
+
throw new Error(`${where}: no scenario "${name}" there now`);
|
|
967
|
+
const results = stepResults(report);
|
|
968
|
+
const checks = [];
|
|
969
|
+
for (const step of pickle.steps) {
|
|
970
|
+
const expectation = scenario.expect[checks.length];
|
|
971
|
+
// Only a `Then` step expects: a `Given` can read the same as one (`{op} was called with`).
|
|
972
|
+
if (expectation?.step === step.text && KINDS[step.type] === "then" && verdicts.has(step.id)) {
|
|
973
|
+
checks.push(verdict(step.text, verdicts.get(step.id)));
|
|
974
|
+
continue;
|
|
975
|
+
}
|
|
976
|
+
const error = stepError(built, results.get(step.id), step, whereOf(report, file, step));
|
|
977
|
+
if (error)
|
|
978
|
+
throw new Error(error);
|
|
979
|
+
}
|
|
980
|
+
if (checks.length !== scenario.expect.length) {
|
|
981
|
+
throw new Error(`${where}: scenario "${name}" no longer has the expectations it was read with`);
|
|
982
|
+
}
|
|
983
|
+
return checks;
|
|
984
|
+
}
|
|
985
|
+
/**
|
|
986
|
+
* The scenarios of the `.feature` files under `dir` (a `file:` URL to a directory), in order,
|
|
987
|
+
* named as `loadScenarios` names them, from a dry run: what a test runner lists before it runs
|
|
988
|
+
* each (`testFeature`). Rejects, naming the file, for one Gherkin cannot parse.
|
|
989
|
+
*/
|
|
990
|
+
export async function featureScenarios(scope, dir) {
|
|
991
|
+
const cwd = fileURLToPath(dir);
|
|
992
|
+
const report = await runFeatures(builtFor(scope).library, () => ({ begin() { }, step() { } }), {
|
|
993
|
+
cwd,
|
|
994
|
+
files: featureFiles(cwd),
|
|
995
|
+
dryRun: true,
|
|
996
|
+
isBug,
|
|
997
|
+
});
|
|
998
|
+
if (report.parseErrors.length)
|
|
999
|
+
throw new Error(report.parseErrors.map(parseErrorOf).join("\n"));
|
|
1000
|
+
const names = namesOf(report);
|
|
1001
|
+
return report.query.findAllPickles().map((pickle) => ({
|
|
1002
|
+
file: pickle.uri,
|
|
1003
|
+
line: lineOf(report, pickle),
|
|
1004
|
+
name: names.get(pickle.id),
|
|
1005
|
+
tags: pickle.tags.map((t) => t.name),
|
|
1006
|
+
}));
|
|
1007
|
+
}
|
|
1008
|
+
/**
|
|
1009
|
+
* Runs the scenario at `line` of the feature file `file` (absolute) as an operation's own test,
|
|
1010
|
+
* on the target: its `Given` steps seed through it, its `When` steps call its operations directly
|
|
1011
|
+
* (no worker, no policy), and its `Then` steps check those calls. Rejects with its first step
|
|
1012
|
+
* that did not pass, at `file:line`.
|
|
1013
|
+
*/
|
|
1014
|
+
export async function testFeature(scope, file, line, target) {
|
|
1015
|
+
const built = builtFor(scope);
|
|
1016
|
+
const name = basename(file);
|
|
1017
|
+
const report = await runFeatures(built.library, testWorld(built, target), {
|
|
1018
|
+
cwd: dirname(file),
|
|
1019
|
+
files: [name],
|
|
1020
|
+
line,
|
|
1021
|
+
isBug,
|
|
1022
|
+
});
|
|
1023
|
+
if (report.parseErrors.length)
|
|
1024
|
+
throw new Error(report.parseErrors.map(parseErrorOf).join("\n"));
|
|
1025
|
+
const results = stepResults(report);
|
|
1026
|
+
for (const pickle of report.query.findAllPickles()) {
|
|
1027
|
+
for (const step of pickle.steps) {
|
|
1028
|
+
const error = stepError(built, results.get(step.id), step, whereOf(report, name, step));
|
|
1029
|
+
if (error)
|
|
1030
|
+
throw new Error(error);
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
/**
|
|
1035
|
+
* Decides the run's approvals as the scenario says until the run ends, and returns how it
|
|
1036
|
+
* ended. Each pending approval takes the decision naming its title (or id), else the next one
|
|
1037
|
+
* left that names no approval of the run (one it has asked for, or one its code names). Throws
|
|
1038
|
+
* for an approval with no such decision, and `run_running` when the run has not ended within
|
|
1039
|
+
* `timeoutMs` or the worker has not read a decision by then.
|
|
1040
|
+
*/
|
|
1041
|
+
export async function drive(client, runId, scenario, { timeoutMs = 15_000 } = {}) {
|
|
1042
|
+
const left = [...scenario.decisions];
|
|
1043
|
+
const deadline = Date.now() + timeoutMs;
|
|
1044
|
+
for (;;) {
|
|
1045
|
+
const run = await client.run(runId);
|
|
1046
|
+
if (!run)
|
|
1047
|
+
throw new SanomaError("run_not_found", `No run ${runId}`, { runId });
|
|
1048
|
+
if (isEnded(run.status))
|
|
1049
|
+
return run;
|
|
1050
|
+
const names = new Set([...scenario.approvals, ...run.approvals.flatMap((a) => [a.title, a.id])]);
|
|
1051
|
+
for (const approval of run.approvals.filter((a) => a.status === "pending")) {
|
|
1052
|
+
const named = left.findIndex((d) => d.approval === approval.title || d.approval === approval.id);
|
|
1053
|
+
const next = named === -1 ? left.findIndex((d) => !names.has(d.approval)) : named;
|
|
1054
|
+
if (next === -1) {
|
|
1055
|
+
const which = left.length
|
|
1056
|
+
? `; the decisions left are for ${left.map((d) => `"${d.approval}"`).join(", ")}`
|
|
1057
|
+
: "";
|
|
1058
|
+
throw new Error(`Scenario "${scenario.name}" has no decision for "${approval.title}" (${approval.id})${which}`);
|
|
1059
|
+
}
|
|
1060
|
+
const [d] = left.splice(next, 1);
|
|
1061
|
+
const note = d.note === undefined ? {} : { note: d.note };
|
|
1062
|
+
const timeoutSeconds = Math.max(1, Math.ceil((deadline - Date.now()) / 1000));
|
|
1063
|
+
const decided = await client.decide(runId, { decision: d.decision, by: { id: d.by }, ...note }, approval.id, {
|
|
1064
|
+
timeoutSeconds,
|
|
1065
|
+
});
|
|
1066
|
+
if (decided.status === "pending") {
|
|
1067
|
+
throw new SanomaError("run_running", `The worker did not read the decision for "${approval.title}" (${approval.id}) of run ${runId} within ${timeoutMs} ms`, { runId, approvalId: approval.id, timeoutMs });
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
if (Date.now() > deadline) {
|
|
1071
|
+
throw new SanomaError("run_running", `Run ${runId} is still running after ${timeoutMs} ms`, {
|
|
1072
|
+
runId,
|
|
1073
|
+
timeoutMs,
|
|
1074
|
+
});
|
|
1075
|
+
}
|
|
1076
|
+
await delay(100);
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
//# sourceMappingURL=scenario.js.map
|