feat(sdk): headless PromptPort + create/config headless plan
Add HeadlessPromptPort (fail-loud non-interactive PromptPort), thread prefilled feature values through FeatureConfigurator, and let CreateWizard and ConfigWorkflow accept a headless feature plan that skips the interactive tree/suggests prompts. Per-file 100% coverage on all changed files.
This commit is contained in:
95
packages/sdk/helper/tests/headless-prompt-port.spec.ts
Normal file
95
packages/sdk/helper/tests/headless-prompt-port.spec.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { HeadlessPromptError, HeadlessPromptPort } from '../src/questions/headless-prompt-port.ts'
|
||||
|
||||
/** Unwrap an answered outcome or fail the test. */
|
||||
async function answered<T>(promise: Promise<{ status: 'answered'; value: T } | { status: 'cancelled' }>): Promise<T> {
|
||||
const outcome = await promise
|
||||
if (outcome.status !== 'answered') throw new Error('expected an answered outcome')
|
||||
return outcome.value
|
||||
}
|
||||
|
||||
describe('HeadlessPromptError', () => {
|
||||
it('names the unanswered prompt', () => {
|
||||
const error = new HeadlessPromptError('DeepSeek API key')
|
||||
expect(error).toBeInstanceOf(Error)
|
||||
expect(error.name).toBe('HeadlessPromptError')
|
||||
expect(error.prompt).toBe('DeepSeek API key')
|
||||
expect(error.message).toContain('DeepSeek API key')
|
||||
})
|
||||
})
|
||||
|
||||
describe('HeadlessPromptPort', () => {
|
||||
const port = new HeadlessPromptPort()
|
||||
|
||||
describe('text', () => {
|
||||
it('takes the initial value when present', async () => {
|
||||
expect(await answered(port.text({ message: 'name', initialValue: 'agent' }))).toBe('agent')
|
||||
})
|
||||
|
||||
it('falls back to the default value', async () => {
|
||||
expect(await answered(port.text({ message: 'dir', defaultValue: 'my-agent' }))).toBe('my-agent')
|
||||
})
|
||||
|
||||
it('prefers the initial value over the default value', async () => {
|
||||
expect(await answered(port.text({ message: 'dir', initialValue: 'given', defaultValue: 'my-agent' }))).toBe('given')
|
||||
})
|
||||
|
||||
it('fails loud when no default exists', async () => {
|
||||
await expect(port.text({ message: 'base URL' })).rejects.toThrow(HeadlessPromptError)
|
||||
})
|
||||
|
||||
it('fails loud when the default is invalid', async () => {
|
||||
await expect(port.text({
|
||||
message: 'name',
|
||||
defaultValue: '',
|
||||
validate: value => value.length === 0 ? 'required' : undefined,
|
||||
})).rejects.toThrow(/required/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('secret', () => {
|
||||
it('always fails loud', async () => {
|
||||
await expect(port.secret({ message: 'API key' })).rejects.toThrow(HeadlessPromptError)
|
||||
})
|
||||
})
|
||||
|
||||
describe('select', () => {
|
||||
it('takes the initial value when present', async () => {
|
||||
expect(await answered(port.select({ message: 'pm', options: [{ value: 'npm', label: 'npm' }], initialValue: 'npm' }))).toBe('npm')
|
||||
})
|
||||
|
||||
it('fails loud without an initial value', async () => {
|
||||
await expect(port.select({ message: 'pm', options: [{ value: 'npm', label: 'npm' }] })).rejects.toThrow(HeadlessPromptError)
|
||||
})
|
||||
})
|
||||
|
||||
describe('multiselect', () => {
|
||||
it('returns the initial values', async () => {
|
||||
expect(await answered(port.multiselect({ message: 'x', options: [], initialValues: ['a', 'b'] }))).toEqual(['a', 'b'])
|
||||
})
|
||||
|
||||
it('returns an empty selection when none are supplied and none are required', async () => {
|
||||
expect(await answered(port.multiselect({ message: 'x', options: [] }))).toEqual([])
|
||||
})
|
||||
|
||||
it('fails loud when required and nothing is preselected', async () => {
|
||||
await expect(port.multiselect({ message: 'x', options: [], required: true })).rejects.toThrow(HeadlessPromptError)
|
||||
})
|
||||
})
|
||||
|
||||
describe('confirm', () => {
|
||||
it('takes the initial value when present', async () => {
|
||||
expect(await answered(port.confirm({ message: 'install?', initialValue: false }))).toBe(false)
|
||||
})
|
||||
|
||||
it('fails loud without an initial value', async () => {
|
||||
await expect(port.confirm({ message: 'apply?' })).rejects.toThrow(HeadlessPromptError)
|
||||
})
|
||||
})
|
||||
|
||||
describe('nestedMultiselect', () => {
|
||||
it('always fails loud', async () => {
|
||||
await expect(port.nestedMultiselect({ message: 'Select features', options: [] })).rejects.toThrow(HeadlessPromptError)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -450,4 +450,35 @@ describe('feature configurator', () => {
|
||||
await expect(new FeatureConfigurator(new QueuePromptPort([])).configure(new EmptyExclusive(), profile))
|
||||
.rejects.toThrow('has no default option')
|
||||
})
|
||||
|
||||
it('configures fully from prefilled options, values, and secrets without prompting', async () => {
|
||||
const registry = createBuiltinRegistry(profile)
|
||||
const port = new QueuePromptPort([])
|
||||
const result = await new FeatureConfigurator(port).configure(
|
||||
registry.get(featureId('provider')),
|
||||
profile,
|
||||
undefined,
|
||||
['custom'],
|
||||
{ apiKey: 'prefilled-key' },
|
||||
{ baseURL: 'https://prefilled' },
|
||||
)
|
||||
expect(result).toMatchObject({
|
||||
options: ['custom'],
|
||||
values: { baseURL: 'https://prefilled' },
|
||||
secrets: { apiKey: 'prefilled-key' },
|
||||
})
|
||||
expect(port.requests).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects a non-string prefilled feature value', async () => {
|
||||
const registry = createBuiltinRegistry(profile)
|
||||
await expect(new FeatureConfigurator(new QueuePromptPort([])).configure(
|
||||
registry.get(featureId('provider')),
|
||||
profile,
|
||||
undefined,
|
||||
['custom'],
|
||||
{ apiKey: 'k' },
|
||||
{ baseURL: 123 },
|
||||
)).rejects.toThrow('must be a string')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user