diff --git a/bin/dependency_services.dart b/bin/dependency_services.dart new file mode 100644 index 000000000..3dabf5d8f --- /dev/null +++ b/bin/dependency_services.dart @@ -0,0 +1,84 @@ +// Copyright (c) 2021, 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. + +/// Support for automated upgrades. +/// +/// For now this is not a finalized interface. Don't rely on this. +library dependency_services; + +import 'dart:async'; + +import 'package:args/args.dart'; +import 'package:args/command_runner.dart'; +import 'package:pub/src/command.dart'; +import 'package:pub/src/command/dependency_services.dart'; +import 'package:pub/src/exit_codes.dart' as exit_codes; +import 'package:pub/src/io.dart'; +import 'package:pub/src/log.dart' as log; + +class _DependencyServicesCommandRunner extends CommandRunner + implements PubTopLevel { + @override + String? get directory => argResults['directory']; + + @override + bool get captureStackChains => argResults['verbose']; + + @override + bool get trace => argResults['verbose']; + + ArgResults? _argResults; + + /// The top-level options parsed by the command runner. + @override + ArgResults get argResults { + final a = _argResults; + if (a == null) { + throw StateError( + 'argResults cannot be used before Command.run is called.'); + } + return a; + } + + _DependencyServicesCommandRunner() + : super('dependency_services', 'Support for automatic upgrades', + usageLineLength: lineLength) { + argParser.addFlag('verbose', + abbr: 'v', negatable: false, help: 'Shortcut for "--verbosity=all".'); + argParser.addOption( + 'directory', + abbr: 'C', + help: 'Run the subcommand in the directory.', + defaultsTo: '.', + valueHelp: 'dir', + ); + + addCommand(DependencyServicesListCommand()); + addCommand(DependencyServicesReportCommand()); + addCommand(DependencyServicesApplyCommand()); + } + + @override + Future run(Iterable args) async { + try { + _argResults = parse(args); + return await runCommand(argResults) ?? exit_codes.SUCCESS; + } on UsageException catch (error) { + log.exception(error); + return exit_codes.USAGE; + } + } + + @override + void printUsage() { + log.message(usage); + } + + @override + log.Verbosity get verbosity => log.Verbosity.normal; +} + +Future main(List arguments) async { + await flushThenExit(await _DependencyServicesCommandRunner().run(arguments)); +} diff --git a/lib/src/command.dart b/lib/src/command.dart index 556c04f96..ee6239231 100644 --- a/lib/src/command.dart +++ b/lib/src/command.dart @@ -13,7 +13,6 @@ import 'package:meta/meta.dart'; import 'package:path/path.dart' as p; import 'authentication/token_store.dart'; -import 'command_runner.dart'; import 'entrypoint.dart'; import 'exceptions.dart'; import 'exit_codes.dart' as exit_codes; @@ -127,7 +126,7 @@ abstract class PubCommand extends Command { } PubTopLevel get _pubTopLevel => - _pubEmbeddableCommand ?? runner as PubCommandRunner; + _pubEmbeddableCommand ?? runner as PubTopLevel; PubAnalytics? get analytics => _pubEmbeddableCommand?.analytics; diff --git a/lib/src/command/dependency_services.dart b/lib/src/command/dependency_services.dart new file mode 100644 index 000000000..47805bcbd --- /dev/null +++ b/lib/src/command/dependency_services.dart @@ -0,0 +1,381 @@ +// Copyright (c) 2021, 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. + +/// This implements support for dependency-bot style automated upgrades. +/// It is still work in progress - do not rely on the current output. +import 'dart:convert'; +import 'dart:io'; + +import 'package:collection/collection.dart'; +import 'package:pub_semver/pub_semver.dart'; +import 'package:yaml/yaml.dart'; +import 'package:yaml_edit/yaml_edit.dart'; + +import '../command.dart'; +import '../entrypoint.dart'; +import '../exceptions.dart'; +import '../io.dart'; +import '../log.dart' as log; +import '../package.dart'; +import '../package_name.dart'; +import '../pubspec.dart'; +import '../pubspec_utils.dart'; +import '../solver.dart'; +import '../system_cache.dart'; +import '../utils.dart'; + +class DependencyServicesReportCommand extends PubCommand { + @override + String get name => 'report'; + @override + String get description => + 'Output a machine-digestible report of the upgrade options for each dependency.'; + @override + String get argumentsDescription => '[options]'; + + @override + bool get takesArguments => false; + + DependencyServicesReportCommand() { + argParser.addOption('directory', + abbr: 'C', help: 'Run this in the directory.', valueHelp: 'dir'); + } + + @override + Future runProtected() async { + final compatiblePubspec = stripDependencyOverrides(entrypoint.root.pubspec); + + final breakingPubspec = stripVersionUpperBounds(compatiblePubspec); + + final compatiblePackagesResult = + await _tryResolve(compatiblePubspec, cache); + + final breakingPackagesResult = await _tryResolve(breakingPubspec, cache); + + // The packages in the current lockfile or resolved from current pubspec.yaml. + late Map currentPackages; + + if (fileExists(entrypoint.lockFilePath)) { + currentPackages = + Map.from(entrypoint.lockFile.packages); + } else { + final resolution = await _tryResolve(entrypoint.root.pubspec, cache) ?? + (throw DataException('Failed to resolve pubspec')); + currentPackages = + Map.fromIterable(resolution, key: (e) => e.name); + } + currentPackages.remove(entrypoint.root.name); + + final dependencies = []; + final result = {'dependencies': dependencies}; + + Future> _computeUpgradeSet( + Pubspec rootPubspec, + PackageId? package, { + required UpgradeType upgradeType, + }) async { + if (package == null) return []; + final lockFile = entrypoint.lockFile; + final pubspec = upgradeType == UpgradeType.multiBreaking + ? stripVersionUpperBounds(rootPubspec) + : Pubspec( + rootPubspec.name, + dependencies: rootPubspec.dependencies.values, + devDependencies: rootPubspec.devDependencies.values, + sdkConstraints: rootPubspec.sdkConstraints, + ); + + final dependencySet = dependencySetOfPackage(pubspec, package); + if (dependencySet != null) { + // Force the version to be the new version. + dependencySet[package.name] = + package.toRange().withConstraint(package.toRange().constraint); + } + + final resolution = await tryResolveVersions( + SolveType.get, + cache, + Package.inMemory(pubspec), + lockFile: lockFile, + ); + + // TODO(sigurdm): improve error messages. + if (resolution == null) { + throw DataException('Failed resolving'); + } + + return [ + ...resolution.packages.where((r) { + if (r.name == rootPubspec.name) return false; + final originalVersion = currentPackages[r.name]; + return originalVersion == null || + r.version != originalVersion.version; + }).map((p) { + final depset = dependencySetOfPackage(rootPubspec, p); + final originalConstraint = depset?[p.name]?.constraint; + return { + 'name': p.name, + 'version': p.version.toString(), + 'kind': _kindString(pubspec, p.name), + 'constraint': originalConstraint == null + ? null + : upgradeType == UpgradeType.compatible + ? originalConstraint.toString() + : VersionConstraint.compatibleWith(p.version).toString(), + 'previousVersion': currentPackages[p.name]?.version.toString(), + 'previousConstraint': originalConstraint?.toString(), + }; + }), + for (final oldPackageName in lockFile.packages.keys) + if (!resolution.packages + .any((newPackage) => newPackage.name == oldPackageName)) + { + 'name': oldPackageName, + 'version': null, + 'kind': + 'transitive', // Only transitive constraints can be removed. + 'constraint': null, + 'previousVersion': + currentPackages[oldPackageName]?.version.toString(), + 'previousConstraint': null, + }, + ]; + } + + for (final package in currentPackages.values) { + final compatibleVersion = compatiblePackagesResult + ?.firstWhereOrNull((element) => element.name == package.name); + final multiBreakingVersion = breakingPackagesResult + ?.firstWhereOrNull((element) => element.name == package.name); + final singleBreakingPubspec = Pubspec( + compatiblePubspec.name, + version: compatiblePubspec.version, + sdkConstraints: compatiblePubspec.sdkConstraints, + dependencies: compatiblePubspec.dependencies.values, + devDependencies: compatiblePubspec.devDependencies.values, + ); + final dependencySet = + dependencySetOfPackage(singleBreakingPubspec, package); + final kind = _kindString(compatiblePubspec, package.name); + PackageId? singleBreakingVersion; + if (dependencySet != null) { + dependencySet[package.name] = package + .toRange() + .withConstraint(stripUpperBound(package.toRange().constraint)); + final singleBreakingPackagesResult = + await _tryResolve(singleBreakingPubspec, cache); + singleBreakingVersion = singleBreakingPackagesResult + ?.firstWhereOrNull((element) => element.name == package.name); + } + dependencies.add({ + 'name': package.name, + 'version': package.version.toString(), + 'kind': kind, + 'latest': (await cache.getLatest(package))?.version.toString(), + 'constraint': + _constraintOf(compatiblePubspec, package.name)?.toString(), + if (compatibleVersion != null) + 'compatible': await _computeUpgradeSet( + compatiblePubspec, compatibleVersion, + upgradeType: UpgradeType.compatible), + 'singleBreaking': kind != 'transitive' && singleBreakingVersion == null + ? [] + : await _computeUpgradeSet(compatiblePubspec, singleBreakingVersion, + upgradeType: UpgradeType.singleBreaking), + 'multiBreaking': kind != 'transitive' && multiBreakingVersion != null + ? await _computeUpgradeSet(compatiblePubspec, multiBreakingVersion, + upgradeType: UpgradeType.multiBreaking) + : [], + }); + } + log.message(JsonEncoder.withIndent(' ').convert(result)); + } +} + +VersionConstraint? _constraintOf(Pubspec pubspec, String packageName) { + return (pubspec.dependencies[packageName] ?? + pubspec.devDependencies[packageName]) + ?.constraint; +} + +String _kindString(Pubspec pubspec, String packageName) { + return pubspec.dependencies.containsKey(packageName) + ? 'direct' + : pubspec.devDependencies.containsKey(packageName) + ? 'dev' + : 'transitive'; +} + +/// Try to solve [pubspec] return [PackageId]s in the resolution or `null` if no +/// resolution was found. +Future?> _tryResolve(Pubspec pubspec, SystemCache cache) async { + final solveResult = await tryResolveVersions( + SolveType.upgrade, + cache, + Package.inMemory(pubspec), + ); + + return solveResult?.packages; +} + +class DependencyServicesListCommand extends PubCommand { + @override + String get name => 'list'; + + @override + String get description => + 'Output a machine digestible listing of all dependencies'; + + @override + bool get takesArguments => false; + + DependencyServicesListCommand() { + argParser.addOption('directory', + abbr: 'C', help: 'Run this in the directory.', valueHelp: 'dir'); + } + + @override + Future runProtected() async { + final pubspec = entrypoint.root.pubspec; + + final currentPackages = fileExists(entrypoint.lockFilePath) + ? entrypoint.lockFile.packages.values.toList() + : (await _tryResolve(pubspec, cache) ?? []); + + final dependencies = []; + final result = {'dependencies': dependencies}; + + for (final package in currentPackages) { + dependencies.add({ + 'name': package.name, + 'version': package.version.toString(), + 'kind': _kindString(pubspec, package.name), + 'constraint': _constraintOf(pubspec, package.name).toString(), + }); + } + log.message(JsonEncoder.withIndent(' ').convert(result)); + } +} + +enum UpgradeType { + /// Only upgrade pubspec.lock + compatible, + + /// Unlock at most one dependency in pubspec.yaml + singleBreaking, + + /// Unlock any dependencies in pubspec.yaml needed for getting the + /// latest resolvable version. + multiBreaking, +} + +class DependencyServicesApplyCommand extends PubCommand { + @override + String get name => 'apply'; + + @override + String get description => + 'Output a machine digestible listing of all dependencies'; + + @override + bool get takesArguments => true; + + DependencyServicesApplyCommand() { + argParser.addOption('directory', + abbr: 'C', help: 'Run this in the directory .', valueHelp: 'dir'); + } + + @override + Future runProtected() async { + YamlEditor(readTextFile(entrypoint.pubspecPath)); + final toApply = <_PackageVersion>[]; + final input = json.decode(await utf8.decodeStream(stdin)); + for (final change in input['dependencyChanges']) { + toApply.add( + _PackageVersion( + change['name'], + change['version'] != null ? Version.parse(change['version']) : null, + change['constraint'] != null + ? VersionConstraint.parse(change['constraint']) + : null, + ), + ); + } + + final pubspec = entrypoint.root.pubspec; + final pubspecEditor = YamlEditor(readTextFile(entrypoint.pubspecPath)); + final lockFile = fileExists(entrypoint.lockFilePath) + ? readTextFile(entrypoint.lockFilePath) + : null; + final lockFileYaml = lockFile == null ? null : loadYaml(lockFile); + final lockFileEditor = lockFile == null ? null : YamlEditor(lockFile); + for (final p in toApply) { + final targetPackage = p.name; + final targetVersion = p.version; + final targetConstraint = p.constraint; + + if (targetConstraint != null) { + final section = pubspec.dependencies[targetPackage] != null + ? 'dependencies' + : 'dev_dependencies'; + pubspecEditor + .update([section, targetPackage], targetConstraint.toString()); + } else if (targetVersion != null) { + final constraint = _constraintOf(pubspec, targetPackage); + if (constraint != null && !constraint.allows(targetVersion)) { + final section = pubspec.dependencies[targetPackage] != null + ? 'dependencies' + : 'dev_dependencies'; + pubspecEditor.update([section, targetPackage], + VersionConstraint.compatibleWith(targetVersion).toString()); + } + } + if (targetVersion != null && + lockFileEditor != null && + lockFileYaml['packages'].containsKey(targetPackage)) { + lockFileEditor.update( + ['packages', targetPackage, 'version'], targetVersion.toString()); + } + if (targetVersion == null && + lockFileEditor != null && + !lockFileYaml['packages'].containsKey(targetPackage)) { + dataError( + 'Trying to remove non-existing transitive dependency $targetPackage.', + ); + } + } + + if (pubspecEditor.edits.isNotEmpty) { + writeTextFile(entrypoint.pubspecPath, pubspecEditor.toString()); + } + if (lockFileEditor != null && lockFileEditor.edits.isNotEmpty) { + writeTextFile(entrypoint.lockFilePath, lockFileEditor.toString()); + } + await log.warningsOnlyUnlessTerminal( + () async { + // This will fail if the new configuration does not resolve. + await Entrypoint(directory, cache).acquireDependencies(SolveType.get, + analytics: null, generateDotPackages: false); + }, + ); + // Dummy message. + log.message(json.encode({'dependencies': []})); + } +} + +class _PackageVersion { + String name; + Version? version; + VersionConstraint? constraint; + _PackageVersion(this.name, this.version, this.constraint); +} + +Map? dependencySetOfPackage( + Pubspec pubspec, PackageName package) { + return pubspec.dependencies.containsKey(package.name) + ? pubspec.dependencies + : pubspec.devDependencies.containsKey(package.name) + ? pubspec.devDependencies + : null; +} diff --git a/lib/src/command/outdated.dart b/lib/src/command/outdated.dart index 609b7ae29..294195243 100644 --- a/lib/src/command/outdated.dart +++ b/lib/src/command/outdated.dart @@ -10,7 +10,6 @@ import 'dart:math'; import 'package:collection/collection.dart' show IterableExtension, IterableNullableExtension; import 'package:path/path.dart' as path; -import 'package:pub_semver/pub_semver.dart'; import '../command.dart'; import '../command_runner.dart'; @@ -181,18 +180,23 @@ class OutdatedCommand extends PubCommand { PackageId? latest; // If not overridden in current resolution we can use this if (!entrypoint.root.pubspec.dependencyOverrides.containsKey(name)) { - latest ??= await _getLatest(current); + latest ??= + await cache.getLatest(current, allowPrereleases: prereleases); } // If present as a dependency or dev_dependency we use this - latest ??= await _getLatest(rootPubspec.dependencies[name]); - latest ??= await _getLatest(rootPubspec.devDependencies[name]); + latest ??= await cache.getLatest(rootPubspec.dependencies[name], + allowPrereleases: prereleases); + latest ??= await cache.getLatest(rootPubspec.devDependencies[name], + allowPrereleases: prereleases); // If not overridden and present in either upgradable or resolvable we // use this reference to find the latest if (!upgradablePubspec.dependencyOverrides.containsKey(name)) { - latest ??= await _getLatest(upgradable); + latest ??= + await cache.getLatest(upgradable, allowPrereleases: prereleases); } if (!resolvablePubspec.dependencyOverrides.containsKey(name)) { - latest ??= await _getLatest(resolvable); + latest ??= + await cache.getLatest(resolvable, allowPrereleases: prereleases); } // Otherwise, we might simply not have a latest, when a transitive // dependency is overridden the source can depend on which versions we @@ -200,7 +204,8 @@ class OutdatedCommand extends PubCommand { // allow 3rd party pub servers, but other servers might. Hence, we choose // to fallback to using the overridden source for latest. if (latest == null) { - latest ??= await _getLatest(current ?? upgradable ?? resolvable); + latest ??= await cache.getLatest(current ?? upgradable ?? resolvable, + allowPrereleases: prereleases); latestIsOverridden = true; } @@ -304,37 +309,6 @@ class OutdatedCommand extends PubCommand { return argResults['mode'] == 'null-safety'; }(); - /// Get the latest version of [package]. - /// - /// Will include prereleases in the comparison if '--prereleases' was enabled - /// by the arguments. - /// - /// If [package] is a [PackageId] with a prerelease version and there are no - /// later stable version we return a prerelease version if it exists. - /// - /// Returns `null`, if unable to find the package. - Future _getLatest(PackageName? package) async { - if (package == null) { - return null; - } - final ref = package.toRef(); - final available = await cache.source(ref.source).getVersions(ref); - if (available.isEmpty) { - return null; - } - - // TODO(sigurdm): Refactor this to share logic with report.dart. - available.sort(prereleases - ? (x, y) => x.version.compareTo(y.version) - : (x, y) => Version.prioritize(x.version, y.version)); - if (package is PackageId && - package.version.isPreRelease && - package.version > available.last.version) { - available.sort((x, y) => x.version.compareTo(y.version)); - } - return available.last; - } - /// Retrieves the pubspec of package [name] in [version] from [source]. /// /// Returns `null`, if given `null` as a convinience. diff --git a/lib/src/io.dart b/lib/src/io.dart index 280dcbd44..2faf85f15 100644 --- a/lib/src/io.dart +++ b/lib/src/io.dart @@ -1080,3 +1080,13 @@ final String? dartConfigDir = () { return null; } }(); + +/// Escape [x] for users to copy-paste in bash. +/// +/// If x is alphanumeric we leave it as is. +/// +/// Otherwise, wrap with single quotation, and use '\'' to insert single quote. +String escapeShellArgument(String x) => + RegExp(r'^[a-zA-Z0-9-_=@.]+$').stringMatch(x) == null + ? "'${x.replaceAll(r'\', '\\').replaceAll("'", r"'\''")}'" + : x; diff --git a/lib/src/pubspec_utils.dart b/lib/src/pubspec_utils.dart index 251c8c9e3..ee904a717 100644 --- a/lib/src/pubspec_utils.dart +++ b/lib/src/pubspec_utils.dart @@ -4,7 +4,6 @@ import 'dart:async'; -import 'package:meta/meta.dart'; import 'package:pub_semver/pub_semver.dart'; import 'package_name.dart'; @@ -148,7 +147,6 @@ Pubspec stripVersionUpperBounds(Pubspec original, /// Removes the upper bound of [constraint]. If [constraint] is the /// empty version constraint, [VersionConstraint.empty] will be returned. -@visibleForTesting VersionConstraint stripUpperBound(VersionConstraint constraint) { ArgumentError.checkNotNull(constraint, 'constraint'); diff --git a/lib/src/system_cache.dart b/lib/src/system_cache.dart index dc6eb4af8..1653f1e0d 100644 --- a/lib/src/system_cache.dart +++ b/lib/src/system_cache.dart @@ -5,6 +5,7 @@ import 'dart:io'; import 'package:path/path.dart' as p; +import 'package:pub_semver/pub_semver.dart'; import 'authentication/token_store.dart'; import 'io.dart'; @@ -20,6 +21,7 @@ import 'source/path.dart'; import 'source/sdk.dart'; import 'source/unknown.dart'; import 'source_registry.dart'; +import 'utils.dart'; /// The system-wide cache of downloaded packages. /// @@ -146,4 +148,43 @@ class SystemCache { log.fine('Clean up system cache temp directory $tempDir.'); if (dirExists(tempDir)) deleteEntry(tempDir); } + + /// Get the latest version of [package]. + /// + /// Will consider _prereleases_ if: + /// * [allowPrereleases] is true, or, + /// * [package] is a [PackageId] with a prerelease version, and no later prerelease exists. + /// + /// Returns `null`, if unable to find the package. + Future getLatest( + PackageName? package, { + bool allowPrereleases = false, + }) async { + if (package == null) { + return null; + } + final ref = package.toRef(); + // TODO: Pass some maxAge to getVersions + final available = await source(ref.source).getVersions(ref); + if (available.isEmpty) { + return null; + } + + final latest = maxAll( + available.map((id) => id.version), + allowPrereleases ? Comparable.compare : Version.prioritize, + ); + + if (package is PackageId && + package.version.isPreRelease && + package.version > latest && + !allowPrereleases) { + return getLatest(package, allowPrereleases: true); + } + + // There should be exactly one entry in [available] matching [latest] + assert(available.where((id) => id.version == latest).length == 1); + + return available.firstWhere((id) => id.version == latest); + } } diff --git a/test/dependency_services/dependency_services_test.dart b/test/dependency_services/dependency_services_test.dart new file mode 100644 index 000000000..4503e67d6 --- /dev/null +++ b/test/dependency_services/dependency_services_test.dart @@ -0,0 +1,254 @@ +// Copyright (c) 2020, 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:convert'; +import 'dart:io'; + +import 'package:path/path.dart' as p; +import 'package:pub/src/io.dart'; +import 'package:pub_semver/pub_semver.dart'; +import 'package:test/test.dart'; + +import '../descriptor.dart' as d; +import '../golden_file.dart'; +import '../test_pub.dart'; + +void manifestAndLockfile(GoldenTestContext context) { + String catFile(String filename) { + final contents = filterUnstableLines( + File(p.join(d.sandbox, appPath, filename)).readAsLinesSync()); + + return ''' +\$ cat $filename +${contents.join('\n')}'''; + } + + context.expectNextSection(''' +${catFile('pubspec.yaml')} +${catFile('pubspec.lock')} +'''); +} + +late final String snapshot; + +extension on GoldenTestContext { + /// Returns the stdout. + Future runDependencyServices(List args, + {String? stdin}) async { + final buffer = StringBuffer(); + buffer.writeln('## Section ${args.join(' ')}'); + final process = await Process.start( + Platform.resolvedExecutable, + [ + snapshot, + ...args, + ], + environment: getPubTestEnvironment(), + workingDirectory: p.join(d.sandbox, appPath), + ); + if (stdin != null) { + process.stdin.write(stdin); + await process.stdin.flush(); + await process.stdin.close(); + } + final outLines = outputLines(process.stdout); + final errLines = outputLines(process.stderr); + final exitCode = await process.exitCode; + + final pipe = stdin == null ? '' : ' echo ${escapeShellArgument(stdin)} |'; + buffer.writeln([ + '\$$pipe dependency_services ${args.map(escapeShellArgument).join(' ')}', + ...await outLines, + ...(await errLines).map((e) => '[STDERR] $e'), + if (exitCode != 0) '[EXIT CODE] $exitCode', + ].join('\n')); + + expectNextSection(buffer.toString()); + return (await outLines).join('\n'); + } +} + +Future> outputLines(Stream> stream) async { + final s = await utf8.decodeStream(stream); + if (s.isEmpty) return []; + return filterUnstableLines(s.split('\n')); +} + +Future listReportApply( + GoldenTestContext context, + List<_PackageVersion> upgrades, { + void Function(Map)? reportAssertions, +}) async { + manifestAndLockfile(context); + await context.runDependencyServices(['list']); + final report = await context.runDependencyServices(['report']); + if (reportAssertions != null) { + reportAssertions(json.decode(report)); + } + final input = json.encode({ + 'dependencyChanges': upgrades, + }); + + await context.runDependencyServices(['apply'], stdin: input); + manifestAndLockfile(context); +} + +Future main() async { + setUpAll(() async { + final tempDir = Directory.systemTemp.createTempSync(); + snapshot = p.join(tempDir.path, 'dependency_services.dart.snapshot'); + final r = Process.runSync(Platform.resolvedExecutable, [ + '--snapshot=$snapshot', + p.join('bin', 'dependency_services.dart'), + ]); + expect(r.exitCode, 0, reason: r.stderr); + }); + + tearDownAll(() { + File(snapshot).parent.deleteSync(recursive: true); + }); + + testWithGolden('Removing transitive', (context) async { + (await servePackages()) + ..serve('foo', '1.2.3', deps: {'transitive': '^1.0.0'}) + ..serve('foo', '2.2.3') + ..serve('transitive', '1.0.0'); + + await d.dir(appPath, [ + d.pubspec({ + 'name': 'app', + 'dependencies': { + 'foo': '^1.0.0', + }, + }) + ]).create(); + await pubGet(); + await listReportApply(context, [ + _PackageVersion('foo', Version.parse('2.2.3')), + _PackageVersion('transitive', null) + ], reportAssertions: (report) { + expect( + findChangeVersion(report, 'singleBreaking', 'foo'), + '2.2.3', + ); + expect( + findChangeVersion(report, 'singleBreaking', 'transitive'), + null, + ); + }); + }); + + testWithGolden('Compatible', (context) async { + final server = (await servePackages()) + ..serve('foo', '1.2.3') + ..serve('foo', '2.2.3') + ..serve('bar', '1.2.3') + ..serve('bar', '2.2.3'); + await d.dir(appPath, [ + d.pubspec({ + 'name': 'app', + 'dependencies': { + 'foo': '^1.0.0', + 'bar': '^1.0.0', + }, + }) + ]).create(); + await pubGet(); + server.serve('foo', '1.2.4'); + await listReportApply(context, [ + _PackageVersion('foo', Version.parse('1.2.4')), + ], reportAssertions: (report) { + expect( + findChangeVersion(report, 'compatible', 'foo'), + '1.2.4', + ); + }); + }); + + testWithGolden('Adding transitive', (context) async { + (await servePackages()) + ..serve('foo', '1.2.3') + ..serve('foo', '2.2.3', deps: {'transitive': '^1.0.0'}) + ..serve('transitive', '1.0.0'); + + await d.dir(appPath, [ + d.pubspec({ + 'name': 'app', + 'dependencies': { + 'foo': '^1.0.0', + }, + }) + ]).create(); + await pubGet(); + await listReportApply(context, [ + _PackageVersion('foo', Version.parse('2.2.3')), + _PackageVersion('transitive', Version.parse('1.0.0')) + ], reportAssertions: (report) { + expect( + findChangeVersion(report, 'singleBreaking', 'foo'), + '2.2.3', + ); + expect( + findChangeVersion(report, 'singleBreaking', 'transitive'), + '1.0.0', + ); + }); + }); + + testWithGolden('multibreaking', (context) async { + final server = (await servePackages()) + ..serve('foo', '1.0.0') + ..serve('bar', '1.0.0'); + + await d.dir(appPath, [ + d.pubspec({ + 'name': 'app', + 'dependencies': { + 'foo': '^1.0.0', + 'bar': '^1.0.0', + }, + }) + ]).create(); + await pubGet(); + server + ..serve('foo', '1.5.0') // compatible + ..serve('foo', '2.0.0') // single breaking + ..serve('foo', '3.0.0', deps: {'bar': '^2.0.0'}) // multi breaking + ..serve('foo', '3.0.1', deps: {'bar': '^2.0.0'}) + ..serve('bar', '2.0.0', deps: {'foo': '^3.0.0'}) + ..serve('transitive', '1.0.0'); + await listReportApply(context, [ + _PackageVersion('foo', Version.parse('3.0.1'), + constraint: VersionConstraint.parse('^3.0.0')), + _PackageVersion('bar', Version.parse('2.0.0')) + ], reportAssertions: (report) { + expect( + findChangeVersion(report, 'multiBreaking', 'foo'), + '3.0.1', + ); + expect( + findChangeVersion(report, 'multiBreaking', 'bar'), + '2.0.0', + ); + }); + }); +} + +dynamic findChangeVersion(dynamic json, String updateType, String name) { + final dep = json['dependencies'].firstWhere((p) => p['name'] == 'foo'); + return dep[updateType].firstWhere((p) => p['name'] == name)['version']; +} + +class _PackageVersion { + String name; + Version? version; + VersionConstraint? constraint; + _PackageVersion(this.name, this.version, {this.constraint}); + + Map toJson() => { + 'name': name, + 'version': version?.toString(), + if (constraint != null) 'constraint': constraint.toString() + }; +} diff --git a/test/golden_file.dart b/test/golden_file.dart index 79277762a..6a88dd9df 100644 --- a/test/golden_file.dart +++ b/test/golden_file.dart @@ -151,6 +151,7 @@ class GoldenTestContext { List args, { Map? environment, String? workingDirectory, + String? stdin, }) async { // Create new section index number (before doing anything async) final sectionIndex = _nextSectionIndex++; @@ -161,6 +162,7 @@ class GoldenTestContext { s, environment: environment, workingDirectory: workingDirectory, + stdin: stdin, ); _expectSection(sectionIndex, s.toString()); diff --git a/test/test_pub.dart b/test/test_pub.dart index fbf63de76..94e64fc9c 100644 --- a/test/test_pub.dart +++ b/test/test_pub.dart @@ -891,7 +891,8 @@ Matcher matchesMultiple(String pattern, int times) { /// A [StreamMatcher] that matches multiple lines of output. StreamMatcher emitsLines(String output) => emitsInOrder(output.split('\n')); -Iterable _filter(List input) { +/// Removes output from pub known to be unstable. +Iterable filterUnstableLines(List input) { return input // Downloading order is not deterministic, so to avoid flakiness we filter // out these lines. @@ -916,12 +917,18 @@ Future runPubIntoBuffer( StringBuffer buffer, { Map? environment, String? workingDirectory, + String? stdin, }) async { final process = await startPub( args: args, environment: environment, workingDirectory: workingDirectory, ); + if (stdin != null) { + process.stdin.write(stdin); + await process.stdin.flush(); + await process.stdin.close(); + } final exitCode = await process.exitCode; // TODO(jonasfj): Clean out temporary directory names from env vars... @@ -933,11 +940,12 @@ Future runPubIntoBuffer( // .map((e) => '\$ export ${e.key}=${e.value}') // .join('\n')); // } - buffer.writeln(_filter([ - '\$ pub ${args.join(' ')}', + final pipe = stdin == null ? '' : ' echo ${escapeShellArgument(stdin)} |'; + buffer.writeln(filterUnstableLines([ + '\$$pipe pub ${args.map(escapeShellArgument).join(' ')}', ...await process.stdout.rest.toList(), ]).join('\n')); - for (final line in _filter(await process.stderr.rest.toList())) { + for (final line in filterUnstableLines(await process.stderr.rest.toList())) { buffer.writeln('[STDERR] $line'); } if (exitCode != 0) { diff --git a/test/testdata/goldens/dependency_services/dependency_services_test/Adding transitive.txt b/test/testdata/goldens/dependency_services/dependency_services_test/Adding transitive.txt new file mode 100644 index 000000000..368a120b3 --- /dev/null +++ b/test/testdata/goldens/dependency_services/dependency_services_test/Adding transitive.txt @@ -0,0 +1,115 @@ +# GENERATED BY: test/dependency_services/dependency_services_test.dart + +$ cat pubspec.yaml +{"name":"app","dependencies":{"foo":"^1.0.0"},"environment":{"sdk":">=0.1.2 <1.0.0"}} +$ cat pubspec.lock +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + foo: + dependency: "direct main" + description: + name: foo + url: "http://localhost:$PORT" + source: hosted + version: "1.2.3" +sdks: + dart: ">=0.1.2 <1.0.0" +-------------------------------- END OF OUTPUT --------------------------------- + +## Section list +$ dependency_services list +{ + "dependencies": [ + { + "name": "foo", + "version": "1.2.3", + "kind": "direct", + "constraint": "^1.0.0" + } + ] +} + +-------------------------------- END OF OUTPUT --------------------------------- + +## Section report +$ dependency_services report +{ + "dependencies": [ + { + "name": "foo", + "version": "1.2.3", + "kind": "direct", + "latest": "2.2.3", + "constraint": "^1.0.0", + "compatible": [], + "singleBreaking": [ + { + "name": "foo", + "version": "2.2.3", + "kind": "direct", + "constraint": "^2.2.3", + "previousVersion": "1.2.3", + "previousConstraint": "^1.0.0" + }, + { + "name": "transitive", + "version": "1.0.0", + "kind": "transitive", + "constraint": null, + "previousVersion": null, + "previousConstraint": null + } + ], + "multiBreaking": [ + { + "name": "foo", + "version": "2.2.3", + "kind": "direct", + "constraint": "^2.2.3", + "previousVersion": "1.2.3", + "previousConstraint": "^1.0.0" + }, + { + "name": "transitive", + "version": "1.0.0", + "kind": "transitive", + "constraint": null, + "previousVersion": null, + "previousConstraint": null + } + ] + } + ] +} + +-------------------------------- END OF OUTPUT --------------------------------- + +## Section apply +$ echo '{"dependencyChanges":[{"name":"foo","version":"2.2.3"},{"name":"transitive","version":"1.0.0"}]}' | dependency_services apply +{"dependencies":[]} + +-------------------------------- END OF OUTPUT --------------------------------- + +$ cat pubspec.yaml +{"name":"app","dependencies":{"foo":^2.2.3},"environment":{"sdk":">=0.1.2 <1.0.0"}} +$ cat pubspec.lock +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + foo: + dependency: "direct main" + description: + name: foo + url: "http://localhost:$PORT" + source: hosted + version: "2.2.3" + transitive: + dependency: transitive + description: + name: transitive + url: "http://localhost:$PORT" + source: hosted + version: "1.0.0" +sdks: + dart: ">=0.1.2 <1.0.0" diff --git a/test/testdata/goldens/dependency_services/dependency_services_test/Compatible.txt b/test/testdata/goldens/dependency_services/dependency_services_test/Compatible.txt new file mode 100644 index 000000000..4c3fd0b40 --- /dev/null +++ b/test/testdata/goldens/dependency_services/dependency_services_test/Compatible.txt @@ -0,0 +1,149 @@ +# GENERATED BY: test/dependency_services/dependency_services_test.dart + +$ cat pubspec.yaml +{"name":"app","dependencies":{"foo":"^1.0.0","bar":"^1.0.0"},"environment":{"sdk":">=0.1.2 <1.0.0"}} +$ cat pubspec.lock +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + bar: + dependency: "direct main" + description: + name: bar + url: "http://localhost:$PORT" + source: hosted + version: "1.2.3" + foo: + dependency: "direct main" + description: + name: foo + url: "http://localhost:$PORT" + source: hosted + version: "1.2.3" +sdks: + dart: ">=0.1.2 <1.0.0" +-------------------------------- END OF OUTPUT --------------------------------- + +## Section list +$ dependency_services list +{ + "dependencies": [ + { + "name": "bar", + "version": "1.2.3", + "kind": "direct", + "constraint": "^1.0.0" + }, + { + "name": "foo", + "version": "1.2.3", + "kind": "direct", + "constraint": "^1.0.0" + } + ] +} + +-------------------------------- END OF OUTPUT --------------------------------- + +## Section report +$ dependency_services report +{ + "dependencies": [ + { + "name": "bar", + "version": "1.2.3", + "kind": "direct", + "latest": "2.2.3", + "constraint": "^1.0.0", + "compatible": [], + "singleBreaking": [ + { + "name": "bar", + "version": "2.2.3", + "kind": "direct", + "constraint": "^2.2.3", + "previousVersion": "1.2.3", + "previousConstraint": "^1.0.0" + } + ], + "multiBreaking": [ + { + "name": "bar", + "version": "2.2.3", + "kind": "direct", + "constraint": "^2.2.3", + "previousVersion": "1.2.3", + "previousConstraint": "^1.0.0" + } + ] + }, + { + "name": "foo", + "version": "1.2.3", + "kind": "direct", + "latest": "2.2.3", + "constraint": "^1.0.0", + "compatible": [ + { + "name": "foo", + "version": "1.2.4", + "kind": "direct", + "constraint": "^1.0.0", + "previousVersion": "1.2.3", + "previousConstraint": "^1.0.0" + } + ], + "singleBreaking": [ + { + "name": "foo", + "version": "2.2.3", + "kind": "direct", + "constraint": "^2.2.3", + "previousVersion": "1.2.3", + "previousConstraint": "^1.0.0" + } + ], + "multiBreaking": [ + { + "name": "foo", + "version": "2.2.3", + "kind": "direct", + "constraint": "^2.2.3", + "previousVersion": "1.2.3", + "previousConstraint": "^1.0.0" + } + ] + } + ] +} + +-------------------------------- END OF OUTPUT --------------------------------- + +## Section apply +$ echo '{"dependencyChanges":[{"name":"foo","version":"1.2.4"}]}' | dependency_services apply +{"dependencies":[]} + +-------------------------------- END OF OUTPUT --------------------------------- + +$ cat pubspec.yaml +{"name":"app","dependencies":{"foo":"^1.0.0","bar":"^1.0.0"},"environment":{"sdk":">=0.1.2 <1.0.0"}} +$ cat pubspec.lock +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + bar: + dependency: "direct main" + description: + name: bar + url: "http://localhost:$PORT" + source: hosted + version: "1.2.3" + foo: + dependency: "direct main" + description: + name: foo + url: "http://localhost:$PORT" + source: hosted + version: "1.2.4" +sdks: + dart: ">=0.1.2 <1.0.0" diff --git a/test/testdata/goldens/dependency_services/dependency_services_test/Removing transitive.txt b/test/testdata/goldens/dependency_services/dependency_services_test/Removing transitive.txt new file mode 100644 index 000000000..3a0133ad8 --- /dev/null +++ b/test/testdata/goldens/dependency_services/dependency_services_test/Removing transitive.txt @@ -0,0 +1,131 @@ +# GENERATED BY: test/dependency_services/dependency_services_test.dart + +$ cat pubspec.yaml +{"name":"app","dependencies":{"foo":"^1.0.0"},"environment":{"sdk":">=0.1.2 <1.0.0"}} +$ cat pubspec.lock +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + foo: + dependency: "direct main" + description: + name: foo + url: "http://localhost:$PORT" + source: hosted + version: "1.2.3" + transitive: + dependency: transitive + description: + name: transitive + url: "http://localhost:$PORT" + source: hosted + version: "1.0.0" +sdks: + dart: ">=0.1.2 <1.0.0" +-------------------------------- END OF OUTPUT --------------------------------- + +## Section list +$ dependency_services list +{ + "dependencies": [ + { + "name": "foo", + "version": "1.2.3", + "kind": "direct", + "constraint": "^1.0.0" + }, + { + "name": "transitive", + "version": "1.0.0", + "kind": "transitive", + "constraint": "null" + } + ] +} + +-------------------------------- END OF OUTPUT --------------------------------- + +## Section report +$ dependency_services report +{ + "dependencies": [ + { + "name": "foo", + "version": "1.2.3", + "kind": "direct", + "latest": "2.2.3", + "constraint": "^1.0.0", + "compatible": [], + "singleBreaking": [ + { + "name": "foo", + "version": "2.2.3", + "kind": "direct", + "constraint": "^2.2.3", + "previousVersion": "1.2.3", + "previousConstraint": "^1.0.0" + }, + { + "name": "transitive", + "version": null, + "kind": "transitive", + "constraint": null, + "previousVersion": "1.0.0", + "previousConstraint": null + } + ], + "multiBreaking": [ + { + "name": "foo", + "version": "2.2.3", + "kind": "direct", + "constraint": "^2.2.3", + "previousVersion": "1.2.3", + "previousConstraint": "^1.0.0" + }, + { + "name": "transitive", + "version": null, + "kind": "transitive", + "constraint": null, + "previousVersion": "1.0.0", + "previousConstraint": null + } + ] + }, + { + "name": "transitive", + "version": "1.0.0", + "kind": "transitive", + "latest": "1.0.0", + "constraint": null, + "compatible": [], + "singleBreaking": [], + "multiBreaking": [] + } + ] +} + +-------------------------------- END OF OUTPUT --------------------------------- + +## Section apply +$ echo '{"dependencyChanges":[{"name":"foo","version":"2.2.3"},{"name":"transitive","version":null}]}' | dependency_services apply +{"dependencies":[]} + +-------------------------------- END OF OUTPUT --------------------------------- + +$ cat pubspec.yaml +{"name":"app","dependencies":{"foo":^2.2.3},"environment":{"sdk":">=0.1.2 <1.0.0"}} +$ cat pubspec.lock +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + foo: + dependency: "direct main" + description: + name: foo + url: "http://localhost:$PORT" + source: hosted + version: "2.2.3" +sdks: + dart: ">=0.1.2 <1.0.0" diff --git a/test/testdata/goldens/dependency_services/dependency_services_test/multibreaking.txt b/test/testdata/goldens/dependency_services/dependency_services_test/multibreaking.txt new file mode 100644 index 000000000..1b3af2484 --- /dev/null +++ b/test/testdata/goldens/dependency_services/dependency_services_test/multibreaking.txt @@ -0,0 +1,156 @@ +# GENERATED BY: test/dependency_services/dependency_services_test.dart + +$ cat pubspec.yaml +{"name":"app","dependencies":{"foo":"^1.0.0","bar":"^1.0.0"},"environment":{"sdk":">=0.1.2 <1.0.0"}} +$ cat pubspec.lock +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + bar: + dependency: "direct main" + description: + name: bar + url: "http://localhost:$PORT" + source: hosted + version: "1.0.0" + foo: + dependency: "direct main" + description: + name: foo + url: "http://localhost:$PORT" + source: hosted + version: "1.0.0" +sdks: + dart: ">=0.1.2 <1.0.0" +-------------------------------- END OF OUTPUT --------------------------------- + +## Section list +$ dependency_services list +{ + "dependencies": [ + { + "name": "bar", + "version": "1.0.0", + "kind": "direct", + "constraint": "^1.0.0" + }, + { + "name": "foo", + "version": "1.0.0", + "kind": "direct", + "constraint": "^1.0.0" + } + ] +} + +-------------------------------- END OF OUTPUT --------------------------------- + +## Section report +$ dependency_services report +{ + "dependencies": [ + { + "name": "bar", + "version": "1.0.0", + "kind": "direct", + "latest": "2.0.0", + "constraint": "^1.0.0", + "compatible": [], + "singleBreaking": [], + "multiBreaking": [ + { + "name": "bar", + "version": "2.0.0", + "kind": "direct", + "constraint": "^2.0.0", + "previousVersion": "1.0.0", + "previousConstraint": "^1.0.0" + }, + { + "name": "foo", + "version": "3.0.1", + "kind": "direct", + "constraint": "^3.0.1", + "previousVersion": "1.0.0", + "previousConstraint": "^1.0.0" + } + ] + }, + { + "name": "foo", + "version": "1.0.0", + "kind": "direct", + "latest": "3.0.1", + "constraint": "^1.0.0", + "compatible": [ + { + "name": "foo", + "version": "1.5.0", + "kind": "direct", + "constraint": "^1.0.0", + "previousVersion": "1.0.0", + "previousConstraint": "^1.0.0" + } + ], + "singleBreaking": [ + { + "name": "foo", + "version": "2.0.0", + "kind": "direct", + "constraint": "^2.0.0", + "previousVersion": "1.0.0", + "previousConstraint": "^1.0.0" + } + ], + "multiBreaking": [ + { + "name": "foo", + "version": "3.0.1", + "kind": "direct", + "constraint": "^3.0.1", + "previousVersion": "1.0.0", + "previousConstraint": "^1.0.0" + }, + { + "name": "bar", + "version": "2.0.0", + "kind": "direct", + "constraint": "^2.0.0", + "previousVersion": "1.0.0", + "previousConstraint": "^1.0.0" + } + ] + } + ] +} + +-------------------------------- END OF OUTPUT --------------------------------- + +## Section apply +$ echo '{"dependencyChanges":[{"name":"foo","version":"3.0.1","constraint":"^3.0.0"},{"name":"bar","version":"2.0.0"}]}' | dependency_services apply +{"dependencies":[]} + +-------------------------------- END OF OUTPUT --------------------------------- + +$ cat pubspec.yaml +{"name":"app","dependencies":{"foo":^3.0.0,"bar":^2.0.0},"environment":{"sdk":">=0.1.2 <1.0.0"}} +$ cat pubspec.lock +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + bar: + dependency: "direct main" + description: + name: bar + url: "http://localhost:$PORT" + source: hosted + version: "2.0.0" + foo: + dependency: "direct main" + description: + name: foo + url: "http://localhost:$PORT" + source: hosted + version: "3.0.1" +sdks: + dart: ">=0.1.2 <1.0.0" diff --git a/test/testdata/goldens/directory_option_test/commands taking a --directory~-C parameter work.txt b/test/testdata/goldens/directory_option_test/commands taking a --directory~-C parameter work.txt index 237b3b579..d80d98443 100644 --- a/test/testdata/goldens/directory_option_test/commands taking a --directory~-C parameter work.txt +++ b/test/testdata/goldens/directory_option_test/commands taking a --directory~-C parameter work.txt @@ -17,7 +17,7 @@ Changed 1 dependency in myapp! -------------------------------- END OF OUTPUT --------------------------------- ## Section 2 -$ pub -C myapp/example get --directory=myapp bar +$ pub -C 'myapp/example' get --directory=myapp bar Resolving dependencies in myapp... Got dependencies in myapp! @@ -40,7 +40,7 @@ Got dependencies in myapp! -------------------------------- END OF OUTPUT --------------------------------- ## Section 5 -$ pub get bar -C myapp/example +$ pub get bar -C 'myapp/example' Resolving dependencies in myapp/example... + foo 1.0.0 + test_pkg 1.0.0 from path myapp @@ -49,7 +49,7 @@ Changed 2 dependencies in myapp/example! -------------------------------- END OF OUTPUT --------------------------------- ## Section 6 -$ pub get bar -C myapp/example2 +$ pub get bar -C 'myapp/example2' Resolving dependencies in myapp/example2... [STDERR] Error on line 1, column 9 of myapp/pubspec.yaml: "name" field doesn't match expected name "myapp". [STDERR] ╷ @@ -61,7 +61,7 @@ Resolving dependencies in myapp/example2... -------------------------------- END OF OUTPUT --------------------------------- ## Section 7 -$ pub get bar -C myapp/broken_dir +$ pub get bar -C 'myapp/broken_dir' [STDERR] Could not find a file named "pubspec.yaml" in "$SANDBOX/myapp/broken_dir". [EXIT CODE] 66 @@ -84,7 +84,7 @@ No dependencies changed in myapp. -------------------------------- END OF OUTPUT --------------------------------- ## Section 10 -$ pub run -C myapp bin/app.dart +$ pub run -C myapp 'bin/app.dart' Building package executable... Built test_pkg:app. Hi