@pmelab/gtd 59.0.0 → 60.0.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 (90) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +42 -38
  3. package/claude/hooks/doors.ts +8 -4
  4. package/dist/chunks/flows-DENt0uWV.mjs +707 -0
  5. package/dist/chunks/{steering-CCNvngSH.mjs → steering-DF_BMadH.mjs} +292 -289
  6. package/dist/chunks/{testkit-CMBAAyRK.mjs → testkit-BvecVjQ0.mjs} +937 -772
  7. package/dist/chunks/{wire-C12dahrH.mjs → wire-DZ8R1RU9.mjs} +9 -2
  8. package/dist/flows.mjs +2 -676
  9. package/dist/gtd.bundle.mjs +1101 -1025
  10. package/dist/steering.mjs +2 -2
  11. package/dist/testkit/gherkin.mjs +371 -82
  12. package/dist/testkit.mjs +3 -2
  13. package/dist/web/main.js +234 -232
  14. package/dist/wire.mjs +2 -2
  15. package/dist/workflow-kit.mjs +3 -3
  16. package/package.json +1 -1
  17. package/skills/authoring/SKILL.md +68 -61
  18. package/src/cli/Cli.test.ts +311 -229
  19. package/src/cli/Cli.ts +486 -585
  20. package/src/cli/Help.test.ts +47 -0
  21. package/src/cli/Help.ts +70 -0
  22. package/src/cli/History.ts +9 -4
  23. package/src/cli/Judges.ts +1 -1
  24. package/src/cli/Output.test.ts +35 -0
  25. package/src/cli/Output.ts +31 -1
  26. package/src/cli/Program.test.ts +131 -82
  27. package/src/cli/Program.ts +197 -111
  28. package/src/edge/Edge.test.ts +64 -56
  29. package/src/edge/Edge.ts +4 -4
  30. package/src/emit/Emit.test.ts +1 -1
  31. package/src/emit/Emit.ts +1 -1
  32. package/src/flows/factory.test.ts +14 -0
  33. package/src/flows/factory.ts +25 -6
  34. package/src/flows/helpers.test.ts +35 -0
  35. package/src/flows/helpers.ts +19 -10
  36. package/src/flows/index.test.ts +66 -0
  37. package/src/flows/index.ts +109 -5
  38. package/src/flows/internal.ts +7 -0
  39. package/src/flows/runtime.test.ts +192 -1
  40. package/src/flows/runtime.ts +188 -54
  41. package/src/gherkin/git-control.feature +6 -6
  42. package/src/gherkin/index.test.ts +127 -0
  43. package/src/gherkin/index.ts +481 -98
  44. package/src/gherkin/messages.test.ts +124 -0
  45. package/src/gherkin/messages.ts +101 -0
  46. package/src/gherkin/more-steps.feature +170 -0
  47. package/src/gherkin/scenario-end.feature +101 -0
  48. package/src/gherkin/testkit.feature +83 -7
  49. package/src/install/Install.test.ts +7 -7
  50. package/src/install/Install.ts +8 -8
  51. package/src/replay/Replay.test.ts +13 -2
  52. package/src/replay/Replay.ts +19 -30
  53. package/src/replay/judgeDiffShape.test.ts +1 -1
  54. package/src/steering/Footnotes.test.ts +1 -1
  55. package/src/steering/SteeringFormats.test.ts +1 -1
  56. package/src/steering/freeform.test.ts +1 -1
  57. package/src/steering/index.test.ts +27 -1
  58. package/src/steering/index.ts +6 -104
  59. package/src/steering/internal.ts +11 -0
  60. package/src/steering/qa.test.ts +7 -2
  61. package/src/steering/reads.test.ts +42 -0
  62. package/src/steering/reads.ts +43 -0
  63. package/src/steering/registry.ts +93 -0
  64. package/src/steering/review.test.ts +2 -2
  65. package/src/step/planStart.test.ts +18 -16
  66. package/src/step/planStart.ts +9 -9
  67. package/src/testkit/TestRepo.test.ts +289 -6
  68. package/src/testkit/TestRepo.ts +317 -41
  69. package/src/testkit/imports.test.ts +58 -0
  70. package/src/testkit/imports.ts +102 -0
  71. package/src/testkit/index.test.ts +28 -0
  72. package/src/testkit/index.ts +14 -2
  73. package/src/twins/EmittedScriptRecognizer.ts +7 -14
  74. package/src/twins/EmittedScripts.test.ts +4 -2
  75. package/src/twins/InMemRepo.ts +1 -0
  76. package/src/wire/Land.ts +1 -1
  77. package/src/wire/Script.ts +11 -0
  78. package/src/wire/WireSchema.test.ts +3 -0
  79. package/src/wire/golden/1/script.json +4 -0
  80. package/src/wire/index.ts +1 -0
  81. package/src/workflow/SteeringFormats.test.ts +1 -1
  82. package/src/workflow/compile.ts +2 -2
  83. package/src/workflow/discovery.test.ts +3 -3
  84. package/src/workflow/load.test.ts +256 -116
  85. package/src/workflow/load.ts +175 -152
  86. package/src/workflow/skills.test.ts +5 -5
  87. package/src/workflow/skills.ts +1 -1
  88. package/src/workflow-kit/index.test.ts +25 -0
  89. package/src/workflow-kit/index.ts +37 -4
  90. package/src/workflow-kit/review.ts +2 -2
@@ -1,6 +1,8 @@
1
1
  // Generic quickpickle steps over `testRepo`: they name no workflow, so any
2
2
  // workflow's `.feature` can use them. A workflow's own steps live beside it.
3
3
  import {
4
+ After,
5
+ AfterAll,
4
6
  Given,
5
7
  QuickPickleWorld,
6
8
  Then,
@@ -16,15 +18,38 @@ import { pathToFileURL } from "node:url"
16
18
  import {
17
19
  runsItsScript,
18
20
  testRepo,
19
- type Actor,
20
21
  type CannedCommand,
22
+ type DriveOptions,
23
+ type ScriptedActor,
21
24
  type TestRepo,
22
25
  type WorkflowModule,
23
26
  } from "../testkit/index.js"
27
+ import {
28
+ assertContains,
29
+ assertLacks,
30
+ assertRestsAt,
31
+ refusal,
32
+ refusedWith,
33
+ restDescription,
34
+ unreachedList,
35
+ type Unreached,
36
+ } from "./messages.js"
37
+
38
+ // Kept on quickpickle's per-feature `common`: which steps a Background scripted,
39
+ // and whether any scenario reached each.
40
+ const BACKGROUND_ACTORS = "gtdBackgroundActors"
41
+
42
+ interface BackgroundActors {
43
+ readonly reached: Map<string, boolean>
44
+ // The feature file's vitest task, to tell whether every scenario ran.
45
+ file: { readonly tasks?: readonly unknown[] } | undefined
46
+ }
24
47
 
25
48
  export class TestkitWorld extends QuickPickleWorld {
26
49
  // Re-declared: under `nodenext` a subclass loses the base's members from TS's view.
27
50
  declare info: QuickPickleWorldInterface["info"]
51
+ declare common: QuickPickleWorldInterface["common"]
52
+ declare context: TestContext
28
53
 
29
54
  constructor(context: TestContext, info: InfoConstructor) {
30
55
  super(context, info)
@@ -33,11 +58,16 @@ export class TestkitWorld extends QuickPickleWorld {
33
58
  private gtdConfig: WorkflowModule | undefined = undefined
34
59
  private env: Record<string, string> = {}
35
60
  private commands: CannedCommand[] = []
36
- /** Per step, what its scripted actor does, in the order the scenario declared it. */
37
- actions = new Map<string, Actor[]>()
38
- verdicts = new Map<string, string>()
39
- /** Per judge step, a verdict for each text its questions may contain; the first match answers. */
40
- conditionalVerdicts = new Map<string, [text: string, verdict: string][]>()
61
+ private modules: Record<string, Readonly<Record<string, unknown>>> = {}
62
+ private actions = new Map<string, ScriptedActor[]>()
63
+ private verdicts = new Map<string, string>()
64
+ private conditionalVerdicts = new Map<string, [text: string, verdict: string][]>()
65
+ private prompts = new Map<string, string[]>()
66
+ private used = new Set<string>()
67
+ // A step a scenario step scripts is the scenario's, even if a Background scripted it too.
68
+ private origins = new Map<string, Unreached["origin"]>()
69
+ private backgroundScripted = new Set<string>()
70
+ private excused = new Set<string>()
41
71
  private created: TestRepo | undefined = undefined
42
72
 
43
73
  // The repository is created on first use and keeps the config and
@@ -66,6 +96,21 @@ export class TestkitWorld extends QuickPickleWorld {
66
96
  this.env[name] = value
67
97
  }
68
98
 
99
+ /** Unlike `setEnv`, also once the repository exists: gtd sees it from its next call on. */
100
+ changeEnv(name: string, value: string): void {
101
+ if (this.created === undefined) this.env[name] = value
102
+ else this.created.setEnv(name, value)
103
+ }
104
+
105
+ /**
106
+ * Lets a config given as source import `module` as `specifier`. A support
107
+ * file may call it for every scenario, e.g. from a `Before` hook.
108
+ */
109
+ importable(specifier: string, module: Readonly<Record<string, unknown>>): void {
110
+ this.assertNotCreated(`the module ${specifier}`)
111
+ this.modules[specifier] = module
112
+ }
113
+
69
114
  private assertNotCreated(what: string): void {
70
115
  if (this.created !== undefined) {
71
116
  throw new Error(`${what} must be set before any step that touches the repository`)
@@ -79,15 +124,87 @@ export class TestkitWorld extends QuickPickleWorld {
79
124
  workflow: this.gtdConfig,
80
125
  env: this.env,
81
126
  commands: this.commands,
127
+ modules: this.modules,
82
128
  }))
83
129
  }
84
130
 
85
- act(step: string, action: Actor): void {
131
+ // quickpickle numbers a (Rule) Background's steps negatively.
132
+ private scripting(step: string, replace = false): void {
133
+ const fromBackground = (this.info.stepIdx ?? 0) < 0
134
+ if (fromBackground) this.backgroundScripted.add(step)
135
+ if (!fromBackground || replace || !this.origins.has(step))
136
+ this.origins.set(step, fromBackground ? "Background" : "scenario")
137
+ }
138
+
139
+ /** Adds `action` to what `step`'s actor does at every visit. */
140
+ act(step: string, action: ScriptedActor): void {
141
+ this.scripting(step)
86
142
  this.actions.set(step, [...(this.actions.get(step) ?? []), action])
87
143
  }
88
144
 
89
- async drive(): Promise<void> {
90
- const actors: Record<string, Actor> = {}
145
+ /** Drops every action and prompt queued for `step`, leaving `action` alone. */
146
+ rescript(step: string, action: ScriptedActor): void {
147
+ this.scripting(step, true)
148
+ this.actions.set(step, [action])
149
+ this.prompts.delete(step)
150
+ }
151
+
152
+ /** Drops every action and prompt queued for `step`, so a drive rests there. */
153
+ unscript(step: string): void {
154
+ this.actions.delete(step)
155
+ this.prompts.delete(step)
156
+ }
157
+
158
+ /**
159
+ * `step`'s beat content must contain `text`. A scripted step is checked at
160
+ * every visit, before its actor acts; a step with nothing else scripted is
161
+ * not scripted at all, so a drive rests there and checks it at the rest.
162
+ */
163
+ expectPrompt(step: string, text: string): void {
164
+ this.scripting(step)
165
+ this.prompts.set(step, [...(this.prompts.get(step) ?? []), text])
166
+ }
167
+
168
+ /** Judge step `step` answers `verdict`, a JSON array. */
169
+ answer(step: string, verdict: string): void {
170
+ this.scripting(step)
171
+ this.verdicts.set(step, verdict)
172
+ }
173
+
174
+ /** Judge step `step` answers `verdict` when its questions contain `text`; the first match answers. */
175
+ answerWhen(step: string, text: string, verdict: string): void {
176
+ this.scripting(step)
177
+ this.conditionalVerdicts.set(step, [
178
+ ...(this.conditionalVerdicts.get(step) ?? []),
179
+ [text, verdict],
180
+ ])
181
+ }
182
+
183
+ private scripted(): Set<string> {
184
+ return new Set([
185
+ ...this.actions.keys(),
186
+ ...this.verdicts.keys(),
187
+ ...this.conditionalVerdicts.keys(),
188
+ ...this.prompts.keys(),
189
+ ])
190
+ }
191
+
192
+ /** The steps the scenario scripted, or expects a prompt at, that no drive has reached. */
193
+ neverUsed(): Unreached[] {
194
+ return [...this.scripted()]
195
+ .filter((step) => !this.used.has(step))
196
+ .map((step) => ({ step, origin: this.origins.get(step) ?? "scenario" }))
197
+ }
198
+
199
+ private checkPrompts(step: string, content: string): void {
200
+ for (const text of this.prompts.get(step) ?? []) {
201
+ if (!content.includes(text))
202
+ throw new Error(`step "${step}" was not prompted with "${text}":\n${content}`)
203
+ }
204
+ }
205
+
206
+ private actors(): Record<string, ScriptedActor> {
207
+ const actors: Record<string, ScriptedActor> = {}
91
208
  for (const [step, actions] of this.actions) {
92
209
  actors[step] = async (beat, repo) => {
93
210
  for (const action of actions) await action(beat, repo)
@@ -115,15 +232,119 @@ export class TestkitWorld extends QuickPickleWorld {
115
232
  return hit[1]
116
233
  }
117
234
  }
118
- await this.repo().drive(actors)
235
+ return actors
236
+ }
237
+
238
+ /**
239
+ * `strict: false` lets what a strict drive refuses pass. Every drive leaves
240
+ * scripted steps it never reached to `assertReached`: an earlier step may
241
+ * script one for a later drive.
242
+ */
243
+ async drive(options: DriveOptions = {}): Promise<void> {
244
+ try {
245
+ const actors = this.actors()
246
+ const tracked: Record<string, ScriptedActor> = {}
247
+ for (const [step, actor] of Object.entries(actors)) {
248
+ tracked[step] = (beat, repo) => {
249
+ this.used.add(step)
250
+ this.checkPrompts(step, beat.content)
251
+ return actor(beat, repo)
252
+ }
253
+ }
254
+ const rest = await this.repo().drive(tracked, { ...options, unreachedActors: "pass" })
255
+ if (rest.kind !== "stalled" && this.prompts.has(rest.state) && !(rest.state in actors)) {
256
+ this.used.add(rest.state)
257
+ this.checkPrompts(rest.state, rest.content)
258
+ }
259
+ } catch (error) {
260
+ // Where a refused drive would have gone is unknown, so every step it was handed is excused.
261
+ for (const step of this.scripted()) this.excused.add(step)
262
+ throw error
263
+ }
264
+ }
265
+
266
+ /**
267
+ * Fails on a step a scenario step scripted, or expects a prompt at, that no
268
+ * drive reached. Run at scenario end. A Background's steps are left to the
269
+ * feature's end, and the steps a refused drive was handed are excused.
270
+ */
271
+ async assertReached(): Promise<void> {
272
+ const never = this.neverUsed()
273
+ const dead = never.filter(
274
+ ({ step, origin }) => origin === "scenario" && !this.excused.has(step),
275
+ )
276
+ if (dead.length === 0) return
277
+ throw new Error(
278
+ `at scenario end, no drive reached the steps scripted for ${unreachedList(dead)} — script a step only for a drive that reaches it, or in the Background when only some scenarios reach it\n\n${await restDescription(() => this.repo(), never)}`,
279
+ )
280
+ }
281
+
282
+ /** Records, for the feature's end, which Background-scripted steps this scenario reached. */
283
+ recordBackground(): void {
284
+ const record = (this.common[BACKGROUND_ACTORS] ??= {
285
+ reached: new Map(),
286
+ file: undefined,
287
+ }) as BackgroundActors
288
+ record.file ??= (this.context as Partial<TestContext> | undefined)?.task?.file
289
+ // A failed scenario stopped early, so it cannot tell a dead actor from one it did not get to.
290
+ const failed = this.info.errors.length > 0
291
+ for (const step of this.backgroundScripted) {
292
+ record.reached.set(step, record.reached.get(step) === true || failed || this.used.has(step))
293
+ }
119
294
  }
120
295
  }
121
296
 
297
+ After(async (world: QuickPickleWorldInterface) => {
298
+ if (!(world instanceof TestkitWorld)) return
299
+ world.recordBackground()
300
+ // Only for a scenario whose steps all passed: a failed one already reports.
301
+ if (world.info.errors.length === 0) await world.assertReached()
302
+ })
303
+
304
+ // vitest marks a test it filtered out, or skipped, as not run.
305
+ const allRan = (tasks: readonly unknown[]): boolean =>
306
+ tasks.every((task) => {
307
+ const t = task as { mode?: string; result?: { state?: string }; tasks?: readonly unknown[] }
308
+ if (t.tasks !== undefined) return allRan(t.tasks)
309
+ return t.mode === "run" && t.result?.state !== "skip"
310
+ })
311
+
312
+ // Per feature: a Background step no scenario reached scripts nothing. Left out
313
+ // when a scenario did not run, since it may be the one that reaches it.
314
+ AfterAll((common: QuickPickleWorldInterface["common"]) => {
315
+ const record = common[BACKGROUND_ACTORS] as BackgroundActors | undefined
316
+ if (record === undefined) return
317
+ if (record.file?.tasks !== undefined && !allRan(record.file.tasks)) return
318
+ const dead = [...record.reached].filter(([, reached]) => !reached)
319
+ if (dead.length === 0) return
320
+ throw new Error(
321
+ `${common.info.feature}: no scenario reached the steps scripted for ${unreachedList(dead.map(([step]) => ({ step, origin: "Background" })))} — script a step in the Background only when some scenario reaches it`,
322
+ )
323
+ })
324
+
122
325
  setWorldConstructor(TestkitWorld)
123
326
 
124
327
  const withNewline = (content: string): string => (content.endsWith("\n") ? content : `${content}\n`)
125
328
 
126
- const args = (list: string): string[] => list.split(" ").filter((arg) => arg !== "")
329
+ // Space-separated; a word that opens with a quote runs to its closing quote,
330
+ // spaces included, so `--var 'title=a b'` is two arguments.
331
+ const args = (list: string): string[] =>
332
+ [...list.matchAll(/(["'])(.*?)\1(?=\s|$)|\S+/g)].map(([word, quote, inner]) =>
333
+ quote === undefined ? word : inner!,
334
+ )
335
+
336
+ /** An outcome step: a failure ends with where the process rests. */
337
+ const outcome =
338
+ <A extends unknown[]>(check: (world: TestkitWorld, ...rest: A) => unknown) =>
339
+ async (world: TestkitWorld, ...rest: A): Promise<void> => {
340
+ try {
341
+ await check(world, ...rest)
342
+ } catch (error) {
343
+ const failure = error instanceof Error ? error : new Error(String(error))
344
+ failure.message += `\n\n${await restDescription(() => world.repo(), world.neverUsed())}`
345
+ throw failure
346
+ }
347
+ }
127
348
 
128
349
  Given("a gtd config with:", (world: TestkitWorld, source: string) => {
129
350
  world.configure(withNewline(source))
@@ -141,12 +362,27 @@ Given("the gtd config ends with:", (world: TestkitWorld, source: string) => {
141
362
  })
142
363
 
143
364
  Given(
144
- "an environment variable {string} set to {string}",
365
+ "the environment variable {string} is {string}",
145
366
  (world: TestkitWorld, name: string, value: string) => {
146
367
  world.setEnv(name, value)
147
368
  },
148
369
  )
149
370
 
371
+ Given(
372
+ "from now on the environment variable {string} is {string}",
373
+ (world: TestkitWorld, name: string, value: string) => {
374
+ world.changeEnv(name, value)
375
+ },
376
+ )
377
+
378
+ // Imported by the test runner, like `a gtd config from the module`.
379
+ Given(
380
+ "the module {string} is importable as {string}",
381
+ async (world: TestkitWorld, path: string, specifier: string) => {
382
+ world.importable(specifier, { ...(await import(pathToFileURL(resolve(path)).href)) })
383
+ },
384
+ )
385
+
150
386
  Given("the command {string} exits {int}", (world: TestkitWorld, run: string, exit: number) => {
151
387
  world.command({ run, exit })
152
388
  })
@@ -158,10 +394,28 @@ Given(
158
394
  },
159
395
  )
160
396
 
397
+ Given(
398
+ "the command matching {string} exits {int}",
399
+ (world: TestkitWorld, pattern: string, exit: number) => {
400
+ world.command({ run: new RegExp(pattern), exit })
401
+ },
402
+ )
403
+
404
+ Given(
405
+ "the command matching {string} exits {int} with:",
406
+ (world: TestkitWorld, pattern: string, exit: number, output: string) => {
407
+ world.command({ run: new RegExp(pattern), exit, output: withNewline(output) })
408
+ },
409
+ )
410
+
161
411
  Given("a branch {string}", (world: TestkitWorld, name: string) => {
162
412
  world.repo().branch(name)
163
413
  })
164
414
 
415
+ Given("a branch {string} at {string}", (world: TestkitWorld, name: string, ref: string) => {
416
+ world.repo().branch(name, ref)
417
+ })
418
+
165
419
  Given("{string} is checked out", (world: TestkitWorld, name: string) => {
166
420
  world.repo().checkout(name)
167
421
  })
@@ -191,7 +445,7 @@ Given(
191
445
  },
192
446
  )
193
447
 
194
- Given("{string} gets appended:", (world: TestkitWorld, path: string, text: string) => {
448
+ Given("text is appended to {string}:", (world: TestkitWorld, path: string, text: string) => {
195
449
  const content = world.repo().read(path) ?? ""
196
450
  world.repo().write(path, `${content.trimEnd()}\n\n${text}\n`)
197
451
  })
@@ -210,12 +464,12 @@ Given("step {string} changes nothing", (world: TestkitWorld, step: string) => {
210
464
 
211
465
  // Unlike `changes nothing`, this drops every action already queued for the step.
212
466
  Given("step {string} from now on changes nothing", (world: TestkitWorld, step: string) => {
213
- world.actions.set(step, [() => undefined])
467
+ world.rescript(step, () => undefined)
214
468
  })
215
469
 
216
470
  // Lets a later visit rest at a step an earlier one had scripted.
217
471
  Given("step {string} is no longer scripted", (world: TestkitWorld, step: string) => {
218
- world.actions.delete(step)
472
+ world.unscript(step)
219
473
  })
220
474
 
221
475
  Given(
@@ -263,23 +517,19 @@ Given("step {string} deletes {string}", (world: TestkitWorld, step: string, path
263
517
  world.act(step, (_, repo) => repo.remove(path))
264
518
  })
265
519
 
266
- // A standing assertion like any queued action: it applies on every later visit
267
- // to the step until `from now on` clears the list.
520
+ // A standing assertion: it applies on every later visit to the step until
521
+ // `from now on` or `no longer scripted` clears it.
268
522
  Given(
269
523
  "step {string} is prompted with {string}",
270
524
  (world: TestkitWorld, step: string, text: string) => {
271
- world.act(step, (beat) => {
272
- if (!beat.content.includes(text)) {
273
- throw new Error(`step "${step}" was not prompted with "${text}":\n${beat.content}`)
274
- }
275
- })
525
+ world.expectPrompt(step, text)
276
526
  },
277
527
  )
278
528
 
279
529
  // A closing step deletes the file before its collecting turn runs, so this edit
280
530
  // leaves a closed turn changing nothing while still answering an in-gate one.
281
531
  Given(
282
- "step {string} rewrites {string} with:",
532
+ "step {string} overwrites {string}, if it exists, with:",
283
533
  (world: TestkitWorld, step: string, path: string, content: string) => {
284
534
  world.act(step, (_, repo) => {
285
535
  if (repo.read(path) !== undefined) repo.write(path, withNewline(content))
@@ -291,17 +541,31 @@ Given("step {string} runs its script", (world: TestkitWorld, step: string) => {
291
541
  world.act(step, runsItsScript)
292
542
  })
293
543
 
544
+ /** A check step's script, every command it runs answering as `command` does. */
545
+ const checkRuns = (world: TestkitWorld, step: string, command: Omit<CannedCommand, "run">) => {
546
+ world.act(step, (beat, repo) => {
547
+ if (beat.actor !== "check")
548
+ throw new Error(`step "${step}" is no check step; its actor is ${beat.actor}`)
549
+ repo.runScript(beat.content, { commands: [{ ...command, run: /[\s\S]*/ }] })
550
+ })
551
+ }
552
+
553
+ Given("check step {string} passes", (world: TestkitWorld, step: string) => {
554
+ checkRuns(world, step, {})
555
+ })
556
+
557
+ Given("check step {string} fails with:", (world: TestkitWorld, step: string, output: string) => {
558
+ checkRuns(world, step, { exit: 1, output: withNewline(output) })
559
+ })
560
+
294
561
  Given("judge step {string} answers:", (world: TestkitWorld, step: string, verdict: string) => {
295
- world.verdicts.set(step, String(verdict))
562
+ world.answer(step, String(verdict))
296
563
  })
297
564
 
298
565
  Given(
299
566
  "judge step {string}, when its questions contain {string}, answers:",
300
567
  (world: TestkitWorld, step: string, text: string, verdict: string) => {
301
- world.conditionalVerdicts.set(step, [
302
- ...(world.conditionalVerdicts.get(step) ?? []),
303
- [text, String(verdict)],
304
- ])
568
+ world.answerWhen(step, text, String(verdict))
305
569
  },
306
570
  )
307
571
 
@@ -335,76 +599,172 @@ When("the process is driven", async (world: TestkitWorld) => {
335
599
  await world.drive()
336
600
  })
337
601
 
602
+ When("the process is driven leniently", async (world: TestkitWorld) => {
603
+ await world.drive({ strict: false })
604
+ })
605
+
338
606
  Then(
339
607
  "opening door {string} is refused with {string}",
340
- async (world: TestkitWorld, door: string, message: string) => {
341
- await assert.rejects(world.repo().door(door), (error: Error) => error.message.includes(message))
342
- },
608
+ outcome(async (world, door: string, message: string) => {
609
+ await refusedWith(world.repo().door(door), message)
610
+ }),
343
611
  )
344
612
 
345
613
  Then(
346
614
  "starting workflow {string} with {string} is refused with {string}",
347
- async (world: TestkitWorld, workflow: string, list: string, message: string) => {
348
- await assert.rejects(world.repo().start(workflow, ...args(list)), (error: Error) =>
349
- error.message.includes(message),
350
- )
351
- },
615
+ outcome(async (world, workflow: string, list: string, message: string) => {
616
+ await refusedWith(world.repo().start(workflow, ...args(list)), message)
617
+ }),
352
618
  )
353
619
 
354
- Then("landing is refused with {string}", async (world: TestkitWorld, message: string) => {
355
- await assert.rejects(world.repo().land(), (error: Error) => error.message.includes(message))
356
- })
620
+ Then(
621
+ "landing is refused with {string}",
622
+ outcome(async (world, message: string) => {
623
+ await refusedWith(world.repo().land(), message)
624
+ }),
625
+ )
357
626
 
358
627
  Then(
359
628
  "driving the process is refused with {string}",
360
- async (world: TestkitWorld, message: string) => {
361
- await assert.rejects(world.drive(), (error: Error) => error.message.includes(message))
362
- },
629
+ outcome(async (world, message: string) => {
630
+ await refusedWith(world.drive(), message)
631
+ }),
363
632
  )
364
633
 
365
634
  Then(
366
635
  "driving the process is refused without {string}",
367
- async (world: TestkitWorld, message: string) => {
368
- await assert.rejects(world.drive(), (error: Error) => !error.message.includes(message))
369
- },
636
+ outcome(async (world, message: string) => {
637
+ const got = await refusal(world.drive(), `without "${message}"`)
638
+ if (got.includes(message))
639
+ assert.fail(`expected a refusal without "${message}", but it was refused with:\n${got}`)
640
+ }),
370
641
  )
371
642
 
372
- Then("the process rests at {string}", async (world: TestkitWorld, step: string) => {
373
- assert.strictEqual((await world.repo().next()).state, step)
374
- })
643
+ Then(
644
+ "the process rests at {string}",
645
+ outcome(async (world, step: string) => {
646
+ assertRestsAt(await world.repo().next(), step)
647
+ }),
648
+ )
375
649
 
376
- Then("the resting step's content contains {string}", async (world: TestkitWorld, text: string) => {
377
- const { content } = await world.repo().next()
378
- assert.ok(content.includes(text), content)
379
- })
650
+ Then(
651
+ "the process stalls at {string}",
652
+ outcome(async (world, step: string) => {
653
+ const beat = await world.repo().next()
654
+ if (beat.kind !== "stalled" || beat.state !== step)
655
+ assert.fail(
656
+ `expected a stall at "${step}", but the process rests at "${beat.state}" (${beat.kind})`,
657
+ )
658
+ }),
659
+ )
660
+
661
+ /** A beat field by its dotted path (`model`, `access.write`), as text: a string as is, anything else as JSON. */
662
+ const beatField = async (world: TestkitWorld, path: string): Promise<unknown> => {
663
+ let value: unknown = await world.repo().next()
664
+ for (const key of path.split(".")) {
665
+ value =
666
+ typeof value === "object" && value !== null
667
+ ? (value as Record<string, unknown>)[key]
668
+ : undefined
669
+ }
670
+ return value
671
+ }
672
+
673
+ const asText = (value: unknown): string =>
674
+ typeof value === "string" ? value : (JSON.stringify(value) ?? "undefined")
675
+
676
+ Then(
677
+ "the resting step's {word} is {string}",
678
+ outcome(async (world, path: string, expected: string) => {
679
+ const value = asText(await beatField(world, path))
680
+ if (value !== expected)
681
+ assert.fail(`the resting step's ${path} is not "${expected}"; it is:\n${value}`)
682
+ }),
683
+ )
684
+
685
+ Then(
686
+ "the resting step's {word} includes {string}",
687
+ outcome(async (world, path: string, expected: string) => {
688
+ const value = await beatField(world, path)
689
+ const found = Array.isArray(value)
690
+ ? value.some((item) => asText(item) === expected)
691
+ : typeof value === "string" && value.includes(expected)
692
+ if (!found)
693
+ assert.fail(
694
+ `the resting step's ${path} does not include "${expected}"; it is:\n${asText(value)}`,
695
+ )
696
+ }),
697
+ )
698
+
699
+ Then(
700
+ "the resting step's content contains {string}",
701
+ outcome(async (world, text: string) => {
702
+ assertContains("the resting step's content", text, (await world.repo().next()).content)
703
+ }),
704
+ )
380
705
 
381
706
  Then(
382
707
  "the resting step's content contains the hash of the commit {string}",
383
- async (world: TestkitWorld, subject: string) => {
708
+ outcome(async (world, subject: string) => {
384
709
  const repo = world.repo()
385
710
  const hash = repo.hashes()[repo.subjects().lastIndexOf(subject)]
386
711
  assert.ok(hash !== undefined, `no commit "${subject}"`)
387
- const { content } = await repo.next()
388
- assert.ok(content.includes(hash), content)
389
- },
712
+ assertContains("the resting step's content", hash, (await repo.next()).content)
713
+ }),
390
714
  )
391
715
 
392
716
  Then(
393
717
  "the resting step's content does not contain {string}",
394
- async (world: TestkitWorld, text: string) => {
395
- const { content } = await world.repo().next()
396
- assert.ok(!content.includes(text), content)
397
- },
718
+ outcome(async (world, text: string) => {
719
+ assertLacks("the resting step's content", text, (await world.repo().next()).content)
720
+ }),
398
721
  )
399
722
 
400
- Then("the last commit subject is {string}", (world: TestkitWorld, subject: string) => {
401
- assert.strictEqual(world.repo().subjects().at(-1), subject)
402
- })
723
+ Then(
724
+ "the last commit subject is {string}",
725
+ outcome((world, subject: string) => {
726
+ assert.strictEqual(world.repo().subjects().at(-1), subject)
727
+ }),
728
+ )
403
729
 
404
- Then("the commit subjects end with:", (world: TestkitWorld, list: string) => {
405
- const expected = list.split("\n").filter((line) => line.trim() !== "")
406
- assert.deepStrictEqual(world.repo().subjects().slice(-expected.length), expected)
407
- })
730
+ Then(
731
+ "the commit subjects end with:",
732
+ outcome((world, list: string) => {
733
+ const expected = list.split("\n").filter((line) => line.trim() !== "")
734
+ assert.deepStrictEqual(world.repo().subjects().slice(-expected.length), expected)
735
+ }),
736
+ )
737
+
738
+ Then(
739
+ "the last commit body contains {string}",
740
+ outcome((world, text: string) => {
741
+ assertContains("the last commit's message", text, world.repo().messages().at(-1))
742
+ }),
743
+ )
744
+
745
+ // Every commit with the subject, so a repeated step is not judged by its first match.
746
+ Then(
747
+ "the commit {string} has a body containing {string}",
748
+ outcome((world, subject: string, text: string) => {
749
+ const matching = world
750
+ .repo()
751
+ .commits()
752
+ .filter((commit) => commit.subject === subject)
753
+ assert.ok(matching.length > 0, `no commit "${subject}"`)
754
+ for (const { message } of matching) assertContains(`the commit "${subject}"`, text, message)
755
+ }),
756
+ )
757
+
758
+ Then(
759
+ "no commit body contains {string}",
760
+ outcome((world, text: string) => {
761
+ const hit = world
762
+ .repo()
763
+ .messages()
764
+ .find((message) => message.includes(text))
765
+ if (hit !== undefined) assert.fail(`a commit's message contains "${text}":\n${hit}`)
766
+ }),
767
+ )
408
768
 
409
769
  const outputOf = async (world: TestkitWorld, list: string): Promise<string> => {
410
770
  const result = await world.repo().gtd(...args(list))
@@ -414,61 +774,84 @@ const outputOf = async (world: TestkitWorld, list: string): Promise<string> => {
414
774
 
415
775
  Then(
416
776
  "the output of gtd {string} contains {string}",
417
- async (world: TestkitWorld, list: string, text: string) => {
418
- const out = await outputOf(world, list)
419
- assert.ok(out.includes(text), out)
420
- },
777
+ outcome(async (world, list: string, text: string) => {
778
+ assertContains(`the output of gtd ${list}`, text, await outputOf(world, list))
779
+ }),
421
780
  )
422
781
 
423
782
  Then(
424
783
  "the output of gtd {string} does not contain {string}",
425
- async (world: TestkitWorld, list: string, text: string) => {
426
- const out = await outputOf(world, list)
427
- assert.ok(!out.includes(text), out)
428
- },
784
+ outcome(async (world, list: string, text: string) => {
785
+ assertLacks(`the output of gtd ${list}`, text, await outputOf(world, list))
786
+ }),
429
787
  )
430
788
 
431
789
  Then(
432
790
  "the output lines of gtd {string} start with:",
433
- async (world: TestkitWorld, list: string, prefixes: string) => {
791
+ outcome(async (world, list: string, prefixes: string) => {
434
792
  const out = (await outputOf(world, list)).split("\n").filter((line) => line !== "")
435
793
  const expected = prefixes.split("\n").filter((line) => line.trim() !== "")
436
- assert.strictEqual(out.length, expected.length, out.join("\n"))
437
- out.forEach((line, i) => assert.ok(line.startsWith(expected[i]!), out.join("\n")))
438
- },
794
+ const sides = `expected lines starting with:\n${expected.join("\n")}\ngot:\n${out.join("\n")}`
795
+ assert.strictEqual(out.length, expected.length, sides)
796
+ out.forEach((line, i) => assert.ok(line.startsWith(expected[i]!), sides))
797
+ }),
439
798
  )
440
799
 
441
- Then("{string} contains {string}", (world: TestkitWorld, path: string, text: string) => {
442
- assert.ok(world.repo().read(path)?.includes(text), `${path}: ${world.repo().read(path)}`)
443
- })
800
+ Then(
801
+ "{string} contains {string}",
802
+ outcome((world, path: string, text: string) => {
803
+ assertContains(`"${path}"`, text, world.repo().read(path))
804
+ }),
805
+ )
444
806
 
445
- Then("{string} contains:", (world: TestkitWorld, path: string, text: string) => {
446
- assert.ok(world.repo().read(path)?.includes(text), `${path}: ${world.repo().read(path)}`)
447
- })
807
+ Then(
808
+ "{string} contains:",
809
+ outcome((world, path: string, text: string) => {
810
+ assertContains(`"${path}"`, text, world.repo().read(path))
811
+ }),
812
+ )
448
813
 
449
814
  Then(
450
815
  "{string} contains the short hash of the commit {string}",
451
- (world: TestkitWorld, path: string, subject: string) => {
816
+ outcome((world, path: string, subject: string) => {
452
817
  const repo = world.repo()
453
818
  const hash = repo.hashes()[repo.subjects().lastIndexOf(subject)]
454
819
  assert.ok(hash !== undefined, `no commit "${subject}"`)
455
- assert.ok(repo.read(path)?.includes(hash.slice(0, 7)), `${path}: ${repo.read(path)}`)
456
- },
820
+ assertContains(`"${path}"`, hash.slice(0, 7), repo.read(path))
821
+ }),
822
+ )
823
+
824
+ Then(
825
+ "{string} as committed at {string} contains {string}",
826
+ outcome((world, path: string, ref: string, text: string) => {
827
+ assertContains(`"${path}" at ${ref}`, text, world.repo().show(ref, path))
828
+ }),
457
829
  )
458
830
 
459
831
  Then(
460
832
  "{string} contains {string} exactly once",
461
- (world: TestkitWorld, path: string, text: string) => {
833
+ outcome((world, path: string, text: string) => {
462
834
  const content = world.repo().read(path) ?? ""
463
- assert.strictEqual(content.split(text).length - 1, 1, `${path}: ${content}`)
464
- },
835
+ const times = content.split(text).length - 1
836
+ assert.strictEqual(
837
+ times,
838
+ 1,
839
+ `"${path}" contains "${text}" ${times} times; it holds:\n${content}`,
840
+ )
841
+ }),
465
842
  )
466
843
 
467
- Then("{string} does not contain {string}", (world: TestkitWorld, path: string, text: string) => {
468
- const content = world.repo().read(path)
469
- assert.ok(content !== undefined && !content.includes(text), `${path}: ${content}`)
470
- })
844
+ Then(
845
+ "{string} does not contain {string}",
846
+ outcome((world, path: string, text: string) => {
847
+ assertLacks(`"${path}"`, text, world.repo().read(path))
848
+ }),
849
+ )
471
850
 
472
- Then("{string} does not exist", (world: TestkitWorld, path: string) => {
473
- assert.strictEqual(world.repo().read(path), undefined)
474
- })
851
+ Then(
852
+ "{string} does not exist",
853
+ outcome((world, path: string) => {
854
+ const content = world.repo().read(path)
855
+ assert.strictEqual(content, undefined, `"${path}" exists; it holds:\n${content}`)
856
+ }),
857
+ )