| // Copyright (c) 2024, 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. |
| |
| // NOTE: this file was originally copied from package:vm_service. |
| |
| // ignore_for_file: constant_identifier_names |
| |
| import 'dart:async'; |
| import 'dart:convert'; |
| import 'dart:io' as io; |
| import 'dart:isolate' as isolate; |
| |
| import 'package:test/test.dart'; |
| import 'package:vm_service/vm_service.dart'; |
| import 'package:vm_service/vm_service_io.dart'; |
| |
| import 'service_test_common.dart'; |
| |
| export 'service_test_common.dart' show IsolateTest, VMTest; |
| |
| /// The extra arguments to use |
| const List<String> extraDebuggingArgs = []; |
| |
| /// Will be set to the http address of the VM's service protocol before |
| /// any tests are invoked. |
| late String serviceHttpAddress; |
| late String serviceWebsocketAddress; |
| |
| const String _TESTEE_ENV_KEY = 'SERVICE_TEST_TESTEE'; |
| const Map<String, String> _TESTEE_SPAWN_ENV = {_TESTEE_ENV_KEY: 'true'}; |
| |
| late Uri remoteVmServiceUri; |
| |
| /// Resolves a path as if it was relative to the `test` dir in this package. |
| Uri resolveTestRelativePath(String relativePath) => |
| isolate.Isolate.resolvePackageUriSync(Uri.parse('package:dds/'))! |
| .resolve('../test/$relativePath'); |
| |
| Future<io.Process> spawnDartProcess( |
| String script, { |
| bool serveObservatory = true, |
| bool pauseOnStart = true, |
| bool disableServiceAuthCodes = false, |
| bool subscribeToStdio = true, |
| }) async { |
| final executable = io.Platform.executable; |
| final tmpDir = await io.Directory.systemTemp.createTemp('dart_service'); |
| final serviceInfoUri = tmpDir.uri.resolve('service_info.json'); |
| final serviceInfoFile = await io.File.fromUri(serviceInfoUri).create(); |
| |
| final arguments = [ |
| '--no-dds', |
| '--observe=0', |
| if (!serveObservatory) '--no-serve-observatory', |
| if (pauseOnStart) '--pause-isolates-on-start', |
| if (disableServiceAuthCodes) '--disable-service-auth-codes', |
| '--write-service-info=$serviceInfoUri', |
| ...io.Platform.executableArguments, |
| resolveTestRelativePath(script).toFilePath(), |
| ]; |
| final process = await io.Process.start(executable, arguments); |
| if (subscribeToStdio) { |
| process.stdout |
| .transform(utf8.decoder) |
| .listen((line) => print('TESTEE OUT: $line')); |
| process.stderr |
| .transform(utf8.decoder) |
| .listen((line) => print('TESTEE ERR: $line')); |
| } |
| while ((await serviceInfoFile.length()) <= 5) { |
| await Future.delayed(const Duration(milliseconds: 50)); |
| } |
| final content = await serviceInfoFile.readAsString(); |
| final infoJson = json.decode(content); |
| remoteVmServiceUri = Uri.parse(infoJson['uri']); |
| return process; |
| } |
| |
| Future<void> executeUntilNextPause(VmService service) async { |
| final vm = await service.getVM(); |
| final isolate = await service.getIsolate(vm.isolates!.first.id!); |
| |
| final completer = Completer<void>(); |
| late StreamSubscription sub; |
| sub = service.onDebugEvent.listen((event) async { |
| if (event.kind == EventKind.kPauseBreakpoint) { |
| completer.complete(); |
| await sub.cancel(); |
| } |
| }); |
| await service.streamListen(EventStreams.kDebug); |
| await service.resume(isolate.id!); |
| await completer.future; |
| } |
| |
| /// Returns the resolved URI to the pre-built devtools app. |
| Uri devtoolsAppUri() { |
| return resolveTestRelativePath('../../../third_party/devtools/web'); |
| } |
| |
| bool _isTestee() { |
| return io.Platform.environment.containsKey(_TESTEE_ENV_KEY); |
| } |
| |
| Uri _getTestUri(String script) { |
| if (io.Platform.script.isScheme('data')) { |
| // If running from pub we can assume that we're in the root of the package |
| // directory. |
| return Uri.parse('test/$script'); |
| } else if (io.Platform.script.toFilePath().endsWith('out.aotsnapshot')) { |
| // We're running an AOT test. In this case, we need to use the exact URI we |
| // launched with. |
| return io.Platform.script; |
| } else { |
| // Resolve the script to ensure that test will fail if the provided script |
| // name doesn't match the actual script. |
| return resolveTestRelativePath(script); |
| } |
| } |
| |
| class _ServiceTesteeRunner { |
| Future<void> run({ |
| Function()? testeeBefore, |
| Function()? testeeConcurrent, |
| bool pauseOnStart = false, |
| bool pauseOnExit = false, |
| }) async { |
| if (!pauseOnStart) { |
| if (testeeBefore != null) { |
| final result = testeeBefore(); |
| if (result is Future) { |
| await result; |
| } |
| } |
| print(''); // Print blank line to signal that testeeBefore has run. |
| } |
| if (testeeConcurrent != null) { |
| final result = testeeConcurrent(); |
| if (result is Future) { |
| await result; |
| } |
| } |
| if (!pauseOnExit) { |
| // Wait around for the process to be killed. |
| await io.stdin.first.then((_) => io.exit(0)); |
| } |
| } |
| |
| void runSync({ |
| void Function()? testeeBeforeSync, |
| void Function()? testeeConcurrentSync, |
| bool pauseOnStart = false, |
| bool pauseOnExit = false, |
| }) { |
| if (!pauseOnStart) { |
| if (testeeBeforeSync != null) { |
| testeeBeforeSync(); |
| } |
| print(''); // Print blank line to signal that testeeBefore has run. |
| } |
| if (testeeConcurrentSync != null) { |
| testeeConcurrentSync(); |
| } |
| if (!pauseOnExit) { |
| // Wait around for the process to be killed. |
| io.stdin.first.then((_) => io.exit(0)); |
| } |
| } |
| } |
| |
| class _ServiceTesteeLauncher { |
| io.Process? process; |
| List<String> args; |
| |
| bool killedByTester = false; |
| final _exitCodeCompleter = Completer<int>(); |
| |
| _ServiceTesteeLauncher(String script) |
| : args = [_getTestUri(script).toFilePath()]; |
| |
| Future<int> get exitCode => _exitCodeCompleter.future; |
| |
| // Spawn the testee process. |
| Future<io.Process> _spawnProcess( |
| bool pauseOnStart, |
| bool pauseOnExit, |
| bool pauseOnUnhandledExceptions, |
| bool testeeControlsServer, |
| bool useAuthToken, |
| List<String>? experiments, |
| List<String>? extraArgs, |
| ) { |
| return _spawnDartProcess( |
| pauseOnStart, |
| pauseOnExit, |
| pauseOnUnhandledExceptions, |
| testeeControlsServer, |
| useAuthToken, |
| experiments, |
| extraArgs, |
| ); |
| } |
| |
| Future<io.Process> _spawnDartProcess( |
| bool pauseOnStart, |
| bool pauseOnExit, |
| bool pauseOnUnhandledExceptions, |
| bool testeeControlsServer, |
| bool useAuthToken, |
| List<String>? experiments, |
| List<String>? extraArgs, |
| ) { |
| final String dartExecutable = io.Platform.executable; |
| |
| final fullArgs = <String>[]; |
| if (pauseOnStart) { |
| fullArgs.add('--pause-isolates-on-start'); |
| } |
| if (pauseOnExit) { |
| fullArgs.add('--pause-isolates-on-exit'); |
| } |
| if (!useAuthToken) { |
| fullArgs.add('--disable-service-auth-codes'); |
| } |
| if (pauseOnUnhandledExceptions) { |
| fullArgs.add('--pause-isolates-on-unhandled-exceptions'); |
| } |
| fullArgs.add('--profiler'); |
| if (experiments != null) { |
| fullArgs.addAll(experiments.map((e) => '--enable-experiment=$e')); |
| } |
| if (extraArgs != null) { |
| fullArgs.addAll(extraArgs); |
| } |
| |
| fullArgs.addAll(io.Platform.executableArguments); |
| if (!testeeControlsServer) { |
| fullArgs.add('--enable-vm-service:0'); |
| } |
| fullArgs.addAll(args); |
| return _spawnCommon(dartExecutable, fullArgs, <String, String>{}); |
| } |
| |
| Future<io.Process> _spawnCommon( |
| String executable, |
| List<String> arguments, |
| Map<String, String> dartEnvironment, |
| ) { |
| final environment = _TESTEE_SPAWN_ENV; |
| final bashEnvironment = StringBuffer(); |
| environment.forEach((k, v) => bashEnvironment.write('$k=$v ')); |
| dartEnvironment.forEach((k, v) { |
| arguments.insert(0, '-D$k=$v'); |
| }); |
| print('** Launching $bashEnvironment$executable ${arguments.join(' ')}'); |
| return io.Process.start( |
| executable, |
| arguments, |
| environment: environment, |
| ); |
| } |
| |
| Future<Uri> launch( |
| bool pauseOnStart, |
| bool pauseOnExit, |
| bool pauseOnUnhandledExceptions, |
| bool testeeControlsServer, |
| bool useAuthToken, |
| List<String>? experiments, |
| List<String>? extraArgs, |
| ) { |
| return _spawnProcess( |
| pauseOnStart, |
| pauseOnExit, |
| pauseOnUnhandledExceptions, |
| testeeControlsServer, |
| useAuthToken, |
| experiments, |
| extraArgs, |
| ).then((p) { |
| final Completer<Uri> completer = Completer<Uri>(); |
| process = p; |
| Uri? uri; |
| bool blank = false; |
| var first = true; |
| process!.stdout |
| .transform(utf8.decoder) |
| .transform(LineSplitter()) |
| .listen((line) { |
| const kDartVMServiceListening = 'The Dart VM service is listening on '; |
| if (line.startsWith(kDartVMServiceListening)) { |
| uri = Uri.parse(line.substring(kDartVMServiceListening.length)); |
| } |
| if (pauseOnStart || line == '') { |
| // Received blank line. |
| blank = true; |
| } |
| if ((uri != null) && (blank == true) && (first == true)) { |
| completer.complete(uri!); |
| // Stop repeat completions. |
| first = false; |
| print('** Signaled to run test queries on $uri'); |
| } |
| io.stdout.write('>testee>out> $line\n'); |
| }); |
| process!.stderr |
| .transform(utf8.decoder) |
| .transform(LineSplitter()) |
| .listen((line) { |
| io.stdout.write('>testee>err> $line\n'); |
| }); |
| process!.exitCode.then(_exitCodeCompleter.complete); |
| return completer.future; |
| }); |
| } |
| |
| void requestExit() { |
| if (process != null) { |
| print('** Killing script'); |
| if (process!.kill()) { |
| killedByTester = true; |
| } |
| } |
| } |
| } |
| |
| void setupAddresses(Uri /*!*/ serverAddress) { |
| serviceWebsocketAddress = |
| 'ws://${serverAddress.authority}${serverAddress.path}ws'; |
| serviceHttpAddress = 'http://${serverAddress.authority}${serverAddress.path}'; |
| } |
| |
| class _ServiceTesterRunner { |
| Future<void> run({ |
| List<String>? mainArgs, |
| List<String>? extraArgs, |
| List<String>? experiments, |
| List<VMTest>? vmTests, |
| List<IsolateTest>? isolateTests, |
| required String scriptName, |
| bool pauseOnStart = false, |
| bool pauseOnExit = false, |
| bool verboseVm = false, |
| bool pauseOnUnhandledExceptions = false, |
| bool testeeControlsServer = false, |
| bool useAuthToken = false, |
| bool allowForNonZeroExitCode = false, |
| VmServiceFactory serviceFactory = VmService.defaultFactory, |
| }) async { |
| final process = _ServiceTesteeLauncher(scriptName); |
| late VmService vm; |
| late IsolateRef isolate; |
| setUp(() async { |
| await process |
| .launch( |
| pauseOnStart, |
| pauseOnExit, |
| pauseOnUnhandledExceptions, |
| testeeControlsServer, |
| useAuthToken, |
| experiments, |
| extraArgs, |
| ) |
| .then((Uri serverAddress) async { |
| if (mainArgs!.contains('--gdb')) { |
| final pid = process.process!.pid; |
| final wait = Duration(seconds: 10); |
| print('Testee has pid $pid, waiting $wait before continuing'); |
| io.sleep(wait); |
| } |
| setupAddresses(serverAddress); |
| vm = await vmServiceConnectUriWithFactory( |
| serviceWebsocketAddress, |
| vmServiceFactory: serviceFactory, |
| ); |
| print('Done loading VM'); |
| isolate = await getFirstIsolate(vm); |
| }); |
| }); |
| |
| final name = _getTestUri(scriptName).pathSegments.last; |
| |
| test( |
| name, |
| () async { |
| // Run vm tests. |
| if (vmTests != null) { |
| var testIndex = 1; |
| final totalTests = vmTests.length; |
| for (var t in vmTests) { |
| print('$name [$testIndex/$totalTests]'); |
| await t(vm); |
| testIndex++; |
| } |
| } |
| |
| // Run isolate tests. |
| if (isolateTests != null) { |
| var testIndex = 1; |
| final totalTests = isolateTests.length; |
| for (var t in isolateTests) { |
| print('$name [$testIndex/$totalTests]'); |
| await t(vm, isolate); |
| testIndex++; |
| } |
| } |
| }, |
| retry: 0, |
| timeout: Timeout.none, |
| ); |
| |
| tearDown(() { |
| print('All service tests completed successfully.'); |
| process.requestExit(); |
| }); |
| |
| final exitCode = await process.exitCode; |
| if (exitCode != 0) { |
| if (!(process.killedByTester || allowForNonZeroExitCode)) { |
| throw 'Testee exited with unexpected exitCode: $exitCode'; |
| } |
| } |
| print('** Process exited: $exitCode'); |
| } |
| |
| Future<IsolateRef> getFirstIsolate(VmService service) async { |
| var vm = await service.getVM(); |
| final vmIsolates = vm.isolates!; |
| if (vmIsolates.isNotEmpty) { |
| return vmIsolates.first; |
| } |
| Completer<dynamic>? completer = Completer(); |
| late StreamSubscription subscription; |
| subscription = service.onIsolateEvent.listen((Event event) async { |
| if (completer == null) { |
| await subscription.cancel(); |
| return; |
| } |
| if (event.kind == EventKind.kIsolateRunnable) { |
| vm = await service.getVM(); |
| await subscription.cancel(); |
| await service.streamCancel(EventStreams.kIsolate); |
| completer!.complete(event.isolate!); |
| completer = null; |
| } |
| }); |
| await service.streamListen(EventStreams.kIsolate); |
| |
| // The isolate may have started before we subscribed. |
| vm = await service.getVM(); |
| if (vmIsolates.isNotEmpty) { |
| await subscription.cancel(); |
| completer!.complete(vmIsolates.first); |
| completer = null; |
| } |
| return (await completer!.future) as IsolateRef; |
| } |
| } |
| |
| /// Runs [tests] in sequence, each of which should take an [Isolate] and |
| /// return a [Future]. Code for setting up state can run before and/or |
| /// concurrently with the tests. Uses [mainArgs] to determine whether |
| /// to run tests or testee in this invocation of the script. |
| Future<void> runIsolateTests( |
| List<String> mainArgs, |
| List<IsolateTest> tests, |
| String scriptName, { |
| Function()? testeeBefore, |
| Function()? testeeConcurrent, |
| bool pauseOnStart = false, |
| bool pauseOnExit = false, |
| bool verboseVm = false, |
| bool pauseOnUnhandledExceptions = false, |
| bool testeeControlsServer = false, |
| bool useAuthToken = false, |
| bool allowForNonZeroExitCode = false, |
| List<String>? experiments, |
| List<String>? extraArgs, |
| }) async { |
| assert(!pauseOnStart || testeeBefore == null); |
| if (_isTestee()) { |
| await _ServiceTesteeRunner().run( |
| testeeBefore: testeeBefore, |
| testeeConcurrent: testeeConcurrent, |
| pauseOnStart: pauseOnStart, |
| pauseOnExit: pauseOnExit, |
| ); |
| } else { |
| await _ServiceTesterRunner().run( |
| mainArgs: mainArgs, |
| scriptName: scriptName, |
| extraArgs: extraArgs, |
| isolateTests: tests, |
| pauseOnStart: pauseOnStart, |
| pauseOnExit: pauseOnExit, |
| verboseVm: verboseVm, |
| experiments: experiments, |
| pauseOnUnhandledExceptions: pauseOnUnhandledExceptions, |
| testeeControlsServer: testeeControlsServer, |
| useAuthToken: useAuthToken, |
| allowForNonZeroExitCode: allowForNonZeroExitCode, |
| ); |
| } |
| } |
| |
| /// Runs [tests] in sequence, each of which should take an [Isolate] and |
| /// return a [Future]. Code for setting up state can run before and/or |
| /// concurrently with the tests. Uses [mainArgs] to determine whether |
| /// to run tests or testee in this invocation of the script. |
| /// |
| /// This is a special version of this test harness specifically for the |
| /// pause_on_unhandled_exceptions_test, which cannot properly function |
| /// in an async context (because exceptions are *always* handled in async |
| /// functions). |
| void runIsolateTestsSynchronous( |
| List<String> mainArgs, |
| List<IsolateTest> tests, |
| String scriptName, { |
| void Function()? testeeBefore, |
| void Function()? testeeConcurrent, |
| bool pauseOnStart = false, |
| bool pauseOnExit = false, |
| bool verboseVm = false, |
| bool pauseOnUnhandledExceptions = false, |
| List<String>? extraArgs, |
| }) { |
| assert(!pauseOnStart || testeeBefore == null); |
| if (_isTestee()) { |
| _ServiceTesteeRunner().runSync( |
| testeeBeforeSync: testeeBefore, |
| testeeConcurrentSync: testeeConcurrent, |
| pauseOnStart: pauseOnStart, |
| pauseOnExit: pauseOnExit, |
| ); |
| } else { |
| _ServiceTesterRunner().run( |
| mainArgs: mainArgs, |
| scriptName: scriptName, |
| extraArgs: extraArgs, |
| isolateTests: tests, |
| pauseOnStart: pauseOnStart, |
| pauseOnExit: pauseOnExit, |
| verboseVm: verboseVm, |
| pauseOnUnhandledExceptions: pauseOnUnhandledExceptions, |
| ); |
| } |
| } |
| |
| /// Runs [tests] in sequence, each of which should take an [Isolate] and |
| /// return a [Future]. Code for setting up state can run before and/or |
| /// concurrently with the tests. Uses [mainArgs] to determine whether |
| /// to run tests or testee in this invocation of the script. |
| Future<void> runVMTests( |
| List<String> mainArgs, |
| List<VMTest> tests, |
| String scriptName, { |
| Function()? testeeBefore, |
| Function()? testeeConcurrent, |
| bool pauseOnStart = false, |
| bool pauseOnExit = false, |
| bool verboseVm = false, |
| bool pauseOnUnhandledExceptions = false, |
| List<String>? extraArgs, |
| VmServiceFactory serviceFactory = VmService.defaultFactory, |
| }) async { |
| if (_isTestee()) { |
| await _ServiceTesteeRunner().run( |
| testeeBefore: testeeBefore, |
| testeeConcurrent: testeeConcurrent, |
| pauseOnStart: pauseOnStart, |
| pauseOnExit: pauseOnExit, |
| ); |
| } else { |
| await _ServiceTesterRunner().run( |
| mainArgs: mainArgs, |
| scriptName: scriptName, |
| extraArgs: extraArgs, |
| vmTests: tests, |
| pauseOnStart: pauseOnStart, |
| pauseOnExit: pauseOnExit, |
| verboseVm: verboseVm, |
| pauseOnUnhandledExceptions: pauseOnUnhandledExceptions, |
| serviceFactory: serviceFactory, |
| ); |
| } |
| } |
| |
| /// Runs the given [testBody] against the [VmService] at [serviceUri]. |
| /// |
| /// The test will fail if connection goes away prematurely. |
| Future<void> withServiceConnection( |
| Uri serviceUri, Future<void> Function(VmService) testBody) async { |
| var disposed = false; |
| final service = await vmServiceConnectUri(serviceUri.toString()); |
| await Future.wait([ |
| () async { |
| try { |
| await testBody(service); |
| } finally { |
| disposed = true; |
| await service.dispose(); |
| } |
| }(), |
| service.onDone.then((_) { |
| expect(disposed, isTrue); |
| }), |
| ], eagerError: true); |
| } |