skillhub/cli/test/unit/agents/resolver.test.ts
betterlmy f519b08a73 fix(cli): preflight canonical install targets
Signed-off-by: betterlmy <betterlmy@icloud.com>
2026-07-17 17:20:22 +08:00

255 lines
8.1 KiB
TypeScript

import { mkdir, mkdtemp, rm, symlink } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, test } from 'bun:test'
import { resolveInstallTargets } from '../../../src/agents/resolver'
import type { AgentCandidate } from '../../../src/agents/types'
describe('resolveInstallTargets', () => {
test('rejects dir and agent together before filesystem writes', async () => {
await expect(resolveInstallTargets({
cwd: '/repo',
dir: '/tmp/skills',
agents: ['codex'],
json: false,
interactive: false
})).rejects.toThrow('--dir cannot be used with --agent')
})
test('falls back to cwd .agents skills when nothing detected', async () => {
const targets = await resolveInstallTargets({
cwd: '/repo',
agents: [],
json: false,
interactive: false,
detected: []
})
expect(targets).toEqual([{ agent: 'generic', rootDir: '/repo/.agents/skills', scope: 'project', source: 'fallback' }])
})
test('uses explicit dir when provided', async () => {
const targets = await resolveInstallTargets({
cwd: '/repo',
dir: '/tmp/my-skills',
json: false,
interactive: false
})
expect(targets).toEqual([{ agent: 'custom', rootDir: '/tmp/my-skills', scope: 'user', source: 'explicit' }])
})
test('explicit agent resolves the profile user root by default', async () => {
const targets = await resolveInstallTargets({
cwd: '/repo',
home: '/home/u',
agents: ['codex'],
json: false,
interactive: false
})
expect(targets).toEqual([{ agent: 'codex', rootDir: '/home/u/.codex/skills', scope: 'user', source: 'explicit' }])
})
test('explicit agent without scope labels root by userRoots membership when cwd === home', async () => {
const targets = await resolveInstallTargets({
cwd: '/home/u',
home: '/home/u',
agents: ['codex'],
json: false,
interactive: false
})
expect(targets[0]!.scope).toBe('user')
expect(targets[0]!.rootDir).toBe('/home/u/.codex/skills')
})
test('deduplicates repeated explicit agents by target root', async () => {
const targets = await resolveInstallTargets({
cwd: '/repo',
home: '/home/u',
agents: ['codex', 'codex'],
json: false,
interactive: false
})
expect(targets).toHaveLength(1)
expect(targets[0]!.rootDir).toBe('/home/u/.codex/skills')
})
test('returns single detected target directly', async () => {
const targets = await resolveInstallTargets({
cwd: '/repo',
agents: [],
json: false,
interactive: false,
detected: [{ agent: 'codex', rootDir: '/repo/.codex/skills', scope: 'project', source: 'detected' }]
})
expect(targets).toHaveLength(1)
expect(targets[0]!.agent).toBe('codex')
})
test('rejects multiple detected targets in non-interactive mode', async () => {
await expect(resolveInstallTargets({
cwd: '/repo',
agents: [],
json: false,
interactive: false,
detected: [
{ agent: 'codex', rootDir: '/repo/.codex/skills', scope: 'project', source: 'detected' },
{ agent: 'claude-code', rootDir: '/repo/.claude/skills', scope: 'project', source: 'detected' }
]
})).rejects.toThrow('multiple install targets detected')
})
test('rejects unknown agent', async () => {
await expect(resolveInstallTargets({
cwd: '/repo',
agents: ['unknown-agent'],
json: false,
interactive: false
})).rejects.toThrow('unknown agent: unknown-agent')
})
test('rejects dir and scope together', async () => {
await expect(resolveInstallTargets({
cwd: '/repo',
dir: '/tmp/skills',
scope: 'user',
json: false,
interactive: false
})).rejects.toThrow('--dir cannot be used with --scope')
})
test('scope=project + agent codex returns project root with project scope', async () => {
const targets = await resolveInstallTargets({
cwd: '/repo',
home: '/home/u',
agents: ['codex'],
scope: 'project',
json: false,
interactive: false
})
expect(targets).toEqual([{ agent: 'codex', rootDir: '/repo/.codex/skills', scope: 'project', source: 'explicit' }])
})
test('scope=user + agent codex returns user root with user scope', async () => {
const targets = await resolveInstallTargets({
cwd: '/repo',
home: '/home/u',
agents: ['codex'],
scope: 'user',
json: false,
interactive: false
})
expect(targets).toEqual([{ agent: 'codex', rootDir: '/home/u/.codex/skills', scope: 'user', source: 'explicit' }])
})
test('scope=user + cwd === home + agent codex still labels candidate as user', async () => {
const targets = await resolveInstallTargets({
cwd: '/home/u',
home: '/home/u',
agents: ['codex'],
scope: 'user',
json: false,
interactive: false
})
expect(targets[0]!.scope).toBe('user')
})
test('scope=user clean env falls back to user agents skills', async () => {
const targets = await resolveInstallTargets({
cwd: '/repo',
home: '/nonexistent-home-' + Math.random().toString(36).slice(2),
agents: [],
scope: 'user',
json: false,
interactive: false
})
expect(targets).toEqual([{
agent: 'generic',
rootDir: targets[0]!.rootDir,
scope: 'user',
source: 'fallback'
}])
expect(targets[0]!.rootDir).toMatch(/\.agents\/skills$/)
expect(targets[0]!.rootDir.startsWith('/nonexistent-home-')).toBe(true)
})
test('scope=project clean env falls back to cwd agents skills', async () => {
const targets = await resolveInstallTargets({
cwd: '/nonexistent-repo-' + Math.random().toString(36).slice(2),
home: '/home/u',
agents: [],
scope: 'project',
json: false,
interactive: false
})
expect(targets).toHaveLength(1)
expect(targets[0]!.scope).toBe('project')
expect(targets[0]!.source).toBe('fallback')
expect(targets[0]!.rootDir).toMatch(/\.agents\/skills$/)
})
test('scope filters detected candidates and falls back when filtered empty', async () => {
const targets = await resolveInstallTargets({
cwd: '/repo',
home: '/home/u',
agents: [],
scope: 'user',
json: false,
interactive: false,
detected: [
{ agent: 'codex', rootDir: '/repo/.codex/skills', scope: 'project', source: 'detected' }
]
})
expect(targets).toHaveLength(1)
expect(targets[0]!.source).toBe('fallback')
expect(targets[0]!.scope).toBe('user')
})
test('scope filters detected candidates keeps matching scope', async () => {
const targets = await resolveInstallTargets({
cwd: '/repo',
home: '/home/u',
agents: [],
scope: 'user',
json: false,
interactive: false,
detected: [
{ agent: 'codex', rootDir: '/repo/.codex/skills', scope: 'project', source: 'detected' },
{ agent: 'codex', rootDir: '/home/u/.codex/skills', scope: 'user', source: 'detected' }
]
})
expect(targets).toHaveLength(1)
expect(targets[0]!.rootDir).toBe('/home/u/.codex/skills')
expect(targets[0]!.scope).toBe('user')
})
test('deduplicates a symlinked detected target and the generic user target', async () => {
const home = await mkdtemp(join(tmpdir(), 'skillhub-resolver-home-'))
const genericRoot = join(home, '.agents', 'skills')
const codexRoot = join(home, '.codex', 'skills')
const codex: AgentCandidate = {
agent: 'codex',
rootDir: codexRoot,
scope: 'user',
source: 'detected'
}
try {
await mkdir(genericRoot, { recursive: true })
await mkdir(join(home, '.codex'), { recursive: true })
await symlink(genericRoot, codexRoot, process.platform === 'win32' ? 'junction' : 'dir')
const targets = await resolveInstallTargets({
cwd: '/repo',
home,
agents: [],
scope: 'user',
json: false,
interactive: true,
detected: [codex]
})
expect(targets).toEqual([codex])
} finally {
await rm(home, { recursive: true, force: true })
}
})
})