@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
package/src/test-utils.ts
DELETED
|
@@ -1,110 +0,0 @@
|
|
|
1
|
-
import { spyOn } from "bun:test"
|
|
2
|
-
import { Effect, Layer } from "effect"
|
|
3
|
-
import { mkdtemp, rm } from "node:fs/promises"
|
|
4
|
-
import { tmpdir } from "node:os"
|
|
5
|
-
import { join } from "node:path"
|
|
6
|
-
import { FileSystemService } from "./services/FileSystemService"
|
|
7
|
-
import { WorkbaseService } from "./services/WorkbaseService"
|
|
8
|
-
import { RepositoryService } from "./services/RepositoryService"
|
|
9
|
-
import { EpicService } from "./services/EpicService"
|
|
10
|
-
import { TaskService } from "./services/TaskService"
|
|
11
|
-
import { PhaseService } from "./services/PhaseService"
|
|
12
|
-
import { WorktreeService } from "./services/WorktreeService"
|
|
13
|
-
import { PullRequestService } from "./services/PullRequestService"
|
|
14
|
-
import { PushService } from "./services/PushService"
|
|
15
|
-
import { ArchiveService } from "./services/ArchiveService"
|
|
16
|
-
import { IntegrationService } from "./services/IntegrationService"
|
|
17
|
-
import { ContextService } from "./services/ContextService"
|
|
18
|
-
import { GraphService } from "./services/GraphService"
|
|
19
|
-
import { SyncService } from "./services/SyncService"
|
|
20
|
-
import { ReadinessService } from "./services/ReadinessService"
|
|
21
|
-
import { GraphMutationService } from "./services/GraphMutationService"
|
|
22
|
-
import { DoctorService } from "./services/DoctorService"
|
|
23
|
-
import { ReviewService } from "./services/ReviewService"
|
|
24
|
-
import {
|
|
25
|
-
GitVersionControlService,
|
|
26
|
-
VersionControlService,
|
|
27
|
-
} from "./services/VersionControlService"
|
|
28
|
-
|
|
29
|
-
export const createTempDir = () => mkdtemp(join(tmpdir(), "agency-test-"))
|
|
30
|
-
|
|
31
|
-
export const cleanupTempDir = (path: string) =>
|
|
32
|
-
rm(path, { recursive: true, force: true })
|
|
33
|
-
|
|
34
|
-
export const trackDocumentReadConcurrency = (fs: FileSystemService) => {
|
|
35
|
-
let active = 0
|
|
36
|
-
let maximum = 0
|
|
37
|
-
return {
|
|
38
|
-
fs: {
|
|
39
|
-
...fs,
|
|
40
|
-
readFile: (path: string) => {
|
|
41
|
-
if (!/(?:EPIC|TASK|PHASE)\.md$/.test(path)) return fs.readFile(path)
|
|
42
|
-
return Effect.gen(function* () {
|
|
43
|
-
active += 1
|
|
44
|
-
maximum = Math.max(maximum, active)
|
|
45
|
-
yield* Effect.sleep(5)
|
|
46
|
-
return yield* fs.readFile(path)
|
|
47
|
-
}).pipe(Effect.ensuring(Effect.sync(() => (active -= 1))))
|
|
48
|
-
},
|
|
49
|
-
} satisfies FileSystemService,
|
|
50
|
-
maximum: () => maximum,
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
const TestLayer = Layer.mergeAll(
|
|
55
|
-
FileSystemService.Default,
|
|
56
|
-
WorkbaseService.Default,
|
|
57
|
-
GitVersionControlService.Default,
|
|
58
|
-
VersionControlService.Default,
|
|
59
|
-
RepositoryService.Default,
|
|
60
|
-
EpicService.Default,
|
|
61
|
-
TaskService.Default,
|
|
62
|
-
PhaseService.Default,
|
|
63
|
-
WorktreeService.Default,
|
|
64
|
-
PullRequestService.Default,
|
|
65
|
-
PushService.Default,
|
|
66
|
-
ArchiveService.Default,
|
|
67
|
-
IntegrationService.Default,
|
|
68
|
-
ContextService.Default,
|
|
69
|
-
GraphService.Default,
|
|
70
|
-
SyncService.Default,
|
|
71
|
-
ReadinessService.Default,
|
|
72
|
-
GraphMutationService.Default,
|
|
73
|
-
DoctorService.Default,
|
|
74
|
-
ReviewService.Default,
|
|
75
|
-
)
|
|
76
|
-
|
|
77
|
-
export async function runTestEffect<A, E>(
|
|
78
|
-
effect: Effect.Effect<A, E, any>,
|
|
79
|
-
): Promise<A> {
|
|
80
|
-
const program = effect.pipe(
|
|
81
|
-
Effect.provide(TestLayer),
|
|
82
|
-
Effect.catchAllDefect((defect) =>
|
|
83
|
-
Effect.fail(defect instanceof Error ? defect : new Error(String(defect))),
|
|
84
|
-
),
|
|
85
|
-
) as Effect.Effect<A, E | Error, never>
|
|
86
|
-
|
|
87
|
-
return Effect.runPromise(program)
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
async function captureConsole(
|
|
91
|
-
method: "log" | "error",
|
|
92
|
-
run: () => Promise<unknown>,
|
|
93
|
-
): Promise<string[]> {
|
|
94
|
-
const logs: string[] = []
|
|
95
|
-
const output = spyOn(console, method).mockImplementation((...args) => {
|
|
96
|
-
logs.push(args.join(" "))
|
|
97
|
-
})
|
|
98
|
-
try {
|
|
99
|
-
await run()
|
|
100
|
-
return logs
|
|
101
|
-
} finally {
|
|
102
|
-
output.mockRestore()
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
export const captureLogs = (run: () => Promise<unknown>) =>
|
|
107
|
-
captureConsole("log", run)
|
|
108
|
-
|
|
109
|
-
export const captureErrors = (run: () => Promise<unknown>) =>
|
|
110
|
-
captureConsole("error", run)
|
package/src/usage-log.test.ts
DELETED
|
@@ -1,188 +0,0 @@
|
|
|
1
|
-
import { afterEach, describe, expect, test } from "bun:test"
|
|
2
|
-
import { Database } from "bun:sqlite"
|
|
3
|
-
import { rm } from "node:fs/promises"
|
|
4
|
-
import { join } from "node:path"
|
|
5
|
-
import { cleanupTempDir, createTempDir } from "./test-utils"
|
|
6
|
-
import {
|
|
7
|
-
exportUsageEvents,
|
|
8
|
-
recordUsageEvent,
|
|
9
|
-
usageOutcomeCode,
|
|
10
|
-
} from "./usage-log"
|
|
11
|
-
|
|
12
|
-
describe("usage logging", () => {
|
|
13
|
-
const tempDirs: string[] = []
|
|
14
|
-
|
|
15
|
-
afterEach(() => Promise.all(tempDirs.splice(0).map(cleanupTempDir)))
|
|
16
|
-
|
|
17
|
-
test("maps only reviewed failure categories", () => {
|
|
18
|
-
expect(usageOutcomeCode({ _tag: "WorkbaseNotFoundError" })).toBe(
|
|
19
|
-
"WORKBASE_NOT_FOUND",
|
|
20
|
-
)
|
|
21
|
-
expect(usageOutcomeCode({ _tag: "private-customer-id" })).toBe(
|
|
22
|
-
"COMMAND_FAILED",
|
|
23
|
-
)
|
|
24
|
-
})
|
|
25
|
-
|
|
26
|
-
test("stores versioned privacy-safe events in session order", async () => {
|
|
27
|
-
const state = await createTempDir()
|
|
28
|
-
tempDirs.push(state)
|
|
29
|
-
const env = {
|
|
30
|
-
XDG_STATE_HOME: state,
|
|
31
|
-
AGENCY_SESSION_ID: "session-1",
|
|
32
|
-
AGENCY_INVOCATION_SOURCE: "automation",
|
|
33
|
-
AGENCY_USAGE_TEST: "1",
|
|
34
|
-
} as NodeJS.ProcessEnv
|
|
35
|
-
for (const commandPath of ["worktree/prepare", "context"]) {
|
|
36
|
-
await recordUsageEvent(
|
|
37
|
-
{
|
|
38
|
-
commandPath,
|
|
39
|
-
flagNames: ["json", "task", "json"],
|
|
40
|
-
durationMs: 12.4,
|
|
41
|
-
outcome: "success",
|
|
42
|
-
outcomeCode: "SUCCESS",
|
|
43
|
-
exitStatus: 0,
|
|
44
|
-
...(commandPath === "context"
|
|
45
|
-
? {
|
|
46
|
-
vcs: "git" as const,
|
|
47
|
-
terminalStage: "publish",
|
|
48
|
-
category: "success",
|
|
49
|
-
}
|
|
50
|
-
: {}),
|
|
51
|
-
},
|
|
52
|
-
"1.2.3",
|
|
53
|
-
env,
|
|
54
|
-
)
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
expect(await Bun.file(join(state, "agency/usage.sqlite3")).exists()).toBe(
|
|
58
|
-
true,
|
|
59
|
-
)
|
|
60
|
-
const events = await exportUsageEvents(env)
|
|
61
|
-
expect(events).toEqual([
|
|
62
|
-
expect.objectContaining({
|
|
63
|
-
version: 2,
|
|
64
|
-
journeyId: expect.stringMatching(/^sha256:[a-f0-9]{64}$/),
|
|
65
|
-
journeySequence: 1,
|
|
66
|
-
invocationSource: "automation",
|
|
67
|
-
isTest: true,
|
|
68
|
-
agencyVersion: "1.2.3",
|
|
69
|
-
commandPath: "worktree/prepare",
|
|
70
|
-
flagNames: ["json", "task"],
|
|
71
|
-
durationMs: 12,
|
|
72
|
-
outcome: "success",
|
|
73
|
-
outcomeCode: "SUCCESS",
|
|
74
|
-
exitStatus: 0,
|
|
75
|
-
}),
|
|
76
|
-
expect.objectContaining({
|
|
77
|
-
journeySequence: 2,
|
|
78
|
-
commandPath: "context",
|
|
79
|
-
vcs: "git",
|
|
80
|
-
terminalStage: "publish",
|
|
81
|
-
category: "success",
|
|
82
|
-
}),
|
|
83
|
-
])
|
|
84
|
-
expect(JSON.stringify(events)).not.toContain("session-1")
|
|
85
|
-
})
|
|
86
|
-
|
|
87
|
-
test("supports opt-out and ignores unavailable storage", async () => {
|
|
88
|
-
const state = await createTempDir()
|
|
89
|
-
tempDirs.push(state)
|
|
90
|
-
const disabled = {
|
|
91
|
-
XDG_STATE_HOME: state,
|
|
92
|
-
AGENCY_NO_USAGE_LOG: "true",
|
|
93
|
-
} as NodeJS.ProcessEnv
|
|
94
|
-
await recordUsageEvent(
|
|
95
|
-
{
|
|
96
|
-
commandPath: "status",
|
|
97
|
-
flagNames: [],
|
|
98
|
-
durationMs: 1,
|
|
99
|
-
outcome: "failure",
|
|
100
|
-
outcomeCode: "COMMAND_FAILED",
|
|
101
|
-
exitStatus: 1,
|
|
102
|
-
},
|
|
103
|
-
"1.2.3",
|
|
104
|
-
disabled,
|
|
105
|
-
)
|
|
106
|
-
expect(await Bun.file(join(state, "agency/usage.sqlite3")).exists()).toBe(
|
|
107
|
-
false,
|
|
108
|
-
)
|
|
109
|
-
|
|
110
|
-
const blocked = join(state, "blocked")
|
|
111
|
-
await Bun.write(blocked, "not a directory")
|
|
112
|
-
await expect(
|
|
113
|
-
recordUsageEvent(
|
|
114
|
-
{
|
|
115
|
-
commandPath: "status",
|
|
116
|
-
flagNames: [],
|
|
117
|
-
durationMs: 1,
|
|
118
|
-
outcome: "failure",
|
|
119
|
-
outcomeCode: "COMMAND_FAILED",
|
|
120
|
-
exitStatus: 1,
|
|
121
|
-
},
|
|
122
|
-
"1.2.3",
|
|
123
|
-
{ AGENCY_USAGE_DB: join(blocked, "usage.sqlite3") },
|
|
124
|
-
),
|
|
125
|
-
).resolves.toBeUndefined()
|
|
126
|
-
await rm(blocked)
|
|
127
|
-
})
|
|
128
|
-
|
|
129
|
-
test("prunes expired events on every database access", async () => {
|
|
130
|
-
const state = await createTempDir()
|
|
131
|
-
tempDirs.push(state)
|
|
132
|
-
const env = {
|
|
133
|
-
XDG_STATE_HOME: state,
|
|
134
|
-
AGENCY_USAGE_RETENTION_DAYS: "30",
|
|
135
|
-
} as NodeJS.ProcessEnv
|
|
136
|
-
await recordUsageEvent(
|
|
137
|
-
{
|
|
138
|
-
commandPath: "status",
|
|
139
|
-
flagNames: [],
|
|
140
|
-
durationMs: 1,
|
|
141
|
-
outcome: "success",
|
|
142
|
-
outcomeCode: "SUCCESS",
|
|
143
|
-
exitStatus: 0,
|
|
144
|
-
},
|
|
145
|
-
"1.2.3",
|
|
146
|
-
env,
|
|
147
|
-
)
|
|
148
|
-
const database = new Database(join(state, "agency/usage.sqlite3"))
|
|
149
|
-
database.run(
|
|
150
|
-
"UPDATE usage_events SET occurred_at = datetime('now', '-31 days')",
|
|
151
|
-
)
|
|
152
|
-
database.close()
|
|
153
|
-
|
|
154
|
-
expect(await exportUsageEvents(env)).toEqual([])
|
|
155
|
-
})
|
|
156
|
-
|
|
157
|
-
test("removes legacy events that may contain positional values", async () => {
|
|
158
|
-
const state = await createTempDir()
|
|
159
|
-
tempDirs.push(state)
|
|
160
|
-
const path = join(state, "usage.sqlite3")
|
|
161
|
-
const database = new Database(path, { create: true })
|
|
162
|
-
database.run(
|
|
163
|
-
"CREATE TABLE usage_events (id INTEGER PRIMARY KEY, command_path TEXT NOT NULL)",
|
|
164
|
-
)
|
|
165
|
-
database.run("INSERT INTO usage_events (command_path) VALUES (?)", [
|
|
166
|
-
"task/private-customer-id",
|
|
167
|
-
])
|
|
168
|
-
database.close()
|
|
169
|
-
const env = { AGENCY_USAGE_DB: path } as NodeJS.ProcessEnv
|
|
170
|
-
|
|
171
|
-
await recordUsageEvent(
|
|
172
|
-
{
|
|
173
|
-
commandPath: "task/show",
|
|
174
|
-
flagNames: [],
|
|
175
|
-
durationMs: 1,
|
|
176
|
-
outcome: "success",
|
|
177
|
-
outcomeCode: "SUCCESS",
|
|
178
|
-
exitStatus: 0,
|
|
179
|
-
},
|
|
180
|
-
"1.2.3",
|
|
181
|
-
env,
|
|
182
|
-
)
|
|
183
|
-
|
|
184
|
-
expect(await exportUsageEvents(env)).toEqual([
|
|
185
|
-
expect.objectContaining({ commandPath: "task/show", version: 2 }),
|
|
186
|
-
])
|
|
187
|
-
})
|
|
188
|
-
})
|
|
@@ -1,192 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test"
|
|
2
|
-
import { Effect } from "effect"
|
|
3
|
-
import { choose, type ChooserIO } from "./chooser"
|
|
4
|
-
|
|
5
|
-
const choices = [
|
|
6
|
-
{ key: "first-key", label: "\x1b[32mFirst\x1b[0m", value: 1 },
|
|
7
|
-
{ key: "second key", label: "Second", value: 2 },
|
|
8
|
-
]
|
|
9
|
-
|
|
10
|
-
const createIO = (
|
|
11
|
-
overrides: Partial<ChooserIO> = {},
|
|
12
|
-
): ChooserIO & {
|
|
13
|
-
readonly selections: Array<{
|
|
14
|
-
readonly prompt: string
|
|
15
|
-
readonly choices: readonly {
|
|
16
|
-
readonly key: string
|
|
17
|
-
readonly label: string
|
|
18
|
-
}[]
|
|
19
|
-
}>
|
|
20
|
-
readonly inputs: string[]
|
|
21
|
-
} => {
|
|
22
|
-
const selections: Array<{
|
|
23
|
-
readonly prompt: string
|
|
24
|
-
readonly choices: readonly {
|
|
25
|
-
readonly key: string
|
|
26
|
-
readonly label: string
|
|
27
|
-
}[]
|
|
28
|
-
}> = []
|
|
29
|
-
const inputs: string[] = []
|
|
30
|
-
return {
|
|
31
|
-
inputIsTTY: true,
|
|
32
|
-
outputIsTTY: true,
|
|
33
|
-
color: false,
|
|
34
|
-
select: async (prompt, choices) => {
|
|
35
|
-
selections.push({ prompt, choices })
|
|
36
|
-
return choices[0]?.key ?? null
|
|
37
|
-
},
|
|
38
|
-
run: async (_command, input) => {
|
|
39
|
-
inputs.push(input)
|
|
40
|
-
return { exitCode: 0, stdout: "first-key\n" }
|
|
41
|
-
},
|
|
42
|
-
...overrides,
|
|
43
|
-
selections,
|
|
44
|
-
inputs,
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
describe("chooser", () => {
|
|
49
|
-
test("offers plain choices to the native interactive renderer", async () => {
|
|
50
|
-
let offered:
|
|
51
|
-
| {
|
|
52
|
-
readonly prompt: string
|
|
53
|
-
readonly choices: readonly {
|
|
54
|
-
readonly key: string
|
|
55
|
-
readonly label: string
|
|
56
|
-
}[]
|
|
57
|
-
}
|
|
58
|
-
| undefined
|
|
59
|
-
const io = createIO({
|
|
60
|
-
select: async (prompt, offeredChoices) => {
|
|
61
|
-
offered = { prompt, choices: offeredChoices }
|
|
62
|
-
return offeredChoices[1]!.key
|
|
63
|
-
},
|
|
64
|
-
})
|
|
65
|
-
|
|
66
|
-
const result = await Effect.runPromise(
|
|
67
|
-
choose("Pick one", choices, undefined, io),
|
|
68
|
-
)
|
|
69
|
-
|
|
70
|
-
expect(result).toBe(2)
|
|
71
|
-
expect(offered).toEqual({
|
|
72
|
-
prompt: "Pick one",
|
|
73
|
-
choices: [
|
|
74
|
-
{ key: "first-key", label: "First" },
|
|
75
|
-
{ key: "second key", label: "Second" },
|
|
76
|
-
],
|
|
77
|
-
})
|
|
78
|
-
})
|
|
79
|
-
|
|
80
|
-
test("passes hierarchy depth only to the native renderer", async () => {
|
|
81
|
-
let offered: readonly {
|
|
82
|
-
readonly key: string
|
|
83
|
-
readonly label: string
|
|
84
|
-
readonly depth?: number
|
|
85
|
-
readonly segments?: readonly {
|
|
86
|
-
readonly text: string
|
|
87
|
-
readonly color?: string
|
|
88
|
-
}[]
|
|
89
|
-
}[] = []
|
|
90
|
-
const io = createIO({
|
|
91
|
-
select: async (_prompt, choices) => {
|
|
92
|
-
offered = choices
|
|
93
|
-
return choices[0]!.key
|
|
94
|
-
},
|
|
95
|
-
})
|
|
96
|
-
|
|
97
|
-
await Effect.runPromise(
|
|
98
|
-
choose(
|
|
99
|
-
"Pick one",
|
|
100
|
-
[
|
|
101
|
-
{
|
|
102
|
-
key: "parent",
|
|
103
|
-
label: "Parent",
|
|
104
|
-
depth: 0,
|
|
105
|
-
segments: [{ text: "P", color: "#c6a0f6" }],
|
|
106
|
-
value: 1,
|
|
107
|
-
},
|
|
108
|
-
{ key: "child", label: "Child", depth: 1, value: 2 },
|
|
109
|
-
],
|
|
110
|
-
undefined,
|
|
111
|
-
io,
|
|
112
|
-
),
|
|
113
|
-
)
|
|
114
|
-
|
|
115
|
-
expect(offered).toEqual([
|
|
116
|
-
{
|
|
117
|
-
key: "parent",
|
|
118
|
-
label: "Parent",
|
|
119
|
-
depth: 0,
|
|
120
|
-
segments: [{ text: "P", color: "#c6a0f6" }],
|
|
121
|
-
},
|
|
122
|
-
{ key: "child", label: "Child", depth: 1 },
|
|
123
|
-
])
|
|
124
|
-
expect(io.inputs).toEqual([])
|
|
125
|
-
})
|
|
126
|
-
|
|
127
|
-
test("preserves colors for configured external choosers", async () => {
|
|
128
|
-
const io = createIO({ color: true })
|
|
129
|
-
|
|
130
|
-
await Effect.runPromise(choose("Pick one", choices, ["chooser"], io))
|
|
131
|
-
|
|
132
|
-
expect(io.inputs[0]).toContain("\x1b[32mFirst\x1b[0m")
|
|
133
|
-
})
|
|
134
|
-
|
|
135
|
-
test("passes generic records to an external argv command", async () => {
|
|
136
|
-
const io = createIO()
|
|
137
|
-
|
|
138
|
-
const result = await Effect.runPromise(
|
|
139
|
-
choose("Pick one", choices, ["custom-chooser", "--flag"], io),
|
|
140
|
-
)
|
|
141
|
-
|
|
142
|
-
expect(result).toBe(1)
|
|
143
|
-
expect(io.inputs).toEqual(["first-key\tFirst\nsecond key\tSecond\n"])
|
|
144
|
-
})
|
|
145
|
-
|
|
146
|
-
test("accepts a selected record from fzf or gum", async () => {
|
|
147
|
-
const io = createIO({
|
|
148
|
-
run: async () => ({ exitCode: 0, stdout: "second key\tSecond\n" }),
|
|
149
|
-
})
|
|
150
|
-
|
|
151
|
-
expect(
|
|
152
|
-
await Effect.runPromise(choose("Pick", choices, ["gum", "filter"], io)),
|
|
153
|
-
).toBe(2)
|
|
154
|
-
})
|
|
155
|
-
|
|
156
|
-
test("treats native and external cancellation as no selection", async () => {
|
|
157
|
-
const native = createIO({ select: async () => null })
|
|
158
|
-
const external = createIO({
|
|
159
|
-
run: async () => ({ exitCode: 130, stdout: "" }),
|
|
160
|
-
})
|
|
161
|
-
|
|
162
|
-
expect(
|
|
163
|
-
await Effect.runPromise(choose("Pick", choices, undefined, native)),
|
|
164
|
-
).toBeNull()
|
|
165
|
-
expect(
|
|
166
|
-
await Effect.runPromise(choose("Pick", choices, ["chooser"], external)),
|
|
167
|
-
).toBeNull()
|
|
168
|
-
})
|
|
169
|
-
|
|
170
|
-
test("uses one typed error for unavailable input and invalid keys", async () => {
|
|
171
|
-
const nonTTY = createIO({ inputIsTTY: false })
|
|
172
|
-
const unknownKey = createIO({
|
|
173
|
-
run: async () => ({ exitCode: 0, stdout: "missing\n" }),
|
|
174
|
-
})
|
|
175
|
-
|
|
176
|
-
const unavailable = await Effect.runPromise(
|
|
177
|
-
Effect.flip(choose("Pick", choices, undefined, nonTTY)),
|
|
178
|
-
)
|
|
179
|
-
const invalid = await Effect.runPromise(
|
|
180
|
-
Effect.flip(choose("Pick", choices, ["chooser"], unknownKey)),
|
|
181
|
-
)
|
|
182
|
-
|
|
183
|
-
expect(unavailable).toMatchObject({
|
|
184
|
-
name: "ChooserError",
|
|
185
|
-
reason: "input-unavailable",
|
|
186
|
-
})
|
|
187
|
-
expect(invalid).toMatchObject({
|
|
188
|
-
name: "ChooserError",
|
|
189
|
-
reason: "invalid-selection",
|
|
190
|
-
})
|
|
191
|
-
})
|
|
192
|
-
})
|
package/src/utils/effect.test.ts
DELETED
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
import { describe, expect, spyOn, test } from "bun:test"
|
|
2
|
-
import { captureLogs } from "../test-utils"
|
|
3
|
-
import { createLoggers } from "./effect"
|
|
4
|
-
|
|
5
|
-
describe("createLoggers", () => {
|
|
6
|
-
test("keeps JSON output machine-readable when verbose is enabled", async () => {
|
|
7
|
-
const errors: string[] = []
|
|
8
|
-
const error = spyOn(console, "error").mockImplementation((...args) => {
|
|
9
|
-
errors.push(args.join(" "))
|
|
10
|
-
})
|
|
11
|
-
const logs = await captureLogs(async () => {
|
|
12
|
-
const { log, verboseLog } = createLoggers({ json: true, verbose: true })
|
|
13
|
-
verboseLog("debug")
|
|
14
|
-
log('{"ok":true}')
|
|
15
|
-
})
|
|
16
|
-
error.mockRestore()
|
|
17
|
-
|
|
18
|
-
expect(logs).toEqual(['{"ok":true}'])
|
|
19
|
-
expect(errors).toEqual(["debug"])
|
|
20
|
-
})
|
|
21
|
-
|
|
22
|
-
test("lets JSON output override silent", async () => {
|
|
23
|
-
const logs = await captureLogs(async () => {
|
|
24
|
-
const { log } = createLoggers({ json: true, silent: true })
|
|
25
|
-
log('{"ok":true}')
|
|
26
|
-
})
|
|
27
|
-
|
|
28
|
-
expect(logs).toEqual(['{"ok":true}'])
|
|
29
|
-
})
|
|
30
|
-
})
|
|
@@ -1,128 +0,0 @@
|
|
|
1
|
-
import { afterEach, describe, expect, test } from "bun:test"
|
|
2
|
-
import { join } from "node:path"
|
|
3
|
-
import { cleanupTempDir, createTempDir } from "../test-utils"
|
|
4
|
-
|
|
5
|
-
const projectRoot = join(import.meta.dir, "../..")
|
|
6
|
-
const cliPath = join(projectRoot, "cli.ts")
|
|
7
|
-
const tempDirs: string[] = []
|
|
8
|
-
|
|
9
|
-
afterEach(() => Promise.all(tempDirs.splice(0).map(cleanupTempDir)))
|
|
10
|
-
|
|
11
|
-
const modes = (terminal: Bun.Terminal) => ({
|
|
12
|
-
input: terminal.inputFlags,
|
|
13
|
-
output: terminal.outputFlags,
|
|
14
|
-
local: terminal.localFlags,
|
|
15
|
-
control: terminal.controlFlags,
|
|
16
|
-
})
|
|
17
|
-
|
|
18
|
-
const waitFor = async (condition: () => boolean, output: () => string) => {
|
|
19
|
-
const deadline = Date.now() + 8_000
|
|
20
|
-
while (!condition()) {
|
|
21
|
-
if (Date.now() >= deadline) {
|
|
22
|
-
throw new Error(`Timed out waiting for terminal output:\n${output()}`)
|
|
23
|
-
}
|
|
24
|
-
await Bun.sleep(10)
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
const waitForExit = (subprocess: Bun.Subprocess, output: () => string) =>
|
|
29
|
-
new Promise<number>((resolve, reject) => {
|
|
30
|
-
const timeout = setTimeout(() => {
|
|
31
|
-
reject(new Error(`Timed out waiting for CLI exit:\n${output()}`))
|
|
32
|
-
}, 8_000)
|
|
33
|
-
subprocess.exited.then(
|
|
34
|
-
(exitCode) => {
|
|
35
|
-
clearTimeout(timeout)
|
|
36
|
-
resolve(exitCode)
|
|
37
|
-
},
|
|
38
|
-
(error) => {
|
|
39
|
-
clearTimeout(timeout)
|
|
40
|
-
reject(error)
|
|
41
|
-
},
|
|
42
|
-
)
|
|
43
|
-
})
|
|
44
|
-
|
|
45
|
-
const createWorkbase = async () => {
|
|
46
|
-
const root = await createTempDir()
|
|
47
|
-
tempDirs.push(root)
|
|
48
|
-
const initialized = Bun.spawnSync(
|
|
49
|
-
[process.execPath, cliPath, "init", root, "--silent"],
|
|
50
|
-
{ stdout: "pipe", stderr: "pipe" },
|
|
51
|
-
)
|
|
52
|
-
if (initialized.exitCode !== 0) {
|
|
53
|
-
throw new Error(new TextDecoder().decode(initialized.stderr))
|
|
54
|
-
}
|
|
55
|
-
return root
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
const runPrompt = async (
|
|
59
|
-
drive: (terminal: Bun.Terminal, output: () => string) => Promise<void>,
|
|
60
|
-
) => {
|
|
61
|
-
const root = await createWorkbase()
|
|
62
|
-
const decoder = new TextDecoder()
|
|
63
|
-
let output = ""
|
|
64
|
-
const terminal = new Bun.Terminal({
|
|
65
|
-
cols: 80,
|
|
66
|
-
rows: 24,
|
|
67
|
-
data: (_terminal, bytes) => {
|
|
68
|
-
output += decoder.decode(bytes, { stream: true })
|
|
69
|
-
},
|
|
70
|
-
})
|
|
71
|
-
const initialModes = modes(terminal)
|
|
72
|
-
const subprocess = Bun.spawn([process.execPath, cliPath, "task", "new"], {
|
|
73
|
-
cwd: root,
|
|
74
|
-
env: { ...process.env, TERM: "xterm-256color" },
|
|
75
|
-
terminal,
|
|
76
|
-
})
|
|
77
|
-
|
|
78
|
-
try {
|
|
79
|
-
await waitFor(
|
|
80
|
-
() => output.includes("Task ID:"),
|
|
81
|
-
() => output,
|
|
82
|
-
)
|
|
83
|
-
const activeModes = modes(terminal)
|
|
84
|
-
expect(activeModes).not.toEqual(initialModes)
|
|
85
|
-
await drive(terminal, () => output)
|
|
86
|
-
const exitCode = await waitForExit(subprocess, () => output)
|
|
87
|
-
output += decoder.decode()
|
|
88
|
-
expect(exitCode).toBe(1)
|
|
89
|
-
expect(modes(terminal)).toEqual(initialModes)
|
|
90
|
-
expect(output.lastIndexOf("\x1b[?25h")).toBeGreaterThan(
|
|
91
|
-
output.lastIndexOf("\x1b[?25l"),
|
|
92
|
-
)
|
|
93
|
-
expect(output.lastIndexOf("\x1b[?2004l")).toBeGreaterThan(
|
|
94
|
-
output.lastIndexOf("\x1b[?2004h"),
|
|
95
|
-
)
|
|
96
|
-
return output
|
|
97
|
-
} finally {
|
|
98
|
-
if (subprocess.exitCode === null) {
|
|
99
|
-
subprocess.kill("SIGKILL")
|
|
100
|
-
await subprocess.exited
|
|
101
|
-
}
|
|
102
|
-
terminal.close()
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
describe("interactive CLI terminal restoration", () => {
|
|
107
|
-
test("restores terminal state after submission, resize, and escape", async () => {
|
|
108
|
-
const output = await runPrompt(async (terminal, currentOutput) => {
|
|
109
|
-
terminal.resize(30, 8)
|
|
110
|
-
terminal.write("pty-contract\r")
|
|
111
|
-
await waitFor(
|
|
112
|
-
() => currentOutput().includes("Ticket URL (optional):"),
|
|
113
|
-
currentOutput,
|
|
114
|
-
)
|
|
115
|
-
terminal.write("\x1b")
|
|
116
|
-
})
|
|
117
|
-
|
|
118
|
-
expect(output).toContain("Failed to read task input")
|
|
119
|
-
}, 12_000)
|
|
120
|
-
|
|
121
|
-
test("restores terminal state after ctrl-c", async () => {
|
|
122
|
-
const output = await runPrompt(async (terminal) => {
|
|
123
|
-
terminal.write("\x03")
|
|
124
|
-
})
|
|
125
|
-
|
|
126
|
-
expect(output).toContain("Failed to read task input")
|
|
127
|
-
}, 12_000)
|
|
128
|
-
})
|