import { PassThrough, Writable } from 'node:stream' import { stripVTControlCharacters } from 'node:util' import { S_CHECKBOX_SELECTED, S_RADIO_ACTIVE, S_WARN } from '@clack/prompts' import { describe, expect, it } from 'vitest' import { createBuiltinRegistry } from '../src/features/builtin/index.ts' import { FeatureConfigurator } from '../src/features/feature-configurator.ts' import { FeatureOption, ExclusiveOptionFeature } from '../src/features/feature.ts' import { ProjectContribution } from '../src/features/resources.ts' import { featureId } from '../src/ids.ts' import { NpmPackageManager } from '../src/package-managers/package-manager.ts' import { ClackPromptPort } from '../src/questions/clack-prompt-port.ts' import { PromptCancelledError, requireAnswer, type ConfirmPromptRequest, type MultiSelectPromptRequest, type NestedMultiSelectRequest, type NestedMultiSelectValue, type PromptOutcome, type PromptPort, type SecretPromptRequest, type SelectPromptRequest, type TextPromptRequest, } from '../src/questions/prompt-port.ts' import { ConfirmQuestion, MultiSelectQuestion, SecretQuestion, SelectQuestion, TextQuestion, } from '../src/questions/question.ts' import type { ProjectProfile } from '../src/project/types.ts' import { clackNestedMultiselect } from '../src/questions/clack-nested-multiselect.ts' function validateString( outcome: PromptOutcome, validate: ((value: string) => string | undefined) | undefined, ): PromptOutcome { if (outcome.status === 'answered') { const diagnostic = validate?.(outcome.value) if (diagnostic) throw new Error(diagnostic) } return outcome } class QueuePromptPort implements PromptPort { readonly answers: unknown[] readonly requests: string[] = [] constructor(answers: unknown[]) { this.answers = [...answers] } next(message: string): PromptOutcome { this.requests.push(message) const value = this.answers.shift() return value === QueuePromptPort.cancel ? { status: 'cancelled' } : { status: 'answered', value: value as T } } async text(request: TextPromptRequest): Promise> { return validateString(this.next(request.message), request.validate) } async secret(request: SecretPromptRequest): Promise> { return validateString(this.next(request.message), request.validate) } select(request: SelectPromptRequest): Promise> { return Promise.resolve(this.next(request.message)) } multiselect(request: MultiSelectPromptRequest): Promise> { return Promise.resolve(this.next(request.message)) } confirm(request: ConfirmPromptRequest): Promise> { return Promise.resolve(this.next(request.message)) } nestedMultiselect( request: NestedMultiSelectRequest, ): Promise[]>> { return Promise.resolve(this.next(request.message)) } static readonly cancel = Symbol('cancel') } describe('typed questions', () => { it('uses and validates prefilled answers without prompting', async () => { const port = new QueuePromptPort([]) const text = new TextQuestion({ id: 'name', message: 'Name', validate: value => value ? undefined : 'required' }) await expect(text.resolve(port, 'demo')).resolves.toEqual({ status: 'answered', value: 'demo' }) await expect(text.resolve(port, '')).rejects.toThrow('name: required') const select = new SelectQuestion({ id: 'choice', message: 'Choice', options: [{ value: 'a', label: 'A' }], initialValue: 'a', }) await expect(select.resolve(port, 'b')).rejects.toThrow('unknown or disabled option') await expect(select.resolve(port, 'a')).resolves.toMatchObject({ value: 'a' }) const disabled = new SelectQuestion({ id: 'disabled', message: 'Disabled', options: [{ value: 'a', label: 'A', disabled: true }], }) await expect(disabled.resolve(port, 'a')).rejects.toThrow('disabled option') const multi = new MultiSelectQuestion({ id: 'many', message: 'Many', options: [{ value: 'a', label: 'A' }], required: true, }) await expect(multi.resolve(port, [])).rejects.toThrow('choose at least one') await expect(multi.resolve(port, ['missing'])).rejects.toThrow('unknown or disabled option') await expect(new MultiSelectQuestion({ id: 'disabled-many', message: 'Disabled many', options: [{ value: 'a', label: 'A', disabled: true }], }).resolve(port, ['a'])).rejects.toThrow('disabled option') await expect(new MultiSelectQuestion({ id: 'optional', message: 'Optional', options: [{ value: 'a', label: 'A' }], }).resolve(port, [])).resolves.toMatchObject({ value: [] }) const secret = new SecretQuestion({ id: 'secret', message: 'Secret', validate: value => value ? undefined : 'required' }) await expect(secret.resolve(port, 'value')).resolves.toMatchObject({ value: 'value' }) await expect(secret.resolve(port, '')).rejects.toThrow('secret: required') await expect(new ConfirmQuestion({ id: 'confirm', message: 'Confirm' }).resolve(port, false)) .resolves.toEqual({ status: 'answered', value: false }) expect(port.requests).toEqual([]) }) it('delegates each interaction shape and propagates cancellation', async () => { const port = new QueuePromptPort(['text', 'secret', 'a', ['a'], true, QueuePromptPort.cancel]) await expect(new TextQuestion({ id: 't', message: 'Text' }).resolve(port)).resolves.toMatchObject({ value: 'text' }) await expect(new SecretQuestion({ id: 's', message: 'Secret' }).resolve(port)).resolves.toMatchObject({ value: 'secret' }) await expect(new SelectQuestion({ id: 'one', message: 'One', options: [{ value: 'a', label: 'A' }], }).resolve(port)).resolves.toMatchObject({ value: 'a' }) await expect(new MultiSelectQuestion({ id: 'many', message: 'Many', options: [{ value: 'a', label: 'A' }], }).resolve(port)).resolves.toMatchObject({ value: ['a'] }) await expect(new ConfirmQuestion({ id: 'yes', message: 'Yes?' }).resolve(port)).resolves.toMatchObject({ value: true }) const cancelled = await new ConfirmQuestion({ id: 'cancel', message: 'Cancel?' }).resolve(port) expect(() => requireAnswer(cancelled)).toThrow(PromptCancelledError) const optionsPort = new QueuePromptPort(['full', 'a', ['a']]) await new TextQuestion({ id: 'full', message: 'Full', placeholder: 'p', initialValue: 'i', defaultValue: 'd', validate: () => undefined, }).resolve(optionsPort) await new SelectQuestion({ id: 'initial', message: 'Initial', options: [{ value: 'a', label: 'A' }], initialValue: 'a', }).resolve(optionsPort) await new MultiSelectQuestion({ id: 'initial-many', message: 'Initial many', options: [{ value: 'a', label: 'A' }], initialValues: ['a'], required: true, }).resolve(optionsPort) }) it('accepts a visible placeholder default before required validation', async () => { const input = new PassThrough() const output = new Writable({ write(_chunk, _encoding, callback) { callback() } }) const pending = new ClackPromptPort(input, output).text({ message: 'Directory', placeholder: 'my-agent', defaultValue: 'my-agent', validate: value => value ? undefined : 'required', }) setTimeout(() => input.write('\r'), 0) await expect(pending).resolves.toEqual({ status: 'answered', value: 'my-agent' }) }) it('renders warning confirmations with a yellow warning marker', async () => { const input = new PassThrough() let screen = '' const output = new Writable({ write(chunk, _encoding, callback) { screen += String(chunk); callback() } }) const pending = new ClackPromptPort(input, output).confirm({ message: 'Keep empty?', initialValue: true, tone: 'warning', }) setTimeout(() => input.write('\r'), 0) await expect(pending).resolves.toEqual({ status: 'answered', value: true }) expect(stripVTControlCharacters(screen)).toContain(`${S_WARN} Keep empty?`) }) it('adapts secret, select, multiselect, nested, and cancellation prompts', async () => { const run = async ( start: (port: ClackPromptPort) => Promise>, keys: string, ): Promise> => { const input = new PassThrough() const output = new Writable({ write(_chunk, _encoding, callback) { callback() } }) const pending = start(new ClackPromptPort(input, output)) setTimeout(() => input.write(keys), 0) return pending } await expect(run(port => port.secret({ message: 'Secret', validate: value => value ? undefined : 'required' }), 'key\r')) .resolves.toEqual({ status: 'answered', value: 'key' }) await expect(run(port => port.secret({ message: 'Secret' }), 'plain\r')) .resolves.toEqual({ status: 'answered', value: 'plain' }) await expect(run(port => port.text({ message: 'Text', initialValue: 'seed' }), '\r')) .resolves.toEqual({ status: 'answered', value: 'seed' }) let validated = 'unset' await expect(run(port => port.text({ message: 'Empty', validate: (value) => { validated = value; return undefined }, }), '\r')).resolves.toEqual({ status: 'answered', value: '' }) expect(validated).toBe('') await expect(run(port => port.select({ message: 'Select', options: [{ value: 'a', label: 'A', hint: 'hint' }, { value: 'b', label: 'B', disabled: true }], initialValue: 'a', }), '\r')).resolves.toEqual({ status: 'answered', value: 'a' }) await expect(run(port => port.multiselect({ message: 'Many', options: [{ value: 'a', label: 'A' }], initialValues: ['a'], required: true, }), '\r')).resolves.toEqual({ status: 'answered', value: ['a'] }) await expect(run(port => port.multiselect({ message: 'Many', options: [{ value: 'a', label: 'A' }], }), ' \r')).resolves.toEqual({ status: 'answered', value: ['a'] }) await expect(run(port => port.nestedMultiselect({ message: 'Nested', options: [{ value: 'a', label: 'A', default: true }], }), '\r')).resolves.toEqual({ status: 'answered', value: [{ value: 'a', choices: [] }] }) await expect(run(port => port.confirm({ message: 'Cancel' }), '\u0003')).resolves.toEqual({ status: 'cancelled' }) expect(new ClackPromptPort()).toBeInstanceOf(ClackPromptPort) }) }) describe('nested Clack picker', () => { it('navigates root options, ignores disabled rows, and toggles optional rows', async () => { const input = new PassThrough() const output = new Writable({ write(_chunk, _encoding, callback) { callback() } }) const pending = clackNestedMultiselect({ message: 'Features', showChanges: true, input, output, options: [ { value: 'required', label: 'Required', required: true }, { value: 'optional', label: 'Optional', default: true }, { value: 'added', label: 'Added' }, { value: 'disabled', label: 'Disabled', disabled: true, warning: 'disabled warning' }, ], }) setTimeout(() => input.write('\x1b[A \x1b[B\x1b[B \x1b[B \x1b[A\r'), 0) await expect(pending).resolves.toEqual({ status: 'answered', value: [{ value: 'required', choices: [] }, { value: 'added', choices: [] }], }) }) it('cancels from the root layer', async () => { const input = new PassThrough() const output = new Writable({ write(_chunk, _encoding, callback) { callback() } }) const pending = clackNestedMultiselect({ message: 'Features', input, output, options: [{ value: 'one', label: 'One' }], }) setTimeout(() => input.write('\u0003'), 0) await expect(pending).resolves.toEqual({ status: 'cancelled' }) }) it('enters an exclusive child with Right and commits the selected option', async () => { const input = new PassThrough() let screen = '' const output = new Writable({ write(chunk, _encoding, callback) { screen += String(chunk); callback() } }) const pending = clackNestedMultiselect({ message: 'Features', showChanges: true, input, output, options: [ { value: 'persistence', label: 'Session storage', required: true, default: true, choiceMode: 'exclusive', choices: [ { value: 'jsonl', label: 'JSONL', default: true }, { value: 'sqlite', label: 'SQLite' }, ], }, { value: 'fs', label: 'Filesystem', default: true }, ], }) setTimeout(() => input.write('\x1b[C\x1b[B\x1b[A\x1b[B\x1b[C\r\r'), 0) await expect(pending).resolves.toEqual({ status: 'answered', value: [ { value: 'persistence', choices: ['sqlite'] }, { value: 'fs', choices: [] }, ], }) const rendered = stripVTControlCharacters(screen) expect(rendered).toContain(`› ${S_CHECKBOX_SELECTED} Session storage`) expect(rendered).toContain(`› ${S_RADIO_ACTIVE} SQLite`) expect(rendered).toContain('● changed') }) it('highlights and blocks a selected multiple feature with no child option', async () => { const input = new PassThrough() let screen = '' const output = new Writable({ write(chunk, _encoding, callback) { screen += String(chunk); callback() } }) const pending = clackNestedMultiselect({ message: 'Features', input, output, options: [{ value: 'hooks', label: 'Hooks', default: true, choiceMode: 'multiple', choices: [ { value: 'claude', label: 'Claude', default: true }, { value: 'codex', label: 'Codex' }, ], }], }) setTimeout(() => input.write('\x1b[C \x1b[D \x1b[D\r'), 0) await expect(pending).resolves.toEqual({ status: 'answered', value: [{ value: 'hooks', choices: ['claude'] }], }) expect(stripVTControlCharacters(screen)).toContain('▲ choose at least one') }) it('blocks root submission for an exclusive feature with no selected option', async () => { const input = new PassThrough() let screen = '' const output = new Writable({ write(chunk, _encoding, callback) { screen += String(chunk); callback() } }) const pending = clackNestedMultiselect({ message: 'Features', input, output, options: [{ value: 'provider', label: 'Provider', default: true, choiceMode: 'exclusive', choices: [{ value: 'one', label: 'One' }], }], }) setTimeout(() => input.write('\r\x1b[C\x1b[C\r\r'), 0) await expect(pending).resolves.toEqual({ status: 'answered', value: [{ value: 'provider', choices: ['one'] }], }) expect(stripVTControlCharacters(screen)).toContain('Choose one Provider option') }) it('blocks root submission for a multiple feature with no selected option', async () => { const input = new PassThrough() const output = new Writable({ write(_chunk, _encoding, callback) { callback() } }) const pending = clackNestedMultiselect({ message: 'Features', input, output, options: [{ value: 'hooks', label: 'Hooks', default: true, choiceMode: 'multiple', choices: [{ value: 'one', label: 'One' }], }], }) setTimeout(() => input.write('\r\x1b[C \r\r'), 0) await expect(pending).resolves.toEqual({ status: 'answered', value: [{ value: 'hooks', choices: ['one'] }], }) }) it('renders an unchanged checked option while another child is focused', async () => { const input = new PassThrough() const output = new Writable({ write(_chunk, _encoding, callback) { callback() } }) const pending = clackNestedMultiselect({ message: 'Features', input, output, options: [{ value: 'hooks', label: 'Hooks', default: true, choiceMode: 'multiple', choices: [ { value: 'one', label: 'One', default: true }, { value: 'two', label: 'Two', default: true }, ], }], }) setTimeout(() => input.write('\x1b[C\x1b[B\x1b[D\r'), 0) await expect(pending).resolves.toEqual({ status: 'answered', value: [{ value: 'hooks', choices: ['one', 'two'] }], }) }) it('submits an empty optional selection', async () => { const input = new PassThrough() const output = new Writable({ write(_chunk, _encoding, callback) { callback() } }) const pending = clackNestedMultiselect({ message: 'Features', input, output, options: [{ value: 'one', label: 'One' }], }) setTimeout(() => input.write('\r'), 0) await expect(pending).resolves.toEqual({ status: 'answered', value: [] }) }) }) describe('feature configurator', () => { const profile: ProjectProfile = { name: 'demo', description: 'demo', runtime: { model: 'deepseek-v4-flash' }, runInterface: 'tui', packageManager: new NpmPackageManager('10.0.0'), releaseVersion: '0.0.1', } it('shares exclusive, multiple, fixed, and secret behavior', async () => { const registry = createBuiltinRegistry(profile) const port = new QueuePromptPort(['sqlite', ['spawn', 'fork'], 'deepseek', 'new-key']) const configurator = new FeatureConfigurator(port) await expect(configurator.configure(registry.get(featureId('persistence')), profile)).resolves.toMatchObject({ options: ['sqlite'], }) await expect(configurator.configure(registry.get(featureId('subagent')), profile)).resolves.toMatchObject({ options: ['spawn', 'fork'], }) await expect(configurator.configure( registry.get(featureId('provider')), profile, { id: featureId('provider'), options: ['deepseek'], secrets: { apiKey: 'old-key' } }, )).resolves.toMatchObject({ secrets: { apiKey: 'new-key' } }) expect(port.requests).toEqual([ 'Choose durable session storage', 'Choose delegate work to child agents', 'Choose model provider', 'DeepSeek API key (leave empty to keep current)', ]) }) it('validates feature values, defaults, and retained secrets', async () => { const registry = createBuiltinRegistry(profile) const fixed = new FeatureConfigurator(new QueuePromptPort([])) await expect(fixed.configure(registry.get(featureId('bash')), profile, undefined, ['local'])).resolves.toMatchObject({ options: ['local'], }) const requiredSecret = new FeatureConfigurator(new QueuePromptPort([])) await expect(requiredSecret.configure( registry.get(featureId('provider')), profile, undefined, ['deepseek'], { apiKey: '' }, )).rejects.toThrow('required') const keep = new FeatureConfigurator(new QueuePromptPort(['deepseek', ''])) await expect(keep.configure( registry.get(featureId('provider')), profile, { id: featureId('provider'), options: ['deepseek'], secrets: { apiKey: 'old' } }, )).resolves.toMatchObject({ secrets: { apiKey: 'old' } }) const custom = registry.get(featureId('provider')) await expect(new FeatureConfigurator(new QueuePromptPort(['custom'])).configure( custom, profile, { id: featureId('provider'), options: ['custom'], values: { baseURL: 1 }, secrets: { apiKey: 'old' } }, )).rejects.toThrow('current value must be a string') await expect(new FeatureConfigurator(new QueuePromptPort(['custom', ''])).configure( custom, profile, undefined, )).rejects.toThrow('required') await expect(new FeatureConfigurator(new QueuePromptPort(['custom', 'https://next', ''])).configure( custom, profile, { id: featureId('provider'), options: ['custom'], values: { baseURL: 'https://old' }, secrets: { apiKey: 'old' }, }, )).resolves.toMatchObject({ values: { baseURL: 'https://next' }, secrets: { apiKey: 'old' } }) class EmptyExclusive extends ExclusiveOptionFeature { override readonly id = featureId('empty-exclusive') override readonly summary = 'Empty' override readonly options = [new (class extends FeatureOption { override readonly id = 'one' override readonly label = 'One' override contribution(): ProjectContribution { return new ProjectContribution([]) } })()] override defaultOptions(): readonly string[] { return [] } } 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') }) })