@markjaquith/agency 3.2.13 → 3.2.15
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/package.json +7 -2
- package/src/workbase/schemas.ts +12 -0
- package/fixtures/protocol/orchestration-recipes.json +0 -53
- package/pi-extensions/agency.test.ts +0 -117
- package/src/check-commits.test.ts +0 -58
- package/src/cli-parser.test.ts +0 -945
- package/src/cli.test.ts +0 -1889
- package/src/commands/act.test.ts +0 -418
- package/src/commands/archive.test.ts +0 -236
- package/src/commands/context.test.ts +0 -665
- package/src/commands/description.test.ts +0 -103
- package/src/commands/doctor.test.ts +0 -185
- package/src/commands/epic.test.ts +0 -196
- package/src/commands/init.test.ts +0 -117
- package/src/commands/integration.test.ts +0 -165
- package/src/commands/pr.test.ts +0 -248
- package/src/commands/push.test.ts +0 -56
- package/src/commands/read-only.test.ts +0 -161
- package/src/commands/repo.test.ts +0 -199
- package/src/commands/restore.test.ts +0 -106
- package/src/commands/status.test.ts +0 -113
- package/src/commands/sync.test.ts +0 -200
- package/src/commands/task-phase.test.ts +0 -410
- package/src/commands/task.test.ts +0 -281
- package/src/commands/validate.test.ts +0 -186
- package/src/commands/work.test.ts +0 -1375
- package/src/commands/workbase.test.ts +0 -170
- package/src/graph-schema.test.ts +0 -124
- package/src/protocol.test.ts +0 -163
- package/src/readiness.test.ts +0 -105
- package/src/services/ArchiveBulkService.test.ts +0 -384
- package/src/services/ArchiveService.test.ts +0 -1092
- package/src/services/EpicService.test.ts +0 -74
- package/src/services/FileSystemService.test.ts +0 -81
- package/src/services/GraphMutationService.test.ts +0 -486
- package/src/services/GraphService.test.ts +0 -494
- package/src/services/IntegrationService.test.ts +0 -1091
- package/src/services/LifecycleTransaction.test.ts +0 -145
- package/src/services/PullRequestService.test.ts +0 -716
- package/src/services/PushService.test.ts +0 -408
- package/src/services/ReadinessService.test.ts +0 -290
- package/src/services/RepositoryService.test.ts +0 -789
- package/src/services/ReviewService.test.ts +0 -408
- package/src/services/SyncService.test.ts +0 -1377
- package/src/services/TaskPhaseService.test.ts +0 -852
- package/src/services/VersionControlService.test.ts +0 -62
- package/src/services/WorkbaseService.test.ts +0 -910
- package/src/services/WorktreeLock.test.ts +0 -205
- package/src/services/WorktreePerformance.test.ts +0 -71
- package/src/services/WorktreeService.test.ts +0 -2292
- package/src/test-utils.ts +0 -110
- package/src/usage-log.test.ts +0 -188
- package/src/utils/chooser.test.ts +0 -192
- package/src/utils/effect.test.ts +0 -30
- package/src/utils/interactive.pty.test.ts +0 -128
- package/src/utils/interactive.test.tsx +0 -860
- package/src/utils/process.test.ts +0 -132
- package/src/utils/progress.test.ts +0 -37
- package/src/work-view.test.ts +0 -151
- package/src/workbase/agent-command.test.ts +0 -128
- package/src/workbase/checkout-command.test.ts +0 -62
- package/src/workbase/delivery-command.test.ts +0 -180
- package/src/workbase/dependency-graph.test.ts +0 -50
- package/src/workbase/execution-contract.test.ts +0 -161
- package/src/workbase/frontmatter.test.ts +0 -67
- package/src/workbase/repository-reference.test.ts +0 -25
- package/src/workbase/schemas.test.ts +0 -543
- package/src/workbase/work-target.test.ts +0 -108
- package/src/workbase/worktree-command.test.ts +0 -61
|
@@ -1,860 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test"
|
|
2
|
-
import { KeyCodes, type MockInput } from "@opentui/core/testing"
|
|
3
|
-
import { testRender } from "@opentui/solid"
|
|
4
|
-
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"
|
|
5
|
-
import { tmpdir } from "node:os"
|
|
6
|
-
import { dirname, join } from "node:path"
|
|
7
|
-
import {
|
|
8
|
-
fuzzyChoices,
|
|
9
|
-
hierarchyPrefix,
|
|
10
|
-
InteractiveSelectPrompt,
|
|
11
|
-
InteractiveTextPrompt,
|
|
12
|
-
interactiveRendererConfig,
|
|
13
|
-
interactiveSelectRendererConfig,
|
|
14
|
-
} from "./interactive"
|
|
15
|
-
|
|
16
|
-
const submitEditedText = async (
|
|
17
|
-
edit: (input: MockInput) => void | Promise<void>,
|
|
18
|
-
) => {
|
|
19
|
-
let submitted: string | null | undefined
|
|
20
|
-
const setup = await testRender(
|
|
21
|
-
() => (
|
|
22
|
-
<InteractiveTextPrompt
|
|
23
|
-
prompt="Text"
|
|
24
|
-
onDone={(value) => {
|
|
25
|
-
submitted = value
|
|
26
|
-
}}
|
|
27
|
-
/>
|
|
28
|
-
),
|
|
29
|
-
{ width: 60, height: 4 },
|
|
30
|
-
)
|
|
31
|
-
try {
|
|
32
|
-
await setup.renderer.setupTerminal()
|
|
33
|
-
await setup.renderOnce()
|
|
34
|
-
await Bun.sleep(0)
|
|
35
|
-
await edit(setup.mockInput)
|
|
36
|
-
setup.mockInput.pressEnter()
|
|
37
|
-
await setup.waitFor(() => submitted !== undefined)
|
|
38
|
-
return submitted
|
|
39
|
-
} finally {
|
|
40
|
-
setup.renderer.destroy()
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
describe("OpenTUI interaction", () => {
|
|
45
|
-
test("selects the Solid JSX runtime without the project preload", async () => {
|
|
46
|
-
const source = await Bun.file(
|
|
47
|
-
new URL("./interactive.tsx", import.meta.url),
|
|
48
|
-
).text()
|
|
49
|
-
const theme = await Bun.file(new URL("./theme.ts", import.meta.url)).text()
|
|
50
|
-
const root = await mkdtemp(join(tmpdir(), "agency-interactive-jsx-"))
|
|
51
|
-
const entrypoint = join(
|
|
52
|
-
root,
|
|
53
|
-
"node_modules",
|
|
54
|
-
"@markjaquith",
|
|
55
|
-
"agency",
|
|
56
|
-
"interactive.tsx",
|
|
57
|
-
)
|
|
58
|
-
await mkdir(dirname(entrypoint), { recursive: true })
|
|
59
|
-
await writeFile(entrypoint, source)
|
|
60
|
-
await writeFile(join(dirname(entrypoint), "theme.ts"), theme)
|
|
61
|
-
|
|
62
|
-
try {
|
|
63
|
-
const result = await Bun.build({
|
|
64
|
-
entrypoints: [entrypoint],
|
|
65
|
-
packages: "external",
|
|
66
|
-
target: "bun",
|
|
67
|
-
})
|
|
68
|
-
expect(result.success).toBeTrue()
|
|
69
|
-
const output = await result.outputs[0]!.text()
|
|
70
|
-
expect(output).toContain("@opentui/solid/jsx-dev-runtime")
|
|
71
|
-
expect(output).not.toContain("react/jsx-dev-runtime")
|
|
72
|
-
} finally {
|
|
73
|
-
await rm(root, { recursive: true, force: true })
|
|
74
|
-
}
|
|
75
|
-
})
|
|
76
|
-
|
|
77
|
-
test("uses the split-footer renderer contract", () => {
|
|
78
|
-
expect(interactiveRendererConfig).toMatchObject({
|
|
79
|
-
screenMode: "split-footer",
|
|
80
|
-
footerHeight: 5,
|
|
81
|
-
externalOutputMode: "capture-stdout",
|
|
82
|
-
clearOnShutdown: false,
|
|
83
|
-
})
|
|
84
|
-
})
|
|
85
|
-
|
|
86
|
-
test("uses the full alternate screen for selectors", () => {
|
|
87
|
-
expect(interactiveSelectRendererConfig).toMatchObject({
|
|
88
|
-
screenMode: "alternate-screen",
|
|
89
|
-
externalOutputMode: "passthrough",
|
|
90
|
-
clearOnShutdown: false,
|
|
91
|
-
})
|
|
92
|
-
})
|
|
93
|
-
|
|
94
|
-
test("fills the available rows and keeps the selection visible after resize", async () => {
|
|
95
|
-
const setup = await testRender(
|
|
96
|
-
() => (
|
|
97
|
-
<InteractiveSelectPrompt
|
|
98
|
-
prompt="Work on"
|
|
99
|
-
choices={Array.from({ length: 10 }, (_, index) => ({
|
|
100
|
-
key: String(index),
|
|
101
|
-
label: `choice-${index}`,
|
|
102
|
-
}))}
|
|
103
|
-
onDone={() => undefined}
|
|
104
|
-
/>
|
|
105
|
-
),
|
|
106
|
-
{ width: 40, height: 8 },
|
|
107
|
-
)
|
|
108
|
-
try {
|
|
109
|
-
await setup.renderer.setupTerminal()
|
|
110
|
-
await setup.renderOnce()
|
|
111
|
-
await Bun.sleep(0)
|
|
112
|
-
|
|
113
|
-
let frame = setup.captureCharFrame()
|
|
114
|
-
for (let index = 0; index < 5; index++) {
|
|
115
|
-
expect(frame).toContain(`choice-${index}`)
|
|
116
|
-
}
|
|
117
|
-
expect(frame).not.toContain("choice-5")
|
|
118
|
-
|
|
119
|
-
for (let index = 0; index < 7; index++) {
|
|
120
|
-
setup.mockInput.pressArrow("down")
|
|
121
|
-
}
|
|
122
|
-
await setup.flush()
|
|
123
|
-
frame = setup.captureCharFrame()
|
|
124
|
-
expect(frame).toContain("choice-5")
|
|
125
|
-
expect(frame).toContain("▌ choice-7")
|
|
126
|
-
expect(frame).toContain("choice-9")
|
|
127
|
-
|
|
128
|
-
setup.resize(40, 5)
|
|
129
|
-
await setup.flush()
|
|
130
|
-
frame = setup.captureCharFrame()
|
|
131
|
-
expect(frame).not.toContain("choice-5")
|
|
132
|
-
expect(frame).toContain("choice-6")
|
|
133
|
-
expect(frame).toContain("▌ choice-7")
|
|
134
|
-
expect(frame).not.toContain("choice-8")
|
|
135
|
-
expect(frame).not.toContain("choice-9")
|
|
136
|
-
} finally {
|
|
137
|
-
setup.renderer.destroy()
|
|
138
|
-
}
|
|
139
|
-
})
|
|
140
|
-
|
|
141
|
-
test("ranks case-insensitive fuzzy matches", () => {
|
|
142
|
-
const choices = [
|
|
143
|
-
{ key: "nested", label: "Manage Agency" },
|
|
144
|
-
{ key: "prefix", label: "Agency" },
|
|
145
|
-
{ key: "other", label: "Website" },
|
|
146
|
-
]
|
|
147
|
-
|
|
148
|
-
expect(fuzzyChoices(choices, "AG").map((choice) => choice.key)).toEqual([
|
|
149
|
-
"prefix",
|
|
150
|
-
"nested",
|
|
151
|
-
])
|
|
152
|
-
expect(fuzzyChoices(choices, "mgy").map((choice) => choice.key)).toEqual([
|
|
153
|
-
"nested",
|
|
154
|
-
])
|
|
155
|
-
expect(fuzzyChoices(choices, "zzz")).toEqual([])
|
|
156
|
-
})
|
|
157
|
-
|
|
158
|
-
test("builds continuous hierarchy connectors for roots, children, and phases", () => {
|
|
159
|
-
const choices = [
|
|
160
|
-
{ key: "epic", label: "epic delivery", depth: 0 },
|
|
161
|
-
{ key: "multi", label: "task multi", depth: 1 },
|
|
162
|
-
{ key: "build", label: "phase build", depth: 2 },
|
|
163
|
-
{ key: "verify", label: "phase verify", depth: 2 },
|
|
164
|
-
{ key: "single", label: "task single", depth: 1 },
|
|
165
|
-
{ key: "empty", label: "epic empty", depth: 0 },
|
|
166
|
-
{ key: "standalone", label: "task standalone", depth: 0 },
|
|
167
|
-
]
|
|
168
|
-
|
|
169
|
-
expect(choices.map((_, index) => hierarchyPrefix(choices, index))).toEqual([
|
|
170
|
-
"╭─ ",
|
|
171
|
-
"│ ╭─ ",
|
|
172
|
-
"│ │ ╭─ ",
|
|
173
|
-
"│ │ ╰─ ",
|
|
174
|
-
"│ ╰─ ",
|
|
175
|
-
"├─ ",
|
|
176
|
-
"╰─ ",
|
|
177
|
-
])
|
|
178
|
-
expect(hierarchyPrefix([{ key: "only", label: "Only", depth: 0 }], 0)).toBe(
|
|
179
|
-
"╰─ ",
|
|
180
|
-
)
|
|
181
|
-
expect(hierarchyPrefix([{ key: "flat", label: "Flat" }], 0)).toBe("")
|
|
182
|
-
})
|
|
183
|
-
|
|
184
|
-
test("renders hierarchy only while the filter is empty", async () => {
|
|
185
|
-
const setup = await testRender(
|
|
186
|
-
() => (
|
|
187
|
-
<InteractiveSelectPrompt
|
|
188
|
-
prompt="Work on"
|
|
189
|
-
choices={[
|
|
190
|
-
{ key: "epic", label: "epic delivery", depth: 0 },
|
|
191
|
-
{ key: "child", label: "task delivery-child", depth: 1 },
|
|
192
|
-
{ key: "standalone", label: "task standalone", depth: 0 },
|
|
193
|
-
]}
|
|
194
|
-
onDone={() => undefined}
|
|
195
|
-
/>
|
|
196
|
-
),
|
|
197
|
-
{ width: 40, height: 5 },
|
|
198
|
-
)
|
|
199
|
-
try {
|
|
200
|
-
await setup.renderer.setupTerminal()
|
|
201
|
-
await setup.renderOnce()
|
|
202
|
-
await Bun.sleep(0)
|
|
203
|
-
|
|
204
|
-
let frame = setup.captureCharFrame()
|
|
205
|
-
expect(frame).toContain("▌ ╭─ epic delivery")
|
|
206
|
-
expect(frame).toContain(" │ ╰─ task delivery-child")
|
|
207
|
-
|
|
208
|
-
setup.mockInput.pressArrow("down")
|
|
209
|
-
await setup.flush()
|
|
210
|
-
frame = setup.captureCharFrame()
|
|
211
|
-
expect(frame).toContain(" ╭─ epic delivery")
|
|
212
|
-
expect(frame).toContain("▌ │ ╰─ task delivery-child")
|
|
213
|
-
|
|
214
|
-
await setup.mockInput.typeText("delivery")
|
|
215
|
-
await setup.flush()
|
|
216
|
-
frame = setup.captureCharFrame()
|
|
217
|
-
expect(frame).toContain("▌ epic delivery")
|
|
218
|
-
expect(frame).toContain(" task delivery-child")
|
|
219
|
-
expect(frame).not.toMatch(/[╭│├╰─]/)
|
|
220
|
-
|
|
221
|
-
setup.mockInput.pressKey("u", { ctrl: true })
|
|
222
|
-
await setup.flush()
|
|
223
|
-
setup.resize(32, 5)
|
|
224
|
-
await setup.flush()
|
|
225
|
-
frame = setup.captureCharFrame()
|
|
226
|
-
expect(frame).toContain("▌ ╭─ epic delivery")
|
|
227
|
-
expect(frame).toContain(" │ ╰─ task delivery-child")
|
|
228
|
-
} finally {
|
|
229
|
-
setup.renderer.destroy()
|
|
230
|
-
}
|
|
231
|
-
})
|
|
232
|
-
|
|
233
|
-
test("keeps connectors stable while highlighting the full active row", async () => {
|
|
234
|
-
const setup = await testRender(
|
|
235
|
-
() => (
|
|
236
|
-
<InteractiveSelectPrompt
|
|
237
|
-
prompt="Work on"
|
|
238
|
-
choices={[
|
|
239
|
-
{ key: "agency", label: "agency", depth: 0 },
|
|
240
|
-
{ key: "web", label: "web", depth: 0 },
|
|
241
|
-
]}
|
|
242
|
-
onDone={() => undefined}
|
|
243
|
-
/>
|
|
244
|
-
),
|
|
245
|
-
{ width: 40, height: 5 },
|
|
246
|
-
)
|
|
247
|
-
try {
|
|
248
|
-
await setup.renderer.setupTerminal()
|
|
249
|
-
await setup.renderOnce()
|
|
250
|
-
await Bun.sleep(0)
|
|
251
|
-
|
|
252
|
-
let lines = setup.captureSpans().lines
|
|
253
|
-
let active = lines.find((line) =>
|
|
254
|
-
line.spans.some((span) => span.text === "▌ "),
|
|
255
|
-
)!
|
|
256
|
-
expect(
|
|
257
|
-
active.spans.find((span) => span.text === "▌ ")?.fg.toInts(),
|
|
258
|
-
).toEqual([198, 160, 246, 255])
|
|
259
|
-
expect(
|
|
260
|
-
active.spans.find((span) => span.text === "╭─ ")?.fg.toInts(),
|
|
261
|
-
).toEqual([128, 135, 162, 255])
|
|
262
|
-
expect(active.spans.map((span) => span.bg.toInts())).toEqual(
|
|
263
|
-
Array.from({ length: active.spans.length }, () => [73, 77, 100, 255]),
|
|
264
|
-
)
|
|
265
|
-
|
|
266
|
-
setup.mockInput.pressArrow("down")
|
|
267
|
-
await setup.flush()
|
|
268
|
-
lines = setup.captureSpans().lines
|
|
269
|
-
active = lines.find((line) =>
|
|
270
|
-
line.spans.some((span) => span.text === "▌ "),
|
|
271
|
-
)!
|
|
272
|
-
expect(
|
|
273
|
-
lines
|
|
274
|
-
.flatMap((line) => line.spans)
|
|
275
|
-
.find((span) => span.text === "╭─ ")
|
|
276
|
-
?.fg.toInts(),
|
|
277
|
-
).toEqual([128, 135, 162, 255])
|
|
278
|
-
expect(
|
|
279
|
-
active.spans.find((span) => span.text === "╰─ ")?.fg.toInts(),
|
|
280
|
-
).toEqual([128, 135, 162, 255])
|
|
281
|
-
expect(active.spans.map((span) => span.bg.toInts())).toEqual(
|
|
282
|
-
Array.from({ length: active.spans.length }, () => [73, 77, 100, 255]),
|
|
283
|
-
)
|
|
284
|
-
} finally {
|
|
285
|
-
setup.renderer.destroy()
|
|
286
|
-
}
|
|
287
|
-
})
|
|
288
|
-
|
|
289
|
-
test("renders entity and status Nerd Font icons in distinct colors", async () => {
|
|
290
|
-
const setup = await testRender(
|
|
291
|
-
() => (
|
|
292
|
-
<InteractiveSelectPrompt
|
|
293
|
-
prompt="Work on"
|
|
294
|
-
choices={[
|
|
295
|
-
{
|
|
296
|
-
key: "verify",
|
|
297
|
-
label: "[done] phase verify",
|
|
298
|
-
depth: 0,
|
|
299
|
-
segments: [
|
|
300
|
-
{ text: "", color: "#a6da95" },
|
|
301
|
-
{ text: " " },
|
|
302
|
-
{ text: "", color: "#eed49f" },
|
|
303
|
-
{ text: " verify" },
|
|
304
|
-
],
|
|
305
|
-
},
|
|
306
|
-
]}
|
|
307
|
-
onDone={() => undefined}
|
|
308
|
-
/>
|
|
309
|
-
),
|
|
310
|
-
{ width: 40, height: 4 },
|
|
311
|
-
)
|
|
312
|
-
try {
|
|
313
|
-
await setup.renderer.setupTerminal()
|
|
314
|
-
await setup.renderOnce()
|
|
315
|
-
await Bun.sleep(0)
|
|
316
|
-
|
|
317
|
-
expect(setup.captureCharFrame()).toContain("▌ ╰─ verify")
|
|
318
|
-
const spans = setup.captureSpans().lines.flatMap((line) => line.spans)
|
|
319
|
-
expect(spans.find((span) => span.text === "")?.fg.toInts()).toEqual([
|
|
320
|
-
166, 218, 149, 255,
|
|
321
|
-
])
|
|
322
|
-
expect(spans.find((span) => span.text === "")?.fg.toInts()).toEqual([
|
|
323
|
-
238, 212, 159, 255,
|
|
324
|
-
])
|
|
325
|
-
} finally {
|
|
326
|
-
setup.renderer.destroy()
|
|
327
|
-
}
|
|
328
|
-
})
|
|
329
|
-
|
|
330
|
-
test("clears a non-empty filter before escape cancels", async () => {
|
|
331
|
-
let selected: string | null | undefined
|
|
332
|
-
const setup = await testRender(
|
|
333
|
-
() => (
|
|
334
|
-
<InteractiveSelectPrompt
|
|
335
|
-
prompt="Work on"
|
|
336
|
-
choices={[
|
|
337
|
-
{ key: "agency", label: "agency", depth: 0 },
|
|
338
|
-
{ key: "web", label: "web", depth: 0 },
|
|
339
|
-
]}
|
|
340
|
-
onDone={(value) => {
|
|
341
|
-
selected = value
|
|
342
|
-
}}
|
|
343
|
-
/>
|
|
344
|
-
),
|
|
345
|
-
{ width: 40, height: 5, kittyKeyboard: true },
|
|
346
|
-
)
|
|
347
|
-
try {
|
|
348
|
-
await setup.renderer.setupTerminal()
|
|
349
|
-
await setup.renderOnce()
|
|
350
|
-
await Bun.sleep(0)
|
|
351
|
-
await setup.mockInput.typeText("web")
|
|
352
|
-
await setup.flush()
|
|
353
|
-
expect(setup.captureCharFrame()).toContain("▌ web")
|
|
354
|
-
|
|
355
|
-
setup.mockInput.pressEscape()
|
|
356
|
-
await setup.flush()
|
|
357
|
-
expect(selected).toBeUndefined()
|
|
358
|
-
const cleared = setup.captureCharFrame()
|
|
359
|
-
expect(cleared).toContain("▌ ╭─ agency")
|
|
360
|
-
expect(cleared).toContain(" ╰─ web")
|
|
361
|
-
|
|
362
|
-
setup.mockInput.pressEscape()
|
|
363
|
-
await setup.waitFor(() => selected !== undefined)
|
|
364
|
-
expect(selected).toBeNull()
|
|
365
|
-
} finally {
|
|
366
|
-
setup.renderer.destroy()
|
|
367
|
-
}
|
|
368
|
-
})
|
|
369
|
-
|
|
370
|
-
test("selects choices with ctrl-p, ctrl-n, and arrow navigation", async () => {
|
|
371
|
-
const selectAfter = async (key: "p" | "n" | "up" | "down") => {
|
|
372
|
-
let selected: string | null | undefined
|
|
373
|
-
const setup = await testRender(
|
|
374
|
-
() => (
|
|
375
|
-
<InteractiveSelectPrompt
|
|
376
|
-
prompt="Repository"
|
|
377
|
-
choices={[
|
|
378
|
-
{ key: "agency", label: "agency" },
|
|
379
|
-
{ key: "web", label: "web" },
|
|
380
|
-
{ key: "docs", label: "docs" },
|
|
381
|
-
]}
|
|
382
|
-
onDone={(value) => {
|
|
383
|
-
selected = value
|
|
384
|
-
}}
|
|
385
|
-
/>
|
|
386
|
-
),
|
|
387
|
-
{ width: 60, height: 4 },
|
|
388
|
-
)
|
|
389
|
-
try {
|
|
390
|
-
await setup.renderer.setupTerminal()
|
|
391
|
-
await setup.renderOnce()
|
|
392
|
-
await Bun.sleep(0)
|
|
393
|
-
if (key === "up" || key === "down") {
|
|
394
|
-
setup.mockInput.pressArrow(key)
|
|
395
|
-
} else {
|
|
396
|
-
setup.mockInput.pressKey(key, { ctrl: true })
|
|
397
|
-
}
|
|
398
|
-
setup.mockInput.pressEnter()
|
|
399
|
-
await setup.waitFor(() => selected !== undefined)
|
|
400
|
-
return selected
|
|
401
|
-
} finally {
|
|
402
|
-
setup.renderer.destroy()
|
|
403
|
-
}
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
expect(await selectAfter("p")).toBe("docs")
|
|
407
|
-
expect(await selectAfter("n")).toBe("web")
|
|
408
|
-
expect(await selectAfter("up")).toBe("docs")
|
|
409
|
-
expect(await selectAfter("down")).toBe("web")
|
|
410
|
-
})
|
|
411
|
-
|
|
412
|
-
test("uses printable j, k, q, and ? characters as the fuzzy query", async () => {
|
|
413
|
-
for (const query of ["j", "k", "q", "?"]) {
|
|
414
|
-
let selected: string | null | undefined
|
|
415
|
-
const setup = await testRender(
|
|
416
|
-
() => (
|
|
417
|
-
<InteractiveSelectPrompt
|
|
418
|
-
prompt="Repository"
|
|
419
|
-
choices={[
|
|
420
|
-
{ key: query, label: `${query} target` },
|
|
421
|
-
{ key: "other", label: "Other" },
|
|
422
|
-
]}
|
|
423
|
-
onDone={(value) => {
|
|
424
|
-
selected = value
|
|
425
|
-
}}
|
|
426
|
-
/>
|
|
427
|
-
),
|
|
428
|
-
{ width: 60, height: 4 },
|
|
429
|
-
)
|
|
430
|
-
try {
|
|
431
|
-
await setup.renderer.setupTerminal()
|
|
432
|
-
await setup.renderOnce()
|
|
433
|
-
await Bun.sleep(0)
|
|
434
|
-
await setup.mockInput.typeText(query)
|
|
435
|
-
setup.mockInput.pressEnter()
|
|
436
|
-
await setup.waitFor(() => selected !== undefined)
|
|
437
|
-
expect(selected).toBe(query)
|
|
438
|
-
} finally {
|
|
439
|
-
setup.renderer.destroy()
|
|
440
|
-
}
|
|
441
|
-
}
|
|
442
|
-
})
|
|
443
|
-
|
|
444
|
-
test("supports the readline movement and deletion contract", async () => {
|
|
445
|
-
const cases = [
|
|
446
|
-
{
|
|
447
|
-
name: "left arrow",
|
|
448
|
-
expected: "aXb",
|
|
449
|
-
edit: async (input: MockInput) => {
|
|
450
|
-
await input.typeText("ab")
|
|
451
|
-
input.pressArrow("left")
|
|
452
|
-
await input.typeText("X")
|
|
453
|
-
},
|
|
454
|
-
},
|
|
455
|
-
{
|
|
456
|
-
name: "home and end",
|
|
457
|
-
expected: "XabY",
|
|
458
|
-
edit: async (input: MockInput) => {
|
|
459
|
-
await input.typeText("ab")
|
|
460
|
-
input.pressKey(KeyCodes.HOME)
|
|
461
|
-
await input.typeText("X")
|
|
462
|
-
input.pressKey(KeyCodes.END)
|
|
463
|
-
await input.typeText("Y")
|
|
464
|
-
},
|
|
465
|
-
},
|
|
466
|
-
{
|
|
467
|
-
name: "backspace and delete",
|
|
468
|
-
expected: "a",
|
|
469
|
-
edit: async (input: MockInput) => {
|
|
470
|
-
await input.typeText("abc")
|
|
471
|
-
input.pressBackspace()
|
|
472
|
-
input.pressArrow("left")
|
|
473
|
-
input.pressKey(KeyCodes.DELETE)
|
|
474
|
-
},
|
|
475
|
-
},
|
|
476
|
-
{
|
|
477
|
-
name: "ctrl-a and ctrl-e",
|
|
478
|
-
expected: "XabY",
|
|
479
|
-
edit: async (input: MockInput) => {
|
|
480
|
-
await input.typeText("ab")
|
|
481
|
-
input.pressKey("a", { ctrl: true })
|
|
482
|
-
await input.typeText("X")
|
|
483
|
-
input.pressKey("e", { ctrl: true })
|
|
484
|
-
await input.typeText("Y")
|
|
485
|
-
},
|
|
486
|
-
},
|
|
487
|
-
{
|
|
488
|
-
name: "ctrl-b and ctrl-f",
|
|
489
|
-
expected: "aXb",
|
|
490
|
-
edit: async (input: MockInput) => {
|
|
491
|
-
await input.typeText("ab")
|
|
492
|
-
input.pressKey("b", { ctrl: true })
|
|
493
|
-
input.pressKey("b", { ctrl: true })
|
|
494
|
-
input.pressKey("f", { ctrl: true })
|
|
495
|
-
await input.typeText("X")
|
|
496
|
-
},
|
|
497
|
-
},
|
|
498
|
-
{
|
|
499
|
-
name: "meta-b and meta-f",
|
|
500
|
-
expected: "one Xtwo",
|
|
501
|
-
edit: async (input: MockInput) => {
|
|
502
|
-
await input.typeText("one two")
|
|
503
|
-
input.pressKey("b", { meta: true })
|
|
504
|
-
input.pressKey("b", { meta: true })
|
|
505
|
-
input.pressKey("f", { meta: true })
|
|
506
|
-
await input.typeText("X")
|
|
507
|
-
},
|
|
508
|
-
},
|
|
509
|
-
{
|
|
510
|
-
name: "ctrl-u",
|
|
511
|
-
expected: "c",
|
|
512
|
-
edit: async (input: MockInput) => {
|
|
513
|
-
await input.typeText("abc")
|
|
514
|
-
input.pressArrow("left")
|
|
515
|
-
input.pressKey("u", { ctrl: true })
|
|
516
|
-
},
|
|
517
|
-
},
|
|
518
|
-
{
|
|
519
|
-
name: "ctrl-k",
|
|
520
|
-
expected: "ab",
|
|
521
|
-
edit: async (input: MockInput) => {
|
|
522
|
-
await input.typeText("abc")
|
|
523
|
-
input.pressArrow("left")
|
|
524
|
-
input.pressKey("k", { ctrl: true })
|
|
525
|
-
},
|
|
526
|
-
},
|
|
527
|
-
{
|
|
528
|
-
name: "ctrl-w",
|
|
529
|
-
expected: "one ",
|
|
530
|
-
edit: async (input: MockInput) => {
|
|
531
|
-
await input.typeText("one two")
|
|
532
|
-
input.pressKey("w", { ctrl: true })
|
|
533
|
-
},
|
|
534
|
-
},
|
|
535
|
-
{
|
|
536
|
-
name: "ctrl-d",
|
|
537
|
-
expected: "ab",
|
|
538
|
-
edit: async (input: MockInput) => {
|
|
539
|
-
await input.typeText("abc")
|
|
540
|
-
input.pressArrow("left")
|
|
541
|
-
input.pressKey("d", { ctrl: true })
|
|
542
|
-
},
|
|
543
|
-
},
|
|
544
|
-
{
|
|
545
|
-
name: "ctrl-h",
|
|
546
|
-
expected: "ab",
|
|
547
|
-
edit: async (input: MockInput) => {
|
|
548
|
-
await input.typeText("abc")
|
|
549
|
-
input.pressKey("h", { ctrl: true })
|
|
550
|
-
},
|
|
551
|
-
},
|
|
552
|
-
]
|
|
553
|
-
|
|
554
|
-
for (const contract of cases) {
|
|
555
|
-
expect(await submitEditedText(contract.edit), contract.name).toBe(
|
|
556
|
-
contract.expected,
|
|
557
|
-
)
|
|
558
|
-
}
|
|
559
|
-
})
|
|
560
|
-
|
|
561
|
-
test("yanks the most recently killed text in both prompt inputs", async () => {
|
|
562
|
-
expect(
|
|
563
|
-
await submitEditedText(async (input) => {
|
|
564
|
-
await input.typeText("one two")
|
|
565
|
-
input.pressKey("w", { ctrl: true })
|
|
566
|
-
input.pressKey("y", { ctrl: true })
|
|
567
|
-
}),
|
|
568
|
-
).toBe("one two")
|
|
569
|
-
|
|
570
|
-
let selected: string | null | undefined
|
|
571
|
-
const setup = await testRender(
|
|
572
|
-
() => (
|
|
573
|
-
<InteractiveSelectPrompt
|
|
574
|
-
prompt="Repository"
|
|
575
|
-
choices={[
|
|
576
|
-
{ key: "agency", label: "agency" },
|
|
577
|
-
{ key: "web", label: "web" },
|
|
578
|
-
]}
|
|
579
|
-
onDone={(value) => {
|
|
580
|
-
selected = value
|
|
581
|
-
}}
|
|
582
|
-
/>
|
|
583
|
-
),
|
|
584
|
-
{ width: 60, height: 4 },
|
|
585
|
-
)
|
|
586
|
-
try {
|
|
587
|
-
await setup.renderer.setupTerminal()
|
|
588
|
-
await setup.renderOnce()
|
|
589
|
-
await Bun.sleep(0)
|
|
590
|
-
await setup.mockInput.typeText("web")
|
|
591
|
-
setup.mockInput.pressKey("u", { ctrl: true })
|
|
592
|
-
setup.mockInput.pressKey("y", { ctrl: true })
|
|
593
|
-
setup.mockInput.pressEnter()
|
|
594
|
-
await setup.waitFor(() => selected !== undefined)
|
|
595
|
-
expect(selected).toBe("web")
|
|
596
|
-
} finally {
|
|
597
|
-
setup.renderer.destroy()
|
|
598
|
-
}
|
|
599
|
-
})
|
|
600
|
-
|
|
601
|
-
test("does not select an empty result and supports ctrl-u editing", async () => {
|
|
602
|
-
let selected: string | null | undefined
|
|
603
|
-
const setup = await testRender(
|
|
604
|
-
() => (
|
|
605
|
-
<InteractiveSelectPrompt
|
|
606
|
-
prompt="Repository"
|
|
607
|
-
choices={[
|
|
608
|
-
{ key: "agency", label: "agency" },
|
|
609
|
-
{ key: "web", label: "web" },
|
|
610
|
-
]}
|
|
611
|
-
onDone={(value) => {
|
|
612
|
-
selected = value
|
|
613
|
-
}}
|
|
614
|
-
/>
|
|
615
|
-
),
|
|
616
|
-
{ width: 60, height: 4 },
|
|
617
|
-
)
|
|
618
|
-
try {
|
|
619
|
-
await setup.renderer.setupTerminal()
|
|
620
|
-
await setup.renderOnce()
|
|
621
|
-
await Bun.sleep(0)
|
|
622
|
-
await setup.mockInput.typeText("zzz")
|
|
623
|
-
setup.mockInput.pressEnter()
|
|
624
|
-
await Bun.sleep(0)
|
|
625
|
-
expect(selected).toBeUndefined()
|
|
626
|
-
setup.mockInput.pressKey("u", { ctrl: true })
|
|
627
|
-
await setup.mockInput.typeText("web")
|
|
628
|
-
setup.mockInput.pressEnter()
|
|
629
|
-
await setup.waitFor(() => selected !== undefined)
|
|
630
|
-
expect(selected).toBe("web")
|
|
631
|
-
} finally {
|
|
632
|
-
setup.renderer.destroy()
|
|
633
|
-
}
|
|
634
|
-
})
|
|
635
|
-
|
|
636
|
-
test("resets and navigates selection after filtering", async () => {
|
|
637
|
-
let selected: string | null | undefined
|
|
638
|
-
const setup = await testRender(
|
|
639
|
-
() => (
|
|
640
|
-
<InteractiveSelectPrompt
|
|
641
|
-
prompt="Repository"
|
|
642
|
-
choices={[
|
|
643
|
-
{ key: "agency", label: "agency" },
|
|
644
|
-
{ key: "web-one", label: "web one" },
|
|
645
|
-
{ key: "web-two", label: "web two" },
|
|
646
|
-
{ key: "docs", label: "docs" },
|
|
647
|
-
]}
|
|
648
|
-
onDone={(value) => {
|
|
649
|
-
selected = value
|
|
650
|
-
}}
|
|
651
|
-
/>
|
|
652
|
-
),
|
|
653
|
-
{ width: 60, height: 4 },
|
|
654
|
-
)
|
|
655
|
-
try {
|
|
656
|
-
await setup.renderer.setupTerminal()
|
|
657
|
-
await setup.renderOnce()
|
|
658
|
-
await Bun.sleep(0)
|
|
659
|
-
setup.mockInput.pressArrow("down")
|
|
660
|
-
setup.mockInput.pressArrow("down")
|
|
661
|
-
setup.mockInput.pressArrow("down")
|
|
662
|
-
await setup.mockInput.typeText("web")
|
|
663
|
-
setup.mockInput.pressKey("n", { ctrl: true })
|
|
664
|
-
setup.mockInput.pressEnter()
|
|
665
|
-
await setup.waitFor(() => selected !== undefined)
|
|
666
|
-
expect(selected).toBe("web-two")
|
|
667
|
-
} finally {
|
|
668
|
-
setup.renderer.destroy()
|
|
669
|
-
}
|
|
670
|
-
})
|
|
671
|
-
|
|
672
|
-
test("wraps text prompt input onto another line", async () => {
|
|
673
|
-
const setup = await testRender(
|
|
674
|
-
() => <InteractiveTextPrompt prompt="Task ID" onDone={() => {}} />,
|
|
675
|
-
{ width: 16, height: 5 },
|
|
676
|
-
)
|
|
677
|
-
try {
|
|
678
|
-
await setup.renderer.setupTerminal()
|
|
679
|
-
await setup.renderOnce()
|
|
680
|
-
await Bun.sleep(0)
|
|
681
|
-
await setup.mockInput.typeText("alpha beta gamma delta")
|
|
682
|
-
await setup.flush()
|
|
683
|
-
const frame = setup.captureCharFrame()
|
|
684
|
-
expect(frame).toContain("alpha beta")
|
|
685
|
-
expect(frame).toContain("gamma")
|
|
686
|
-
expect(frame).toMatch(/alpha beta\s*\ngamma delta/)
|
|
687
|
-
} finally {
|
|
688
|
-
setup.renderer.destroy()
|
|
689
|
-
}
|
|
690
|
-
})
|
|
691
|
-
|
|
692
|
-
test("keeps empty and single-item list boundaries safe", async () => {
|
|
693
|
-
let emptyResult: string | null | undefined
|
|
694
|
-
const empty = await testRender(
|
|
695
|
-
() => (
|
|
696
|
-
<InteractiveSelectPrompt
|
|
697
|
-
prompt="Empty"
|
|
698
|
-
choices={[]}
|
|
699
|
-
onDone={(value) => {
|
|
700
|
-
emptyResult = value
|
|
701
|
-
}}
|
|
702
|
-
/>
|
|
703
|
-
),
|
|
704
|
-
{ width: 60, height: 4 },
|
|
705
|
-
)
|
|
706
|
-
try {
|
|
707
|
-
await empty.renderer.setupTerminal()
|
|
708
|
-
await empty.renderOnce()
|
|
709
|
-
await Bun.sleep(0)
|
|
710
|
-
empty.mockInput.pressArrow("up")
|
|
711
|
-
empty.mockInput.pressArrow("down")
|
|
712
|
-
empty.mockInput.pressKey("p", { ctrl: true })
|
|
713
|
-
empty.mockInput.pressKey("n", { ctrl: true })
|
|
714
|
-
empty.mockInput.pressEnter()
|
|
715
|
-
await Bun.sleep(0)
|
|
716
|
-
expect(emptyResult).toBeUndefined()
|
|
717
|
-
expect(empty.captureCharFrame()).toContain("No matches")
|
|
718
|
-
} finally {
|
|
719
|
-
empty.renderer.destroy()
|
|
720
|
-
}
|
|
721
|
-
|
|
722
|
-
for (const move of ["up", "down"] as const) {
|
|
723
|
-
let singleResult: string | null | undefined
|
|
724
|
-
const single = await testRender(
|
|
725
|
-
() => (
|
|
726
|
-
<InteractiveSelectPrompt
|
|
727
|
-
prompt="Single"
|
|
728
|
-
choices={[{ key: "one", label: "One" }]}
|
|
729
|
-
onDone={(value) => {
|
|
730
|
-
singleResult = value
|
|
731
|
-
}}
|
|
732
|
-
/>
|
|
733
|
-
),
|
|
734
|
-
{ width: 60, height: 4 },
|
|
735
|
-
)
|
|
736
|
-
try {
|
|
737
|
-
await single.renderer.setupTerminal()
|
|
738
|
-
await single.renderOnce()
|
|
739
|
-
await Bun.sleep(0)
|
|
740
|
-
single.mockInput.pressArrow(move)
|
|
741
|
-
single.mockInput.pressEnter()
|
|
742
|
-
await single.waitFor(() => singleResult !== undefined)
|
|
743
|
-
expect(singleResult).toBe("one")
|
|
744
|
-
} finally {
|
|
745
|
-
single.renderer.destroy()
|
|
746
|
-
}
|
|
747
|
-
}
|
|
748
|
-
})
|
|
749
|
-
|
|
750
|
-
test("remains usable after a narrow terminal resize", async () => {
|
|
751
|
-
let selected: string | null | undefined
|
|
752
|
-
const setup = await testRender(
|
|
753
|
-
() => (
|
|
754
|
-
<InteractiveSelectPrompt
|
|
755
|
-
prompt="Repository with a long prompt"
|
|
756
|
-
choices={[
|
|
757
|
-
{ key: "agency", label: "agency" },
|
|
758
|
-
{ key: "docs", label: "docs" },
|
|
759
|
-
]}
|
|
760
|
-
onDone={(value) => {
|
|
761
|
-
selected = value
|
|
762
|
-
}}
|
|
763
|
-
/>
|
|
764
|
-
),
|
|
765
|
-
{ width: 60, height: 4 },
|
|
766
|
-
)
|
|
767
|
-
try {
|
|
768
|
-
await setup.renderer.setupTerminal()
|
|
769
|
-
await setup.renderOnce()
|
|
770
|
-
await Bun.sleep(0)
|
|
771
|
-
setup.resize(18, 4)
|
|
772
|
-
await setup.renderOnce()
|
|
773
|
-
await setup.mockInput.typeText("docs")
|
|
774
|
-
await setup.renderOnce()
|
|
775
|
-
expect(setup.captureCharFrame()).toContain("▌ docs")
|
|
776
|
-
setup.mockInput.pressEnter()
|
|
777
|
-
await setup.waitFor(() => selected !== undefined)
|
|
778
|
-
expect(selected).toBe("docs")
|
|
779
|
-
} finally {
|
|
780
|
-
setup.renderer.destroy()
|
|
781
|
-
}
|
|
782
|
-
})
|
|
783
|
-
|
|
784
|
-
test("submits text and cancels with ctrl-c or escape", async () => {
|
|
785
|
-
let submitted: string | null | undefined
|
|
786
|
-
const input = await testRender(
|
|
787
|
-
() => (
|
|
788
|
-
<InteractiveTextPrompt
|
|
789
|
-
prompt="Task ID"
|
|
790
|
-
onDone={(value) => {
|
|
791
|
-
submitted = value
|
|
792
|
-
}}
|
|
793
|
-
/>
|
|
794
|
-
),
|
|
795
|
-
{ width: 60, height: 4 },
|
|
796
|
-
)
|
|
797
|
-
try {
|
|
798
|
-
await input.renderer.setupTerminal()
|
|
799
|
-
await input.renderOnce()
|
|
800
|
-
await Bun.sleep(0)
|
|
801
|
-
expect(input.renderer.keyInput.listenerCount("keypress")).toBeGreaterThan(
|
|
802
|
-
0,
|
|
803
|
-
)
|
|
804
|
-
await input.mockInput.typeText("improve-ui")
|
|
805
|
-
input.mockInput.pressEnter()
|
|
806
|
-
await input.waitFor(() => submitted !== undefined)
|
|
807
|
-
expect(submitted).toBe("improve-ui")
|
|
808
|
-
} finally {
|
|
809
|
-
input.renderer.destroy()
|
|
810
|
-
}
|
|
811
|
-
|
|
812
|
-
let cancelled: string | null | undefined
|
|
813
|
-
const select = await testRender(
|
|
814
|
-
() => (
|
|
815
|
-
<InteractiveSelectPrompt
|
|
816
|
-
prompt="Cancel"
|
|
817
|
-
choices={[{ key: "one", label: "One" }]}
|
|
818
|
-
onDone={(value) => {
|
|
819
|
-
cancelled = value
|
|
820
|
-
}}
|
|
821
|
-
/>
|
|
822
|
-
),
|
|
823
|
-
{ width: 60, height: 4 },
|
|
824
|
-
)
|
|
825
|
-
try {
|
|
826
|
-
await select.renderer.setupTerminal()
|
|
827
|
-
await select.renderOnce()
|
|
828
|
-
await Bun.sleep(0)
|
|
829
|
-
select.mockInput.pressCtrlC()
|
|
830
|
-
await select.waitFor(() => cancelled !== undefined)
|
|
831
|
-
expect(cancelled).toBeNull()
|
|
832
|
-
} finally {
|
|
833
|
-
select.renderer.destroy()
|
|
834
|
-
}
|
|
835
|
-
|
|
836
|
-
let escaped: string | null | undefined
|
|
837
|
-
const escape = await testRender(
|
|
838
|
-
() => (
|
|
839
|
-
<InteractiveSelectPrompt
|
|
840
|
-
prompt="Cancel"
|
|
841
|
-
choices={[{ key: "one", label: "One" }]}
|
|
842
|
-
onDone={(value) => {
|
|
843
|
-
escaped = value
|
|
844
|
-
}}
|
|
845
|
-
/>
|
|
846
|
-
),
|
|
847
|
-
{ width: 60, height: 4, kittyKeyboard: true },
|
|
848
|
-
)
|
|
849
|
-
try {
|
|
850
|
-
await escape.renderer.setupTerminal()
|
|
851
|
-
await escape.renderOnce()
|
|
852
|
-
await Bun.sleep(0)
|
|
853
|
-
escape.mockInput.pressEscape()
|
|
854
|
-
await escape.waitFor(() => escaped !== undefined)
|
|
855
|
-
expect(escaped).toBeNull()
|
|
856
|
-
} finally {
|
|
857
|
-
escape.renderer.destroy()
|
|
858
|
-
}
|
|
859
|
-
})
|
|
860
|
-
})
|