opencastle 0.8.2 → 0.9.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.
@@ -0,0 +1,337 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
2
+ import { mkdtemp, writeFile, mkdir, rm } from 'node:fs/promises'
3
+ import { join } from 'node:path'
4
+ import { tmpdir } from 'node:os'
5
+ import { detectRepoInfo, mergeStackIntoRepoInfo, formatRepoInfo } from './detect.js'
6
+ import type { StackConfig, RepoInfo } from './types.js'
7
+
8
+ // ── detectRepoInfo (filesystem-backed) ─────────────────────────
9
+
10
+ describe('detectRepoInfo', () => {
11
+ let tempDir: string
12
+
13
+ beforeEach(async () => {
14
+ tempDir = await mkdtemp(join(tmpdir(), 'opencastle-test-'))
15
+ })
16
+
17
+ afterEach(async () => {
18
+ await rm(tempDir, { recursive: true, force: true })
19
+ })
20
+
21
+ it('detects npm from package-lock.json', async () => {
22
+ await writeFile(join(tempDir, 'package-lock.json'), '{}')
23
+ const info = await detectRepoInfo(tempDir)
24
+ expect(info.packageManager).toBe('npm')
25
+ })
26
+
27
+ it('detects pnpm from pnpm-lock.yaml', async () => {
28
+ await writeFile(join(tempDir, 'pnpm-lock.yaml'), '')
29
+ const info = await detectRepoInfo(tempDir)
30
+ expect(info.packageManager).toBe('pnpm')
31
+ })
32
+
33
+ it('detects yarn from yarn.lock', async () => {
34
+ await writeFile(join(tempDir, 'yarn.lock'), '')
35
+ const info = await detectRepoInfo(tempDir)
36
+ expect(info.packageManager).toBe('yarn')
37
+ })
38
+
39
+ it('detects TypeScript from tsconfig.json', async () => {
40
+ await writeFile(join(tempDir, 'tsconfig.json'), '{}')
41
+ const info = await detectRepoInfo(tempDir)
42
+ expect(info.language).toBe('typescript')
43
+ })
44
+
45
+ it('detects JavaScript from jsconfig.json', async () => {
46
+ await writeFile(join(tempDir, 'jsconfig.json'), '{}')
47
+ const info = await detectRepoInfo(tempDir)
48
+ expect(info.language).toBe('javascript')
49
+ })
50
+
51
+ it('detects Next.js from next.config.mjs', async () => {
52
+ await writeFile(join(tempDir, 'next.config.mjs'), 'export default {}')
53
+ const info = await detectRepoInfo(tempDir)
54
+ expect(info.frameworks).toContain('next')
55
+ })
56
+
57
+ it('detects Astro from astro.config.mjs', async () => {
58
+ await writeFile(join(tempDir, 'astro.config.mjs'), 'export default {}')
59
+ const info = await detectRepoInfo(tempDir)
60
+ expect(info.frameworks).toContain('astro')
61
+ })
62
+
63
+ it('detects NX monorepo from nx.json', async () => {
64
+ await writeFile(join(tempDir, 'nx.json'), '{}')
65
+ const info = await detectRepoInfo(tempDir)
66
+ expect(info.monorepo).toBe('nx')
67
+ })
68
+
69
+ it('detects Supabase from supabase/config.toml', async () => {
70
+ await mkdir(join(tempDir, 'supabase'), { recursive: true })
71
+ await writeFile(join(tempDir, 'supabase', 'config.toml'), '')
72
+ const info = await detectRepoInfo(tempDir)
73
+ expect(info.databases).toContain('supabase')
74
+ })
75
+
76
+ it('detects Prisma from prisma/schema.prisma', async () => {
77
+ await mkdir(join(tempDir, 'prisma'), { recursive: true })
78
+ await writeFile(join(tempDir, 'prisma', 'schema.prisma'), '')
79
+ const info = await detectRepoInfo(tempDir)
80
+ expect(info.databases).toContain('prisma')
81
+ })
82
+
83
+ it('detects Vercel from vercel.json', async () => {
84
+ await writeFile(join(tempDir, 'vercel.json'), '{}')
85
+ const info = await detectRepoInfo(tempDir)
86
+ expect(info.deployment).toContain('vercel')
87
+ })
88
+
89
+ it('detects Docker from Dockerfile', async () => {
90
+ await writeFile(join(tempDir, 'Dockerfile'), 'FROM node:22')
91
+ const info = await detectRepoInfo(tempDir)
92
+ expect(info.deployment).toContain('docker')
93
+ })
94
+
95
+ it('detects Playwright from playwright.config.ts', async () => {
96
+ await writeFile(join(tempDir, 'playwright.config.ts'), 'export default {}')
97
+ const info = await detectRepoInfo(tempDir)
98
+ expect(info.testing).toContain('playwright')
99
+ })
100
+
101
+ it('detects GitHub Actions from .github/workflows/', async () => {
102
+ await mkdir(join(tempDir, '.github', 'workflows'), { recursive: true })
103
+ const info = await detectRepoInfo(tempDir)
104
+ expect(info.cicd).toContain('github-actions')
105
+ })
106
+
107
+ it('detects Tailwind from tailwind.config.js', async () => {
108
+ await writeFile(join(tempDir, 'tailwind.config.js'), 'module.exports = {}')
109
+ const info = await detectRepoInfo(tempDir)
110
+ expect(info.styling).toContain('tailwind')
111
+ })
112
+
113
+ it('detects MCP config from .vscode/mcp.json', async () => {
114
+ await mkdir(join(tempDir, '.vscode'), { recursive: true })
115
+ await writeFile(join(tempDir, '.vscode', 'mcp.json'), '{}')
116
+ const info = await detectRepoInfo(tempDir)
117
+ expect(info.mcpConfig).toBe(true)
118
+ })
119
+
120
+ it('detects packages from package.json dependencies', async () => {
121
+ await writeFile(
122
+ join(tempDir, 'package.json'),
123
+ JSON.stringify({
124
+ dependencies: { next: '^14.0.0', '@supabase/supabase-js': '^2.0.0' },
125
+ devDependencies: { vitest: '^1.0.0', tailwindcss: '^3.0.0' },
126
+ })
127
+ )
128
+ const info = await detectRepoInfo(tempDir)
129
+ expect(info.frameworks).toContain('next')
130
+ expect(info.databases).toContain('supabase')
131
+ expect(info.testing).toContain('vitest')
132
+ expect(info.styling).toContain('tailwind')
133
+ })
134
+
135
+ it('detects corepack packageManager field', async () => {
136
+ await writeFile(
137
+ join(tempDir, 'package.json'),
138
+ JSON.stringify({ packageManager: 'pnpm@9.0.0' })
139
+ )
140
+ const info = await detectRepoInfo(tempDir)
141
+ expect(info.packageManager).toBe('pnpm')
142
+ })
143
+
144
+ it('returns clean object for empty directory', async () => {
145
+ const info = await detectRepoInfo(tempDir)
146
+ expect(info).toBeDefined()
147
+ // No undefined values — only populated fields
148
+ for (const value of Object.values(info)) {
149
+ expect(value).not.toBeUndefined()
150
+ }
151
+ })
152
+
153
+ it('deduplicates config files', async () => {
154
+ await writeFile(join(tempDir, 'package-lock.json'), '{}')
155
+ await writeFile(join(tempDir, 'tsconfig.json'), '{}')
156
+ const info = await detectRepoInfo(tempDir)
157
+ const unique = new Set(info.configFiles)
158
+ expect(info.configFiles?.length).toBe(unique.size)
159
+ })
160
+
161
+ it('sorts arrays for stable output', async () => {
162
+ await writeFile(
163
+ join(tempDir, 'package.json'),
164
+ JSON.stringify({
165
+ dependencies: { next: '1', express: '1' },
166
+ })
167
+ )
168
+ const info = await detectRepoInfo(tempDir)
169
+ if (info.frameworks && info.frameworks.length > 1) {
170
+ const sorted = [...info.frameworks].sort()
171
+ expect(info.frameworks).toEqual(sorted)
172
+ }
173
+ })
174
+
175
+ it('detects Sanity CMS from sanity.config.ts', async () => {
176
+ await writeFile(join(tempDir, 'sanity.config.ts'), 'export default {}')
177
+ const info = await detectRepoInfo(tempDir)
178
+ expect(info.cms).toContain('sanity')
179
+ })
180
+
181
+ it('auto-adds supabase-auth when supabase is detected', async () => {
182
+ await mkdir(join(tempDir, 'supabase'), { recursive: true })
183
+ await writeFile(join(tempDir, 'supabase', 'config.toml'), '')
184
+ const info = await detectRepoInfo(tempDir)
185
+ expect(info.auth).toContain('supabase-auth')
186
+ })
187
+
188
+ it('detects multiple tools simultaneously', async () => {
189
+ await writeFile(join(tempDir, 'next.config.mjs'), '')
190
+ await writeFile(join(tempDir, 'vercel.json'), '{}')
191
+ await writeFile(join(tempDir, 'tsconfig.json'), '{}')
192
+ await writeFile(join(tempDir, 'tailwind.config.js'), '')
193
+ const info = await detectRepoInfo(tempDir)
194
+ expect(info.frameworks).toContain('next')
195
+ expect(info.deployment).toContain('vercel')
196
+ expect(info.language).toBe('typescript')
197
+ expect(info.styling).toContain('tailwind')
198
+ })
199
+ })
200
+
201
+ // ── mergeStackIntoRepoInfo ─────────────────────────────────────
202
+
203
+ describe('mergeStackIntoRepoInfo', () => {
204
+ const emptyStack: StackConfig = { ides: [], techTools: [], teamTools: [] }
205
+
206
+ it('returns original info when stack is empty', () => {
207
+ const info: RepoInfo = { language: 'typescript' }
208
+ const merged = mergeStackIntoRepoInfo(info, emptyStack)
209
+ expect(merged.language).toBe('typescript')
210
+ })
211
+
212
+ it('adds CMS tools from techTools', () => {
213
+ const merged = mergeStackIntoRepoInfo(
214
+ {},
215
+ { ides: [], techTools: ['sanity'], teamTools: [] }
216
+ )
217
+ expect(merged.cms).toContain('sanity')
218
+ })
219
+
220
+ it('adds database tools from techTools', () => {
221
+ const merged = mergeStackIntoRepoInfo(
222
+ {},
223
+ { ides: [], techTools: ['supabase'], teamTools: [] }
224
+ )
225
+ expect(merged.databases).toContain('supabase')
226
+ })
227
+
228
+ it('adds deployment tools from techTools', () => {
229
+ const merged = mergeStackIntoRepoInfo(
230
+ {},
231
+ { ides: [], techTools: ['vercel'], teamTools: [] }
232
+ )
233
+ expect(merged.deployment).toContain('vercel')
234
+ })
235
+
236
+ it('sets NX monorepo from techTools', () => {
237
+ const merged = mergeStackIntoRepoInfo(
238
+ {},
239
+ { ides: [], techTools: ['nx'], teamTools: [] }
240
+ )
241
+ expect(merged.monorepo).toBe('nx')
242
+ })
243
+
244
+ it('does not overwrite existing monorepo with NX', () => {
245
+ const merged = mergeStackIntoRepoInfo(
246
+ { monorepo: 'turborepo' },
247
+ { ides: [], techTools: ['nx'], teamTools: [] }
248
+ )
249
+ expect(merged.monorepo).toBe('turborepo')
250
+ })
251
+
252
+ it('adds PM tools from teamTools', () => {
253
+ const merged = mergeStackIntoRepoInfo(
254
+ {},
255
+ { ides: [], techTools: [], teamTools: ['linear'] }
256
+ )
257
+ expect(merged.pm).toContain('linear')
258
+ })
259
+
260
+ it('adds notification tools from teamTools', () => {
261
+ const merged = mergeStackIntoRepoInfo(
262
+ {},
263
+ { ides: [], techTools: [], teamTools: ['slack'] }
264
+ )
265
+ expect(merged.notifications).toContain('slack')
266
+ })
267
+
268
+ it('deduplicates when tool already exists', () => {
269
+ const merged = mergeStackIntoRepoInfo(
270
+ { cms: ['sanity'] },
271
+ { ides: [], techTools: ['sanity'], teamTools: [] }
272
+ )
273
+ expect(merged.cms).toEqual(['sanity'])
274
+ })
275
+
276
+ it('preserves existing values while adding new ones', () => {
277
+ const merged = mergeStackIntoRepoInfo(
278
+ { databases: ['prisma'], language: 'typescript' },
279
+ { ides: [], techTools: ['supabase'], teamTools: ['linear'] }
280
+ )
281
+ expect(merged.databases).toContain('prisma')
282
+ expect(merged.databases).toContain('supabase')
283
+ expect(merged.language).toBe('typescript')
284
+ expect(merged.pm).toContain('linear')
285
+ })
286
+ })
287
+
288
+ // ── formatRepoInfo ─────────────────────────────────────────────
289
+
290
+ describe('formatRepoInfo', () => {
291
+ it('formats empty info as empty string', () => {
292
+ expect(formatRepoInfo({})).toBe('')
293
+ })
294
+
295
+ it('includes package manager', () => {
296
+ const output = formatRepoInfo({ packageManager: 'pnpm' })
297
+ expect(output).toContain('pnpm')
298
+ })
299
+
300
+ it('includes frameworks', () => {
301
+ const output = formatRepoInfo({ frameworks: ['next', 'astro'] })
302
+ expect(output).toContain('next')
303
+ expect(output).toContain('astro')
304
+ })
305
+
306
+ it('includes all populated fields', () => {
307
+ const output = formatRepoInfo({
308
+ packageManager: 'npm',
309
+ monorepo: 'nx',
310
+ language: 'typescript',
311
+ frameworks: ['next'],
312
+ databases: ['supabase'],
313
+ cms: ['sanity'],
314
+ deployment: ['vercel'],
315
+ testing: ['vitest'],
316
+ cicd: ['github-actions'],
317
+ styling: ['tailwind'],
318
+ auth: ['clerk'],
319
+ })
320
+ expect(output).toContain('npm')
321
+ expect(output).toContain('nx')
322
+ expect(output).toContain('typescript')
323
+ expect(output).toContain('next')
324
+ expect(output).toContain('supabase')
325
+ expect(output).toContain('sanity')
326
+ expect(output).toContain('vercel')
327
+ expect(output).toContain('vitest')
328
+ expect(output).toContain('github-actions')
329
+ expect(output).toContain('tailwind')
330
+ expect(output).toContain('clerk')
331
+ })
332
+
333
+ it('indents lines with 4 spaces', () => {
334
+ const output = formatRepoInfo({ packageManager: 'npm' })
335
+ expect(output).toMatch(/^ {4}/)
336
+ })
337
+ })
@@ -0,0 +1,338 @@
1
+ import { describe, it, expect, vi } from 'vitest'
2
+ import { buildPhases, createExecutor, formatDuration } from './executor.js'
3
+ import type { Task, TaskSpec, AgentAdapter, Reporter, TaskResult, RunReport } from '../types.js'
4
+
5
+ // ── Helpers ────────────────────────────────────────────────────
6
+
7
+ function makeTask(overrides: Partial<Task> & { id: string; prompt: string }): Task {
8
+ return {
9
+ agent: 'developer',
10
+ timeout: '10m',
11
+ depends_on: [],
12
+ files: [],
13
+ description: overrides.id,
14
+ ...overrides,
15
+ }
16
+ }
17
+
18
+ function makeAdapter(results?: Record<string, { success: boolean; exitCode: number }>): AgentAdapter {
19
+ return {
20
+ name: 'test-adapter',
21
+ isAvailable: async () => true,
22
+ execute: async (task) => {
23
+ const r = results?.[task.id] ?? { success: true, exitCode: 0 }
24
+ return { success: r.success, output: `output-${task.id}`, exitCode: r.exitCode }
25
+ },
26
+ }
27
+ }
28
+
29
+ function makeReporter(): Reporter & {
30
+ started: string[]; done: TaskResult[]; skipped: string[]; phases: number[]; report: RunReport | null
31
+ } {
32
+ const tracker = {
33
+ started: [] as string[],
34
+ done: [] as TaskResult[],
35
+ skipped: [] as string[],
36
+ phases: [] as number[],
37
+ report: null as RunReport | null,
38
+ onTaskStart(task: Task) { tracker.started.push(task.id) },
39
+ onTaskDone(_task: Task, result: TaskResult) { tracker.done.push(result) },
40
+ onTaskSkipped(task: Task, _reason: string) { tracker.skipped.push(task.id) },
41
+ onPhaseStart(phase: number) { tracker.phases.push(phase) },
42
+ onComplete: async (report: RunReport) => { tracker.report = report },
43
+ }
44
+ return tracker
45
+ }
46
+
47
+ // ── buildPhases ────────────────────────────────────────────────
48
+
49
+ describe('buildPhases', () => {
50
+ it('puts independent tasks in the same phase', () => {
51
+ const tasks = [
52
+ makeTask({ id: 'a', prompt: 'x' }),
53
+ makeTask({ id: 'b', prompt: 'y' }),
54
+ makeTask({ id: 'c', prompt: 'z' }),
55
+ ]
56
+ const phases = buildPhases(tasks)
57
+ expect(phases).toHaveLength(1)
58
+ expect(phases[0]).toHaveLength(3)
59
+ })
60
+
61
+ it('orders dependent tasks into separate phases', () => {
62
+ const tasks = [
63
+ makeTask({ id: 'a', prompt: 'x' }),
64
+ makeTask({ id: 'b', prompt: 'y', depends_on: ['a'] }),
65
+ makeTask({ id: 'c', prompt: 'z', depends_on: ['b'] }),
66
+ ]
67
+ const phases = buildPhases(tasks)
68
+ expect(phases).toHaveLength(3)
69
+ expect(phases[0].map((t) => t.id)).toEqual(['a'])
70
+ expect(phases[1].map((t) => t.id)).toEqual(['b'])
71
+ expect(phases[2].map((t) => t.id)).toEqual(['c'])
72
+ })
73
+
74
+ it('handles diamond dependency pattern', () => {
75
+ const tasks = [
76
+ makeTask({ id: 'a', prompt: 'x' }),
77
+ makeTask({ id: 'b', prompt: 'y', depends_on: ['a'] }),
78
+ makeTask({ id: 'c', prompt: 'z', depends_on: ['a'] }),
79
+ makeTask({ id: 'd', prompt: 'w', depends_on: ['b', 'c'] }),
80
+ ]
81
+ const phases = buildPhases(tasks)
82
+ expect(phases).toHaveLength(3)
83
+ expect(phases[0].map((t) => t.id)).toEqual(['a'])
84
+ expect(phases[1].map((t) => t.id).sort()).toEqual(['b', 'c'])
85
+ expect(phases[2].map((t) => t.id)).toEqual(['d'])
86
+ })
87
+
88
+ it('handles single task', () => {
89
+ const phases = buildPhases([makeTask({ id: 'solo', prompt: 'x' })])
90
+ expect(phases).toHaveLength(1)
91
+ expect(phases[0]).toHaveLength(1)
92
+ })
93
+
94
+ it('handles complex fan-out/fan-in', () => {
95
+ const tasks = [
96
+ makeTask({ id: 'root', prompt: 'x' }),
97
+ makeTask({ id: 'b1', prompt: 'y', depends_on: ['root'] }),
98
+ makeTask({ id: 'b2', prompt: 'y', depends_on: ['root'] }),
99
+ makeTask({ id: 'b3', prompt: 'y', depends_on: ['root'] }),
100
+ makeTask({ id: 'join', prompt: 'z', depends_on: ['b1', 'b2', 'b3'] }),
101
+ ]
102
+ const phases = buildPhases(tasks)
103
+ expect(phases).toHaveLength(3)
104
+ expect(phases[1]).toHaveLength(3)
105
+ })
106
+ })
107
+
108
+ // ── createExecutor ─────────────────────────────────────────────
109
+
110
+ describe('createExecutor', () => {
111
+ it('executes all tasks and reports success', async () => {
112
+ const spec: TaskSpec = {
113
+ name: 'test-run',
114
+ concurrency: 2,
115
+ on_failure: 'continue',
116
+ adapter: 'test',
117
+ tasks: [
118
+ makeTask({ id: 'a', prompt: 'x' }),
119
+ makeTask({ id: 'b', prompt: 'y' }),
120
+ ],
121
+ }
122
+ const reporter = makeReporter()
123
+ const executor = createExecutor(spec, makeAdapter(), reporter)
124
+ const report = await executor.run()
125
+
126
+ expect(report.summary.total).toBe(2)
127
+ expect(report.summary.done).toBe(2)
128
+ expect(report.summary.failed).toBe(0)
129
+ expect(reporter.started).toEqual(['a', 'b'])
130
+ })
131
+
132
+ it('skips dependents when a task fails (on_failure: continue)', async () => {
133
+ const spec: TaskSpec = {
134
+ name: 'test-run',
135
+ concurrency: 1,
136
+ on_failure: 'continue',
137
+ adapter: 'test',
138
+ tasks: [
139
+ makeTask({ id: 'a', prompt: 'x' }),
140
+ makeTask({ id: 'b', prompt: 'y', depends_on: ['a'] }),
141
+ ],
142
+ }
143
+ const adapter = makeAdapter({ a: { success: false, exitCode: 1 } })
144
+ const reporter = makeReporter()
145
+ const executor = createExecutor(spec, adapter, reporter)
146
+ const report = await executor.run()
147
+
148
+ expect(report.summary.failed).toBe(1)
149
+ expect(report.summary.skipped).toBe(1)
150
+ expect(reporter.skipped).toContain('b')
151
+ })
152
+
153
+ it('halts all tasks on failure when on_failure: stop', async () => {
154
+ const spec: TaskSpec = {
155
+ name: 'test-run',
156
+ concurrency: 1,
157
+ on_failure: 'stop',
158
+ adapter: 'test',
159
+ tasks: [
160
+ makeTask({ id: 'a', prompt: 'x' }),
161
+ makeTask({ id: 'b', prompt: 'y' }),
162
+ makeTask({ id: 'c', prompt: 'z' }),
163
+ ],
164
+ }
165
+ const adapter = makeAdapter({ a: { success: false, exitCode: 1 } })
166
+ const reporter = makeReporter()
167
+ const executor = createExecutor(spec, adapter, reporter)
168
+ const report = await executor.run()
169
+
170
+ expect(report.summary.failed).toBe(1)
171
+ expect(report.summary.skipped).toBe(2)
172
+ })
173
+
174
+ it('respects concurrency limit', async () => {
175
+ let maxConcurrent = 0
176
+ let currentConcurrent = 0
177
+
178
+ const adapter: AgentAdapter = {
179
+ name: 'test',
180
+ isAvailable: async () => true,
181
+ execute: async (task) => {
182
+ currentConcurrent++
183
+ maxConcurrent = Math.max(maxConcurrent, currentConcurrent)
184
+ await new Promise((r) => setTimeout(r, 50))
185
+ currentConcurrent--
186
+ return { success: true, output: '', exitCode: 0 }
187
+ },
188
+ }
189
+
190
+ const spec: TaskSpec = {
191
+ name: 'test',
192
+ concurrency: 2,
193
+ on_failure: 'continue',
194
+ adapter: 'test',
195
+ tasks: [
196
+ makeTask({ id: 'a', prompt: 'x' }),
197
+ makeTask({ id: 'b', prompt: 'y' }),
198
+ makeTask({ id: 'c', prompt: 'z' }),
199
+ makeTask({ id: 'd', prompt: 'w' }),
200
+ ],
201
+ }
202
+
203
+ const executor = createExecutor(spec, adapter, makeReporter())
204
+ await executor.run()
205
+
206
+ expect(maxConcurrent).toBeLessThanOrEqual(2)
207
+ })
208
+
209
+ it('reports phase starts', async () => {
210
+ const spec: TaskSpec = {
211
+ name: 'test',
212
+ concurrency: 1,
213
+ on_failure: 'continue',
214
+ adapter: 'test',
215
+ tasks: [
216
+ makeTask({ id: 'a', prompt: 'x' }),
217
+ makeTask({ id: 'b', prompt: 'y', depends_on: ['a'] }),
218
+ ],
219
+ }
220
+ const reporter = makeReporter()
221
+ const executor = createExecutor(spec, reporter as unknown as AgentAdapter, reporter)
222
+ // Actually use the adapter
223
+ const executor2 = createExecutor(spec, makeAdapter(), reporter)
224
+ await executor2.run()
225
+
226
+ expect(reporter.phases).toContain(1)
227
+ expect(reporter.phases).toContain(2)
228
+ })
229
+
230
+ it('records duration on the report', async () => {
231
+ const spec: TaskSpec = {
232
+ name: 'test',
233
+ concurrency: 1,
234
+ on_failure: 'continue',
235
+ adapter: 'test',
236
+ tasks: [makeTask({ id: 'a', prompt: 'x' })],
237
+ }
238
+ const executor = createExecutor(spec, makeAdapter(), makeReporter())
239
+ const report = await executor.run()
240
+
241
+ expect(report.duration).toBeDefined()
242
+ expect(report.startedAt).toBeDefined()
243
+ expect(report.completedAt).toBeDefined()
244
+ expect(report.name).toBe('test')
245
+ })
246
+
247
+ it('handles adapter throwing errors', async () => {
248
+ const adapter: AgentAdapter = {
249
+ name: 'failing',
250
+ isAvailable: async () => true,
251
+ execute: async () => { throw new Error('Adapter crashed') },
252
+ }
253
+ const spec: TaskSpec = {
254
+ name: 'test',
255
+ concurrency: 1,
256
+ on_failure: 'continue',
257
+ adapter: 'test',
258
+ tasks: [makeTask({ id: 'a', prompt: 'x' })],
259
+ }
260
+ const reporter = makeReporter()
261
+ const executor = createExecutor(spec, adapter, reporter)
262
+ const report = await executor.run()
263
+
264
+ expect(report.summary.failed).toBe(1)
265
+ expect(report.tasks[0].output).toContain('Adapter crashed')
266
+ })
267
+
268
+ it('getPhases returns the computed phases', () => {
269
+ const spec: TaskSpec = {
270
+ name: 'test',
271
+ concurrency: 1,
272
+ on_failure: 'continue',
273
+ adapter: 'test',
274
+ tasks: [
275
+ makeTask({ id: 'a', prompt: 'x' }),
276
+ makeTask({ id: 'b', prompt: 'y', depends_on: ['a'] }),
277
+ ],
278
+ }
279
+ const executor = createExecutor(spec, makeAdapter(), makeReporter())
280
+ const phases = executor.getPhases()
281
+ expect(phases).toHaveLength(2)
282
+ })
283
+
284
+ it('skips transitive dependents on failure', async () => {
285
+ const spec: TaskSpec = {
286
+ name: 'test',
287
+ concurrency: 1,
288
+ on_failure: 'continue',
289
+ adapter: 'test',
290
+ tasks: [
291
+ makeTask({ id: 'a', prompt: 'x' }),
292
+ makeTask({ id: 'b', prompt: 'y', depends_on: ['a'] }),
293
+ makeTask({ id: 'c', prompt: 'z', depends_on: ['b'] }),
294
+ ],
295
+ }
296
+ const adapter = makeAdapter({ a: { success: false, exitCode: 1 } })
297
+ const reporter = makeReporter()
298
+ const executor = createExecutor(spec, adapter, reporter)
299
+ const report = await executor.run()
300
+
301
+ expect(report.summary.failed).toBe(1)
302
+ expect(report.summary.skipped).toBe(2)
303
+ expect(reporter.skipped).toContain('b')
304
+ expect(reporter.skipped).toContain('c')
305
+ })
306
+ })
307
+
308
+ // ── formatDuration ─────────────────────────────────────────────
309
+
310
+ describe('formatDuration', () => {
311
+ it('formats milliseconds', () => {
312
+ expect(formatDuration(500)).toBe('500ms')
313
+ })
314
+
315
+ it('formats seconds', () => {
316
+ expect(formatDuration(5000)).toBe('5s')
317
+ })
318
+
319
+ it('formats minutes', () => {
320
+ expect(formatDuration(120_000)).toBe('2m')
321
+ })
322
+
323
+ it('formats minutes and seconds', () => {
324
+ expect(formatDuration(125_000)).toBe('2m 5s')
325
+ })
326
+
327
+ it('formats hours', () => {
328
+ expect(formatDuration(3_600_000)).toBe('1h')
329
+ })
330
+
331
+ it('formats hours and minutes', () => {
332
+ expect(formatDuration(5_400_000)).toBe('1h 30m')
333
+ })
334
+
335
+ it('handles zero', () => {
336
+ expect(formatDuration(0)).toBe('0ms')
337
+ })
338
+ })