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

177 lines
6.9 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 { chmodSync, existsSync, mkdirSync, readFileSync, realpathSync, symlinkSync } 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')
const root = join(here, '..', '..')
const agentsSkills = join(root, '.agents', 'skills')
const NAMES = [
'openspec-apply-change',
'openspec-archive-change',
'openspec-explore',
'openspec-propose',
'openspec-sync-specs',
'openspec-update-change',
]
// #62 T2.1/M-2: a negative test must not inherit the host PATH — on Linux a
// global npm prefix puts openspec into /usr/bin, so a fixed system PATH is not
// openspec-free. The child gets a guard dir holding symlinks to the binaries
// the script needs and nothing else. `bash` is addressed by absolute path
// because libuv resolves the command with the child's restricted PATH.
function which(name) {
const out = spawnSync('bash', ['-c', `command -v ${name}`], { encoding: 'utf8' })
return out.stdout.trim()
}
const BASH = which('bash') || '/bin/bash'
function isolatedPath(home) {
const bin = join(home, 'guard-bin')
mkdirSync(bin)
// The toolchain --check verifies (python3, git, node) plus the POSIX tools
// install.sh calls on this path (dirname for ROOT, head for versions, the
// ls/sort/tail legacy lookup). openspec is deliberately absent.
for (const name of ['python3', 'git', 'node', 'dirname', 'head', 'ls', 'sort', 'tail']) {
const target = which(name)
assert.ok(target, `cannot resolve ${name}: the isolated PATH would be incomplete`)
symlinkSync(target, join(bin, name))
}
return bin
}
test('repository ships six agents skills for DSH', () => {
assert.equal(readFileSync(join(agentsSkills, '.openspec-target'), 'utf8').trim(), 'agents')
for (const name of NAMES) {
const file = join(agentsSkills, name, 'SKILL.md')
assert.ok(existsSync(file), `${name}/SKILL.md is missing`)
const text = readFileSync(file, 'utf8')
assert.match(text, new RegExp(`^name: ${name}$`, 'm'), `${name}: frontmatter name must match the dir`)
assert.match(text, /description: .*openspec/i, `${name}: description must mention openspec`)
}
})
test('install.sh --check reports the openspec CLI and the agents skills', () => {
const home = tempDir()
try {
const bin = join(home, 'bin')
write(join(bin, 'openspec'), '#!/bin/sh\n[ "$1" = "--version" ] && echo 1.13.1\nexit 0\n')
chmodSync(join(bin, 'openspec'), 0o755)
const env = { ...process.env, HOME: home, DSH_HOME: join(home, '.dsh'), PATH: `${bin}:${process.env.PATH}` }
const run = spawnSync('bash', [script, '--check'], { env, encoding: 'utf8' })
assert.equal(run.status, 0, run.stderr)
assert.match(run.stdout, /ok: openspec 1\.13\.1/)
assert.match(run.stdout, /ok: openspec agents skills \(/)
} finally {
cleanup(home)
}
})
test('install.sh --check warns but does not fail without the openspec CLI', () => {
const home = tempDir()
try {
// #62 T2.1: the guard dir is the whole PATH, so a host openspec CLI (e.g.
// /usr/bin on Linux) can never leak into this negative test.
const bin = isolatedPath(home)
const env = {
...process.env,
HOME: home,
DSH_HOME: join(home, '.dsh'),
PATH: bin,
}
const run = spawnSync(BASH, [script, '--check'], { env, encoding: 'utf8' })
assert.equal(run.status, 0, run.stderr)
assert.match(run.stdout + run.stderr, /WARN: openspec not found/)
} finally {
cleanup(home)
}
})
function fakeOpenspec(dir, log) {
const bin = join(dir, 'bin')
write(join(bin, 'openspec'), `#!/bin/sh\necho "$PWD $@" >> ${JSON.stringify(log)}\nexit 0\n`)
chmodSync(join(bin, 'openspec'), 0o755)
return bin
}
test('--openspec adds the agents adapter to an existing OpenSpec project', () => {
const home = tempDir()
try {
const project = join(home, 'project')
mkdirSync(join(project, 'openspec'), { recursive: true })
// macOS: os.tmpdir() идёт через симлинк /var -> /private/var, а $PWD в шелле — уже реальный путь
const realProject = realpathSync(project)
const log = join(home, 'calls.log')
const bin = fakeOpenspec(home, log)
const env = { ...process.env, HOME: home, DSH_HOME: join(home, '.dsh'), PATH: `${bin}:${process.env.PATH}` }
const run = spawnSync('bash', [script, '--openspec'], { cwd: project, env, encoding: 'utf8' })
assert.equal(run.status, 0, run.stderr)
assert.match(run.stdout, /ok: openspec agents adapter in /)
assert.match(readFileSync(log, 'utf8'), new RegExp(`^${realProject} init --tools agents$`, 'm'))
} finally {
cleanup(home)
}
})
test('--openspec refuses a project without an openspec root', () => {
const home = tempDir()
try {
const project = join(home, 'plain')
mkdirSync(project, { recursive: true })
const log = join(home, 'calls.log')
const bin = fakeOpenspec(home, log)
const env = { ...process.env, HOME: home, DSH_HOME: join(home, '.dsh'), PATH: `${bin}:${process.env.PATH}` }
const run = spawnSync('bash', [script, '--openspec'], { cwd: project, env, encoding: 'utf8' })
assert.notEqual(run.status, 0)
assert.match(run.stdout + run.stderr, /has no openspec\/ root/)
assert.equal(existsSync(log), false, 'openspec must not run in a non-OpenSpec project')
assert.equal(existsSync(join(project, 'openspec')), false)
} finally {
cleanup(home)
}
})
test('--openspec fails when openspec init fails', () => {
const home = tempDir()
try {
const project = join(home, 'project')
mkdirSync(join(project, 'openspec'), { recursive: true })
const bin = join(home, 'bin')
write(join(bin, 'openspec'), '#!/bin/sh\nexit 1\n')
chmodSync(join(bin, 'openspec'), 0o755)
const env = { ...process.env, HOME: home, DSH_HOME: join(home, '.dsh'), PATH: `${bin}:${process.env.PATH}` }
const run = spawnSync('bash', [script, '--openspec'], { cwd: project, env, encoding: 'utf8' })
assert.notEqual(run.status, 0)
assert.doesNotMatch(run.stdout, /ok: openspec agents adapter/)
assert.match(run.stderr, /FAIL/)
} finally {
cleanup(home)
}
})
test('--openspec fails without the openspec CLI', () => {
const home = tempDir()
try {
const project = join(home, 'project')
mkdirSync(join(project, 'openspec'), { recursive: true })
// #62 T2.1: same guard-dir isolation as the --check negative test.
const bin = isolatedPath(home)
const env = {
...process.env,
HOME: home,
DSH_HOME: join(home, '.dsh'),
PATH: bin,
}
const run = spawnSync(BASH, [script, '--openspec'], { cwd: project, env, encoding: 'utf8' })
assert.notEqual(run.status, 0)
assert.match(run.stdout + run.stderr, /FAIL: openspec CLI not found/)
} finally {
cleanup(home)
}
})