@sanoma-ai/workflows 0.1.0 → 0.4.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.
Files changed (184) hide show
  1. package/README.md +186 -76
  2. package/dist/apply-steps.d.ts +6 -0
  3. package/dist/apply-steps.js +93 -0
  4. package/dist/apply-steps.js.map +1 -0
  5. package/dist/apply.d.ts +197 -0
  6. package/dist/apply.js +470 -0
  7. package/dist/apply.js.map +1 -0
  8. package/dist/approvals.d.ts +11 -6
  9. package/dist/approvals.js +38 -10
  10. package/dist/approvals.js.map +1 -1
  11. package/dist/call-workflow.d.ts +16 -0
  12. package/dist/call-workflow.js +35 -0
  13. package/dist/call-workflow.js.map +1 -0
  14. package/dist/call.d.ts +35 -2
  15. package/dist/call.js +123 -43
  16. package/dist/call.js.map +1 -1
  17. package/dist/cedar.d.ts +87 -0
  18. package/dist/cedar.js +365 -0
  19. package/dist/cedar.js.map +1 -0
  20. package/dist/client.d.ts +123 -9
  21. package/dist/client.js +355 -34
  22. package/dist/client.js.map +1 -1
  23. package/dist/collection.d.ts +107 -0
  24. package/dist/collection.js +306 -0
  25. package/dist/collection.js.map +1 -0
  26. package/dist/config.d.ts +42 -14
  27. package/dist/config.js +109 -28
  28. package/dist/config.js.map +1 -1
  29. package/dist/constructors.d.ts +2 -0
  30. package/dist/constructors.js +5 -0
  31. package/dist/constructors.js.map +1 -0
  32. package/dist/credentials.d.ts +25 -0
  33. package/dist/credentials.js +73 -5
  34. package/dist/credentials.js.map +1 -1
  35. package/dist/data.d.ts +2 -0
  36. package/dist/data.js +3 -0
  37. package/dist/data.js.map +1 -0
  38. package/dist/datafile.d.ts +38 -3
  39. package/dist/datafile.js +166 -13
  40. package/dist/datafile.js.map +1 -1
  41. package/dist/db.d.ts +5 -3
  42. package/dist/db.js +88 -21
  43. package/dist/db.js.map +1 -1
  44. package/dist/define.d.ts +10 -4
  45. package/dist/define.js +1 -5
  46. package/dist/define.js.map +1 -1
  47. package/dist/describe.d.ts +54 -8
  48. package/dist/describe.js +84 -11
  49. package/dist/describe.js.map +1 -1
  50. package/dist/direct.d.ts +9 -0
  51. package/dist/direct.js +11 -0
  52. package/dist/direct.js.map +1 -0
  53. package/dist/drift.d.ts +64 -12
  54. package/dist/drift.js +118 -41
  55. package/dist/drift.js.map +1 -1
  56. package/dist/errors.d.ts +16 -1
  57. package/dist/errors.js +30 -1
  58. package/dist/errors.js.map +1 -1
  59. package/dist/expectations.d.ts +4 -4
  60. package/dist/expectations.js +43 -9
  61. package/dist/expectations.js.map +1 -1
  62. package/dist/host.d.ts +56 -0
  63. package/dist/host.js +45 -0
  64. package/dist/host.js.map +1 -0
  65. package/dist/index.d.ts +13 -5
  66. package/dist/index.js +15 -5
  67. package/dist/index.js.map +1 -1
  68. package/dist/ledger.d.ts +12 -1
  69. package/dist/ledger.js +18 -11
  70. package/dist/ledger.js.map +1 -1
  71. package/dist/lint.d.ts +5 -4
  72. package/dist/lint.js +24 -7
  73. package/dist/lint.js.map +1 -1
  74. package/dist/lockfile.d.ts +36 -0
  75. package/dist/lockfile.js +69 -0
  76. package/dist/lockfile.js.map +1 -0
  77. package/dist/op.d.ts +9 -0
  78. package/dist/op.js.map +1 -1
  79. package/dist/outline.d.ts +2 -0
  80. package/dist/outline.js +1 -1
  81. package/dist/outline.js.map +1 -1
  82. package/dist/paths.d.ts +4 -0
  83. package/dist/paths.js +17 -3
  84. package/dist/paths.js.map +1 -1
  85. package/dist/person.d.ts +61 -0
  86. package/dist/person.js +69 -0
  87. package/dist/person.js.map +1 -0
  88. package/dist/plugin.js +1 -1
  89. package/dist/plugin.js.map +1 -1
  90. package/dist/policy.d.ts +51 -55
  91. package/dist/policy.js +14 -36
  92. package/dist/policy.js.map +1 -1
  93. package/dist/resource.d.ts +207 -25
  94. package/dist/resource.js +184 -30
  95. package/dist/resource.js.map +1 -1
  96. package/dist/resources.d.ts +76 -9
  97. package/dist/resources.js +170 -30
  98. package/dist/resources.js.map +1 -1
  99. package/dist/run.d.ts +31 -1
  100. package/dist/run.js +6 -1
  101. package/dist/run.js.map +1 -1
  102. package/dist/sandbox.js +5 -1
  103. package/dist/sandbox.js.map +1 -1
  104. package/dist/scenario.d.ts +25 -9
  105. package/dist/scenario.js +125 -27
  106. package/dist/scenario.js.map +1 -1
  107. package/dist/scenarios.d.ts +10 -6
  108. package/dist/scenarios.js +8 -4
  109. package/dist/scenarios.js.map +1 -1
  110. package/dist/schema.d.ts +32 -0
  111. package/dist/schema.js +225 -0
  112. package/dist/schema.js.map +1 -0
  113. package/dist/sdk.d.ts +34 -0
  114. package/dist/sdk.js +168 -0
  115. package/dist/sdk.js.map +1 -0
  116. package/dist/secret.d.ts +42 -0
  117. package/dist/secret.js +58 -0
  118. package/dist/secret.js.map +1 -0
  119. package/dist/shared.d.ts +188 -4
  120. package/dist/shared.js +165 -5
  121. package/dist/shared.js.map +1 -1
  122. package/dist/sites.d.ts +52 -0
  123. package/dist/sites.js +174 -0
  124. package/dist/sites.js.map +1 -0
  125. package/dist/source.d.ts +58 -0
  126. package/dist/source.js +54 -0
  127. package/dist/source.js.map +1 -0
  128. package/dist/version.d.ts +3 -2
  129. package/dist/version.js +3 -2
  130. package/dist/version.js.map +1 -1
  131. package/dist/worker.d.ts +6 -2
  132. package/dist/worker.js +37 -8
  133. package/dist/worker.js.map +1 -1
  134. package/dist/writer.d.ts +35 -0
  135. package/dist/writer.js +201 -0
  136. package/dist/writer.js.map +1 -0
  137. package/oxlint.json +5 -16
  138. package/package.json +11 -1
  139. package/src/apply-steps.ts +110 -0
  140. package/src/apply.ts +626 -0
  141. package/src/approvals.ts +49 -13
  142. package/src/call-workflow.ts +44 -0
  143. package/src/call.ts +173 -46
  144. package/src/cedar.ts +493 -0
  145. package/src/client.ts +450 -49
  146. package/src/collection.ts +431 -0
  147. package/src/config.ts +168 -48
  148. package/src/constructors.ts +4 -0
  149. package/src/credentials.ts +90 -5
  150. package/src/data.ts +10 -0
  151. package/src/datafile.ts +207 -15
  152. package/src/db.ts +90 -22
  153. package/src/define.ts +11 -9
  154. package/src/describe.ts +146 -23
  155. package/src/direct.ts +15 -0
  156. package/src/drift.ts +155 -45
  157. package/src/errors.ts +36 -1
  158. package/src/expectations.ts +46 -11
  159. package/src/host.ts +96 -0
  160. package/src/index.ts +56 -2
  161. package/src/ledger.ts +35 -17
  162. package/src/lint.ts +29 -12
  163. package/src/lockfile.ts +95 -0
  164. package/src/op.ts +9 -0
  165. package/src/outline.ts +1 -1
  166. package/src/paths.ts +18 -3
  167. package/src/person.ts +75 -0
  168. package/src/plugin.ts +1 -1
  169. package/src/policy.ts +59 -63
  170. package/src/resource.ts +302 -52
  171. package/src/resources.ts +226 -30
  172. package/src/run.ts +33 -1
  173. package/src/sandbox.ts +5 -1
  174. package/src/scenario.ts +175 -43
  175. package/src/scenarios.ts +15 -8
  176. package/src/schema.ts +261 -0
  177. package/src/sdk.ts +211 -0
  178. package/src/secret.ts +92 -0
  179. package/src/shared.ts +279 -6
  180. package/src/sites.ts +220 -0
  181. package/src/source.ts +82 -0
  182. package/src/version.ts +3 -2
  183. package/src/worker.ts +45 -11
  184. package/src/writer.ts +225 -0
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,14 +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
32
  import { fill, isBug, 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";
35
- import { DRIFT_WORKFLOW, errorMessage, fitsOp, flatten, isEnded } from "./shared.ts";
36
+ import { admit, type DeclaredPeople, readPeople } from "./resources.ts";
37
+ import { memorySecrets } from "./secret.ts";
38
+ import {
39
+ APPLY_WORKFLOW,
40
+ BUILT_IN_WORKFLOWS,
41
+ DATA_VENDOR,
42
+ declaresPeople,
43
+ DRIFT_WORKFLOW,
44
+ errorMessage,
45
+ fitsOp,
46
+ flatten,
47
+ groupNamesOf,
48
+ isEnded,
49
+ principalIdsOf,
50
+ problemLine,
51
+ RUNTIME_AGENTS,
52
+ } from "./shared.ts";
36
53
  import type { Call, RunView, Seed } from "./steps.ts";
37
54
 
38
55
  /*
@@ -89,8 +106,11 @@ export interface Decision {
89
106
  /**
90
107
  * What the run should do: call an operation (with at least this input) or not, run the step
91
108
  * `name`d in the workflow's outline or not, or a number of `times`, have the policy deny an
92
- * operation (`denied`) or hold one for an approver (`held`), end a certain way, or, for a drift
93
- * run, report the resource `declared` (`<file>#<export>`) clean, gone, or drifted in a field.
109
+ * operation (`denied`, for a `reason` when the step names one) or hold one for an approver
110
+ * (`held`: a person's id or a group's name, or `{ group }` a group's alone), end a certain way,
111
+ * for a drift run, report the resource `declared` (`<file>#<export>`) clean, gone, absent, or
112
+ * drifted in a field, or for an apply run, create it, or leave it bound to an object (`bound`,
113
+ * its vendor's id).
94
114
  * A step whose own code checks the run (a connector's or the company's, or an operation's own
95
115
  * test's) is `custom`, with what it is about.
96
116
  */
@@ -98,11 +118,13 @@ export type Expectation =
98
118
  | { step: string; op: string; input?: unknown; called: boolean }
99
119
  | { step: string; name: string; input?: unknown; called: boolean }
100
120
  | { step: string; name: string; times: number }
101
- | { step: string; op: string; decision: "denied" }
102
- | { step: string; op: string; decision: "held"; approver: string }
121
+ | { step: string; op: string; decision: "denied"; reason?: string }
122
+ | { step: string; op: string; decision: "held"; approver: Approver }
103
123
  | { step: string; outcome: "finished" | "failed"; code?: string }
104
- | { step: string; declared: string; status: "clean" | "gone" }
124
+ | { step: string; declared: string; status: "clean" | "gone" | "absent" }
105
125
  | { step: string; declared: string; status: "drifted"; field: string }
126
+ | { step: string; declared: string; created: true }
127
+ | { step: string; declared: string; bound: string }
106
128
  | { step: string; custom: true; op?: string; called?: boolean };
107
129
 
108
130
  export interface Scenario {
@@ -120,6 +142,8 @@ export interface Scenario {
120
142
  text: string;
121
143
  /** Every step in order, for a list and for marking graph nodes. */
122
144
  steps: ScenarioStep[];
145
+ /** Who starts the run, by id, when a `Given <who> is acting` says: a declared person or agent. */
146
+ startedBy?: string;
123
147
  given: Given[];
124
148
  input: unknown;
125
149
  decisions: Decision[];
@@ -140,10 +164,14 @@ export interface Check {
140
164
  }
141
165
 
142
166
  /**
143
- * What steps can name: the config's operations, workflows and resource types by id, the steps
144
- * its connectors (and the company) ship, and where the feature files are.
167
+ * What steps can name: the config's operations, workflows, resource types and collections by id,
168
+ * the steps its connectors (and the company) ship, where the feature files are, and the root
169
+ * whose data files declare its people.
145
170
  */
146
- export type Scope = Pick<ResolvedConfig, "ops" | "workflows" | "scenarios" | "types" | "steps">;
171
+ export type Scope = Pick<
172
+ ResolvedConfig,
173
+ "ops" | "workflows" | "scenarios" | "types" | "collections" | "steps" | "root"
174
+ >;
147
175
 
148
176
  type Kind = ScenarioStep["kind"];
149
177
  type Fault = Extract<Seed, { fault: unknown }>["fault"];
@@ -158,9 +186,13 @@ interface Then {
158
186
  verify(run: RunView): string | undefined;
159
187
  }
160
188
 
161
- /** What a step of each kind reads into; `any` is a decision, which a step of any keyword may make. */
189
+ /**
190
+ * What a step of each kind reads into; `any` is a decision, which a step of any keyword may make,
191
+ * and `actor` a `Given` saying who starts the run (`{who} is acting`), by id.
192
+ */
162
193
  interface Items {
163
194
  given: Unstepped<Given>[];
195
+ actor: string;
164
196
  when: When;
165
197
  then: Then;
166
198
  any: Unstepped<Decision>;
@@ -302,7 +334,9 @@ function builtFor(scope: Scope): Built {
302
334
  cached &&
303
335
  cached.scope.workflows === scope.workflows &&
304
336
  cached.scope.types === scope.types &&
305
- cached.scope.steps === scope.steps
337
+ cached.scope.collections === scope.collections &&
338
+ cached.scope.steps === scope.steps &&
339
+ cached.scope.root === scope.root
306
340
  ) {
307
341
  return cached;
308
342
  }
@@ -314,29 +348,66 @@ function builtFor(scope: Scope): Built {
314
348
  return built;
315
349
  }
316
350
 
351
+ /** The built-in workflows, whose input is never made up: each field is optional, and left out unless given. */
352
+
317
353
  /** A `Then` step of the generic steps: its expectation, checked against the run's records. */
318
354
  const expecting = (expect: CoreExpectation): Then => ({ expect, verify: (run) => detailOf(expect, run.records) });
319
355
 
320
356
  /** The rules a scope has, and the parameter types their expressions use. */
321
357
  function rulesOf(scope: Scope): { rules: Rule[]; parameterTypes: ParameterTypeSpec[] } {
322
- const { types } = scope;
358
+ const { types, collections } = scope;
359
+ // A collection's records are seeded as a resource's objects are, by `data.<name>`, through its operations.
360
+ const collectionOf = (id: unknown) =>
361
+ (id as string).startsWith(`${DATA_VENDOR}.`)
362
+ ? collections.get((id as string).slice(DATA_VENDOR.length + 1))
363
+ : undefined;
364
+ const seedable = new Map<string, unknown>([
365
+ ...types,
366
+ ...[...collections.keys()].map((n) => [`${DATA_VENDOR}.${n}`, n] as const),
367
+ ]);
323
368
  const parameterTypes: ParameterTypeSpec[] = [
324
369
  { name: "op", regexp: /\S+/, transformer: known("operation", scope.ops) },
325
370
  { name: "workflow", regexp: /\S+/, transformer: known("workflow", scope.workflows) },
326
371
  { name: "who", regexp: /\S+/, transformer: (s) => s },
327
- { name: "resource", regexp: /\S+/, transformer: known("resource type", types) },
372
+ { name: "resource", regexp: /\S+/, transformer: known("resource type", seedable) },
328
373
  // A declared resource, as a drift report names it: `<file>#<export>`.
329
374
  { name: "declared", regexp: /[^\s#]+#\S+/, transformer: (s) => s },
330
375
  { name: "field", regexp: /\S+/, transformer: (s) => s },
331
376
  ];
332
377
  const opOf = (id: unknown) => scope.ops.get(id as string)!;
333
378
  const workflowOf = (name: unknown) => scope.workflows.get(name as string)!;
379
+ // The people as the root's data files declare them when a step names one (`readPeople`, read
380
+ // again only once a file changed): while they declare no one, any id and any group go.
381
+ /** `who`, as a run's start admits them (`admit`): a declared person or agent. */
382
+ const actorNamed = (who: string) => {
383
+ const declared = readPeople(scope.root);
384
+ try {
385
+ return admit({ id: who }, declared).id;
386
+ } catch (err) {
387
+ if (errorCode(err) !== "not_admitted" || declared.problems.length) throw err;
388
+ const ids = [...principalIdsOf(declared.people), ...RUNTIME_AGENTS].join(", ");
389
+ throw new Error(`${who} is not a declared person or agent; resources/ declares ${ids}`, { cause: err });
390
+ }
391
+ };
392
+ /** `group`, a declared group or `agents`, while the people's files have no problems. */
393
+ const groupNamed = (group: string) => {
394
+ const { people, problems } = readPeople(scope.root);
395
+ if (problems.length) {
396
+ throw new Error(
397
+ `no group can be told while the people's files have problems: ${problems.map(problemLine).join("; ")}`,
398
+ );
399
+ }
400
+ const groups = groupNamesOf(people);
401
+ if (!declaresPeople(people) || groups.includes(group)) return group;
402
+ throw new Error(`no group "${group}"; resources/ declares ${groups.join(", ")}`);
403
+ };
334
404
  /** A resource step's object: its type's id, vendor and type, and its import id. */
335
405
  const object = (id: unknown, name: unknown) => {
336
406
  const { vendor, type } = types.get(id as string)!;
337
407
  return { resource: id as string, vendor, type, name: name as string };
338
408
  };
339
409
  const rules: Rule[] = [
410
+ { kind: "actor", source: "{who} is acting", read: ([who]) => actorNamed(who as string) },
340
411
  {
341
412
  kind: "given",
342
413
  source: "{op} was called with",
@@ -354,21 +425,32 @@ function rulesOf(scope: Scope): { rules: Rule[]; parameterTypes: ParameterTypeSp
354
425
  kind: "given",
355
426
  source: "{resource} {string} has",
356
427
  takes: TAKES_OBJECT,
357
- read: ([resource, name], pickle) => [
358
- {
359
- ...object(resource, name),
360
- fields: fieldsFrom(
361
- pickle,
362
- types.get(resource as string)!.schema,
363
- `${resource as string} "${name as string}"`,
364
- ),
365
- },
366
- ],
428
+ read: ([resource, name], pickle) => {
429
+ const owner = `${resource as string} "${name as string}"`;
430
+ const c = collectionOf(resource);
431
+ if (c) {
432
+ // The record with its key, the fields the step gives, and the rest made up.
433
+ const record = shapeOf(opOf(c.save.id).input).record!;
434
+ const fields = fieldsFrom(pickle, record, owner);
435
+ if (Object.hasOwn(fields, c.key) && fields[c.key] !== name) {
436
+ throw new Error(
437
+ `${c.key} in ${owner} is ${JSON.stringify(fields[c.key])}: it is the key, "${name as string}"`,
438
+ );
439
+ }
440
+ return [{ op: c.save.id, input: { record: fill(record, { ...fields, [c.key]: name }) } }];
441
+ }
442
+ return [
443
+ { ...object(resource, name), fields: fieldsFrom(pickle, types.get(resource as string)!.schema, owner) },
444
+ ];
445
+ },
367
446
  },
368
447
  {
369
448
  kind: "given",
370
449
  source: "{resource} {string} is gone",
371
- read: ([resource, name]) => [{ ...object(resource, name), gone: true }],
450
+ read: ([resource, name]) => {
451
+ const c = collectionOf(resource);
452
+ return [c ? { op: c.delete.id, input: { key: name } } : { ...object(resource, name), gone: true }];
453
+ },
372
454
  },
373
455
  {
374
456
  kind: "when",
@@ -376,13 +458,21 @@ function rulesOf(scope: Scope): { rules: Rule[]; parameterTypes: ParameterTypeSp
376
458
  takes: TAKES_OBJECT,
377
459
  read: ([name], pickle) => {
378
460
  const { input } = workflowOf(name);
379
- return { workflow: name as string, input: fill(input, fieldsFrom(pickle, input, inputOf(name))) };
461
+ const given = fieldsFrom(pickle, input, inputOf(name));
462
+ return {
463
+ workflow: name as string,
464
+ input: BUILT_IN_WORKFLOWS.has(name as string) ? input.parse(given) : fill(input, given),
465
+ };
380
466
  },
381
467
  },
382
468
  {
383
469
  kind: "when",
384
470
  source: "{workflow} runs",
385
- read: ([name]) => ({ workflow: name as string, input: fill(workflowOf(name).input, {}) }),
471
+ // A built-in's input is optional throughout: what is not given is left out, not made up.
472
+ read: ([name]) => ({
473
+ workflow: name as string,
474
+ input: BUILT_IN_WORKFLOWS.has(name as string) ? {} : fill(workflowOf(name).input, {}),
475
+ }),
386
476
  },
387
477
  // An operation's own test: a call made to its driver, or its fake, directly.
388
478
  {
@@ -426,11 +516,22 @@ function rulesOf(scope: Scope): { rules: Rule[]; parameterTypes: ParameterTypeSp
426
516
  source: "{op} was denied",
427
517
  read: ([op]) => expecting({ op: op as string, decision: "denied" }),
428
518
  },
519
+ {
520
+ kind: "then",
521
+ source: "{op} was denied with {string}",
522
+ read: ([op, reason]) => expecting({ op: op as string, decision: "denied", reason: reason as string }),
523
+ },
429
524
  {
430
525
  kind: "then",
431
526
  source: "{op} was held for {who}",
432
527
  read: ([op, who]) => expecting({ op: op as string, decision: "held", approver: who as string }),
433
528
  },
529
+ {
530
+ kind: "then",
531
+ source: "{op} was held for group {string}",
532
+ read: ([op, group]) =>
533
+ expecting({ op: op as string, decision: "held", approver: { group: groupNamed(group as string) } }),
534
+ },
434
535
  {
435
536
  kind: "then",
436
537
  source: "step {string} was called with",
@@ -469,11 +570,21 @@ function rulesOf(scope: Scope): { rules: Rule[]; parameterTypes: ParameterTypeSp
469
570
  read: ([declared, field]) =>
470
571
  expecting({ declared: declared as string, status: "drifted", field: field as string }),
471
572
  },
472
- ...(["clean", "gone"] as const).map((status): Rule => ({
573
+ ...(["clean", "gone", "absent"] as const).map((status): Rule => ({
473
574
  kind: "then",
474
575
  source: `{declared} is ${status}`,
475
576
  read: ([declared]) => expecting({ declared: declared as string, status }),
476
577
  })),
578
+ {
579
+ kind: "then",
580
+ source: "apply creates {declared}",
581
+ read: ([declared]) => expecting({ declared: declared as string, created: true }),
582
+ },
583
+ {
584
+ kind: "then",
585
+ source: "{declared} is bound to {string}",
586
+ read: ([declared, id]) => expecting({ declared: declared as string, bound: id as string }),
587
+ },
477
588
  { kind: "then", source: "the run succeeds", read: () => expecting({ outcome: "finished" }) },
478
589
  { kind: "then", source: "the run fails", read: () => expecting({ outcome: "failed" }) },
479
590
  {
@@ -618,7 +729,8 @@ function lastCall(records: readonly LedgerRecord[]): CallRecord | string {
618
729
 
619
730
  /** 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. */
620
731
  function knownSteps(rules: Rule[]): string {
621
- const keyword = (kind: Rule["kind"]) => (kind === "any" ? "Given/When/Then" : kind[0]!.toUpperCase() + kind.slice(1));
732
+ const keyword = (kind: Rule["kind"]) =>
733
+ kind === "any" ? "Given/When/Then" : kind === "actor" ? "Given" : kind[0]!.toUpperCase() + kind.slice(1);
622
734
  const line = (r: Rule) => `${keyword(r.kind)} ${r.source}${r.takes ? ` (and ${r.takes})` : ""}`;
623
735
  const { generic = [], ...groups } = Object.groupBy(rules, (r) => r.group ?? "generic");
624
736
  return [
@@ -772,7 +884,9 @@ const ambiguous = (text: string, sources: string[]) =>
772
884
  function ruleFor(built: Built, expression: string, pickle: PickleStep): Rule {
773
885
  const kind = pickle.type && KINDS[pickle.type];
774
886
  if (!kind) throw new Error(`"${pickle.text}" needs a Given, When or Then before it`);
775
- const rules = built.byExpression.get(expression)!.filter((r) => r.kind === kind || r.kind === "any");
887
+ const rules = built.byExpression
888
+ .get(expression)!
889
+ .filter((r) => r.kind === kind || r.kind === "any" || (r.kind === "actor" && kind === "given"));
776
890
  if (rules.length === 0) throw new Error(noMatch(built, pickle.text));
777
891
  if (rules.length > 1)
778
892
  throw new Error(
@@ -805,6 +919,11 @@ function compileWorld(built: Built, specs: Map<string, Spec>) {
805
919
  const { text } = pickle;
806
920
  const step: ScenarioStep = { text, kind: KINDS[pickle.type!]! };
807
921
  switch (rule.kind) {
922
+ case "actor":
923
+ if (spec.startedBy !== undefined)
924
+ throw new Error(`${spec.startedBy} is acting already: a run has one actor`);
925
+ spec.startedBy = rule.read(args, pickle);
926
+ break;
808
927
  case "given": {
809
928
  const items = rule.read(args, pickle);
810
929
  spec.given.push(...items.map((item) => ({ step: text, ...item }) as Given));
@@ -879,6 +998,8 @@ function testWorld(built: Built, { driver, fake }: FeatureTarget) {
879
998
  const values = new Map<string, unknown>();
880
999
  const records: LedgerRecord[] = [];
881
1000
  let runId = "";
1001
+ // The test's own secrets, in memory: a driver resolves `secret()` markers against them.
1002
+ const secrets = memorySecrets();
882
1003
  /** A record of the test, as a run's ledger would hold it. */
883
1004
  const record = (body: Record<string, unknown>) => {
884
1005
  const seq = records.length;
@@ -892,7 +1013,7 @@ function testWorld(built: Built, { driver, fake }: FeatureTarget) {
892
1013
  const input = op.input.parse(resolve(given, values));
893
1014
  const fn = driver.ops[`${op.resource}.${op.name}`];
894
1015
  if (!fn) throw new Error(`the driver under test does not implement ${id}`);
895
- const context = { idempotencyKey: `${runId}:${records.length}`, runId, opId: id, attempt: 1 };
1016
+ const context = { idempotencyKey: `${runId}:${records.length}`, runId, opId: id, attempt: 1, secrets };
896
1017
  return { op, input, output: () => fn(input, context) };
897
1018
  };
898
1019
  return {
@@ -954,6 +1075,8 @@ function testWorld(built: Built, { driver, fake }: FeatureTarget) {
954
1075
  throw new Error(
955
1076
  `"${text}" decides an approval: a step for a scenario in scenarios/, not an operation's own test`,
956
1077
  );
1078
+ case "actor":
1079
+ throw new Error(`"${text}" says who starts a run: a step for a scenario in scenarios/`);
957
1080
  case "then": {
958
1081
  const detail = rule.read(args, pickle).verify(viewOf(records));
959
1082
  if (detail !== undefined) throw new Error(detail);
@@ -1092,11 +1215,14 @@ async function compile(scope: Scope, dir: string, files: string[]): Promise<Load
1092
1215
  `${where}: scenario "${name}" has no When: say which workflow runs, such as "When <workflow> runs with"`,
1093
1216
  );
1094
1217
  }
1095
- const drift = spec.expect.find((e) => "declared" in e);
1096
- if (drift && workflow !== DRIFT_WORKFLOW) {
1097
- throw new Error(
1098
- `${where}: "${drift.step}" reads a drift report, so the scenario must run ${DRIFT_WORKFLOW}, not ${workflow}`,
1099
- );
1218
+ // A step about a declared resource reads the report of the built-in it is about.
1219
+ for (const e of spec.expect) {
1220
+ const reads: string = "declared" in e ? ("status" in e ? DRIFT_WORKFLOW : APPLY_WORKFLOW) : workflow;
1221
+ if (reads !== workflow) {
1222
+ throw new Error(
1223
+ `${where}: "${e.step}" reads ${reads === DRIFT_WORKFLOW ? "a drift" : "an apply"} report, so the scenario must run ${reads}, not ${workflow}`,
1224
+ );
1225
+ }
1100
1226
  }
1101
1227
  inFile.push({
1102
1228
  ...spec,
@@ -1142,18 +1268,22 @@ async function compile(scope: Scope, dir: string, files: string[]): Promise<Load
1142
1268
  }
1143
1269
 
1144
1270
  /**
1145
- * Each scope's scenarios as last read, with the stamp of the files they were read from. By
1146
- * scope, since what a file means depends on its operations and workflows.
1271
+ * Each scope's scenarios as last read, with the stamp of the files they were read from and the
1272
+ * people they were checked against (`readPeople`'s answer, the same object until a data file
1273
+ * changes). By scope, since what a file means depends on its operations and workflows.
1147
1274
  */
1148
- const loadedByScope = new WeakMap<Scope, { stamp: string; loaded: Promise<LoadedScenarios> }>();
1275
+ const loadedByScope = new WeakMap<
1276
+ Scope,
1277
+ { stamp: string; people: DeclaredPeople | undefined; loaded: Promise<LoadedScenarios> }
1278
+ >();
1149
1279
 
1150
1280
  /**
1151
1281
  * Every scenario in the `.feature` files under `scenarios` (a `file:` URL to a directory),
1152
1282
  * compiled by Cucumber, with an error for each file that does not compile and each name used
1153
1283
  * twice. No directory, or none given, is no scenarios. Rejects when an operation's phrase names
1154
1284
  * a field it lacks. The files are compiled again only once one is added, removed or written
1155
- * (its name, `mtimeMs` or size changes): until then each call returns the same object, which
1156
- * callers must not change.
1285
+ * (its name, `mtimeMs` or size changes), or the people's data files change: until then each
1286
+ * call returns the same object, which callers must not change.
1157
1287
  */
1158
1288
  export async function loadScenarios(scope: Scope): Promise<LoadedScenarios> {
1159
1289
  if (!scope.scenarios) return { scenarios: [], errors: [] };
@@ -1169,10 +1299,12 @@ export async function loadScenarios(scope: Scope): Promise<LoadedScenarios> {
1169
1299
  return [file, mtimeMs, size];
1170
1300
  }),
1171
1301
  ]);
1302
+ // None without a root, which declares no one.
1303
+ const people = scope.root === undefined ? undefined : readPeople(scope.root);
1172
1304
  const cached = loadedByScope.get(scope);
1173
- if (cached?.stamp === stamp) return cached.loaded;
1305
+ if (cached?.stamp === stamp && cached.people === people) return cached.loaded;
1174
1306
  const loaded = files.length ? compile(scope, dir, files) : Promise.resolve({ scenarios: [], errors: [] });
1175
- loadedByScope.set(scope, { stamp, loaded });
1307
+ loadedByScope.set(scope, { stamp, people, loaded });
1176
1308
  // A compile that throws (a bug) is not kept: the next call tries again.
1177
1309
  loaded.catch(() => {
1178
1310
  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. */