1// Copyright 2014 The Flutter Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5import 'dart:async';
6import 'dart:io' as io; // flutter_ignore: dart_io_import;
7
8import 'package:meta/meta.dart';
9import 'package:process/process.dart';
10import 'package:stream_channel/stream_channel.dart';
11
12import '../base/dds.dart';
13import '../base/file_system.dart';
14import '../base/io.dart';
15import '../base/logger.dart';
16import '../base/platform.dart';
17import '../convert.dart';
18import '../device.dart';
19import '../globals.dart' as globals;
20import '../native_assets.dart';
21import '../project.dart';
22import '../resident_runner.dart';
23import '../vmservice.dart';
24import 'font_config_manager.dart';
25import 'test_device.dart';
26
27/// Implementation of [TestDevice] with the Flutter Tester over a [Process].
28class FlutterTesterTestDevice extends TestDevice {
29 FlutterTesterTestDevice({
30 required this.id,
31 required this.platform,
32 required this.fileSystem,
33 required this.processManager,
34 required this.logger,
35 required this.flutterTesterBinPath,
36 required this.debuggingOptions,
37 required this.enableVmService,
38 required this.machine,
39 required this.host,
40 required this.testAssetDirectory,
41 required this.flutterProject,
42 required this.icudtlPath,
43 required this.compileExpression,
44 required this.fontConfigManager,
45 required this.nativeAssetsBuilder,
46 }) : assert(!debuggingOptions.startPaused || enableVmService),
47 _gotProcessVmServiceUri = enableVmService
48 ? Completer<Uri?>()
49 : (Completer<Uri?>()..complete());
50
51 /// Used for logging to identify the test that is currently being executed.
52 final int id;
53 final Platform platform;
54 final FileSystem fileSystem;
55 final ProcessManager processManager;
56 final Logger logger;
57 final String flutterTesterBinPath;
58 final DebuggingOptions debuggingOptions;
59 final bool enableVmService;
60 final bool? machine;
61 final InternetAddress? host;
62 final String? testAssetDirectory;
63 final FlutterProject? flutterProject;
64 final String? icudtlPath;
65 final CompileExpression? compileExpression;
66 final FontConfigManager fontConfigManager;
67 final TestCompilerNativeAssetsBuilder? nativeAssetsBuilder;
68
69 late final _ddsLauncher = DartDevelopmentService(logger: logger);
70 final Completer<Uri?> _gotProcessVmServiceUri;
71 final _exitCode = Completer<int>();
72
73 Process? _process;
74 HttpServer? _server;
75 DevtoolsLauncher? _devToolsLauncher;
76
77 /// Starts the device.
78 ///
79 /// [entrypointPath] is the path to the entrypoint file which must be compiled
80 /// as a dill.
81 @override
82 Future<StreamChannel<String>> start(String entrypointPath) async {
83 assert(!_exitCode.isCompleted);
84 assert(_process == null);
85 assert(_server == null);
86
87 // Prepare our WebSocket server to talk to the engine subprocess.
88 // Let the server choose an unused port.
89 _server = await bind(host, /*port*/ 0);
90 logger.printTrace('test $id: test harness socket server is running at port:${_server!.port}');
91 final command = <String>[
92 flutterTesterBinPath,
93 if (enableVmService) ...<String>[
94 // Some systems drive the _FlutterPlatform class in an unusual way, where
95 // only one test file is processed at a time, and the operating
96 // environment hands out specific ports ahead of time in a cooperative
97 // manner, where we're only allowed to open ports that were given to us in
98 // advance like this. For those esoteric systems, we have this feature
99 // whereby you can create _FlutterPlatform with a pair of ports.
100 //
101 // I mention this only so that you won't be tempted, as I was, to apply
102 // the obvious simplification to this code and remove this entire feature.
103 '--vm-service-port=${debuggingOptions.enableDds ? 0 : debuggingOptions.hostVmServicePort}',
104 if (debuggingOptions.startPaused) '--start-paused',
105 if (debuggingOptions.disableServiceAuthCodes) '--disable-service-auth-codes',
106 ] else
107 '--disable-vm-service',
108 if (host!.type == InternetAddressType.IPv6) '--ipv6',
109 if (icudtlPath != null) '--icu-data-file-path=$icudtlPath',
110 '--enable-checked-mode',
111 '--verify-entry-points',
112 if (debuggingOptions.enableImpeller == ImpellerStatus.enabled)
113 '--enable-impeller'
114 else ...<String>['--enable-software-rendering', '--skia-deterministic-rendering'],
115 if (debuggingOptions.enableFlutterGpu) '--enable-flutter-gpu',
116 if (debuggingOptions.enableDartProfiling) '--enable-dart-profiling',
117 '--non-interactive',
118 '--use-test-fonts',
119 '--disable-asset-fonts',
120 '--packages=${debuggingOptions.buildInfo.packageConfigPath}',
121 if (testAssetDirectory != null) '--flutter-assets-dir=$testAssetDirectory',
122 ...debuggingOptions.dartEntrypointArgs,
123 entrypointPath,
124 ];
125
126 // If the FLUTTER_TEST environment variable has been set, then pass it on
127 // for package:flutter_test to handle the value.
128 //
129 // If FLUTTER_TEST has not been set, assume from this context that this
130 // call was invoked by the command 'flutter test'.
131 final String flutterTest = platform.environment.containsKey('FLUTTER_TEST')
132 ? platform.environment['FLUTTER_TEST']!
133 : 'true';
134 final environment = <String, String>{
135 'FLUTTER_TEST': flutterTest,
136 'FONTCONFIG_FILE': fontConfigManager.fontConfigFile.path,
137 'SERVER_PORT': _server!.port.toString(),
138 'APP_NAME': flutterProject?.manifest.appName ?? '',
139 if (debuggingOptions.enableImpeller == ImpellerStatus.enabled)
140 'FLUTTER_TEST_IMPELLER': 'true',
141 if (testAssetDirectory != null) 'UNIT_TEST_ASSETS': testAssetDirectory!,
142 if (platform.isWindows && nativeAssetsBuilder != null && flutterProject != null)
143 'PATH':
144 '${nativeAssetsBuilder!.windowsBuildDirectory(flutterProject!)};${platform.environment['PATH']}',
145 };
146
147 logger.printTrace(
148 'test $id: Starting flutter_tester process with command=$command, environment=$environment',
149 );
150 _process = await processManager.start(command, environment: environment);
151
152 // Unawaited to update state.
153 unawaited(
154 _process!.exitCode.then((int exitCode) {
155 logger.printTrace(
156 'test $id: flutter_tester process at pid ${_process!.pid} exited with code=$exitCode',
157 );
158 _exitCode.complete(exitCode);
159 }),
160 );
161
162 logger.printTrace('test $id: Started flutter_tester process at pid ${_process!.pid}');
163
164 // Pipe stdout and stderr from the subprocess to our printStatus console.
165 // We also keep track of what VM Service port the engine used, if any.
166 _pipeStandardStreamsToConsole(
167 process: _process!,
168 reportVmServiceUri: (Uri detectedUri) async {
169 assert(!_gotProcessVmServiceUri.isCompleted);
170 assert(
171 debuggingOptions.hostVmServicePort == null ||
172 debuggingOptions.hostVmServicePort == detectedUri.port,
173 );
174
175 Uri? forwardingUri;
176
177 if (debuggingOptions.enableDds) {
178 logger.printTrace('test $id: Starting Dart Development Service');
179 await _ddsLauncher.startDartDevelopmentServiceFromDebuggingOptions(
180 detectedUri,
181 debuggingOptions: debuggingOptions,
182 );
183 forwardingUri = _ddsLauncher.uri;
184 logger.printTrace(
185 'test $id: Dart Development Service started at $forwardingUri, forwarding to VM service at $detectedUri.',
186 );
187 } else {
188 forwardingUri = detectedUri;
189 }
190
191 logger.printTrace('Connecting to service protocol: $forwardingUri');
192 await connectToVmServiceImpl(
193 forwardingUri!,
194 compileExpression: compileExpression,
195 logger: logger,
196 );
197 logger.printTrace('test $id: Successfully connected to service protocol: $forwardingUri');
198 if (debuggingOptions.startPaused && !machine!) {
199 logger.printStatus('The Dart VM service is listening on $forwardingUri');
200 await _startDevTools(forwardingUri, _ddsLauncher);
201 logger.printStatus('');
202 logger.printStatus(
203 'The test process has been started. Set any relevant breakpoints and then resume the test in the debugger.',
204 );
205 }
206 _gotProcessVmServiceUri.complete(forwardingUri);
207 },
208 );
209
210 return remoteChannel;
211 }
212
213 @override
214 Future<Uri?> get vmServiceUri {
215 return _gotProcessVmServiceUri.future;
216 }
217
218 @override
219 Future<void> kill() async {
220 logger.printTrace('test $id: Terminating flutter_tester process');
221 _process?.kill(io.ProcessSignal.sigkill);
222
223 logger.printTrace('test $id: Shutting down DevTools server');
224 await _devToolsLauncher?.close();
225
226 logger.printTrace('test $id: Shutting down test harness socket server');
227 await _server?.close(force: true);
228 await finished;
229 }
230
231 @override
232 Future<void> get finished async {
233 final int exitCode = await _exitCode.future;
234
235 // On Windows, the [exitCode] and the terminating signal have no correlation.
236 if (platform.isWindows) {
237 return;
238 }
239
240 // ProcessSignal.SIGKILL. Negative because signals are returned as negative
241 // exit codes.
242 if (exitCode == -9) {
243 // We expect SIGKILL (9) because we could have tried to [kill] it.
244 return;
245 }
246 throw TestDeviceException(_getExitCodeMessage(exitCode), StackTrace.current);
247 }
248
249 @visibleForTesting
250 @protected
251 Future<FlutterVmService> connectToVmServiceImpl(
252 Uri httpUri, {
253 CompileExpression? compileExpression,
254 required Logger logger,
255 }) {
256 return connectToVmService(httpUri, compileExpression: compileExpression, logger: logger);
257 }
258
259 // TODO(bkonyi): remove when ready to serve DevTools from DDS.
260 Future<void> _startDevTools(Uri forwardingUri, DartDevelopmentService? dds) async {
261 _devToolsLauncher = DevtoolsLauncher.instance;
262 logger.printTrace('test $id: Serving DevTools...');
263 final DevToolsServerAddress? devToolsServerAddress = await _devToolsLauncher?.serve();
264
265 if (devToolsServerAddress == null) {
266 logger.printTrace('test $id: Failed to start DevTools');
267 return;
268 }
269 await _devToolsLauncher?.ready;
270 logger.printTrace('test $id: DevTools is being served at ${devToolsServerAddress.uri}');
271
272 final Uri devToolsUri = devToolsServerAddress.uri!.replace(
273 // Use query instead of queryParameters to avoid unnecessary encoding.
274 query: 'uri=$forwardingUri',
275 );
276 logger.printStatus('The Flutter DevTools debugger and profiler is available at: $devToolsUri');
277 }
278
279 /// Binds an [HttpServer] serving from `host` on `port`.
280 ///
281 /// Only intended to be overridden in tests.
282 @protected
283 @visibleForTesting
284 Future<HttpServer> bind(InternetAddress? host, int port) => HttpServer.bind(host, port);
285
286 @protected
287 @visibleForTesting
288 Future<StreamChannel<String>> get remoteChannel async {
289 assert(_server != null);
290
291 try {
292 final HttpRequest firstRequest = await _server!.first;
293 final WebSocket webSocket = await WebSocketTransformer.upgrade(firstRequest);
294 return _webSocketToStreamChannel(webSocket);
295 } on Exception catch (error, stackTrace) {
296 throw TestDeviceException('Unable to connect to flutter_tester process: $error', stackTrace);
297 }
298 }
299
300 @override
301 String toString() {
302 final status = _process != null
303 ? 'pid: ${_process!.pid}, ${_exitCode.isCompleted ? 'exited' : 'running'}'
304 : 'not started';
305 return 'Flutter Tester ($status) for test $id';
306 }
307
308 void _pipeStandardStreamsToConsole({
309 required Process process,
310 required Future<void> Function(Uri uri) reportVmServiceUri,
311 }) {
312 for (final stream in <Stream<List<int>>>[process.stderr, process.stdout]) {
313 stream
314 .transform<String>(utf8.decoder)
315 .transform<String>(const LineSplitter())
316 .listen(
317 (String line) async {
318 logger.printTrace('test $id: Shell: $line');
319
320 final Match? match = globals.kVMServiceMessageRegExp.firstMatch(line);
321 if (match != null) {
322 try {
323 final Uri uri = Uri.parse(match[1]!);
324 await reportVmServiceUri(uri);
325 } on Exception catch (error) {
326 logger.printError('Could not parse shell VM Service port message: $error');
327 }
328 } else {
329 logger.printStatus('Shell: $line');
330 }
331 },
332 onError: (dynamic error) {
333 logger.printError(
334 'shell console stream for process pid ${process.pid} experienced an unexpected error: $error',
335 );
336 },
337 cancelOnError: true,
338 );
339 }
340 }
341}
342
343String _getExitCodeMessage(int exitCode) {
344 return switch (exitCode) {
345 1 => 'Shell subprocess cleanly reported an error. Check the logs above for an error message.',
346 0 => 'Shell subprocess ended cleanly. Did main() call exit()?',
347 -0x0f => 'Shell subprocess crashed with SIGTERM ($exitCode).', // ProcessSignal.SIGTERM
348 -0x0b => 'Shell subprocess crashed with segmentation fault.', // ProcessSignal.SIGSEGV
349 -0x06 => 'Shell subprocess crashed with SIGABRT ($exitCode).', // ProcessSignal.SIGABRT
350 -0x02 => 'Shell subprocess terminated by ^C (SIGINT, $exitCode).', // ProcessSignal.SIGINT
351 _ => 'Shell subprocess crashed with unexpected exit code $exitCode.',
352 };
353}
354
355StreamChannel<String> _webSocketToStreamChannel(WebSocket webSocket) {
356 final controller = StreamChannelController<String>();
357
358 controller.local.stream.map<dynamic>((String message) => message as dynamic).pipe(webSocket);
359 webSocket
360 // We're only communicating with string encoded JSON.
361 .map<String>((dynamic message) => message as String)
362 .pipe(controller.local.sink);
363
364 return controller.foreign;
365}
366