Files
sdk/pkg/dartdev/test/native_assets/install_test.dart
T
Daco Harkes 94894b1796 [dartdev] dart install
This CL adds three new commands to `dart`:

```
Global
  install     Install a Dart CLI tool for global use.
  installed   List globally installed Dart CLI tools.
  uninstall   Remove a globally installed Dart CLI tool.
```

These commands are intended to replace `dart pub global` subcommands
while adding support for `hook/build.dart` and building packages in
AOT instead of running them with JIT.

Internal design doc: http://go/dart-install-cli.

Implementation details:
* The source of truth is the state of the file system. These commands
  write and read directories, files, and symlinks.
* App bundles and symlinks are placed in `DART_DATA_HOME` as per
  http://go/dart-data-home.
* On Unix systems we use symlinks and on Windows batchfiles to place
  executables in the bin directory that point to an application
  bundle. (These OS differences have been encapsulated in a single
  class.)
* On Windows, when an application is running, trying to re-install it
  will fail.

Test coverage:
* Installing from hosted, git, and local paths.
* Installing a package with hooks.
* Installing a package with hooks and user-defines.
* Surfacing build hook failures during install.
* Installing packages with conflicting executables names. This tests
  `--overwrite` flag behavior.
* Installing a new or the same version, this should simply succeed.
* A warning is shown if the bin directory is not on the `PATH`.
* Running an installed app reports the correct exit code on exit.
* Listing all installed versions, including the versions not on
  the`PATH`.
* Uninstalling, which uninstalls all versions.
* Re-installing while it is running.
* Uninstalling while it is running.

Out of scope for initial version:
* Saving the SDK version (to display in `dart installed`).
* Short-circuiting if re-installing an exactly installed version.

Bug: https://github.com/dart-lang/sdk/issues/60889
Change-Id: I8f3a60d26e013957ce6fd7f52e564bcaaff30509
Cq-Include-Trybots: luci.dart.try:pkg-linux-debug-try,pkg-linux-release-arm64-try,pkg-linux-release-try,pkg-mac-release-arm64-try,pkg-mac-release-try,pkg-win-release-arm64-try,pkg-win-release-try
Reviewed-on: https://dart-review.googlesource.com/c/sdk/+/441581
Reviewed-by: Sigurd Meldgaard <sigurdm@google.com>
Commit-Queue: Daco Harkes <dacoharkes@google.com>
Reviewed-by: Michael Goderbauer <goderbauer@google.com>
2025-08-13 01:33:59 -07:00

851 lines
25 KiB
Dart

// Copyright (c) 2025, 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:meta/meta.dart';
import 'package:pub_formats/pub_formats.dart';
import 'package:test/test.dart';
import '../utils.dart';
import 'helpers.dart';
/// A package in the Dart SDK that we use for testing.
///
/// This package is on pub.dev, and on a Git repo, and on disk. So, this can be
/// used for testing hosted, git, and path installs.
///
/// Moreover it has an executables section in the pubspec.
const _packageForTest = 'vm_snapshot_analysis';
/// A valid version for [_packageForTest].
///
/// Not the newest version.
const _packageVersion = '0.7.5';
/// The name of an executable in [_packageForTest].
const _cliToolForTest = 'snapshot_analysis';
final _pathEnvVarSeparator = Platform.isWindows ? ';' : ':';
/// A package not in the Dart SDK repo. The Dart SDK repo takes too long to
/// clone.
const _gitPackageForTest = 'dart_app';
final _gitHttpsUrl = Uri.parse('https://dart.googlesource.com/native');
const _gitPath = 'pkgs/hooks_runner/test_data/dart_app/';
const _gitRef = '8ce789991cddb8864b0f3fa210c83d3d10e78316';
const String _dartDirectoryEnvKey = 'DART_DATA_HOME';
final _dartDevEntryScriptUri = resolveDartDevUri('bin/dartdev.dart');
final _sdkUri = resolveDartDevUri('.').resolve('../../');
final _packageRelativePath = Uri.directory('pkg/vm_snapshot_analysis/');
final _packageDir = Directory.fromUri(
_sdkUri.resolveUri(_packageRelativePath),
);
void main([List<String> args = const []]) async {
if (!nativeAssetsExperimentAvailableOnCurrentChannel) {
return;
}
final bool fromDartdevSource = args.contains('--source');
final errorExitCode = fromDartdevSource
? /* Dartdev doesn't exit the process, it sends a message to the VM.*/ 0
: 255;
final argsFiltered = args.where((e) => e != '--source').toList();
final testName = argsFiltered.isEmpty ? null : argsFiltered.join(' ');
@isTest
void skippableTest(
String description,
dynamic Function() body, {
Timeout? timeout,
}) {
test(
description,
skip: !(testName == null || description.contains(testName)),
timeout: timeout,
body,
);
}
final commandsHelpmessages = [
(
'install',
'''
Install or upgrade a Dart CLI tool for global use.
Install all executables specified in a package's pubspec.yaml executables
section (https://dart.dev/tools/pub/pubspec#executables) on the PATH. If the
executables section doesn't exist, installs all `bin/*.dart` entry points as
executables.
If the same package has been previously installed, it will be overwritten.
You can specify three different values for the <package> argument:
1. A package name. This will install the package from pub.dev. (hosted)
The [version-constraint] argument can only be passed to 'hosted'.
2. A git url. This will install the package from a git repository. (git)
3. A path on your machine. This will install the package from that path. (path)
Usage: dart install <package> [version-constraint]
-h, --help Print this usage information.
--git-path Path of git package in repository. Only applies when using a git url for <package>.
--git-ref Git branch or commit to be retrieved. Only applies when using a git url for <package>.
--overwrite Overwrite executables from other packages with the same name.
-u, --hosted-url A custom pub server URL for the package. Only applies when using a package name for <package>.
Run "dart help" to see global options.
'''
),
(
'installed',
'''
List globally installed Dart CLI tools.
Usage: dart installed [arguments]
-h, --help Print this usage information.
-a, --[no-]all Also list packages which are currently not active.
Active package have executables on `PATH`.
App bundles of packages on disk which have no executables
on `PATH` are non-active.
Run "dart help" to see global options.
'''
),
(
'uninstall',
'''
Remove a globally installed Dart CLI tool.
Completely deletes all installed versions of <package> and all executables from
<package> placed on PATH.
Usage: dart uninstall <package>
-h, --help Print this usage information.
Run "dart help" to see global options.
'''
),
];
for (final (command, helpMessage) in commandsHelpmessages) {
skippableTest('dart $command --help', timeout: longTimeout, () async {
final result = await _runDartdev(
fromDartdevSource,
command,
['--help'],
null,
{},
);
expect(result.stdout, contains(helpMessage));
});
}
final argumentss = [
(
null,
[_packageForTest],
),
(
null,
[_packageForTest, _packageVersion],
),
(
null,
[_packageForTest, _packageVersion, '--hosted-url', 'https://pub.dev/'],
),
(
null,
[_packageDir.path],
),
(
_sdkUri,
[_packageRelativePath.path],
),
(
_packageDir.uri,
['.'],
),
];
for (final (workingDirectory, arguments) in argumentss) {
var testName = arguments.join(' ');
if (workingDirectory != null) {
testName += ' in ${workingDirectory.toFilePath()}';
}
skippableTest('dart install $testName', timeout: longTimeout, () async {
await inTempDir((tempUri) async {
final binDir = Directory.fromUri(tempUri.resolve('install/bin'));
final environment = {
_dartDirectoryEnvKey: tempUri.toFilePath(),
'PATH':
'${binDir.path}$_pathEnvVarSeparator${Platform.environment['PATH']!}',
};
await _runDartdev(
fromDartdevSource,
'install',
arguments,
workingDirectory,
environment,
);
await _runToolForTest(environment);
final installedResult = await _runDartdev(
fromDartdevSource,
'installed',
[],
null,
environment,
);
final installedLines = installedResult.stdout.split('\n');
expect(installedLines.where((e) => e.isNotEmpty).length, equals(1));
final installedLine = installedLines.first;
expect(
installedLine,
startsWith(_packageForTest),
);
if (arguments.contains(_packageVersion)) {
expect(
installedLine,
equals('$_packageForTest $_packageVersion'),
);
}
if (arguments.contains(_packageRelativePath.toString())) {
expect(
installedLine,
stringContainsInOrder([_packageRelativePath.toString(), '" at 20']),
);
}
await _runDartdev(
fromDartdevSource,
'uninstall',
[_packageForTest],
null,
environment,
);
});
});
}
final argumentssGit = [
(
null,
[_gitHttpsUrl.toString(), '--git-path', _gitPath],
),
(
null,
[_gitHttpsUrl.toString(), '--git-path', _gitPath, '--git-ref', _gitRef],
),
];
for (final (workingDirectory, arguments) in argumentssGit) {
var testName = arguments.join(' ');
if (workingDirectory != null) {
testName += ' in ${workingDirectory.toFilePath()}';
}
skippableTest('dart install $testName', timeout: longTimeout, () async {
await inTempDir((tempUri) async {
final binDir = Directory.fromUri(tempUri.resolve('install/bin'));
final environment = {
_dartDirectoryEnvKey: tempUri.toFilePath(),
'PATH':
'${binDir.path}$_pathEnvVarSeparator${Platform.environment['PATH']!}',
};
await _runDartdev(
fromDartdevSource,
'install',
arguments,
workingDirectory,
environment,
);
final installedResult = await _runDartdev(
fromDartdevSource,
'installed',
[],
null,
environment,
);
final installedLines = installedResult.stdout.split('\n');
expect(installedLines.where((e) => e.isNotEmpty).length, equals(1));
final installedLine = installedLines.first;
expect(
installedLine,
startsWith(_gitPackageForTest),
);
expect(
installedLine,
contains(' from Git repository "${_gitHttpsUrl.toString()}"'),
);
if (arguments.contains('--git-ref')) {
expect(
installedLine,
contains(' at "${_gitRef.substring(0, 8)}"'),
);
}
await _runDartdev(
fromDartdevSource,
'uninstall',
[_gitPackageForTest],
null,
environment,
);
});
});
}
skippableTest('dart install ~/.dart/install/bin/ not on PATH', () async {
await inTempDir((tempUri) async {
final environment = {
_dartDirectoryEnvKey: tempUri.toFilePath(),
};
await inTempDir((tempUri) async {
final installResult = await _runDartdev(
fromDartdevSource,
'install',
[_packageForTest],
null,
environment,
);
if (Platform.isWindows) {
expect(
installResult.stdout,
stringContainsInOrder([
'Warning: Dart installs executables into ',
'which is not on your path.',
"You can fix that by adding that directory to your system's ",
'"Path" environment variable.',
'A web search for "configure windows path" will show you how.',
]),
);
} else {
expect(
installResult.stdout,
stringContainsInOrder([
'Warning: Dart installs executables into',
'You can fix that by adding this to your shell\'s config file ',
'export PATH="\$PATH":',
]),
);
}
});
});
});
skippableTest('dart install dart_app (with build hooks and code assets)',
timeout: longTimeout, () async {
await inTempDir((tempUri) async {
final binDir = Directory.fromUri(tempUri.resolve('install/bin'));
final environment = {
_dartDirectoryEnvKey: tempUri.toFilePath(),
'PATH':
'${binDir.path}$_pathEnvVarSeparator${Platform.environment['PATH']!}',
};
await nativeAssetsTest('dart_app', (dartAppUri) async {
// Add a second executable.
final entryPoint1 =
File.fromUri(dartAppUri.resolve('bin/dart_app.dart'));
final entryPoint2 =
File.fromUri(dartAppUri.resolve('bin/dart_app_copy.dart'));
final entryPoint1Contents = await entryPoint1.readAsString();
final entryPoint2Contents = entryPoint1Contents.replaceAll('5', '42');
await entryPoint2.writeAsString(entryPoint2Contents);
final pubspecFile = File.fromUri(dartAppUri.resolve('pubspec.yaml'));
final pubspecOld =
pubspecFile.readAsStringSync().replaceAll('\r\n', '\n');
final pubspecNew = pubspecOld.replaceAll(
'''executables:
dart_app:'''
.replaceAll('\r\n', '\n'),
'''executables:
dart_app:
dart_app_copy:'''
.replaceAll('\r\n', '\n'),
);
expect(pubspecNew, isNot(equals(pubspecOld)));
pubspecFile.writeAsStringSync(pubspecNew);
await _runDartdev(
fromDartdevSource,
'install',
[dartAppUri.toFilePath()],
null,
environment,
);
for (final (tool, someInt) in [
('dart_app', 5),
('dart_app_copy', 42)
]) {
final toolResult = await runProcess(
// Note this has `runInShell: true` under it to ensure PATHEXT is used on
// Windows so that invoking an executable without extension works.
executable: Uri.file(tool),
// Run in some unrelated directory ensuring PATH is picked up.
workingDirectory: Directory.systemTemp.uri,
logger: logger,
environment: environment,
);
expect(
toolResult.stdout,
stringContainsInOrder([
'add($someInt, 6) = ${someInt + 6}',
'subtract($someInt, 6) = ${someInt - 6}',
]),
);
expect(toolResult.exitCode, 0);
}
});
});
});
skippableTest('dart install --overwrite', timeout: longTimeout, () async {
await inTempDir((tempUri) async {
final binDir = Directory.fromUri(tempUri.resolve('install/bin'));
final environment = {
_dartDirectoryEnvKey: tempUri.toFilePath(),
'PATH':
'${binDir.path}$_pathEnvVarSeparator${Platform.environment['PATH']!}',
};
await nativeAssetsTest('dart_app', (dartAppUri) async {
await _runDartdev(
fromDartdevSource,
'install',
[dartAppUri.toFilePath()],
null,
environment,
);
// Not overwriting, but the same package is fine.
await _runDartdev(
fromDartdevSource,
'install',
[dartAppUri.toFilePath()],
null,
environment,
);
final pubspecFile = File.fromUri(dartAppUri.resolve('pubspec.yaml'));
final pubspecContents = await pubspecFile.readAsString();
final pubspecContentsNew =
pubspecContents.replaceFirst('dart_app', 'a_different_name');
await pubspecFile.writeAsString(pubspecContentsNew);
// Trying to install an executable with the same name from a different
// package should fail.
await _runDartdev(
fromDartdevSource,
'install',
[dartAppUri.toFilePath()],
null,
environment,
expectedExitCode: errorExitCode,
);
// Overwriting is fine.
await _runDartdev(
fromDartdevSource,
'install',
[dartAppUri.toFilePath(), '--overwrite'],
null,
environment,
);
// Using --overwrite leads to inactive versions.
// `dart installed --all` should also report the non-active versions.
for (final all in [true, false]) {
final installedResult = await _runDartdev(
fromDartdevSource,
'installed',
[if (all) '--all'],
null,
environment,
);
final installedLines = installedResult.stdout
.split('\n')
.where((e) => e.isNotEmpty)
.toList();
if (all) {
expect(installedLines, hasLength(2));
expect(
installedLines,
contains(startsWith('dart_app')),
);
} else {
expect(installedLines, hasLength(1));
expect(
installedLines,
isNot(contains(startsWith('dart_app'))),
);
}
}
});
});
});
skippableTest('dart install check exit codes', timeout: longTimeout,
() async {
await inTempDir((tempUri) async {
final binDir = Directory.fromUri(tempUri.resolve('install/bin'));
final environment = {
_dartDirectoryEnvKey: tempUri.toFilePath(),
'PATH':
'${binDir.path}$_pathEnvVarSeparator${Platform.environment['PATH']!}',
};
const appName = 'test_app';
final dartAppUri = tempUri.resolve('$appName/');
final pubspec = File.fromUri(dartAppUri.resolve('pubspec.yaml'));
await pubspec.create(recursive: true);
await pubspec.writeAsString(jsonEncode(PubspecYamlFileSyntax(
name: appName,
environment: EnvironmentSyntax(
sdk: '^${Platform.version.split(' ').first}',
),
executables: {
appName: appName,
},
).json));
final mainFile = File.fromUri(dartAppUri.resolve('bin/$appName.dart'));
await mainFile.create(recursive: true);
mainFile.writeAsString('''
import 'dart:io';
void main(List<String> args) {
exit(int.parse(args.first));
}
''');
await _runDartdev(
fromDartdevSource,
'install',
[dartAppUri.toFilePath()],
null,
environment,
);
const testExitCode = 55;
final toolResult = await runProcess(
// Note this has `runInShell: true` under it to ensure PATHEXT is used on
// Windows so that invoking an executable without extension works.
executable: Uri.file(appName),
// Run in some unrelated directory ensuring PATH is picked up.
workingDirectory: Directory.systemTemp.uri,
arguments: ['$testExitCode'],
logger: logger,
environment: environment,
expectedExitCode: testExitCode,
);
expect(toolResult.exitCode, testExitCode);
});
});
skippableTest('dart install hooks user-defines and failures',
timeout: longTimeout, () async {
await inTempDir((tempUri) async {
final binDir = Directory.fromUri(tempUri.resolve('install/bin'));
final environment = {
_dartDirectoryEnvKey: tempUri.toFilePath(),
'PATH':
'${binDir.path}$_pathEnvVarSeparator${Platform.environment['PATH']!}',
};
const packageName = 'test_app';
final dartAppUri = tempUri.resolve('$packageName/');
final pubspec = File.fromUri(dartAppUri.resolve('pubspec.yaml'));
await pubspec.create(recursive: true);
final mainFile =
File.fromUri(dartAppUri.resolve('bin/$packageName.dart'));
await mainFile.create(recursive: true);
mainFile.writeAsString('''
void main(List<String> args) { }
''');
final buildHookFile = File.fromUri(dartAppUri.resolve('hook/build.dart'));
await buildHookFile.create(recursive: true);
buildHookFile.writeAsString('''
import 'package:hooks/hooks.dart';
void main(List<String> args) async {
await build(args, (input, output) async {
final myUserDefine = input.userDefines['my_user_define'];
if (myUserDefine == null) {
throw Exception('Expected a user define');
}
});
}
''');
for (final addUserDefine in [true, false]) {
await pubspec.writeAsString(jsonEncode(PubspecYamlFileSyntax(
name: packageName,
environment: EnvironmentSyntax(
sdk: '^${Platform.version.split(' ').first}',
),
executables: {
packageName: packageName,
},
dependencies: {
'hooks': PathDependencySourceSyntax(
path$: sdkRootUri
.resolve('third_party/pkg/native/pkgs/hooks/')
.toFilePath(),
),
},
hooks: HooksSyntax(
userDefines: {
packageName: {
if (addUserDefine) 'my_user_define': 'a_value,',
},
},
),
).json));
final installResult = await _runDartdev(
fromDartdevSource,
'install',
[dartAppUri.toFilePath()],
null,
environment,
expectedExitCode: addUserDefine ? 0 : errorExitCode,
);
if (addUserDefine) {
expect(installResult.exitCode, equals(0));
expect(installResult.stderr, isEmpty);
} else {
// Check that build hook failures are surfaced and that error messages
// are visible.
expect(installResult.exitCode, equals(errorExitCode));
expect(installResult.stderr, contains('Expected a user define'));
}
}
});
});
skippableTest('dart install uninstalls old versions', timeout: longTimeout,
() async {
await inTempDir((tempUri) async {
final binDir = Directory.fromUri(tempUri.resolve('install/bin'));
final environment = {
_dartDirectoryEnvKey: tempUri.toFilePath(),
'PATH':
'${binDir.path}$_pathEnvVarSeparator${Platform.environment['PATH']!}',
};
// Install two versions.
await _runDartdev(
fromDartdevSource,
'install',
['.'],
_packageDir.uri,
environment,
);
final installResult = await _runDartdev(
fromDartdevSource,
'install',
[_packageForTest, _packageVersion],
null,
environment,
);
expect(
installResult.stdout,
stringContainsInOrder(['Uninstalling ', _packageForTest]),
);
// `--all` should also report the non-active versions.
Future<List<String>> runInstalled() async {
final installedResult = await _runDartdev(
fromDartdevSource,
'installed',
['--all'],
null,
environment,
);
final installedLines = installedResult.stdout
.split('\n')
.where((e) => e.isNotEmpty)
.toList();
return installedLines;
}
expect(await runInstalled(), hasLength(1));
// `uninstall` uninstalls all versions.
await _runDartdev(
fromDartdevSource,
'uninstall',
[_packageForTest],
null,
environment,
);
expect(await runInstalled(), hasLength(0));
});
});
skippableTest('dart uninstall', timeout: longTimeout, () async {
await inTempDir((tempUri) async {
final environment = {
_dartDirectoryEnvKey: tempUri.toFilePath(),
};
// `uninstall` should have a non-zero exit if nothing was uninstalled.
await _runDartdev(
fromDartdevSource,
'uninstall',
[_packageForTest],
null,
environment,
expectedExitCode: errorExitCode,
);
});
});
skippableTest('dart uninstall while running', timeout: longTimeout, () async {
await inTempDir((tempUri) async {
final binDir = Directory.fromUri(tempUri.resolve('install/bin'));
final environment = {
_dartDirectoryEnvKey: tempUri.toFilePath(),
'PATH':
'${binDir.path}$_pathEnvVarSeparator${Platform.environment['PATH']!}',
};
const packageName = 'test_app';
final dartAppUri = tempUri.resolve('$packageName/');
final pubspec = File.fromUri(dartAppUri.resolve('pubspec.yaml'));
await pubspec.create(recursive: true);
await pubspec.writeAsString(jsonEncode(PubspecYamlFileSyntax(
name: packageName,
environment: EnvironmentSyntax(
sdk: '^${Platform.version.split(' ').first}',
),
executables: {
packageName: packageName,
},
).json));
final mainFile =
File.fromUri(dartAppUri.resolve('bin/$packageName.dart'));
await mainFile.create(recursive: true);
mainFile.writeAsString('''
void main(List<String> args) async {
await Future.delayed(Duration(days: 1000000));
}
''');
Future<RunProcessResult> doInstall(int expectedExitCode) async {
return await _runDartdev(fromDartdevSource, 'install',
[dartAppUri.toFilePath()], null, environment,
expectedExitCode: expectedExitCode);
}
await doInstall(0);
final runningProcess = await Process.start(
packageName,
[],
environment: environment,
runInShell: true,
);
final installWhileRunningResult = await doInstall(
Platform.isWindows ? errorExitCode : 0,
);
if (Platform.isWindows) {
expect(
installWhileRunningResult.stderr,
contains('The application might be in use.'),
);
} else {
expect(installWhileRunningResult.stderr, isEmpty);
}
final uninstallWhileRunningResult = await _runDartdev(
fromDartdevSource,
'uninstall',
[packageName],
null,
environment,
expectedExitCode: Platform.isWindows ? errorExitCode : 0,
);
if (Platform.isWindows) {
expect(
uninstallWhileRunningResult.stderr,
contains('The application might be in use.'),
);
} else {
expect(uninstallWhileRunningResult.stderr, isEmpty);
}
runningProcess.kill();
});
});
}
Future<RunProcessResult> _runDartdev(
bool fromDartdevSource,
String command,
List<String> arguments,
Uri? workingDirectory,
Map<String, String> environment, {
int expectedExitCode = 0,
}) async {
final installResult = await runDart(
arguments: [
if (fromDartdevSource) _dartDevEntryScriptUri.toFilePath(),
command,
...arguments,
],
workingDirectory: workingDirectory,
logger: logger,
environment: environment,
expectExitCodeZero: false,
);
expect(installResult.exitCode, equals(expectedExitCode));
return installResult;
}
/// Runs [_cliToolForTest] and expects the help message.
Future<RunProcessResult> _runToolForTest(
Map<String, String> environment,
) async {
final toolResult = await runProcess(
// Note this has `runInShell: true` under it to ensure PATHEXT is used on
// Windows so that invoking an executable without extension works.
executable: Uri.file(_cliToolForTest),
arguments: ['--help'],
// Run in some unrelated directory ensuring PATH is picked up.
workingDirectory: Directory.systemTemp.uri,
logger: logger,
environment: environment,
);
expect(
toolResult.stdout,
stringContainsInOrder([
'Tools for binary size analysis of Dart VM AOT snapshots.',
]),
);
expect(toolResult.exitCode, 0);
return toolResult;
}