import 'dart:io'; import 'package:args/args.dart'; import 'package:pipe/pipe.dart'; import 'package:pipe/src/cli.dart'; import 'package:test/test.dart'; /// Options every project-targeting command reads out of its own ArgResults. /// /// A command whose body reads an option it never registered does not fail with a /// usage message - it dies on an unhandled `ArgResults` exception, exit 255. /// `pipe graph` was exactly that: it read `--dir`, `--sdk`, `--concurrency`, /// `--include-generated`, `--limit` and `--format`, but its class had no /// constructor, so it registered nothing but `--help` and every invocation /// crashed. This test is the guard for that whole class of mistake. const _commonOptions = [ 'concurrency', 'dir', 'format', 'include-generated', 'limit', 'sdk', ]; void main() { test('every project command registers the common options it reads', () { final runner = PipeRunner(); for (final entry in runner.commands.entries) { final name = entry.key; final options = entry.value.argParser.options.keys; // The built-in help command takes no project. if (name == 'help') continue; // `recall` deliberately targets a corpus root plus its own manifest // rather than a project path, and registers its own equivalents. if (name == 'recall') { for (final option in [ 'concurrency', 'format', 'manifest', 'root', 'sdk', ]) { expect( options, contains(option), reason: 'recall must register --$option', ); } continue; } for (final option in _commonOptions) { expect( options, contains(option), reason: '$name reads --$option, so it must register it', ); } } }); test('queries registers its own options on top of the common ones', () { final queries = PipeRunner().commands['queries']!; final options = queries.argParser.options; expect(options.keys, containsAll(['adapter-config', 'require-firestore'])); expect(options['format']!.allowed, ['json', 'md']); expect(options['format']!.defaultsTo, 'json'); }); test('the fixture project is a valid target for every command parser', () { final runner = PipeRunner(); for (final entry in runner.commands.entries) { if (entry.key == 'help') continue; expect( () => entry.value.argParser.parse(['.', '--format', 'json']), returnsNormally, reason: '${entry.key} rejected a plain JSON invocation', ); } }); test('graph runs end to end, not just through its parser', () async { // Registration alone is not enough: this asserts the command completes. // Before the fix GraphCommand threw // `ArgumentError: Could not find an option named "--dir"` at run time, so // a parser-only test plus this one are the pair that pins it down. final runner = PipeRunner(); final code = await runner.run([ 'graph', 'test/fixtures/stuck_loading', '--format', 'text', '-c', '1', ]); expect(code, 0); }); group('numeric options', () { ArgResults parse(String name, String value) { final parser = ArgParser()..addOption(name); return parser.parse(['--$name', value]); } test('reject anything that is not a positive integer', () { // These used to be read as `int.tryParse(...) ?? default`, so `--top 2o` // silently produced the default number of rows. A user typo must not be // answered with a different question's answer. for (final bad in ['abc', '2o', '0', '-3', '', '1.5']) { expect( () => readPositiveInt(parse('top', bad), 'top', fallback: 25), throwsA(isA()), reason: '--top "$bad" must be rejected, not silently defaulted', ); } }); test('accept a positive integer, and fall back only when absent', () { expect(readPositiveInt(parse('top', '7'), 'top', fallback: 25), 7); final absent = (ArgParser()..addOption('top')).parse([]); expect(readPositiveInt(absent, 'top', fallback: 25), 25); }); test('a real command rejects a bad value instead of defaulting', () async { final runner = PipeRunner(); await expectLater( runner.run([ 'check', 'test/fixtures/stuck_loading', '--top', 'abc', '-c', '1', ]), throwsA(isA()), ); }); test( 'a numeric option is rejected on every format, not only on the branch that reads it', () async { // `suggest` used to read each numeric option inside the one output branch // that uses it - --top in the text branch, --max-actions in the agent // branch - so a malformed value was silently ignored on the other paths. final runner = PipeRunner(); await expectLater( runner.run([ 'suggest', 'test/fixtures/stuck_loading', '--format', 'agent', '--top', 'abc', '-c', '1', ]), throwsA(isA()), ); await expectLater( runner.run([ 'suggest', 'test/fixtures/stuck_loading', '--format', 'text', '--max-actions', 'abc', '-c', '1', ]), throwsA(isA()), ); }, ); }); test('an unwritable baseline path is a clean error, not a crash', () async { // writeAsStringSync threw an uncaught FileSystemException: exit 255 and a // stack trace. It is now a PipeException, which exits 2 with one line. final missing = Directory.systemTemp.createTempSync('pipe-nowrite'); await expectLater( PipeRunner().run([ 'check', 'test/fixtures/stuck_loading', '--write-baseline', '${missing.path}/no/such/dir/baseline.json', '-c', '1', ]), throwsA( isA().having( (error) => error.toString(), 'message', contains('cannot write baseline'), ), ), ); }); }