329 lines
11 KiB
TypeScript
329 lines
11 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
|
|
import type { CommandSpec } from './args'
|
|
import {
|
|
REPEATED_FLAG_SEPARATOR,
|
|
findCommandSpec,
|
|
normalizeCommandPositionals,
|
|
parseArgs,
|
|
supportsBrowserPageFlag,
|
|
validateCommandAndFlags
|
|
} from './args'
|
|
|
|
describe('parseArgs', () => {
|
|
it('keeps an empty string as a flag value', () => {
|
|
const parsed = parseArgs(['computer', 'set-value', '--value', '', '--json'])
|
|
|
|
expect(parsed.commandPath).toEqual(['computer', 'set-value'])
|
|
expect(parsed.flags.get('value')).toBe('')
|
|
expect(parsed.flags.get('json')).toBe(true)
|
|
})
|
|
|
|
it('accepts a flag value that starts with -- via the = form', () => {
|
|
const parsed = parseArgs(['terminal', 'send', '--text=--help'])
|
|
|
|
expect(parsed.commandPath).toEqual(['terminal', 'send'])
|
|
expect(parsed.flags.get('text')).toBe('--help')
|
|
})
|
|
|
|
it('splits --flag=value on the first = so values may contain =', () => {
|
|
const parsed = parseArgs(['set', 'cookie', '--value=a=b=c'])
|
|
|
|
expect(parsed.flags.get('value')).toBe('a=b=c')
|
|
})
|
|
|
|
it('treats --flag= as an empty string value', () => {
|
|
const parsed = parseArgs(['--value='])
|
|
|
|
expect(parsed.flags.get('value')).toBe('')
|
|
})
|
|
|
|
it('still parses boolean flags and space-separated values', () => {
|
|
const parsed = parseArgs(['tab', 'create', '--json', '--url', 'https://example.com'])
|
|
|
|
expect(parsed.commandPath).toEqual(['tab', 'create'])
|
|
expect(parsed.flags.get('json')).toBe(true)
|
|
expect(parsed.flags.get('url')).toBe('https://example.com')
|
|
})
|
|
|
|
it('does not consume a command token after an unknown flag', () => {
|
|
const parsed = parseArgs(['--jso', 'worktree', 'list'], [['worktree', 'list']])
|
|
|
|
expect(parsed.commandPath).toEqual(['worktree', 'list'])
|
|
expect(parsed.flags.get('jso')).toBe(true)
|
|
})
|
|
|
|
it('does not consume a command token after a flag valid on another command', () => {
|
|
const parsed = parseArgs(['--workspace', 'worktree', 'list'], [['worktree', 'list']])
|
|
|
|
expect(parsed.commandPath).toEqual(['worktree', 'list'])
|
|
expect(parsed.flags.get('workspace')).toBe(true)
|
|
})
|
|
|
|
it('recognizes a command path with a boolean flag between its segments', () => {
|
|
const parsed = parseArgs(['--jso', 'worktree', '--json', 'list'], [['worktree', 'list']])
|
|
|
|
expect(parsed.commandPath).toEqual(['worktree', 'list'])
|
|
expect(parsed.flags.get('jso')).toBe(true)
|
|
expect(parsed.flags.get('json')).toBe(true)
|
|
})
|
|
|
|
it('recognizes a command path with a value flag between its segments', () => {
|
|
const parsed = parseArgs(
|
|
['--jso', 'worktree', '--repo', 'id:abc', 'list'],
|
|
[['worktree', 'list']]
|
|
)
|
|
|
|
expect(parsed.commandPath).toEqual(['worktree', 'list'])
|
|
expect(parsed.flags.get('jso')).toBe(true)
|
|
expect(parsed.flags.get('repo')).toBe('id:abc')
|
|
})
|
|
|
|
it('still consumes a pre-command flag value before a later command path', () => {
|
|
const parsed = parseArgs(['--workspace', 'team-1', 'linear', 'list'], [['linear', 'list']])
|
|
|
|
expect(parsed.commandPath).toEqual(['linear', 'list'])
|
|
expect(parsed.flags.get('workspace')).toBe('team-1')
|
|
})
|
|
|
|
it('preserves existing pre-command selector values', () => {
|
|
const parsed = parseArgs(['--repo', 'id:abc', 'worktree', 'list'], [['worktree', 'list']])
|
|
|
|
expect(parsed.commandPath).toEqual(['worktree', 'list'])
|
|
expect(parsed.flags.get('repo')).toBe('id:abc')
|
|
})
|
|
|
|
it('preserves a selector value that is also a registered command', () => {
|
|
const parsed = parseArgs(
|
|
['--environment', 'status', 'worktree', 'list'],
|
|
[['status'], ['worktree', 'list']]
|
|
)
|
|
|
|
expect(parsed.commandPath).toEqual(['worktree', 'list'])
|
|
expect(parsed.flags.get('environment')).toBe('status')
|
|
})
|
|
|
|
it('preserves a selector value that is also a command group', () => {
|
|
const parsed = parseArgs(
|
|
['--environment', 'worktree', 'status'],
|
|
[['status'], ['worktree', 'list']]
|
|
)
|
|
|
|
expect(parsed.commandPath).toEqual(['status'])
|
|
expect(parsed.flags.get('environment')).toBe('worktree')
|
|
})
|
|
|
|
it('parses emulator reinstall as a boolean flag', () => {
|
|
const parsed = parseArgs(['emulator', 'install', 'app.apk', '--reinstall', '--device', 'emu'])
|
|
|
|
expect(parsed.commandPath).toEqual(['emulator', 'install', 'app.apk'])
|
|
expect(parsed.flags.get('reinstall')).toBe(true)
|
|
expect(parsed.flags.get('device')).toBe('emu')
|
|
})
|
|
|
|
it('normalizes partial positionals without conflicting later flag-supplied args', () => {
|
|
const parsed = normalizeCommandPositionals(
|
|
[
|
|
{
|
|
path: ['emulator', 'permissions'],
|
|
summary: 'Permissions',
|
|
usage: 'orca emulator permissions <op> <package> [permission]',
|
|
allowedFlags: ['op', 'package', 'permission'],
|
|
positionalArgs: ['op', 'package', 'permission']
|
|
}
|
|
],
|
|
parseArgs([
|
|
'emulator',
|
|
'permissions',
|
|
'grant',
|
|
'--package',
|
|
'com.example.app',
|
|
'--permission',
|
|
'android.permission.CAMERA'
|
|
])
|
|
)
|
|
|
|
expect(parsed.commandPath).toEqual(['emulator', 'permissions'])
|
|
expect(parsed.flags.get('op')).toBe('grant')
|
|
expect(parsed.flags.get('package')).toBe('com.example.app')
|
|
expect(parsed.flags.get('permission')).toBe('android.permission.CAMERA')
|
|
expect(parsed.positionalFlagConflicts).toEqual([])
|
|
})
|
|
|
|
it('preserves repeated string flags', () => {
|
|
const parsed = parseArgs(['linear', 'label', 'add', '--label', 'Bug', '--label=Regression'])
|
|
|
|
expect(parsed.flags.get('label')).toBe(`Bug${REPEATED_FLAG_SEPARATOR}Regression`)
|
|
})
|
|
|
|
it('does not apply repeated flag encoding to ordinary string flags', () => {
|
|
const parsed = parseArgs(['linear', 'list', '--workspace', 'old', '--workspace', 'new'])
|
|
|
|
expect(parsed.flags.get('workspace')).toBe('new')
|
|
})
|
|
})
|
|
|
|
describe('command aliases', () => {
|
|
const specs: CommandSpec[] = [
|
|
{
|
|
path: ['worktree', 'rm'],
|
|
aliases: [
|
|
['worktree', 'remove'],
|
|
['worktree', 'delete']
|
|
],
|
|
summary: 'Remove a worktree',
|
|
usage: 'orca worktree rm --worktree <selector>',
|
|
allowedFlags: ['worktree', 'force']
|
|
},
|
|
{
|
|
path: ['repo', 'show'],
|
|
summary: 'Show a repo',
|
|
usage: 'orca repo show --repo <selector>',
|
|
allowedFlags: ['repo'],
|
|
positionalArgs: ['repo']
|
|
}
|
|
]
|
|
|
|
it('resolves an exact canonical path to its spec', () => {
|
|
expect(findCommandSpec(specs, ['worktree', 'rm'])?.path).toEqual(['worktree', 'rm'])
|
|
})
|
|
|
|
it('resolves an aliased path to the canonical spec', () => {
|
|
expect(findCommandSpec(specs, ['worktree', 'remove'])?.path).toEqual(['worktree', 'rm'])
|
|
})
|
|
|
|
it('resolves each declared alias to the canonical spec', () => {
|
|
expect(findCommandSpec(specs, ['worktree', 'delete'])?.path).toEqual(['worktree', 'rm'])
|
|
})
|
|
|
|
it('returns undefined for a path matching neither canonical nor alias', () => {
|
|
expect(findCommandSpec(specs, ['worktree', 'destroy'])).toBeUndefined()
|
|
})
|
|
|
|
it('canonicalizes an aliased command path during normalization', () => {
|
|
const normalized = normalizeCommandPositionals(
|
|
specs,
|
|
parseArgs(['worktree', 'remove', '--force'])
|
|
)
|
|
|
|
expect(normalized.commandPath).toEqual(['worktree', 'rm'])
|
|
expect(normalized.flags.get('force')).toBe(true)
|
|
})
|
|
|
|
it('leaves a canonical command path unchanged during normalization', () => {
|
|
const normalized = normalizeCommandPositionals(specs, parseArgs(['worktree', 'rm']))
|
|
|
|
expect(normalized.commandPath).toEqual(['worktree', 'rm'])
|
|
})
|
|
|
|
it('binds a trailing positional after canonicalizing the base path', () => {
|
|
const normalized = normalizeCommandPositionals(specs, parseArgs(['repo', 'show', 'id:abc']))
|
|
|
|
expect(normalized.commandPath).toEqual(['repo', 'show'])
|
|
expect(normalized.flags.get('repo')).toBe('id:abc')
|
|
})
|
|
})
|
|
|
|
describe('supportsBrowserPageFlag', () => {
|
|
it('does not expose browser page targeting on orchestration commands', () => {
|
|
expect(supportsBrowserPageFlag(['orchestration', 'send'])).toBe(false)
|
|
})
|
|
|
|
it('does not expose browser page targeting on local agent discovery', () => {
|
|
expect(supportsBrowserPageFlag(['agent-context'])).toBe(false)
|
|
})
|
|
|
|
it('does not expose browser page targeting on host-local account commands', () => {
|
|
expect(supportsBrowserPageFlag(['account', 'add'])).toBe(false)
|
|
expect(supportsBrowserPageFlag(['account', 'list'])).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('validateCommandAndFlags', () => {
|
|
const specs = [
|
|
{
|
|
path: ['demo'],
|
|
summary: 'Demo command',
|
|
usage: 'orca demo',
|
|
allowedFlags: []
|
|
}
|
|
]
|
|
|
|
it('allows global runtime selector flags even when the command spec omits them', () => {
|
|
const parsed = parseArgs([
|
|
'demo',
|
|
'--pairing-code',
|
|
'remote-runtime',
|
|
'--environment',
|
|
'server',
|
|
'--json'
|
|
])
|
|
|
|
expect(() => validateCommandAndFlags(specs, parsed)).not.toThrow()
|
|
})
|
|
|
|
it.each(['environment', 'pairing-code'])('rejects --%s without a value', (flag) => {
|
|
const parsed = parseArgs([`--${flag}`, 'demo'], [['demo']])
|
|
|
|
expect(() => validateCommandAndFlags(specs, parsed)).toThrow(`Flag --${flag} requires a value.`)
|
|
})
|
|
|
|
it.each(['environment', 'pairing-code'])('rejects an empty --%s= value', (flag) => {
|
|
const parsed = parseArgs(['demo', `--${flag}=`])
|
|
|
|
expect(() => validateCommandAndFlags(specs, parsed)).toThrow(`Flag --${flag} requires a value.`)
|
|
})
|
|
|
|
it('still rejects unknown command-specific flags', () => {
|
|
const parsed = parseArgs(['demo', '--bogus'])
|
|
|
|
expect(() => validateCommandAndFlags(specs, parsed)).toThrow(
|
|
'Unknown flag --bogus for command: demo'
|
|
)
|
|
})
|
|
|
|
it('enumerates valid flags and suggests a near-miss on unknown-flag errors', () => {
|
|
const flagSpecs: CommandSpec[] = [
|
|
{
|
|
path: ['worktree', 'rm'],
|
|
summary: 'Remove a worktree',
|
|
usage: 'orca worktree rm',
|
|
allowedFlags: ['worktree', 'force', 'run-hooks']
|
|
}
|
|
]
|
|
const parsed = parseArgs(['worktree', 'rm', '--forcce'])
|
|
|
|
try {
|
|
validateCommandAndFlags(flagSpecs, parsed)
|
|
throw new Error('expected validateCommandAndFlags to throw')
|
|
} catch (error) {
|
|
const data = (error as { data?: { validFlags: string[]; nextSteps: string[] } }).data
|
|
expect(data?.validFlags).toContain('force')
|
|
expect(data?.validFlags).toContain('json')
|
|
expect(data?.nextSteps.join('\n')).toContain('--force')
|
|
expect(data?.nextSteps.join('\n')).toContain('Valid flags:')
|
|
}
|
|
})
|
|
|
|
it('attaches did-you-mean suggestions to unknown-command errors', () => {
|
|
const suggestSpecs: CommandSpec[] = [
|
|
{
|
|
path: ['worktree', 'rm'],
|
|
summary: 'Remove a worktree',
|
|
usage: 'orca worktree rm',
|
|
allowedFlags: []
|
|
}
|
|
]
|
|
const parsed = parseArgs(['worktree', 'remov'])
|
|
|
|
try {
|
|
validateCommandAndFlags(suggestSpecs, parsed)
|
|
throw new Error('expected validateCommandAndFlags to throw')
|
|
} catch (error) {
|
|
const data = (error as { data?: { suggestions: string[]; nextSteps: string[] } }).data
|
|
expect(data?.suggestions).toContain('worktree rm')
|
|
expect(data?.nextSteps[0]).toContain('orca worktree rm')
|
|
}
|
|
})
|
|
})
|