| // Copyright 2014 The Flutter Authors. 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:async'; |
| import 'dart:convert'; |
| import 'dart:io'; |
| |
| import 'package:flutter_tools/src/base/platform.dart'; |
| import 'package:meta/meta.dart'; |
| import 'package:process/process.dart'; |
| |
| import '../src/common.dart'; |
| import 'test_utils.dart' show fileSystem; |
| |
| const ProcessManager processManager = LocalProcessManager(); |
| final String flutterRoot = getFlutterRoot(); |
| final String flutterBin = fileSystem.path.join(flutterRoot, 'bin', 'flutter'); |
| |
| void debugPrint(String message) { |
| // This is called to intentionally print debugging output when a test is |
| // either taking too long or has failed. |
| // ignore: avoid_print |
| print(message); |
| } |
| |
| typedef LineHandler = String? Function(String line); |
| |
| abstract class Transition { |
| const Transition({this.handler, this.logging}); |
| |
| /// Callback that is invoked when the transition matches. |
| /// |
| /// This should not throw, even if the test is failing. (For example, don't use "expect" |
| /// in these callbacks.) Throwing here would prevent the [runFlutter] function from running |
| /// to completion, which would leave zombie `flutter` processes around. |
| final LineHandler? handler; |
| |
| /// Whether to enable or disable logging when this transition is matched. |
| /// |
| /// The default value, null, leaves the logging state unaffected. |
| final bool? logging; |
| |
| bool matches(String line); |
| |
| @protected |
| bool lineMatchesPattern(String line, Pattern pattern) { |
| if (pattern is String) { |
| return line == pattern; |
| } |
| return line.contains(pattern); |
| } |
| |
| @protected |
| String describe(Pattern pattern) { |
| if (pattern is String) { |
| return '"$pattern"'; |
| } |
| if (pattern is RegExp) { |
| return '/${pattern.pattern}/'; |
| } |
| return '$pattern'; |
| } |
| } |
| |
| class Barrier extends Transition { |
| const Barrier(this.pattern, {super.handler, super.logging}); |
| final Pattern pattern; |
| |
| @override |
| bool matches(String line) => lineMatchesPattern(line, pattern); |
| |
| @override |
| String toString() => describe(pattern); |
| } |
| |
| class Multiple extends Transition { |
| Multiple( |
| List<Pattern> patterns, { |
| super.handler, |
| super.logging, |
| }) : _originalPatterns = patterns, |
| patterns = patterns.toList(); |
| |
| final List<Pattern> _originalPatterns; |
| final List<Pattern> patterns; |
| |
| @override |
| bool matches(String line) { |
| for (int index = 0; index < patterns.length; index += 1) { |
| if (lineMatchesPattern(line, patterns[index])) { |
| patterns.removeAt(index); |
| break; |
| } |
| } |
| return patterns.isEmpty; |
| } |
| |
| @override |
| String toString() { |
| if (patterns.isEmpty) { |
| return '${_originalPatterns.map(describe).join(', ')} (all matched)'; |
| } |
| return '${_originalPatterns.map(describe).join(', ')} (matched ${_originalPatterns.length - patterns.length} so far)'; |
| } |
| } |
| |
| class LogLine { |
| const LogLine(this.channel, this.stamp, this.message); |
| final String channel; |
| final String stamp; |
| final String message; |
| |
| bool get couldBeCrash => |
| message.contains('Oops; flutter has exited unexpectedly:'); |
| |
| @override |
| String toString() => '$stamp $channel: $message'; |
| |
| void printClearly() { |
| debugPrint('$stamp $channel: ${clarify(message)}'); |
| } |
| |
| static String clarify(String line) { |
| return line.runes.map<String>((int rune) => switch (rune) { |
| >= 0x20 && <= 0x7F => String.fromCharCode(rune), |
| 0x00 => '<NUL>', |
| 0x07 => '<BEL>', |
| 0x08 => '<TAB>', |
| 0x09 => '<BS>', |
| 0x0A => '<LF>', |
| 0x0D => '<CR>', |
| _ => '<${rune.toRadixString(16).padLeft(rune <= 0xFF ? 2 : rune <= 0xFFFF ? 4 : 5, '0')}>', |
| }).join(); |
| } |
| } |
| |
| class ProcessTestResult { |
| const ProcessTestResult(this.exitCode, this.logs); |
| final int exitCode; |
| final List<LogLine> logs; |
| |
| List<String> get stdout { |
| return logs |
| .where((LogLine log) => log.channel == 'stdout') |
| .map<String>((LogLine log) => log.message) |
| .toList(); |
| } |
| |
| List<String> get stderr { |
| return logs |
| .where((LogLine log) => log.channel == 'stderr') |
| .map<String>((LogLine log) => log.message) |
| .toList(); |
| } |
| |
| @override |
| String toString() => 'exit code $exitCode\nlogs:\n ${logs.join('\n ')}\n'; |
| } |
| |
| Future<ProcessTestResult> runFlutter( |
| List<String> arguments, |
| String workingDirectory, |
| List<Transition> transitions, { |
| bool debug = false, |
| bool logging = true, |
| Duration expectedMaxDuration = const Duration( |
| minutes: 10, |
| ), // must be less than test timeout of 15 minutes! See ../../dart_test.yaml. |
| }) async { |
| const LocalPlatform platform = LocalPlatform(); |
| final Stopwatch clock = Stopwatch()..start(); |
| final Process process = await processManager.start( |
| <String>[ |
| // In a container with no X display, use the virtual framebuffer. |
| if (platform.isLinux && (platform.environment['DISPLAY'] ?? '').isEmpty) '/usr/bin/xvfb-run', |
| flutterBin, |
| ...arguments, |
| ], |
| workingDirectory: workingDirectory, |
| ); |
| final List<LogLine> logs = <LogLine>[]; |
| int nextTransition = 0; |
| void describeStatus() { |
| if (transitions.isNotEmpty) { |
| debugPrint('Expected state transitions:'); |
| for (int index = 0; index < transitions.length; index += 1) { |
| debugPrint('${index.toString().padLeft(5)} ' |
| '${index < nextTransition ? 'ALREADY MATCHED ' : index == nextTransition ? 'NOW WAITING FOR>' : ' '} ${transitions[index]}'); |
| } |
| } |
| if (logs.isEmpty) { |
| debugPrint( |
| 'So far nothing has been logged${debug ? "" : "; use debug:true to print all output"}.'); |
| } else { |
| debugPrint( |
| 'Log${debug ? "" : " (only contains logged lines; use debug:true to print all output)"}:'); |
| for (final LogLine log in logs) { |
| log.printClearly(); |
| } |
| } |
| } |
| |
| bool streamingLogs = false; |
| Timer? timeout; |
| void processTimeout() { |
| if (!streamingLogs) { |
| streamingLogs = true; |
| if (!debug) { |
| debugPrint( |
| 'Test is taking a long time (${clock.elapsed.inSeconds} seconds so far).'); |
| } |
| describeStatus(); |
| debugPrint('(streaming all logs from this point on...)'); |
| } else { |
| debugPrint('(taking a long time...)'); |
| } |
| } |
| |
| String stamp() => |
| '[${(clock.elapsed.inMilliseconds / 1000.0).toStringAsFixed(1).padLeft(5)}s]'; |
| void processStdout(String line) { |
| final LogLine log = LogLine('stdout', stamp(), line); |
| if (logging) { |
| logs.add(log); |
| } |
| if (streamingLogs) { |
| log.printClearly(); |
| } |
| if (nextTransition < transitions.length && |
| transitions[nextTransition].matches(line)) { |
| if (streamingLogs) { |
| debugPrint('(matched ${transitions[nextTransition]})'); |
| } |
| if (transitions[nextTransition].logging != null) { |
| if (!logging && transitions[nextTransition].logging!) { |
| logs.add(log); |
| } |
| logging = transitions[nextTransition].logging!; |
| if (streamingLogs) { |
| if (logging) { |
| debugPrint('(enabled logging)'); |
| } else { |
| debugPrint('(disabled logging)'); |
| } |
| } |
| } |
| if (transitions[nextTransition].handler != null) { |
| final String? command = transitions[nextTransition].handler!(line); |
| if (command != null) { |
| final LogLine inLog = LogLine('stdin', stamp(), command); |
| logs.add(inLog); |
| if (streamingLogs) { |
| inLog.printClearly(); |
| } |
| process.stdin.write(command); |
| } |
| } |
| nextTransition += 1; |
| timeout?.cancel(); |
| timeout = Timer(expectedMaxDuration ~/ 5, |
| processTimeout); // This is not a failure timeout, just when to start logging verbosely to help debugging. |
| } |
| } |
| |
| void processStderr(String line) { |
| final LogLine log = LogLine('stdout', stamp(), line); |
| logs.add(log); |
| if (streamingLogs) { |
| log.printClearly(); |
| } |
| } |
| |
| if (debug) { |
| processTimeout(); |
| } else { |
| timeout = Timer(expectedMaxDuration ~/ 2, |
| processTimeout); // This is not a failure timeout, just when to start logging verbosely to help debugging. |
| } |
| process.stdout |
| .transform<String>(utf8.decoder) |
| .transform<String>(const LineSplitter()) |
| .listen(processStdout); |
| process.stderr |
| .transform<String>(utf8.decoder) |
| .transform<String>(const LineSplitter()) |
| .listen(processStderr); |
| unawaited(process.exitCode.timeout(expectedMaxDuration, onTimeout: () { |
| // This is a failure timeout, must not be short. |
| debugPrint( |
| '${stamp()} (process is not quitting, trying to send a "q" just in case that helps)'); |
| debugPrint('(a functional test should never reach this point)'); |
| final LogLine inLog = LogLine('stdin', stamp(), 'q'); |
| logs.add(inLog); |
| if (streamingLogs) { |
| inLog.printClearly(); |
| } |
| process.stdin.write('q'); |
| return -1; // discarded |
| }).then( |
| (int i) => i, |
| onError: (Object error) { |
| // ignore errors here, they will be reported on the next line |
| return -1; // discarded |
| }, |
| )); |
| final int exitCode = await process.exitCode; |
| if (streamingLogs) { |
| debugPrint('${stamp()} (process terminated with exit code $exitCode)'); |
| } |
| timeout?.cancel(); |
| if (nextTransition < transitions.length) { |
| debugPrint( |
| 'The subprocess terminated before all the expected transitions had been matched.'); |
| if (logs.any((LogLine line) => line.couldBeCrash)) { |
| debugPrint( |
| 'The subprocess may in fact have crashed. Check the stderr logs below.'); |
| } |
| debugPrint( |
| 'The transition that we were hoping to see next but that we never saw was:'); |
| debugPrint( |
| '${nextTransition.toString().padLeft(5)} NOW WAITING FOR> ${transitions[nextTransition]}'); |
| if (!streamingLogs) { |
| describeStatus(); |
| debugPrint('(process terminated with exit code $exitCode)'); |
| } |
| throw TestFailure('Missed some expected transitions.'); |
| } |
| if (streamingLogs) { |
| debugPrint('${stamp()} (completed execution successfully!)'); |
| } |
| return ProcessTestResult(exitCode, logs); |
| } |
| |
| const int progressMessageWidth = 64; |