177 lines
6.9 KiB
JavaScript
177 lines
6.9 KiB
JavaScript
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)
|
||
}
|
||
})
|