@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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +42 -38
- package/claude/hooks/doors.ts +8 -4
- package/dist/chunks/flows-DENt0uWV.mjs +707 -0
- package/dist/chunks/{steering-CCNvngSH.mjs → steering-DF_BMadH.mjs} +292 -289
- package/dist/chunks/{testkit-CMBAAyRK.mjs → testkit-BvecVjQ0.mjs} +937 -772
- package/dist/chunks/{wire-C12dahrH.mjs → wire-DZ8R1RU9.mjs} +9 -2
- package/dist/flows.mjs +2 -676
- package/dist/gtd.bundle.mjs +1101 -1025
- package/dist/steering.mjs +2 -2
- package/dist/testkit/gherkin.mjs +371 -82
- package/dist/testkit.mjs +3 -2
- package/dist/web/main.js +234 -232
- package/dist/wire.mjs +2 -2
- package/dist/workflow-kit.mjs +3 -3
- package/package.json +1 -1
- package/skills/authoring/SKILL.md +68 -61
- package/src/cli/Cli.test.ts +311 -229
- package/src/cli/Cli.ts +486 -585
- package/src/cli/Help.test.ts +47 -0
- package/src/cli/Help.ts +70 -0
- package/src/cli/History.ts +9 -4
- package/src/cli/Judges.ts +1 -1
- package/src/cli/Output.test.ts +35 -0
- package/src/cli/Output.ts +31 -1
- package/src/cli/Program.test.ts +131 -82
- package/src/cli/Program.ts +197 -111
- package/src/edge/Edge.test.ts +64 -56
- package/src/edge/Edge.ts +4 -4
- package/src/emit/Emit.test.ts +1 -1
- package/src/emit/Emit.ts +1 -1
- package/src/flows/factory.test.ts +14 -0
- package/src/flows/factory.ts +25 -6
- package/src/flows/helpers.test.ts +35 -0
- package/src/flows/helpers.ts +19 -10
- package/src/flows/index.test.ts +66 -0
- package/src/flows/index.ts +109 -5
- package/src/flows/internal.ts +7 -0
- package/src/flows/runtime.test.ts +192 -1
- package/src/flows/runtime.ts +188 -54
- package/src/gherkin/git-control.feature +6 -6
- package/src/gherkin/index.test.ts +127 -0
- package/src/gherkin/index.ts +481 -98
- package/src/gherkin/messages.test.ts +124 -0
- package/src/gherkin/messages.ts +101 -0
- package/src/gherkin/more-steps.feature +170 -0
- package/src/gherkin/scenario-end.feature +101 -0
- package/src/gherkin/testkit.feature +83 -7
- package/src/install/Install.test.ts +7 -7
- package/src/install/Install.ts +8 -8
- package/src/replay/Replay.test.ts +13 -2
- package/src/replay/Replay.ts +19 -30
- package/src/replay/judgeDiffShape.test.ts +1 -1
- package/src/steering/Footnotes.test.ts +1 -1
- package/src/steering/SteeringFormats.test.ts +1 -1
- package/src/steering/freeform.test.ts +1 -1
- package/src/steering/index.test.ts +27 -1
- package/src/steering/index.ts +6 -104
- package/src/steering/internal.ts +11 -0
- package/src/steering/qa.test.ts +7 -2
- package/src/steering/reads.test.ts +42 -0
- package/src/steering/reads.ts +43 -0
- package/src/steering/registry.ts +93 -0
- package/src/steering/review.test.ts +2 -2
- package/src/step/planStart.test.ts +18 -16
- package/src/step/planStart.ts +9 -9
- package/src/testkit/TestRepo.test.ts +289 -6
- package/src/testkit/TestRepo.ts +317 -41
- package/src/testkit/imports.test.ts +58 -0
- package/src/testkit/imports.ts +102 -0
- package/src/testkit/index.test.ts +28 -0
- package/src/testkit/index.ts +14 -2
- package/src/twins/EmittedScriptRecognizer.ts +7 -14
- package/src/twins/EmittedScripts.test.ts +4 -2
- package/src/twins/InMemRepo.ts +1 -0
- package/src/wire/Land.ts +1 -1
- package/src/wire/Script.ts +11 -0
- package/src/wire/WireSchema.test.ts +3 -0
- package/src/wire/golden/1/script.json +4 -0
- package/src/wire/index.ts +1 -0
- package/src/workflow/SteeringFormats.test.ts +1 -1
- package/src/workflow/compile.ts +2 -2
- package/src/workflow/discovery.test.ts +3 -3
- package/src/workflow/load.test.ts +256 -116
- package/src/workflow/load.ts +175 -152
- package/src/workflow/skills.test.ts +5 -5
- package/src/workflow/skills.ts +1 -1
- package/src/workflow-kit/index.test.ts +25 -0
- package/src/workflow-kit/index.ts +37 -4
- package/src/workflow-kit/review.ts +2 -2
package/src/gherkin/index.ts
CHANGED
|
@@ -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
|
-
|
|
37
|
-
actions = new Map<string,
|
|
38
|
-
verdicts = new Map<string, string>()
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
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
|
-
|
|
90
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
"
|
|
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}
|
|
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.
|
|
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.
|
|
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
|
|
267
|
-
//
|
|
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.
|
|
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}
|
|
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.
|
|
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.
|
|
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
|
|
341
|
-
await
|
|
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
|
|
348
|
-
await
|
|
349
|
-
|
|
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(
|
|
355
|
-
|
|
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
|
|
361
|
-
await
|
|
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
|
|
368
|
-
await
|
|
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(
|
|
373
|
-
|
|
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(
|
|
377
|
-
|
|
378
|
-
|
|
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
|
|
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
|
-
|
|
388
|
-
|
|
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
|
|
395
|
-
|
|
396
|
-
|
|
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(
|
|
401
|
-
|
|
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(
|
|
405
|
-
|
|
406
|
-
|
|
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
|
|
418
|
-
|
|
419
|
-
|
|
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
|
|
426
|
-
|
|
427
|
-
|
|
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
|
|
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
|
-
|
|
437
|
-
out.
|
|
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(
|
|
442
|
-
|
|
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(
|
|
446
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
833
|
+
outcome((world, path: string, text: string) => {
|
|
462
834
|
const content = world.repo().read(path) ?? ""
|
|
463
|
-
|
|
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(
|
|
468
|
-
|
|
469
|
-
|
|
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(
|
|
473
|
-
|
|
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
|
+
)
|