8fa0cfa271
docs/AUDIT.md and docs/PLAN.md record the review and the ten phases it turns into. This commit is phase 1. keyman.cli.ts fell through to an interactive session for --help, ignored unknown flags, and called keyman() unawaited — so Ctrl-C at any prompt, and any rejection inside the menu loop, became an unhandled-rejection stack trace. flagValue() also read `--channel --force` as the channel "--force", which reached the dist-tag lookup as a key that cannot exist and reported an unreachable registry. New keyman.args.ts owns the parse: both --flag value and --flag=value, a UsageError for an unknown flag or command, --channel validated against the three real channels, and self-update-only flags rejected rather than silently ignored. It is a separate module because cli.ts is excluded from coverage and these are rules, not wiring. --help short-circuits before tokenising, so it answers a line the parser would otherwise reject. Usage errors exit 2; ExitPromptError is caught by name (@inquirer/core is transitive here and does not resolve) and prints Goodbye. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
156 lines
5.4 KiB
TypeScript
156 lines
5.4 KiB
TypeScript
/**
|
|
* Tests for keyman's argv parsing.
|
|
*
|
|
* The old inline reader in keyman.cli.ts turned three different mistakes into
|
|
* silence or into a wrong diagnosis, so the interesting cases here are the
|
|
* rejections rather than the happy paths.
|
|
*/
|
|
|
|
import { describe, expect, it } from 'vitest';
|
|
import { CHANNELS, helpText, KNOWN_FLAGS, parseArgs, UsageError } from '../src/keyman.args.js';
|
|
|
|
describe('parseArgs', () => {
|
|
it('defaults to the interactive session', () => {
|
|
expect(parseArgs([])).toEqual({ command: 'interactive' });
|
|
});
|
|
|
|
it.each([
|
|
[['--help'], 'help'],
|
|
[['-h'], 'help'],
|
|
[['--version'], 'version'],
|
|
[['-V'], 'version'],
|
|
[['--print-config'], 'print-config'],
|
|
] as const)('%s selects %s', (argv, command) => {
|
|
expect(parseArgs([...argv])).toEqual({ command });
|
|
});
|
|
|
|
it('answers --help even when the rest of the line is wrong', () => {
|
|
expect(parseArgs(['--bogus', '--help'])).toEqual({ command: 'help' });
|
|
expect(parseArgs(['--help', '--channel'])).toEqual({ command: 'help' });
|
|
});
|
|
|
|
describe('self-update', () => {
|
|
it.each(['self-update', 'upgrade'])('is selected by the %s subcommand', (subcommand) => {
|
|
expect(parseArgs([subcommand])).toEqual({
|
|
command: 'self-update',
|
|
dryRun: false,
|
|
force: false,
|
|
channel: undefined,
|
|
registry: undefined,
|
|
});
|
|
});
|
|
|
|
it('is selected by --self-update', () => {
|
|
expect(parseArgs(['--self-update'])).toMatchObject({ command: 'self-update' });
|
|
});
|
|
|
|
it('collects its flags, long and short', () => {
|
|
expect(parseArgs(['self-update', '--dry-run', '--force'])).toMatchObject({
|
|
dryRun: true,
|
|
force: true,
|
|
});
|
|
expect(parseArgs(['self-update', '-n', '-f'])).toMatchObject({
|
|
dryRun: true,
|
|
force: true,
|
|
});
|
|
});
|
|
|
|
it.each(['--channel main', '--channel=main'])('accepts %s', (form) => {
|
|
expect(parseArgs(['self-update', ...form.split(' ')])).toMatchObject({ channel: 'main' });
|
|
});
|
|
|
|
it('accepts every real channel', () => {
|
|
for (const channel of CHANNELS) {
|
|
expect(parseArgs(['self-update', '--channel', channel])).toMatchObject({ channel });
|
|
}
|
|
});
|
|
|
|
it('reads a registry in either form', () => {
|
|
expect(parseArgs(['self-update', '--registry', 'https://r.example'])).toMatchObject({
|
|
registry: 'https://r.example',
|
|
});
|
|
expect(parseArgs(['self-update', '--registry=https://r.example'])).toMatchObject({
|
|
registry: 'https://r.example',
|
|
});
|
|
});
|
|
|
|
it('keeps a value that starts with a dash when it was written inline', () => {
|
|
expect(parseArgs(['self-update', '--registry=-weird'])).toMatchObject({
|
|
registry: '-weird',
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('rejections', () => {
|
|
const reject = (argv: string[]) => () => parseArgs(argv);
|
|
|
|
it('rejects a channel that is not a channel', () => {
|
|
expect(reject(['self-update', '--channel', 'stable'])).toThrow(UsageError);
|
|
expect(reject(['self-update', '--channel', 'stable'])).toThrow(
|
|
'Unknown channel: stable (expected latest, next, main)'
|
|
);
|
|
});
|
|
|
|
it('rejects the next flag being eaten as a value', () => {
|
|
// The bug this whole module exists for: --channel --force used to set the
|
|
// channel to "--force" and report an unreachable registry.
|
|
expect(reject(['self-update', '--channel', '--force'])).toThrow('--channel expects a value');
|
|
});
|
|
|
|
it('rejects a value flag with nothing after it', () => {
|
|
expect(reject(['self-update', '--channel'])).toThrow('--channel expects a value');
|
|
expect(reject(['self-update', '--registry='])).toThrow('--registry expects a value');
|
|
});
|
|
|
|
it('rejects a boolean flag given a value', () => {
|
|
expect(reject(['--dry-run=yes'])).toThrow('--dry-run does not take a value');
|
|
});
|
|
|
|
it('rejects unknown flags and commands', () => {
|
|
expect(reject(['--vault', 'foo'])).toThrow('Unknown flag: --vault');
|
|
expect(reject(['-x'])).toThrow('Unknown flag: -x');
|
|
expect(reject(['encrypt'])).toThrow('Unknown command: encrypt');
|
|
expect(reject(['self-update', 'upgrade'])).toThrow('Unexpected argument: upgrade');
|
|
});
|
|
|
|
it.each(['--channel', '--registry', '--dry-run', '-n', '--force', '-f'])(
|
|
'rejects %s without self-update rather than ignoring it',
|
|
(flag) => {
|
|
const argv = flag === '--channel' || flag === '--registry' ? [flag, 'main'] : [flag];
|
|
expect(reject(argv)).toThrow('is only valid with `keyman self-update`');
|
|
}
|
|
);
|
|
|
|
it('rejects a self-update flag alongside another command', () => {
|
|
expect(reject(['--print-config', '--force'])).toThrow('--force is only valid');
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('helpText', () => {
|
|
it('documents every flag the parser accepts', () => {
|
|
const text = helpText();
|
|
for (const flag of KNOWN_FLAGS) {
|
|
expect(text, `${flag} is missing from --help`).toContain(flag);
|
|
}
|
|
});
|
|
|
|
it('names both subcommands, every channel, and the environment variables', () => {
|
|
const text = helpText();
|
|
expect(text).toContain('self-update');
|
|
expect(text).toContain('upgrade');
|
|
for (const channel of CHANNELS) {
|
|
expect(text).toContain(channel);
|
|
}
|
|
for (const variable of [
|
|
'VAULT_ROOT',
|
|
'KEYMAN_REGISTRY',
|
|
'KEYMAN_REGISTRY_TOKEN',
|
|
'KEYMAN_NO_UPDATE_CHECK',
|
|
'KEYMAN_PACKAGE_MANAGER',
|
|
]) {
|
|
expect(text).toContain(variable);
|
|
}
|
|
});
|
|
});
|