@sanoma-ai/workflows 0.6.0 → 0.7.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 +52 -14
- package/dist/apply.d.ts +2 -1
- package/dist/apply.js +29 -7
- package/dist/apply.js.map +1 -1
- package/dist/approvals.d.ts +27 -2
- package/dist/approvals.js +73 -9
- package/dist/approvals.js.map +1 -1
- package/dist/call.d.ts +3 -2
- package/dist/call.js +38 -30
- package/dist/call.js.map +1 -1
- package/dist/cedar.js +24 -9
- package/dist/cedar.js.map +1 -1
- package/dist/change.d.ts +219 -0
- package/dist/change.js +150 -0
- package/dist/change.js.map +1 -0
- package/dist/chats.d.ts +43 -0
- package/dist/chats.js +48 -0
- package/dist/chats.js.map +1 -0
- package/dist/client.d.ts +52 -6
- package/dist/client.js +88 -47
- package/dist/client.js.map +1 -1
- package/dist/config.d.ts +75 -8
- package/dist/config.js +103 -13
- package/dist/config.js.map +1 -1
- package/dist/constructors.d.ts +37 -1
- package/dist/constructors.js +6 -2
- package/dist/constructors.js.map +1 -1
- package/dist/database.d.ts +83 -0
- package/dist/database.js +92 -0
- package/dist/database.js.map +1 -0
- package/dist/db.d.ts +19 -1
- package/dist/db.js +51 -7
- package/dist/db.js.map +1 -1
- package/dist/deadlines.d.ts +91 -0
- package/dist/deadlines.js +165 -0
- package/dist/deadlines.js.map +1 -0
- package/dist/define.d.ts +25 -4
- package/dist/define.js +40 -0
- package/dist/define.js.map +1 -1
- package/dist/demo.d.ts +28 -0
- package/dist/demo.js +72 -0
- package/dist/demo.js.map +1 -0
- package/dist/describe.d.ts +5 -2
- package/dist/describe.js +10 -6
- package/dist/describe.js.map +1 -1
- package/dist/errors.d.ts +10 -1
- package/dist/errors.js +13 -0
- package/dist/errors.js.map +1 -1
- package/dist/fake.d.ts +34 -0
- package/dist/fake.js +134 -1
- package/dist/fake.js.map +1 -1
- package/dist/grid.d.ts +34 -0
- package/dist/grid.js +62 -0
- package/dist/grid.js.map +1 -0
- package/dist/host.d.ts +12 -2
- package/dist/host.js +2 -2
- package/dist/host.js.map +1 -1
- package/dist/index.d.ts +15 -8
- package/dist/index.js +10 -5
- package/dist/index.js.map +1 -1
- package/dist/ledger.d.ts +11 -2
- package/dist/ledger.js.map +1 -1
- package/dist/lint.js +2 -2
- package/dist/lint.js.map +1 -1
- package/dist/op.d.ts +21 -2
- package/dist/op.js.map +1 -1
- package/dist/person.d.ts +5 -0
- package/dist/person.js +3 -1
- package/dist/person.js.map +1 -1
- package/dist/policy.d.ts +7 -1
- package/dist/policy.js +2 -1
- package/dist/policy.js.map +1 -1
- package/dist/resource.d.ts +13 -1
- package/dist/resource.js +9 -1
- package/dist/resource.js.map +1 -1
- package/dist/resources.d.ts +2 -0
- package/dist/resources.js +15 -4
- package/dist/resources.js.map +1 -1
- package/dist/run.d.ts +5 -0
- package/dist/run.js.map +1 -1
- package/dist/sandbox.d.ts +38 -10
- package/dist/sandbox.js +66 -35
- package/dist/sandbox.js.map +1 -1
- package/dist/scenario.d.ts +18 -1
- package/dist/scenario.js +77 -27
- package/dist/scenario.js.map +1 -1
- package/dist/scenarios.d.ts +10 -1
- package/dist/scenarios.js +19 -4
- package/dist/scenarios.js.map +1 -1
- package/dist/schedules.d.ts +66 -0
- package/dist/schedules.js +158 -0
- package/dist/schedules.js.map +1 -0
- package/dist/schema.d.ts +6 -0
- package/dist/schema.js +13 -3
- package/dist/schema.js.map +1 -1
- package/dist/shared.d.ts +30 -2
- package/dist/shared.js +42 -4
- package/dist/shared.js.map +1 -1
- package/dist/source.d.ts +185 -2
- package/dist/source.js +9 -0
- package/dist/source.js.map +1 -1
- package/dist/supervisor.d.ts +39 -0
- package/dist/supervisor.js +68 -0
- package/dist/supervisor.js.map +1 -0
- package/dist/version.d.ts +3 -1
- package/dist/version.js +3 -1
- package/dist/version.js.map +1 -1
- package/dist/wakeup-driver.d.ts +128 -0
- package/dist/wakeup-driver.js +216 -0
- package/dist/wakeup-driver.js.map +1 -0
- package/dist/wakeup-service.d.ts +83 -0
- package/dist/wakeup-service.js +415 -0
- package/dist/wakeup-service.js.map +1 -0
- package/dist/wakeup-type.d.ts +43 -0
- package/dist/wakeup-type.js +80 -0
- package/dist/wakeup-type.js.map +1 -0
- package/dist/wakeup.d.ts +77 -0
- package/dist/wakeup.js +78 -0
- package/dist/wakeup.js.map +1 -0
- package/dist/worker.d.ts +39 -1
- package/dist/worker.js +125 -38
- package/dist/worker.js.map +1 -1
- package/oxlint.json +3 -1
- package/package.json +10 -2
- package/src/apply.ts +25 -6
- package/src/approvals.ts +89 -16
- package/src/call.ts +40 -29
- package/src/cedar.ts +24 -6
- package/src/change.ts +222 -0
- package/src/chats.ts +114 -0
- package/src/client.ts +145 -55
- package/src/config.ts +149 -21
- package/src/constructors.ts +8 -2
- package/src/database.ts +152 -0
- package/src/db.ts +58 -9
- package/src/deadlines.ts +228 -0
- package/src/define.ts +71 -4
- package/src/demo.ts +87 -0
- package/src/describe.ts +18 -6
- package/src/errors.ts +14 -0
- package/src/fake.ts +149 -0
- package/src/grid.ts +100 -0
- package/src/host.ts +12 -4
- package/src/index.ts +66 -3
- package/src/ledger.ts +15 -3
- package/src/lint.ts +2 -2
- package/src/op.ts +22 -2
- package/src/person.ts +4 -1
- package/src/policy.ts +7 -1
- package/src/resource.ts +22 -3
- package/src/resources.ts +17 -4
- package/src/run.ts +5 -0
- package/src/sandbox.ts +97 -37
- package/src/scenario.ts +97 -28
- package/src/scenarios.ts +21 -5
- package/src/schedules.ts +206 -0
- package/src/schema.ts +16 -3
- package/src/shared.ts +51 -4
- package/src/source.ts +176 -3
- package/src/supervisor.ts +102 -0
- package/src/version.ts +3 -1
- package/src/wakeup-driver.ts +270 -0
- package/src/wakeup-service.ts +504 -0
- package/src/wakeup-type.ts +90 -0
- package/src/wakeup.ts +139 -0
- package/src/worker.ts +176 -43
package/src/sandbox.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { opIdsOf } from "./define.ts";
|
|
|
4
4
|
import { SanomaError } from "./errors.ts";
|
|
5
5
|
import type { Fake, ResourceFake } from "./fake.ts";
|
|
6
6
|
import { entry, type LedgerRecord, write } from "./ledger.ts";
|
|
7
|
-
import type { DriverFn } from "./op.ts";
|
|
7
|
+
import type { CallContext, DriverFn, Op } from "./op.ts";
|
|
8
8
|
import type { Run } from "./run.ts";
|
|
9
9
|
import { memorySecrets } from "./secret.ts";
|
|
10
10
|
import { type Given, loadScenarios, type Scenario } from "./scenario.ts";
|
|
@@ -58,7 +58,7 @@ export function seedResource(
|
|
|
58
58
|
scenario: string,
|
|
59
59
|
copy: ResourceFake,
|
|
60
60
|
given: Extract<Given, { resource: string }>,
|
|
61
|
-
):
|
|
61
|
+
): Seeds[number] {
|
|
62
62
|
const { resource, type, name } = given;
|
|
63
63
|
try {
|
|
64
64
|
if ("gone" in given) {
|
|
@@ -77,16 +77,81 @@ export function seedResource(
|
|
|
77
77
|
}
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
/** What a scenario's `Given` steps seeded, as its `scenario.seeded` record has it. */
|
|
81
|
+
export type Seeds = Extract<LedgerRecord, { type: "scenario.seeded" }>["seeds"];
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Where a scenario's `Given` steps go (`applyGivens`): a sandbox run's own fakes (`run`), or the
|
|
85
|
+
* instance a demo runs (`instance`), its collections in the config's database and every vendor
|
|
86
|
+
* on its one fake, which every run shares, so a fault, injected into a call some other run would
|
|
87
|
+
* make, is refused there.
|
|
88
|
+
*/
|
|
89
|
+
export interface GivenTarget {
|
|
90
|
+
kind: "run" | "instance";
|
|
91
|
+
/** What a `Given` operation is called through, by operation id. */
|
|
92
|
+
drivers: ReadonlyMap<string, DriverFn>;
|
|
93
|
+
/** The fakes, by vendor id: what a `Given` resource is put in, and a `run`'s faults injected into. */
|
|
94
|
+
fakes: ReadonlyMap<string, Fake<unknown>>;
|
|
95
|
+
/** The operations by id, for a fault's vendor. */
|
|
96
|
+
ops: ReadonlyMap<string, Op>;
|
|
97
|
+
/** The context of the call the `i`th `Given` makes. */
|
|
98
|
+
call(i: number, op: string): CallContext;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Applies the scenario's `Given` steps to the target: calls each operation through its drivers,
|
|
103
|
+
* puts or removes each resource in its vendor's fake, then, for a `run`, injects the faults.
|
|
104
|
+
* Checks first that every operation it seeds has a driver there, that every resource's vendor has
|
|
105
|
+
* a fake holding resources, and, for an `instance`, that it injects no fault. Returns what it
|
|
106
|
+
* seeded, in order.
|
|
107
|
+
*/
|
|
108
|
+
export async function applyGivens(scenario: Pick<Scenario, "name" | "given">, target: GivenTarget): Promise<Seeds> {
|
|
109
|
+
const { name, given } = scenario;
|
|
110
|
+
const fault = given.find((g) => "fault" in g);
|
|
111
|
+
if (fault && target.kind === "instance") {
|
|
112
|
+
throw new SanomaError(
|
|
113
|
+
"invalid_input",
|
|
114
|
+
`Scenario "${name}" injects a fault ("${fault.step}"), but its Givens go to the instance, whose fakes every run shares`,
|
|
115
|
+
{ scenario: name, step: fault.step },
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
const missing = [...new Set(given.flatMap((g) => ("op" in g && !target.drivers.has(g.op) ? [g.op] : [])))];
|
|
119
|
+
if (missing.length) {
|
|
120
|
+
throw new SanomaError(
|
|
121
|
+
"invalid_input",
|
|
122
|
+
`Scenario "${name}" seeds ${missing.join(", ")}, but no fake its connectors attach implements ${missing.length > 1 ? "them" : "it"}`,
|
|
123
|
+
{ scenario: name, missing },
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
const plain = missingResourceFake(scenario, target.fakes);
|
|
127
|
+
if (plain) throw plain;
|
|
128
|
+
const seeded: Seeds = [];
|
|
129
|
+
for (const [i, g] of given.entries()) {
|
|
130
|
+
if ("fault" in g) continue;
|
|
131
|
+
if ("op" in g) {
|
|
132
|
+
const output = await target.drivers.get(g.op)!(g.input, target.call(i, g.op));
|
|
133
|
+
seeded.push({ op: g.op, input: g.input, output });
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
// Checked above: the vendor's fake holds resources.
|
|
137
|
+
seeded.push(seedResource(name, target.fakes.get(g.vendor) as Fake<unknown> & ResourceFake, g));
|
|
138
|
+
}
|
|
139
|
+
for (const g of given) {
|
|
140
|
+
if ("fault" in g) target.fakes.get(target.ops.get(g.op)!.vendor);
|
|
141
|
+
}
|
|
142
|
+
return seeded;
|
|
143
|
+
}
|
|
144
|
+
|
|
80
145
|
/**
|
|
81
146
|
* Seeds a sandbox run of the scenario `name`, from the worker, in one step: reads the scenario,
|
|
82
|
-
* checks that it runs this workflow,
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
* run whose seeding is replayed from it has none, and fails,
|
|
89
|
-
* survive a worker restart.
|
|
147
|
+
* checks that it runs this workflow, picks the run's fakes, checks that every operation the
|
|
148
|
+
* workflow uses has one, then applies the scenario's `Given` steps to them
|
|
149
|
+
* (`applyGivens`) and puts their functions on the run. The run then calls those only. Its fakes
|
|
150
|
+
* are its own (each vendor's maker, called once for the run), so sandbox runs share no state when
|
|
151
|
+
* the makers make a new fake each; on an instance that runs on fakes (`instanceFakes`) they are
|
|
152
|
+
* the instance's, which its sandbox runs share, as its live drivers are. The step's checkpoint
|
|
153
|
+
* never stands in for a run's own: a run whose seeding is replayed from it has none, and fails,
|
|
154
|
+
* since a sandbox run does not survive a worker restart; on an instance's fakes it goes on with them.
|
|
90
155
|
*/
|
|
91
156
|
export async function seedSandbox(run: Run, name: string): Promise<void> {
|
|
92
157
|
const { state } = run;
|
|
@@ -103,19 +168,22 @@ export async function seedSandbox(run: Run, name: string): Promise<void> {
|
|
|
103
168
|
throw new SanomaError("invalid_input", `No scenario named "${name}"${why}`, { scenario: name });
|
|
104
169
|
}
|
|
105
170
|
if (scenario.workflow !== run.workflow) {
|
|
106
|
-
|
|
171
|
+
const runs = scenario.workflow === undefined ? "is a world, which runs nothing" : `runs ${scenario.workflow}`;
|
|
172
|
+
throw new SanomaError("invalid_input", `Scenario "${name}" ${runs}, not ${run.workflow}`, {
|
|
107
173
|
scenario: name,
|
|
108
|
-
workflow: scenario.workflow,
|
|
174
|
+
...(scenario.workflow !== undefined && { workflow: scenario.workflow }),
|
|
109
175
|
});
|
|
110
176
|
}
|
|
111
|
-
const
|
|
112
|
-
const
|
|
113
|
-
|
|
114
|
-
state.
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
177
|
+
const instance = state.instanceFakes;
|
|
178
|
+
const fakes = instance ?? new Map([...state.fakes].map(([vendor, make]) => [vendor, make()]));
|
|
179
|
+
const drivers = instance
|
|
180
|
+
? state.drivers
|
|
181
|
+
: indexDrivers(
|
|
182
|
+
[...fakes.values()].map((f) => f.driver),
|
|
183
|
+
state.ops,
|
|
184
|
+
);
|
|
185
|
+
// What the scenario seeds is checked by `applyGivens`.
|
|
186
|
+
const missing = opIdsOf(state.workflows.get(run.workflow)!).filter((id) => !drivers.has(id));
|
|
119
187
|
if (missing.length) {
|
|
120
188
|
throw new SanomaError(
|
|
121
189
|
"invalid_input",
|
|
@@ -123,28 +191,20 @@ export async function seedSandbox(run: Run, name: string): Promise<void> {
|
|
|
123
191
|
{ scenario: name, missing },
|
|
124
192
|
);
|
|
125
193
|
}
|
|
126
|
-
const
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
const output = await drivers.get(given.op)!(given.input, call);
|
|
134
|
-
seeded.push({ op: given.op, input: given.input, output });
|
|
135
|
-
continue;
|
|
136
|
-
}
|
|
137
|
-
// Checked above: the vendor's fake holds resources.
|
|
138
|
-
seeded.push(seedResource(name, fakes.get(given.vendor) as Fake<unknown> & ResourceFake, given));
|
|
139
|
-
}
|
|
140
|
-
for (const given of scenario.given) {
|
|
141
|
-
if ("fault" in given) fakes.get(state.ops.get(given.op)!.vendor);
|
|
142
|
-
}
|
|
194
|
+
const seeded = await applyGivens(scenario, {
|
|
195
|
+
kind: instance ? "instance" : "run",
|
|
196
|
+
drivers,
|
|
197
|
+
fakes,
|
|
198
|
+
ops: state.ops,
|
|
199
|
+
call: (i, opId) => ({ idempotencyKey: `${run.id}:seed:${i}`, runId: run.id, opId, attempt: 1, secrets }),
|
|
200
|
+
});
|
|
143
201
|
copies = drivers;
|
|
144
202
|
return { seeds: seeded };
|
|
145
203
|
},
|
|
146
204
|
{ name: "sandbox:seed" },
|
|
147
205
|
);
|
|
206
|
+
// Replayed on an instance that runs on fakes: they are the instance's, still there in this process.
|
|
207
|
+
if (!copies && state.instanceFakes) copies = state.drivers;
|
|
148
208
|
if (!copies) {
|
|
149
209
|
// Replayed from its checkpoint (after a worker restart, or in a fork), so the run has no fakes.
|
|
150
210
|
// The records written before the restart hold the seqs from here on: the failure goes after them.
|
package/src/scenario.ts
CHANGED
|
@@ -135,7 +135,19 @@ export interface Scenario {
|
|
|
135
135
|
file: string;
|
|
136
136
|
/** Its line in the file: its keyword's, or for an outline's row, the row's. */
|
|
137
137
|
line: number;
|
|
138
|
-
|
|
138
|
+
/**
|
|
139
|
+
* The workflow its `When` runs. Absent for a scenario with no `When`, a *world*: its `Given`
|
|
140
|
+
* steps alone, which a demo applies to the instance (`applyGivens`) and nothing runs or checks.
|
|
141
|
+
*/
|
|
142
|
+
workflow?: string;
|
|
143
|
+
/** Its tags, as written (`@slow`), the feature's and its own. */
|
|
144
|
+
tags: string[];
|
|
145
|
+
/**
|
|
146
|
+
* When it is tagged `@moment(<person>,<path>)` (no space: a Gherkin tag has none): a moment,
|
|
147
|
+
* which a demo starts as a sandbox run by `person`, a declared person, and leaves waiting, for
|
|
148
|
+
* a visitor signed in as them to land on at `path`.
|
|
149
|
+
*/
|
|
150
|
+
moment?: Moment;
|
|
139
151
|
/**
|
|
140
152
|
* The scenario's own lines of its feature file, for display: from its keyword line to the end
|
|
141
153
|
* of its last step (a step's doc string or table included, and an outline's examples), less
|
|
@@ -158,6 +170,12 @@ export interface Scenario {
|
|
|
158
170
|
expect: Expectation[];
|
|
159
171
|
}
|
|
160
172
|
|
|
173
|
+
/** A moment's visitor and where they land: `@moment(<person>,<path>)`. */
|
|
174
|
+
export interface Moment {
|
|
175
|
+
person: string;
|
|
176
|
+
path: string;
|
|
177
|
+
}
|
|
178
|
+
|
|
161
179
|
/** The result of one expectation against a run's ledger. */
|
|
162
180
|
export interface Check {
|
|
163
181
|
step: string;
|
|
@@ -380,6 +398,22 @@ function builtFor(scope: Scope): Built {
|
|
|
380
398
|
/** A `Then` step of the generic steps: its expectation, checked against the run's records. */
|
|
381
399
|
const expecting = (expect: CoreExpectation): Then => ({ expect, verify: (run) => detailOf(expect, run.records) });
|
|
382
400
|
|
|
401
|
+
/**
|
|
402
|
+
* `who`, as a run's start admits them (`admit`): a declared person or agent, or `anonymous`. The
|
|
403
|
+
* people are the root's data files' (`readPeople`, read again only once a file changed): while
|
|
404
|
+
* they declare no one, any id goes.
|
|
405
|
+
*/
|
|
406
|
+
function actorNamed(scope: Scope, who: string): string {
|
|
407
|
+
const declared = readPeople(scope.root);
|
|
408
|
+
try {
|
|
409
|
+
return admit({ id: who }, declared).id;
|
|
410
|
+
} catch (err) {
|
|
411
|
+
if (errorCode(err) !== "not_admitted" || declared.problems.length) throw err;
|
|
412
|
+
const ids = [...principalIdsOf(declared.people), ...RUNTIME_AGENTS].join(", ");
|
|
413
|
+
throw new Error(`${who} is not a declared person or agent; resources/ declares ${ids}`, { cause: err });
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
383
417
|
/** The rules a scope has, and the parameter types their expressions use. */
|
|
384
418
|
function rulesOf(scope: Scope): { rules: Rule[]; parameterTypes: ParameterTypeSpec[] } {
|
|
385
419
|
const { types, collections } = scope;
|
|
@@ -403,20 +437,10 @@ function rulesOf(scope: Scope): { rules: Rule[]; parameterTypes: ParameterTypeSp
|
|
|
403
437
|
];
|
|
404
438
|
const opOf = (id: unknown) => scope.ops.get(id as string)!;
|
|
405
439
|
const workflowOf = (name: unknown) => scope.workflows.get(name as string)!;
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
const declared = readPeople(scope.root);
|
|
411
|
-
try {
|
|
412
|
-
return admit({ id: who }, declared).id;
|
|
413
|
-
} catch (err) {
|
|
414
|
-
if (errorCode(err) !== "not_admitted" || declared.problems.length) throw err;
|
|
415
|
-
const ids = [...principalIdsOf(declared.people), ...RUNTIME_AGENTS].join(", ");
|
|
416
|
-
throw new Error(`${who} is not a declared person or agent; resources/ declares ${ids}`, { cause: err });
|
|
417
|
-
}
|
|
418
|
-
};
|
|
419
|
-
/** `group`, a declared group or `agents`, while the people's files have no problems. */
|
|
440
|
+
/**
|
|
441
|
+
* `group`, a declared group or `agents`, while the people's files have no problems (`readPeople`,
|
|
442
|
+
* read again only once a file changed): while they declare no one, any group goes.
|
|
443
|
+
*/
|
|
420
444
|
const groupNamed = (group: string) => {
|
|
421
445
|
const { people, problems } = readPeople(scope.root);
|
|
422
446
|
if (problems.length) {
|
|
@@ -434,7 +458,7 @@ function rulesOf(scope: Scope): { rules: Rule[]; parameterTypes: ParameterTypeSp
|
|
|
434
458
|
return { resource: id as string, vendor, type, name: name as string };
|
|
435
459
|
};
|
|
436
460
|
const rules: Rule[] = [
|
|
437
|
-
{ kind: "actor", source: "{who} is acting", read: ([who]) => actorNamed(who as string) },
|
|
461
|
+
{ kind: "actor", source: "{who} is acting", read: ([who]) => actorNamed(scope, who as string) },
|
|
438
462
|
// A visitor of a public site, as a login would vouch for one; `anonymous` is a `{who}`.
|
|
439
463
|
{ kind: "actor", source: "a visitor is acting", read: () => visitorPrincipal("scenario", "visitor").id },
|
|
440
464
|
{
|
|
@@ -881,7 +905,7 @@ function namesOf(report: Report): Map<string, string> {
|
|
|
881
905
|
}
|
|
882
906
|
|
|
883
907
|
/** What one scenario's steps said, as compiling it records them. */
|
|
884
|
-
type Spec = Omit<Scenario, "
|
|
908
|
+
type Spec = Omit<Scenario, "approvals" | "file" | "line" | "text" | "tags" | "moment">;
|
|
885
909
|
|
|
886
910
|
/** The run's records, as a `Then` step reads them. */
|
|
887
911
|
function viewOf(records: readonly LedgerRecord[]): RunView {
|
|
@@ -1177,6 +1201,28 @@ function stepError(built: Built, result: StepResult | undefined, step: PickleSte
|
|
|
1177
1201
|
}
|
|
1178
1202
|
}
|
|
1179
1203
|
|
|
1204
|
+
/**
|
|
1205
|
+
* The moment its tags make a scenario (`@moment(<person>,<path>)`), the person admitted as a run's
|
|
1206
|
+
* starter is, or undefined for none; throws, at `where`, for a malformed one or a second.
|
|
1207
|
+
*/
|
|
1208
|
+
function momentOf(scope: Scope, tags: readonly string[], where: string): Moment | undefined {
|
|
1209
|
+
const tagged = tags.filter((t) => t === "@moment" || t.startsWith("@moment("));
|
|
1210
|
+
if (tagged.length === 0) return undefined;
|
|
1211
|
+
if (tagged.length > 1) throw new Error(`${where}: ${tagged.join(" and ")}: a moment has one person and one page`);
|
|
1212
|
+
const [, person, path] = /^@moment\(([^,()]+),(\/[^()]*)\)$/.exec(tagged[0]!) ?? [];
|
|
1213
|
+
if (person === undefined || path === undefined) {
|
|
1214
|
+
throw new Error(
|
|
1215
|
+
`${where}: ${tagged[0]} needs a person and a path, as in @moment(alex,/sites/portal/requests), with no space: a tag has none`,
|
|
1216
|
+
);
|
|
1217
|
+
}
|
|
1218
|
+
try {
|
|
1219
|
+
return { person: actorNamed(scope, person), path };
|
|
1220
|
+
} catch (err) {
|
|
1221
|
+
if (isBug(err)) throw err;
|
|
1222
|
+
throw new Error(`${where}: ${tagged[0]}: ${errorMessage(err)}`, { cause: err });
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1180
1226
|
/** The `.feature` files under `dir`, relative to it, with `/` between directories, in order. */
|
|
1181
1227
|
function featureFiles(dir: string): string[] {
|
|
1182
1228
|
return readdirSync(dir, { recursive: true, encoding: "utf8" })
|
|
@@ -1197,8 +1243,9 @@ const parseErrorOf = ({ uri, line, message }: Report["parseErrors"][number]) =>
|
|
|
1197
1243
|
|
|
1198
1244
|
/**
|
|
1199
1245
|
* Compiles the feature files `files` (relative to `dir`) into scenarios: Cucumber runs each,
|
|
1200
|
-
* and its steps record what they say. A file
|
|
1201
|
-
*
|
|
1246
|
+
* and its steps record what they say. A file none of whose scenarios has a `When` is of worlds.
|
|
1247
|
+
* A file with an error has none of its scenarios: the
|
|
1248
|
+
* first error in it (a step that fails or matches no step or two, a scenario with no `When` beside one with,
|
|
1202
1249
|
* a name used twice, a file Gherkin cannot parse, no scenarios at all) is its error.
|
|
1203
1250
|
*/
|
|
1204
1251
|
async function compile(scope: Scope, dir: string, files: string[]): Promise<LoadedScenarios> {
|
|
@@ -1234,6 +1281,8 @@ async function compile(scope: Scope, dir: string, files: string[]): Promise<Load
|
|
|
1234
1281
|
try {
|
|
1235
1282
|
const list = pickles.get(file) ?? [];
|
|
1236
1283
|
if (list.length === 0) throw new Error(`${file}: no scenarios; add one with "Scenario: <name>"`);
|
|
1284
|
+
// A feature none of whose scenarios has a When is of worlds; elsewhere a missing When is a mistake.
|
|
1285
|
+
const ofWorlds = list.every((p) => specs.get(p.id)!.workflow === undefined);
|
|
1237
1286
|
for (const pickle of list) {
|
|
1238
1287
|
const line = lineOf(report, pickle);
|
|
1239
1288
|
const scenario = query.findLineageBy(pickle)!.scenario!;
|
|
@@ -1242,16 +1291,37 @@ async function compile(scope: Scope, dir: string, files: string[]): Promise<Load
|
|
|
1242
1291
|
const error = stepError(built, results.get(step.id), step, whereOf(report, file, step));
|
|
1243
1292
|
if (error) throw new Error(error);
|
|
1244
1293
|
}
|
|
1245
|
-
const
|
|
1294
|
+
const spec = specs.get(pickle.id)!;
|
|
1295
|
+
const { workflow } = spec;
|
|
1246
1296
|
const name = names.get(pickle.id)!;
|
|
1247
1297
|
if (inFile.some((s) => s.name === name)) {
|
|
1248
1298
|
throw new Error(`${where}: a second scenario named "${name}"; give each its own name`);
|
|
1249
1299
|
}
|
|
1250
|
-
|
|
1300
|
+
const tags = pickle.tags.map((t) => t.name);
|
|
1301
|
+
const moment = momentOf(scope, tags, where);
|
|
1302
|
+
const common = { name, file, line, tags, text: textOf(scenario, report.texts.get(file) ?? "") };
|
|
1303
|
+
if (workflow === undefined && !ofWorlds) {
|
|
1251
1304
|
throw new Error(
|
|
1252
|
-
`${where}: scenario "${name}" has no When: say which workflow runs, such as "When <workflow> runs with"`,
|
|
1305
|
+
`${where}: scenario "${name}" has no When: say which workflow runs, such as "When <workflow> runs with" (a feature with no When in any scenario is of worlds)`,
|
|
1253
1306
|
);
|
|
1254
1307
|
}
|
|
1308
|
+
if (workflow === undefined) {
|
|
1309
|
+
// A world: its Givens alone, which nothing runs or checks.
|
|
1310
|
+
const runs = moment
|
|
1311
|
+
? "is a moment, which starts a run"
|
|
1312
|
+
: spec.expect[0]
|
|
1313
|
+
? `checks a run ("${spec.expect[0].step}")`
|
|
1314
|
+
: spec.decisions[0]
|
|
1315
|
+
? `decides an approval ("${spec.decisions[0].step}")`
|
|
1316
|
+
: undefined;
|
|
1317
|
+
if (runs) {
|
|
1318
|
+
throw new Error(
|
|
1319
|
+
`${where}: scenario "${name}" has no When, so it is a world, Givens only, but it ${runs}: say which workflow runs, such as "When <workflow> runs with"`,
|
|
1320
|
+
);
|
|
1321
|
+
}
|
|
1322
|
+
inFile.push({ ...spec, ...common, approvals: [] });
|
|
1323
|
+
continue;
|
|
1324
|
+
}
|
|
1255
1325
|
// A step about a declared resource reads the report of the built-in it is about.
|
|
1256
1326
|
for (const e of spec.expect) {
|
|
1257
1327
|
const reads: string = "declared" in e ? ("status" in e ? DRIFT_WORKFLOW : APPLY_WORKFLOW) : workflow;
|
|
@@ -1263,11 +1333,9 @@ async function compile(scope: Scope, dir: string, files: string[]): Promise<Load
|
|
|
1263
1333
|
}
|
|
1264
1334
|
inFile.push({
|
|
1265
1335
|
...spec,
|
|
1266
|
-
|
|
1267
|
-
file,
|
|
1268
|
-
line,
|
|
1336
|
+
...common,
|
|
1269
1337
|
workflow,
|
|
1270
|
-
|
|
1338
|
+
...(moment && { moment }),
|
|
1271
1339
|
approvals: approvalsOf(scope.workflows.get(workflow)!),
|
|
1272
1340
|
});
|
|
1273
1341
|
}
|
|
@@ -1358,10 +1426,11 @@ export async function loadScenarios(scope: Scope): Promise<LoadedScenarios> {
|
|
|
1358
1426
|
*/
|
|
1359
1427
|
export async function check(scope: Scope, scenario: Scenario, records: readonly LedgerRecord[]): Promise<Check[]> {
|
|
1360
1428
|
if (!scope.scenarios) throw new Error(`No scenarios directory to check scenario "${scenario.name}" from`);
|
|
1429
|
+
const { file, line, name, workflow } = scenario;
|
|
1430
|
+
if (workflow === undefined) throw new Error(`Scenario "${name}" is a world: it runs nothing to check`);
|
|
1361
1431
|
const built = builtFor(scope);
|
|
1362
|
-
const { file, line, name } = scenario;
|
|
1363
1432
|
const verdicts = new Map<string, string | undefined>();
|
|
1364
|
-
const report = await runFeatures(built.library, checkWorld(built,
|
|
1433
|
+
const report = await runFeatures(built.library, checkWorld(built, workflow, viewOf(records), verdicts), {
|
|
1365
1434
|
cwd: fileURLToPath(scope.scenarios),
|
|
1366
1435
|
files: [file],
|
|
1367
1436
|
line,
|
package/src/scenarios.ts
CHANGED
|
@@ -6,11 +6,13 @@ import { type Check, check, drive, loadScenarios, type Scenario, type Scope } fr
|
|
|
6
6
|
import { errorMessage } from "./shared.ts";
|
|
7
7
|
import { startWorker } from "./worker.ts";
|
|
8
8
|
|
|
9
|
+
export { type DemoState, demoState, reseed } from "./demo.ts";
|
|
10
|
+
|
|
9
11
|
/*
|
|
10
12
|
* `@sanoma-ai/workflows/scenarios`: runs a config's scenarios as sandbox runs, with no test
|
|
11
13
|
* framework. Server-side only, like `./scenario`: it loads Cucumber and starts a worker.
|
|
12
14
|
* `sanoma check` runs every scenario through `runScenarios`; `@sanoma-ai/testing/scenarios` wraps
|
|
13
|
-
* `runScenario` in a vitest test per scenario.
|
|
15
|
+
* `runScenario` in a vitest test per scenario. A demo seeds its instance with `reseed`.
|
|
14
16
|
*/
|
|
15
17
|
|
|
16
18
|
export interface RunScenarioOptions {
|
|
@@ -30,12 +32,15 @@ export interface RunScenarioOptions {
|
|
|
30
32
|
/**
|
|
31
33
|
* Runs a scenario as a sandbox run: starts its workflow with its input, as whoever it says is
|
|
32
34
|
* acting, decides its approvals as it says, and checks the run's ledger against it (`check`:
|
|
33
|
-
* Cucumber runs its `Then` steps).
|
|
35
|
+
* Cucumber runs its `Then` steps). Refuses a world, which runs nothing.
|
|
34
36
|
*/
|
|
35
37
|
export async function runScenario(
|
|
36
38
|
scenario: Scenario,
|
|
37
39
|
{ client, scope, startedBy = "scenarios", timeoutMs }: RunScenarioOptions,
|
|
38
40
|
): Promise<{ runId: string; run: RunSummary; ledger: LedgerRecord[]; checks: Check[] }> {
|
|
41
|
+
if (scenario.workflow === undefined) {
|
|
42
|
+
throw new Error(`Scenario "${scenario.name}" is a world: it seeds a demo's instance, and runs nothing`);
|
|
43
|
+
}
|
|
39
44
|
const runId = await client.start(scenario.workflow, scenario.input, {
|
|
40
45
|
startedBy: scenario.startedBy ?? startedBy,
|
|
41
46
|
sandbox: scenario.name,
|
|
@@ -64,6 +69,13 @@ export interface RunScenariosOptions {
|
|
|
64
69
|
log?: (line: string) => void;
|
|
65
70
|
}
|
|
66
71
|
|
|
72
|
+
/**
|
|
73
|
+
* True for a scenario that is a test: one that runs a workflow (not a world), and not a moment
|
|
74
|
+
* that decides nothing, which a demo leaves waiting at its first approval.
|
|
75
|
+
*/
|
|
76
|
+
export const isTest = (scenario: Scenario): scenario is Scenario & { workflow: string } =>
|
|
77
|
+
scenario.workflow !== undefined && !(scenario.moment && scenario.decisions.length === 0);
|
|
78
|
+
|
|
67
79
|
/**
|
|
68
80
|
* Runs every scenario in `scenarios/` in the config's root, one after another, as sandbox runs
|
|
69
81
|
* on a worker it starts on `databaseUrl` with an in-memory ledger (the config's `ledger` is not
|
|
@@ -72,6 +84,7 @@ export interface RunScenariosOptions {
|
|
|
72
84
|
* could not be read, with why. A scenario whose run cannot be driven to its end (an approval
|
|
73
85
|
* with no decision, a run still going after `timeoutMs`) fails with that error at its `When`
|
|
74
86
|
* step. A config whose root has no `scenarios/`, or none in it, passes nothing and fails nothing.
|
|
87
|
+
* Worlds and moments that decide nothing are no tests (`isTest`): they are left out, each logged as skipped.
|
|
75
88
|
*/
|
|
76
89
|
export async function runScenarios(
|
|
77
90
|
config: SanomaConfig,
|
|
@@ -79,7 +92,9 @@ export async function runScenarios(
|
|
|
79
92
|
): Promise<{ passed: string[]; failed: ScenarioFailure[] }> {
|
|
80
93
|
const shared: SanomaConfig = { ...config, databaseUrl, ledger: memoryLedger() };
|
|
81
94
|
const resolved = resolveConfig(shared);
|
|
82
|
-
const { scenarios, errors } = await loadScenarios(resolved);
|
|
95
|
+
const { scenarios: loaded, errors } = await loadScenarios(resolved);
|
|
96
|
+
const scenarios = loaded.filter(isTest);
|
|
97
|
+
for (const s of loaded) if (!isTest(s)) log?.(`skipped ${s.file} › ${s.name}: ${s.moment ? "a moment" : "a world"}`);
|
|
83
98
|
const failed: ScenarioFailure[] = errors.map(({ file, message }) => ({
|
|
84
99
|
scenario: file,
|
|
85
100
|
step: "did not load",
|
|
@@ -88,8 +103,9 @@ export async function runScenarios(
|
|
|
88
103
|
const passed: string[] = [];
|
|
89
104
|
if (scenarios.length === 0) return { passed, failed };
|
|
90
105
|
|
|
91
|
-
// Quiet: the scenarios call the fakes alone, so no vendor need be configured
|
|
92
|
-
|
|
106
|
+
// Quiet: the scenarios call the fakes alone, so no vendor need be configured; and no schedule
|
|
107
|
+
// is applied, since a tick's run would be live.
|
|
108
|
+
const worker = await startWorker(shared, { promote: true, quiet: true, schedules: false });
|
|
93
109
|
try {
|
|
94
110
|
const client = await SanomaClient.connect(shared);
|
|
95
111
|
try {
|