200 lines
8.2 KiB
JavaScript
200 lines
8.2 KiB
JavaScript
import { test } from 'node:test'
|
|
import assert from 'node:assert/strict'
|
|
import fs from 'node:fs'
|
|
import os from 'node:os'
|
|
import path from 'node:path'
|
|
|
|
import {
|
|
deleteGoalFile,
|
|
discoverGoalFiles,
|
|
listGoalSummaries,
|
|
loadGoalSummary,
|
|
resolveGoalPath,
|
|
setGlobalVerify,
|
|
setGoalAllDone,
|
|
setGoalVerify,
|
|
} from '../src/goals.mjs'
|
|
|
|
function tmpProject() {
|
|
return fs.mkdtempSync(path.join(os.tmpdir(), 'kilo-goals-'))
|
|
}
|
|
|
|
test('discoverGoalFiles finds curated goals, the legacy goal, and checkbox plans', () => {
|
|
const project = tmpProject()
|
|
fs.mkdirSync(path.join(project, '.kilocode-loop', 'goals'), { recursive: true })
|
|
fs.writeFileSync(path.join(project, '.kilocode-loop', 'goals', 'a.json'), JSON.stringify({ title: 'A', stages: [] }))
|
|
fs.writeFileSync(path.join(project, '.kilocode-loop', 'goals', 'b.md'), '# B\n\n- [ ] one\n')
|
|
fs.writeFileSync(path.join(project, '.kilocode-loop', 'goal.json'), JSON.stringify({ title: 'Legacy', stages: [] }))
|
|
fs.mkdirSync(path.join(project, 'plans'), { recursive: true })
|
|
fs.writeFileSync(path.join(project, 'plans', 'with.md'), '# P\n\n- [ ] step\n')
|
|
fs.writeFileSync(path.join(project, 'plans', 'without.md'), '# Q\n\nno checkboxes here\n')
|
|
|
|
const found = discoverGoalFiles(project).map((f) => path.relative(project, f).split(path.sep).join('/'))
|
|
assert.ok(found.includes('.kilocode-loop/goals/a.json'))
|
|
assert.ok(found.includes('.kilocode-loop/goals/b.md'))
|
|
assert.ok(found.includes('.kilocode-loop/goal.json'))
|
|
assert.ok(found.includes('plans/with.md'))
|
|
assert.ok(!found.includes('plans/without.md'))
|
|
})
|
|
|
|
test('loadGoalSummary computes completion, verify and the last run', () => {
|
|
const project = tmpProject()
|
|
const goalFile = path.join(project, '.kilocode-loop', 'goal.json')
|
|
fs.mkdirSync(path.dirname(goalFile), { recursive: true })
|
|
fs.writeFileSync(
|
|
goalFile,
|
|
JSON.stringify({
|
|
title: 'My plan',
|
|
stages: [
|
|
{ id: 's1', title: 'One', done: true, verify: 'pnpm test' },
|
|
{ id: 's2', title: 'Two' },
|
|
],
|
|
}),
|
|
)
|
|
fs.writeFileSync(path.join(project, '.kilocode-loop', 'config.json'), JSON.stringify({ verify: 'dotnet test' }))
|
|
|
|
const runDir = path.join(project, '.kilocode-loop', 'runs', 'r1')
|
|
fs.mkdirSync(runDir, { recursive: true })
|
|
fs.writeFileSync(
|
|
path.join(runDir, 'state.json'),
|
|
JSON.stringify({
|
|
runId: 'r1',
|
|
startedAt: '2026-09-13T00:00:00Z',
|
|
status: 'done',
|
|
goal: { title: 'My plan', source: goalFile },
|
|
iterations: [{ index: 1 }],
|
|
totals: { cost: 0.5, tokens: { total: 1234 } },
|
|
}),
|
|
)
|
|
|
|
const summary = loadGoalSummary(project, '.kilocode-loop/goal.json')
|
|
assert.equal(summary.title, 'My plan')
|
|
assert.equal(summary.total, 2)
|
|
assert.equal(summary.done, 1)
|
|
assert.equal(summary.percent, 50)
|
|
assert.equal(summary.globalVerify, 'dotnet test')
|
|
assert.equal(summary.stages[0].verify, 'pnpm test')
|
|
assert.equal(summary.lastRun.runId, 'r1')
|
|
assert.equal(summary.lastRun.status, 'done')
|
|
})
|
|
|
|
test('setGoalVerify edits a JSON stage and the JSON global verify', () => {
|
|
const project = tmpProject()
|
|
const file = path.join(project, 'goal.json')
|
|
fs.writeFileSync(file, JSON.stringify({ title: 'T', stages: [{ id: 's1', title: 'One' }, { id: 's2', title: 'Two' }] }))
|
|
|
|
assert.equal(setGoalVerify(project, 'goal.json', { stageIndex: 1, verify: 'pnpm build' }).ok, true)
|
|
let data = JSON.parse(fs.readFileSync(file, 'utf8'))
|
|
assert.equal(data.stages[1].verify, 'pnpm build')
|
|
|
|
assert.equal(setGoalVerify(project, 'goal.json', { verify: 'dotnet test' }).ok, true)
|
|
data = JSON.parse(fs.readFileSync(file, 'utf8'))
|
|
assert.equal(data.verify, 'dotnet test')
|
|
})
|
|
|
|
test('setGoalVerify adds and replaces the directive on a markdown stage', () => {
|
|
const project = tmpProject()
|
|
const file = path.join(project, 'plan.md')
|
|
fs.writeFileSync(file, '# P\n\n- [ ] First :: acceptance: green\n- [ ] Second\n')
|
|
|
|
assert.equal(setGoalVerify(project, 'plan.md', { stageIndex: 0, verify: 'pnpm test' }).ok, true)
|
|
let text = fs.readFileSync(file, 'utf8')
|
|
assert.match(text, /- \[ \] First :: acceptance: green :: verify: pnpm test/)
|
|
|
|
assert.equal(setGoalVerify(project, 'plan.md', { stageIndex: 0, verify: 'pnpm build' }).ok, true)
|
|
text = fs.readFileSync(file, 'utf8')
|
|
assert.match(text, /verify: pnpm build/)
|
|
assert.doesNotMatch(text, /pnpm test/)
|
|
assert.match(text, /- \[ \] Second/)
|
|
})
|
|
|
|
test('setGlobalVerify writes .kilocode-loop/config.json', () => {
|
|
const project = tmpProject()
|
|
assert.equal(setGlobalVerify(project, 'dotnet test').ok, true)
|
|
const config = JSON.parse(fs.readFileSync(path.join(project, '.kilocode-loop', 'config.json'), 'utf8'))
|
|
assert.equal(config.verify, 'dotnet test')
|
|
})
|
|
|
|
test('setGoalAllDone marks and clears markdown checkboxes, keeping directives', () => {
|
|
const project = tmpProject()
|
|
const file = path.join(project, 'plan.md')
|
|
fs.writeFileSync(file, '# P\n\n- [ ] First :: verify: pnpm test\n- [x] Second\n- plain note\n')
|
|
|
|
assert.equal(setGoalAllDone(project, 'plan.md', { done: true }).ok, true)
|
|
let text = fs.readFileSync(file, 'utf8')
|
|
assert.match(text, /- \[x\] First :: verify: pnpm test/)
|
|
assert.match(text, /- \[x\] Second/)
|
|
assert.match(text, /- plain note/)
|
|
assert.equal(loadGoalSummary(project, 'plan.md').percent, 100)
|
|
|
|
assert.equal(setGoalAllDone(project, 'plan.md', { done: false }).ok, true)
|
|
text = fs.readFileSync(file, 'utf8')
|
|
assert.match(text, /- \[ \] First :: verify: pnpm test/)
|
|
assert.match(text, /- \[ \] Second/)
|
|
assert.equal(loadGoalSummary(project, 'plan.md').percent, 0)
|
|
})
|
|
|
|
test('setGoalAllDone marks and clears JSON stages', () => {
|
|
const project = tmpProject()
|
|
const file = path.join(project, 'goal.json')
|
|
fs.writeFileSync(file, JSON.stringify({ title: 'T', stages: [{ id: 's1', title: 'One' }, { id: 's2', title: 'Two', done: true }] }))
|
|
|
|
assert.equal(setGoalAllDone(project, 'goal.json', { done: true }).ok, true)
|
|
let data = JSON.parse(fs.readFileSync(file, 'utf8'))
|
|
assert.deepEqual(data.stages.map((s) => s.done), [true, true])
|
|
|
|
assert.equal(setGoalAllDone(project, 'goal.json', { done: false }).ok, true)
|
|
data = JSON.parse(fs.readFileSync(file, 'utf8'))
|
|
assert.deepEqual(data.stages.map((s) => s.done), [false, false])
|
|
})
|
|
|
|
test('setGoalAllDone rejects a plan without checkboxes or stages', () => {
|
|
const project = tmpProject()
|
|
fs.writeFileSync(path.join(project, 'empty.md'), '# P\n\nno checkboxes\n')
|
|
const result = setGoalAllDone(project, 'empty.md', { done: true })
|
|
assert.equal(result.ok, false)
|
|
assert.match(result.error, /no checkbox stages/)
|
|
})
|
|
|
|
test('deleteGoalFile moves a plan to .kilocode-loop/trash (recoverable)', () => {
|
|
const project = tmpProject()
|
|
const file = path.join(project, 'plans', 'x.md')
|
|
fs.mkdirSync(path.dirname(file), { recursive: true })
|
|
fs.writeFileSync(file, '# X\n\n- [ ] one\n')
|
|
|
|
const result = deleteGoalFile(project, 'plans/x.md')
|
|
assert.equal(result.ok, true)
|
|
assert.equal(fs.existsSync(file), false)
|
|
const trashed = path.join(project, result.trashed)
|
|
assert.ok(fs.existsSync(trashed))
|
|
assert.match(fs.readFileSync(trashed, 'utf8'), /one/)
|
|
})
|
|
|
|
test('deleteGoalFile purges when asked', () => {
|
|
const project = tmpProject()
|
|
const file = path.join(project, 'gone.json')
|
|
fs.writeFileSync(file, JSON.stringify({ title: 'G', stages: [] }))
|
|
|
|
const result = deleteGoalFile(project, 'gone.json', { purge: true })
|
|
assert.equal(result.ok, true)
|
|
assert.equal(result.purged, true)
|
|
assert.equal(fs.existsSync(file), false)
|
|
assert.equal(fs.existsSync(path.join(project, '.kilocode-loop', 'trash')), false)
|
|
})
|
|
|
|
test('resolveGoalPath rejects paths outside the project', () => {
|
|
const project = tmpProject()
|
|
assert.throws(() => resolveGoalPath(project, '../../etc/passwd'), /inside the project/)
|
|
assert.throws(() => resolveGoalPath(project, 'missing.json'), /not found/)
|
|
})
|
|
|
|
test('listGoalSummaries skips broken goal files', () => {
|
|
const project = tmpProject()
|
|
fs.mkdirSync(path.join(project, '.kilocode-loop', 'goals'), { recursive: true })
|
|
fs.writeFileSync(path.join(project, '.kilocode-loop', 'goals', 'bad.json'), '{ not json')
|
|
fs.writeFileSync(path.join(project, '.kilocode-loop', 'goals', 'ok.json'), JSON.stringify({ title: 'OK', stages: [{ id: 's1', title: 'x' }] }))
|
|
const list = listGoalSummaries(project)
|
|
assert.equal(list.length, 1)
|
|
assert.equal(list[0].title, 'OK')
|
|
})
|