@sanoma-ai/workflows 0.2.0 → 0.4.1
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/README.md +146 -85
- package/dist/approvals.d.ts +11 -6
- package/dist/approvals.js +38 -10
- package/dist/approvals.js.map +1 -1
- package/dist/call-workflow.d.ts +16 -0
- package/dist/call-workflow.js +35 -0
- package/dist/call-workflow.js.map +1 -0
- package/dist/call.d.ts +33 -4
- package/dist/call.js +107 -42
- package/dist/call.js.map +1 -1
- package/dist/cedar.d.ts +87 -0
- package/dist/cedar.js +365 -0
- package/dist/cedar.js.map +1 -0
- package/dist/client.d.ts +85 -7
- package/dist/client.js +289 -25
- package/dist/client.js.map +1 -1
- package/dist/collection.d.ts +107 -0
- package/dist/collection.js +306 -0
- package/dist/collection.js.map +1 -0
- package/dist/config.d.ts +42 -14
- package/dist/config.js +82 -18
- package/dist/config.js.map +1 -1
- package/dist/constructors.d.ts +2 -0
- package/dist/constructors.js +5 -0
- package/dist/constructors.js.map +1 -0
- package/dist/data.d.ts +2 -0
- package/dist/data.js +3 -0
- package/dist/data.js.map +1 -0
- package/dist/datafile.d.ts +28 -2
- package/dist/datafile.js +118 -19
- package/dist/datafile.js.map +1 -1
- package/dist/db.d.ts +5 -3
- package/dist/db.js +88 -21
- package/dist/db.js.map +1 -1
- package/dist/define.d.ts +14 -5
- package/dist/define.js +1 -5
- package/dist/define.js.map +1 -1
- package/dist/describe.d.ts +45 -5
- package/dist/describe.js +72 -7
- package/dist/describe.js.map +1 -1
- package/dist/direct.d.ts +9 -0
- package/dist/direct.js +11 -0
- package/dist/direct.js.map +1 -0
- package/dist/errors.d.ts +16 -1
- package/dist/errors.js +30 -1
- package/dist/errors.js.map +1 -1
- package/dist/expectations.d.ts +3 -3
- package/dist/expectations.js +16 -7
- package/dist/expectations.js.map +1 -1
- package/dist/fill.d.ts +4 -0
- package/dist/fill.js +13 -0
- package/dist/fill.js.map +1 -1
- package/dist/index.d.ts +8 -4
- package/dist/index.js +10 -4
- package/dist/index.js.map +1 -1
- package/dist/ledger.d.ts +12 -1
- package/dist/ledger.js +18 -11
- package/dist/ledger.js.map +1 -1
- package/dist/lint.d.ts +5 -4
- package/dist/lint.js +24 -7
- package/dist/lint.js.map +1 -1
- package/dist/op.d.ts +6 -0
- package/dist/op.js.map +1 -1
- package/dist/outline.d.ts +2 -0
- package/dist/outline.js +1 -1
- package/dist/outline.js.map +1 -1
- package/dist/paths.d.ts +4 -0
- package/dist/paths.js +17 -3
- package/dist/paths.js.map +1 -1
- package/dist/person.d.ts +61 -0
- package/dist/person.js +69 -0
- package/dist/person.js.map +1 -0
- package/dist/plugin.d.ts +28 -0
- package/dist/plugin.js +52 -1
- package/dist/plugin.js.map +1 -1
- package/dist/policy.d.ts +51 -55
- package/dist/policy.js +14 -36
- package/dist/policy.js.map +1 -1
- package/dist/resource.d.ts +21 -5
- package/dist/resource.js +12 -3
- package/dist/resource.js.map +1 -1
- package/dist/resources.d.ts +58 -6
- package/dist/resources.js +119 -26
- package/dist/resources.js.map +1 -1
- package/dist/run.d.ts +17 -1
- package/dist/run.js.map +1 -1
- package/dist/scenario.d.ts +16 -9
- package/dist/scenario.js +149 -44
- package/dist/scenario.js.map +1 -1
- package/dist/scenarios.d.ts +10 -6
- package/dist/scenarios.js +8 -4
- package/dist/scenarios.js.map +1 -1
- package/dist/schema.d.ts +32 -0
- package/dist/schema.js +225 -0
- package/dist/schema.js.map +1 -0
- package/dist/sdk.d.ts +34 -0
- package/dist/sdk.js +168 -0
- package/dist/sdk.js.map +1 -0
- package/dist/shared.d.ts +181 -5
- package/dist/shared.js +160 -7
- package/dist/shared.js.map +1 -1
- package/dist/sites.d.ts +52 -0
- package/dist/sites.js +174 -0
- package/dist/sites.js.map +1 -0
- package/dist/version.d.ts +3 -2
- package/dist/version.js +3 -2
- package/dist/version.js.map +1 -1
- package/dist/worker.d.ts +3 -2
- package/dist/worker.js +24 -4
- package/dist/worker.js.map +1 -1
- package/oxlint.json +7 -20
- package/package.json +8 -3
- package/src/approvals.ts +49 -13
- package/src/call-workflow.ts +44 -0
- package/src/call.ts +157 -45
- package/src/cedar.ts +493 -0
- package/src/client.ts +368 -37
- package/src/collection.ts +431 -0
- package/src/config.ts +131 -34
- package/src/constructors.ts +4 -0
- package/src/data.ts +10 -0
- package/src/datafile.ts +141 -21
- package/src/db.ts +90 -22
- package/src/define.ts +15 -10
- package/src/describe.ts +113 -10
- package/src/direct.ts +15 -0
- package/src/errors.ts +36 -1
- package/src/expectations.ts +17 -8
- package/src/fill.ts +15 -0
- package/src/index.ts +33 -2
- package/src/ledger.ts +35 -17
- package/src/lint.ts +29 -12
- package/src/op.ts +6 -0
- package/src/outline.ts +1 -1
- package/src/paths.ts +18 -3
- package/src/person.ts +75 -0
- package/src/plugin.ts +74 -5
- package/src/policy.ts +59 -63
- package/src/resource.ts +35 -8
- package/src/resources.ts +165 -24
- package/src/run.ts +18 -1
- package/src/scenario.ts +186 -57
- package/src/scenarios.ts +15 -8
- package/src/schema.ts +261 -0
- package/src/sdk.ts +211 -0
- package/src/shared.ts +271 -8
- package/src/sites.ts +220 -0
- package/src/version.ts +3 -2
- package/src/worker.ts +29 -6
package/src/scenario.ts
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
} from "@cucumber/messages";
|
|
14
14
|
import type { z } from "zod";
|
|
15
15
|
import type { RunSummary, SanomaClient } from "./client.ts";
|
|
16
|
+
import type { Approver } from "./define.ts";
|
|
16
17
|
import type { ResolvedConfig } from "./config.ts";
|
|
17
18
|
import {
|
|
18
19
|
buildLibrary,
|
|
@@ -25,22 +26,30 @@ import {
|
|
|
25
26
|
stepResults,
|
|
26
27
|
StepStatus,
|
|
27
28
|
} from "./cucumber.ts";
|
|
28
|
-
import { errorInfo, SanomaError } from "./errors.ts";
|
|
29
|
+
import { errorCode, errorInfo, SanomaError } from "./errors.ts";
|
|
29
30
|
import { type CallRecord, contains, type CoreExpectation, detailOf, isCall } from "./expectations.ts";
|
|
30
31
|
import type { Fake } from "./fake.ts";
|
|
31
|
-
import { fill, isBug, seedFrom, shapeOf } from "./fill.ts";
|
|
32
|
+
import { fill, isBug, objectUnder, restOf, seedFrom, shapeOf } from "./fill.ts";
|
|
32
33
|
import type { LedgerRecord } from "./ledger.ts";
|
|
33
34
|
import type { Driver, Op } from "./op.ts";
|
|
34
35
|
import { type OutlineNode, outlineWorkflow } from "./outline.ts";
|
|
36
|
+
import { isObject } from "./resource.ts";
|
|
37
|
+
import { admit, type DeclaredPeople, readPeople } from "./resources.ts";
|
|
35
38
|
import { memorySecrets } from "./secret.ts";
|
|
36
39
|
import {
|
|
37
40
|
APPLY_WORKFLOW,
|
|
38
41
|
BUILT_IN_WORKFLOWS,
|
|
42
|
+
DATA_VENDOR,
|
|
43
|
+
declaresPeople,
|
|
39
44
|
DRIFT_WORKFLOW,
|
|
40
45
|
errorMessage,
|
|
41
46
|
fitsOp,
|
|
42
47
|
flatten,
|
|
48
|
+
groupNamesOf,
|
|
43
49
|
isEnded,
|
|
50
|
+
principalIdsOf,
|
|
51
|
+
problemLine,
|
|
52
|
+
RUNTIME_AGENTS,
|
|
44
53
|
} from "./shared.ts";
|
|
45
54
|
import type { Call, RunView, Seed } from "./steps.ts";
|
|
46
55
|
|
|
@@ -98,9 +107,11 @@ export interface Decision {
|
|
|
98
107
|
/**
|
|
99
108
|
* What the run should do: call an operation (with at least this input) or not, run the step
|
|
100
109
|
* `name`d in the workflow's outline or not, or a number of `times`, have the policy deny an
|
|
101
|
-
* operation (`denied
|
|
102
|
-
*
|
|
103
|
-
*
|
|
110
|
+
* operation (`denied`, for a `reason` when the step names one) or hold one for an approver
|
|
111
|
+
* (`held`: a person's id or a group's name, or `{ group }` a group's alone), end a certain way,
|
|
112
|
+
* for a drift run, report the resource `declared` (`<file>#<export>`) clean, gone, absent, or
|
|
113
|
+
* drifted in a field, or for an apply run, create it, or leave it bound to an object (`bound`,
|
|
114
|
+
* its vendor's id).
|
|
104
115
|
* A step whose own code checks the run (a connector's or the company's, or an operation's own
|
|
105
116
|
* test's) is `custom`, with what it is about.
|
|
106
117
|
*/
|
|
@@ -108,8 +119,8 @@ export type Expectation =
|
|
|
108
119
|
| { step: string; op: string; input?: unknown; called: boolean }
|
|
109
120
|
| { step: string; name: string; input?: unknown; called: boolean }
|
|
110
121
|
| { step: string; name: string; times: number }
|
|
111
|
-
| { step: string; op: string; decision: "denied" }
|
|
112
|
-
| { step: string; op: string; decision: "held"; approver:
|
|
122
|
+
| { step: string; op: string; decision: "denied"; reason?: string }
|
|
123
|
+
| { step: string; op: string; decision: "held"; approver: Approver }
|
|
113
124
|
| { step: string; outcome: "finished" | "failed"; code?: string }
|
|
114
125
|
| { step: string; declared: string; status: "clean" | "gone" | "absent" }
|
|
115
126
|
| { step: string; declared: string; status: "drifted"; field: string }
|
|
@@ -132,6 +143,8 @@ export interface Scenario {
|
|
|
132
143
|
text: string;
|
|
133
144
|
/** Every step in order, for a list and for marking graph nodes. */
|
|
134
145
|
steps: ScenarioStep[];
|
|
146
|
+
/** Who starts the run, by id, when a `Given <who> is acting` says: a declared person or agent. */
|
|
147
|
+
startedBy?: string;
|
|
135
148
|
given: Given[];
|
|
136
149
|
input: unknown;
|
|
137
150
|
decisions: Decision[];
|
|
@@ -152,10 +165,14 @@ export interface Check {
|
|
|
152
165
|
}
|
|
153
166
|
|
|
154
167
|
/**
|
|
155
|
-
* What steps can name: the config's operations, workflows
|
|
156
|
-
* its connectors (and the company) ship,
|
|
168
|
+
* What steps can name: the config's operations, workflows, resource types and collections by id,
|
|
169
|
+
* the steps its connectors (and the company) ship, where the feature files are, and the root
|
|
170
|
+
* whose data files declare its people.
|
|
157
171
|
*/
|
|
158
|
-
export type Scope = Pick<
|
|
172
|
+
export type Scope = Pick<
|
|
173
|
+
ResolvedConfig,
|
|
174
|
+
"ops" | "workflows" | "scenarios" | "types" | "collections" | "steps" | "root"
|
|
175
|
+
>;
|
|
159
176
|
|
|
160
177
|
type Kind = ScenarioStep["kind"];
|
|
161
178
|
type Fault = Extract<Seed, { fault: unknown }>["fault"];
|
|
@@ -170,9 +187,13 @@ interface Then {
|
|
|
170
187
|
verify(run: RunView): string | undefined;
|
|
171
188
|
}
|
|
172
189
|
|
|
173
|
-
/**
|
|
190
|
+
/**
|
|
191
|
+
* What a step of each kind reads into; `any` is a decision, which a step of any keyword may make,
|
|
192
|
+
* and `actor` a `Given` saying who starts the run (`{who} is acting`), by id.
|
|
193
|
+
*/
|
|
174
194
|
interface Items {
|
|
175
195
|
given: Unstepped<Given>[];
|
|
196
|
+
actor: string;
|
|
176
197
|
when: When;
|
|
177
198
|
then: Then;
|
|
178
199
|
any: Unstepped<Decision>;
|
|
@@ -209,22 +230,50 @@ const FAULTS = {
|
|
|
209
230
|
"loses its reply once": "loseReply",
|
|
210
231
|
} as const;
|
|
211
232
|
|
|
212
|
-
/**
|
|
233
|
+
/**
|
|
234
|
+
* The schema of the field `name` of an object schema (`owner`, for the error), or of the fields it
|
|
235
|
+
* takes besides its own (`restOf`); throws naming the fields it has.
|
|
236
|
+
*/
|
|
213
237
|
function fieldOf(schema: z.ZodType, name: string, owner: string): z.ZodType {
|
|
214
238
|
const shape = shapeOf(schema);
|
|
215
|
-
if (
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
239
|
+
if (Object.hasOwn(shape, name)) return shape[name]!;
|
|
240
|
+
const rest = restOf(schema);
|
|
241
|
+
if (rest) return rest;
|
|
242
|
+
throw new Error(`no field "${name}" in ${owner}; it has ${Object.keys(shape).join(", ") || "none"}`);
|
|
219
243
|
}
|
|
220
244
|
|
|
221
245
|
const issues = (error: z.ZodError) => error.issues.map((i) => i.message).join("; ");
|
|
222
246
|
|
|
247
|
+
/**
|
|
248
|
+
* Why `field` refuses `value`, or undefined when it takes it. `partial`, for what a `Then` expects
|
|
249
|
+
* at least: an object may leave fields out at any depth, and only the fields it has are checked.
|
|
250
|
+
*/
|
|
251
|
+
function refusal(field: z.ZodType, value: unknown, what: string, partial: boolean): string | undefined {
|
|
252
|
+
const object = partial && isObject(value) ? objectUnder(field) : undefined;
|
|
253
|
+
if (object) return fieldsRefusal(object, value as Record<string, unknown>, what, true);
|
|
254
|
+
const parsed = field.safeParse(value);
|
|
255
|
+
return parsed.success ? undefined : `${what} cannot be ${JSON.stringify(value)}: ${issues(parsed.error)}`;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Why the object schema refuses the first of `fields` it refuses (`refusal`), `owner` its owner in errors. */
|
|
259
|
+
function fieldsRefusal(
|
|
260
|
+
schema: z.ZodType,
|
|
261
|
+
fields: Record<string, unknown>,
|
|
262
|
+
owner: string,
|
|
263
|
+
partial: boolean,
|
|
264
|
+
): string | undefined {
|
|
265
|
+
for (const [name, v] of Object.entries(fields)) {
|
|
266
|
+
const why = refusal(fieldOf(schema, name, owner), v, `${name} in ${owner}`, partial);
|
|
267
|
+
if (why) return why;
|
|
268
|
+
}
|
|
269
|
+
return undefined;
|
|
270
|
+
}
|
|
271
|
+
|
|
223
272
|
/**
|
|
224
273
|
* The value text from a table cell or a phrase gives a field: the text itself when the field
|
|
225
274
|
* takes it, else the text read as JSON (a number, `true`, a list) when the field takes that.
|
|
226
275
|
*/
|
|
227
|
-
function valueFor(field: z.ZodType, text: string, what: string): unknown {
|
|
276
|
+
function valueFor(field: z.ZodType, text: string, what: string, partial = false): unknown {
|
|
228
277
|
const asText = field.safeParse(text);
|
|
229
278
|
if (asText.success) return text;
|
|
230
279
|
let json: unknown;
|
|
@@ -233,17 +282,18 @@ function valueFor(field: z.ZodType, text: string, what: string): unknown {
|
|
|
233
282
|
} catch {
|
|
234
283
|
throw new Error(`${what} cannot be ${JSON.stringify(text)}: ${issues(asText.error)}`);
|
|
235
284
|
}
|
|
236
|
-
const
|
|
237
|
-
if (
|
|
285
|
+
const why = refusal(field, json, what, partial);
|
|
286
|
+
if (why) throw new Error(why);
|
|
238
287
|
return json;
|
|
239
288
|
}
|
|
240
289
|
|
|
241
290
|
/**
|
|
242
291
|
* The fields the step's doc string (a JSON object) or two-column table gives `owner` (an
|
|
243
292
|
* operation's input, or a resource), each checked against its field in `schema`: a field it
|
|
244
|
-
* lacks, or a value it refuses, is an error.
|
|
293
|
+
* lacks, or a value it refuses, is an error. `partial` (a `Then`'s "at least these fields"):
|
|
294
|
+
* an object value may leave fields out, at any depth.
|
|
245
295
|
*/
|
|
246
|
-
function fieldsFrom(pickle: PickleStep, schema: z.ZodType, owner: string): Record<string, unknown> {
|
|
296
|
+
function fieldsFrom(pickle: PickleStep, schema: z.ZodType, owner: string, partial = false): Record<string, unknown> {
|
|
247
297
|
const { docString, dataTable } = pickle.argument ?? {};
|
|
248
298
|
const what = (name: string) => `${name} in ${owner}`;
|
|
249
299
|
if (dataTable) {
|
|
@@ -251,7 +301,7 @@ function fieldsFrom(pickle: PickleStep, schema: z.ZodType, owner: string): Recor
|
|
|
251
301
|
dataTable.rows.map(({ cells }) => {
|
|
252
302
|
if (cells.length !== 2) throw new Error(`the table after "${pickle.text}" needs two columns: name | value`);
|
|
253
303
|
const [name, text] = cells.map((c) => c.value) as [string, string];
|
|
254
|
-
return [name, valueFor(fieldOf(schema, name, owner), text, what(name))];
|
|
304
|
+
return [name, valueFor(fieldOf(schema, name, owner), text, what(name), partial)];
|
|
255
305
|
}),
|
|
256
306
|
);
|
|
257
307
|
}
|
|
@@ -262,14 +312,10 @@ function fieldsFrom(pickle: PickleStep, schema: z.ZodType, owner: string): Recor
|
|
|
262
312
|
} catch (err) {
|
|
263
313
|
throw new Error(`the doc string after "${pickle.text}" is not JSON: ${errorMessage(err)}`, { cause: err });
|
|
264
314
|
}
|
|
265
|
-
if (
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
const parsed = fieldOf(schema, name, owner).safeParse(v);
|
|
270
|
-
if (!parsed.success) throw new Error(`${what(name)} cannot be ${JSON.stringify(v)}: ${issues(parsed.error)}`);
|
|
271
|
-
}
|
|
272
|
-
return value as Record<string, unknown>;
|
|
315
|
+
if (!isObject(value)) throw new Error(`"${pickle.text}" needs a JSON object`);
|
|
316
|
+
const why = fieldsRefusal(schema, value, owner, partial);
|
|
317
|
+
if (why) throw new Error(why);
|
|
318
|
+
return value;
|
|
273
319
|
}
|
|
274
320
|
|
|
275
321
|
/** A parameter's transform: the name, when `ids` has it. */
|
|
@@ -314,7 +360,9 @@ function builtFor(scope: Scope): Built {
|
|
|
314
360
|
cached &&
|
|
315
361
|
cached.scope.workflows === scope.workflows &&
|
|
316
362
|
cached.scope.types === scope.types &&
|
|
317
|
-
cached.scope.
|
|
363
|
+
cached.scope.collections === scope.collections &&
|
|
364
|
+
cached.scope.steps === scope.steps &&
|
|
365
|
+
cached.scope.root === scope.root
|
|
318
366
|
) {
|
|
319
367
|
return cached;
|
|
320
368
|
}
|
|
@@ -333,24 +381,59 @@ const expecting = (expect: CoreExpectation): Then => ({ expect, verify: (run) =>
|
|
|
333
381
|
|
|
334
382
|
/** The rules a scope has, and the parameter types their expressions use. */
|
|
335
383
|
function rulesOf(scope: Scope): { rules: Rule[]; parameterTypes: ParameterTypeSpec[] } {
|
|
336
|
-
const { types } = scope;
|
|
384
|
+
const { types, collections } = scope;
|
|
385
|
+
// A collection's records are seeded as a resource's objects are, by `data.<name>`, through its operations.
|
|
386
|
+
const collectionOf = (id: unknown) =>
|
|
387
|
+
(id as string).startsWith(`${DATA_VENDOR}.`)
|
|
388
|
+
? collections.get((id as string).slice(DATA_VENDOR.length + 1))
|
|
389
|
+
: undefined;
|
|
390
|
+
const seedable = new Map<string, unknown>([
|
|
391
|
+
...types,
|
|
392
|
+
...[...collections.keys()].map((n) => [`${DATA_VENDOR}.${n}`, n] as const),
|
|
393
|
+
]);
|
|
337
394
|
const parameterTypes: ParameterTypeSpec[] = [
|
|
338
395
|
{ name: "op", regexp: /\S+/, transformer: known("operation", scope.ops) },
|
|
339
396
|
{ name: "workflow", regexp: /\S+/, transformer: known("workflow", scope.workflows) },
|
|
340
397
|
{ name: "who", regexp: /\S+/, transformer: (s) => s },
|
|
341
|
-
{ name: "resource", regexp: /\S+/, transformer: known("resource type",
|
|
398
|
+
{ name: "resource", regexp: /\S+/, transformer: known("resource type", seedable) },
|
|
342
399
|
// A declared resource, as a drift report names it: `<file>#<export>`.
|
|
343
400
|
{ name: "declared", regexp: /[^\s#]+#\S+/, transformer: (s) => s },
|
|
344
401
|
{ name: "field", regexp: /\S+/, transformer: (s) => s },
|
|
345
402
|
];
|
|
346
403
|
const opOf = (id: unknown) => scope.ops.get(id as string)!;
|
|
347
404
|
const workflowOf = (name: unknown) => scope.workflows.get(name as string)!;
|
|
405
|
+
// The people as the root's data files declare them when a step names one (`readPeople`, read
|
|
406
|
+
// again only once a file changed): while they declare no one, any id and any group go.
|
|
407
|
+
/** `who`, as a run's start admits them (`admit`): a declared person or agent. */
|
|
408
|
+
const actorNamed = (who: string) => {
|
|
409
|
+
const declared = readPeople(scope.root);
|
|
410
|
+
try {
|
|
411
|
+
return admit({ id: who }, declared).id;
|
|
412
|
+
} catch (err) {
|
|
413
|
+
if (errorCode(err) !== "not_admitted" || declared.problems.length) throw err;
|
|
414
|
+
const ids = [...principalIdsOf(declared.people), ...RUNTIME_AGENTS].join(", ");
|
|
415
|
+
throw new Error(`${who} is not a declared person or agent; resources/ declares ${ids}`, { cause: err });
|
|
416
|
+
}
|
|
417
|
+
};
|
|
418
|
+
/** `group`, a declared group or `agents`, while the people's files have no problems. */
|
|
419
|
+
const groupNamed = (group: string) => {
|
|
420
|
+
const { people, problems } = readPeople(scope.root);
|
|
421
|
+
if (problems.length) {
|
|
422
|
+
throw new Error(
|
|
423
|
+
`no group can be told while the people's files have problems: ${problems.map(problemLine).join("; ")}`,
|
|
424
|
+
);
|
|
425
|
+
}
|
|
426
|
+
const groups = groupNamesOf(people);
|
|
427
|
+
if (!declaresPeople(people) || groups.includes(group)) return group;
|
|
428
|
+
throw new Error(`no group "${group}"; resources/ declares ${groups.join(", ")}`);
|
|
429
|
+
};
|
|
348
430
|
/** A resource step's object: its type's id, vendor and type, and its import id. */
|
|
349
431
|
const object = (id: unknown, name: unknown) => {
|
|
350
432
|
const { vendor, type } = types.get(id as string)!;
|
|
351
433
|
return { resource: id as string, vendor, type, name: name as string };
|
|
352
434
|
};
|
|
353
435
|
const rules: Rule[] = [
|
|
436
|
+
{ kind: "actor", source: "{who} is acting", read: ([who]) => actorNamed(who as string) },
|
|
354
437
|
{
|
|
355
438
|
kind: "given",
|
|
356
439
|
source: "{op} was called with",
|
|
@@ -368,21 +451,32 @@ function rulesOf(scope: Scope): { rules: Rule[]; parameterTypes: ParameterTypeSp
|
|
|
368
451
|
kind: "given",
|
|
369
452
|
source: "{resource} {string} has",
|
|
370
453
|
takes: TAKES_OBJECT,
|
|
371
|
-
read: ([resource, name], pickle) =>
|
|
372
|
-
{
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
)
|
|
379
|
-
|
|
380
|
-
|
|
454
|
+
read: ([resource, name], pickle) => {
|
|
455
|
+
const owner = `${resource as string} "${name as string}"`;
|
|
456
|
+
const c = collectionOf(resource);
|
|
457
|
+
if (c) {
|
|
458
|
+
// The record with its key, the fields the step gives, and the rest made up.
|
|
459
|
+
const record = shapeOf(opOf(c.save.id).input).record!;
|
|
460
|
+
const fields = fieldsFrom(pickle, record, owner);
|
|
461
|
+
if (Object.hasOwn(fields, c.key) && fields[c.key] !== name) {
|
|
462
|
+
throw new Error(
|
|
463
|
+
`${c.key} in ${owner} is ${JSON.stringify(fields[c.key])}: it is the key, "${name as string}"`,
|
|
464
|
+
);
|
|
465
|
+
}
|
|
466
|
+
return [{ op: c.save.id, input: { record: fill(record, { ...fields, [c.key]: name }) } }];
|
|
467
|
+
}
|
|
468
|
+
return [
|
|
469
|
+
{ ...object(resource, name), fields: fieldsFrom(pickle, types.get(resource as string)!.schema, owner) },
|
|
470
|
+
];
|
|
471
|
+
},
|
|
381
472
|
},
|
|
382
473
|
{
|
|
383
474
|
kind: "given",
|
|
384
475
|
source: "{resource} {string} is gone",
|
|
385
|
-
read: ([resource, name]) =>
|
|
476
|
+
read: ([resource, name]) => {
|
|
477
|
+
const c = collectionOf(resource);
|
|
478
|
+
return [c ? { op: c.delete.id, input: { key: name } } : { ...object(resource, name), gone: true }];
|
|
479
|
+
},
|
|
386
480
|
},
|
|
387
481
|
{
|
|
388
482
|
kind: "when",
|
|
@@ -436,7 +530,7 @@ function rulesOf(scope: Scope): { rules: Rule[]; parameterTypes: ParameterTypeSp
|
|
|
436
530
|
source: "{op} was called with",
|
|
437
531
|
takes: TAKES_OBJECT,
|
|
438
532
|
read: ([op], pickle) =>
|
|
439
|
-
expecting({ op: op as string, input: fieldsFrom(pickle, opOf(op).input, inputOf(op)), called: true }),
|
|
533
|
+
expecting({ op: op as string, input: fieldsFrom(pickle, opOf(op).input, inputOf(op), true), called: true }),
|
|
440
534
|
},
|
|
441
535
|
...[true, false].map((called): Rule => ({
|
|
442
536
|
kind: "then",
|
|
@@ -448,11 +542,22 @@ function rulesOf(scope: Scope): { rules: Rule[]; parameterTypes: ParameterTypeSp
|
|
|
448
542
|
source: "{op} was denied",
|
|
449
543
|
read: ([op]) => expecting({ op: op as string, decision: "denied" }),
|
|
450
544
|
},
|
|
545
|
+
{
|
|
546
|
+
kind: "then",
|
|
547
|
+
source: "{op} was denied with {string}",
|
|
548
|
+
read: ([op, reason]) => expecting({ op: op as string, decision: "denied", reason: reason as string }),
|
|
549
|
+
},
|
|
451
550
|
{
|
|
452
551
|
kind: "then",
|
|
453
552
|
source: "{op} was held for {who}",
|
|
454
553
|
read: ([op, who]) => expecting({ op: op as string, decision: "held", approver: who as string }),
|
|
455
554
|
},
|
|
555
|
+
{
|
|
556
|
+
kind: "then",
|
|
557
|
+
source: "{op} was held for group {string}",
|
|
558
|
+
read: ([op, group]) =>
|
|
559
|
+
expecting({ op: op as string, decision: "held", approver: { group: groupNamed(group as string) } }),
|
|
560
|
+
},
|
|
456
561
|
{
|
|
457
562
|
kind: "then",
|
|
458
563
|
source: "step {string} was called with",
|
|
@@ -466,7 +571,11 @@ function rulesOf(scope: Scope): { rules: Rule[]; parameterTypes: ParameterTypeSp
|
|
|
466
571
|
// An unnamed call with a computed segment is named by the operation it calls.
|
|
467
572
|
const op = scope.ops.get(node.id) ?? (node.name === undefined ? scope.ops.get(name as string) : undefined);
|
|
468
573
|
if (!op) throw new Error(`${step} calls ${node.id}, whose input cannot be read: name its operation in full`);
|
|
469
|
-
return expecting({
|
|
574
|
+
return expecting({
|
|
575
|
+
name: name as string,
|
|
576
|
+
input: fieldsFrom(pickle, op.input, inputOf(op.id), true),
|
|
577
|
+
called: true,
|
|
578
|
+
});
|
|
470
579
|
},
|
|
471
580
|
},
|
|
472
581
|
...[true, false].map((called): Rule => ({
|
|
@@ -524,7 +633,7 @@ function rulesOf(scope: Scope): { rules: Rule[]; parameterTypes: ParameterTypeSp
|
|
|
524
633
|
const last = lastCall(records);
|
|
525
634
|
if (typeof last === "string") return last;
|
|
526
635
|
if (last.error) return `it failed: ${last.error.code ?? last.error.name}: ${last.error.message}`;
|
|
527
|
-
const expected = fieldsFrom(pickle, opOf(last.op).output, `${last.op}'s output
|
|
636
|
+
const expected = fieldsFrom(pickle, opOf(last.op).output, `${last.op}'s output`, true);
|
|
528
637
|
return contains(last.output, expected) ? undefined : `it returned ${JSON.stringify(last.output)}`;
|
|
529
638
|
},
|
|
530
639
|
}),
|
|
@@ -628,9 +737,13 @@ function rulesOf(scope: Scope): { rules: Rule[]; parameterTypes: ParameterTypeSp
|
|
|
628
737
|
transformer: (...g) => field(g),
|
|
629
738
|
});
|
|
630
739
|
}
|
|
740
|
+
// An `expect` phrase, like `was called with`, may give some of an object's fields.
|
|
631
741
|
const given = (values: unknown[]) =>
|
|
632
742
|
Object.fromEntries(
|
|
633
|
-
fields.map((name, i) => [
|
|
743
|
+
fields.map((name, i) => [
|
|
744
|
+
name,
|
|
745
|
+
valueFor(schemas[i]!, values[i] as string, `${name} in ${inputOf(op.id)}`, kind === "then"),
|
|
746
|
+
]),
|
|
634
747
|
);
|
|
635
748
|
const common = { source: template, group: op.id, expression: template.replaceAll(/\{([^}]*)\}/g, "{field_$1}") };
|
|
636
749
|
rules.push(
|
|
@@ -650,7 +763,8 @@ function lastCall(records: readonly LedgerRecord[]): CallRecord | string {
|
|
|
650
763
|
|
|
651
764
|
/** 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. */
|
|
652
765
|
function knownSteps(rules: Rule[]): string {
|
|
653
|
-
const keyword = (kind: Rule["kind"]) =>
|
|
766
|
+
const keyword = (kind: Rule["kind"]) =>
|
|
767
|
+
kind === "any" ? "Given/When/Then" : kind === "actor" ? "Given" : kind[0]!.toUpperCase() + kind.slice(1);
|
|
654
768
|
const line = (r: Rule) => `${keyword(r.kind)} ${r.source}${r.takes ? ` (and ${r.takes})` : ""}`;
|
|
655
769
|
const { generic = [], ...groups } = Object.groupBy(rules, (r) => r.group ?? "generic");
|
|
656
770
|
return [
|
|
@@ -804,7 +918,9 @@ const ambiguous = (text: string, sources: string[]) =>
|
|
|
804
918
|
function ruleFor(built: Built, expression: string, pickle: PickleStep): Rule {
|
|
805
919
|
const kind = pickle.type && KINDS[pickle.type];
|
|
806
920
|
if (!kind) throw new Error(`"${pickle.text}" needs a Given, When or Then before it`);
|
|
807
|
-
const rules = built.byExpression
|
|
921
|
+
const rules = built.byExpression
|
|
922
|
+
.get(expression)!
|
|
923
|
+
.filter((r) => r.kind === kind || r.kind === "any" || (r.kind === "actor" && kind === "given"));
|
|
808
924
|
if (rules.length === 0) throw new Error(noMatch(built, pickle.text));
|
|
809
925
|
if (rules.length > 1)
|
|
810
926
|
throw new Error(
|
|
@@ -837,6 +953,11 @@ function compileWorld(built: Built, specs: Map<string, Spec>) {
|
|
|
837
953
|
const { text } = pickle;
|
|
838
954
|
const step: ScenarioStep = { text, kind: KINDS[pickle.type!]! };
|
|
839
955
|
switch (rule.kind) {
|
|
956
|
+
case "actor":
|
|
957
|
+
if (spec.startedBy !== undefined)
|
|
958
|
+
throw new Error(`${spec.startedBy} is acting already: a run has one actor`);
|
|
959
|
+
spec.startedBy = rule.read(args, pickle);
|
|
960
|
+
break;
|
|
840
961
|
case "given": {
|
|
841
962
|
const items = rule.read(args, pickle);
|
|
842
963
|
spec.given.push(...items.map((item) => ({ step: text, ...item }) as Given));
|
|
@@ -988,6 +1109,8 @@ function testWorld(built: Built, { driver, fake }: FeatureTarget) {
|
|
|
988
1109
|
throw new Error(
|
|
989
1110
|
`"${text}" decides an approval: a step for a scenario in scenarios/, not an operation's own test`,
|
|
990
1111
|
);
|
|
1112
|
+
case "actor":
|
|
1113
|
+
throw new Error(`"${text}" says who starts a run: a step for a scenario in scenarios/`);
|
|
991
1114
|
case "then": {
|
|
992
1115
|
const detail = rule.read(args, pickle).verify(viewOf(records));
|
|
993
1116
|
if (detail !== undefined) throw new Error(detail);
|
|
@@ -1179,18 +1302,22 @@ async function compile(scope: Scope, dir: string, files: string[]): Promise<Load
|
|
|
1179
1302
|
}
|
|
1180
1303
|
|
|
1181
1304
|
/**
|
|
1182
|
-
* Each scope's scenarios as last read, with the stamp of the files they were read from
|
|
1183
|
-
*
|
|
1305
|
+
* Each scope's scenarios as last read, with the stamp of the files they were read from and the
|
|
1306
|
+
* people they were checked against (`readPeople`'s answer, the same object until a data file
|
|
1307
|
+
* changes). By scope, since what a file means depends on its operations and workflows.
|
|
1184
1308
|
*/
|
|
1185
|
-
const loadedByScope = new WeakMap<
|
|
1309
|
+
const loadedByScope = new WeakMap<
|
|
1310
|
+
Scope,
|
|
1311
|
+
{ stamp: string; people: DeclaredPeople | undefined; loaded: Promise<LoadedScenarios> }
|
|
1312
|
+
>();
|
|
1186
1313
|
|
|
1187
1314
|
/**
|
|
1188
1315
|
* Every scenario in the `.feature` files under `scenarios` (a `file:` URL to a directory),
|
|
1189
1316
|
* compiled by Cucumber, with an error for each file that does not compile and each name used
|
|
1190
1317
|
* twice. No directory, or none given, is no scenarios. Rejects when an operation's phrase names
|
|
1191
1318
|
* a field it lacks. The files are compiled again only once one is added, removed or written
|
|
1192
|
-
* (its name, `mtimeMs` or size changes)
|
|
1193
|
-
* callers must not change.
|
|
1319
|
+
* (its name, `mtimeMs` or size changes), or the people's data files change: until then each
|
|
1320
|
+
* call returns the same object, which callers must not change.
|
|
1194
1321
|
*/
|
|
1195
1322
|
export async function loadScenarios(scope: Scope): Promise<LoadedScenarios> {
|
|
1196
1323
|
if (!scope.scenarios) return { scenarios: [], errors: [] };
|
|
@@ -1206,10 +1333,12 @@ export async function loadScenarios(scope: Scope): Promise<LoadedScenarios> {
|
|
|
1206
1333
|
return [file, mtimeMs, size];
|
|
1207
1334
|
}),
|
|
1208
1335
|
]);
|
|
1336
|
+
// None without a root, which declares no one.
|
|
1337
|
+
const people = scope.root === undefined ? undefined : readPeople(scope.root);
|
|
1209
1338
|
const cached = loadedByScope.get(scope);
|
|
1210
|
-
if (cached?.stamp === stamp) return cached.loaded;
|
|
1339
|
+
if (cached?.stamp === stamp && cached.people === people) return cached.loaded;
|
|
1211
1340
|
const loaded = files.length ? compile(scope, dir, files) : Promise.resolve({ scenarios: [], errors: [] });
|
|
1212
|
-
loadedByScope.set(scope, { stamp, loaded });
|
|
1341
|
+
loadedByScope.set(scope, { stamp, people, loaded });
|
|
1213
1342
|
// A compile that throws (a bug) is not kept: the next call tries again.
|
|
1214
1343
|
loaded.catch(() => {
|
|
1215
1344
|
if (loadedByScope.get(scope)?.loaded === loaded) loadedByScope.delete(scope);
|
package/src/scenarios.ts
CHANGED
|
@@ -18,21 +18,28 @@ export interface RunScenarioOptions {
|
|
|
18
18
|
client: SanomaClient;
|
|
19
19
|
/** What the scenario was loaded from (`loadScenarios`), whose feature files its run is checked against: the resolved config. */
|
|
20
20
|
scope: Scope;
|
|
21
|
-
/**
|
|
22
|
-
|
|
21
|
+
/**
|
|
22
|
+
* Who starts the run, by id (`"ada"` or `{ id: "ada" }`), unless the scenario says who is acting.
|
|
23
|
+
* Defaults to `scenarios`, one of the runtime's own agents.
|
|
24
|
+
*/
|
|
25
|
+
startedBy?: Principal | string;
|
|
23
26
|
/** How long the run may take, passed to `drive`. */
|
|
24
27
|
timeoutMs?: number;
|
|
25
28
|
}
|
|
26
29
|
|
|
27
30
|
/**
|
|
28
|
-
* Runs a scenario as a sandbox run: starts its workflow with its input,
|
|
29
|
-
* as it says, and checks the run's ledger against it (`check`:
|
|
31
|
+
* Runs a scenario as a sandbox run: starts its workflow with its input, as whoever it says is
|
|
32
|
+
* acting, decides its approvals as it says, and checks the run's ledger against it (`check`:
|
|
33
|
+
* Cucumber runs its `Then` steps).
|
|
30
34
|
*/
|
|
31
35
|
export async function runScenario(
|
|
32
36
|
scenario: Scenario,
|
|
33
|
-
{ client, scope, startedBy =
|
|
37
|
+
{ client, scope, startedBy = "scenarios", timeoutMs }: RunScenarioOptions,
|
|
34
38
|
): Promise<{ runId: string; run: RunSummary; ledger: LedgerRecord[]; checks: Check[] }> {
|
|
35
|
-
const runId = await client.start(scenario.workflow, scenario.input, {
|
|
39
|
+
const runId = await client.start(scenario.workflow, scenario.input, {
|
|
40
|
+
startedBy: scenario.startedBy ?? startedBy,
|
|
41
|
+
sandbox: scenario.name,
|
|
42
|
+
});
|
|
36
43
|
const run = await drive(client, runId, scenario, { timeoutMs });
|
|
37
44
|
const ledger = await client.ledger(runId);
|
|
38
45
|
return { runId, run, ledger, checks: await check(scope, scenario, ledger) };
|
|
@@ -49,8 +56,8 @@ export interface ScenarioFailure {
|
|
|
49
56
|
export interface RunScenariosOptions {
|
|
50
57
|
/** The Postgres the worker and the client run on, instead of the config's: a database of its own. */
|
|
51
58
|
databaseUrl: string;
|
|
52
|
-
/** Who starts the runs
|
|
53
|
-
startedBy?: Principal;
|
|
59
|
+
/** Who starts the runs whose scenario does not say who is acting (`runScenario`'s). */
|
|
60
|
+
startedBy?: Principal | string;
|
|
54
61
|
/** How long each run may take (`drive`'s; 15 seconds by default). */
|
|
55
62
|
timeoutMs?: number;
|
|
56
63
|
/** Receives a line per scenario as it ends. */
|