@pmelab/gtd 59.0.1 → 60.1.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 +48 -32
- 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-BhTey1Do.mjs +30517 -0
- package/dist/chunks/{wire-BTUhRGXj.mjs → wire-DZ8R1RU9.mjs} +8 -1
- package/dist/flows.mjs +2 -676
- package/dist/gtd.bundle.mjs +11285 -19864
- package/dist/gtd.core.mjs +593 -0
- package/dist/steering.mjs +2 -2
- package/dist/testkit/gherkin.mjs +246 -37
- 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 +7 -7
- package/skills/authoring/SKILL.md +66 -60
- package/src/cli/Cli.test.ts +185 -184
- package/src/cli/Cli.ts +219 -224
- 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 +34 -3
- package/src/cli/Program.test.ts +78 -84
- package/src/cli/Program.ts +192 -128
- package/src/cli/ToolCommands.ts +2 -2
- package/src/edge/Edge.test.ts +72 -62
- package/src/edge/Edge.ts +6 -6
- package/src/flows/factory.test.ts +14 -0
- package/src/flows/factory.ts +22 -5
- 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 +189 -55
- package/src/gherkin/git-control.feature +6 -6
- package/src/gherkin/index.test.ts +127 -0
- package/src/gherkin/index.ts +370 -51
- package/src/gherkin/messages.test.ts +38 -4
- package/src/gherkin/messages.ts +21 -4
- 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/install/Version.ts +9 -3
- package/src/install/index.ts +1 -1
- package/src/platform/CommandRunner.ts +13 -11
- package/src/platform/Commentary.ts +2 -2
- package/src/platform/Git.test.ts +4 -5
- package/src/platform/Git.ts +42 -38
- package/src/platform/Host.ts +1 -1
- package/src/platform/NodeServices.ts +11 -0
- package/src/platform/Workspace.test.ts +4 -5
- package/src/platform/Workspace.ts +3 -3
- package/src/platform/WorktreeState.test.ts +2 -3
- package/src/platform/WorktreeState.ts +1 -1
- package/src/platform/index.ts +2 -1
- 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 +12 -12
- package/src/testkit/TestRepo.test.ts +245 -7
- package/src/testkit/TestRepo.ts +271 -40
- 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 +2 -6
- package/src/twins/EmittedScripts.test.ts +5 -3
- package/src/twins/GitTiers.ts +6 -7
- package/src/twins/InMemRepo.ts +1 -0
- package/src/twins/Layers.test.ts +1 -1
- package/src/twins/Layers.ts +10 -3
- package/src/twins/cliIo.ts +9 -4
- 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 +27 -10
- package/src/workflow/discovery.ts +5 -6
- package/src/workflow/index.ts +1 -0
- package/src/workflow/load.test.ts +316 -119
- package/src/workflow/load.ts +478 -305
- package/src/workflow/pin.test.ts +159 -0
- package/src/workflow/pin.ts +72 -0
- package/src/workflow/vars.ts +3 -3
- 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/dist/chunks/testkit-07Z_ENch.mjs +0 -32175
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,19 +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"
|
|
24
|
-
import {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
+
}
|
|
28
47
|
|
|
29
48
|
export class TestkitWorld extends QuickPickleWorld {
|
|
30
49
|
// Re-declared: under `nodenext` a subclass loses the base's members from TS's view.
|
|
31
50
|
declare info: QuickPickleWorldInterface["info"]
|
|
51
|
+
declare common: QuickPickleWorldInterface["common"]
|
|
52
|
+
declare context: TestContext
|
|
32
53
|
|
|
33
54
|
constructor(context: TestContext, info: InfoConstructor) {
|
|
34
55
|
super(context, info)
|
|
@@ -37,11 +58,16 @@ export class TestkitWorld extends QuickPickleWorld {
|
|
|
37
58
|
private gtdConfig: WorkflowModule | undefined = undefined
|
|
38
59
|
private env: Record<string, string> = {}
|
|
39
60
|
private commands: CannedCommand[] = []
|
|
40
|
-
|
|
41
|
-
actions = new Map<string,
|
|
42
|
-
verdicts = new Map<string, string>()
|
|
43
|
-
|
|
44
|
-
|
|
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>()
|
|
45
71
|
private created: TestRepo | undefined = undefined
|
|
46
72
|
|
|
47
73
|
// The repository is created on first use and keeps the config and
|
|
@@ -70,6 +96,21 @@ export class TestkitWorld extends QuickPickleWorld {
|
|
|
70
96
|
this.env[name] = value
|
|
71
97
|
}
|
|
72
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
|
+
|
|
73
114
|
private assertNotCreated(what: string): void {
|
|
74
115
|
if (this.created !== undefined) {
|
|
75
116
|
throw new Error(`${what} must be set before any step that touches the repository`)
|
|
@@ -83,17 +124,87 @@ export class TestkitWorld extends QuickPickleWorld {
|
|
|
83
124
|
workflow: this.gtdConfig,
|
|
84
125
|
env: this.env,
|
|
85
126
|
commands: this.commands,
|
|
127
|
+
modules: this.modules,
|
|
86
128
|
}))
|
|
87
129
|
}
|
|
88
130
|
|
|
89
|
-
|
|
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)
|
|
90
142
|
this.actions.set(step, [...(this.actions.get(step) ?? []), action])
|
|
91
143
|
}
|
|
92
144
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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> = {}
|
|
97
208
|
for (const [step, actions] of this.actions) {
|
|
98
209
|
actors[step] = async (beat, repo) => {
|
|
99
210
|
for (const action of actions) await action(beat, repo)
|
|
@@ -121,32 +232,106 @@ export class TestkitWorld extends QuickPickleWorld {
|
|
|
121
232
|
return hit[1]
|
|
122
233
|
}
|
|
123
234
|
}
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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)
|
|
129
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))
|
|
130
293
|
}
|
|
131
|
-
await this.repo().drive(tracked)
|
|
132
294
|
}
|
|
133
295
|
}
|
|
134
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
|
+
|
|
135
325
|
setWorldConstructor(TestkitWorld)
|
|
136
326
|
|
|
137
327
|
const withNewline = (content: string): string => (content.endsWith("\n") ? content : `${content}\n`)
|
|
138
328
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
const
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
...world.verdicts.keys(),
|
|
146
|
-
...world.conditionalVerdicts.keys(),
|
|
147
|
-
])
|
|
148
|
-
return [...scripted].filter((step) => !used.has(step))
|
|
149
|
-
}
|
|
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
|
+
)
|
|
150
335
|
|
|
151
336
|
/** An outcome step: a failure ends with where the process rests. */
|
|
152
337
|
const outcome =
|
|
@@ -156,7 +341,7 @@ const outcome =
|
|
|
156
341
|
await check(world, ...rest)
|
|
157
342
|
} catch (error) {
|
|
158
343
|
const failure = error instanceof Error ? error : new Error(String(error))
|
|
159
|
-
failure.message += `\n\n${await restDescription(() => world.repo(), neverUsed(
|
|
344
|
+
failure.message += `\n\n${await restDescription(() => world.repo(), world.neverUsed())}`
|
|
160
345
|
throw failure
|
|
161
346
|
}
|
|
162
347
|
}
|
|
@@ -177,12 +362,27 @@ Given("the gtd config ends with:", (world: TestkitWorld, source: string) => {
|
|
|
177
362
|
})
|
|
178
363
|
|
|
179
364
|
Given(
|
|
180
|
-
"
|
|
365
|
+
"the environment variable {string} is {string}",
|
|
181
366
|
(world: TestkitWorld, name: string, value: string) => {
|
|
182
367
|
world.setEnv(name, value)
|
|
183
368
|
},
|
|
184
369
|
)
|
|
185
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
|
+
|
|
186
386
|
Given("the command {string} exits {int}", (world: TestkitWorld, run: string, exit: number) => {
|
|
187
387
|
world.command({ run, exit })
|
|
188
388
|
})
|
|
@@ -194,10 +394,28 @@ Given(
|
|
|
194
394
|
},
|
|
195
395
|
)
|
|
196
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
|
+
|
|
197
411
|
Given("a branch {string}", (world: TestkitWorld, name: string) => {
|
|
198
412
|
world.repo().branch(name)
|
|
199
413
|
})
|
|
200
414
|
|
|
415
|
+
Given("a branch {string} at {string}", (world: TestkitWorld, name: string, ref: string) => {
|
|
416
|
+
world.repo().branch(name, ref)
|
|
417
|
+
})
|
|
418
|
+
|
|
201
419
|
Given("{string} is checked out", (world: TestkitWorld, name: string) => {
|
|
202
420
|
world.repo().checkout(name)
|
|
203
421
|
})
|
|
@@ -227,7 +445,7 @@ Given(
|
|
|
227
445
|
},
|
|
228
446
|
)
|
|
229
447
|
|
|
230
|
-
Given("{string}
|
|
448
|
+
Given("text is appended to {string}:", (world: TestkitWorld, path: string, text: string) => {
|
|
231
449
|
const content = world.repo().read(path) ?? ""
|
|
232
450
|
world.repo().write(path, `${content.trimEnd()}\n\n${text}\n`)
|
|
233
451
|
})
|
|
@@ -246,12 +464,12 @@ Given("step {string} changes nothing", (world: TestkitWorld, step: string) => {
|
|
|
246
464
|
|
|
247
465
|
// Unlike `changes nothing`, this drops every action already queued for the step.
|
|
248
466
|
Given("step {string} from now on changes nothing", (world: TestkitWorld, step: string) => {
|
|
249
|
-
world.
|
|
467
|
+
world.rescript(step, () => undefined)
|
|
250
468
|
})
|
|
251
469
|
|
|
252
470
|
// Lets a later visit rest at a step an earlier one had scripted.
|
|
253
471
|
Given("step {string} is no longer scripted", (world: TestkitWorld, step: string) => {
|
|
254
|
-
world.
|
|
472
|
+
world.unscript(step)
|
|
255
473
|
})
|
|
256
474
|
|
|
257
475
|
Given(
|
|
@@ -299,23 +517,19 @@ Given("step {string} deletes {string}", (world: TestkitWorld, step: string, path
|
|
|
299
517
|
world.act(step, (_, repo) => repo.remove(path))
|
|
300
518
|
})
|
|
301
519
|
|
|
302
|
-
// A standing assertion
|
|
303
|
-
//
|
|
520
|
+
// A standing assertion: it applies on every later visit to the step until
|
|
521
|
+
// `from now on` or `no longer scripted` clears it.
|
|
304
522
|
Given(
|
|
305
523
|
"step {string} is prompted with {string}",
|
|
306
524
|
(world: TestkitWorld, step: string, text: string) => {
|
|
307
|
-
world.
|
|
308
|
-
if (!beat.content.includes(text)) {
|
|
309
|
-
throw new Error(`step "${step}" was not prompted with "${text}":\n${beat.content}`)
|
|
310
|
-
}
|
|
311
|
-
})
|
|
525
|
+
world.expectPrompt(step, text)
|
|
312
526
|
},
|
|
313
527
|
)
|
|
314
528
|
|
|
315
529
|
// A closing step deletes the file before its collecting turn runs, so this edit
|
|
316
530
|
// leaves a closed turn changing nothing while still answering an in-gate one.
|
|
317
531
|
Given(
|
|
318
|
-
"step {string}
|
|
532
|
+
"step {string} overwrites {string}, if it exists, with:",
|
|
319
533
|
(world: TestkitWorld, step: string, path: string, content: string) => {
|
|
320
534
|
world.act(step, (_, repo) => {
|
|
321
535
|
if (repo.read(path) !== undefined) repo.write(path, withNewline(content))
|
|
@@ -327,17 +541,31 @@ Given("step {string} runs its script", (world: TestkitWorld, step: string) => {
|
|
|
327
541
|
world.act(step, runsItsScript)
|
|
328
542
|
})
|
|
329
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
|
+
|
|
330
561
|
Given("judge step {string} answers:", (world: TestkitWorld, step: string, verdict: string) => {
|
|
331
|
-
world.
|
|
562
|
+
world.answer(step, String(verdict))
|
|
332
563
|
})
|
|
333
564
|
|
|
334
565
|
Given(
|
|
335
566
|
"judge step {string}, when its questions contain {string}, answers:",
|
|
336
567
|
(world: TestkitWorld, step: string, text: string, verdict: string) => {
|
|
337
|
-
world.
|
|
338
|
-
...(world.conditionalVerdicts.get(step) ?? []),
|
|
339
|
-
[text, String(verdict)],
|
|
340
|
-
])
|
|
568
|
+
world.answerWhen(step, text, String(verdict))
|
|
341
569
|
},
|
|
342
570
|
)
|
|
343
571
|
|
|
@@ -371,6 +599,10 @@ When("the process is driven", async (world: TestkitWorld) => {
|
|
|
371
599
|
await world.drive()
|
|
372
600
|
})
|
|
373
601
|
|
|
602
|
+
When("the process is driven leniently", async (world: TestkitWorld) => {
|
|
603
|
+
await world.drive({ strict: false })
|
|
604
|
+
})
|
|
605
|
+
|
|
374
606
|
Then(
|
|
375
607
|
"opening door {string} is refused with {string}",
|
|
376
608
|
outcome(async (world, door: string, message: string) => {
|
|
@@ -411,7 +643,56 @@ Then(
|
|
|
411
643
|
Then(
|
|
412
644
|
"the process rests at {string}",
|
|
413
645
|
outcome(async (world, step: string) => {
|
|
414
|
-
|
|
646
|
+
assertRestsAt(await world.repo().next(), step)
|
|
647
|
+
}),
|
|
648
|
+
)
|
|
649
|
+
|
|
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
|
+
)
|
|
415
696
|
}),
|
|
416
697
|
)
|
|
417
698
|
|
|
@@ -454,6 +735,37 @@ Then(
|
|
|
454
735
|
}),
|
|
455
736
|
)
|
|
456
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
|
+
)
|
|
768
|
+
|
|
457
769
|
const outputOf = async (world: TestkitWorld, list: string): Promise<string> => {
|
|
458
770
|
const result = await world.repo().gtd(...args(list))
|
|
459
771
|
assert.strictEqual(result.exitCode, 0, result.stderr)
|
|
@@ -509,6 +821,13 @@ Then(
|
|
|
509
821
|
}),
|
|
510
822
|
)
|
|
511
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
|
+
}),
|
|
829
|
+
)
|
|
830
|
+
|
|
512
831
|
Then(
|
|
513
832
|
"{string} contains {string} exactly once",
|
|
514
833
|
outcome((world, path: string, text: string) => {
|
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest"
|
|
2
|
-
import { agent, human } from "../flows/index.js"
|
|
2
|
+
import { agent, human, workflow } from "../flows/index.js"
|
|
3
3
|
import { testRepo } from "../testkit/index.js"
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
assertContains,
|
|
6
|
+
assertLacks,
|
|
7
|
+
assertRestsAt,
|
|
8
|
+
refusedWith,
|
|
9
|
+
restDescription,
|
|
10
|
+
} from "./messages.js"
|
|
5
11
|
|
|
6
12
|
const flow = async (): Promise<void> => {
|
|
7
13
|
await agent("plan", "Write PLAN.md.\nKeep it short.\n3\n4\n5\n6\n7")
|
|
@@ -22,7 +28,15 @@ describe("restDescription", () => {
|
|
|
22
28
|
const repo = testRepo({ workflow: { default: flow } })
|
|
23
29
|
repo.write("go", "\n")
|
|
24
30
|
await repo.drive({ plan: (_, r) => r.write("PLAN.md", "p\n") })
|
|
25
|
-
expect(
|
|
31
|
+
expect(
|
|
32
|
+
await restDescription(
|
|
33
|
+
() => repo,
|
|
34
|
+
[
|
|
35
|
+
{ step: "repair", origin: "scenario" },
|
|
36
|
+
{ step: "ready", origin: "Background" },
|
|
37
|
+
],
|
|
38
|
+
),
|
|
39
|
+
).toBe(
|
|
26
40
|
[
|
|
27
41
|
"where the process rests:",
|
|
28
42
|
' step "sign-off" (message, human)',
|
|
@@ -31,7 +45,7 @@ describe("restDescription", () => {
|
|
|
31
45
|
" last commits:",
|
|
32
46
|
" chore: initial commit",
|
|
33
47
|
" gtd(agent): plan → sign-off",
|
|
34
|
-
' actors never used: "repair", "ready"',
|
|
48
|
+
' actors never used: "repair" (scenario), "ready" (Background)',
|
|
35
49
|
].join("\n"),
|
|
36
50
|
)
|
|
37
51
|
})
|
|
@@ -88,3 +102,23 @@ describe("contains", () => {
|
|
|
88
102
|
expect(await thrown(() => assertLacks('"a.md"', "x", undefined))).toBe('"a.md" does not exist')
|
|
89
103
|
})
|
|
90
104
|
})
|
|
105
|
+
|
|
106
|
+
describe("assertRestsAt", () => {
|
|
107
|
+
it("fails on a stall at the step, pointing to the stall assertion", async () => {
|
|
108
|
+
const repo = testRepo({ workflow: { default: flow, flow: workflow(flow) } })
|
|
109
|
+
await repo.start("flow")
|
|
110
|
+
const stall = await repo.drive({ plan: () => undefined })
|
|
111
|
+
expect(stall.kind).toBe("stalled")
|
|
112
|
+
expect(await thrown(() => assertRestsAt(stall, "plan"))).toBe(
|
|
113
|
+
'expected a rest at "plan", but the process stalls at "plan" — assert a stall with: the process stalls at "plan"',
|
|
114
|
+
)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it("passes at a rest at the step, and fails at another", async () => {
|
|
118
|
+
const repo = testRepo({ workflow: { default: flow } })
|
|
119
|
+
repo.write("go", "\n")
|
|
120
|
+
const rest = await repo.drive({ plan: (_, r) => r.write("PLAN.md", "p\n") })
|
|
121
|
+
assertRestsAt(rest, "sign-off")
|
|
122
|
+
expect(await thrown(() => assertRestsAt(rest, "plan"))).toContain("sign-off")
|
|
123
|
+
})
|
|
124
|
+
})
|