pipe/test/cli_options_test.dart
trvon 1f60197a15
feat(queries): inventory firestore query surfaces as typed records
Add `pipe queries`, which lists every Firestore query a project can issue:
scope, collection, path pattern, filters with operators and value types,
composites, orderBy, limit, cursors, realtime, aggregation, in-loop, plus a
confidence and the reasons it is not exact. Receivers are matched by static
type and cloud_firestore library URI, never by name.

Queries are followed through chains, local variables (flow-insensitive with
two cuts), fields, getters and one level of helper methods with arguments
bound. An --adapter-config (YAML or JSON) maps declarative query APIs such as
a server-side gateway onto the same schema.

Inventory only: no rules, findings or baseline changes. Every emitted name is
validated against a safe character set so no source text reaches the output.
2026-09-23 18:03:09 -06:00

202 lines
6.2 KiB
Dart

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 = <String>[
'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<PipeException>()),
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<PipeException>()),
);
});
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<PipeException>()),
);
await expectLater(
runner.run([
'suggest',
'test/fixtures/stuck_loading',
'--format',
'text',
'--max-actions',
'abc',
'-c',
'1',
]),
throwsA(isA<PipeException>()),
);
},
);
});
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<PipeException>().having(
(error) => error.toString(),
'message',
contains('cannot write baseline'),
),
),
);
});
}