@sanoma-ai/workflows 0.2.0 → 0.4.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.
Files changed (149) hide show
  1. package/README.md +146 -85
  2. package/dist/approvals.d.ts +11 -6
  3. package/dist/approvals.js +38 -10
  4. package/dist/approvals.js.map +1 -1
  5. package/dist/call-workflow.d.ts +16 -0
  6. package/dist/call-workflow.js +35 -0
  7. package/dist/call-workflow.js.map +1 -0
  8. package/dist/call.d.ts +33 -4
  9. package/dist/call.js +107 -42
  10. package/dist/call.js.map +1 -1
  11. package/dist/cedar.d.ts +87 -0
  12. package/dist/cedar.js +365 -0
  13. package/dist/cedar.js.map +1 -0
  14. package/dist/client.d.ts +85 -7
  15. package/dist/client.js +289 -25
  16. package/dist/client.js.map +1 -1
  17. package/dist/collection.d.ts +107 -0
  18. package/dist/collection.js +306 -0
  19. package/dist/collection.js.map +1 -0
  20. package/dist/config.d.ts +42 -14
  21. package/dist/config.js +82 -18
  22. package/dist/config.js.map +1 -1
  23. package/dist/constructors.d.ts +2 -0
  24. package/dist/constructors.js +5 -0
  25. package/dist/constructors.js.map +1 -0
  26. package/dist/data.d.ts +2 -0
  27. package/dist/data.js +3 -0
  28. package/dist/data.js.map +1 -0
  29. package/dist/datafile.d.ts +28 -2
  30. package/dist/datafile.js +118 -19
  31. package/dist/datafile.js.map +1 -1
  32. package/dist/db.d.ts +5 -3
  33. package/dist/db.js +88 -21
  34. package/dist/db.js.map +1 -1
  35. package/dist/define.d.ts +14 -5
  36. package/dist/define.js +1 -5
  37. package/dist/define.js.map +1 -1
  38. package/dist/describe.d.ts +45 -5
  39. package/dist/describe.js +72 -7
  40. package/dist/describe.js.map +1 -1
  41. package/dist/direct.d.ts +9 -0
  42. package/dist/direct.js +11 -0
  43. package/dist/direct.js.map +1 -0
  44. package/dist/errors.d.ts +16 -1
  45. package/dist/errors.js +30 -1
  46. package/dist/errors.js.map +1 -1
  47. package/dist/expectations.d.ts +3 -3
  48. package/dist/expectations.js +16 -7
  49. package/dist/expectations.js.map +1 -1
  50. package/dist/fill.d.ts +4 -0
  51. package/dist/fill.js +13 -0
  52. package/dist/fill.js.map +1 -1
  53. package/dist/index.d.ts +8 -4
  54. package/dist/index.js +10 -4
  55. package/dist/index.js.map +1 -1
  56. package/dist/ledger.d.ts +12 -1
  57. package/dist/ledger.js +18 -11
  58. package/dist/ledger.js.map +1 -1
  59. package/dist/lint.d.ts +5 -4
  60. package/dist/lint.js +24 -7
  61. package/dist/lint.js.map +1 -1
  62. package/dist/op.d.ts +6 -0
  63. package/dist/op.js.map +1 -1
  64. package/dist/outline.d.ts +2 -0
  65. package/dist/outline.js +1 -1
  66. package/dist/outline.js.map +1 -1
  67. package/dist/paths.d.ts +4 -0
  68. package/dist/paths.js +17 -3
  69. package/dist/paths.js.map +1 -1
  70. package/dist/person.d.ts +61 -0
  71. package/dist/person.js +69 -0
  72. package/dist/person.js.map +1 -0
  73. package/dist/plugin.d.ts +28 -0
  74. package/dist/plugin.js +52 -1
  75. package/dist/plugin.js.map +1 -1
  76. package/dist/policy.d.ts +51 -55
  77. package/dist/policy.js +14 -36
  78. package/dist/policy.js.map +1 -1
  79. package/dist/resource.d.ts +21 -5
  80. package/dist/resource.js +12 -3
  81. package/dist/resource.js.map +1 -1
  82. package/dist/resources.d.ts +58 -6
  83. package/dist/resources.js +119 -26
  84. package/dist/resources.js.map +1 -1
  85. package/dist/run.d.ts +17 -1
  86. package/dist/run.js.map +1 -1
  87. package/dist/scenario.d.ts +16 -9
  88. package/dist/scenario.js +149 -44
  89. package/dist/scenario.js.map +1 -1
  90. package/dist/scenarios.d.ts +10 -6
  91. package/dist/scenarios.js +8 -4
  92. package/dist/scenarios.js.map +1 -1
  93. package/dist/schema.d.ts +32 -0
  94. package/dist/schema.js +225 -0
  95. package/dist/schema.js.map +1 -0
  96. package/dist/sdk.d.ts +34 -0
  97. package/dist/sdk.js +168 -0
  98. package/dist/sdk.js.map +1 -0
  99. package/dist/shared.d.ts +181 -5
  100. package/dist/shared.js +160 -7
  101. package/dist/shared.js.map +1 -1
  102. package/dist/sites.d.ts +52 -0
  103. package/dist/sites.js +174 -0
  104. package/dist/sites.js.map +1 -0
  105. package/dist/version.d.ts +3 -2
  106. package/dist/version.js +3 -2
  107. package/dist/version.js.map +1 -1
  108. package/dist/worker.d.ts +3 -2
  109. package/dist/worker.js +24 -4
  110. package/dist/worker.js.map +1 -1
  111. package/oxlint.json +7 -20
  112. package/package.json +8 -3
  113. package/src/approvals.ts +49 -13
  114. package/src/call-workflow.ts +44 -0
  115. package/src/call.ts +157 -45
  116. package/src/cedar.ts +493 -0
  117. package/src/client.ts +368 -37
  118. package/src/collection.ts +431 -0
  119. package/src/config.ts +131 -34
  120. package/src/constructors.ts +4 -0
  121. package/src/data.ts +10 -0
  122. package/src/datafile.ts +141 -21
  123. package/src/db.ts +90 -22
  124. package/src/define.ts +15 -10
  125. package/src/describe.ts +113 -10
  126. package/src/direct.ts +15 -0
  127. package/src/errors.ts +36 -1
  128. package/src/expectations.ts +17 -8
  129. package/src/fill.ts +15 -0
  130. package/src/index.ts +33 -2
  131. package/src/ledger.ts +35 -17
  132. package/src/lint.ts +29 -12
  133. package/src/op.ts +6 -0
  134. package/src/outline.ts +1 -1
  135. package/src/paths.ts +18 -3
  136. package/src/person.ts +75 -0
  137. package/src/plugin.ts +74 -5
  138. package/src/policy.ts +59 -63
  139. package/src/resource.ts +35 -8
  140. package/src/resources.ts +165 -24
  141. package/src/run.ts +18 -1
  142. package/src/scenario.ts +186 -57
  143. package/src/scenarios.ts +15 -8
  144. package/src/schema.ts +261 -0
  145. package/src/sdk.ts +211 -0
  146. package/src/shared.ts +271 -8
  147. package/src/sites.ts +220 -0
  148. package/src/version.ts +3 -2
  149. package/src/worker.ts +29 -6
package/src/scenario.ts CHANGED
@@ -13,6 +13,7 @@ import {
13
13
  } from "@cucumber/messages";
14
14
  import type { z } from "zod";
15
15
  import type { RunSummary, SanomaClient } from "./client.ts";
16
+ import type { Approver } from "./define.ts";
16
17
  import type { ResolvedConfig } from "./config.ts";
17
18
  import {
18
19
  buildLibrary,
@@ -25,22 +26,30 @@ import {
25
26
  stepResults,
26
27
  StepStatus,
27
28
  } from "./cucumber.ts";
28
- import { errorInfo, SanomaError } from "./errors.ts";
29
+ import { errorCode, errorInfo, SanomaError } from "./errors.ts";
29
30
  import { type CallRecord, contains, type CoreExpectation, detailOf, isCall } from "./expectations.ts";
30
31
  import type { Fake } from "./fake.ts";
31
- import { fill, isBug, seedFrom, shapeOf } from "./fill.ts";
32
+ import { fill, isBug, objectUnder, restOf, seedFrom, shapeOf } from "./fill.ts";
32
33
  import type { LedgerRecord } from "./ledger.ts";
33
34
  import type { Driver, Op } from "./op.ts";
34
35
  import { type OutlineNode, outlineWorkflow } from "./outline.ts";
36
+ import { isObject } from "./resource.ts";
37
+ import { admit, type DeclaredPeople, readPeople } from "./resources.ts";
35
38
  import { memorySecrets } from "./secret.ts";
36
39
  import {
37
40
  APPLY_WORKFLOW,
38
41
  BUILT_IN_WORKFLOWS,
42
+ DATA_VENDOR,
43
+ declaresPeople,
39
44
  DRIFT_WORKFLOW,
40
45
  errorMessage,
41
46
  fitsOp,
42
47
  flatten,
48
+ groupNamesOf,
43
49
  isEnded,
50
+ principalIdsOf,
51
+ problemLine,
52
+ RUNTIME_AGENTS,
44
53
  } from "./shared.ts";
45
54
  import type { Call, RunView, Seed } from "./steps.ts";
46
55
 
@@ -98,9 +107,11 @@ export interface Decision {
98
107
  /**
99
108
  * What the run should do: call an operation (with at least this input) or not, run the step
100
109
  * `name`d in the workflow's outline or not, or a number of `times`, have the policy deny an
101
- * operation (`denied`) or hold one for an approver (`held`), end a certain way, for a drift
102
- * run, report the resource `declared` (`<file>#<export>`) clean, gone, absent, or drifted in a
103
- * field, or for an apply run, create it, or leave it bound to an object (`bound`, its vendor's id).
110
+ * operation (`denied`, for a `reason` when the step names one) or hold one for an approver
111
+ * (`held`: a person's id or a group's name, or `{ group }` a group's alone), end a certain way,
112
+ * for a drift run, report the resource `declared` (`<file>#<export>`) clean, gone, absent, or
113
+ * drifted in a field, or for an apply run, create it, or leave it bound to an object (`bound`,
114
+ * its vendor's id).
104
115
  * A step whose own code checks the run (a connector's or the company's, or an operation's own
105
116
  * test's) is `custom`, with what it is about.
106
117
  */
@@ -108,8 +119,8 @@ export type Expectation =
108
119
  | { step: string; op: string; input?: unknown; called: boolean }
109
120
  | { step: string; name: string; input?: unknown; called: boolean }
110
121
  | { step: string; name: string; times: number }
111
- | { step: string; op: string; decision: "denied" }
112
- | { step: string; op: string; decision: "held"; approver: string }
122
+ | { step: string; op: string; decision: "denied"; reason?: string }
123
+ | { step: string; op: string; decision: "held"; approver: Approver }
113
124
  | { step: string; outcome: "finished" | "failed"; code?: string }
114
125
  | { step: string; declared: string; status: "clean" | "gone" | "absent" }
115
126
  | { step: string; declared: string; status: "drifted"; field: string }
@@ -132,6 +143,8 @@ export interface Scenario {
132
143
  text: string;
133
144
  /** Every step in order, for a list and for marking graph nodes. */
134
145
  steps: ScenarioStep[];
146
+ /** Who starts the run, by id, when a `Given <who> is acting` says: a declared person or agent. */
147
+ startedBy?: string;
135
148
  given: Given[];
136
149
  input: unknown;
137
150
  decisions: Decision[];
@@ -152,10 +165,14 @@ export interface Check {
152
165
  }
153
166
 
154
167
  /**
155
- * What steps can name: the config's operations, workflows and resource types by id, the steps
156
- * its connectors (and the company) ship, and where the feature files are.
168
+ * What steps can name: the config's operations, workflows, resource types and collections by id,
169
+ * the steps its connectors (and the company) ship, where the feature files are, and the root
170
+ * whose data files declare its people.
157
171
  */
158
- export type Scope = Pick<ResolvedConfig, "ops" | "workflows" | "scenarios" | "types" | "steps">;
172
+ export type Scope = Pick<
173
+ ResolvedConfig,
174
+ "ops" | "workflows" | "scenarios" | "types" | "collections" | "steps" | "root"
175
+ >;
159
176
 
160
177
  type Kind = ScenarioStep["kind"];
161
178
  type Fault = Extract<Seed, { fault: unknown }>["fault"];
@@ -170,9 +187,13 @@ interface Then {
170
187
  verify(run: RunView): string | undefined;
171
188
  }
172
189
 
173
- /** What a step of each kind reads into; `any` is a decision, which a step of any keyword may make. */
190
+ /**
191
+ * What a step of each kind reads into; `any` is a decision, which a step of any keyword may make,
192
+ * and `actor` a `Given` saying who starts the run (`{who} is acting`), by id.
193
+ */
174
194
  interface Items {
175
195
  given: Unstepped<Given>[];
196
+ actor: string;
176
197
  when: When;
177
198
  then: Then;
178
199
  any: Unstepped<Decision>;
@@ -209,22 +230,50 @@ const FAULTS = {
209
230
  "loses its reply once": "loseReply",
210
231
  } as const;
211
232
 
212
- /** The schema of the field `name` of an object schema (`owner`, for the error); throws naming the fields it has. */
233
+ /**
234
+ * The schema of the field `name` of an object schema (`owner`, for the error), or of the fields it
235
+ * takes besides its own (`restOf`); throws naming the fields it has.
236
+ */
213
237
  function fieldOf(schema: z.ZodType, name: string, owner: string): z.ZodType {
214
238
  const shape = shapeOf(schema);
215
- if (!Object.hasOwn(shape, name)) {
216
- throw new Error(`no field "${name}" in ${owner}; it has ${Object.keys(shape).join(", ") || "none"}`);
217
- }
218
- return shape[name]!;
239
+ if (Object.hasOwn(shape, name)) return shape[name]!;
240
+ const rest = restOf(schema);
241
+ if (rest) return rest;
242
+ throw new Error(`no field "${name}" in ${owner}; it has ${Object.keys(shape).join(", ") || "none"}`);
219
243
  }
220
244
 
221
245
  const issues = (error: z.ZodError) => error.issues.map((i) => i.message).join("; ");
222
246
 
247
+ /**
248
+ * Why `field` refuses `value`, or undefined when it takes it. `partial`, for what a `Then` expects
249
+ * at least: an object may leave fields out at any depth, and only the fields it has are checked.
250
+ */
251
+ function refusal(field: z.ZodType, value: unknown, what: string, partial: boolean): string | undefined {
252
+ const object = partial && isObject(value) ? objectUnder(field) : undefined;
253
+ if (object) return fieldsRefusal(object, value as Record<string, unknown>, what, true);
254
+ const parsed = field.safeParse(value);
255
+ return parsed.success ? undefined : `${what} cannot be ${JSON.stringify(value)}: ${issues(parsed.error)}`;
256
+ }
257
+
258
+ /** Why the object schema refuses the first of `fields` it refuses (`refusal`), `owner` its owner in errors. */
259
+ function fieldsRefusal(
260
+ schema: z.ZodType,
261
+ fields: Record<string, unknown>,
262
+ owner: string,
263
+ partial: boolean,
264
+ ): string | undefined {
265
+ for (const [name, v] of Object.entries(fields)) {
266
+ const why = refusal(fieldOf(schema, name, owner), v, `${name} in ${owner}`, partial);
267
+ if (why) return why;
268
+ }
269
+ return undefined;
270
+ }
271
+
223
272
  /**
224
273
  * The value text from a table cell or a phrase gives a field: the text itself when the field
225
274
  * takes it, else the text read as JSON (a number, `true`, a list) when the field takes that.
226
275
  */
227
- function valueFor(field: z.ZodType, text: string, what: string): unknown {
276
+ function valueFor(field: z.ZodType, text: string, what: string, partial = false): unknown {
228
277
  const asText = field.safeParse(text);
229
278
  if (asText.success) return text;
230
279
  let json: unknown;
@@ -233,17 +282,18 @@ function valueFor(field: z.ZodType, text: string, what: string): unknown {
233
282
  } catch {
234
283
  throw new Error(`${what} cannot be ${JSON.stringify(text)}: ${issues(asText.error)}`);
235
284
  }
236
- const asJson = field.safeParse(json);
237
- if (!asJson.success) throw new Error(`${what} cannot be ${text}: ${issues(asJson.error)}`);
285
+ const why = refusal(field, json, what, partial);
286
+ if (why) throw new Error(why);
238
287
  return json;
239
288
  }
240
289
 
241
290
  /**
242
291
  * The fields the step's doc string (a JSON object) or two-column table gives `owner` (an
243
292
  * operation's input, or a resource), each checked against its field in `schema`: a field it
244
- * lacks, or a value it refuses, is an error.
293
+ * lacks, or a value it refuses, is an error. `partial` (a `Then`'s "at least these fields"):
294
+ * an object value may leave fields out, at any depth.
245
295
  */
246
- function fieldsFrom(pickle: PickleStep, schema: z.ZodType, owner: string): Record<string, unknown> {
296
+ function fieldsFrom(pickle: PickleStep, schema: z.ZodType, owner: string, partial = false): Record<string, unknown> {
247
297
  const { docString, dataTable } = pickle.argument ?? {};
248
298
  const what = (name: string) => `${name} in ${owner}`;
249
299
  if (dataTable) {
@@ -251,7 +301,7 @@ function fieldsFrom(pickle: PickleStep, schema: z.ZodType, owner: string): Recor
251
301
  dataTable.rows.map(({ cells }) => {
252
302
  if (cells.length !== 2) throw new Error(`the table after "${pickle.text}" needs two columns: name | value`);
253
303
  const [name, text] = cells.map((c) => c.value) as [string, string];
254
- return [name, valueFor(fieldOf(schema, name, owner), text, what(name))];
304
+ return [name, valueFor(fieldOf(schema, name, owner), text, what(name), partial)];
255
305
  }),
256
306
  );
257
307
  }
@@ -262,14 +312,10 @@ function fieldsFrom(pickle: PickleStep, schema: z.ZodType, owner: string): Recor
262
312
  } catch (err) {
263
313
  throw new Error(`the doc string after "${pickle.text}" is not JSON: ${errorMessage(err)}`, { cause: err });
264
314
  }
265
- if (typeof value !== "object" || value === null || Array.isArray(value)) {
266
- throw new Error(`"${pickle.text}" needs a JSON object`);
267
- }
268
- for (const [name, v] of Object.entries(value)) {
269
- const parsed = fieldOf(schema, name, owner).safeParse(v);
270
- if (!parsed.success) throw new Error(`${what(name)} cannot be ${JSON.stringify(v)}: ${issues(parsed.error)}`);
271
- }
272
- return value as Record<string, unknown>;
315
+ if (!isObject(value)) throw new Error(`"${pickle.text}" needs a JSON object`);
316
+ const why = fieldsRefusal(schema, value, owner, partial);
317
+ if (why) throw new Error(why);
318
+ return value;
273
319
  }
274
320
 
275
321
  /** A parameter's transform: the name, when `ids` has it. */
@@ -314,7 +360,9 @@ function builtFor(scope: Scope): Built {
314
360
  cached &&
315
361
  cached.scope.workflows === scope.workflows &&
316
362
  cached.scope.types === scope.types &&
317
- cached.scope.steps === scope.steps
363
+ cached.scope.collections === scope.collections &&
364
+ cached.scope.steps === scope.steps &&
365
+ cached.scope.root === scope.root
318
366
  ) {
319
367
  return cached;
320
368
  }
@@ -333,24 +381,59 @@ const expecting = (expect: CoreExpectation): Then => ({ expect, verify: (run) =>
333
381
 
334
382
  /** The rules a scope has, and the parameter types their expressions use. */
335
383
  function rulesOf(scope: Scope): { rules: Rule[]; parameterTypes: ParameterTypeSpec[] } {
336
- const { types } = scope;
384
+ const { types, collections } = scope;
385
+ // A collection's records are seeded as a resource's objects are, by `data.<name>`, through its operations.
386
+ const collectionOf = (id: unknown) =>
387
+ (id as string).startsWith(`${DATA_VENDOR}.`)
388
+ ? collections.get((id as string).slice(DATA_VENDOR.length + 1))
389
+ : undefined;
390
+ const seedable = new Map<string, unknown>([
391
+ ...types,
392
+ ...[...collections.keys()].map((n) => [`${DATA_VENDOR}.${n}`, n] as const),
393
+ ]);
337
394
  const parameterTypes: ParameterTypeSpec[] = [
338
395
  { name: "op", regexp: /\S+/, transformer: known("operation", scope.ops) },
339
396
  { name: "workflow", regexp: /\S+/, transformer: known("workflow", scope.workflows) },
340
397
  { name: "who", regexp: /\S+/, transformer: (s) => s },
341
- { name: "resource", regexp: /\S+/, transformer: known("resource type", types) },
398
+ { name: "resource", regexp: /\S+/, transformer: known("resource type", seedable) },
342
399
  // A declared resource, as a drift report names it: `<file>#<export>`.
343
400
  { name: "declared", regexp: /[^\s#]+#\S+/, transformer: (s) => s },
344
401
  { name: "field", regexp: /\S+/, transformer: (s) => s },
345
402
  ];
346
403
  const opOf = (id: unknown) => scope.ops.get(id as string)!;
347
404
  const workflowOf = (name: unknown) => scope.workflows.get(name as string)!;
405
+ // The people as the root's data files declare them when a step names one (`readPeople`, read
406
+ // again only once a file changed): while they declare no one, any id and any group go.
407
+ /** `who`, as a run's start admits them (`admit`): a declared person or agent. */
408
+ const actorNamed = (who: string) => {
409
+ const declared = readPeople(scope.root);
410
+ try {
411
+ return admit({ id: who }, declared).id;
412
+ } catch (err) {
413
+ if (errorCode(err) !== "not_admitted" || declared.problems.length) throw err;
414
+ const ids = [...principalIdsOf(declared.people), ...RUNTIME_AGENTS].join(", ");
415
+ throw new Error(`${who} is not a declared person or agent; resources/ declares ${ids}`, { cause: err });
416
+ }
417
+ };
418
+ /** `group`, a declared group or `agents`, while the people's files have no problems. */
419
+ const groupNamed = (group: string) => {
420
+ const { people, problems } = readPeople(scope.root);
421
+ if (problems.length) {
422
+ throw new Error(
423
+ `no group can be told while the people's files have problems: ${problems.map(problemLine).join("; ")}`,
424
+ );
425
+ }
426
+ const groups = groupNamesOf(people);
427
+ if (!declaresPeople(people) || groups.includes(group)) return group;
428
+ throw new Error(`no group "${group}"; resources/ declares ${groups.join(", ")}`);
429
+ };
348
430
  /** A resource step's object: its type's id, vendor and type, and its import id. */
349
431
  const object = (id: unknown, name: unknown) => {
350
432
  const { vendor, type } = types.get(id as string)!;
351
433
  return { resource: id as string, vendor, type, name: name as string };
352
434
  };
353
435
  const rules: Rule[] = [
436
+ { kind: "actor", source: "{who} is acting", read: ([who]) => actorNamed(who as string) },
354
437
  {
355
438
  kind: "given",
356
439
  source: "{op} was called with",
@@ -368,21 +451,32 @@ function rulesOf(scope: Scope): { rules: Rule[]; parameterTypes: ParameterTypeSp
368
451
  kind: "given",
369
452
  source: "{resource} {string} has",
370
453
  takes: TAKES_OBJECT,
371
- read: ([resource, name], pickle) => [
372
- {
373
- ...object(resource, name),
374
- fields: fieldsFrom(
375
- pickle,
376
- types.get(resource as string)!.schema,
377
- `${resource as string} "${name as string}"`,
378
- ),
379
- },
380
- ],
454
+ read: ([resource, name], pickle) => {
455
+ const owner = `${resource as string} "${name as string}"`;
456
+ const c = collectionOf(resource);
457
+ if (c) {
458
+ // The record with its key, the fields the step gives, and the rest made up.
459
+ const record = shapeOf(opOf(c.save.id).input).record!;
460
+ const fields = fieldsFrom(pickle, record, owner);
461
+ if (Object.hasOwn(fields, c.key) && fields[c.key] !== name) {
462
+ throw new Error(
463
+ `${c.key} in ${owner} is ${JSON.stringify(fields[c.key])}: it is the key, "${name as string}"`,
464
+ );
465
+ }
466
+ return [{ op: c.save.id, input: { record: fill(record, { ...fields, [c.key]: name }) } }];
467
+ }
468
+ return [
469
+ { ...object(resource, name), fields: fieldsFrom(pickle, types.get(resource as string)!.schema, owner) },
470
+ ];
471
+ },
381
472
  },
382
473
  {
383
474
  kind: "given",
384
475
  source: "{resource} {string} is gone",
385
- read: ([resource, name]) => [{ ...object(resource, name), gone: true }],
476
+ read: ([resource, name]) => {
477
+ const c = collectionOf(resource);
478
+ return [c ? { op: c.delete.id, input: { key: name } } : { ...object(resource, name), gone: true }];
479
+ },
386
480
  },
387
481
  {
388
482
  kind: "when",
@@ -436,7 +530,7 @@ function rulesOf(scope: Scope): { rules: Rule[]; parameterTypes: ParameterTypeSp
436
530
  source: "{op} was called with",
437
531
  takes: TAKES_OBJECT,
438
532
  read: ([op], pickle) =>
439
- expecting({ op: op as string, input: fieldsFrom(pickle, opOf(op).input, inputOf(op)), called: true }),
533
+ expecting({ op: op as string, input: fieldsFrom(pickle, opOf(op).input, inputOf(op), true), called: true }),
440
534
  },
441
535
  ...[true, false].map((called): Rule => ({
442
536
  kind: "then",
@@ -448,11 +542,22 @@ function rulesOf(scope: Scope): { rules: Rule[]; parameterTypes: ParameterTypeSp
448
542
  source: "{op} was denied",
449
543
  read: ([op]) => expecting({ op: op as string, decision: "denied" }),
450
544
  },
545
+ {
546
+ kind: "then",
547
+ source: "{op} was denied with {string}",
548
+ read: ([op, reason]) => expecting({ op: op as string, decision: "denied", reason: reason as string }),
549
+ },
451
550
  {
452
551
  kind: "then",
453
552
  source: "{op} was held for {who}",
454
553
  read: ([op, who]) => expecting({ op: op as string, decision: "held", approver: who as string }),
455
554
  },
555
+ {
556
+ kind: "then",
557
+ source: "{op} was held for group {string}",
558
+ read: ([op, group]) =>
559
+ expecting({ op: op as string, decision: "held", approver: { group: groupNamed(group as string) } }),
560
+ },
456
561
  {
457
562
  kind: "then",
458
563
  source: "step {string} was called with",
@@ -466,7 +571,11 @@ function rulesOf(scope: Scope): { rules: Rule[]; parameterTypes: ParameterTypeSp
466
571
  // An unnamed call with a computed segment is named by the operation it calls.
467
572
  const op = scope.ops.get(node.id) ?? (node.name === undefined ? scope.ops.get(name as string) : undefined);
468
573
  if (!op) throw new Error(`${step} calls ${node.id}, whose input cannot be read: name its operation in full`);
469
- return expecting({ name: name as string, input: fieldsFrom(pickle, op.input, inputOf(op.id)), called: true });
574
+ return expecting({
575
+ name: name as string,
576
+ input: fieldsFrom(pickle, op.input, inputOf(op.id), true),
577
+ called: true,
578
+ });
470
579
  },
471
580
  },
472
581
  ...[true, false].map((called): Rule => ({
@@ -524,7 +633,7 @@ function rulesOf(scope: Scope): { rules: Rule[]; parameterTypes: ParameterTypeSp
524
633
  const last = lastCall(records);
525
634
  if (typeof last === "string") return last;
526
635
  if (last.error) return `it failed: ${last.error.code ?? last.error.name}: ${last.error.message}`;
527
- const expected = fieldsFrom(pickle, opOf(last.op).output, `${last.op}'s output`);
636
+ const expected = fieldsFrom(pickle, opOf(last.op).output, `${last.op}'s output`, true);
528
637
  return contains(last.output, expected) ? undefined : `it returned ${JSON.stringify(last.output)}`;
529
638
  },
530
639
  }),
@@ -628,9 +737,13 @@ function rulesOf(scope: Scope): { rules: Rule[]; parameterTypes: ParameterTypeSp
628
737
  transformer: (...g) => field(g),
629
738
  });
630
739
  }
740
+ // An `expect` phrase, like `was called with`, may give some of an object's fields.
631
741
  const given = (values: unknown[]) =>
632
742
  Object.fromEntries(
633
- fields.map((name, i) => [name, valueFor(schemas[i]!, values[i] as string, `${name} in ${inputOf(op.id)}`)]),
743
+ fields.map((name, i) => [
744
+ name,
745
+ valueFor(schemas[i]!, values[i] as string, `${name} in ${inputOf(op.id)}`, kind === "then"),
746
+ ]),
634
747
  );
635
748
  const common = { source: template, group: op.id, expression: template.replaceAll(/\{([^}]*)\}/g, "{field_$1}") };
636
749
  rules.push(
@@ -650,7 +763,8 @@ function lastCall(records: readonly LedgerRecord[]): CallRecord | string {
650
763
 
651
764
  /** Every step the scope knows, for an error: the generic ones by keyword, then each operation's phrases and each step library's steps under its name. */
652
765
  function knownSteps(rules: Rule[]): string {
653
- const keyword = (kind: Rule["kind"]) => (kind === "any" ? "Given/When/Then" : kind[0]!.toUpperCase() + kind.slice(1));
766
+ const keyword = (kind: Rule["kind"]) =>
767
+ kind === "any" ? "Given/When/Then" : kind === "actor" ? "Given" : kind[0]!.toUpperCase() + kind.slice(1);
654
768
  const line = (r: Rule) => `${keyword(r.kind)} ${r.source}${r.takes ? ` (and ${r.takes})` : ""}`;
655
769
  const { generic = [], ...groups } = Object.groupBy(rules, (r) => r.group ?? "generic");
656
770
  return [
@@ -804,7 +918,9 @@ const ambiguous = (text: string, sources: string[]) =>
804
918
  function ruleFor(built: Built, expression: string, pickle: PickleStep): Rule {
805
919
  const kind = pickle.type && KINDS[pickle.type];
806
920
  if (!kind) throw new Error(`"${pickle.text}" needs a Given, When or Then before it`);
807
- const rules = built.byExpression.get(expression)!.filter((r) => r.kind === kind || r.kind === "any");
921
+ const rules = built.byExpression
922
+ .get(expression)!
923
+ .filter((r) => r.kind === kind || r.kind === "any" || (r.kind === "actor" && kind === "given"));
808
924
  if (rules.length === 0) throw new Error(noMatch(built, pickle.text));
809
925
  if (rules.length > 1)
810
926
  throw new Error(
@@ -837,6 +953,11 @@ function compileWorld(built: Built, specs: Map<string, Spec>) {
837
953
  const { text } = pickle;
838
954
  const step: ScenarioStep = { text, kind: KINDS[pickle.type!]! };
839
955
  switch (rule.kind) {
956
+ case "actor":
957
+ if (spec.startedBy !== undefined)
958
+ throw new Error(`${spec.startedBy} is acting already: a run has one actor`);
959
+ spec.startedBy = rule.read(args, pickle);
960
+ break;
840
961
  case "given": {
841
962
  const items = rule.read(args, pickle);
842
963
  spec.given.push(...items.map((item) => ({ step: text, ...item }) as Given));
@@ -988,6 +1109,8 @@ function testWorld(built: Built, { driver, fake }: FeatureTarget) {
988
1109
  throw new Error(
989
1110
  `"${text}" decides an approval: a step for a scenario in scenarios/, not an operation's own test`,
990
1111
  );
1112
+ case "actor":
1113
+ throw new Error(`"${text}" says who starts a run: a step for a scenario in scenarios/`);
991
1114
  case "then": {
992
1115
  const detail = rule.read(args, pickle).verify(viewOf(records));
993
1116
  if (detail !== undefined) throw new Error(detail);
@@ -1179,18 +1302,22 @@ async function compile(scope: Scope, dir: string, files: string[]): Promise<Load
1179
1302
  }
1180
1303
 
1181
1304
  /**
1182
- * Each scope's scenarios as last read, with the stamp of the files they were read from. By
1183
- * scope, since what a file means depends on its operations and workflows.
1305
+ * Each scope's scenarios as last read, with the stamp of the files they were read from and the
1306
+ * people they were checked against (`readPeople`'s answer, the same object until a data file
1307
+ * changes). By scope, since what a file means depends on its operations and workflows.
1184
1308
  */
1185
- const loadedByScope = new WeakMap<Scope, { stamp: string; loaded: Promise<LoadedScenarios> }>();
1309
+ const loadedByScope = new WeakMap<
1310
+ Scope,
1311
+ { stamp: string; people: DeclaredPeople | undefined; loaded: Promise<LoadedScenarios> }
1312
+ >();
1186
1313
 
1187
1314
  /**
1188
1315
  * Every scenario in the `.feature` files under `scenarios` (a `file:` URL to a directory),
1189
1316
  * compiled by Cucumber, with an error for each file that does not compile and each name used
1190
1317
  * twice. No directory, or none given, is no scenarios. Rejects when an operation's phrase names
1191
1318
  * a field it lacks. The files are compiled again only once one is added, removed or written
1192
- * (its name, `mtimeMs` or size changes): until then each call returns the same object, which
1193
- * callers must not change.
1319
+ * (its name, `mtimeMs` or size changes), or the people's data files change: until then each
1320
+ * call returns the same object, which callers must not change.
1194
1321
  */
1195
1322
  export async function loadScenarios(scope: Scope): Promise<LoadedScenarios> {
1196
1323
  if (!scope.scenarios) return { scenarios: [], errors: [] };
@@ -1206,10 +1333,12 @@ export async function loadScenarios(scope: Scope): Promise<LoadedScenarios> {
1206
1333
  return [file, mtimeMs, size];
1207
1334
  }),
1208
1335
  ]);
1336
+ // None without a root, which declares no one.
1337
+ const people = scope.root === undefined ? undefined : readPeople(scope.root);
1209
1338
  const cached = loadedByScope.get(scope);
1210
- if (cached?.stamp === stamp) return cached.loaded;
1339
+ if (cached?.stamp === stamp && cached.people === people) return cached.loaded;
1211
1340
  const loaded = files.length ? compile(scope, dir, files) : Promise.resolve({ scenarios: [], errors: [] });
1212
- loadedByScope.set(scope, { stamp, loaded });
1341
+ loadedByScope.set(scope, { stamp, people, loaded });
1213
1342
  // A compile that throws (a bug) is not kept: the next call tries again.
1214
1343
  loaded.catch(() => {
1215
1344
  if (loadedByScope.get(scope)?.loaded === loaded) loadedByScope.delete(scope);
package/src/scenarios.ts CHANGED
@@ -18,21 +18,28 @@ export interface RunScenarioOptions {
18
18
  client: SanomaClient;
19
19
  /** What the scenario was loaded from (`loadScenarios`), whose feature files its run is checked against: the resolved config. */
20
20
  scope: Scope;
21
- /** Who starts the run. Defaults to `{ id: "scenarios" }`. */
22
- startedBy?: Principal;
21
+ /**
22
+ * Who starts the run, by id (`"ada"` or `{ id: "ada" }`), unless the scenario says who is acting.
23
+ * Defaults to `scenarios`, one of the runtime's own agents.
24
+ */
25
+ startedBy?: Principal | string;
23
26
  /** How long the run may take, passed to `drive`. */
24
27
  timeoutMs?: number;
25
28
  }
26
29
 
27
30
  /**
28
- * Runs a scenario as a sandbox run: starts its workflow with its input, decides its approvals
29
- * as it says, and checks the run's ledger against it (`check`: Cucumber runs its `Then` steps).
31
+ * Runs a scenario as a sandbox run: starts its workflow with its input, as whoever it says is
32
+ * acting, decides its approvals as it says, and checks the run's ledger against it (`check`:
33
+ * Cucumber runs its `Then` steps).
30
34
  */
31
35
  export async function runScenario(
32
36
  scenario: Scenario,
33
- { client, scope, startedBy = { id: "scenarios" }, timeoutMs }: RunScenarioOptions,
37
+ { client, scope, startedBy = "scenarios", timeoutMs }: RunScenarioOptions,
34
38
  ): Promise<{ runId: string; run: RunSummary; ledger: LedgerRecord[]; checks: Check[] }> {
35
- const runId = await client.start(scenario.workflow, scenario.input, { startedBy, sandbox: scenario.name });
39
+ const runId = await client.start(scenario.workflow, scenario.input, {
40
+ startedBy: scenario.startedBy ?? startedBy,
41
+ sandbox: scenario.name,
42
+ });
36
43
  const run = await drive(client, runId, scenario, { timeoutMs });
37
44
  const ledger = await client.ledger(runId);
38
45
  return { runId, run, ledger, checks: await check(scope, scenario, ledger) };
@@ -49,8 +56,8 @@ export interface ScenarioFailure {
49
56
  export interface RunScenariosOptions {
50
57
  /** The Postgres the worker and the client run on, instead of the config's: a database of its own. */
51
58
  databaseUrl: string;
52
- /** Who starts the runs. Defaults to `{ id: "scenarios" }`. */
53
- startedBy?: Principal;
59
+ /** Who starts the runs whose scenario does not say who is acting (`runScenario`'s). */
60
+ startedBy?: Principal | string;
54
61
  /** How long each run may take (`drive`'s; 15 seconds by default). */
55
62
  timeoutMs?: number;
56
63
  /** Receives a line per scenario as it ends. */