Files
deepseek-harness/packages/sdk/create-sdk/tests/create.spec.ts
T
2026-07-20 19:26:04 +08:00

675 lines
28 KiB
TypeScript

import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { PassThrough, Writable } from 'node:stream'
import { fileURLToPath } from 'node:url'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
HeadlessPromptPort,
LocalPluginBlueprint,
featureId,
NodeCommandRunner,
NpmPackageManager,
type FeatureSelection,
type NestedMultiSelectValue,
type PromptPort,
} from '@deepseek-ai/dsh-helper'
import type {
ConfirmPromptRequest,
MultiSelectPromptRequest,
NestedMultiSelectRequest,
PromptOutcome,
SecretPromptRequest,
SelectPromptRequest,
TextPromptRequest,
} from '../../helper/src/questions/prompt-port.ts'
import { parseCreateArgs } from '../src/args.ts'
import {
createProject,
readCreateSdkVersion,
runCreateCommand,
type CreateCommandContext,
} from '../src/command.ts'
import { CreateWizard } from '../src/create-wizard.ts'
import { resolveHeadless } from '../src/headless.ts'
import { scaffoldProject } from '../src/project-scaffolder.ts'
class ScriptedPort implements PromptPort {
readonly requests: string[] = []
readonly #answers: unknown[]
constructor(answers: unknown[]) {
this.#answers = [...answers]
}
answer<T>(message: string): Promise<PromptOutcome<T>> {
this.requests.push(message)
const value = this.#answers.shift()
return Promise.resolve(value === ScriptedPort.cancel
? { status: 'cancelled' }
: { status: 'answered', value: value as T })
}
async text(request: TextPromptRequest): Promise<PromptOutcome<string>> {
const outcome = await this.answer<string>(request.message)
if (outcome.status === 'cancelled') return outcome
const value = outcome.value || request.defaultValue || ''
const diagnostic = request.validate?.(value)
if (diagnostic) throw new Error(diagnostic)
return { status: 'answered', value }
}
secret(request: SecretPromptRequest): Promise<PromptOutcome<string>> { return this.answer(request.message) }
select<T>(request: SelectPromptRequest<T>): Promise<PromptOutcome<T>> { return this.answer(request.message) }
multiselect<T>(request: MultiSelectPromptRequest<T>): Promise<PromptOutcome<readonly T[]>> {
return this.answer(request.message)
}
confirm(request: ConfirmPromptRequest): Promise<PromptOutcome<boolean>> { return this.answer(request.message) }
nestedMultiselect<TValue, TChoice>(
request: NestedMultiSelectRequest<TValue, TChoice>,
): Promise<PromptOutcome<readonly NestedMultiSelectValue<TValue, TChoice>[]>> {
return this.answer(request.message)
}
static readonly cancel = Symbol('cancel')
}
const temporary: string[] = []
const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url))
interface GeneratedPackageManifest {
scripts?: Record<string, string>
dependencies?: Record<string, string>
devDependencies?: Record<string, string>
}
interface GeneratedTsConfig {
compilerOptions: {
types?: readonly string[]
}
}
function parseGeneratedPackageManifest(text: string): GeneratedPackageManifest {
return JSON.parse(text) as GeneratedPackageManifest
}
function parseGeneratedTsConfig(text: string): GeneratedTsConfig {
return JSON.parse(text) as GeneratedTsConfig
}
function commandContext(
cwd: string,
port?: PromptPort,
setup?: CreateCommandContext['setup'],
): CreateCommandContext & { readStdout: () => string; readStderr: () => string } {
let stdout = ''
let stderr = ''
const input = Object.assign(new PassThrough(), { isTTY: true }) as unknown as NodeJS.ReadStream
const output = Object.assign(new Writable({
write(chunk, _encoding, callback) { stdout += String(chunk); callback() },
}), { isTTY: true }) as unknown as NodeJS.WriteStream
const error = new Writable({
write(chunk, _encoding, callback) { stderr += String(chunk); callback() },
}) as unknown as NodeJS.WriteStream
return {
cwd,
stdin: input,
stdout: output,
stderr: error,
releaseVersion: '0.0.1',
versionProbe: async () => '10.0.0',
...port ? { port } : {},
...setup ? { setup } : {},
readStdout: () => stdout,
readStderr: () => stderr,
}
}
afterEach(async () => {
await Promise.all(temporary.splice(0).map(path => rm(path, { recursive: true, force: true })))
})
describe('create arguments', () => {
it('parses public options and the private repository link mode', () => {
expect(parseCreateArgs([
'agent', '--description=demo', '--provider', 'deepseek', '--base-url=https://api.example',
'--api-key', 'key', '--model=m', '--interface', 'acp', '--pm=pnpm', '--no-install',
'--link-workspace',
])).toEqual({
directory: 'agent',
description: 'demo',
provider: 'deepseek',
baseURL: 'https://api.example',
apiKey: 'key',
model: 'm',
runInterface: 'acp',
packageManager: 'pnpm',
install: false,
linkWorkspace: true,
help: false,
})
expect(parseCreateArgs(['--link-workspace']).linkWorkspace).toBe(true)
expect(() => parseCreateArgs(['--link-packages-workspace'])).toThrow("unknown option '--link-packages-workspace'")
expect(parseCreateArgs(['--provider=custom']).provider).toBe('custom')
expect(parseCreateArgs(['--help']).help).toBe(true)
expect(() => parseCreateArgs(['--interface=bad'])).toThrow('Allowed choices are acp, tui, embed')
expect(() => parseCreateArgs(['--unknown'])).toThrow("unknown option '--unknown'")
expect(() => parseCreateArgs(['one', 'two'])).toThrow('too many arguments')
})
it('validates empty directories and package names', async () => {
const root = await mkdtemp(join(tmpdir(), 'create-validation-'))
temporary.push(root)
await expect(new CreateWizard({
args: parseCreateArgs(['']), port: new ScriptedPort([]), cwd: root,
releaseVersion: '0.0.1', versionProbe: async () => '10.0.0',
}).run()).rejects.toThrow('A value is required')
await expect(new CreateWizard({
args: parseCreateArgs(['agent']), port: new ScriptedPort(['Invalid Name']), cwd: root,
releaseVersion: '0.0.1', versionProbe: async () => '10.0.0',
}).run()).rejects.toThrow('lowercase npm package name')
})
it('rejects an existing target before asking project questions', async () => {
const cwd = await mkdtemp(join(tmpdir(), 'create-existing-target-'))
temporary.push(cwd)
await mkdir(join(cwd, 'taken'))
const port = new ScriptedPort([])
const wizard = new CreateWizard({
args: parseCreateArgs(['taken']),
port,
cwd,
releaseVersion: '0.0.1',
versionProbe: async () => '10.0.0',
})
await expect(wizard.run()).rejects.toThrow('directory: Target already exists')
expect(port.requests).toEqual([])
})
})
describe('CreateWizard and scaffolder', () => {
it('asks only unresolved questions in requirement-safe order', async () => {
const cwd = await mkdtemp(join(tmpdir(), 'create-wizard-'))
temporary.push(cwd)
const port = new ScriptedPort([
'my-agent',
[
{ value: featureId('persistence'), choices: ['sqlite'] },
{ value: featureId('hmr'), choices: [] },
{ value: featureId('fs'), choices: [] },
{ value: featureId('web'), choices: ['exa'] },
],
false,
'exa-key',
'tool',
])
const args = parseCreateArgs([
'my-agent',
'--description=demo',
'--provider=deepseek',
'--api-key=deepseek-key',
'--model=deepseek-v4-flash',
'--interface=tui',
'--pm=npm',
'--no-install',
'--link-workspace',
])
const resolved = await new CreateWizard({
args,
port,
cwd,
releaseVersion: '0.0.1',
versionProbe: async () => '10.0.0',
}).run()
expect(port.requests).toEqual([
'Package name',
'Select features',
'Add the recommended tool timeout policy for web search and fetch tools?',
'Exa API key',
'Local plugin',
])
expect(resolved.install).toBe(false)
expect(resolved.request.packageManager.name).toBe('npm')
expect(resolved.request.linkWorkspaceRoot).toBe(repoRoot)
expect(resolved.request.localPlugins[0]).toMatchObject({ name: 'tool', kind: 'tool' })
expect(resolved.request.features.find(item => item.id === 'web')).toMatchObject({
options: ['exa'], secrets: { apiKey: 'exa-key' },
})
expect(resolved.request.features.find(item => item.id === 'hmr')).toMatchObject({ options: ['default'] })
})
it('runs headlessly from a feature plan without reaching the terminal', async () => {
const cwd = await mkdtemp(join(tmpdir(), 'create-headless-'))
temporary.push(cwd)
const features: FeatureSelection[] = [
{ id: featureId('persistence'), options: ['sqlite'], values: { region: 'us' } },
{ id: featureId('web'), options: ['exa'], secrets: { apiKey: 'exa-key' } },
]
const resolved = await new CreateWizard({
args: parseCreateArgs([
'my-agent', '--description=demo', '--provider=deepseek', '--api-key=deepseek-key',
'--model=deepseek-v4-flash', '--interface=tui', '--pm=npm', '--no-install',
]),
port: new HeadlessPromptPort(),
cwd,
releaseVersion: '0.0.1',
versionProbe: async () => '10.0.0',
features,
}).run()
expect(resolved.install).toBe(false)
expect(resolved.request.localPlugins).toEqual([])
expect(resolved.request.features.find(item => item.id === 'web')).toMatchObject({
options: ['exa'], secrets: { apiKey: 'exa-key' },
})
expect(resolved.request.features.find(item => item.id === 'persistence')).toMatchObject({ options: ['sqlite'] })
expect(resolved.request.features.find(item => item.id === 'provider')).toMatchObject({
secrets: { apiKey: 'deepseek-key' },
})
})
it('rejects a non-string feature value in a headless plan', async () => {
const cwd = await mkdtemp(join(tmpdir(), 'create-headless-bad-'))
temporary.push(cwd)
const features = [
{ id: featureId('persistence'), options: ['sqlite'], values: { bad: 1 } },
] as unknown as FeatureSelection[]
await expect(new CreateWizard({
args: parseCreateArgs([
'my-agent', '--description=demo', '--provider=deepseek', '--api-key=k',
'--model=m', '--interface=tui', '--pm=npm', '--no-install',
]),
port: new HeadlessPromptPort(),
cwd,
releaseVersion: '0.0.1',
versionProbe: async () => '10.0.0',
features,
}).run()).rejects.toThrow('must be a string')
})
it('writes the project once and refuses every existing target', async () => {
const root = await mkdtemp(join(tmpdir(), 'create-scaffold-'))
temporary.push(root)
const request = {
name: 'agent',
description: 'demo',
runtime: { model: 'deepseek-v4-flash' },
packageManager: new NpmPackageManager('10.0.0'),
releaseVersion: '0.0.1',
features: [
{ id: featureId('provider'), options: ['deepseek'], secrets: { apiKey: 'key' } },
{ id: featureId('bash'), options: ['local'] },
{ id: featureId('app'), options: ['embed'] },
{ id: featureId('persistence'), options: ['jsonl'] },
],
localPlugins: [new LocalPluginBlueprint('plugin', 'plugin')],
}
const target = join(root, 'project')
const result = await scaffoldProject(target, request)
expect(result.changes.changedFiles).toContain('README.md')
const index = await readFile(join(target, 'index.ts'), 'utf8')
expect(index).toContain('SdkBootContext')
expect(index).toContain('ctx.agents.create')
expect(index).toContain('agentOptions: { model: "deepseek-v4-flash" }')
expect(index).not.toContain('AgentId')
const tsconfig = parseGeneratedTsConfig(await readFile(join(target, 'tsconfig.base.json'), 'utf8'))
const manifest = parseGeneratedPackageManifest(await readFile(join(target, 'package.json'), 'utf8'))
expect(tsconfig.compilerOptions.types).toEqual(['node'])
expect(manifest.scripts).toEqual({
dev: 'dsh-sdk dev index.ts',
build: 'dsh-sdk build',
typecheck: 'tsc -b',
start: 'dsh-sdk start index.js',
config: 'dsh-sdk config',
})
expect(manifest.dependencies).not.toHaveProperty('node-addon-require-builtin')
expect(manifest.devDependencies?.['@types/node']).toBe('^22.20.0')
expect(await readFile(join(target, 'plugins/plugin/src/index.ts'), 'utf8')).toContain('export function apply')
const cordis = await readFile(join(target, 'cordis.yml'), 'utf8')
expect(cordis).toMatch(/^- id:/)
expect(cordis).not.toMatch(/^\[/)
const occupied = join(root, 'occupied')
await mkdir(occupied)
await expect(scaffoldProject(occupied, request)).rejects.toThrow('already exists')
await writeFile(join(occupied, 'keep'), 'x')
await expect(scaffoldProject(occupied, request)).rejects.toThrow('already exists')
})
it('installs workflow requirements before validating the next feature', async () => {
const cwd = await mkdtemp(join(tmpdir(), 'create-workflow-requires-'))
temporary.push(cwd)
const port = new ScriptedPort([
'workflow-agent',
[
{ value: featureId('persistence'), choices: ['jsonl'] },
{ value: featureId('workflow'), choices: [] },
],
'none',
])
const resolved = await new CreateWizard({
args: parseCreateArgs([
'workflow-agent', '--description=test', '--provider=deepseek', '--api-key=key',
'--interface=embed', '--pm=npm', '--no-install',
]),
port,
cwd,
releaseVersion: '0.0.1',
versionProbe: async () => '10.0.0',
}).run()
const result = await scaffoldProject(resolved.directory, resolved.request)
expect(result.project.cordis.entry('subagent-spawn')).toBeDefined()
expect(result.project.cordis.entry('tool-subagent')).toBeDefined()
})
it('confirms an empty provider key and leaves a documented .env placeholder', async () => {
const cwd = await mkdtemp(join(tmpdir(), 'create-empty-key-'))
temporary.push(cwd)
const port = new ScriptedPort([
'empty-key-agent',
'',
true,
[{ value: featureId('persistence'), choices: ['jsonl'] }],
'none',
])
const resolved = await new CreateWizard({
args: parseCreateArgs([
'empty-key-agent', '--description=test', '--provider=deepseek',
'--interface=embed', '--pm=npm', '--no-install',
]),
port,
cwd,
releaseVersion: '0.0.1',
versionProbe: async () => '10.0.0',
}).run()
await scaffoldProject(resolved.directory, resolved.request)
expect(await readFile(join(resolved.directory, '.env'), 'utf8')).toBe(
'# Required before start; an empty value makes provider startup fail.\nDEEPSEEK_API_KEY=\n',
)
expect(port.requests).toContain('Keep the API key empty and fill .env later?')
})
it('collects custom provider inputs, retries an empty key, and accepts a recommendation', async () => {
const cwd = await mkdtemp(join(tmpdir(), 'create-custom-inputs-'))
temporary.push(cwd)
const port = new ScriptedPort([
'custom-agent',
'test custom provider',
'custom',
'https://provider.example/v1',
'', false, 'custom-key',
'embed',
[
{ value: featureId('persistence'), choices: ['jsonl'] },
{ value: featureId('web'), choices: ['deepseek'] },
],
true,
'none',
'npm',
false,
])
const resolved = await new CreateWizard({
args: parseCreateArgs(['custom-agent']),
port,
cwd,
releaseVersion: '0.0.1',
versionProbe: async () => '10.0.0',
userAgent: '',
}).run()
expect(resolved.request.features.find(item => item.id === 'provider')).toMatchObject({
options: ['custom'], values: { baseURL: 'https://provider.example/v1' }, secrets: { apiKey: 'custom-key' },
})
expect(resolved.request.features.some(item => item.id === 'timeout-policy')).toBe(true)
})
it('does not re-suggest an already selected feature', async () => {
const cwd = await mkdtemp(join(tmpdir(), 'create-selected-suggestion-'))
temporary.push(cwd)
const port = new ScriptedPort([
'agent',
[
{ value: featureId('persistence'), choices: ['jsonl'] },
{ value: featureId('web'), choices: ['deepseek'] },
{ value: featureId('timeout-policy'), choices: ['default'] },
],
'none',
])
const resolved = await new CreateWizard({
args: parseCreateArgs([
'agent', '--description=test', '--provider=deepseek', '--api-key=key',
'--interface=embed', '--pm=npm', '--no-install',
]),
port,
cwd,
releaseVersion: '0.0.1',
versionProbe: async () => '10.0.0',
}).run()
expect(resolved.request.features.filter(item => item.id === 'timeout-policy')).toHaveLength(1)
})
it('uses process defaults when constructor infrastructure is omitted', async () => {
const name = `default-infra-${String(process.pid)}`
const port = new ScriptedPort([
name, [{ value: featureId('persistence'), choices: ['jsonl'] }], 'none',
])
const resolved = await new CreateWizard({
args: parseCreateArgs([
name, '--description=test', '--provider=deepseek', '--api-key=key',
'--interface=embed', '--pm=npm', '--no-install',
]),
port,
releaseVersion: '0.0.1',
}).run()
expect(resolved.request.packageManager.name).toBe('npm')
})
it('reads the release batch from the initializer package', async () => {
await expect(readCreateSdkVersion()).resolves.toBe('0.0.1')
})
})
describe('create command composition', () => {
const argv = (directory: string, install: boolean): string[] => [
directory, '--description=test', '--provider=deepseek', '--api-key=key',
'--interface=embed', '--pm=npm', install ? '--install' : '--no-install',
]
it('prints help before requiring a TTY and rejects non-interactive creation', async () => {
const root = await mkdtemp(join(tmpdir(), 'create-command-help-'))
temporary.push(root)
const context = commandContext(root)
context.stdin.isTTY = false
context.stdout.isTTY = false
await expect(createProject(['--help'], context)).resolves.toBeUndefined()
expect(context.readStdout()).toContain('Usage: create-sdk')
expect(context.readStdout()).toContain('--config-json <json>')
expect(context.readStdout()).not.toContain('--link-workspace')
await expect(createProject(argv('agent', false), context)).rejects.toThrow('interactive TTY')
context.stdin.isTTY = true
await expect(createProject(argv('agent', false), context)).rejects.toThrow('interactive TTY')
})
it('creates headlessly from --config-json with no TTY', async () => {
const root = await mkdtemp(join(tmpdir(), 'create-headless-cmd-'))
temporary.push(root)
const spec = JSON.stringify({
directory: 'agent', description: 'test', provider: 'deepseek', apiKey: 'key',
model: 'deepseek-v4-flash', interface: 'embed', pm: 'npm', install: false,
features: [{ id: 'persistence', options: ['jsonl'] }],
})
const context = commandContext(root)
context.stdin.isTTY = false
context.stdout.isTTY = false
const result = await createProject(['--config-json', spec], context)
expect(result?.project.root).toBe(join(root, 'agent'))
})
it('emits NDJSON lifecycle events under --json', async () => {
const root = await mkdtemp(join(tmpdir(), 'create-headless-json-'))
temporary.push(root)
const base = {
description: 'test', model: 'deepseek-v4-flash', interface: 'embed', pm: 'npm', install: false,
}
const ok = commandContext(root)
ok.stdin.isTTY = false
ok.stdout.isTTY = false
const okSpec = JSON.stringify({ ...base, directory: 'done-agent', provider: 'deepseek', apiKey: 'key', features: [] })
await expect(runCreateCommand(['--config-json', okSpec, '--json'], ok)).resolves.toBe(0)
expect(ok.readStdout()).toContain('{"type":"done"}')
// stdout stays pure NDJSON: every line parses, human progress goes to stderr
for (const line of ok.readStdout().split('\n').filter(line => line.length > 0)) {
expect(() => { JSON.parse(line) }).not.toThrow()
}
expect(ok.readStderr()).toContain('Created done-agent')
expect(ok.readStderr()).toContain('Next: cd')
const missing = commandContext(root)
missing.stdin.isTTY = false
missing.stdout.isTTY = false
const missingSpec = JSON.stringify({ ...base, directory: 'miss-agent', provider: 'custom', baseURL: 'https://x', features: [] })
await expect(runCreateCommand(['--config-json', missingSpec, '--json'], missing)).resolves.toBe(1)
expect(missing.readStdout()).toContain('"type":"action-required"')
const broken = commandContext(root)
broken.stdin.isTTY = false
broken.stdout.isTTY = false
await expect(runCreateCommand(['--config-json', '{bad', '--json'], broken)).resolves.toBe(1)
expect(broken.readStdout()).toContain('"type":"error"')
const cancelled = commandContext(root, new ScriptedPort([ScriptedPort.cancel]))
await expect(runCreateCommand(['--json', ...argv('cancel-agent', false)], cancelled)).resolves.toBe(1)
expect(cancelled.readStdout()).toContain('"reason":"cancelled"')
})
it('creates through an injected prompt port and delegates optional setup', async () => {
const root = await mkdtemp(join(tmpdir(), 'create-command-success-'))
temporary.push(root)
const port = new ScriptedPort([
'agent', [{ value: featureId('persistence'), choices: ['jsonl'] }], 'none',
])
let setupDirectory = ''
const context = commandContext(root, port, async (request) => { setupDirectory = request.directory })
const result = await createProject(argv('agent', true), context)
expect(result?.project.root).toBe(join(root, 'agent'))
expect(setupDirectory).toBe(join(root, 'agent'))
expect(context.readStdout()).toContain('Created agent')
expect(context.readStdout()).toContain('Next: cd')
const noInstall = commandContext(root, new ScriptedPort([
'next', [{ value: featureId('persistence'), choices: ['jsonl'] }], 'none',
]))
await expect(createProject(argv('next', false), noInstall)).resolves.toBeDefined()
expect(noInstall.readStdout()).toContain('npm install && npm run build && npm start')
})
it('uses the package manager setup path when no setup override is supplied', async () => {
const root = await mkdtemp(join(tmpdir(), 'create-command-default-setup-'))
temporary.push(root)
const port = new ScriptedPort([
'agent', [{ value: featureId('persistence'), choices: ['jsonl'] }], 'none',
])
const install = vi.spyOn(NpmPackageManager.prototype, 'install').mockResolvedValue()
const build = vi.spyOn(NpmPackageManager.prototype, 'build').mockResolvedValue()
const context = commandContext(root, port)
delete context.releaseVersion
delete context.versionProbe
await createProject(argv('agent', true), context)
expect(install).toHaveBeenCalledOnce()
expect(build).toHaveBeenCalledOnce()
const spec = JSON.stringify({
directory: 'json-agent', description: 'test', provider: 'deepseek', apiKey: 'key',
model: 'deepseek-v4-flash', interface: 'embed', pm: 'npm', install: true, features: [],
})
const json = commandContext(root)
json.stdin.isTTY = false
json.stdout.isTTY = false
await createProject(['--config-json', spec, '--json'], json)
// json mode hands install/build a runner that redirects child output to stderr
expect(install).toHaveBeenCalledTimes(2)
expect(install.mock.calls[1]?.[1]).toBeInstanceOf(NodeCommandRunner)
install.mockRestore()
build.mockRestore()
})
it('reports setup failures after preserving generated files', async () => {
const root = await mkdtemp(join(tmpdir(), 'create-command-failure-'))
temporary.push(root)
const port = new ScriptedPort([
'agent', [{ value: featureId('persistence'), choices: ['jsonl'] }], 'none',
])
const context = commandContext(root, port, async () => { throw new Error('offline') })
await expect(createProject(argv('agent', true), context)).rejects.toThrow('offline')
expect(context.readStderr()).toContain('Project files are ready, but setup failed')
expect(context.readStderr()).toContain('npm install && npm run build')
const stringFailure = commandContext(root, new ScriptedPort([
'next', [{ value: featureId('persistence'), choices: ['jsonl'] }], 'none',
]), async () => { throw 'offline-string' })
await expect(runCreateCommand(argv('next', true), stringFailure)).resolves.toBe(1)
expect(stringFailure.readStderr()).toContain('offline-string')
})
it('maps cancellation and ordinary errors to command exit codes', async () => {
const root = await mkdtemp(join(tmpdir(), 'create-command-exit-'))
temporary.push(root)
const cancelled = commandContext(root, new ScriptedPort([ScriptedPort.cancel]))
await expect(runCreateCommand([], cancelled)).resolves.toBe(1)
expect(cancelled.readStderr()).toContain('cancelled')
const invalid = commandContext(root)
await expect(runCreateCommand(['--unknown'], invalid)).resolves.toBe(1)
expect(invalid.readStderr()).toContain('unknown option')
const help = commandContext(root)
await expect(runCreateCommand(['--help'], help)).resolves.toBe(0)
})
})
describe('resolveHeadless', () => {
it('returns undefined without a config source', async () => {
expect(await resolveHeadless(parseCreateArgs(['agent']))).toBeUndefined()
})
it('maps every inline --config-json field into args plus the feature plan', async () => {
const spec = JSON.stringify({
directory: 'a', description: 'd', provider: 'custom', baseURL: 'https://x', apiKey: 'k',
model: 'm', interface: 'acp', pm: 'pnpm', install: true, linkWorkspace: true,
features: [{ id: 'todo', options: ['default'] }],
})
const resolved = await resolveHeadless(parseCreateArgs(['--config-json', spec]))
expect(resolved?.args).toMatchObject({
directory: 'a', description: 'd', provider: 'custom', baseURL: 'https://x', apiKey: 'k',
model: 'm', runInterface: 'acp', packageManager: 'pnpm', install: true, linkWorkspace: true, help: false,
})
expect(resolved?.features).toEqual([{ id: 'todo', options: ['default'] }])
})
it('reads --config from a file via the injected reader and omits absent fields', async () => {
const resolved = await resolveHeadless(
parseCreateArgs(['--config', '/spec.json']),
async () => JSON.stringify({ description: 'from-file' }),
)
expect(resolved?.args.description).toBe('from-file')
expect(resolved?.args.directory).toBeUndefined()
expect(resolved?.args.linkWorkspace).toBeUndefined()
expect(resolved?.features).toBeUndefined()
})
it('reads --config from disk with the default reader', async () => {
const dir = await mkdtemp(join(tmpdir(), 'create-headless-file-'))
temporary.push(dir)
const file = join(dir, 'spec.json')
await writeFile(file, JSON.stringify({ description: 'on-disk' }))
const resolved = await resolveHeadless(parseCreateArgs(['--config', file]))
expect(resolved?.args.description).toBe('on-disk')
})
it('fails loud on invalid JSON, a non-object root, or a non-array features field', async () => {
await expect(resolveHeadless(parseCreateArgs(['--config-json', '{bad']))).rejects.toThrow('invalid JSON')
await expect(resolveHeadless(parseCreateArgs(['--config-json', '[]']))).rejects.toThrow('expected a JSON object')
await expect(resolveHeadless(parseCreateArgs(['--config-json', 'null']))).rejects.toThrow('expected a JSON object')
await expect(resolveHeadless(parseCreateArgs(['--config-json', '5']))).rejects.toThrow('expected a JSON object')
await expect(resolveHeadless(parseCreateArgs(['--config-json', '{"features":1}']))).rejects.toThrow('must be an array')
})
it('accepts a minimal spec, leaving unspecified answers undefined', async () => {
const resolved = await resolveHeadless(parseCreateArgs(['--config-json', '{"directory":"x"}']))
expect(resolved?.args.directory).toBe('x')
expect(resolved?.args.description).toBeUndefined()
expect(resolved?.features).toBeUndefined()
})
})