@sanoma-ai/workflows 0.1.0 → 0.4.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/README.md +186 -76
- package/dist/apply-steps.d.ts +6 -0
- package/dist/apply-steps.js +93 -0
- package/dist/apply-steps.js.map +1 -0
- package/dist/apply.d.ts +197 -0
- package/dist/apply.js +470 -0
- package/dist/apply.js.map +1 -0
- 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 +35 -2
- package/dist/call.js +123 -43
- 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 +123 -9
- package/dist/client.js +355 -34
- 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 +109 -28
- 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/credentials.d.ts +25 -0
- package/dist/credentials.js +73 -5
- package/dist/credentials.js.map +1 -1
- 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 +38 -3
- package/dist/datafile.js +166 -13
- 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 +10 -4
- package/dist/define.js +1 -5
- package/dist/define.js.map +1 -1
- package/dist/describe.d.ts +54 -8
- package/dist/describe.js +84 -11
- 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/drift.d.ts +64 -12
- package/dist/drift.js +118 -41
- package/dist/drift.js.map +1 -1
- 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 +4 -4
- package/dist/expectations.js +43 -9
- package/dist/expectations.js.map +1 -1
- package/dist/host.d.ts +56 -0
- package/dist/host.js +45 -0
- package/dist/host.js.map +1 -0
- package/dist/index.d.ts +13 -5
- package/dist/index.js +15 -5
- 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/lockfile.d.ts +36 -0
- package/dist/lockfile.js +69 -0
- package/dist/lockfile.js.map +1 -0
- package/dist/op.d.ts +9 -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.js +1 -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 +207 -25
- package/dist/resource.js +184 -30
- package/dist/resource.js.map +1 -1
- package/dist/resources.d.ts +76 -9
- package/dist/resources.js +170 -30
- package/dist/resources.js.map +1 -1
- package/dist/run.d.ts +31 -1
- package/dist/run.js +6 -1
- package/dist/run.js.map +1 -1
- package/dist/sandbox.js +5 -1
- package/dist/sandbox.js.map +1 -1
- package/dist/scenario.d.ts +25 -9
- package/dist/scenario.js +125 -27
- 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/secret.d.ts +42 -0
- package/dist/secret.js +58 -0
- package/dist/secret.js.map +1 -0
- package/dist/shared.d.ts +188 -4
- package/dist/shared.js +165 -5
- 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/source.d.ts +58 -0
- package/dist/source.js +54 -0
- package/dist/source.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 +6 -2
- package/dist/worker.js +37 -8
- package/dist/worker.js.map +1 -1
- package/dist/writer.d.ts +35 -0
- package/dist/writer.js +201 -0
- package/dist/writer.js.map +1 -0
- package/oxlint.json +5 -16
- package/package.json +11 -1
- package/src/apply-steps.ts +110 -0
- package/src/apply.ts +626 -0
- package/src/approvals.ts +49 -13
- package/src/call-workflow.ts +44 -0
- package/src/call.ts +173 -46
- package/src/cedar.ts +493 -0
- package/src/client.ts +450 -49
- package/src/collection.ts +431 -0
- package/src/config.ts +168 -48
- package/src/constructors.ts +4 -0
- package/src/credentials.ts +90 -5
- package/src/data.ts +10 -0
- package/src/datafile.ts +207 -15
- package/src/db.ts +90 -22
- package/src/define.ts +11 -9
- package/src/describe.ts +146 -23
- package/src/direct.ts +15 -0
- package/src/drift.ts +155 -45
- package/src/errors.ts +36 -1
- package/src/expectations.ts +46 -11
- package/src/host.ts +96 -0
- package/src/index.ts +56 -2
- package/src/ledger.ts +35 -17
- package/src/lint.ts +29 -12
- package/src/lockfile.ts +95 -0
- package/src/op.ts +9 -0
- package/src/outline.ts +1 -1
- package/src/paths.ts +18 -3
- package/src/person.ts +75 -0
- package/src/plugin.ts +1 -1
- package/src/policy.ts +59 -63
- package/src/resource.ts +302 -52
- package/src/resources.ts +226 -30
- package/src/run.ts +33 -1
- package/src/sandbox.ts +5 -1
- package/src/scenario.ts +175 -43
- package/src/scenarios.ts +15 -8
- package/src/schema.ts +261 -0
- package/src/sdk.ts +211 -0
- package/src/secret.ts +92 -0
- package/src/shared.ts +279 -6
- package/src/sites.ts +220 -0
- package/src/source.ts +82 -0
- package/src/version.ts +3 -2
- package/src/worker.ts +45 -11
- package/src/writer.ts +225 -0
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,14 +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
32
|
import { fill, isBug, 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";
|
|
35
|
-
import {
|
|
36
|
+
import { admit, type DeclaredPeople, readPeople } from "./resources.ts";
|
|
37
|
+
import { memorySecrets } from "./secret.ts";
|
|
38
|
+
import {
|
|
39
|
+
APPLY_WORKFLOW,
|
|
40
|
+
BUILT_IN_WORKFLOWS,
|
|
41
|
+
DATA_VENDOR,
|
|
42
|
+
declaresPeople,
|
|
43
|
+
DRIFT_WORKFLOW,
|
|
44
|
+
errorMessage,
|
|
45
|
+
fitsOp,
|
|
46
|
+
flatten,
|
|
47
|
+
groupNamesOf,
|
|
48
|
+
isEnded,
|
|
49
|
+
principalIdsOf,
|
|
50
|
+
problemLine,
|
|
51
|
+
RUNTIME_AGENTS,
|
|
52
|
+
} from "./shared.ts";
|
|
36
53
|
import type { Call, RunView, Seed } from "./steps.ts";
|
|
37
54
|
|
|
38
55
|
/*
|
|
@@ -89,8 +106,11 @@ export interface Decision {
|
|
|
89
106
|
/**
|
|
90
107
|
* What the run should do: call an operation (with at least this input) or not, run the step
|
|
91
108
|
* `name`d in the workflow's outline or not, or a number of `times`, have the policy deny an
|
|
92
|
-
* operation (`denied
|
|
93
|
-
*
|
|
109
|
+
* operation (`denied`, for a `reason` when the step names one) or hold one for an approver
|
|
110
|
+
* (`held`: a person's id or a group's name, or `{ group }` a group's alone), end a certain way,
|
|
111
|
+
* for a drift run, report the resource `declared` (`<file>#<export>`) clean, gone, absent, or
|
|
112
|
+
* drifted in a field, or for an apply run, create it, or leave it bound to an object (`bound`,
|
|
113
|
+
* its vendor's id).
|
|
94
114
|
* A step whose own code checks the run (a connector's or the company's, or an operation's own
|
|
95
115
|
* test's) is `custom`, with what it is about.
|
|
96
116
|
*/
|
|
@@ -98,11 +118,13 @@ export type Expectation =
|
|
|
98
118
|
| { step: string; op: string; input?: unknown; called: boolean }
|
|
99
119
|
| { step: string; name: string; input?: unknown; called: boolean }
|
|
100
120
|
| { step: string; name: string; times: number }
|
|
101
|
-
| { step: string; op: string; decision: "denied" }
|
|
102
|
-
| { step: string; op: string; decision: "held"; approver:
|
|
121
|
+
| { step: string; op: string; decision: "denied"; reason?: string }
|
|
122
|
+
| { step: string; op: string; decision: "held"; approver: Approver }
|
|
103
123
|
| { step: string; outcome: "finished" | "failed"; code?: string }
|
|
104
|
-
| { step: string; declared: string; status: "clean" | "gone" }
|
|
124
|
+
| { step: string; declared: string; status: "clean" | "gone" | "absent" }
|
|
105
125
|
| { step: string; declared: string; status: "drifted"; field: string }
|
|
126
|
+
| { step: string; declared: string; created: true }
|
|
127
|
+
| { step: string; declared: string; bound: string }
|
|
106
128
|
| { step: string; custom: true; op?: string; called?: boolean };
|
|
107
129
|
|
|
108
130
|
export interface Scenario {
|
|
@@ -120,6 +142,8 @@ export interface Scenario {
|
|
|
120
142
|
text: string;
|
|
121
143
|
/** Every step in order, for a list and for marking graph nodes. */
|
|
122
144
|
steps: ScenarioStep[];
|
|
145
|
+
/** Who starts the run, by id, when a `Given <who> is acting` says: a declared person or agent. */
|
|
146
|
+
startedBy?: string;
|
|
123
147
|
given: Given[];
|
|
124
148
|
input: unknown;
|
|
125
149
|
decisions: Decision[];
|
|
@@ -140,10 +164,14 @@ export interface Check {
|
|
|
140
164
|
}
|
|
141
165
|
|
|
142
166
|
/**
|
|
143
|
-
* What steps can name: the config's operations, workflows
|
|
144
|
-
* its connectors (and the company) ship,
|
|
167
|
+
* What steps can name: the config's operations, workflows, resource types and collections by id,
|
|
168
|
+
* the steps its connectors (and the company) ship, where the feature files are, and the root
|
|
169
|
+
* whose data files declare its people.
|
|
145
170
|
*/
|
|
146
|
-
export type Scope = Pick<
|
|
171
|
+
export type Scope = Pick<
|
|
172
|
+
ResolvedConfig,
|
|
173
|
+
"ops" | "workflows" | "scenarios" | "types" | "collections" | "steps" | "root"
|
|
174
|
+
>;
|
|
147
175
|
|
|
148
176
|
type Kind = ScenarioStep["kind"];
|
|
149
177
|
type Fault = Extract<Seed, { fault: unknown }>["fault"];
|
|
@@ -158,9 +186,13 @@ interface Then {
|
|
|
158
186
|
verify(run: RunView): string | undefined;
|
|
159
187
|
}
|
|
160
188
|
|
|
161
|
-
/**
|
|
189
|
+
/**
|
|
190
|
+
* What a step of each kind reads into; `any` is a decision, which a step of any keyword may make,
|
|
191
|
+
* and `actor` a `Given` saying who starts the run (`{who} is acting`), by id.
|
|
192
|
+
*/
|
|
162
193
|
interface Items {
|
|
163
194
|
given: Unstepped<Given>[];
|
|
195
|
+
actor: string;
|
|
164
196
|
when: When;
|
|
165
197
|
then: Then;
|
|
166
198
|
any: Unstepped<Decision>;
|
|
@@ -302,7 +334,9 @@ function builtFor(scope: Scope): Built {
|
|
|
302
334
|
cached &&
|
|
303
335
|
cached.scope.workflows === scope.workflows &&
|
|
304
336
|
cached.scope.types === scope.types &&
|
|
305
|
-
cached.scope.
|
|
337
|
+
cached.scope.collections === scope.collections &&
|
|
338
|
+
cached.scope.steps === scope.steps &&
|
|
339
|
+
cached.scope.root === scope.root
|
|
306
340
|
) {
|
|
307
341
|
return cached;
|
|
308
342
|
}
|
|
@@ -314,29 +348,66 @@ function builtFor(scope: Scope): Built {
|
|
|
314
348
|
return built;
|
|
315
349
|
}
|
|
316
350
|
|
|
351
|
+
/** The built-in workflows, whose input is never made up: each field is optional, and left out unless given. */
|
|
352
|
+
|
|
317
353
|
/** A `Then` step of the generic steps: its expectation, checked against the run's records. */
|
|
318
354
|
const expecting = (expect: CoreExpectation): Then => ({ expect, verify: (run) => detailOf(expect, run.records) });
|
|
319
355
|
|
|
320
356
|
/** The rules a scope has, and the parameter types their expressions use. */
|
|
321
357
|
function rulesOf(scope: Scope): { rules: Rule[]; parameterTypes: ParameterTypeSpec[] } {
|
|
322
|
-
const { types } = scope;
|
|
358
|
+
const { types, collections } = scope;
|
|
359
|
+
// A collection's records are seeded as a resource's objects are, by `data.<name>`, through its operations.
|
|
360
|
+
const collectionOf = (id: unknown) =>
|
|
361
|
+
(id as string).startsWith(`${DATA_VENDOR}.`)
|
|
362
|
+
? collections.get((id as string).slice(DATA_VENDOR.length + 1))
|
|
363
|
+
: undefined;
|
|
364
|
+
const seedable = new Map<string, unknown>([
|
|
365
|
+
...types,
|
|
366
|
+
...[...collections.keys()].map((n) => [`${DATA_VENDOR}.${n}`, n] as const),
|
|
367
|
+
]);
|
|
323
368
|
const parameterTypes: ParameterTypeSpec[] = [
|
|
324
369
|
{ name: "op", regexp: /\S+/, transformer: known("operation", scope.ops) },
|
|
325
370
|
{ name: "workflow", regexp: /\S+/, transformer: known("workflow", scope.workflows) },
|
|
326
371
|
{ name: "who", regexp: /\S+/, transformer: (s) => s },
|
|
327
|
-
{ name: "resource", regexp: /\S+/, transformer: known("resource type",
|
|
372
|
+
{ name: "resource", regexp: /\S+/, transformer: known("resource type", seedable) },
|
|
328
373
|
// A declared resource, as a drift report names it: `<file>#<export>`.
|
|
329
374
|
{ name: "declared", regexp: /[^\s#]+#\S+/, transformer: (s) => s },
|
|
330
375
|
{ name: "field", regexp: /\S+/, transformer: (s) => s },
|
|
331
376
|
];
|
|
332
377
|
const opOf = (id: unknown) => scope.ops.get(id as string)!;
|
|
333
378
|
const workflowOf = (name: unknown) => scope.workflows.get(name as string)!;
|
|
379
|
+
// The people as the root's data files declare them when a step names one (`readPeople`, read
|
|
380
|
+
// again only once a file changed): while they declare no one, any id and any group go.
|
|
381
|
+
/** `who`, as a run's start admits them (`admit`): a declared person or agent. */
|
|
382
|
+
const actorNamed = (who: string) => {
|
|
383
|
+
const declared = readPeople(scope.root);
|
|
384
|
+
try {
|
|
385
|
+
return admit({ id: who }, declared).id;
|
|
386
|
+
} catch (err) {
|
|
387
|
+
if (errorCode(err) !== "not_admitted" || declared.problems.length) throw err;
|
|
388
|
+
const ids = [...principalIdsOf(declared.people), ...RUNTIME_AGENTS].join(", ");
|
|
389
|
+
throw new Error(`${who} is not a declared person or agent; resources/ declares ${ids}`, { cause: err });
|
|
390
|
+
}
|
|
391
|
+
};
|
|
392
|
+
/** `group`, a declared group or `agents`, while the people's files have no problems. */
|
|
393
|
+
const groupNamed = (group: string) => {
|
|
394
|
+
const { people, problems } = readPeople(scope.root);
|
|
395
|
+
if (problems.length) {
|
|
396
|
+
throw new Error(
|
|
397
|
+
`no group can be told while the people's files have problems: ${problems.map(problemLine).join("; ")}`,
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
const groups = groupNamesOf(people);
|
|
401
|
+
if (!declaresPeople(people) || groups.includes(group)) return group;
|
|
402
|
+
throw new Error(`no group "${group}"; resources/ declares ${groups.join(", ")}`);
|
|
403
|
+
};
|
|
334
404
|
/** A resource step's object: its type's id, vendor and type, and its import id. */
|
|
335
405
|
const object = (id: unknown, name: unknown) => {
|
|
336
406
|
const { vendor, type } = types.get(id as string)!;
|
|
337
407
|
return { resource: id as string, vendor, type, name: name as string };
|
|
338
408
|
};
|
|
339
409
|
const rules: Rule[] = [
|
|
410
|
+
{ kind: "actor", source: "{who} is acting", read: ([who]) => actorNamed(who as string) },
|
|
340
411
|
{
|
|
341
412
|
kind: "given",
|
|
342
413
|
source: "{op} was called with",
|
|
@@ -354,21 +425,32 @@ function rulesOf(scope: Scope): { rules: Rule[]; parameterTypes: ParameterTypeSp
|
|
|
354
425
|
kind: "given",
|
|
355
426
|
source: "{resource} {string} has",
|
|
356
427
|
takes: TAKES_OBJECT,
|
|
357
|
-
read: ([resource, name], pickle) =>
|
|
358
|
-
{
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
)
|
|
365
|
-
|
|
366
|
-
|
|
428
|
+
read: ([resource, name], pickle) => {
|
|
429
|
+
const owner = `${resource as string} "${name as string}"`;
|
|
430
|
+
const c = collectionOf(resource);
|
|
431
|
+
if (c) {
|
|
432
|
+
// The record with its key, the fields the step gives, and the rest made up.
|
|
433
|
+
const record = shapeOf(opOf(c.save.id).input).record!;
|
|
434
|
+
const fields = fieldsFrom(pickle, record, owner);
|
|
435
|
+
if (Object.hasOwn(fields, c.key) && fields[c.key] !== name) {
|
|
436
|
+
throw new Error(
|
|
437
|
+
`${c.key} in ${owner} is ${JSON.stringify(fields[c.key])}: it is the key, "${name as string}"`,
|
|
438
|
+
);
|
|
439
|
+
}
|
|
440
|
+
return [{ op: c.save.id, input: { record: fill(record, { ...fields, [c.key]: name }) } }];
|
|
441
|
+
}
|
|
442
|
+
return [
|
|
443
|
+
{ ...object(resource, name), fields: fieldsFrom(pickle, types.get(resource as string)!.schema, owner) },
|
|
444
|
+
];
|
|
445
|
+
},
|
|
367
446
|
},
|
|
368
447
|
{
|
|
369
448
|
kind: "given",
|
|
370
449
|
source: "{resource} {string} is gone",
|
|
371
|
-
read: ([resource, name]) =>
|
|
450
|
+
read: ([resource, name]) => {
|
|
451
|
+
const c = collectionOf(resource);
|
|
452
|
+
return [c ? { op: c.delete.id, input: { key: name } } : { ...object(resource, name), gone: true }];
|
|
453
|
+
},
|
|
372
454
|
},
|
|
373
455
|
{
|
|
374
456
|
kind: "when",
|
|
@@ -376,13 +458,21 @@ function rulesOf(scope: Scope): { rules: Rule[]; parameterTypes: ParameterTypeSp
|
|
|
376
458
|
takes: TAKES_OBJECT,
|
|
377
459
|
read: ([name], pickle) => {
|
|
378
460
|
const { input } = workflowOf(name);
|
|
379
|
-
|
|
461
|
+
const given = fieldsFrom(pickle, input, inputOf(name));
|
|
462
|
+
return {
|
|
463
|
+
workflow: name as string,
|
|
464
|
+
input: BUILT_IN_WORKFLOWS.has(name as string) ? input.parse(given) : fill(input, given),
|
|
465
|
+
};
|
|
380
466
|
},
|
|
381
467
|
},
|
|
382
468
|
{
|
|
383
469
|
kind: "when",
|
|
384
470
|
source: "{workflow} runs",
|
|
385
|
-
|
|
471
|
+
// A built-in's input is optional throughout: what is not given is left out, not made up.
|
|
472
|
+
read: ([name]) => ({
|
|
473
|
+
workflow: name as string,
|
|
474
|
+
input: BUILT_IN_WORKFLOWS.has(name as string) ? {} : fill(workflowOf(name).input, {}),
|
|
475
|
+
}),
|
|
386
476
|
},
|
|
387
477
|
// An operation's own test: a call made to its driver, or its fake, directly.
|
|
388
478
|
{
|
|
@@ -426,11 +516,22 @@ function rulesOf(scope: Scope): { rules: Rule[]; parameterTypes: ParameterTypeSp
|
|
|
426
516
|
source: "{op} was denied",
|
|
427
517
|
read: ([op]) => expecting({ op: op as string, decision: "denied" }),
|
|
428
518
|
},
|
|
519
|
+
{
|
|
520
|
+
kind: "then",
|
|
521
|
+
source: "{op} was denied with {string}",
|
|
522
|
+
read: ([op, reason]) => expecting({ op: op as string, decision: "denied", reason: reason as string }),
|
|
523
|
+
},
|
|
429
524
|
{
|
|
430
525
|
kind: "then",
|
|
431
526
|
source: "{op} was held for {who}",
|
|
432
527
|
read: ([op, who]) => expecting({ op: op as string, decision: "held", approver: who as string }),
|
|
433
528
|
},
|
|
529
|
+
{
|
|
530
|
+
kind: "then",
|
|
531
|
+
source: "{op} was held for group {string}",
|
|
532
|
+
read: ([op, group]) =>
|
|
533
|
+
expecting({ op: op as string, decision: "held", approver: { group: groupNamed(group as string) } }),
|
|
534
|
+
},
|
|
434
535
|
{
|
|
435
536
|
kind: "then",
|
|
436
537
|
source: "step {string} was called with",
|
|
@@ -469,11 +570,21 @@ function rulesOf(scope: Scope): { rules: Rule[]; parameterTypes: ParameterTypeSp
|
|
|
469
570
|
read: ([declared, field]) =>
|
|
470
571
|
expecting({ declared: declared as string, status: "drifted", field: field as string }),
|
|
471
572
|
},
|
|
472
|
-
...(["clean", "gone"] as const).map((status): Rule => ({
|
|
573
|
+
...(["clean", "gone", "absent"] as const).map((status): Rule => ({
|
|
473
574
|
kind: "then",
|
|
474
575
|
source: `{declared} is ${status}`,
|
|
475
576
|
read: ([declared]) => expecting({ declared: declared as string, status }),
|
|
476
577
|
})),
|
|
578
|
+
{
|
|
579
|
+
kind: "then",
|
|
580
|
+
source: "apply creates {declared}",
|
|
581
|
+
read: ([declared]) => expecting({ declared: declared as string, created: true }),
|
|
582
|
+
},
|
|
583
|
+
{
|
|
584
|
+
kind: "then",
|
|
585
|
+
source: "{declared} is bound to {string}",
|
|
586
|
+
read: ([declared, id]) => expecting({ declared: declared as string, bound: id as string }),
|
|
587
|
+
},
|
|
477
588
|
{ kind: "then", source: "the run succeeds", read: () => expecting({ outcome: "finished" }) },
|
|
478
589
|
{ kind: "then", source: "the run fails", read: () => expecting({ outcome: "failed" }) },
|
|
479
590
|
{
|
|
@@ -618,7 +729,8 @@ function lastCall(records: readonly LedgerRecord[]): CallRecord | string {
|
|
|
618
729
|
|
|
619
730
|
/** 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. */
|
|
620
731
|
function knownSteps(rules: Rule[]): string {
|
|
621
|
-
const keyword = (kind: Rule["kind"]) =>
|
|
732
|
+
const keyword = (kind: Rule["kind"]) =>
|
|
733
|
+
kind === "any" ? "Given/When/Then" : kind === "actor" ? "Given" : kind[0]!.toUpperCase() + kind.slice(1);
|
|
622
734
|
const line = (r: Rule) => `${keyword(r.kind)} ${r.source}${r.takes ? ` (and ${r.takes})` : ""}`;
|
|
623
735
|
const { generic = [], ...groups } = Object.groupBy(rules, (r) => r.group ?? "generic");
|
|
624
736
|
return [
|
|
@@ -772,7 +884,9 @@ const ambiguous = (text: string, sources: string[]) =>
|
|
|
772
884
|
function ruleFor(built: Built, expression: string, pickle: PickleStep): Rule {
|
|
773
885
|
const kind = pickle.type && KINDS[pickle.type];
|
|
774
886
|
if (!kind) throw new Error(`"${pickle.text}" needs a Given, When or Then before it`);
|
|
775
|
-
const rules = built.byExpression
|
|
887
|
+
const rules = built.byExpression
|
|
888
|
+
.get(expression)!
|
|
889
|
+
.filter((r) => r.kind === kind || r.kind === "any" || (r.kind === "actor" && kind === "given"));
|
|
776
890
|
if (rules.length === 0) throw new Error(noMatch(built, pickle.text));
|
|
777
891
|
if (rules.length > 1)
|
|
778
892
|
throw new Error(
|
|
@@ -805,6 +919,11 @@ function compileWorld(built: Built, specs: Map<string, Spec>) {
|
|
|
805
919
|
const { text } = pickle;
|
|
806
920
|
const step: ScenarioStep = { text, kind: KINDS[pickle.type!]! };
|
|
807
921
|
switch (rule.kind) {
|
|
922
|
+
case "actor":
|
|
923
|
+
if (spec.startedBy !== undefined)
|
|
924
|
+
throw new Error(`${spec.startedBy} is acting already: a run has one actor`);
|
|
925
|
+
spec.startedBy = rule.read(args, pickle);
|
|
926
|
+
break;
|
|
808
927
|
case "given": {
|
|
809
928
|
const items = rule.read(args, pickle);
|
|
810
929
|
spec.given.push(...items.map((item) => ({ step: text, ...item }) as Given));
|
|
@@ -879,6 +998,8 @@ function testWorld(built: Built, { driver, fake }: FeatureTarget) {
|
|
|
879
998
|
const values = new Map<string, unknown>();
|
|
880
999
|
const records: LedgerRecord[] = [];
|
|
881
1000
|
let runId = "";
|
|
1001
|
+
// The test's own secrets, in memory: a driver resolves `secret()` markers against them.
|
|
1002
|
+
const secrets = memorySecrets();
|
|
882
1003
|
/** A record of the test, as a run's ledger would hold it. */
|
|
883
1004
|
const record = (body: Record<string, unknown>) => {
|
|
884
1005
|
const seq = records.length;
|
|
@@ -892,7 +1013,7 @@ function testWorld(built: Built, { driver, fake }: FeatureTarget) {
|
|
|
892
1013
|
const input = op.input.parse(resolve(given, values));
|
|
893
1014
|
const fn = driver.ops[`${op.resource}.${op.name}`];
|
|
894
1015
|
if (!fn) throw new Error(`the driver under test does not implement ${id}`);
|
|
895
|
-
const context = { idempotencyKey: `${runId}:${records.length}`, runId, opId: id, attempt: 1 };
|
|
1016
|
+
const context = { idempotencyKey: `${runId}:${records.length}`, runId, opId: id, attempt: 1, secrets };
|
|
896
1017
|
return { op, input, output: () => fn(input, context) };
|
|
897
1018
|
};
|
|
898
1019
|
return {
|
|
@@ -954,6 +1075,8 @@ function testWorld(built: Built, { driver, fake }: FeatureTarget) {
|
|
|
954
1075
|
throw new Error(
|
|
955
1076
|
`"${text}" decides an approval: a step for a scenario in scenarios/, not an operation's own test`,
|
|
956
1077
|
);
|
|
1078
|
+
case "actor":
|
|
1079
|
+
throw new Error(`"${text}" says who starts a run: a step for a scenario in scenarios/`);
|
|
957
1080
|
case "then": {
|
|
958
1081
|
const detail = rule.read(args, pickle).verify(viewOf(records));
|
|
959
1082
|
if (detail !== undefined) throw new Error(detail);
|
|
@@ -1092,11 +1215,14 @@ async function compile(scope: Scope, dir: string, files: string[]): Promise<Load
|
|
|
1092
1215
|
`${where}: scenario "${name}" has no When: say which workflow runs, such as "When <workflow> runs with"`,
|
|
1093
1216
|
);
|
|
1094
1217
|
}
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1218
|
+
// A step about a declared resource reads the report of the built-in it is about.
|
|
1219
|
+
for (const e of spec.expect) {
|
|
1220
|
+
const reads: string = "declared" in e ? ("status" in e ? DRIFT_WORKFLOW : APPLY_WORKFLOW) : workflow;
|
|
1221
|
+
if (reads !== workflow) {
|
|
1222
|
+
throw new Error(
|
|
1223
|
+
`${where}: "${e.step}" reads ${reads === DRIFT_WORKFLOW ? "a drift" : "an apply"} report, so the scenario must run ${reads}, not ${workflow}`,
|
|
1224
|
+
);
|
|
1225
|
+
}
|
|
1100
1226
|
}
|
|
1101
1227
|
inFile.push({
|
|
1102
1228
|
...spec,
|
|
@@ -1142,18 +1268,22 @@ async function compile(scope: Scope, dir: string, files: string[]): Promise<Load
|
|
|
1142
1268
|
}
|
|
1143
1269
|
|
|
1144
1270
|
/**
|
|
1145
|
-
* Each scope's scenarios as last read, with the stamp of the files they were read from
|
|
1146
|
-
*
|
|
1271
|
+
* Each scope's scenarios as last read, with the stamp of the files they were read from and the
|
|
1272
|
+
* people they were checked against (`readPeople`'s answer, the same object until a data file
|
|
1273
|
+
* changes). By scope, since what a file means depends on its operations and workflows.
|
|
1147
1274
|
*/
|
|
1148
|
-
const loadedByScope = new WeakMap<
|
|
1275
|
+
const loadedByScope = new WeakMap<
|
|
1276
|
+
Scope,
|
|
1277
|
+
{ stamp: string; people: DeclaredPeople | undefined; loaded: Promise<LoadedScenarios> }
|
|
1278
|
+
>();
|
|
1149
1279
|
|
|
1150
1280
|
/**
|
|
1151
1281
|
* Every scenario in the `.feature` files under `scenarios` (a `file:` URL to a directory),
|
|
1152
1282
|
* compiled by Cucumber, with an error for each file that does not compile and each name used
|
|
1153
1283
|
* twice. No directory, or none given, is no scenarios. Rejects when an operation's phrase names
|
|
1154
1284
|
* a field it lacks. The files are compiled again only once one is added, removed or written
|
|
1155
|
-
* (its name, `mtimeMs` or size changes)
|
|
1156
|
-
* callers must not change.
|
|
1285
|
+
* (its name, `mtimeMs` or size changes), or the people's data files change: until then each
|
|
1286
|
+
* call returns the same object, which callers must not change.
|
|
1157
1287
|
*/
|
|
1158
1288
|
export async function loadScenarios(scope: Scope): Promise<LoadedScenarios> {
|
|
1159
1289
|
if (!scope.scenarios) return { scenarios: [], errors: [] };
|
|
@@ -1169,10 +1299,12 @@ export async function loadScenarios(scope: Scope): Promise<LoadedScenarios> {
|
|
|
1169
1299
|
return [file, mtimeMs, size];
|
|
1170
1300
|
}),
|
|
1171
1301
|
]);
|
|
1302
|
+
// None without a root, which declares no one.
|
|
1303
|
+
const people = scope.root === undefined ? undefined : readPeople(scope.root);
|
|
1172
1304
|
const cached = loadedByScope.get(scope);
|
|
1173
|
-
if (cached?.stamp === stamp) return cached.loaded;
|
|
1305
|
+
if (cached?.stamp === stamp && cached.people === people) return cached.loaded;
|
|
1174
1306
|
const loaded = files.length ? compile(scope, dir, files) : Promise.resolve({ scenarios: [], errors: [] });
|
|
1175
|
-
loadedByScope.set(scope, { stamp, loaded });
|
|
1307
|
+
loadedByScope.set(scope, { stamp, people, loaded });
|
|
1176
1308
|
// A compile that throws (a bug) is not kept: the next call tries again.
|
|
1177
1309
|
loaded.catch(() => {
|
|
1178
1310
|
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. */
|