@sanoma-ai/workflows 0.8.2 → 0.9.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 +6 -19
- package/dist/apply.js +8 -8
- package/dist/apply.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 +7 -46
- package/dist/config.js +13 -39
- package/dist/config.js.map +1 -1
- package/dist/cucumber.d.ts +4 -2
- package/dist/cucumber.js +8 -3
- package/dist/cucumber.js.map +1 -1
- package/dist/db.d.ts +0 -1
- package/dist/db.js +2 -12
- package/dist/db.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/index.d.ts +1 -1
- package/dist/index.js.map +1 -1
- package/dist/resource.d.ts +17 -0
- package/dist/resource.js +38 -0
- package/dist/resource.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 +36 -66
- package/dist/sandbox.js.map +1 -1
- package/dist/scenario.d.ts +8 -18
- package/dist/scenario.js +60 -80
- 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/source.d.ts +6 -0
- package/dist/source.js +13 -0
- package/dist/source.js.map +1 -1
- package/dist/steps.d.ts +3 -1
- package/dist/steps.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 +2 -2
- package/src/apply.ts +16 -8
- package/src/client.ts +2 -28
- package/src/config.ts +14 -91
- package/src/cucumber.ts +16 -3
- package/src/db.ts +2 -13
- package/src/fake.ts +1 -13
- package/src/index.ts +0 -1
- package/src/resource.ts +43 -0
- package/src/run.ts +0 -2
- package/src/sandbox.ts +38 -94
- package/src/scenario.ts +73 -100
- package/src/scenarios.ts +3 -18
- package/src/sites.ts +67 -11
- package/src/source.ts +14 -0
- package/src/steps.ts +3 -1
- 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/scenario.ts
CHANGED
|
@@ -34,7 +34,7 @@ import type { LedgerRecord } from "./ledger.ts";
|
|
|
34
34
|
import type { Driver, Op } from "./op.ts";
|
|
35
35
|
import { type OutlineNode, outlineWorkflow } from "./outline.ts";
|
|
36
36
|
import { isDirectory, kindDirs, SCENARIOS_DIR } from "./paths.ts";
|
|
37
|
-
import { isObject } from "./resource.ts";
|
|
37
|
+
import { AFTER_CREATE_MS, callAfterCreate, isObject } from "./resource.ts";
|
|
38
38
|
import { admit, type DeclaredPeople, readPeople } from "./resources.ts";
|
|
39
39
|
import { memorySecrets } from "./secret.ts";
|
|
40
40
|
import {
|
|
@@ -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 {
|
|
@@ -927,11 +903,19 @@ function viewOf(records: readonly LedgerRecord[]): RunView {
|
|
|
927
903
|
return { records, calls, seeded, made: (op) => [...seeded(op), ...calls(op)] };
|
|
928
904
|
}
|
|
929
905
|
|
|
906
|
+
/** How long a step of an operation's own test may take live: a `Given` may seed an object as apply makes it, its `afterCreate` polled. */
|
|
907
|
+
export const FEATURE_STEP_MS = AFTER_CREATE_MS + 60_000;
|
|
908
|
+
|
|
930
909
|
/** Where an operation's own test makes its calls: an operation's driver, live, replayed or a fake's, and the fake. */
|
|
931
910
|
export interface FeatureTarget {
|
|
932
911
|
driver: Driver;
|
|
933
912
|
/** The fake whose driver it is: a scenario's faults need one. */
|
|
934
913
|
fake?: Fake<any, any>;
|
|
914
|
+
/**
|
|
915
|
+
* The driver calls the vendor: a seed's `afterCreate` waits between its calls as apply's do,
|
|
916
|
+
* and a step may take `FEATURE_STEP_MS`. Replayed or on a fake, the calls answer at once.
|
|
917
|
+
*/
|
|
918
|
+
live?: boolean;
|
|
935
919
|
}
|
|
936
920
|
|
|
937
921
|
/** Why no step, or two, match a step's text. */
|
|
@@ -1054,12 +1038,13 @@ function checkWorld(built: Built, workflow: string, view: RunView, verdicts: Map
|
|
|
1054
1038
|
* A World for an operation's own test: `Given` steps seed through the target, `When` steps call
|
|
1055
1039
|
* its operations directly (no worker, no policy), and `Then` steps fail when they do not hold.
|
|
1056
1040
|
*/
|
|
1057
|
-
function testWorld(built: Built, { driver, fake }: FeatureTarget) {
|
|
1041
|
+
function testWorld(built: Built, { driver, fake, live }: FeatureTarget) {
|
|
1058
1042
|
return (report: Report): SanomaWorld => {
|
|
1059
1043
|
// What the steps named (`as`), and the calls made, as a run's ledger would hold them.
|
|
1060
1044
|
const values = new Map<string, unknown>();
|
|
1061
1045
|
const records: LedgerRecord[] = [];
|
|
1062
1046
|
let runId = "";
|
|
1047
|
+
let calls = 0;
|
|
1063
1048
|
// The test's own secrets, in memory: a driver resolves `secret()` markers against them.
|
|
1064
1049
|
const secrets = memorySecrets();
|
|
1065
1050
|
/** A record of the test, as a run's ledger would hold it. */
|
|
@@ -1075,7 +1060,8 @@ function testWorld(built: Built, { driver, fake }: FeatureTarget) {
|
|
|
1075
1060
|
const input = op.input.parse(resolve(given, values));
|
|
1076
1061
|
const fn = driver.ops[`${op.resource}.${op.name}`];
|
|
1077
1062
|
if (!fn) throw new Error(`the driver under test does not implement ${id}`);
|
|
1078
|
-
|
|
1063
|
+
// A key of its own per call: a fake answers a repeated key with its remembered reply.
|
|
1064
|
+
const context = { idempotencyKey: `${runId}:${++calls}`, runId, opId: id, attempt: 1, secrets };
|
|
1079
1065
|
return { op, input, output: () => fn(input, context) };
|
|
1080
1066
|
};
|
|
1081
1067
|
return {
|
|
@@ -1100,6 +1086,37 @@ function testWorld(built: Built, { driver, fake }: FeatureTarget) {
|
|
|
1100
1086
|
const output = await made.output();
|
|
1101
1087
|
if (item.as !== undefined) values.set(item.as, output);
|
|
1102
1088
|
seeds.push({ op: item.op, input: made.input, output });
|
|
1089
|
+
// A resource type's create seeds the object as apply makes it: the type's `afterCreate`
|
|
1090
|
+
// calls follow, each polled as apply polls it, the seed's name (else the type's) its export.
|
|
1091
|
+
const type =
|
|
1092
|
+
made.op.name === "create" ? built.scope.types.get(`${made.op.vendor}.${made.op.resource}`) : undefined;
|
|
1093
|
+
const id = (output as { id?: unknown } | undefined)?.id;
|
|
1094
|
+
if (type?.afterCreate?.length && typeof id !== "string") {
|
|
1095
|
+
throw new Error(`"${text}": ${item.op} returned no id for its afterCreate`);
|
|
1096
|
+
}
|
|
1097
|
+
for (const hook of type?.afterCreate ?? []) {
|
|
1098
|
+
// One the target cannot call fails at once: polled, its error would read as not passing yet.
|
|
1099
|
+
const polled = built.scope.ops.get(hook.op);
|
|
1100
|
+
if (!polled || !driver.ops[`${polled.resource}.${polled.name}`]) {
|
|
1101
|
+
throw new Error(
|
|
1102
|
+
`"${text}": the driver under test does not implement ${hook.op}, an afterCreate of ${item.op}`,
|
|
1103
|
+
);
|
|
1104
|
+
}
|
|
1105
|
+
const stuck = await callAfterCreate(
|
|
1106
|
+
hook,
|
|
1107
|
+
{ id: id as string, export: item.as ?? made.op.resource },
|
|
1108
|
+
async (op, input) => {
|
|
1109
|
+
const after = call(op, input);
|
|
1110
|
+
const out = await after.output();
|
|
1111
|
+
seeds.push({ op, input: after.input, output: out });
|
|
1112
|
+
return out;
|
|
1113
|
+
},
|
|
1114
|
+
async (seconds) => {
|
|
1115
|
+
if (live) await delay(seconds * 1000);
|
|
1116
|
+
},
|
|
1117
|
+
);
|
|
1118
|
+
if (stuck !== undefined) throw new Error(`"${text}": ${stuck}`);
|
|
1119
|
+
}
|
|
1103
1120
|
}
|
|
1104
1121
|
if (seeds.length) record({ type: "scenario.seeded", scenario: "", seeds });
|
|
1105
1122
|
return;
|
|
@@ -1202,28 +1219,6 @@ function stepError(built: Built, result: StepResult | undefined, step: PickleSte
|
|
|
1202
1219
|
}
|
|
1203
1220
|
}
|
|
1204
1221
|
|
|
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
1222
|
/** The `.feature` files under `dir`, relative to it, with `/` between directories, in order. */
|
|
1228
1223
|
function featureFiles(dir: string): string[] {
|
|
1229
1224
|
return readdirSync(dir, { recursive: true, encoding: "utf8" })
|
|
@@ -1253,9 +1248,8 @@ const parseErrorOf = ({ uri, line, message }: Report["parseErrors"][number]) =>
|
|
|
1253
1248
|
|
|
1254
1249
|
/**
|
|
1255
1250
|
* 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,
|
|
1251
|
+
* and its steps record what they say. A file with an error has none of its scenarios: the
|
|
1252
|
+
* first error in it (a step that fails or matches no step or two, a scenario with no `When`,
|
|
1259
1253
|
* a name used twice, a file Gherkin cannot parse, no scenarios at all) is its error.
|
|
1260
1254
|
*/
|
|
1261
1255
|
async function compile(scope: Scope, dir: string, files: string[]): Promise<LoadedScenarios> {
|
|
@@ -1291,8 +1285,6 @@ async function compile(scope: Scope, dir: string, files: string[]): Promise<Load
|
|
|
1291
1285
|
try {
|
|
1292
1286
|
const list = pickles.get(file) ?? [];
|
|
1293
1287
|
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
1288
|
for (const pickle of list) {
|
|
1297
1289
|
const line = lineOf(report, pickle);
|
|
1298
1290
|
const scenario = query.findLineageBy(pickle)!.scenario!;
|
|
@@ -1301,37 +1293,16 @@ async function compile(scope: Scope, dir: string, files: string[]): Promise<Load
|
|
|
1301
1293
|
const error = stepError(built, results.get(step.id), step, whereOf(report, file, step));
|
|
1302
1294
|
if (error) throw new Error(error);
|
|
1303
1295
|
}
|
|
1304
|
-
const spec = specs.get(pickle.id)!;
|
|
1305
|
-
const { workflow } = spec;
|
|
1296
|
+
const { workflow, ...spec } = specs.get(pickle.id)!;
|
|
1306
1297
|
const name = names.get(pickle.id)!;
|
|
1307
1298
|
if (inFile.some((s) => s.name === name)) {
|
|
1308
1299
|
throw new Error(`${where}: a second scenario named "${name}"; give each its own name`);
|
|
1309
1300
|
}
|
|
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) {
|
|
1301
|
+
if (workflow === undefined) {
|
|
1314
1302
|
throw new Error(
|
|
1315
|
-
`${where}: scenario "${name}" has no When: say which workflow runs, such as "When <workflow> runs with"
|
|
1303
|
+
`${where}: scenario "${name}" has no When: say which workflow runs, such as "When <workflow> runs with"`,
|
|
1316
1304
|
);
|
|
1317
1305
|
}
|
|
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
1306
|
// A step about a declared resource reads the report of the built-in it is about.
|
|
1336
1307
|
for (const e of spec.expect) {
|
|
1337
1308
|
const reads: string = "declared" in e ? ("status" in e ? DRIFT_WORKFLOW : APPLY_WORKFLOW) : workflow;
|
|
@@ -1343,9 +1314,11 @@ async function compile(scope: Scope, dir: string, files: string[]): Promise<Load
|
|
|
1343
1314
|
}
|
|
1344
1315
|
inFile.push({
|
|
1345
1316
|
...spec,
|
|
1346
|
-
|
|
1317
|
+
name,
|
|
1318
|
+
file,
|
|
1319
|
+
line,
|
|
1347
1320
|
workflow,
|
|
1348
|
-
|
|
1321
|
+
text: textOf(scenario, report.texts.get(file) ?? ""),
|
|
1349
1322
|
approvals: approvalsOf(scope.workflows.get(workflow)!),
|
|
1350
1323
|
});
|
|
1351
1324
|
}
|
|
@@ -1436,11 +1409,10 @@ export async function loadScenarios(scope: Scope): Promise<LoadedScenarios> {
|
|
|
1436
1409
|
*/
|
|
1437
1410
|
export async function check(scope: Scope, scenario: Scenario, records: readonly LedgerRecord[]): Promise<Check[]> {
|
|
1438
1411
|
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
1412
|
const built = builtFor(scope);
|
|
1413
|
+
const { file, line, name } = scenario;
|
|
1442
1414
|
const verdicts = new Map<string, string | undefined>();
|
|
1443
|
-
const report = await runFeatures(built.library, checkWorld(built, workflow, viewOf(records), verdicts), {
|
|
1415
|
+
const report = await runFeatures(built.library, checkWorld(built, scenario.workflow, viewOf(records), verdicts), {
|
|
1444
1416
|
cwd: fileURLToPath(scope.scenarios),
|
|
1445
1417
|
files: [file],
|
|
1446
1418
|
line,
|
|
@@ -1511,6 +1483,7 @@ export async function testFeature(scope: Scope, file: string, line: number, targ
|
|
|
1511
1483
|
files: [name],
|
|
1512
1484
|
line,
|
|
1513
1485
|
isBug,
|
|
1486
|
+
timeoutMs: target.live ? FEATURE_STEP_MS : undefined,
|
|
1514
1487
|
});
|
|
1515
1488
|
if (report.parseErrors.length) throw new Error(report.parseErrors.map(parseErrorOf).join("\n"));
|
|
1516
1489
|
const results = stepResults(report);
|
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",
|
package/src/sites.ts
CHANGED
|
@@ -63,6 +63,12 @@ export interface SitePage {
|
|
|
63
63
|
* destructured) with `content` from `sanoma/site`.
|
|
64
64
|
*/
|
|
65
65
|
content?: string;
|
|
66
|
+
/**
|
|
67
|
+
* `false` when its route says `staticData: { sitemap: false }`, written out: the sitemap leaves it
|
|
68
|
+
* out. Its index route's `sitemap` (`true` or `false`) decides over a layout's at its path; a
|
|
69
|
+
* pathless or group layout's says nothing.
|
|
70
|
+
*/
|
|
71
|
+
sitemap?: false;
|
|
66
72
|
}
|
|
67
73
|
|
|
68
74
|
/** A site, `sites/<name>/` or an app's `apps/<app>/sites/<name>/`, and what it calls. */
|
|
@@ -225,12 +231,27 @@ export function readSites(
|
|
|
225
231
|
});
|
|
226
232
|
}
|
|
227
233
|
|
|
228
|
-
/**
|
|
229
|
-
|
|
234
|
+
/**
|
|
235
|
+
* What a route made by `createFileRoute(id)({ … })` says of the sitemap, `staticData`'s `sitemap`
|
|
236
|
+
* written out (a lazy route's options carry no `staticData`), and how much it is its page's own:
|
|
237
|
+
* an index (2) over a route at its path (1). A pathless or group layout's says nothing.
|
|
238
|
+
*/
|
|
239
|
+
type Says = { listed: boolean; rank: number };
|
|
240
|
+
|
|
241
|
+
/** A route file's page, with what its route says of the sitemap. */
|
|
242
|
+
type FileRoute = Omit<SitePage, "sitemap"> & { says?: Says };
|
|
243
|
+
|
|
244
|
+
/** A site's pages, by path, from its files: a layout's and its index's (one path) merged, the most its own route that says deciding `sitemap`. */
|
|
245
|
+
function pagesOf(read: readonly { route?: FileRoute }[]): Map<string, SitePage> {
|
|
230
246
|
const pages = new Map<string, SitePage>();
|
|
247
|
+
const own = new Map<string, Says>();
|
|
231
248
|
for (const { route } of read) {
|
|
232
|
-
if (route)
|
|
249
|
+
if (!route) continue;
|
|
250
|
+
const { says, ...page } = route;
|
|
251
|
+
pages.set(page.path, { ...pages.get(page.path), ...page });
|
|
252
|
+
if (says && says.rank > (own.get(page.path)?.rank ?? 0)) own.set(page.path, says);
|
|
233
253
|
}
|
|
254
|
+
for (const [path, { listed }] of own) if (!listed) pages.get(path)!.sitemap = false;
|
|
234
255
|
return pages;
|
|
235
256
|
}
|
|
236
257
|
|
|
@@ -318,13 +339,14 @@ type Written = Omit<SiteCall, "target"> & { name: string };
|
|
|
318
339
|
|
|
319
340
|
/**
|
|
320
341
|
* What one file holds: its uses, in its order, and the page it is, from its route's id, with the
|
|
321
|
-
* content kind whose entry its one parameter names (`SitePage.content`)
|
|
322
|
-
*
|
|
342
|
+
* content kind whose entry its one parameter names (`SitePage.content`) and whether its route's
|
|
343
|
+
* options keep it out of the sitemap (`SitePage.sitemap`). A file under `routes/` is read whole;
|
|
344
|
+
* any other only when it imports `api` or `RunForm`.
|
|
323
345
|
*/
|
|
324
346
|
function readSiteFile(
|
|
325
347
|
{ program, at }: SourceFile,
|
|
326
348
|
file: string,
|
|
327
|
-
): { uses: Written[]; route?:
|
|
349
|
+
): { uses: Written[]; route?: FileRoute; warning?: LintProblem & { file: string } } {
|
|
328
350
|
const api = importedAs(program, SITE_SOURCES, "api");
|
|
329
351
|
const runForm = importedAs(program, SITE_SOURCES, "RunForm");
|
|
330
352
|
const content = importedAs(program, SITE_SOURCES, "content");
|
|
@@ -333,6 +355,7 @@ function readSiteFile(
|
|
|
333
355
|
/** Each `content.<kind>.get(arg)`: its kind, its argument and where it is. */
|
|
334
356
|
const gets: { kind: string; arg: Node; start: number }[] = [];
|
|
335
357
|
let page: string | undefined;
|
|
358
|
+
let says: Says | undefined;
|
|
336
359
|
const visit = (node: Node) => {
|
|
337
360
|
if (!node) return;
|
|
338
361
|
if (node.type === "MemberExpression") {
|
|
@@ -341,6 +364,18 @@ function readSiteFile(
|
|
|
341
364
|
if (named) return void found.push({ ...named, via: "api", file, ...at(node.start) });
|
|
342
365
|
} else if (node.type === "CallExpression") {
|
|
343
366
|
const callee = unwrap(node.callee);
|
|
367
|
+
// `createFileRoute(id)({ … })`: its page, and what its options say of the sitemap.
|
|
368
|
+
const maker = callee?.type === "CallExpression" ? unwrap(callee.callee) : undefined;
|
|
369
|
+
const madeId =
|
|
370
|
+
maker?.type === "Identifier" && ROUTE_MAKERS.has(maker.name) && page === undefined
|
|
371
|
+
? stringValue(callee.arguments[0])
|
|
372
|
+
: undefined;
|
|
373
|
+
if (madeId !== undefined) {
|
|
374
|
+
page = pageOf(madeId);
|
|
375
|
+
const listed = maker.name === "createFileRoute" ? sitemapOf(node.arguments[0]) : undefined;
|
|
376
|
+
const rank = madeId.endsWith("/") ? 2 : isPathless(madeId.split("/").at(-1)!) ? 0 : 1;
|
|
377
|
+
if (listed !== undefined && rank) says = { listed, rank };
|
|
378
|
+
}
|
|
344
379
|
if (callee?.type === "Identifier" && ROUTE_MAKERS.has(callee.name) && page === undefined) {
|
|
345
380
|
const id = stringValue(node.arguments[0]);
|
|
346
381
|
if (id !== undefined) page = pageOf(id);
|
|
@@ -360,9 +395,10 @@ function readSiteFile(
|
|
|
360
395
|
visit(program);
|
|
361
396
|
const uses = found.map((use) => (page === undefined ? use : { ...use, page }));
|
|
362
397
|
if (page === undefined) return { uses };
|
|
398
|
+
const route: FileRoute = { path: page, ...(says && { says }) };
|
|
363
399
|
// The one parameter's name (not a splat's), and the kinds whose `get` is given it (`params.slug`, or `slug` destructured).
|
|
364
400
|
const params = page.split("/").filter((segment) => segment.startsWith("$"));
|
|
365
|
-
if (params.length !== 1 || params[0] === "$") return { uses, route
|
|
401
|
+
if (params.length !== 1 || params[0] === "$") return { uses, route };
|
|
366
402
|
const param = params[0]!.slice(1);
|
|
367
403
|
const kinds = new Set(
|
|
368
404
|
gets
|
|
@@ -373,16 +409,33 @@ function readSiteFile(
|
|
|
373
409
|
)
|
|
374
410
|
.map(({ kind }) => kind),
|
|
375
411
|
);
|
|
376
|
-
if (kinds.size === 1) return { uses, route: {
|
|
412
|
+
if (kinds.size === 1) return { uses, route: { ...route, content: [...kinds][0]! } };
|
|
377
413
|
// A read by the parameter in a form the sitemap does not follow (`params.id.toLowerCase()`, two kinds).
|
|
378
414
|
const unfollowed = gets.find(({ arg }) => mentions(arg, param));
|
|
379
|
-
if (!unfollowed) return { uses, route
|
|
415
|
+
if (!unfollowed) return { uses, route };
|
|
380
416
|
const message =
|
|
381
417
|
`the sitemap lists none of this page's entries: it reads content by the page's parameter in a way it cannot follow. ` +
|
|
382
418
|
`Read one kind by it as written, \`content.<kind>.get(params.${param})\` (or \`${param}\` destructured)`;
|
|
383
|
-
return { uses, route
|
|
419
|
+
return { uses, route, warning: { file, ...at(unfollowed.start), message } };
|
|
384
420
|
}
|
|
385
421
|
|
|
422
|
+
/** A route's `options`' `staticData: { sitemap }`, each written out, `sitemap` a boolean; undefined for any other. */
|
|
423
|
+
const sitemapOf = (options: Node): boolean | undefined => {
|
|
424
|
+
const flag = unwrap(valueOf(valueOf(options, "staticData"), "sitemap"));
|
|
425
|
+
return flag?.type === "Literal" && typeof flag.value === "boolean" ? flag.value : undefined;
|
|
426
|
+
};
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* The value of an object literal's property `name`, its last, written out; undefined for anything
|
|
430
|
+
* else, or when a spread or a computed key after it could replace it.
|
|
431
|
+
*/
|
|
432
|
+
const valueOf = (object: Node, name: string): Node => {
|
|
433
|
+
const o = unwrap(object);
|
|
434
|
+
const last =
|
|
435
|
+
o?.type === "ObjectExpression" && o.properties.findLast((p: Node) => !isWritten(p) || keyName(p.key) === name);
|
|
436
|
+
return last && isWritten(last) ? last.value : undefined;
|
|
437
|
+
};
|
|
438
|
+
|
|
386
439
|
/** Whether `node` reads the parameter `param` anywhere in it: `params.<param>`, or `param` destructured (not another parameter, nor a property's name, `post.slug` or `{ slug: x }`). */
|
|
387
440
|
const mentions = (node: Node, param: string): boolean => {
|
|
388
441
|
if (!node) return false;
|
|
@@ -555,9 +608,12 @@ function siteOptionsOf(
|
|
|
555
608
|
const pageOf = (id: string): string =>
|
|
556
609
|
`/${id
|
|
557
610
|
.split("/")
|
|
558
|
-
.filter((s) => s &&
|
|
611
|
+
.filter((s) => s && !isPathless(s))
|
|
559
612
|
.map((s) => s.replace(/_$/, ""))
|
|
560
613
|
.join("/")}`;
|
|
561
614
|
|
|
615
|
+
/** A route id's segment the router's path leaves out: a `(group)` or a `_pathless` one. */
|
|
616
|
+
const isPathless = (segment: string): boolean => /^\(.*\)$/.test(segment) || segment.startsWith("_");
|
|
617
|
+
|
|
562
618
|
/** A site's source file: `.ts` or `.tsx`, but a test or a declaration. */
|
|
563
619
|
const isSource = (name: string) => /\.tsx?$/.test(name) && !/\.(test|d)\.tsx?$/.test(name);
|
package/src/source.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
2
3
|
import { promisify } from "node:util";
|
|
3
4
|
import { z } from "zod";
|
|
4
5
|
import type { PermissionsGrid } from "./grid.ts";
|
|
@@ -155,6 +156,19 @@ export const sourceDriver = defineDriver(sourceOps, {
|
|
|
155
156
|
|
|
156
157
|
const COMMIT = /^[0-9a-f]{40}([0-9a-f]{24})?$/;
|
|
157
158
|
|
|
159
|
+
/**
|
|
160
|
+
* The fake of `sanoma.source.resolve`, which a sandbox run calls (`sandboxDrivers`): no git and no
|
|
161
|
+
* network, a full commit id is itself as the driver has it, and any other ref the SHA-1 of
|
|
162
|
+
* `<url>#<ref>`, so a scenario's revisions are the same on every run.
|
|
163
|
+
*/
|
|
164
|
+
export const fakeSourceDriver = defineDriver(sourceOps, {
|
|
165
|
+
source: {
|
|
166
|
+
resolve: async ({ url, ref }) => ({
|
|
167
|
+
revision: COMMIT.test(ref) ? ref : createHash("sha1").update(`${url}#${ref}`).digest("hex"),
|
|
168
|
+
}),
|
|
169
|
+
},
|
|
170
|
+
});
|
|
171
|
+
|
|
158
172
|
/** The commit `ref` (a branch, a tag, `HEAD`, a full ref or a commit id) names in the repository at `url`. */
|
|
159
173
|
export async function resolveRevision(url: string, ref: string): Promise<string> {
|
|
160
174
|
if (COMMIT.test(ref)) return ref;
|
package/src/steps.ts
CHANGED
|
@@ -14,7 +14,9 @@ type AnyOp<I = any, O = any> = Op<string, string, string, I, O>;
|
|
|
14
14
|
* What a `Given` step sets up before the run: a call through an operation of the run's fake,
|
|
15
15
|
* with the fields of its input to set (the rest is made up), or a fault to inject into its next
|
|
16
16
|
* call. `as` names a call's output, so a later step's table of an operation's own test can use
|
|
17
|
-
* its fields: `$draft.id`.
|
|
17
|
+
* its fields: `$draft.id`. In an operation's own test, a resource type's `create` seeds the object
|
|
18
|
+
* as apply makes it: the type's `afterCreate` calls follow, each polled as apply polls it, the
|
|
19
|
+
* seed's name (else the type's) as the export.
|
|
18
20
|
*/
|
|
19
21
|
export type Seed =
|
|
20
22
|
| { op: AnyOp; input: Record<string, unknown>; as?: string }
|
package/src/worker.ts
CHANGED
|
@@ -93,12 +93,6 @@ export interface WorkerOptions {
|
|
|
93
93
|
* it off, on a database of its own, so no schedule is applied there to start a live run.
|
|
94
94
|
*/
|
|
95
95
|
schedules?: boolean;
|
|
96
|
-
/**
|
|
97
|
-
* The config as the caller resolved it (`resolveConfig(config, options)`), for a worker that
|
|
98
|
-
* shares its drivers with the caller: the instance fakes of `fakes: "instance"`, which a demo's
|
|
99
|
-
* client and reseed call too. The caller closes its `dataStore`, as it made it.
|
|
100
|
-
*/
|
|
101
|
-
resolved?: ResolvedConfig;
|
|
102
96
|
}
|
|
103
97
|
|
|
104
98
|
/**
|
|
@@ -110,7 +104,7 @@ export interface WorkerOptions {
|
|
|
110
104
|
*/
|
|
111
105
|
export async function startWorker(config: SanomaConfig, options: WorkerOptions = {}): Promise<Worker> {
|
|
112
106
|
// Check everything before touching the state a running worker reads.
|
|
113
|
-
const resolved =
|
|
107
|
+
const resolved = resolveConfig(config);
|
|
114
108
|
setLogLevel(options.logLevel);
|
|
115
109
|
// Read once, when it matters: a worker stops itself, registering its next deadline there, only
|
|
116
110
|
// when its caller hears it stop (`onStopped`).
|
|
@@ -132,7 +126,6 @@ export async function startWorker(config: SanomaConfig, options: WorkerOptions =
|
|
|
132
126
|
ops: resolved.ops,
|
|
133
127
|
drivers: resolved.drivers,
|
|
134
128
|
fakes: resolved.fakes,
|
|
135
|
-
...(resolved.instanceFakes && { instanceFakes: resolved.instanceFakes }),
|
|
136
129
|
...(resolved.scenarios && { scenarios: resolved.scenarios }),
|
|
137
130
|
...(options.source && { source: options.source }),
|
|
138
131
|
types: resolved.types,
|
|
@@ -174,7 +167,7 @@ export async function startWorker(config: SanomaConfig, options: WorkerOptions =
|
|
|
174
167
|
() => credentials.close(),
|
|
175
168
|
() => secrets.close(),
|
|
176
169
|
() => ledger.close(),
|
|
177
|
-
async () =>
|
|
170
|
+
async () => resolved.dataStore?.close(),
|
|
178
171
|
]) {
|
|
179
172
|
await close().catch((error: unknown) => (failed ??= { error }));
|
|
180
173
|
}
|
package/dist/demo.d.ts
DELETED
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
import type { SanomaClient } from "./client.ts";
|
|
2
|
-
import type { ResolvedConfig } from "./config.ts";
|
|
3
|
-
import { type Moment } from "./scenario.ts";
|
|
4
|
-
/** What the last `reseed` of an instance did: what the demo's banner shows. */
|
|
5
|
-
export interface DemoState {
|
|
6
|
-
/** When it was seeded, as an ISO timestamp. */
|
|
7
|
-
seededAt: string;
|
|
8
|
-
/** The worlds applied, by scenario name. */
|
|
9
|
-
worlds: string[];
|
|
10
|
-
/** Each moment started and left waiting: who it is for, where they land, its scenario and its run. */
|
|
11
|
-
moments: (Moment & {
|
|
12
|
-
scenario: string;
|
|
13
|
-
runId: string;
|
|
14
|
-
})[];
|
|
15
|
-
/** What could not be done, one line each: a feature file that did not load, a world or a moment that failed. */
|
|
16
|
-
problems: string[];
|
|
17
|
-
}
|
|
18
|
-
/**
|
|
19
|
-
* A half of a demo's seed (`reseed`'s `only`): what goes into its `database` (the runs cancelled,
|
|
20
|
-
* `sanoma_data` emptied, the worlds' collection records, the moments' runs), or into its `memory`
|
|
21
|
-
* (the instance's fakes reset, the worlds' vendor objects and calls). A copy of a seeded database
|
|
22
|
-
* holds the first half but not the second, which a process over it applies to its own fakes.
|
|
23
|
-
*/
|
|
24
|
-
export type SeedHalf = "database" | "memory";
|
|
25
|
-
/**
|
|
26
|
-
* Seeds the demo instance afresh: cancels the app's unfinished runs (`cancelUnfinished`: the last
|
|
27
|
-
* seed's moments among them), empties the collections' table, resets the instance's fakes,
|
|
28
|
-
* applies every world (a scenario with no `When`) to the instance (`applyGivens`: its records
|
|
29
|
-
* saved through the collections' driver, its vendor objects put in the fakes), then starts each
|
|
30
|
-
* moment as a sandbox run by its person, left undriven, so its first approval waits for them.
|
|
31
|
-
* `only` seeds one half (`SeedHalf`). `resolved` is the resolution the worker and `client` run
|
|
32
|
-
* on, made with `fakes: "instance"`. A world or a moment that fails is a problem in the state it
|
|
33
|
-
* returns, and the rest go on.
|
|
34
|
-
*/
|
|
35
|
-
export declare function reseed(client: SanomaClient, resolved: ResolvedConfig, { only }?: {
|
|
36
|
-
only?: SeedHalf;
|
|
37
|
-
}): Promise<DemoState>;
|
|
38
|
-
/** What the instance's last `reseed` did, or undefined before its first. */
|
|
39
|
-
export declare const demoState: (resolved: ResolvedConfig) => DemoState | undefined;
|