654 lines
24 KiB
JavaScript
654 lines
24 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 { createApproveTool } from '../bundle/tool-approve.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')
|
|
}
|
|
|
|
/**
|
|
* A session repository (no `.git`) whose demo change and plan live in a linked
|
|
* worktree `<repo>/.claude/worktrees/foo` (its `.git` is a file).
|
|
*/
|
|
function worktreeFixture() {
|
|
const tmp = tempDir('dsh-approve-wt-')
|
|
const repo = join(tmp, 'repo')
|
|
const wt = join(repo, '.claude', 'worktrees', 'foo')
|
|
write(join(wt, '.git'), 'gitdir: /fixture/.git/worktrees/foo\n')
|
|
write(join(wt, 'openspec', 'changes', 'demo', 'proposal.md'), '# Proposal\n')
|
|
write(join(wt, 'openspec', 'changes', 'demo', 'design.md'), '# Design\n')
|
|
write(join(wt, 'openspec', 'changes', 'demo', 'builder-check.md'), '# Check\n')
|
|
const planPath = join(wt, 'docs', 'superpowers', 'plans', 'plan-x.md')
|
|
write(planPath, PLAN_TEXT)
|
|
return { tmp, repo, wt, planPath }
|
|
}
|
|
|
|
/** 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)
|
|
}
|
|
})
|
|
|
|
// T3.9 pin: the process.cwd() fallback of currentDirectory is part of the
|
|
// T3.1 contract — a workingDirectory that throws must not break /approve.
|
|
// The test changes the process directory (each test file runs in its own
|
|
// process) so the write lands in an assertable place, then restores it.
|
|
|
|
test('approve falls back to process.cwd() when the session cwd lookup throws (T3.9)', async () => {
|
|
const { tmp, repo } = repoFixture()
|
|
const previous = process.cwd()
|
|
try {
|
|
process.chdir(repo)
|
|
const captured = {}
|
|
const commands = { register(definition) { Object.assign(captured, definition) } }
|
|
registerApproveCommand({
|
|
commands,
|
|
userQuestions: { async ask() { return approveAnswer('design') } },
|
|
workingDirectory: {
|
|
get() {
|
|
throw new Error('cannot get property "session" without inject')
|
|
},
|
|
},
|
|
logger: { warn() {} },
|
|
})
|
|
|
|
const result = await captured.handler({ agent: { session: 's1' }, rawInput: 'demo design' })
|
|
assert.equal(result.kind, 'success')
|
|
assert.equal(existsSync(approvalPath(repo)), true, 'approval.md is written into process.cwd()')
|
|
} finally {
|
|
process.chdir(previous)
|
|
cleanup(tmp)
|
|
}
|
|
})
|
|
|
|
// `request_approval` (bounded #77): the model drives the approval gate, the
|
|
// owner only confirms in the userQuestions dialog, and the marker is still
|
|
// written by bundle code through the same lib/approval.js as `/approve`.
|
|
|
|
const FIXED_ISO = '2026-10-25T12:00:00.000Z'
|
|
|
|
/**
|
|
* Build the tool with fakes: `getWorkingDirectory` is the DSH service factory
|
|
* (`workingDirectory.get(session)`), `getUserQuestions` resolves lazily, and
|
|
* `now` is pinned so the written `at` is exact.
|
|
*/
|
|
function setupTool(repo, { answer, missingService = false, at = FIXED_ISO } = {}) {
|
|
const calls = []
|
|
const getUserQuestions = missingService
|
|
? () => null
|
|
: () => ({
|
|
async ask(request) {
|
|
calls.push(request)
|
|
return answer
|
|
},
|
|
})
|
|
const tool = createApproveTool({
|
|
getWorkingDirectory: () => ({ get() { return repo } }),
|
|
getUserQuestions,
|
|
logger: { warn() {} },
|
|
now: () => new Date(at),
|
|
})
|
|
return { tool, calls, run: (args, exec = {}) => tool.execute(args, exec) }
|
|
}
|
|
|
|
test('request_approval design: owner confirms, section is written by the harness', async () => {
|
|
const { tmp, repo } = repoFixture()
|
|
try {
|
|
const harness = setupTool(repo, { answer: approveAnswer('design') })
|
|
assert.equal(harness.tool.name, 'request_approval')
|
|
assert.match(harness.tool.description, /written by the harness only after the owner confirms/u)
|
|
assert.deepEqual(harness.tool.parameters.required, ['change_id', 'kind'])
|
|
assert.deepEqual(harness.tool.parameters.properties.kind.enum, ['design', 'plan'])
|
|
assert.deepEqual(harness.tool.output.render({}, { approved: true, message: 'ok' }), [
|
|
{ type: 'text', text: 'ok' },
|
|
])
|
|
|
|
const result = await harness.run({ change_id: 'demo', kind: 'design' }, { agent: { session: 's1' } })
|
|
|
|
assert.equal(result.approved, true)
|
|
assert.equal(result.message, `Approved demo design at ${FIXED_ISO}; wrote openspec/changes/demo/approval.md.`)
|
|
|
|
const question = harness.calls[0].questions[0]
|
|
assert.equal(question.question, 'Approve demo design?')
|
|
assert.equal(question.header, 'Approval gate')
|
|
assert.deepEqual(question.options.map((option) => option.label), ['Approve', 'Cancel'])
|
|
assert.equal(harness.calls[0].agent.session, 's1', 'the receiving agent is forwarded')
|
|
|
|
const parsed = parseApproval(readFileSync(approvalPath(repo), 'utf8'))
|
|
assert.equal(parsed.design.by, 'owner')
|
|
assert.equal(parsed.design.at, FIXED_ISO)
|
|
} finally {
|
|
cleanup(tmp)
|
|
}
|
|
})
|
|
|
|
test('request_approval design: cancel writes nothing', async () => {
|
|
const { tmp, repo } = repoFixture()
|
|
try {
|
|
const harness = setupTool(repo, {
|
|
answer: { answers: [{ id: 'approve', selected: ['Cancel'], custom: undefined }] },
|
|
})
|
|
const result = await harness.run({ change_id: 'demo', kind: 'design' })
|
|
|
|
assert.equal(result.approved, false)
|
|
assert.match(result.message, /not confirmed/u)
|
|
assert.equal(existsSync(approvalPath(repo)), false, 'no approval.md is written')
|
|
} finally {
|
|
cleanup(tmp)
|
|
}
|
|
})
|
|
|
|
test('request_approval refuses when the userQuestions service is missing', async () => {
|
|
const { tmp, repo } = repoFixture()
|
|
try {
|
|
const harness = setupTool(repo, { missingService: true })
|
|
const result = await harness.run({ change_id: 'demo', kind: 'design' })
|
|
|
|
assert.equal(result.approved, false)
|
|
assert.match(result.message, /userQuestions service unavailable/u)
|
|
assert.equal(existsSync(approvalPath(repo)), false, 'no approval.md is written')
|
|
} finally {
|
|
cleanup(tmp)
|
|
}
|
|
})
|
|
|
|
test('request_approval refuses an unknown kind with usage', async () => {
|
|
const { tmp, repo } = repoFixture()
|
|
try {
|
|
const harness = setupTool(repo, { answer: approveAnswer('design') })
|
|
const result = await harness.run({ change_id: 'demo', kind: 'review' })
|
|
|
|
assert.equal(result.approved, false)
|
|
assert.match(result.message, /usage: request_approval/u)
|
|
assert.equal(harness.calls.length, 0, 'the owner is not asked')
|
|
assert.equal(existsSync(approvalPath(repo)), false, 'no approval.md is written')
|
|
} finally {
|
|
cleanup(tmp)
|
|
}
|
|
})
|
|
|
|
test('request_approval refuses an unsafe change id', async () => {
|
|
const { tmp, repo } = repoFixture()
|
|
try {
|
|
const harness = setupTool(repo, { answer: approveAnswer('design') })
|
|
const result = await harness.run({ change_id: '../x', kind: 'design' })
|
|
|
|
assert.equal(result.approved, false)
|
|
assert.match(result.message, /Invalid change id "\.\.\/x"/u)
|
|
assert.equal(harness.calls.length, 0, 'the owner is not asked')
|
|
assert.equal(existsSync(approvalPath(repo)), false, 'no approval.md is written')
|
|
} finally {
|
|
cleanup(tmp)
|
|
}
|
|
})
|
|
|
|
test('request_approval plan writes the 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 = setupTool(repo, { answer: approveAnswer('plan') })
|
|
const result = await harness.run({ change_id: 'demo', kind: 'plan' })
|
|
|
|
assert.equal(result.approved, true)
|
|
assert.equal(
|
|
result.message,
|
|
`Approved demo plan at ${FIXED_ISO} (sha256 ${shaOf(planPath)}); wrote openspec/changes/demo/approval.md.`,
|
|
)
|
|
|
|
const parsed = parseApproval(readFileSync(approvalPath(repo), 'utf8'))
|
|
assert.equal(parsed.plan.by, 'owner')
|
|
assert.equal(parsed.plan.at, FIXED_ISO)
|
|
assert.equal(parsed.plan.sha256, shaOf(planPath))
|
|
assert.deepEqual(parsed.design, design, 'the design section is untouched')
|
|
} finally {
|
|
cleanup(tmp)
|
|
}
|
|
})
|
|
|
|
test('request_approval design refuses without builder-check.md (prerequisites hold)', async () => {
|
|
const { tmp, repo } = repoFixture({ builderCheck: false })
|
|
try {
|
|
const harness = setupTool(repo, { answer: approveAnswer('design') })
|
|
const result = await harness.run({ change_id: 'demo', kind: 'design' })
|
|
|
|
assert.equal(result.approved, false)
|
|
assert.match(result.message, /builder-check\.md/u)
|
|
assert.equal(harness.calls.length, 0, 'the owner is not asked before the prerequisites pass')
|
|
assert.equal(existsSync(approvalPath(repo)), false, 'no approval.md is written')
|
|
} finally {
|
|
cleanup(tmp)
|
|
}
|
|
})
|
|
|
|
test('request_approval falls back to process.cwd() when the session cwd lookup throws', async () => {
|
|
const { tmp, repo } = repoFixture()
|
|
const previous = process.cwd()
|
|
try {
|
|
process.chdir(repo)
|
|
const tool = createApproveTool({
|
|
getWorkingDirectory: () => ({
|
|
get() {
|
|
throw new Error('cannot get property "session" without inject')
|
|
},
|
|
}),
|
|
getUserQuestions: () => ({ async ask() { return approveAnswer('design') } }),
|
|
logger: { warn() {} },
|
|
now: () => new Date(FIXED_ISO),
|
|
})
|
|
|
|
const result = await tool.execute({ change_id: 'demo', kind: 'design' }, { agent: { session: 's1' } })
|
|
assert.equal(result.approved, true)
|
|
assert.equal(existsSync(approvalPath(repo)), true, 'approval.md is written into process.cwd()')
|
|
} finally {
|
|
process.chdir(previous)
|
|
cleanup(tmp)
|
|
}
|
|
})
|
|
|
|
// Refactor guard (bounded #77): `/approve` still reports a failing
|
|
// userQuestions service as a refusal with the service's reason, and writes
|
|
// nothing. The shared runApproval only writes after a `true` answer.
|
|
test('approve reports a userQuestions failure and writes nothing', async () => {
|
|
const { tmp, repo } = repoFixture()
|
|
try {
|
|
const captured = {}
|
|
const commands = { register(definition) { Object.assign(captured, definition) } }
|
|
registerApproveCommand({
|
|
commands,
|
|
userQuestions: {
|
|
async ask() {
|
|
throw new Error('dialog closed')
|
|
},
|
|
},
|
|
workingDirectory: { get() { return repo } },
|
|
logger: { warn() {} },
|
|
})
|
|
|
|
const result = await captured.handler({ rawInput: 'demo design' })
|
|
|
|
assert.equal(result.kind, 'error')
|
|
assert.equal(result.text, 'Refused: userQuestions failed: dialog closed')
|
|
assert.equal(existsSync(approvalPath(repo)), false, 'no approval.md is written')
|
|
} finally {
|
|
cleanup(tmp)
|
|
}
|
|
})
|
|
|
|
// issue #76: `/approve` resolves the change and the plan across the linked
|
|
// worktrees of the session repository (`.claude/worktrees/*`, `.wt/*`).
|
|
|
|
test('approve design finds a change that lives in a linked worktree', async () => {
|
|
const { tmp, repo, wt } = worktreeFixture()
|
|
try {
|
|
const harness = setup(repo, { answer: approveAnswer('design') })
|
|
const result = await harness.run('demo design')
|
|
|
|
assert.equal(result.kind, 'success')
|
|
assert.equal(harness.calls.length, 1, 'the owner is asked once')
|
|
const parsed = parseApproval(readFileSync(approvalPath(wt), 'utf8'))
|
|
assert.equal(parsed.design.by, 'owner')
|
|
assert.match(parsed.design.at, ISO)
|
|
assert.equal(existsSync(approvalPath(repo)), false, 'the main checkout is untouched')
|
|
} finally {
|
|
cleanup(tmp)
|
|
}
|
|
})
|
|
|
|
test('approve plan finds the plan in a worktree and writes sha256 into the change worktree', async () => {
|
|
const { tmp, repo, wt, planPath } = worktreeFixture()
|
|
try {
|
|
const design = { by: 'aep', at: '2026-10-25T10:00:00+04:00' }
|
|
write(approvalPath(wt), serializeApproval({ design }))
|
|
const before = readFileSync(approvalPath(wt), 'utf8')
|
|
|
|
const harness = setup(repo, { answer: approveAnswer('plan') })
|
|
const result = await harness.run('demo plan')
|
|
|
|
assert.equal(result.kind, 'success')
|
|
const parsed = parseApproval(readFileSync(approvalPath(wt), 'utf8'))
|
|
assert.equal(parsed.plan.by, 'owner')
|
|
assert.match(parsed.plan.at, ISO)
|
|
assert.equal(parsed.plan.sha256, shaOf(planPath))
|
|
assert.deepEqual(parsed.design, design, 'the design section is untouched')
|
|
assert.notEqual(readFileSync(approvalPath(wt), 'utf8'), before)
|
|
assert.equal(existsSync(approvalPath(repo)), false, 'the main checkout is untouched')
|
|
} finally {
|
|
cleanup(tmp)
|
|
}
|
|
})
|
|
|
|
test('approve plan sees plans in different roots and refuses with both', async () => {
|
|
const { tmp, repo, wt } = worktreeFixture()
|
|
try {
|
|
write(join(repo, 'docs', 'superpowers', 'plans', 'plan-y.md'), PLAN_TEXT)
|
|
write(
|
|
approvalPath(wt),
|
|
serializeApproval({ design: { by: 'aep', at: '2026-10-25T10:00:00+04:00' } }),
|
|
)
|
|
const before = readFileSync(approvalPath(wt), 'utf8')
|
|
|
|
const harness = setup(repo, { answer: approveAnswer('plan') })
|
|
const result = await harness.run('demo plan')
|
|
|
|
assert.equal(result.kind, 'error')
|
|
assert.match(result.text, /plan-x\.md/u)
|
|
assert.match(result.text, /plan-y\.md/u)
|
|
assert.match(result.text, /\.claude\/worktrees\/foo/u, 'the worktree plan is shown with its root')
|
|
assert.ok(
|
|
result.text.indexOf('plan-y.md') < result.text.indexOf('plan-x.md'),
|
|
'plans are listed by root (session root first), then by name',
|
|
)
|
|
assert.equal(readFileSync(approvalPath(wt), '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)
|
|
}
|
|
})
|