// Copyright (c) 2013, 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:async'; import 'dart:convert'; import 'dart:io'; import 'package:webdriver/async_io.dart'; import 'android.dart'; import 'configuration.dart'; import 'path.dart'; import 'service/web_driver_service.dart'; import 'utils.dart'; typedef BrowserDoneCallback = void Function(BrowserTestOutput output); typedef TestChangedCallback = void Function( String browserId, String output, int testId); typedef NextTestCallback = BrowserTest? Function(String browserId); class BrowserOutput { final StringBuffer stdout = StringBuffer(); final StringBuffer stderr = StringBuffer(); final StringBuffer eventLog = StringBuffer(); } /// Class describing the interface for communicating with browsers. abstract class Browser { static int _browserIdCounter = 1; static String _nextBrowserId() => "BROWSER${_browserIdCounter++}"; /// Get the output that was written so far to stdout/stderr/eventLog. BrowserOutput get testBrowserOutput => _testBrowserOutput; BrowserOutput _testBrowserOutput = BrowserOutput(); /// This is called after the process is closed, before the done future /// is completed. /// /// Subclasses can use this to cleanup any browser specific resources /// (temp directories, profiles, etc). The function is expected to do /// it's work synchronously. void Function()? _cleanup; /// The version of the browser - normally set when starting a browser Future get version; /// The underlying process - don't mess directly with this if you don't /// know what you are doing (this is an interactive process that needs /// special treatment to not leak). Process? process; void Function(String)? logger; /// Id of the browser. final String id = _nextBrowserId(); /// Reset the browser to a known configuration on start-up. /// Browser specific implementations are free to ignore this. static bool resetBrowserConfiguration = false; /// Print everything (stdout, stderr, usageLog) whenever we add to it bool debugPrint = false; /// This future returns when the process exits. It is also the return value /// of close() Future? done; Browser(); static Browser fromConfiguration(TestConfiguration configuration) { Browser browser; switch (configuration.runtime) { case Runtime.firefox: browser = Firefox(configuration.browserLocation); break; case Runtime.chrome: browser = Chrome(configuration.browserLocation); break; case Runtime.safari: var service = WebDriverService.fromRuntime(Runtime.safari); browser = Safari(service.port); break; case Runtime.ie9: case Runtime.ie10: case Runtime.ie11: browser = IE(configuration.browserLocation); break; default: throw "unreachable"; } return browser; } static const List supportedBrowsers = [ 'safari', 'ff', 'firefox', 'chrome', 'ie9', 'ie10', 'ie11' ]; static bool requiresFocus(String browserName) { return browserName == "safari"; } // TODO(kustermann): add standard support for chrome on android static bool supportedBrowser(String name) { return supportedBrowsers.contains(name); } void _logEvent(String event) { var toLog = "$this ($id) - $event \n"; if (debugPrint) print("usageLog: $toLog"); logger?.call(toLog); _testBrowserOutput.eventLog.write(toLog); } void _addStdout(String output) { if (debugPrint) print("stdout: $output"); _testBrowserOutput.stdout.write(output); } void _addStderr(String output) { if (debugPrint) print("stderr: $output"); _testBrowserOutput.stderr.write(output); } Future close() { _logEvent("Close called on browser"); if (process != null) { if (process!.kill(ProcessSignal.sigkill)) { _logEvent("Successfully sent kill signal to process."); } else { _logEvent("Sending kill signal failed."); } return done ?? Future.value(true); } else { _logEvent("The process is already dead."); return Future.value(true); } } /// Start the browser using the supplied argument. /// This sets up the error handling and usage logging. Future startBrowserProcess(String command, List arguments, {Map? environment}) { return Process.start(command, arguments, environment: environment) .then((startedProcess) { _logEvent("Started browser using $command ${arguments.join(' ')}"); process = startedProcess; // Used to notify when exiting, and as a return value on calls to // close(). var doneCompleter = Completer(); done = doneCompleter.future; var stdoutDone = Completer(); var stderrDone = Completer(); var stdoutIsDone = false; var stderrIsDone = false; StreamSubscription stdoutSubscription; StreamSubscription stderrSubscription; // This timer is used to close stdio to the subprocess once we got // the exitCode. Sometimes descendants of the subprocess keep stdio // handles alive even though the direct subprocess is dead. Timer? watchdogTimer; void closeStdout([_]) { if (!stdoutIsDone) { stdoutDone.complete(); stdoutIsDone = true; if (stderrIsDone) { watchdogTimer?.cancel(); } } } void closeStderr([_]) { if (!stderrIsDone) { stderrDone.complete(); stderrIsDone = true; if (stdoutIsDone) { watchdogTimer?.cancel(); } } } stdoutSubscription = process!.stdout .transform(utf8.decoder) .listen(_addStdout, onError: (error) { // This should _never_ happen, but we really want this in the log // if it actually does due to dart:io or vm bug. _logEvent("An error occurred in the process stdout handling: $error"); }, onDone: closeStdout); stderrSubscription = process!.stderr .transform(utf8.decoder) .listen(_addStderr, onError: (error) { // This should _never_ happen, but we really want this in the log // if it actually does due to dart:io or vm bug. _logEvent("An error occurred in the process stderr handling: $error"); }, onDone: closeStderr); process!.exitCode.then((exitCode) { _logEvent("Browser closed with exitcode $exitCode"); if (!stdoutIsDone || !stderrIsDone) { watchdogTimer = Timer(maxStdioDelay, () { DebugLogger.warning("$maxStdioDelayPassedMessage (browser: $this)"); watchdogTimer = null; stdoutSubscription.cancel(); stderrSubscription.cancel(); closeStdout(); closeStderr(); }); } Future.wait([stdoutDone.future, stderrDone.future]).then((_) { process = null; _cleanup?.call(); }).catchError((error) { _logEvent("Error closing browsers: $error"); }).whenComplete(() => doneCompleter.complete(true)); }); return true; }).catchError((error) { _logEvent("Running $command $arguments failed with $error"); return false; }); } void resetTestBrowserOutput() { _testBrowserOutput = BrowserOutput(); } /// Add useful info about the browser to the _testBrowserOutput.stdout, /// where it will be reported for failing tests. Used to report which /// android device a failing test is running on. void logBrowserInfoToTestBrowserOutput() {} /// Starts the browser loading the given url Future start(String url); @override String toString() => '$runtimeType'; } abstract class WebDriverBrowser extends Browser { WebDriver? _driver; final int _port; final Map _desiredCapabilities; bool _terminated = false; WebDriverBrowser(this._port, this._desiredCapabilities); @override Future start(String url) async { _logEvent('Starting $this browser on: $url'); await _createDriver(); if (_terminated) return false; await _driver!.get(url); try { _logEvent('Got version: ${await version}'); } catch (error) { _logEvent('Failed to get version.\nError: $error'); return false; } return true; } Future _createDriver() async { for (var i = 5; i >= 0; i--) { // Give the driver process some time to be ready to accept connections. await Future.delayed(const Duration(seconds: 1)); if (_terminated) return; try { _driver = await createDriver( uri: Uri.parse('http://localhost:$_port/'), desired: _desiredCapabilities); } catch (error) { if (_terminated) return; if (i > 0) { _logEvent( 'Failed to create driver ($i retries left).\nError: $error'); } else { _logEvent('Failed to create driver.\nError: $error'); await close(); rethrow; } } if (_driver != null) break; } } @override Future close() async { _terminated = true; await _driver?.quit(); // Give the driver process some time to be quit the browser. return true; } } class Safari extends WebDriverBrowser { /// We get the safari version by parsing a version file static const versionFile = '/Applications/Safari.app/Contents/version.plist'; Safari(int port) : super(port, { 'browserName': 'safari', }); @override Future get version async { // Example of the file: // // // // // BuildVersion // 2 // CFBundleShortVersionString // 6.0.4 // CFBundleVersion // 8536.29.13 // ProjectName // WebBrowser // SourceVersion // 7536029013000000 // // final versionLine = (await File(versionFile).readAsLines()) .skipWhile((line) => !line.contains("CFBundleShortVersionString")) .skip(1) .take(1); return versionLine.isEmpty ? 'unknown' : versionLine.first; } } class Chrome extends Browser { Chrome(this._binary); final String _binary; @override Future get version async { if (Platform.isWindows) { // The version flag does not work on windows. // See issue: // https://code.google.com/p/chromium/issues/detail?id=158372 // The registry hack does not seem to work. return "unknown on windows"; } final result = await Process.run(_binary, ["--version"]); if (result.exitCode != 0) { _logEvent("Failed to get chrome version"); _logEvent("Make sure $_binary is a valid program for running chrome"); throw StateError( "Failed to get chrome version.\nExit code: ${result.exitCode}"); } return result.stdout as String; } @override Future start(String url) async { _logEvent("Starting chrome browser on: $url"); if (!await File(_binary).exists()) { _logEvent("Chrome binary not available."); _logEvent("Make sure $_binary is a valid program for running chrome"); return false; } try { _logEvent("Got version: ${await version}"); final userDir = await Directory.systemTemp.createTemp(); _cleanup = () { try { userDir.deleteSync(recursive: true); } catch (e) { _logEvent( "Error: failed to delete Chrome user-data-dir ${userDir.path}, " "will try again in 40 seconds: $e"); Timer(const Duration(seconds: 40), () { try { userDir.deleteSync(recursive: true); } catch (e) { _logEvent("Error: failed on second attempt to delete Chrome " "user-data-dir ${userDir.path}: $e"); } }); } }; var args = [ "--bwsi", "--disable-component-update", "--disable-extensions", "--disable-popup-blocking", "--disable-features=IntensiveWakeUpThrottling", "--disable-blink-features=TimerThrottlingForBackgroundTabs", "--no-first-run", "--use-mock-keychain", "--password-store=basic", "--user-data-dir=${userDir.path}", url, ]; // TODO(rnystrom): Uncomment this to open the dev tools tab when Chrome // is spawned. Handy for debugging tests. // args.add("--auto-open-devtools-for-tabs"); return startBrowserProcess(_binary, args); } catch (e) { _logEvent("Starting chrome failed with $e"); return false; } } } class IE extends Browser { IE(this._binary); final String _binary; @override Future get version async { var args = [ "query", "HKEY_LOCAL_MACHINE\\Software\\Microsoft\\Internet Explorer", "/v", "svcVersion" ]; final result = await Process.run("reg", args); if (result.exitCode != 0) { throw StateError("Could not get the version of internet explorer"); } // The string we get back looks like this: // HKEY_LOCAL_MACHINE\Software\Microsoft\Internet Explorer // version REG_SZ 9.0.8112.16421 var findString = "REG_SZ"; var index = (result.stdout as String).indexOf(findString); if (index > 0) { return (result.stdout as String) .substring(index + findString.length) .trim(); } throw StateError("Could not get the version of internet explorer"); } // Clears the recovery cache and allows popups on localhost if the static // resetBrowserConfiguration flag is set. Future resetConfiguration() async { if (!Browser.resetBrowserConfiguration) return true; const ieKey = r"HKCU\Software\Microsoft\Internet Explorer"; // Turn off popup blocker await _setRegistryKey("$ieKey\\New Windows", "PopupMgr", data: "0", type: "REG_DWORD"); // Allow popups from localhost await _setRegistryKey("$ieKey\\New Windows\\Allow", "127.0.0.1"); // Disable IE first run wizard await _setRegistryKey("$ieKey\\Main", "DisableFirstRunCustomize", data: "1", type: "REG_DWORD"); var localAppData = Platform.environment['LOCALAPPDATA']; var dir = Directory("$localAppData\\Microsoft\\" "Internet Explorer\\Recovery"); try { dir.delete(recursive: true); return true; } catch (error) { _logEvent("Deleting recovery dir failed with $error"); return false; } } @override Future start(String url) async { _logEvent("Starting ie browser on: $url"); await resetConfiguration(); _logEvent("Got version: ${await version}"); return startBrowserProcess(_binary, [url]); } Future _setRegistryKey(String key, String value, {String? data, String? type}) async { var args = [ "add", key, "/v", value, "/f", if (type != null) ...["/t", type] ]; var result = await Process.run("reg", args); if (result.exitCode != 0) { _logEvent("Failed to set '$key' to '$value'"); } } } class AndroidChrome extends Browser { static const String viewAction = 'android.intent.action.VIEW'; static const String mainAction = 'android.intent.action.MAIN'; static const String chromePackage = 'com.android.chrome'; static const String chromeActivity = '.Main'; static const String browserPackage = 'com.android.browser'; static const String browserActivity = '.BrowserActivity'; static const String firefoxPackage = 'org.mozilla.firefox'; static const String firefoxActivity = '.App'; static const String turnScreenOnPackage = 'com.google.dart.turnscreenon'; static const String turnScreenOnActivity = '.Main'; final AdbDevice _adbDevice; AndroidChrome(this._adbDevice); @override Future start(String url) { var chromeIntent = Intent(viewAction, chromePackage, chromeActivity, url); var turnScreenOnIntent = Intent(mainAction, turnScreenOnPackage, turnScreenOnActivity); var testingResourcesDir = Path('third_party/android_testing_resources'); if (!Directory(testingResourcesDir.toNativePath()).existsSync()) { DebugLogger.error("$testingResourcesDir doesn't exist. Exiting now."); exit(1); } var chromeAPK = testingResourcesDir.append('com.android.chrome-1.apk'); var turnScreenOnAPK = testingResourcesDir.append('TurnScreenOn.apk'); var chromeConfDir = testingResourcesDir.append('chrome_configuration'); var chromeConfDirRemote = Path('/data/user/0/com.android.chrome/'); return _adbDevice.waitForBootCompleted().then((_) { return _adbDevice.forceStop(chromeIntent.package); }).then((_) { return _adbDevice.killAll(); }).then((_) { return _adbDevice.adbRoot(); }).then((_) { return _adbDevice.installApk(turnScreenOnAPK); }).then((_) { return _adbDevice.installApk(chromeAPK); }).then((_) { return _adbDevice.pushData(chromeConfDir, chromeConfDirRemote); }).then((_) { return _adbDevice.chmod('777', chromeConfDirRemote); }).then((_) { return _adbDevice.startActivity(turnScreenOnIntent).then((_) => true); }).then((_) { return _adbDevice.startActivity(chromeIntent).then((_) => true); }); } @override Future close() async { await _adbDevice.forceStop(chromePackage); await _adbDevice.killAll(); return true; } @override void logBrowserInfoToTestBrowserOutput() { _testBrowserOutput.stdout .write('Android device id: ${_adbDevice.deviceId}\n'); } @override final Future version = Future.value('unknown'); @override String toString() => "chromeOnAndroid"; } class Firefox extends Browser { Firefox(this._binary); final String _binary; static const String enablePopUp = 'user_pref("dom.disable_open_during_load", false);'; static const String disableDefaultCheck = 'user_pref("browser.shell.checkDefaultBrowser", false);'; static const String disableScriptTimeLimit = 'user_pref("dom.max_script_run_time", 0);'; static const String disableAutoUpdate = 'user_pref("app.update.auto", false);'; void _createPreferenceFile(String path) { var file = File("$path/user.js"); var randomFile = file.openSync(mode: FileMode.write); randomFile.writeStringSync(enablePopUp); randomFile.writeStringSync(disableDefaultCheck); randomFile.writeStringSync(disableScriptTimeLimit); randomFile.writeStringSync(disableAutoUpdate); randomFile.close(); } @override Future get version async { final result = await Process.run(_binary, ["--version"]); if (result.exitCode != 0) { _logEvent("Failed to get firefox version"); _logEvent("Make sure $_binary is a valid program for running firefox"); throw StateError( "Failed to get firefox version.\nExit code: ${result.exitCode}"); } return result.stdout as String; } @override Future start(String url) async { _logEvent("Starting firefox browser on: $url"); try { _logEvent("Got version: ${await version}"); final userDir = await Directory.systemTemp.createTemp(); _createPreferenceFile(userDir.path); _cleanup = () { userDir.deleteSync(recursive: true); }; var args = [ "-profile", userDir.path, "-no-remote", "-new-instance", url, ]; var environment = Map.from(Platform.environment); environment["MOZ_CRASHREPORTER_DISABLE"] = "1"; return startBrowserProcess(_binary, args, environment: environment); } catch (e) { _logEvent("Starting firefox failed with $e"); return false; } } } /// Describes the current state of a browser used for testing. class BrowserStatus { Browser browser; BrowserTest? currentTest; // This is currently not used for anything except for error reporting. // Given the usefulness of this in debugging issues this should not be // removed even when we have a really stable system. BrowserTest? lastTest; bool timeout = false; Timer? nextTestTimeout; Stopwatch timeSinceRestart = Stopwatch()..start(); BrowserStatus(this.browser); } /// Describes a single test to be run in the browser. class BrowserTest { // TODO(ricow): Add timeout callback instead of the string passing hack. BrowserDoneCallback doneCallback; String url; int timeout; String lastKnownMessage = ''; late Stopwatch stopwatch; Duration? delayUntilTestStarted; // We store this here for easy access when tests time out (instead of // capturing this in a closure) late Timer timeoutTimer; // Used for debugging, this is simply a unique identifier assigned to each // test. final int id = _idCounter++; static int _idCounter = 0; BrowserTest(this.url, this.doneCallback, this.timeout); String toJSON() => jsonEncode({'url': url, 'id': id}); } /* Describes the output of running the test in a browser */ class BrowserTestOutput { final Duration? delayUntilTestStarted; final Duration duration; final String lastKnownMessage; final BrowserOutput browserOutput; final bool didTimeout; BrowserTestOutput(this.delayUntilTestStarted, this.duration, this.lastKnownMessage, this.browserOutput, {this.didTimeout = false}); } /// Encapsulates all the functionality for running tests in browsers. /// Tests are added to the queue and the supplied callbacks are called /// when a test completes. /// BrowserTestRunner starts up to maxNumBrowser instances of the browser, /// to run the tests, starting them sequentially, as needed, so only /// one is starting up at a time. /// BrowserTestRunner starts a BrowserTestingServer, which serves a /// driver page to the browsers, serves tests, and receives results and /// requests back from the browsers. class BrowserTestRunner { static const int _maxNextTestTimeouts = 10; static const Duration _nextTestTimeout = Duration(seconds: 120); static const Duration _restartBrowserInterval = Duration(seconds: 60); /// If the queue was recently empty, don't start another browser. static const Duration _minNonemptyQueueTime = Duration(seconds: 1); final TestConfiguration configuration; final BrowserTestingServer testingServer; final Browser Function(TestConfiguration configuration) browserFactory; final String localIp; final int maxNumBrowsers; int numBrowsers = 0; /// Used to send back logs from the browser (start, stop etc.). void Function(String)? logger; bool testingServerStarted = false; bool underTermination = false; int numBrowserGetTestTimeouts = 0; DateTime lastEmptyTestQueueTime = DateTime.now(); String? _currentStartingBrowserId; List testQueue = []; Map browserStatus = {}; Map adbDeviceMapping = {}; late List idleAdbDevices; /// This cache is used to guarantee that we never see double reporting. /// If we do we need to provide developers with this information. /// We don't add urls to the cache until we have run it. Map testCache = {}; Map doubleReportingOutputs = {}; List timedOut = []; /// We will start a new browser when the test queue hasn't been empty /// recently, we have fewer than maxNumBrowsers browsers, and there is /// no other browser instance currently starting up. bool get queueWasEmptyRecently { return testQueue.isEmpty || DateTime.now().difference(lastEmptyTestQueueTime) < _minNonemptyQueueTime; } /// While a browser is starting, but has not requested its first test, its /// browserId is stored in _currentStartingBrowserId. /// When no browser is currently starting, _currentStartingBrowserId is null. bool get aBrowserIsCurrentlyStarting => _currentStartingBrowserId != null; void markCurrentlyStarting(String id) { _currentStartingBrowserId = id; } void markNotCurrentlyStarting(String id) { if (_currentStartingBrowserId == id) _currentStartingBrowserId = null; } BrowserTestRunner(this.configuration, this.localIp, this.maxNumBrowsers, [this.browserFactory = Browser.fromConfiguration]) : testingServer = BrowserTestingServer(configuration, localIp, Browser.requiresFocus(configuration.runtime.name)) { testingServer.testRunner = this; } Future start() async { await testingServer.start(); testingServer ..testDoneCallBack = handleResults ..testStatusUpdateCallBack = handleStatusUpdate ..testStartedCallBack = handleStarted ..nextTestCallBack = getNextTest; testingServerStarted = true; requestBrowser(); return this; } /// requestBrowser() is called whenever we might want to start an additional /// browser instance. /// /// It is called when starting the BrowserTestRunner, and whenever a browser /// is killed, whenever a new test is enqueued, or whenever a browser /// finishes a test. /// So we are guaranteed that this will always eventually be called, as long /// as the test queue isn't empty. void requestBrowser() { if (!testingServerStarted) return; if (underTermination) return; if (numBrowsers == maxNumBrowsers) return; if (aBrowserIsCurrentlyStarting) return; if (numBrowsers > 0 && queueWasEmptyRecently) return; createBrowser(); } Future createBrowser() { Browser browser; if (configuration.runtime == Runtime.chromeOnAndroid) { var device = idleAdbDevices.removeLast(); browser = AndroidChrome(device); adbDeviceMapping[browser.id] = device; } else { browser = browserFactory(configuration); browser.logger = logger; } markCurrentlyStarting(browser.id); var status = BrowserStatus(browser); browserStatus[browser.id] = status; numBrowsers++; status.nextTestTimeout = createNextTestTimer(status); return browser.start(testingServer.getDriverUrl(browser.id)); } void handleResults(String browserId, String output, int testId) { var status = browserStatus[browserId]; if (testCache.containsKey(testId)) { doubleReportingOutputs[testId] = output; return; } var test = status?.currentTest; if (status == null || status.timeout) { // We don't do anything, this browser is currently being killed and // replaced. The browser here can be null if we decided to kill the // browser. } else if (test != null) { test.timeoutTimer.cancel(); test.stopwatch.stop(); if (test.id != testId) { print("Expected test id ${test.id} for ${test.url}"); print("Got test id $testId"); print("Last test id was ${status.lastTest?.id} for ${test.url}"); throw "This should never happen, wrong test id"; } testCache[testId] = test.url; // Report that the test is finished now var browserTestOutput = BrowserTestOutput(test.delayUntilTestStarted, test.stopwatch.elapsed, output, status.browser.testBrowserOutput); test.doneCallback(browserTestOutput); status.lastTest = test; status.currentTest = null; status.nextTestTimeout = createNextTestTimer(status); } else { print("\nThis is bad, should never happen, handleResult no test"); print("URL: ${status.lastTest?.url}"); print(output); terminate().then((_) { exit(1); }); } } void handleStatusUpdate(String browserId, String output, int testId) { var status = browserStatus[browserId]; if (status == null || status.timeout) { // We don't do anything, this browser is currently being killed and // replaced. The browser here can be null if we decided to kill the // browser. } else if (status.currentTest?.id == testId) { status.currentTest!.lastKnownMessage = output; } } void handleStarted(String browserId, String output, int testId) { var status = browserStatus[browserId]; if (status == null || status.timeout) return; var currentTest = status.currentTest; if (currentTest == null) return; currentTest.timeoutTimer.cancel(); currentTest.timeoutTimer = createTimeoutTimer(currentTest, status); currentTest.delayUntilTestStarted = currentTest.stopwatch.elapsed; } Future handleTimeout(BrowserStatus status) async { // We simply kill the browser and starts up a new one! // We could be smarter here, but it does not seems like it is worth it. if (status.timeout) { DebugLogger.error("Got test timeout for an already restarting browser"); return; } status.timeout = true; var currentTest = status.currentTest!; timedOut.add(currentTest.url); var id = status.browser.id; currentTest.stopwatch.stop(); await status.browser.close(); var lastKnownMessage = 'Dom could not be fetched, since the test timed out.'; if (currentTest.lastKnownMessage.isNotEmpty) { lastKnownMessage = currentTest.lastKnownMessage; } if (status.lastTest != null) { lastKnownMessage += '\nPrevious test was ${status.lastTest!.url}'; } // Wait until the browser is closed before reporting the test as timeout. // This will enable us to capture stdout/stderr from the browser // (which might provide us with information about what went wrong). var browserTestOutput = BrowserTestOutput( currentTest.delayUntilTestStarted, currentTest.stopwatch.elapsed, lastKnownMessage, status.browser.testBrowserOutput, didTimeout: true); currentTest.doneCallback(browserTestOutput); status.lastTest = status.currentTest; status.currentTest = null; // We don't want to start a new browser if we are terminating. if (underTermination) return; removeBrowser(id); requestBrowser(); } /// Remove a browser that has closed from our data structures that track /// open browsers. Check if we want to replace it with a new browser. void removeBrowser(String id) { if (configuration.runtime == Runtime.chromeOnAndroid) { idleAdbDevices.add(adbDeviceMapping.remove(id)!); } markNotCurrentlyStarting(id); browserStatus.remove(id); --numBrowsers; } BrowserTest? getNextTest(String browserId) { markNotCurrentlyStarting(browserId); var status = browserStatus[browserId]; if (status == null) return null; status.nextTestTimeout?.cancel(); status.nextTestTimeout = null; if (testQueue.isEmpty) return null; // We are currently terminating this browser, don't start a new test. if (status.timeout) return null; // Restart Internet Explorer if it has been // running for longer than RESTART_BROWSER_INTERVAL. The tests have // had flaky timeouts, and this may help. if ((configuration.runtime == Runtime.ie10 || configuration.runtime == Runtime.ie11) && status.timeSinceRestart.elapsed > _restartBrowserInterval) { var id = status.browser.id; // Reset stopwatch so we don't trigger again before restarting. status.timeout = true; status.browser.close().then((_) { // We don't want to start a new browser if we are terminating. if (underTermination) return; removeBrowser(id); requestBrowser(); }); // Don't send a test to the browser we are restarting. return null; } var test = testQueue.removeLast(); // If our queue isn't empty, try starting more browsers if (testQueue.isEmpty) { lastEmptyTestQueueTime = DateTime.now(); } else { requestBrowser(); } if (status.currentTest != null) { // TODO(ricow): Handle this better. print("Browser requested next test before reporting previous result"); print("This happened for browser $browserId"); print("Old test was: ${status.currentTest!.url}"); print("The test before that was: ${status.lastTest?.url}"); print("Timed out tests:"); for (var v in timedOut) { print(" $v"); } exit(1); } status.currentTest = test ..lastKnownMessage = '' ..timeoutTimer = createTimeoutTimer(test, status) ..stopwatch = (Stopwatch()..start()); // Reset the test specific output information (stdout, stderr) on the // browser, since a new test is being started. status.browser.resetTestBrowserOutput(); status.browser.logBrowserInfoToTestBrowserOutput(); return test; } /// Creates a timer that is active while a test is running on a browser. Timer createTimeoutTimer(BrowserTest test, BrowserStatus status) { return Timer(Duration(seconds: test.timeout), () { handleTimeout(status); }); } /// Creates a timer that is active while no test is running on the /// browser. It has finished one test, and it has not requested a new test. Timer createNextTestTimer(BrowserStatus status) { return Timer(BrowserTestRunner._nextTestTimeout, () { handleNextTestTimeout(status); }); } void handleNextTestTimeout(BrowserStatus status) { DebugLogger.warning( "Browser timed out before getting next test. Restarting"); if (status.timeout) return; numBrowserGetTestTimeouts++; if (numBrowserGetTestTimeouts >= _maxNextTestTimeouts) { DebugLogger.error( "Too many browser timeouts before getting next test. Terminating"); terminate().then((_) => exit(1)); } else { status.timeout = true; status.browser.close().then((_) { removeBrowser(status.browser.id); requestBrowser(); }); } } void enqueueTest(BrowserTest test) { testQueue.add(test); requestBrowser(); } void printDoubleReportingTests() { if (doubleReportingOutputs.isEmpty) return; // TODO(ricow): die on double reporting. // Currently we just report this here, we could have a callback to the // encapsulating environment. print(""); print("Double reporting tests"); for (var id in doubleReportingOutputs.keys) { print(" ${testCache[id]}"); } DebugLogger.warning("Double reporting tests:"); for (var id in doubleReportingOutputs.keys) { DebugLogger.warning("${testCache[id]}"); } } // TODO(26191): Call a unified fatalError(), that shuts down all subprocesses. // This just kills the browsers in this BrowserTestRunner instance. Future terminate() async { var browsers = []; underTermination = true; testingServer.underTermination = true; for (var status in browserStatus.values) { browsers.add(status.browser); status.nextTestTimeout?.cancel(); status.nextTestTimeout = null; } for (var browser in browsers) { await browser.close(); } testingServer.errorReportingServer.close(); printDoubleReportingTests(); } } /// Interface of the testing server: /// /// GET /driver/BROWSER_ID -- This will get the driver page to fetch /// and run tests ... /// GET /next_test/BROWSER_ID -- returns "WAIT" "TERMINATE" or "url#id" /// where url is the test to run, and id is the id of the test. /// If there are currently no available tests the waitSignal is send /// back. If we are in the process of terminating the terminateSignal /// is send back and the browser will stop requesting new tasks. /// POST /report/BROWSER_ID?id=NUM -- sends back the dom of the executed /// test class BrowserTestingServer { final TestConfiguration configuration; final String localIp; final bool requiresFocus; late BrowserTestRunner testRunner; static const String driverPath = "/driver"; static const String nextTestPath = "/next_test"; static const String reportPath = "/report"; static const String statusUpdatePath = "/status_update"; static const String startedPath = "/started"; static const String waitSignal = "WAIT"; static const String terminateSignal = "TERMINATE"; var testCount = 0; late HttpServer errorReportingServer; bool underTermination = false; late TestChangedCallback testDoneCallBack; late TestChangedCallback testStatusUpdateCallBack; late TestChangedCallback testStartedCallBack; late NextTestCallback nextTestCallBack; BrowserTestingServer(this.configuration, this.localIp, this.requiresFocus); Future start() async { var server = await HttpServer.bind(localIp, configuration.testDriverErrorPort); setupErrorServer(server); setupDispatchingServer(server); } void setupErrorServer(HttpServer server) { errorReportingServer = server; void errorReportingHandler(HttpRequest request) { var buffer = StringBuffer(); request.cast>().transform(utf8.decoder).listen((data) { buffer.write(data); }, onDone: () { var back = buffer.toString(); request.response.headers.set("Access-Control-Allow-Origin", "*"); request.response.done.catchError((error) { DebugLogger.error("Error getting error from browser" "on uri ${request.uri.path}: $error"); }); request.response.close(); DebugLogger.error("Error from browser on : " "${request.uri.path}, data: $back"); }, onError: print); } void errorHandler(e) { if (!underTermination) print("Error occurred in httpserver: $e"); } errorReportingServer.listen(errorReportingHandler, onError: errorHandler); } void setupDispatchingServer(_) { var server = configuration.servers.server!; void noCache(HttpRequest request) { request.response.headers .set("Cache-Control", "no-cache, no-store, must-revalidate"); } int testId(HttpRequest request) => int.parse(request.uri.queryParameters["id"]!); String browserId(HttpRequest request, String prefix) => request.uri.path.substring(prefix.length + 1); server.addHandler(reportPath, (HttpRequest request) { noCache(request); handleReport(request, browserId(request, reportPath), testId(request), isStatusUpdate: false); }); server.addHandler(statusUpdatePath, (HttpRequest request) { noCache(request); handleReport( request, browserId(request, statusUpdatePath), testId(request), isStatusUpdate: true); }); server.addHandler(startedPath, (HttpRequest request) { noCache(request); handleStarted(request, browserId(request, startedPath), testId(request)); }); void sendPageHandler(HttpRequest request) { // Do NOT make this method async. We need to call catchError below // synchronously to avoid unhandled asynchronous errors. noCache(request); Future textResponse; if (request.uri.path.startsWith(driverPath)) { textResponse = getDriverPage(browserId(request, driverPath)); request.response.headers.set('Content-Type', 'text/html'); } else if (request.uri.path.startsWith(nextTestPath)) { textResponse = Future.value(getNextTest(browserId(request, nextTestPath))); request.response.headers.set('Content-Type', 'text/plain'); } else { textResponse = Future.value(""); } request.response.done.catchError((error) async { if (!underTermination) { var text = await textResponse; print("URI ${request.uri}"); print("text $text"); throw "Error returning content to browser: $error"; } }); textResponse.then((String text) async { request.response.write(text); await request.listen(null).asFuture(); // Ignoring the returned closure as it returns the 'done' future // which already has catchError installed above. request.response.close(); }); } server.addHandler(driverPath, sendPageHandler); server.addHandler(nextTestPath, sendPageHandler); } void handleReport(HttpRequest request, String browserId, int testId, {required bool isStatusUpdate}) { var buffer = StringBuffer(); request.cast>().transform(utf8.decoder).listen((data) { buffer.write(data); }, onDone: () { var back = buffer.toString(); request.response.close(); if (isStatusUpdate) { testStatusUpdateCallBack(browserId, back, testId); } else { testDoneCallBack(browserId, back, testId); } // TODO(ricow): We should do something smart if we get an error here. }, onError: (error) { DebugLogger.error("$error"); }); } void handleStarted(HttpRequest request, String browserId, int testId) { var buffer = StringBuffer(); // If an error occurs while receiving the data from the request stream, // we don't handle it specially. We can safely ignore it, since the started // events are not crucial. request.cast>().transform(utf8.decoder).listen((data) { buffer.write(data); }, onDone: () { var back = buffer.toString(); request.response.close(); testStartedCallBack(browserId, back, testId); }, onError: (error) { DebugLogger.error("$error"); }); } String getNextTest(String browserId) { var nextTest = nextTestCallBack(browserId); if (underTermination) { // Browsers will be killed shortly, send them a terminate signal so // that they stop pulling. return terminateSignal; } return nextTest == null ? waitSignal : nextTest.toJSON(); } String getDriverUrl(String browserId) { return "http://$localIp:${configuration.servers.port}/driver/$browserId"; } Future getDriverPage(String browserId) async { var errorReportingUrl = "http://$localIp:${errorReportingServer.port}/$browserId"; var driverContent = """ Driving page
Please keep this window in focus at all times.
Some browsers, Safari, in particular, may pause JavaScript when not visible to conserve power consumption and CPU resources. In addition, some tests of focus events will not work correctly if this window doesn't have focus. It's also advisable to close any other programs that may open modal dialogs, for example, Chrome with Calendar open.
Dart test driver, number of tests:
Currently executing:
Unhandled error:
"""; return driverContent; } }