@sanoma-ai/workflows 0.8.2 → 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/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 { CallContext, DriverFn, Op } from "./op.ts";
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
- ): Seeds[number] {
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)![g.fault](g.op);
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, 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.
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
- 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}`, {
106
+ throw new SanomaError("invalid_input", `Scenario "${name}" runs ${scenario.workflow}, not ${run.workflow}`, {
173
107
  scenario: name,
174
- ...(scenario.workflow !== undefined && { workflow: scenario.workflow }),
108
+ workflow: scenario.workflow,
175
109
  });
176
110
  }
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([...Array.from(fakes.values(), (f) => f.driver), ...sandboxDrivers], state.ops);
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 seeded = await applyGivens(scenario, {
192
- kind: instance ? "instance" : "run",
193
- drivers,
194
- fakes,
195
- ops: state.ops,
196
- call: (i, opId) => ({ idempotencyKey: `${run.id}:seed:${i}`, runId: run.id, opId, attempt: 1, secrets }),
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)![given.fault](given.op);
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
- * `group`, a declared group or `agents`, while the people's files have no problems (`readPeople`,
443
- * read again only once a file changed): while they declare no one, any group goes.
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(scope, who as string) },
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" | "tags" | "moment">;
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 none of whose scenarios has a `When` is of worlds.
1257
- * A file with an error has none of its scenarios: the
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
- const tags = pickle.tags.map((t) => t.name);
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" (a feature with no When in any scenario is of worlds)`,
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
- ...common,
1276
+ name,
1277
+ file,
1278
+ line,
1347
1279
  workflow,
1348
- ...(moment && { moment }),
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. A demo seeds its instance with `reseed`.
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). Refuses a world, which runs nothing.
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: 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"}`);
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
- /** A site's pages, by path, from its files: a layout's and its index's (one path) merged. */
229
- function pagesOf(read: readonly { route?: SitePage }[]): Map<string, SitePage> {
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) pages.set(route.path, { ...pages.get(route.path), ...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`). A file under `routes/` is
322
- * read whole; any other only when it imports `api` or `RunForm`.
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?: SitePage; warning?: LintProblem & { file: string } } {
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: { path: page } };
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: { path: page, content: [...kinds][0]! } };
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: { path: page } };
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: { path: page }, warning: { file, ...at(unfollowed.start), message } };
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 && !/^\(.*\)$/.test(s) && !s.startsWith("_"))
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);