claude-harness/dsh/tests/approve.test.mjs

318 lines
11 KiB
JavaScript

import assert from 'node:assert/strict'
import { createHash } from 'node:crypto'
import { existsSync, readFileSync } from 'node:fs'
import { join } from 'node:path'
import test from 'node:test'
import { registerApproveCommand } from '../bundle/command-approve.js'
import { hashPlan, parseApproval, serializeApproval } from '../bundle/lib/gates.js'
import { cleanup, tempDir, write } from './helpers.mjs'
const PLAN_TEXT = [
'# Plan X',
'',
'**Spec:** `openspec/changes/demo/`',
'',
'## Task 1',
'',
'Do the work.',
'',
].join('\n')
const ISO = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u
const APPROVE = 'Approve'
/** A throwaway repository with the design artifacts and one demo plan. */
function repoFixture({ builderCheck = true, withPlan = true } = {}) {
const tmp = tempDir('dsh-approve-')
const repo = join(tmp, 'repo')
write(join(repo, 'openspec', 'changes', 'demo', 'proposal.md'), '# Proposal\n')
write(join(repo, 'openspec', 'changes', 'demo', 'design.md'), '# Design\n')
if (builderCheck) write(join(repo, 'openspec', 'changes', 'demo', 'builder-check.md'), '# Check\n')
let planPath = null
if (withPlan) {
planPath = join(repo, 'docs', 'superpowers', 'plans', 'plan-x.md')
write(planPath, PLAN_TEXT)
}
return { tmp, repo, planPath }
}
function approvalPath(repo) {
return join(repo, 'openspec', 'changes', 'demo', 'approval.md')
}
/** An ask() answer batch shaped like the DSH UserQuestionService result. */
function approveAnswer(kind = 'design') {
return { answers: [{ id: `approve-${kind}`, selected: [APPROVE], custom: undefined }] }
}
/**
* Wire the command against a fake `commands.register` that captures the
* registered definition, and a scripted `userQuestions.ask`. The fake
* `workingDirectory` resolves every session to the fixture repo.
*/
function setup(repo, { answer, missingService = false } = {}) {
const captured = {}
const commands = {
register(definition) {
Object.assign(captured, definition)
},
}
const calls = []
let userQuestions
if (!missingService) {
userQuestions = {
async ask(request) {
calls.push(request)
return answer
},
}
}
registerApproveCommand({
commands,
userQuestions,
workingDirectory: { get() { return repo } },
logger: { warn() {} },
})
return { captured, calls, run: (rawInput) => captured.handler({ rawInput }) }
}
function shaOf(path) {
return createHash('sha256').update(readFileSync(path)).digest('hex')
}
test('approve design refuses without builder-check.md', async () => {
const { tmp, repo } = repoFixture({ builderCheck: false })
try {
const harness = setup(repo, { answer: approveAnswer('design') })
const result = await harness.run('demo design')
assert.match(result.text, /builder-check\.md/u)
assert.equal(existsSync(approvalPath(repo)), false, 'no approval.md is written')
assert.equal(harness.calls.length, 0, 'the owner is not asked when artifacts are missing')
} finally {
cleanup(tmp)
}
})
test('approve design writes design section (by owner, at ISO)', async () => {
const { tmp, repo } = repoFixture()
try {
const existingPlan = { by: 'aep', at: '2026-10-25T11:30:00+04:00', sha256: 'a'.repeat(64) }
write(approvalPath(repo), serializeApproval({ plan: existingPlan }))
const harness = setup(repo, { answer: approveAnswer('design') })
const result = await harness.run('demo design')
assert.equal(result.kind, 'success')
const request = harness.calls[0].questions[0]
assert.equal(request.question, 'Approve demo design?')
const parsed = parseApproval(readFileSync(approvalPath(repo), 'utf8'))
assert.equal(parsed.design.by, 'owner')
assert.match(parsed.design.at, ISO)
assert.equal(Number.isNaN(Date.parse(parsed.design.at)), false)
assert.deepEqual(parsed.plan, existingPlan, 'the pre-existing plan section is untouched')
} finally {
cleanup(tmp)
}
})
test('approve design refuses without confirmation', async () => {
const { tmp, repo } = repoFixture()
try {
const harness = setup(repo, {
answer: { answers: [{ id: 'approve-design', selected: ['Cancel'] }] },
})
const result = await harness.run('demo design')
assert.match(result.text, /not confirmed/u)
assert.equal(existsSync(approvalPath(repo)), false, 'no approval.md is written')
} finally {
cleanup(tmp)
}
})
test('approve plan refuses without design section', async () => {
const { tmp, repo } = repoFixture()
try {
const harness = setup(repo, { answer: approveAnswer('plan') })
const result = await harness.run('demo plan')
assert.match(result.text, /design not approved/u)
assert.match(result.text, /\/approve demo design/u)
assert.equal(existsSync(approvalPath(repo)), false, 'no approval.md is written')
assert.equal(harness.calls.length, 0, 'the owner is not asked before the design gate passes')
} finally {
cleanup(tmp)
}
})
test('approve plan refuses when two plans reference the change', async () => {
const { tmp, repo } = repoFixture()
try {
write(join(repo, 'docs', 'superpowers', 'plans', 'plan-y.md'), PLAN_TEXT)
write(
approvalPath(repo),
serializeApproval({ design: { by: 'aep', at: '2026-10-25T10:00:00+04:00' } }),
)
const before = readFileSync(approvalPath(repo), 'utf8')
const harness = setup(repo, { answer: approveAnswer('plan') })
const result = await harness.run('demo plan')
assert.match(result.text, /plan-x\.md/u)
assert.match(result.text, /plan-y\.md/u)
assert.equal(readFileSync(approvalPath(repo), 'utf8'), before, 'approval.md is untouched')
assert.equal(harness.calls.length, 0, 'the owner is not asked when the plan is ambiguous')
} finally {
cleanup(tmp)
}
})
test('approve plan writes plan section with sha256 of the plan', async () => {
const { tmp, repo, planPath } = repoFixture()
try {
const design = { by: 'aep', at: '2026-10-25T10:00:00+04:00' }
write(approvalPath(repo), serializeApproval({ design }))
const harness = setup(repo, { answer: approveAnswer('plan') })
const result = await harness.run('demo plan')
assert.equal(result.kind, 'success')
const request = harness.calls[0].questions[0]
assert.equal(request.question, 'Approve demo plan?')
const parsed = parseApproval(readFileSync(approvalPath(repo), 'utf8'))
assert.equal(parsed.plan.by, 'owner')
assert.match(parsed.plan.at, ISO)
assert.equal(parsed.plan.sha256, shaOf(planPath))
assert.equal(parsed.plan.sha256, hashPlan(planPath))
assert.deepEqual(parsed.design, design, 'the design section is untouched')
} finally {
cleanup(tmp)
}
})
test('approve refuses when userQuestions is missing', async () => {
const { tmp, repo } = repoFixture()
try {
const harness = setup(repo, { missingService: true })
const result = await harness.run('demo design')
assert.match(result.text, /userQuestions service unavailable/u)
assert.match(result.text, /\/approve requires it/u)
assert.equal(existsSync(approvalPath(repo)), false, 'no approval.md is written')
} finally {
cleanup(tmp)
}
})
test('approve with unknown kind returns usage', async () => {
const { tmp, repo } = repoFixture()
try {
const harness = setup(repo, { answer: approveAnswer() })
const unknown = await harness.run('demo review')
assert.match(unknown.text, /Usage: \/approve <change-id> design\|plan/u)
assert.equal(unknown.kind, 'error')
const missing = await harness.run('demo')
assert.match(missing.text, /Usage: \/approve <change-id> design\|plan/u)
assert.equal(harness.calls.length, 0, 'nothing is asked')
assert.equal(existsSync(approvalPath(repo)), false, 'no approval.md is written')
} finally {
cleanup(tmp)
}
})
// Pins for the Task 2 review findings (R-5): branches without a first test.
test('approve plan refuses when no plan references the change', async () => {
const { tmp, repo } = repoFixture({ withPlan: false })
try {
write(
approvalPath(repo),
serializeApproval({ design: { by: 'aep', at: '2026-10-25T10:00:00+04:00' } }),
)
const before = readFileSync(approvalPath(repo), 'utf8')
const harness = setup(repo, { answer: approveAnswer('plan') })
const result = await harness.run('demo plan')
assert.equal(result.kind, 'error')
assert.match(result.text, /no plan for demo found/u)
assert.match(result.text, /docs\/superpowers\/plans\//u)
assert.equal(readFileSync(approvalPath(repo), 'utf8'), before, 'approval.md is untouched')
assert.equal(harness.calls.length, 0, 'the owner is not asked without a plan')
} finally {
cleanup(tmp)
}
})
test('approve refuses an unsafe change id', async () => {
const { tmp, repo } = repoFixture()
try {
const harness = setup(repo, { answer: approveAnswer('design') })
const result = await harness.run('../x design')
assert.match(result.text, /Invalid change id "\.\.\/x"\./u)
assert.match(result.text, /Usage: \/approve <change-id> design\|plan/u)
assert.equal(existsSync(approvalPath(repo)), false, 'no approval.md is written')
assert.equal(harness.calls.length, 0, 'the owner is not asked for an invalid id')
} finally {
cleanup(tmp)
}
})
test('approve accepts the fallback answer shapes {approved}/{confirmed}/true', async () => {
const { tmp, repo } = repoFixture()
try {
const shapes = [{ approved: true }, { confirmed: true }, true]
for (const answer of shapes) {
const harness = setup(repo, { answer })
const result = await harness.run('demo design')
assert.equal(result.kind, 'success', `answer shape ${JSON.stringify(answer)} approves`)
const parsed = parseApproval(readFileSync(approvalPath(repo), 'utf8'))
assert.equal(parsed.design.by, 'owner')
assert.match(parsed.design.at, ISO)
}
} finally {
cleanup(tmp)
}
})
// T3.1 regression: the repo root is resolved per handler invocation from the
// invocation agent's session, not fixed at registration.
test('approve resolves the session cwd per invocation (T3.1)', async () => {
const first = repoFixture()
const second = repoFixture()
try {
const roots = new Map([
['s1', first.repo],
['s2', second.repo],
])
const captured = {}
const commands = { register(definition) { Object.assign(captured, definition) } }
registerApproveCommand({
commands,
userQuestions: { async ask() { return approveAnswer('design') } },
workingDirectory: { get(session) { return roots.get(session) } },
logger: { warn() {} },
})
const firstResult = await captured.handler({ agent: { session: 's1' }, rawInput: 'demo design' })
assert.equal(firstResult.kind, 'success')
assert.equal(existsSync(approvalPath(first.repo)), true, 'approval.md written in the s1 repo')
assert.equal(existsSync(approvalPath(second.repo)), false, 'the s2 repo is untouched')
const secondResult = await captured.handler({ agent: { session: 's2' }, rawInput: 'demo design' })
assert.equal(secondResult.kind, 'success')
assert.equal(existsSync(approvalPath(second.repo)), true, 'approval.md written in the s2 repo')
} finally {
cleanup(first.tmp)
cleanup(second.tmp)
}
})