pipe/test/project_scan_test.dart
trvon ae412a7964
fix(cli): reject a missing --dir and report filesystem errors cleanly
With several --dir values, one that did not exist was skipped silently: the
no-files guard only fires when every directory is empty, so
`--dir lib --dir tset` scanned lib and reported as though it had covered what
was asked for. A --dir that does not exist is now an error naming it.

A directory that could not be listed, or a baseline that could not be read or
written, escaped as an uncaught FileSystemException: exit 255 and a stack
trace. Those paths now raise a PipeException with the OS reason (exit 2), and
bin/pipe.dart catches any remaining FileSystemException the same way. The exit
codes are documented in bin/pipe.dart: 0 success, 1 a gate failed, 2 pipe could
not do what was asked, 64 usage.

rule_scope_claim_test widened the fixture with a test/ directory it does not
have, relying on exactly the silent skip; it now builds a project that has one.
2026-09-22 12:05:12 -06:00

297 lines
10 KiB
Dart

import 'dart:io';
import 'package:pipe/pipe.dart';
import 'package:pipe/src/cli.dart';
import 'package:pipe/src/index.dart';
import 'package:test/test.dart';
/// A directory that is a valid project (it has a package config) but has no Dart
/// files to analyse - an empty `lib/`, or a `--dir` that does not exist.
String emptyProject(String label) {
final temp = Directory.systemTemp.createTempSync('pipe-$label');
final dartTool = Directory('${temp.path}/.dart_tool')
..createSync(recursive: true);
File('${dartTool.path}/package_config.json').writeAsStringSync(
File(
'test/fixtures/stuck_loading/.dart_tool/package_config.json',
).readAsStringSync(),
);
Directory('${temp.path}/lib').createSync();
return temp.path;
}
void main() {
test('a scan that finds no Dart files fails instead of passing quietly', () {
// Before this, `pipe check . --dir libs` (a typo) or a project with an empty
// lib/ analysed zero files and exited 0, so a CI gate "passed" having
// checked nothing at all. Reporting success for an empty scan is the same
// class of defect as a test that cannot fail.
expect(
() => loadProject(emptyProject('empty-lib')),
throwsA(
isA<PipeException>().having(
(error) => error.toString(),
'message',
allOf(contains('no Dart files found'), contains('--dir')),
),
),
);
});
test(
'a nonexistent --dir fails rather than being skipped silently',
() async {
final projectPath = emptyProject('missing-dir');
await expectLater(
loadProject(projectPath, directories: const ['missing']),
throwsA(isA<PipeException>()),
);
},
);
test('one mistyped --dir among several is an error, not skipped', () async {
// With a single --dir the no-files guard caught a typo, but with several a
// missing one was skipped silently and the scan reported on the others as
// though it covered what was asked for.
await expectLater(
loadProject(
'test/fixtures/stuck_loading',
directories: const ['lib', 'tset'],
),
throwsA(
isA<PipeException>().having(
(error) => error.toString(),
'message',
contains('--dir tset does not exist'),
),
),
);
});
test('a real project still loads', () async {
final project = await loadProject('test/fixtures/stuck_loading');
expect(project.dartFiles, isNotEmpty);
});
group('index text accounting', () {
IndexReport report({required int discovered, required int selected}) =>
IndexReport(
root: '/project',
sdkPath: null,
sdkSource: 'test',
isFlutter: false,
contextCount: 1,
filesDiscovered: discovered,
filesSelected: selected,
filesResolved: selected,
filesSkipped: 0,
filesFailed: 0,
generatedFiles: 0,
diagnosticsBySeverity: const {},
diagnosticsByCode: const {},
unresolvedUris: 0,
samples: const [],
failures: const [],
wall: Duration.zero,
perFileMicros: const [],
concurrency: 1,
);
test('explains the gap when files were filtered out', () {
// `--no-include-generated` used to print "13 discovered, 1 generated,
// 0 skipped, 0 failed" beside "12/12 resolved", leaving the 13-vs-12 gap
// looking like a file had gone missing.
final text = report(discovered: 13, selected: 12).describe();
expect(text, contains('13 discovered'));
expect(text, contains('12 selected'));
expect(text, contains('12/12 resolved'));
});
test('stays quiet when nothing was filtered', () {
final text = report(discovered: 12, selected: 12).describe();
expect(text, contains('12 discovered'));
expect(text, isNot(contains('12 selected')));
});
});
group('limited runs are never silent', () {
test(
'produce a warning naming the partial coverage, but only when limited',
() {
// A truncated analysis used to look exactly like a complete one, and
// `verify --limit 2` read the unscanned findings as RESOLVED.
expect(
limitedRunWarning(limit: null, analysed: 12, discovered: 12),
isNull,
);
final warning = limitedRunWarning(
limit: 2,
analysed: 2,
discovered: 12,
);
expect(warning, isNotNull);
expect(warning, contains('--limit 2'));
expect(warning, contains('2 of 12'));
expect(warning, contains('PARTIAL'));
},
);
test('files excluded by analysis_options make a scan partial too', () {
// A skipped file was never examined, exactly like a failed one; naming
// only failures let a scan with excluded files read as complete.
final warning = partialScanWarning(failed: 0, resolved: 11, skipped: 2);
expect(warning, isNotNull);
expect(warning, contains('2 file(s) were not analysed'));
expect(warning, contains('PARTIAL'));
});
test('verify refuses a partial scan instead of claiming fixes', () async {
final runner = PipeRunner();
await expectLater(
runner.run([
'verify',
'test/fixtures/stuck_loading',
'--baseline',
'/tmp/sw2/bl1.json',
'--limit',
'2',
'-c',
'1',
]),
throwsA(
isA<PipeException>().having(
(error) => error.toString(),
'message',
contains('complete scan'),
),
),
);
});
});
group('nothing to analyse is never a pass', () {
test('every file filtered out by --no-include-generated is an error', () {
// loadProject rejects a project with no Dart files at all; this is the
// other half - files WERE discovered but the generated filter removed them
// all, so the old code analysed nothing and exited 0.
final temp = Directory.systemTemp.createTempSync('pipe-onlygen');
final dartTool = Directory('${temp.path}/.dart_tool')
..createSync(recursive: true);
File('${dartTool.path}/package_config.json').writeAsStringSync(
File(
'test/fixtures/stuck_loading/.dart_tool/package_config.json',
).readAsStringSync(),
);
final lib = Directory('${temp.path}/lib')..createSync();
File(
'${lib.path}/thing.g.dart',
).writeAsStringSync('class Generated {}\n');
expect(
() => resolveProject(
Project(
root: temp.path,
packageConfigPath: '',
packageConfig: const {},
flutterRoot: null,
dartFiles: ['${lib.path}/thing.g.dart'],
),
includeGenerated: false,
),
throwsA(
isA<PipeException>().having(
(error) => error.toString(),
'message',
contains('nothing to analyse'),
),
),
);
});
});
group('unreadable files are not a clean result', () {
test(
'partialScanWarning reports partial coverage, or nothing when clean',
() {
expect(partialScanWarning(failed: 0, resolved: 12), isNull);
final warning = partialScanWarning(failed: 1, resolved: 11);
expect(warning, isNotNull);
expect(warning, contains('1 file(s) could not be analysed'));
expect(warning, contains('11 resolved'));
expect(warning, contains('PARTIAL'));
},
);
test('files excluded by analysis_options make a scan partial too', () {
// A skipped file was never examined, exactly like a failed one; naming
// only failures let a scan with excluded files read as complete.
final warning = partialScanWarning(failed: 0, resolved: 11, skipped: 2);
expect(warning, isNotNull);
expect(warning, contains('2 file(s) were not analysed'));
expect(warning, contains('PARTIAL'));
});
test('a run where every file fails to resolve is an error', () async {
// An unreadable file comes back from the analyzer as a unit with EMPTY
// content rather than an error, so it used to look like a successful
// resolution: a project of 12 unreadable files reported "12/12 resolved"
// and `check` said 0 findings, indistinguishable from a clean project.
final temp = Directory.systemTemp.createTempSync('pipe-unreadable');
Directory('${temp.path}/lib').createSync();
await expectLater(
resolveProject(
Project(
root: temp.path,
packageConfigPath: '',
packageConfig: const {},
flutterRoot: null,
// A path that cannot be read: the same shape as an unreadable file
// or one deleted between discovery and resolution.
dartFiles: ['${temp.path}/lib/gone.dart'],
),
concurrency: 1,
),
throwsA(
isA<PipeException>().having(
(error) => error.toString(),
'message',
contains('no file could be analysed'),
),
),
);
});
});
test(
'a run whose every file sits outside any context is an error too',
() async {
// The guard used to fire only when files FAILED, so a run where every file
// was SKIPPED (outside every analysis context) could still report success
// having analysed nothing. The test is now simply "nothing was resolved".
final temp = Directory.systemTemp.createTempSync('pipe-outside-context');
await expectLater(
resolveProject(
Project(
root: temp.path,
packageConfigPath: '',
packageConfig: const {},
flutterRoot: null,
// Outside the project root, so no analysis context covers it: the
// analyzer reports this as skipped, not failed.
dartFiles: const ['/etc/hosts'],
),
concurrency: 1,
),
throwsA(
isA<PipeException>().having(
(error) => error.toString(),
'message',
contains('no file could be analysed'),
),
),
);
},
);
}