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

306 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import assert from 'node:assert/strict'
import { spawnSync } from 'node:child_process'
import { existsSync, lstatSync, readFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
import { cleanup, tempDir, write } from './helpers.mjs'
const here = dirname(fileURLToPath(import.meta.url))
const script = join(here, '..', 'install.sh')
test('install.sh copies skills and roles instead of linking into ~/.claude', () => {
const home = tempDir()
try {
const dshHome = join(home, '.dsh')
const cache = join(
home,
'.claude',
'plugins',
'cache',
'claude-plugins-official',
'superpowers',
'7.0.0',
'skills',
)
write(join(cache, 'using-superpowers', 'SKILL.md'), '---\nname: using-superpowers\n---\nbody\n')
write(join(cache, 'brainstorming', 'SKILL.md'), '---\nname: brainstorming\n---\nbody\n')
const env = { ...process.env, HOME: home, DSH_HOME: dshHome }
const run = () => spawnSync('bash', [script, '--skills', '--roles'], { env, encoding: 'utf8' })
const first = run()
assert.equal(first.status, 0, first.stderr)
assert.match(first.stdout, /copied 2 superpowers skills/)
assert.match(first.stdout, /copied 2 local skills/)
assert.match(first.stdout, /installed 4 roles/)
const skill = join(dshHome, 'skills', 'using-superpowers')
assert.equal(lstatSync(skill).isSymbolicLink(), false)
assert.equal(lstatSync(skill).isDirectory(), true)
assert.match(readFileSync(join(skill, 'SKILL.md'), 'utf8'), /body/)
const roleSkill = join(dshHome, 'skills', 'role', 'SKILL.md')
assert.ok(existsSync(roleSkill), 'role skill was not installed')
assert.match(readFileSync(roleSkill, 'utf8'), /name: role/)
const sddSkill = join(dshHome, 'skills', 'sdd', 'SKILL.md')
assert.ok(existsSync(sddSkill), 'sdd skill was not installed')
assert.match(readFileSync(sddSkill, 'utf8'), /name: sdd/)
for (const role of ['dev', 'admin', 'analyst', 'qa']) {
assert.ok(existsSync(join(dshHome, 'roles', `${role}.md`)), `missing ${role}.md`)
}
const devRole = join(dshHome, 'roles', 'dev.md')
const devText = readFileSync(devRole, 'utf8')
assert.match(devText, /В DSH-сессии исполнение плана идёт по skill `sdd`/)
assert.match(devText, /`maxDepth: 2`/)
assert.match(first.stdout, /role dsh notes/)
assert.equal(countNotes(devText), 1, 'note must be inserted once')
const second = run()
assert.equal(second.status, 0, second.stderr)
assert.equal(lstatSync(skill).isSymbolicLink(), false)
assert.ok(existsSync(roleSkill))
assert.match(second.stdout, /role dsh notes/)
assert.equal(countNotes(readFileSync(devRole, 'utf8')), 1, 'note must not duplicate')
const check = spawnSync('bash', [script, '--check'], { env, encoding: 'utf8' })
assert.match(check.stdout, /ok: role dsh notes/)
} finally {
cleanup(home)
}
})
function countNotes(text) {
return (text.match(/В DSH-сессии исполнение плана/g) ?? []).length
}
function makeSuperpowers(root, body = 'body') {
for (const name of ['using-superpowers', 'brainstorming']) {
write(join(root, 'skills', name, 'SKILL.md'), `---\nname: ${name}\n---\n${body}\n`)
}
return root
}
function makeLegacyCache(home) {
const root = join(home, '.claude', 'plugins', 'cache', 'claude-plugins-official', 'superpowers', '7.0.0')
makeSuperpowers(root)
return join(root, 'skills')
}
function installerEnv(home) {
const env = { ...process.env, HOME: home, DSH_HOME: join(home, '.dsh') }
delete env.DSH_SUPERPOWERS_SOURCE
return env
}
function runInstaller(home, args, extraEnv = {}) {
return spawnSync('bash', [script, ...args], {
env: { ...installerEnv(home), ...extraEnv },
encoding: 'utf8',
})
}
test('--skills takes superpowers from --superpowers-from in a clean HOME', () => {
const home = tempDir()
try {
const src = makeSuperpowers(join(home, 'src', 'superpowers'))
const run = runInstaller(home, ['--skills', '--superpowers-from', src])
const out = run.stdout + run.stderr
assert.equal(run.status, 0, out)
assert.match(run.stdout, /copied 2 superpowers skills/)
assert.ok(run.stdout.includes(`source: ${join(src, 'skills')}`), out)
assert.ok(existsSync(join(home, '.dsh', 'skills', 'using-superpowers', 'SKILL.md')))
assert.ok(existsSync(join(home, '.dsh', 'skills', 'role', 'SKILL.md')))
assert.ok(existsSync(join(home, '.dsh', 'skills', 'sdd', 'SKILL.md')))
assert.equal(existsSync(join(home, '.claude')), false, 'clean HOME must stay clean')
assert.doesNotMatch(out, /legacy/)
} finally {
cleanup(home)
}
})
test('--superpowers-from accepts the skills directory itself', () => {
const home = tempDir()
try {
const src = makeSuperpowers(join(home, 'src', 'superpowers'))
const run = runInstaller(home, ['--skills', '--superpowers-from', join(src, 'skills')])
assert.equal(run.status, 0, run.stdout + run.stderr)
assert.match(run.stdout, /copied 2 superpowers skills/)
} finally {
cleanup(home)
}
})
test('--superpowers-from accepts a path with spaces', () => {
const home = tempDir()
try {
const src = makeSuperpowers(join(home, 'super powers'))
const run = runInstaller(home, ['--skills', '--superpowers-from', src])
assert.equal(run.status, 0, run.stdout + run.stderr)
assert.ok(existsSync(join(home, '.dsh', 'skills', 'using-superpowers', 'SKILL.md')))
} finally {
cleanup(home)
}
})
test('--superpowers-from wins over DSH_SUPERPOWERS_SOURCE', () => {
const home = tempDir()
try {
const fromFlag = makeSuperpowers(join(home, 'flag-source'), 'from-flag')
const fromEnv = makeSuperpowers(join(home, 'env-source'), 'from-env')
const run = runInstaller(home, ['--skills', '--superpowers-from', fromFlag], {
DSH_SUPERPOWERS_SOURCE: fromEnv,
})
assert.equal(run.status, 0, run.stdout + run.stderr)
assert.match(
readFileSync(join(home, '.dsh', 'skills', 'using-superpowers', 'SKILL.md'), 'utf8'),
/from-flag/,
)
} finally {
cleanup(home)
}
})
test('DSH_SUPERPOWERS_SOURCE is used when the flag is absent', () => {
const home = tempDir()
try {
const fromEnv = makeSuperpowers(join(home, 'env-source'), 'from-env')
const run = runInstaller(home, ['--skills'], { DSH_SUPERPOWERS_SOURCE: fromEnv })
assert.equal(run.status, 0, run.stdout + run.stderr)
assert.match(
readFileSync(join(home, '.dsh', 'skills', 'using-superpowers', 'SKILL.md'), 'utf8'),
/from-env/,
)
} finally {
cleanup(home)
}
})
test('--skills fails when there is no source and nothing installed', () => {
const home = tempDir()
try {
const run = runInstaller(home, ['--skills'])
const out = run.stdout + run.stderr
assert.equal(run.status, 1, out)
assert.match(out, /FAIL: no superpowers source/)
assert.match(out, /git clone --depth 1 https:\/\/github\.com\/obra\/superpowers/)
assert.ok(existsSync(join(home, '.dsh', 'skills', 'role', 'SKILL.md')), 'local skills still installed')
assert.ok(existsSync(join(home, '.dsh', 'skills', 'sdd', 'SKILL.md')), 'local skills still installed')
assert.equal(existsSync(join(home, '.dsh', 'skills', 'using-superpowers')), false)
} finally {
cleanup(home)
}
})
test('--skills keeps installed copies when the source is gone', () => {
const home = tempDir()
try {
const installed = join(home, '.dsh', 'skills', 'using-superpowers', 'SKILL.md')
write(installed, '---\nname: using-superpowers\n---\ninstalled\n')
const run = runInstaller(home, ['--skills'])
const out = run.stdout + run.stderr
assert.equal(run.status, 0, out)
assert.match(out, /WARN: no superpowers source/)
assert.match(readFileSync(installed, 'utf8'), /installed/)
} finally {
cleanup(home)
}
})
test('empty DSH_SUPERPOWERS_SOURCE counts as unset', () => {
const home = tempDir()
try {
const run = runInstaller(home, ['--skills'], { DSH_SUPERPOWERS_SOURCE: '' })
const out = run.stdout + run.stderr
assert.equal(run.status, 1, out)
assert.match(out, /FAIL: no superpowers source/)
assert.doesNotMatch(out, /explicit superpowers source/)
} finally {
cleanup(home)
}
})
test('an invalid explicit source fails without touching the legacy cache', () => {
const home = tempDir()
try {
makeLegacyCache(home)
const empty = join(home, 'not-superpowers')
const run = runInstaller(home, ['--skills', '--superpowers-from', empty])
const out = run.stdout + run.stderr
assert.equal(run.status, 1, out)
assert.match(out, /FAIL: explicit superpowers source .* has no using-superpowers\/SKILL\.md/)
assert.equal(existsSync(join(home, '.dsh', 'skills', 'brainstorming')), false)
} finally {
cleanup(home)
}
})
test('the legacy plugin cache still works and warns', () => {
const home = tempDir()
try {
makeLegacyCache(home)
const run = runInstaller(home, ['--skills'])
const out = run.stdout + run.stderr
assert.equal(run.status, 0, out)
assert.match(out, /WARN: legacy superpowers source/)
assert.match(run.stdout, /copied 2 superpowers skills/)
assert.ok(existsSync(join(home, '.dsh', 'skills', 'using-superpowers', 'SKILL.md')))
} finally {
cleanup(home)
}
})
test('--check reports the pinned source', () => {
const home = tempDir()
try {
const pinned = join(home, '.dsh', 'superpowers')
makeSuperpowers(pinned)
const run = runInstaller(home, ['--check'])
const out = run.stdout + run.stderr
assert.equal(run.status, 0, out)
assert.match(out, /ok: superpowers source \(.*superpowers\/skills\)/)
} finally {
cleanup(home)
}
})
test('--check fails on an invalid explicit source', () => {
const home = tempDir()
try {
const empty = join(home, 'not-superpowers')
const run = runInstaller(home, ['--check'], { DSH_SUPERPOWERS_SOURCE: empty })
const out = run.stdout + run.stderr
assert.equal(run.status, 1, out)
assert.match(out, /FAIL: explicit superpowers source/)
} finally {
cleanup(home)
}
})
test('--global installs DSH role paths and explains the instruction format', () => {
const home = tempDir()
try {
const globalSource = join(here, '..', '..', 'global', 'CLAUDE.md')
const before = readFileSync(globalSource, 'utf8')
const run = runInstaller(home, ['--global'])
assert.equal(run.status, 0, run.stdout + run.stderr)
const agents = readFileSync(join(home, '.dsh', 'AGENTS.md'), 'utf8')
assert.match(agents, /~\/\.dsh\/roles\/dev\.md/)
assert.doesNotMatch(agents, /~\/\.claude/)
assert.match(agents, /superpowers skills are ~\/\.dsh\/skills/)
assert.match(agents, /instruction-file format of this harness/)
assert.match(agents, /does not imply a Claude Code runtime/)
assert.equal(readFileSync(globalSource, 'utf8'), before, 'global/CLAUDE.md must stay untouched')
const second = runInstaller(home, ['--global'])
assert.equal(second.status, 0, second.stdout + second.stderr)
assert.match(second.stdout, /backup: /)
} finally {
cleanup(home)
}
})