@sanoma-ai/workflows 0.8.1 → 0.9.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 +4 -17
- package/dist/apps-list.d.ts +32 -6
- package/dist/apps-list.js +66 -25
- package/dist/apps-list.js.map +1 -1
- package/dist/client.d.ts +1 -13
- package/dist/client.js +2 -22
- package/dist/client.js.map +1 -1
- package/dist/config.d.ts +2 -30
- package/dist/config.js +3 -21
- package/dist/config.js.map +1 -1
- package/dist/fake.d.ts +1 -9
- package/dist/fake.js +2 -12
- package/dist/fake.js.map +1 -1
- package/dist/run.d.ts +0 -2
- package/dist/run.js.map +1 -1
- package/dist/sandbox.d.ts +10 -38
- package/dist/sandbox.js +35 -66
- package/dist/sandbox.js.map +1 -1
- package/dist/scenario.d.ts +1 -18
- package/dist/scenario.js +27 -77
- package/dist/scenario.js.map +1 -1
- package/dist/scenarios.d.ts +1 -10
- package/dist/scenarios.js +2 -16
- package/dist/scenarios.js.map +1 -1
- package/dist/sites.d.ts +6 -0
- package/dist/sites.js +49 -10
- package/dist/sites.js.map +1 -1
- package/dist/worker.d.ts +1 -7
- package/dist/worker.js +2 -3
- package/dist/worker.js.map +1 -1
- package/package.json +1 -1
- package/src/apps-list.ts +75 -26
- package/src/client.ts +2 -28
- package/src/config.ts +4 -54
- package/src/fake.ts +1 -13
- package/src/run.ts +0 -2
- package/src/sandbox.ts +34 -94
- package/src/scenario.ts +28 -97
- package/src/scenarios.ts +3 -18
- package/src/sites.ts +67 -11
- package/src/worker.ts +2 -9
- package/dist/demo.d.ts +0 -39
- package/dist/demo.js +0 -87
- package/dist/demo.js.map +0 -1
- package/src/demo.ts +0 -118
package/src/config.ts
CHANGED
|
@@ -114,12 +114,6 @@ export interface SanomaConfig {
|
|
|
114
114
|
* that sets it and declares a wakeup of its own.
|
|
115
115
|
*/
|
|
116
116
|
wakeup?: true;
|
|
117
|
-
/**
|
|
118
|
-
* What the app says when it runs this repo as a demo (`sanoma demo`): `start`, the URL its
|
|
119
|
-
* banner's "Start with these apps" opens, such as the hosted wizard with the demo's apps
|
|
120
|
-
* chosen; without one, the banner has no such link.
|
|
121
|
-
*/
|
|
122
|
-
demo?: { start?: string };
|
|
123
117
|
}
|
|
124
118
|
|
|
125
119
|
/** A config, checked, with everything a worker, client or app derives from it. */
|
|
@@ -149,18 +143,8 @@ export interface ResolvedConfig {
|
|
|
149
143
|
* `connectors` order: what `drivers` indexes and the credentials are read from (`credentialsOf`).
|
|
150
144
|
*/
|
|
151
145
|
driversByVendor: Map<string, Driver>;
|
|
152
|
-
/**
|
|
153
|
-
* The makers of the fakes the connectors attach, by vendor id in `connectors` order: each
|
|
154
|
-
* sandbox run makes its own. Resolved with `fakes: "instance"`, each gives its vendor's
|
|
155
|
-
* instance fake (`instanceFakes`).
|
|
156
|
-
*/
|
|
146
|
+
/** The makers of the fakes the connectors attach, by vendor id in `connectors` order: each sandbox run makes its own. */
|
|
157
147
|
fakes: Map<string, () => Fake<any, any>>;
|
|
158
|
-
/**
|
|
159
|
-
* Resolved with `fakes: "instance"` (a demo): each vendor's one fake, by vendor id, made once
|
|
160
|
-
* for this resolution. Each is its vendor's driver (`driversByVendor`), so live runs, sandbox
|
|
161
|
-
* runs and direct calls all call it, and the world's Givens seed it (`applyGivens`).
|
|
162
|
-
*/
|
|
163
|
-
instanceFakes?: ReadonlyMap<string, Fake<any, any>>;
|
|
164
148
|
/** The makers of the login halves the connectors attach, by vendor id in `connectors` order: the app makes one per public site that names it. */
|
|
165
149
|
logins: ReadonlyMap<string, NonNullable<ConnectorSetup["login"]>>;
|
|
166
150
|
/** The makers of the model halves the connectors attach, by vendor id in `connectors` order: the app makes each once for its model registry. */
|
|
@@ -257,17 +241,6 @@ export function rootOf(config: Pick<SanomaConfig, "root" | "file">): string | un
|
|
|
257
241
|
: undefined;
|
|
258
242
|
}
|
|
259
243
|
|
|
260
|
-
/** What `resolveConfig` is given besides the config. */
|
|
261
|
-
export interface ResolveOptions {
|
|
262
|
-
/**
|
|
263
|
-
* `"instance"`: run the whole instance on the connectors' fakes, as a demo does: each vendor's
|
|
264
|
-
* driver is one fake made for this resolution (`instanceFakes`), which every run and call
|
|
265
|
-
* shares. The collections stay on the config's database. Refused for a vendor whose connector
|
|
266
|
-
* attaches a driver and no fake, which would otherwise run live.
|
|
267
|
-
*/
|
|
268
|
-
fakes?: "instance";
|
|
269
|
-
}
|
|
270
|
-
|
|
271
244
|
/**
|
|
272
245
|
* Checks a config and derives what the runtime needs from it: the drivers and fakes from the
|
|
273
246
|
* connectors' setups (`withSetup`), the collections' operations, driver and fake, and from
|
|
@@ -278,7 +251,7 @@ export interface ResolveOptions {
|
|
|
278
251
|
* `root`. Each entry point resolves once: the worker and the client when they start, the app at
|
|
279
252
|
* `startApp`, the `sanoma` bin when it loads the config.
|
|
280
253
|
*/
|
|
281
|
-
export function resolveConfig(config: SanomaConfig
|
|
254
|
+
export function resolveConfig(config: SanomaConfig): ResolvedConfig {
|
|
282
255
|
if (!Array.isArray(config.connectors)) {
|
|
283
256
|
throw new Error("The config needs `connectors`: the connectors its workflows call, as their packages export them");
|
|
284
257
|
}
|
|
@@ -320,11 +293,7 @@ export function resolveConfig(config: SanomaConfig, options: ResolveOptions = {}
|
|
|
320
293
|
]);
|
|
321
294
|
checkWakeupOps(types, ops);
|
|
322
295
|
checkDatabaseOps(types, ops);
|
|
323
|
-
const
|
|
324
|
-
const instanceFakes = options.fakes === "instance" ? instanceFakesOf(running, fakes) : undefined;
|
|
325
|
-
const driversByVendor = instanceFakes
|
|
326
|
-
? new Map([...instanceFakes].map(([vendor, fake]) => [vendor, fake.driver]))
|
|
327
|
-
: new Map([...makersOf(running, "driver")].map(([vendor, make]) => [vendor, make()]));
|
|
296
|
+
const driversByVendor = new Map([...makersOf(running, "driver")].map(([vendor, make]) => [vendor, make()]));
|
|
328
297
|
const remotes = new Map([...makersOf(config.connectors, "remote")].map(([vendor, make]) => [vendor, make()]));
|
|
329
298
|
const drivers = indexDrivers(
|
|
330
299
|
[
|
|
@@ -336,6 +305,7 @@ export function resolveConfig(config: SanomaConfig, options: ResolveOptions = {}
|
|
|
336
305
|
],
|
|
337
306
|
ops,
|
|
338
307
|
);
|
|
308
|
+
const fakes = makersOf(running, "fake");
|
|
339
309
|
if (dataStore) fakes.set(DATA_VENDOR, () => collectionsFake(declared));
|
|
340
310
|
const names = new Map<string, WorkflowDefinition<any, any>>();
|
|
341
311
|
for (const wf of config.workflows ?? []) {
|
|
@@ -383,7 +353,6 @@ export function resolveConfig(config: SanomaConfig, options: ResolveOptions = {}
|
|
|
383
353
|
drivers,
|
|
384
354
|
driversByVendor,
|
|
385
355
|
fakes,
|
|
386
|
-
...(instanceFakes && { instanceFakes }),
|
|
387
356
|
logins: makersOf(config.connectors, "login"),
|
|
388
357
|
models: makersOf(config.connectors, "model"),
|
|
389
358
|
remotes,
|
|
@@ -483,25 +452,6 @@ function makersOf<K extends keyof ConnectorSetup>(
|
|
|
483
452
|
return makers;
|
|
484
453
|
}
|
|
485
454
|
|
|
486
|
-
/**
|
|
487
|
-
* Each vendor's one fake, made now, for `resolveConfig`'s `fakes: "instance"`; `fakes` (the
|
|
488
|
-
* makers) then give these. Refuses a connector that attaches a driver and no fake.
|
|
489
|
-
*/
|
|
490
|
-
function instanceFakesOf(
|
|
491
|
-
connectors: readonly Connector<any, any>[],
|
|
492
|
-
fakes: Map<string, () => Fake<any, any>>,
|
|
493
|
-
): Map<string, Fake<any, any>> {
|
|
494
|
-
const live = [...makersOf(connectors, "driver").keys()].filter((vendor) => !fakes.has(vendor));
|
|
495
|
-
if (live.length) {
|
|
496
|
-
throw new Error(
|
|
497
|
-
`The instance runs on fakes, but the connector of ${live.join(", ")} attaches no fake, so it would run live; attach one (\`withSetup\`) or leave it out`,
|
|
498
|
-
);
|
|
499
|
-
}
|
|
500
|
-
const made = new Map([...fakes].map(([vendor, make]) => [vendor, make()]));
|
|
501
|
-
for (const [vendor, fake] of made) fakes.set(vendor, () => fake);
|
|
502
|
-
return made;
|
|
503
|
-
}
|
|
504
|
-
|
|
505
455
|
/**
|
|
506
456
|
* The `name` of `<root>/package.json`, kept whole (`@acme/ops`), when that file exists and names
|
|
507
457
|
* one; else "sanoma", as for a config with no `root`. Never a `package.json` above the root: a
|
package/src/fake.ts
CHANGED
|
@@ -7,7 +7,6 @@ import {
|
|
|
7
7
|
DriverError,
|
|
8
8
|
type OpIdOf,
|
|
9
9
|
type Specs,
|
|
10
|
-
withSetup,
|
|
11
10
|
} from "./op.ts";
|
|
12
11
|
import { fail, type Instance, UNREGISTERED, type WakeupApi, wakeupCalls } from "./wakeup-driver.ts";
|
|
13
12
|
import { INSTANCE_TOKEN, instanceUrl } from "./wakeup-service.ts";
|
|
@@ -73,7 +72,7 @@ type Fault =
|
|
|
73
72
|
| { kind: "hold"; released: Promise<void> };
|
|
74
73
|
|
|
75
74
|
/**
|
|
76
|
-
* Builds a fake vendor for a connector, for tests and
|
|
75
|
+
* Builds a fake vendor for a connector, for tests and sandbox runs. `ops` implements the operations
|
|
77
76
|
* against `state`, typed by the connector as `defineDriver` does. The fake adds what a real
|
|
78
77
|
* vendor does around them: a repeated idempotency key gets the first reply and changes
|
|
79
78
|
* nothing. A test can make the next call fail, lose its reply, hit a rate limit or wait.
|
|
@@ -159,17 +158,6 @@ export function defineFake<V extends string, S extends Specs, T extends Record<s
|
|
|
159
158
|
};
|
|
160
159
|
}
|
|
161
160
|
|
|
162
|
-
/**
|
|
163
|
-
* `connector` set up with this fake instead of the one its package attaches, for a test or a demo
|
|
164
|
-
* that seeds or reads the fake its runs call: live runs call its driver, and sandbox runs call it
|
|
165
|
-
* too, since its maker gives this very one. A driver swap alone is `withSetup(connector, { driver })`,
|
|
166
|
-
* which keeps the package's fake, so each sandbox run has one of its own. The operations are the
|
|
167
|
-
* same.
|
|
168
|
-
*/
|
|
169
|
-
export function withFake<C extends Connector<any, any>>(connector: C, fake: Fake<any, any>): C {
|
|
170
|
-
return withSetup(connector, { driver: () => fake.driver, fake: () => fake });
|
|
171
|
-
}
|
|
172
|
-
|
|
173
161
|
// The built-in `sanoma.wakeup`'s fake: an in-memory wakeup service that calls the host's `/wake`.
|
|
174
162
|
|
|
175
163
|
/** An instance of the fake wakeup service, as it keeps one. */
|
package/src/run.ts
CHANGED
|
@@ -46,8 +46,6 @@ export interface WorkerState {
|
|
|
46
46
|
drivers: Map<string, DriverFn>;
|
|
47
47
|
/** The makers of the config's fake vendors, by vendor id: a sandbox run makes its own of each when it is seeded. */
|
|
48
48
|
fakes: Map<string, () => Fake<any, any>>;
|
|
49
|
-
/** The instance's fakes, by vendor id, when it runs on them (`fakes: "instance"`): its sandbox runs seed and call these, not fakes of their own. */
|
|
50
|
-
instanceFakes?: ReadonlyMap<string, Fake<any, any>>;
|
|
51
49
|
/** Where sandbox runs' scenarios are, the resource types their steps may name, and the steps the connectors ship. */
|
|
52
50
|
scenarios?: URL;
|
|
53
51
|
/** The worker's `source`, when it was given one. */
|
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 {
|
|
7
|
+
import type { DriverFn } 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
|
+
): Extract<LedgerRecord, { type: "scenario.seeded" }>["seeds"][number] {
|
|
62
62
|
const { resource, type, name } = given;
|
|
63
63
|
try {
|
|
64
64
|
if ("gone" in given) {
|
|
@@ -77,81 +77,16 @@ 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
|
-
|
|
145
80
|
/**
|
|
146
81
|
* Seeds a sandbox run of the scenario `name`, from the worker, in one step: reads the scenario,
|
|
147
|
-
* checks that it runs this workflow,
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
*
|
|
82
|
+
* checks that it runs this workflow, makes the run's own fakes (each vendor's maker, called once
|
|
83
|
+
* for the run), checks that every operation the workflow uses or the scenario seeds has one and
|
|
84
|
+
* that every resource it seeds has a fake holding resources, calls each `Given` operation
|
|
85
|
+
* through them, puts or removes each `Given` resource in them, injects the faults into them,
|
|
86
|
+
* then puts their functions on the run. The run then calls those only, so sandbox runs share no
|
|
87
|
+
* state when the makers make a new fake each. The step's checkpoint never stands in for them: a
|
|
88
|
+
* run whose seeding is replayed from it has none, and fails, since a sandbox run does not
|
|
89
|
+
* survive a worker restart.
|
|
155
90
|
*/
|
|
156
91
|
export async function seedSandbox(run: Run, name: string): Promise<void> {
|
|
157
92
|
const { state } = run;
|
|
@@ -168,19 +103,16 @@ export async function seedSandbox(run: Run, name: string): Promise<void> {
|
|
|
168
103
|
throw new SanomaError("invalid_input", `No scenario named "${name}"${why}`, { scenario: name });
|
|
169
104
|
}
|
|
170
105
|
if (scenario.workflow !== run.workflow) {
|
|
171
|
-
|
|
172
|
-
throw new SanomaError("invalid_input", `Scenario "${name}" ${runs}, not ${run.workflow}`, {
|
|
106
|
+
throw new SanomaError("invalid_input", `Scenario "${name}" runs ${scenario.workflow}, not ${run.workflow}`, {
|
|
173
107
|
scenario: name,
|
|
174
|
-
|
|
108
|
+
workflow: scenario.workflow,
|
|
175
109
|
});
|
|
176
110
|
}
|
|
177
|
-
const
|
|
178
|
-
const
|
|
179
|
-
const
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
// What the scenario seeds is checked by `applyGivens`.
|
|
183
|
-
const missing = opIdsOf(state.workflows.get(run.workflow)!).filter((id) => !drivers.has(id));
|
|
111
|
+
const fakes = new Map([...state.fakes].map(([vendor, make]) => [vendor, make()]));
|
|
112
|
+
const drivers = indexDrivers([...Array.from(fakes.values(), (f) => f.driver), ...sandboxDrivers], state.ops);
|
|
113
|
+
const uses = opIdsOf(state.workflows.get(run.workflow)!);
|
|
114
|
+
const seeding = scenario.given.flatMap((g) => ("op" in g ? [g.op] : []));
|
|
115
|
+
const missing = [...new Set([...uses, ...seeding])].filter((id) => !drivers.has(id));
|
|
184
116
|
if (missing.length) {
|
|
185
117
|
throw new SanomaError(
|
|
186
118
|
"invalid_input",
|
|
@@ -188,20 +120,28 @@ export async function seedSandbox(run: Run, name: string): Promise<void> {
|
|
|
188
120
|
{ scenario: name, missing },
|
|
189
121
|
);
|
|
190
122
|
}
|
|
191
|
-
const
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
123
|
+
const plain = missingResourceFake(scenario, fakes);
|
|
124
|
+
if (plain) throw plain;
|
|
125
|
+
const seeded: Extract<LedgerRecord, { type: "scenario.seeded" }>["seeds"] = [];
|
|
126
|
+
for (const [i, given] of scenario.given.entries()) {
|
|
127
|
+
if ("fault" in given) continue;
|
|
128
|
+
if ("op" in given) {
|
|
129
|
+
const call = { idempotencyKey: `${run.id}:seed:${i}`, runId: run.id, opId: given.op, attempt: 1, secrets };
|
|
130
|
+
const output = await drivers.get(given.op)!(given.input, call);
|
|
131
|
+
seeded.push({ op: given.op, input: given.input, output });
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
// Checked above: the vendor's fake holds resources.
|
|
135
|
+
seeded.push(seedResource(name, fakes.get(given.vendor) as Fake<unknown> & ResourceFake, given));
|
|
136
|
+
}
|
|
137
|
+
for (const given of scenario.given) {
|
|
138
|
+
if ("fault" in given) fakes.get(state.ops.get(given.op)!.vendor);
|
|
139
|
+
}
|
|
198
140
|
copies = drivers;
|
|
199
141
|
return { seeds: seeded };
|
|
200
142
|
},
|
|
201
143
|
{ name: "sandbox:seed" },
|
|
202
144
|
);
|
|
203
|
-
// Replayed on an instance that runs on fakes: they are the instance's, still there in this process.
|
|
204
|
-
if (!copies && state.instanceFakes) copies = state.drivers;
|
|
205
145
|
if (!copies) {
|
|
206
146
|
// Replayed from its checkpoint (after a worker restart, or in a fork), so the run has no fakes.
|
|
207
147
|
// The records written before the restart hold the seqs from here on: the failure goes after them.
|
package/src/scenario.ts
CHANGED
|
@@ -136,19 +136,7 @@ export interface Scenario {
|
|
|
136
136
|
file: string;
|
|
137
137
|
/** Its line in the file: its keyword's, or for an outline's row, the row's. */
|
|
138
138
|
line: number;
|
|
139
|
-
|
|
140
|
-
* The workflow its `When` runs. Absent for a scenario with no `When`, a *world*: its `Given`
|
|
141
|
-
* steps alone, which a demo applies to the instance (`applyGivens`) and nothing runs or checks.
|
|
142
|
-
*/
|
|
143
|
-
workflow?: string;
|
|
144
|
-
/** Its tags, as written (`@slow`), the feature's and its own. */
|
|
145
|
-
tags: string[];
|
|
146
|
-
/**
|
|
147
|
-
* When it is tagged `@moment(<person>,<path>)` (no space: a Gherkin tag has none): a moment,
|
|
148
|
-
* which a demo starts as a sandbox run by `person`, a declared person, and leaves waiting, for
|
|
149
|
-
* a visitor signed in as them to land on at `path`.
|
|
150
|
-
*/
|
|
151
|
-
moment?: Moment;
|
|
139
|
+
workflow: string;
|
|
152
140
|
/**
|
|
153
141
|
* The scenario's own lines of its feature file, for display: from its keyword line to the end
|
|
154
142
|
* of its last step (a step's doc string or table included, and an outline's examples), less
|
|
@@ -171,12 +159,6 @@ export interface Scenario {
|
|
|
171
159
|
expect: Expectation[];
|
|
172
160
|
}
|
|
173
161
|
|
|
174
|
-
/** A moment's visitor and where they land: `@moment(<person>,<path>)`. */
|
|
175
|
-
export interface Moment {
|
|
176
|
-
person: string;
|
|
177
|
-
path: string;
|
|
178
|
-
}
|
|
179
|
-
|
|
180
162
|
/** The result of one expectation against a run's ledger. */
|
|
181
163
|
export interface Check {
|
|
182
164
|
step: string;
|
|
@@ -399,22 +381,6 @@ function builtFor(scope: Scope): Built {
|
|
|
399
381
|
/** A `Then` step of the generic steps: its expectation, checked against the run's records. */
|
|
400
382
|
const expecting = (expect: CoreExpectation): Then => ({ expect, verify: (run) => detailOf(expect, run.records) });
|
|
401
383
|
|
|
402
|
-
/**
|
|
403
|
-
* `who`, as a run's start admits them (`admit`): a declared person or agent, or `anonymous`. The
|
|
404
|
-
* people are the root's data files' (`readPeople`, read again only once a file changed): while
|
|
405
|
-
* they declare no one, any id goes.
|
|
406
|
-
*/
|
|
407
|
-
function actorNamed(scope: Scope, who: string): string {
|
|
408
|
-
const declared = readPeople(scope.root);
|
|
409
|
-
try {
|
|
410
|
-
return admit({ id: who }, declared).id;
|
|
411
|
-
} catch (err) {
|
|
412
|
-
if (errorCode(err) !== "not_admitted" || declared.problems.length) throw err;
|
|
413
|
-
const ids = [...principalIdsOf(declared.people), ...RUNTIME_AGENTS].join(", ");
|
|
414
|
-
throw new Error(`${who} is not a declared person or agent; resources/ declares ${ids}`, { cause: err });
|
|
415
|
-
}
|
|
416
|
-
}
|
|
417
|
-
|
|
418
384
|
/** The rules a scope has, and the parameter types their expressions use. */
|
|
419
385
|
function rulesOf(scope: Scope): { rules: Rule[]; parameterTypes: ParameterTypeSpec[] } {
|
|
420
386
|
const { types, collections } = scope;
|
|
@@ -438,10 +404,20 @@ function rulesOf(scope: Scope): { rules: Rule[]; parameterTypes: ParameterTypeSp
|
|
|
438
404
|
];
|
|
439
405
|
const opOf = (id: unknown) => scope.ops.get(id as string)!;
|
|
440
406
|
const workflowOf = (name: unknown) => scope.workflows.get(name as string)!;
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
407
|
+
// The people as the root's data files declare them when a step names one (`readPeople`, read
|
|
408
|
+
// again only once a file changed): while they declare no one, any id and any group go.
|
|
409
|
+
/** `who`, as a run's start admits them (`admit`): a declared person or agent, or `anonymous`. */
|
|
410
|
+
const actorNamed = (who: string) => {
|
|
411
|
+
const declared = readPeople(scope.root);
|
|
412
|
+
try {
|
|
413
|
+
return admit({ id: who }, declared).id;
|
|
414
|
+
} catch (err) {
|
|
415
|
+
if (errorCode(err) !== "not_admitted" || declared.problems.length) throw err;
|
|
416
|
+
const ids = [...principalIdsOf(declared.people), ...RUNTIME_AGENTS].join(", ");
|
|
417
|
+
throw new Error(`${who} is not a declared person or agent; resources/ declares ${ids}`, { cause: err });
|
|
418
|
+
}
|
|
419
|
+
};
|
|
420
|
+
/** `group`, a declared group or `agents`, while the people's files have no problems. */
|
|
445
421
|
const groupNamed = (group: string) => {
|
|
446
422
|
const { people, problems } = readPeople(scope.root);
|
|
447
423
|
if (problems.length) {
|
|
@@ -459,7 +435,7 @@ function rulesOf(scope: Scope): { rules: Rule[]; parameterTypes: ParameterTypeSp
|
|
|
459
435
|
return { resource: id as string, vendor, type, name: name as string };
|
|
460
436
|
};
|
|
461
437
|
const rules: Rule[] = [
|
|
462
|
-
{ kind: "actor", source: "{who} is acting", read: ([who]) => actorNamed(
|
|
438
|
+
{ kind: "actor", source: "{who} is acting", read: ([who]) => actorNamed(who as string) },
|
|
463
439
|
// A visitor of a public site, as a login would vouch for one; `anonymous` is a `{who}`.
|
|
464
440
|
{ kind: "actor", source: "a visitor is acting", read: () => visitorPrincipal("scenario", "visitor").id },
|
|
465
441
|
{
|
|
@@ -906,7 +882,7 @@ function namesOf(report: Report): Map<string, string> {
|
|
|
906
882
|
}
|
|
907
883
|
|
|
908
884
|
/** What one scenario's steps said, as compiling it records them. */
|
|
909
|
-
type Spec = Omit<Scenario, "approvals" | "file" | "line" | "text"
|
|
885
|
+
type Spec = Omit<Scenario, "workflow" | "approvals" | "file" | "line" | "text"> & { workflow?: string };
|
|
910
886
|
|
|
911
887
|
/** The run's records, as a `Then` step reads them. */
|
|
912
888
|
function viewOf(records: readonly LedgerRecord[]): RunView {
|
|
@@ -1202,28 +1178,6 @@ function stepError(built: Built, result: StepResult | undefined, step: PickleSte
|
|
|
1202
1178
|
}
|
|
1203
1179
|
}
|
|
1204
1180
|
|
|
1205
|
-
/**
|
|
1206
|
-
* The moment its tags make a scenario (`@moment(<person>,<path>)`), the person admitted as a run's
|
|
1207
|
-
* starter is, or undefined for none; throws, at `where`, for a malformed one or a second.
|
|
1208
|
-
*/
|
|
1209
|
-
function momentOf(scope: Scope, tags: readonly string[], where: string): Moment | undefined {
|
|
1210
|
-
const tagged = tags.filter((t) => t === "@moment" || t.startsWith("@moment("));
|
|
1211
|
-
if (tagged.length === 0) return undefined;
|
|
1212
|
-
if (tagged.length > 1) throw new Error(`${where}: ${tagged.join(" and ")}: a moment has one person and one page`);
|
|
1213
|
-
const [, person, path] = /^@moment\(([^,()]+),(\/[^()]*)\)$/.exec(tagged[0]!) ?? [];
|
|
1214
|
-
if (person === undefined || path === undefined) {
|
|
1215
|
-
throw new Error(
|
|
1216
|
-
`${where}: ${tagged[0]} needs a person and a path, as in @moment(alex,/sites/portal/requests), with no space: a tag has none`,
|
|
1217
|
-
);
|
|
1218
|
-
}
|
|
1219
|
-
try {
|
|
1220
|
-
return { person: actorNamed(scope, person), path };
|
|
1221
|
-
} catch (err) {
|
|
1222
|
-
if (isBug(err)) throw err;
|
|
1223
|
-
throw new Error(`${where}: ${tagged[0]}: ${errorMessage(err)}`, { cause: err });
|
|
1224
|
-
}
|
|
1225
|
-
}
|
|
1226
|
-
|
|
1227
1181
|
/** The `.feature` files under `dir`, relative to it, with `/` between directories, in order. */
|
|
1228
1182
|
function featureFiles(dir: string): string[] {
|
|
1229
1183
|
return readdirSync(dir, { recursive: true, encoding: "utf8" })
|
|
@@ -1253,9 +1207,8 @@ const parseErrorOf = ({ uri, line, message }: Report["parseErrors"][number]) =>
|
|
|
1253
1207
|
|
|
1254
1208
|
/**
|
|
1255
1209
|
* Compiles the feature files `files` (relative to `dir`) into scenarios: Cucumber runs each,
|
|
1256
|
-
* and its steps record what they say. A file
|
|
1257
|
-
*
|
|
1258
|
-
* first error in it (a step that fails or matches no step or two, a scenario with no `When` beside one with,
|
|
1210
|
+
* and its steps record what they say. A file with an error has none of its scenarios: the
|
|
1211
|
+
* first error in it (a step that fails or matches no step or two, a scenario with no `When`,
|
|
1259
1212
|
* a name used twice, a file Gherkin cannot parse, no scenarios at all) is its error.
|
|
1260
1213
|
*/
|
|
1261
1214
|
async function compile(scope: Scope, dir: string, files: string[]): Promise<LoadedScenarios> {
|
|
@@ -1291,8 +1244,6 @@ async function compile(scope: Scope, dir: string, files: string[]): Promise<Load
|
|
|
1291
1244
|
try {
|
|
1292
1245
|
const list = pickles.get(file) ?? [];
|
|
1293
1246
|
if (list.length === 0) throw new Error(`${file}: no scenarios; add one with "Scenario: <name>"`);
|
|
1294
|
-
// A feature none of whose scenarios has a When is of worlds; elsewhere a missing When is a mistake.
|
|
1295
|
-
const ofWorlds = list.every((p) => specs.get(p.id)!.workflow === undefined);
|
|
1296
1247
|
for (const pickle of list) {
|
|
1297
1248
|
const line = lineOf(report, pickle);
|
|
1298
1249
|
const scenario = query.findLineageBy(pickle)!.scenario!;
|
|
@@ -1301,37 +1252,16 @@ async function compile(scope: Scope, dir: string, files: string[]): Promise<Load
|
|
|
1301
1252
|
const error = stepError(built, results.get(step.id), step, whereOf(report, file, step));
|
|
1302
1253
|
if (error) throw new Error(error);
|
|
1303
1254
|
}
|
|
1304
|
-
const spec = specs.get(pickle.id)!;
|
|
1305
|
-
const { workflow } = spec;
|
|
1255
|
+
const { workflow, ...spec } = specs.get(pickle.id)!;
|
|
1306
1256
|
const name = names.get(pickle.id)!;
|
|
1307
1257
|
if (inFile.some((s) => s.name === name)) {
|
|
1308
1258
|
throw new Error(`${where}: a second scenario named "${name}"; give each its own name`);
|
|
1309
1259
|
}
|
|
1310
|
-
|
|
1311
|
-
const moment = momentOf(scope, tags, where);
|
|
1312
|
-
const common = { name, file, line, tags, text: textOf(scenario, report.texts.get(file) ?? "") };
|
|
1313
|
-
if (workflow === undefined && !ofWorlds) {
|
|
1260
|
+
if (workflow === undefined) {
|
|
1314
1261
|
throw new Error(
|
|
1315
|
-
`${where}: scenario "${name}" has no When: say which workflow runs, such as "When <workflow> runs with"
|
|
1262
|
+
`${where}: scenario "${name}" has no When: say which workflow runs, such as "When <workflow> runs with"`,
|
|
1316
1263
|
);
|
|
1317
1264
|
}
|
|
1318
|
-
if (workflow === undefined) {
|
|
1319
|
-
// A world: its Givens alone, which nothing runs or checks.
|
|
1320
|
-
const runs = moment
|
|
1321
|
-
? "is a moment, which starts a run"
|
|
1322
|
-
: spec.expect[0]
|
|
1323
|
-
? `checks a run ("${spec.expect[0].step}")`
|
|
1324
|
-
: spec.decisions[0]
|
|
1325
|
-
? `decides an approval ("${spec.decisions[0].step}")`
|
|
1326
|
-
: undefined;
|
|
1327
|
-
if (runs) {
|
|
1328
|
-
throw new Error(
|
|
1329
|
-
`${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"`,
|
|
1330
|
-
);
|
|
1331
|
-
}
|
|
1332
|
-
inFile.push({ ...spec, ...common, approvals: [] });
|
|
1333
|
-
continue;
|
|
1334
|
-
}
|
|
1335
1265
|
// A step about a declared resource reads the report of the built-in it is about.
|
|
1336
1266
|
for (const e of spec.expect) {
|
|
1337
1267
|
const reads: string = "declared" in e ? ("status" in e ? DRIFT_WORKFLOW : APPLY_WORKFLOW) : workflow;
|
|
@@ -1343,9 +1273,11 @@ async function compile(scope: Scope, dir: string, files: string[]): Promise<Load
|
|
|
1343
1273
|
}
|
|
1344
1274
|
inFile.push({
|
|
1345
1275
|
...spec,
|
|
1346
|
-
|
|
1276
|
+
name,
|
|
1277
|
+
file,
|
|
1278
|
+
line,
|
|
1347
1279
|
workflow,
|
|
1348
|
-
|
|
1280
|
+
text: textOf(scenario, report.texts.get(file) ?? ""),
|
|
1349
1281
|
approvals: approvalsOf(scope.workflows.get(workflow)!),
|
|
1350
1282
|
});
|
|
1351
1283
|
}
|
|
@@ -1436,11 +1368,10 @@ export async function loadScenarios(scope: Scope): Promise<LoadedScenarios> {
|
|
|
1436
1368
|
*/
|
|
1437
1369
|
export async function check(scope: Scope, scenario: Scenario, records: readonly LedgerRecord[]): Promise<Check[]> {
|
|
1438
1370
|
if (!scope.scenarios) throw new Error(`No scenarios directory to check scenario "${scenario.name}" from`);
|
|
1439
|
-
const { file, line, name, workflow } = scenario;
|
|
1440
|
-
if (workflow === undefined) throw new Error(`Scenario "${name}" is a world: it runs nothing to check`);
|
|
1441
1371
|
const built = builtFor(scope);
|
|
1372
|
+
const { file, line, name } = scenario;
|
|
1442
1373
|
const verdicts = new Map<string, string | undefined>();
|
|
1443
|
-
const report = await runFeatures(built.library, checkWorld(built, workflow, viewOf(records), verdicts), {
|
|
1374
|
+
const report = await runFeatures(built.library, checkWorld(built, scenario.workflow, viewOf(records), verdicts), {
|
|
1444
1375
|
cwd: fileURLToPath(scope.scenarios),
|
|
1445
1376
|
files: [file],
|
|
1446
1377
|
line,
|
package/src/scenarios.ts
CHANGED
|
@@ -6,13 +6,11 @@ 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, type SeedHalf } from "./demo.ts";
|
|
10
|
-
|
|
11
9
|
/*
|
|
12
10
|
* `@sanoma-ai/workflows/scenarios`: runs a config's scenarios as sandbox runs, with no test
|
|
13
11
|
* framework. Server-side only, like `./scenario`: it loads Cucumber and starts a worker.
|
|
14
12
|
* `sanoma check` runs every scenario through `runScenarios`; `@sanoma-ai/testing/scenarios` wraps
|
|
15
|
-
* `runScenario` in a vitest test per scenario.
|
|
13
|
+
* `runScenario` in a vitest test per scenario.
|
|
16
14
|
*/
|
|
17
15
|
|
|
18
16
|
export interface RunScenarioOptions {
|
|
@@ -32,15 +30,12 @@ export interface RunScenarioOptions {
|
|
|
32
30
|
/**
|
|
33
31
|
* Runs a scenario as a sandbox run: starts its workflow with its input, as whoever it says is
|
|
34
32
|
* acting, decides its approvals as it says, and checks the run's ledger against it (`check`:
|
|
35
|
-
* Cucumber runs its `Then` steps).
|
|
33
|
+
* Cucumber runs its `Then` steps).
|
|
36
34
|
*/
|
|
37
35
|
export async function runScenario(
|
|
38
36
|
scenario: Scenario,
|
|
39
37
|
{ client, scope, startedBy = "scenarios", timeoutMs }: RunScenarioOptions,
|
|
40
38
|
): 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
|
-
}
|
|
44
39
|
const runId = await client.start(scenario.workflow, scenario.input, {
|
|
45
40
|
startedBy: scenario.startedBy ?? startedBy,
|
|
46
41
|
sandbox: scenario.name,
|
|
@@ -69,13 +64,6 @@ export interface RunScenariosOptions {
|
|
|
69
64
|
log?: (line: string) => void;
|
|
70
65
|
}
|
|
71
66
|
|
|
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
|
-
|
|
79
67
|
/**
|
|
80
68
|
* Runs every scenario in `scenarios/` in the config's root, one after another, as sandbox runs
|
|
81
69
|
* on a worker it starts on `databaseUrl` with an in-memory ledger (the config's `ledger` is not
|
|
@@ -84,7 +72,6 @@ export const isTest = (scenario: Scenario): scenario is Scenario & { workflow: s
|
|
|
84
72
|
* could not be read, with why. A scenario whose run cannot be driven to its end (an approval
|
|
85
73
|
* with no decision, a run still going after `timeoutMs`) fails with that error at its `When`
|
|
86
74
|
* 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.
|
|
88
75
|
*/
|
|
89
76
|
export async function runScenarios(
|
|
90
77
|
config: SanomaConfig,
|
|
@@ -92,9 +79,7 @@ export async function runScenarios(
|
|
|
92
79
|
): Promise<{ passed: string[]; failed: ScenarioFailure[] }> {
|
|
93
80
|
const shared: SanomaConfig = { ...config, databaseUrl, ledger: memoryLedger() };
|
|
94
81
|
const resolved = resolveConfig(shared);
|
|
95
|
-
const { scenarios
|
|
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"}`);
|
|
82
|
+
const { scenarios, errors } = await loadScenarios(resolved);
|
|
98
83
|
const failed: ScenarioFailure[] = errors.map(({ file, message }) => ({
|
|
99
84
|
scenario: file,
|
|
100
85
|
step: "did not load",
|