Skip to content

Commit 3be9226

Browse files
alari76claude
andcommitted
Add workflow-loader tests covering MD parsing, step handlers, and cleanup
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 20bb2d7 commit 3be9226

File tree

1 file changed

+333
-0
lines changed

1 file changed

+333
-0
lines changed

server/workflow-loader.test.ts

Lines changed: 333 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,333 @@
1+
/* eslint-disable @typescript-eslint/no-explicit-any */
2+
import { describe, it, expect, beforeEach, vi } from 'vitest'
3+
4+
// Hoist mock fns so they're available at module evaluation time
5+
const mockExecSync = vi.hoisted(() => vi.fn())
6+
const mockExecFileSync = vi.hoisted(() => vi.fn())
7+
const mockExistsSync = vi.hoisted(() => vi.fn(() => true))
8+
const mockMkdirSync = vi.hoisted(() => vi.fn())
9+
const mockReaddirSync = vi.hoisted(() => vi.fn(() => [] as string[]))
10+
const mockReadFileSync = vi.hoisted(() => vi.fn(() => ''))
11+
const mockWriteFileSync = vi.hoisted(() => vi.fn())
12+
13+
vi.mock('child_process', () => ({
14+
execSync: (...args: any[]) => mockExecSync(...args),
15+
execFileSync: (...args: any[]) => mockExecFileSync(...args),
16+
}))
17+
18+
vi.mock('fs', async (importOriginal) => {
19+
const actual = await importOriginal<typeof import('fs')>()
20+
return {
21+
...actual,
22+
existsSync: (...args: any[]) => mockExistsSync(...args),
23+
mkdirSync: (...args: any[]) => mockMkdirSync(...args),
24+
readdirSync: (...args: any[]) => mockReaddirSync(...args),
25+
readFileSync: (...args: any[]) => mockReadFileSync(...args),
26+
writeFileSync: (...args: any[]) => mockWriteFileSync(...args),
27+
}
28+
})
29+
30+
// Mock better-sqlite3 (needed by workflow-engine import)
31+
vi.mock('better-sqlite3', () => {
32+
class MockDatabase {
33+
pragma = vi.fn()
34+
exec = vi.fn()
35+
prepare = vi.fn(() => ({
36+
run: vi.fn(() => ({ changes: 0 })),
37+
get: vi.fn(),
38+
all: vi.fn(() => []),
39+
}))
40+
close = vi.fn()
41+
}
42+
return { default: MockDatabase }
43+
})
44+
45+
import { loadMdWorkflows } from './workflow-loader.js'
46+
47+
// Valid workflow MD content
48+
const VALID_MD = `---
49+
kind: test-review.daily
50+
name: Test Review
51+
sessionPrefix: test-review
52+
outputDir: .codekin/reports/test
53+
filenameSuffix: _test-review.md
54+
commitMessage: chore: test review
55+
---
56+
You are performing an automated test review of the codebase.
57+
`
58+
59+
const VALID_MD_2 = `---
60+
kind: coverage.daily
61+
name: Coverage Assessment
62+
sessionPrefix: coverage
63+
outputDir: .codekin/reports/coverage
64+
filenameSuffix: _coverage.md
65+
commitMessage: chore: coverage
66+
---
67+
Analyze test coverage.
68+
`
69+
70+
function fakeEngine() {
71+
return {
72+
registerWorkflow: vi.fn(),
73+
} as any
74+
}
75+
76+
function fakeSessionManager() {
77+
return {
78+
create: vi.fn(() => ({ id: 'session-1' })),
79+
get: vi.fn(() => ({
80+
outputHistory: [],
81+
claudeProcess: { isAlive: () => false },
82+
})),
83+
startClaude: vi.fn(),
84+
stopClaude: vi.fn(),
85+
sendInput: vi.fn(),
86+
} as any
87+
}
88+
89+
describe('workflow-loader', () => {
90+
beforeEach(() => {
91+
vi.clearAllMocks()
92+
mockExistsSync.mockReturnValue(true)
93+
mockReaddirSync.mockReturnValue([])
94+
mockReadFileSync.mockReturnValue('')
95+
})
96+
97+
describe('loadMdWorkflows', () => {
98+
it('loads and registers workflows from MD files', () => {
99+
mockReaddirSync.mockReturnValue(['test-review.daily.md', 'coverage.daily.md'])
100+
mockReadFileSync.mockImplementation((path: string) => {
101+
if (String(path).includes('test-review')) return VALID_MD
102+
if (String(path).includes('coverage')) return VALID_MD_2
103+
return ''
104+
})
105+
106+
const engine = fakeEngine()
107+
const sessions = fakeSessionManager()
108+
109+
loadMdWorkflows(engine, sessions)
110+
111+
expect(engine.registerWorkflow).toHaveBeenCalledTimes(2)
112+
expect(engine.registerWorkflow).toHaveBeenCalledWith(
113+
expect.objectContaining({ kind: 'test-review.daily' })
114+
)
115+
expect(engine.registerWorkflow).toHaveBeenCalledWith(
116+
expect.objectContaining({ kind: 'coverage.daily' })
117+
)
118+
})
119+
120+
it('skips non-MD files', () => {
121+
mockReaddirSync.mockReturnValue(['readme.txt', 'test.md', 'config.json'])
122+
mockReadFileSync.mockReturnValue(VALID_MD)
123+
124+
const engine = fakeEngine()
125+
loadMdWorkflows(engine, fakeSessionManager())
126+
127+
// Only test.md should be processed
128+
expect(engine.registerWorkflow).toHaveBeenCalledTimes(1)
129+
})
130+
131+
it('handles missing workflows directory', () => {
132+
mockExistsSync.mockReturnValue(false)
133+
134+
const engine = fakeEngine()
135+
loadMdWorkflows(engine, fakeSessionManager())
136+
137+
expect(engine.registerWorkflow).not.toHaveBeenCalled()
138+
})
139+
140+
it('skips MD files with invalid frontmatter', () => {
141+
mockReaddirSync.mockReturnValue(['bad.md', 'good.md'])
142+
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
143+
mockReadFileSync.mockImplementation((path: string) => {
144+
if (String(path).includes('bad')) return 'no frontmatter here'
145+
return VALID_MD
146+
})
147+
148+
const engine = fakeEngine()
149+
loadMdWorkflows(engine, fakeSessionManager())
150+
151+
expect(engine.registerWorkflow).toHaveBeenCalledTimes(1)
152+
expect(consoleSpy).toHaveBeenCalled()
153+
consoleSpy.mockRestore()
154+
})
155+
156+
it('skips MD files with missing required fields', () => {
157+
const incompleteMd = `---
158+
kind: incomplete
159+
name: Incomplete
160+
---
161+
Some prompt.
162+
`
163+
mockReaddirSync.mockReturnValue(['incomplete.md'])
164+
mockReadFileSync.mockReturnValue(incompleteMd)
165+
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
166+
167+
const engine = fakeEngine()
168+
loadMdWorkflows(engine, fakeSessionManager())
169+
170+
expect(engine.registerWorkflow).not.toHaveBeenCalled()
171+
expect(consoleSpy).toHaveBeenCalled()
172+
consoleSpy.mockRestore()
173+
})
174+
})
175+
176+
describe('registered workflow steps', () => {
177+
let engine: any
178+
let sessions: any
179+
let registeredDef: any
180+
181+
beforeEach(() => {
182+
mockReaddirSync.mockReturnValue(['test.md'])
183+
mockReadFileSync.mockReturnValue(VALID_MD)
184+
185+
engine = fakeEngine()
186+
sessions = fakeSessionManager()
187+
loadMdWorkflows(engine, sessions)
188+
189+
registeredDef = engine.registerWorkflow.mock.calls[0][0]
190+
})
191+
192+
it('registers 4 steps', () => {
193+
expect(registeredDef.steps).toHaveLength(4)
194+
expect(registeredDef.steps.map((s: any) => s.key)).toEqual([
195+
'validate_repo',
196+
'create_session',
197+
'run_prompt',
198+
'save_report',
199+
])
200+
})
201+
202+
describe('validate_repo step', () => {
203+
it('throws when repoPath is missing', async () => {
204+
const handler = registeredDef.steps[0].handler
205+
await expect(handler({}, { runId: 'r1', run: {}, abortSignal: new AbortController().signal }))
206+
.rejects.toThrow('Missing repoPath')
207+
})
208+
209+
it('throws when repoPath does not exist', async () => {
210+
mockExistsSync.mockImplementation((p: string) => {
211+
if (String(p) === '/nonexistent') return false
212+
return true
213+
})
214+
215+
const handler = registeredDef.steps[0].handler
216+
await expect(handler({ repoPath: '/nonexistent' }, { runId: 'r1', run: {}, abortSignal: new AbortController().signal }))
217+
.rejects.toThrow('does not exist')
218+
})
219+
220+
it('validates a valid git repo', async () => {
221+
mockExecSync.mockReturnValue(Buffer.from('main\n'))
222+
223+
const handler = registeredDef.steps[0].handler
224+
const result = await handler(
225+
{ repoPath: '/tmp/repo', repoName: 'my-repo' },
226+
{ runId: 'r1', run: {}, abortSignal: new AbortController().signal }
227+
)
228+
229+
expect(result.branch).toBe('main')
230+
expect(result.repoPath).toBe('/tmp/repo')
231+
expect(result.repoName).toBe('my-repo')
232+
})
233+
234+
it('skips when no code changes since last run', async () => {
235+
mockExecSync.mockReturnValue(Buffer.from('main\n'))
236+
mockExecFileSync.mockReturnValue(Buffer.from(''))
237+
238+
const handler = registeredDef.steps[0].handler
239+
await expect(handler(
240+
{ repoPath: '/tmp/repo', sinceTimestamp: '2026-03-07T00:00:00Z' },
241+
{ runId: 'r1', run: {}, abortSignal: new AbortController().signal }
242+
)).rejects.toThrow('No code changes')
243+
})
244+
245+
it('continues when there are code changes since last run', async () => {
246+
mockExecSync.mockReturnValue(Buffer.from('main\n'))
247+
mockExecFileSync.mockReturnValue(Buffer.from('abc123 some commit\n'))
248+
249+
const handler = registeredDef.steps[0].handler
250+
const result = await handler(
251+
{ repoPath: '/tmp/repo', sinceTimestamp: '2026-03-07T00:00:00Z' },
252+
{ runId: 'r1', run: {}, abortSignal: new AbortController().signal }
253+
)
254+
255+
expect(result.branch).toBe('main')
256+
})
257+
258+
it('throws for non-git directory', async () => {
259+
mockExecSync.mockImplementation(() => { throw new Error('not a git repo') })
260+
261+
const handler = registeredDef.steps[0].handler
262+
await expect(handler(
263+
{ repoPath: '/tmp/not-git' },
264+
{ runId: 'r1', run: {}, abortSignal: new AbortController().signal }
265+
)).rejects.toThrow('Not a valid git repository')
266+
})
267+
})
268+
269+
describe('create_session step', () => {
270+
it('creates a session with the correct name and working directory', async () => {
271+
const handler = registeredDef.steps[1].handler
272+
const result = await handler(
273+
{ repoPath: '/tmp/repo', repoName: 'my-repo', branch: 'main', lastCommit: 'abc123' },
274+
{ runId: 'r1', run: {}, abortSignal: new AbortController().signal }
275+
)
276+
277+
expect(sessions.create).toHaveBeenCalledWith('test-review:my-repo', '/tmp/repo', {
278+
source: 'workflow',
279+
groupDir: '/tmp/repo',
280+
})
281+
expect(result.sessionId).toBe('session-1')
282+
expect(result.repoName).toBe('my-repo')
283+
})
284+
285+
it('extracts repo name from path when not provided', async () => {
286+
const handler = registeredDef.steps[1].handler
287+
await handler(
288+
{ repoPath: '/home/user/projects/my-app', branch: 'main' },
289+
{ runId: 'r1', run: {}, abortSignal: new AbortController().signal }
290+
)
291+
292+
expect(sessions.create).toHaveBeenCalledWith(
293+
'test-review:my-app',
294+
'/home/user/projects/my-app',
295+
expect.any(Object)
296+
)
297+
})
298+
})
299+
300+
describe('afterRun hook', () => {
301+
it('stops Claude process on completion', async () => {
302+
sessions.get.mockReturnValue({
303+
claudeProcess: { isAlive: () => true },
304+
})
305+
306+
await registeredDef.afterRun({ output: { sessionId: 'session-1' } })
307+
308+
expect(sessions.stopClaude).toHaveBeenCalledWith('session-1')
309+
})
310+
311+
it('skips cleanup when no session ID', async () => {
312+
await registeredDef.afterRun({ output: null })
313+
expect(sessions.stopClaude).not.toHaveBeenCalled()
314+
})
315+
316+
it('skips cleanup when Claude is not alive', async () => {
317+
sessions.get.mockReturnValue({
318+
claudeProcess: { isAlive: () => false },
319+
})
320+
321+
await registeredDef.afterRun({ output: { sessionId: 'session-1' } })
322+
expect(sessions.stopClaude).not.toHaveBeenCalled()
323+
})
324+
325+
it('handles cleanup errors gracefully', async () => {
326+
sessions.get.mockImplementation(() => { throw new Error('session gone') })
327+
328+
// Should not throw
329+
await registeredDef.afterRun({ output: { sessionId: 'session-1' } })
330+
})
331+
})
332+
})
333+
})

0 commit comments

Comments
 (0)