blob: 817b4fa30dc6c2bd4fb25508b40b650943ff0626 [file] [edit]
// Copyright (c) 2026, the Dart project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
@Timeout.factor(3)
library;
import 'dart:convert';
import 'dart:io';
import 'package:api_summary/api_summary.dart';
import 'package:path/path.dart' as p;
import 'package:test/test.dart';
import 'package:test_descriptor/test_descriptor.dart' as d;
void main() {
late Directory tempKernelDir;
late String compiledCliDill;
setUpAll(() async {
final packageDir = _pkgDir();
Directory.current = packageDir;
tempKernelDir = Directory.systemTemp.createTempSync(
'api_summary_cli_test_',
);
compiledCliDill = p.join(tempKernelDir.path, 'api_summary.dill');
final compileRes = await Process.run(Platform.resolvedExecutable, [
'compile',
'kernel',
if (Platform.packageConfig != null)
'--packages=${Uri.parse(Platform.packageConfig!).toFilePath()}',
p.join(packageDir, 'bin', 'api_summary.dart'),
'-o',
compiledCliDill,
], workingDirectory: packageDir);
if (compileRes.exitCode != 0) {
throw StateError(
'Failed to compile bin/api_summary.dart to kernel:\n'
'${compileRes.stderr}',
);
}
});
tearDownAll(() {
if (tempKernelDir.existsSync()) {
tempKernelDir.deleteSync(recursive: true);
}
});
Future<ProcessResult> runCli(List<String> args) {
final packageDir = _pkgDir();
return Process.run(Platform.resolvedExecutable, [
if (Platform.packageConfig != null)
'--packages=${Platform.packageConfig}',
compiledCliDill,
...args,
], workingDirectory: packageDir);
}
test('text output matches api.txt', expectApiSummaryClean);
test(
'json output matches api.json',
() => expectApiSummaryClean(format: ApiSummaryFormat.json),
);
test(
'yaml output matches api.yaml',
() => expectApiSummaryClean(format: ApiSummaryFormat.yaml),
);
test('rehydrated json renders identical text summary', () async {
final apiPackage = await apiSummary(_pkgDir());
final jsonSummary = ApiSummaryFormat.json.format(apiPackage);
final textSummary = ApiSummaryFormat.text.format(apiPackage);
final parsed = jsonDecode(jsonSummary) as Map<String, dynamic>;
final rehydrated = ApiSummary.fromJson(parsed);
expect(rehydrated.toString(), equals(textSummary));
});
test('expectApiSummaryClean throws when golden file is missing', () async {
final samplePkgDir = await _createSandboxPackage();
final missingPath = p.join(samplePkgDir, 'missing_api.txt');
await expectLater(
expectApiSummaryClean(
packagePath: samplePkgDir,
goldenFilePath: missingPath,
),
throwsA(
isA<ApiSummaryVerificationException>()
.having((e) => e.goldenFilePath, 'goldenFilePath', missingPath)
.having((e) => e.expected, 'expected', isNull)
.having(
(e) => e.toString(),
'toString',
allOf(
contains('does not exist'),
contains('dart run api_summary --output=missing_api.txt'),
),
),
),
);
});
test('expectApiSummaryClean throws with diff on mismatch', () async {
final samplePkgDir = await _createSandboxPackage();
File(
p.join(samplePkgDir, 'api.txt'),
).writeAsStringSync('environment:\n stale_line\n');
await expectLater(
expectApiSummaryClean(packagePath: samplePkgDir),
throwsA(
isA<ApiSummaryVerificationException>()
.having((e) => e.expected, 'expected', contains('stale_line'))
.having(
(e) => e.toString(),
'toString',
allOf(
contains('does not match the current public API'),
contains('@@ line 2 @@'),
contains('- stale_line'),
contains('dart run api_summary --write'),
),
),
),
);
});
test('CLI --write and --check round-trip in sandbox package', () async {
final samplePkgDir = await _createSandboxPackage();
// 1. --check fails with exit code 1 before api.txt exists
final checkMissing = await runCli(['-p', samplePkgDir, '--check']);
expect(checkMissing.exitCode, equals(1));
expect(checkMissing.stderr, contains('does not exist'));
expect(checkMissing.stderr, contains('dart run api_summary --write'));
// 2. --write creates api.txt
final writeRes = await runCli(['-p', samplePkgDir, '--write']);
expect(writeRes.exitCode, equals(0), reason: '${writeRes.stderr}');
final generatedFile = File(p.join(samplePkgDir, 'api.txt'));
expect(generatedFile.existsSync(), isTrue);
expect(
generatedFile.readAsStringSync(),
contains('add (function: int Function(int, int))'),
);
// 3. --check now succeeds with exit code 0
final checkClean = await runCli(['-p', samplePkgDir, '--check']);
expect(checkClean.exitCode, equals(0), reason: '${checkClean.stderr}');
// 4. Relative -o with -p resolves relative to packagePath for writing
final writeCustom = await runCli([
'-p',
samplePkgDir,
'-o',
'custom_api.txt',
]);
expect(writeCustom.exitCode, equals(0), reason: '${writeCustom.stderr}');
expect(File(p.join(samplePkgDir, 'custom_api.txt')).existsSync(), isTrue);
// 5. Mutually exclusive flag combinations exit with code 64
for (final (args, expectedMessage) in [
(
['-p', samplePkgDir, '--write', '--check'],
'Cannot specify both --write and --check.',
),
(
['-p', samplePkgDir, '--write', '-o', 'custom_api.txt'],
'Cannot specify both --write and --output.',
),
(
['-p', samplePkgDir, '--check', '-o', 'custom_api.txt'],
'Cannot specify both --check and --output.',
),
(
['some_positional_arg'],
'Unexpected positional arguments: "some_positional_arg".',
),
]) {
final res = await runCli(args);
expect(res.exitCode, equals(64), reason: 'Args: $args');
expect(res.stderr, contains(expectedMessage), reason: 'Args: $args');
}
});
test('throws ArgumentError on missing pubspec.yaml', () async {
await expectLater(
apiSummary('/non_existent_directory_12345'),
throwsArgumentError,
);
});
test('exits with code 64 on invalid arguments', () async {
final result = await runCli(['--invalid-option']);
expect(result.exitCode, equals(64));
expect(result.stderr, contains('Usage: api_summary'));
});
test('exits with code 64 on invalid pubspec.yaml', () async {
await d.file('pubspec.yaml', 'not_a_map').create();
final result = await runCli(['-p', d.sandbox]);
expect(result.exitCode, equals(64));
expect(result.stderr, contains('Failed to parse pubspec.yaml'));
});
test(
'exits with code 64 when no analysis context is found (missing lib/)',
() async {
await d.file('pubspec.yaml', 'name: foo\n').create();
final result = await runCli(['-p', d.sandbox]);
expect(result.exitCode, equals(64));
expect(result.stderr, contains('No "lib" directory found'));
},
);
}
Future<String> _createSandboxPackage() async {
await d.dir('pkg', [
d.file('pubspec.yaml', 'name: sample_pkg\nenvironment:\n sdk: ^3.12.0\n'),
d.dir('.dart_tool', [
d.file(
'package_config.json',
jsonEncode({
'configVersion': 2,
'packages': [
{
'name': 'sample_pkg',
'rootUri': '../',
'packageUri': 'lib/',
'languageVersion': '3.12',
},
],
}),
),
]),
d.dir('lib', [
d.file('sample_pkg.dart', 'int add(int a, int b) => a + b;\n'),
]),
]).create();
return p.join(d.sandbox, 'pkg');
}
// Dynamically locate the api_summary package root
String _pkgDir() {
var packageDir = p.normalize(p.absolute(Directory.current.path));
if (!_isApiSummaryDir(packageDir)) {
for (final dir in ['pkgs', 'pkg']) {
final candidate = p.join(packageDir, dir, 'api_summary');
if (_isApiSummaryDir(candidate)) {
packageDir = candidate;
break;
}
}
}
return packageDir;
}
bool _isApiSummaryDir(String dir) {
final pubspec = File(p.join(dir, 'pubspec.yaml'));
if (!pubspec.existsSync()) return false;
return pubspec.readAsStringSync().contains('name: api_summary');
}