blob: 8d2e88508815c13cb2b941f6759144469be62cfc [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.
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;
import 'package:yaml_edit/yaml_edit.dart';
void main() {
group('ApiSummary environment and executables', () {
test('sorts environment and executables keys alphabetically', () {
final summary = ApiSummary(
name: 'test_pkg',
environment: {
'sdk': '^3.12.0',
'flutter': '>=3.2.0 <3.9.0',
'fuchsia': '>=1.0.0',
},
executables: {'z_tool': null, 'a_tool': 'main_a', 'm_tool': 'm_tool'},
libraries: [],
);
expect(
summary.environment.keys.toList(),
equals(['flutter', 'fuchsia', 'sdk']),
);
expect(
summary.executables.keys.toList(),
equals(['a_tool', 'm_tool', 'z_tool']),
);
});
test(
'produces identical output regardless of input map insertion order',
() {
final summaryA = ApiSummary(
name: 'test_pkg',
environment: {'sdk': '^3.12.0', 'flutter': '>=3.2.0 <3.9.0'},
executables: {'z_tool': null, 'a_tool': 'main_a'},
libraries: [],
);
final summaryB = ApiSummary(
name: 'test_pkg',
environment: {'flutter': '>=3.2.0 <3.9.0', 'sdk': '^3.12.0'},
executables: {'a_tool': 'main_a', 'z_tool': null},
libraries: [],
);
expect(summaryA.toString(), equals(summaryB.toString()));
expect(summaryA.toJson(), equals(summaryB.toJson()));
},
);
test('toJson and fromJson round-trip with environment and executables', () {
final summary = ApiSummary(
name: 'test_pkg',
environment: {'sdk': '^3.12.0', 'flutter': '>=3.2.0 <3.9.0'},
executables: {'cli_a': null, 'cli_b': 'custom_b'},
libraries: [],
);
final json = summary.toJson();
expect(json['name'], equals('test_pkg'));
expect(
json['environment'],
equals({'flutter': '>=3.2.0 <3.9.0', 'sdk': '^3.12.0'}),
);
expect(json['executables'], equals({'cli_a': null, 'cli_b': 'custom_b'}));
final rehydrated = ApiSummary.fromJson(json);
expect(rehydrated.name, equals('test_pkg'));
expect(rehydrated.environment, equals(summary.environment));
expect(rehydrated.executables, equals(summary.executables));
});
test('toJson omits environment and executables when empty', () {
final summary = ApiSummary(name: 'test_pkg', libraries: []);
final json = summary.toJson();
expect(json.containsKey('environment'), isFalse);
expect(json.containsKey('executables'), isFalse);
final rehydrated = ApiSummary.fromJson(json);
expect(rehydrated.environment, isEmpty);
expect(rehydrated.executables, isEmpty);
});
test('text rendering formats environment and executables', () {
final summary = ApiSummary(
name: 'test_pkg',
environment: {'sdk': '^3.12.0', 'flutter': '>=3.2.0 <3.9.0'},
executables: {
'simple_tool': null,
'same_name_tool': 'same_name_tool',
'custom_tool': 'custom_script',
},
libraries: [],
);
final rendered = summary.toString();
expect(
rendered,
equals(
'environment:\n'
' flutter: >=3.2.0 <3.9.0\n'
' sdk: ^3.12.0\n'
'executables:\n'
' custom_tool: custom_script\n'
' same_name_tool\n'
' simple_tool\n',
),
);
});
test('yaml serialization renders environment and executables', () {
final summary = ApiSummary(
name: 'test_pkg',
environment: {'sdk': '^3.12.0'},
executables: {'my_tool': null},
libraries: [],
);
final editor = YamlEditor('');
editor.update([], summary.toJson());
final yaml = '$editor\n';
expect(yaml, contains('environment:\n sdk: ^3.12.0'));
expect(yaml, contains('executables:\n my_tool: null'));
});
});
group('pubspec.yaml parsing in apiSummary', () {
Future<void> withTempPkg(
String pubspecContent,
Future<void> Function(String pkgPath) fn, {
List<String> referencedPackages = const [],
List<String> reexportedPackages = const [],
}) async {
final allExternalPackages = {
...referencedPackages,
...reexportedPackages,
};
for (final dep in allExternalPackages) {
final typeName = 'Dep_${dep.replaceAll(RegExp('[^a-zA-Z0-9_]'), '_')}';
await d.dir(dep, [
d.file('pubspec.yaml', 'name: $dep\nenvironment:\n sdk: ^3.12.0\n'),
d.dir('lib', [d.file('$dep.dart', 'class $typeName {}\n')]),
]).create();
}
final imports = [
for (final dep in referencedPackages)
"import 'package:$dep/$dep.dart';",
for (final dep in reexportedPackages)
"export 'package:$dep/$dep.dart';",
].join('\n');
final fields = [
for (final dep in referencedPackages)
'Dep_${dep.replaceAll(RegExp('[^a-zA-Z0-9_]'), '_')}? field_$dep;',
].join('\n ');
final fooSource = allExternalPackages.isEmpty
? 'void foo() {}'
: '$imports\n\nclass Foo {\n $fields\n}\n';
final packageEntries = [
'{"name": "sample_pkg", "rootUri": "../", "packageUri": "lib/"}',
for (final dep in allExternalPackages)
'{"name": "$dep", "rootUri": "../../$dep", "packageUri": "lib/"}',
].join(',\n ');
await d.dir('pkg', [
d.file('pubspec.yaml', pubspecContent),
if (allExternalPackages.isNotEmpty)
d.dir('.dart_tool', [
d.file(
'package_config.json',
'{\n'
' "configVersion": 2,\n'
' "packages": [\n'
' $packageEntries\n'
' ]\n'
'}\n',
),
]),
d.dir('lib', [d.file('foo.dart', fooSource)]),
]).create();
await fn(d.path('pkg'));
}
test('extracts environment and executables successfully', () async {
await withTempPkg(
'''
name: sample_pkg
environment:
sdk: ^3.12.0
flutter: ">=3.2.0 <3.9.0"
executables:
tool_a:
tool_b: custom_b
''',
(pkgPath) async {
final summary = await apiSummary(pkgPath);
expect(summary.name, equals('sample_pkg'));
expect(
summary.environment,
equals({'flutter': '>=3.2.0 <3.9.0', 'sdk': '^3.12.0'}),
);
expect(
summary.executables,
equals({'tool_a': null, 'tool_b': 'custom_b'}),
);
},
);
});
test('handles omitted and null environment and executables', () async {
await withTempPkg(
'''
name: minimal_pkg
environment:
executables:
''',
(pkgPath) async {
final summary = await apiSummary(pkgPath);
expect(summary.name, equals('minimal_pkg'));
expect(summary.environment, isEmpty);
expect(summary.executables, isEmpty);
},
);
});
test('resolves relative packagePath (such as ".")', () async {
await withTempPkg('name: relative_pkg\n', (pkgPath) async {
final previousCurrent = Directory.current;
try {
Directory.current = pkgPath;
final summary = await apiSummary('.');
expect(summary.name, equals('relative_pkg'));
} finally {
Directory.current = previousCurrent;
}
final relativeFromCwd = p.relative(pkgPath);
final summaryFromRelative = await apiSummary(relativeFromCwd);
expect(summaryFromRelative.name, equals('relative_pkg'));
});
});
test('ignores null environment constraints', () async {
await withTempPkg(
'''
name: sample_pkg
environment:
sdk: ^3.12.0
flutter:
''',
(pkgPath) async {
final summary = await apiSummary(pkgPath);
expect(summary.environment, equals({'sdk': '^3.12.0'}));
},
);
});
test('throws FormatException when pubspec is not a YAML map', () async {
await withTempPkg('- item1\n- item2', (pkgPath) async {
await expectLater(
apiSummary(pkgPath),
throwsA(
isA<FormatException>().having(
(e) => e.message,
'message',
contains('Expected pubspec to be a YAML map'),
),
),
);
});
});
test(
'throws FormatException when name is missing or not a string',
() async {
await withTempPkg(
'''
environment:
sdk: ^3.12.0
''',
(pkgPath) async {
await expectLater(
apiSummary(pkgPath),
throwsA(
isA<FormatException>().having(
(e) => e.message,
'message',
contains('Expected pubspec to contain a "name" string'),
),
),
);
},
);
await withTempPkg('name: 12345', (pkgPath) async {
await expectLater(
apiSummary(pkgPath),
throwsA(
isA<FormatException>().having(
(e) => e.message,
'message',
contains('Expected pubspec to contain a "name" string'),
),
),
);
});
},
);
test('throws FormatException when environment is not a map', () async {
await withTempPkg('name: foo\nenvironment: "sdk ^3.12.0"', (
pkgPath,
) async {
await expectLater(
apiSummary(pkgPath),
throwsA(
isA<FormatException>().having(
(e) => e.message,
'message',
contains('Expected "environment" to be a YAML map'),
),
),
);
});
});
test(
'throws FormatException when environment constraint is not a scalar',
() async {
await withTempPkg(
'''
name: foo
environment:
sdk: [3, 0]
''',
(pkgPath) async {
await expectLater(
apiSummary(pkgPath),
throwsA(
isA<FormatException>().having(
(e) => e.message,
'message',
contains(
'Expected environment constraint for "sdk" '
'to be a string or scalar',
),
),
),
);
},
);
await withTempPkg(
'''
name: foo
environment:
sdk:
major: 3
''',
(pkgPath) async {
await expectLater(
apiSummary(pkgPath),
throwsA(
isA<FormatException>().having(
(e) => e.message,
'message',
contains(
'Expected environment constraint for "sdk" '
'to be a string or scalar',
),
),
),
);
},
);
},
);
test('throws FormatException when executables is not a map', () async {
await withTempPkg('name: foo\nexecutables: "my_tool"', (pkgPath) async {
await expectLater(
apiSummary(pkgPath),
throwsA(
isA<FormatException>().having(
(e) => e.message,
'message',
contains('Expected "executables" to be a YAML map'),
),
),
);
});
});
test(
'throws FormatException when executable target is not string or null',
() async {
await withTempPkg(
'''
name: foo
executables:
my_tool: [invalid, target]
''',
(pkgPath) async {
await expectLater(
apiSummary(pkgPath),
throwsA(
isA<FormatException>().having(
(e) => e.message,
'message',
contains(
'Expected executable target for "my_tool" '
'to be a string or null',
),
),
),
);
},
);
},
);
test('extracts API-referenced dependencies and ignores unreferenced, '
'dev_dependencies, and overrides', () async {
await withTempPkg(
'''
name: sample_pkg
environment:
sdk: ^3.12.0
dependencies:
z_pkg: ^2.0.0
a_pkg: ">=1.0.0 <2.0.0"
range_pkg: ">= 13.0.0 < 15.0.0"
any_pkg:
flutter:
sdk: flutter
local_pkg:
path: ../local_pkg
hosted_pkg:
hosted: https://pub.example.org
version: ">=1.2.3 <2.0.0"
git_pkg:
git:
url: https://example.com/git_pkg.git
ref: main
unreferenced_pkg: ^9.9.9
dev_dependencies:
test: ^1.28.0
dependency_overrides:
a_pkg: ^1.5.0
''',
referencedPackages: [
'a_pkg',
'any_pkg',
'flutter',
'git_pkg',
'hosted_pkg',
'local_pkg',
'range_pkg',
],
reexportedPackages: ['z_pkg'],
(pkgPath) async {
final summary = await apiSummary(pkgPath);
expect(
summary.dependencies.keys.toList(),
equals([
'a_pkg',
'any_pkg',
'flutter',
'git_pkg',
'hosted_pkg',
'local_pkg',
'range_pkg',
'z_pkg',
]),
);
expect(
summary.dependencies,
equals({
'a_pkg': '^1.0.0',
'any_pkg': 'any',
'flutter': '{sdk: flutter}',
'git_pkg':
'{git: {ref: main, url: https://example.com/git_pkg.git}}',
'hosted_pkg':
'{hosted: https://pub.example.org, version: ^1.2.3}',
'local_pkg': '{path: ../local_pkg}',
'range_pkg': '>=13.0.0 <15.0.0',
'z_pkg': '^2.0.0',
}),
);
expect(summary.dependencies.containsKey('unreferenced_pkg'), isFalse);
expect(summary.dependencies.containsKey('test'), isFalse);
final json = summary.toJson();
expect(json['dependencies'], equals(summary.dependencies));
final rehydrated = ApiSummary.fromJson(json);
expect(rehydrated.dependencies, equals(summary.dependencies));
final rendered = summary.toString();
expect(rendered, isNot(contains('dependencies:\n')));
expect(rendered, isNot(contains('unreferenced_pkg')));
expect(
rendered,
contains(
'package:a_pkg/a_pkg.dart (^1.0.0):\n'
' Dep_a_pkg (referenced)\n'
'package:any_pkg/any_pkg.dart (any):\n'
' Dep_any_pkg (referenced)\n'
'package:flutter/flutter.dart ({sdk: flutter}):\n'
' Dep_flutter (referenced)\n'
'package:git_pkg/git_pkg.dart '
'({git: {ref: main, url: https://example.com/git_pkg.git}}):\n'
' Dep_git_pkg (referenced)\n'
'package:hosted_pkg/hosted_pkg.dart '
'({hosted: https://pub.example.org, version: ^1.2.3}):\n'
' Dep_hosted_pkg (referenced)\n'
'package:local_pkg/local_pkg.dart ({path: ../local_pkg}):\n'
' Dep_local_pkg (referenced)\n'
'package:range_pkg/range_pkg.dart (>=13.0.0 <15.0.0):\n'
' Dep_range_pkg (referenced)\n'
'package:z_pkg/z_pkg.dart (^2.0.0):\n'
' Dep_z_pkg (see above)\n',
),
);
},
);
});
test('normalizes equivalent version constraints in environment '
'and dependencies', () async {
await withTempPkg(
'''
name: sample_pkg
environment:
sdk: ">=3.12.0 <4.0.0"
dependencies:
foo: ">= 1.2.3 < 2.0.0"
''',
referencedPackages: ['foo'],
(pkgPath) async {
final summary = await apiSummary(pkgPath);
expect(summary.environment, equals({'sdk': '^3.12.0'}));
expect(summary.dependencies, equals({'foo': '^1.2.3'}));
},
);
});
test('throws FormatException when dependencies is not a map', () async {
await withTempPkg('name: foo\ndependencies: "^1.0.0"', (pkgPath) async {
await expectLater(
apiSummary(pkgPath),
throwsA(
isA<FormatException>().having(
(e) => e.message,
'message',
contains('Expected "dependencies" to be a YAML map'),
),
),
);
});
});
test(
'throws FormatException when dependency constraint is invalid',
() async {
await withTempPkg(
'''
name: foo
dependencies:
bar: [1, 2]
''',
(pkgPath) async {
await expectLater(
apiSummary(pkgPath),
throwsA(
isA<FormatException>().having(
(e) => e.message,
'message',
contains(
'Expected dependency constraint for "bar" '
'to be a string, null, or map',
),
),
),
);
},
);
},
);
});
}