blob: c4300eea347eaba34cb38ebfbd5cb212e6c79ded [file] [log] [blame]
// 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/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<String> 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<bool>? 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<String> 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<bool> 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<bool> startBrowserProcess(String command, List<String> arguments,
{Map<String, String>? 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<bool>();
done = doneCompleter.future;
var stdoutDone = Completer<Null>();
var stderrDone = Completer<Null>();
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<bool> start(String url);
/// Called when the driver page is requested, that is, when the browser first
/// contacts the test server. At this time, it's safe to assume that the
/// browser process has started and opened its first window.
///
/// This is used by [Safari] to ensure the browser window has focus.
Future<Null> onDriverPageRequested() => Future.value();
@override
String toString() => '$runtimeType';
}
abstract class WebDriverBrowser extends Browser {
WebDriver? _driver;
final int _port;
final Map<String, dynamic> _desiredCapabilities;
WebDriverBrowser(this._port, this._desiredCapabilities);
@override
Future<bool> start(String url) async {
_logEvent('Starting $this browser on: $url');
await _createDriver();
await _driver!.get(url);
try {
_logEvent('Got version: ${await version}');
} catch (error) {
_logEvent('Failed to get version.\nError: $error');
return false;
}
return true;
}
Future<void> _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));
try {
_driver = await createDriver(
uri: Uri.parse('http://localhost:$_port/'),
desired: _desiredCapabilities);
} catch (error) {
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<bool> close() async {
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<String> get version async {
// Example of the file:
// <?xml version="1.0" encoding="UTF-8"?>
// <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
// <plist version="1.0">
// <dict>
// <key>BuildVersion</key>
// <string>2</string>
// <key>CFBundleShortVersionString</key>
// <string>6.0.4</string>
// <key>CFBundleVersion</key>
// <string>8536.29.13</string>
// <key>ProjectName</key>
// <string>WebBrowser</string>
// <key>SourceVersion</key>
// <string>7536029013000000</string>
// </dict>
// </plist>
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<String> 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<bool> 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",
"--no-first-run",
"--use-mock-keychain",
"--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<String> 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<bool> 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<bool> start(String url) async {
_logEvent("Starting ie browser on: $url");
await resetConfiguration();
_logEvent("Got version: ${await version}");
return startBrowserProcess(_binary, [url]);
}
Future<void> _setRegistryKey(String key, String value,
{String? data, String? type}) async {
var args = <String>[
"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<bool> 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<bool> close() async {
await _adbDevice.forceStop(chromePackage);
await _adbDevice.killAll();
return true;
}
void logBrowserInfoToTestBrowserOutput() {
_testBrowserOutput.stdout
.write('Android device id: ${_adbDevice.deviceId}\n');
}
@override
final Future<String> 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);';
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.close();
}
@override
Future<String> 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<bool> 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<String, String>.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<BrowserTest> testQueue = [];
Map<String, BrowserStatus> browserStatus = {};
Map<String, AdbDevice> adbDeviceMapping = {};
late List<AdbDevice> 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<int, String> testCache = {};
Map<int, String> doubleReportingOutputs = {};
List<String> 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<BrowserTestRunner> 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<bool> 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 = <Browser>[];
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<List<int>>().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<String> 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<List<int>>().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<List<int>>().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<String> getDriverPage(String browserId) async {
await testRunner.browserStatus[browserId]?.browser.onDriverPageRequested();
var errorReportingUrl =
"http://$localIp:${errorReportingServer.port}/$browserId";
var driverContent = """
<!DOCTYPE html><html>
<head>
<title>Driving page</title>
<meta charset="utf-8">
<style>
.big-notice {
background-color: red;
color: white;
font-weight: bold;
font-size: xx-large;
text-align: center;
}
.controller.box {
white-space: nowrap;
overflow: scroll;
height: 6em;
}
body {
font-family: sans-serif;
}
body div {
padding-top: 10px;
}
</style>
<script type='text/javascript'>
var STATUS_UPDATE_INTERVAL = 10000;
function startTesting() {
var number_of_tests = 0;
var current_id;
var next_id;
// Has the test in the current iframe reported that it is done?
var test_completed = true;
// Has the test in the current iframe reported that it is started?
var test_started = false;
var testing_window;
var embedded_iframe_div = document.getElementById('embedded_iframe_div');
var embedded_iframe = document.getElementById('embedded_iframe');
var number_div = document.getElementById('number');
var executing_div = document.getElementById('currently_executing');
var error_div = document.getElementById('unhandled_error');
var use_iframe = ${configuration.runtime.requiresIFrame};
var start = new Date();
function newTaskHandler() {
if (this.readyState == this.DONE) {
if (this.status == 200) {
if (this.responseText == '$waitSignal') {
setTimeout(getNextTask, 500);
} else if (this.responseText == '$terminateSignal') {
// Don't do anything, we will be killed shortly.
} else {
var elapsed = new Date() - start;
var nextTask = JSON.parse(this.responseText);
var url = nextTask.url;
next_id = nextTask.id;
run(url);
}
} else {
reportError('Could not contact the server and get a new task');
}
}
}
function contactBrowserController(method,
path,
callback,
msg,
isUrlEncoded) {
var client = new XMLHttpRequest();
client.onreadystatechange = callback;
client.open(method, path);
if (isUrlEncoded) {
client.setRequestHeader('Content-type',
'application/x-www-form-urlencoded');
}
client.send(msg);
}
function getNextTask() {
// Until we have the next task we set the current_id to a specific
// negative value.
contactBrowserController(
'GET', '$nextTestPath/$browserId', newTaskHandler, "", false);
}
function childError(message, filename, lineno, colno, error) {
sendStatusUpdate();
if (error) {
reportMessage('FAIL:' + filename + ':' + lineno +
':' + colno + ':' + message + '\\n' + error.stack, false, false);
} else if (filename) {
reportMessage('FAIL:' + filename + ':' + lineno +
':' + colno + ':' + message, false, false);
} else {
reportMessage('FAIL: ' + message, false, false);
}
return true;
}
function run(url) {
number_of_tests++;
number_div.innerHTML = number_of_tests;
executing_div.innerHTML = url;
if (use_iframe) {
embedded_iframe.onload = null;
embedded_iframe_div.removeChild(embedded_iframe);
embedded_iframe = document.createElement('iframe');
embedded_iframe.id = "embedded_iframe";
embedded_iframe.width='800px';
embedded_iframe.height='600px';
embedded_iframe_div.appendChild(embedded_iframe);
embedded_iframe.src = url;
} else {
if (typeof testing_window != 'undefined') {
testing_window.close();
}
testing_window = window.open(url);
}
test_started = false;
test_completed = false;
}
window.onerror = function (message, url, lineNumber) {
if (url) {
reportError(url + ':' + lineNumber + ':' + message);
} else {
reportError(message);
}
}
function reportError(msg) {
function handleReady() {
if (this.readyState == this.DONE && this.status != 200) {
var error = 'Sending back error did not succeeed: ' + this.status;
error = error + '. Failed to send msg: ' + msg;
error_div.innerHTML = error;
}
}
contactBrowserController(
'POST', '$errorReportingUrl?test=1', handleReady, msg, true);
}
function reportMessage(msg, isFirstMessage, isStatusUpdate) {
if (isFirstMessage) {
if (test_started) {
reportMessage(
"FAIL: test started more than once (test reloads itself) " +
msg, false, false);
return;
}
current_id = next_id;
test_started = true;
contactBrowserController(
'POST', '$startedPath/$browserId?id=' + current_id,
function () {}, msg, true);
} else if (isStatusUpdate) {
contactBrowserController(
'POST', '$statusUpdatePath/$browserId?id=' + current_id,
function() {}, msg, true);
} else {
var is_double_report = test_completed;
var retry = 0;
test_completed = true;
function reportDoneMessage() {
contactBrowserController(
'POST', '$reportPath/$browserId?id=' + current_id,
handleReady, msg, true);
}
function handleReady() {
if (this.readyState == this.DONE) {
if (this.status == 200) {
if (!is_double_report) {
getNextTask();
}
} else {
reportError('Error sending result to server. Status: ' +
this.status + ' Retry: ' + retry);
retry++;
if (retry < 3) {
setTimeout(reportDoneMessage, 1000);
}
}
}
}
reportDoneMessage();
}
}
function parseResult(result) {
var parsedData = null;
try {
parsedData = JSON.parse(result);
} catch(error) { }
return parsedData;
}
// Browser tests send JSON messages to the driver window, handled here.
function messageHandler(e) {
var msg = e.data;
if (typeof msg != 'string') return;
var expectedSource =
use_iframe ? embedded_iframe.contentWindow : testing_window;
if (e.source != expectedSource) {
reportError("Message received from old test window: " + msg);
return;
}
var parsedData = parseResult(msg);
if (parsedData) {
// Only if the JSON message contains all required parameters,
// will we handle it and post it back to the test controller.
if ('message' in parsedData &&
'is_first_message' in parsedData &&
'is_status_update' in parsedData &&
'is_done' in parsedData) {
var message = parsedData['message'];
var isFirstMessage = parsedData['is_first_message'];
var isStatusUpdate = parsedData['is_status_update'];
var isDone = parsedData['is_done'];
if (!isFirstMessage && !isStatusUpdate) {
if (!isDone) {
alert("Bug in test_controller.js: " +
"isFirstMessage/isStatusUpdate/isDone were all false");
}
}
reportMessage(message, isFirstMessage, isStatusUpdate);
}
}
}
function sendStatusUpdate () {
var dom =
embedded_iframe.contentWindow.document.documentElement.innerHTML;
var message = 'Status:\\n';
message += ' DOM:\\n' +
' ' + dom;
reportMessage(message, false, true);
}
function sendRepeatingStatusUpdate() {
sendStatusUpdate();
setTimeout(sendRepeatingStatusUpdate, STATUS_UPDATE_INTERVAL);
}
window.addEventListener('message', messageHandler, false);
waitForDone = false;
getNextTask();
}
</script>
</head>
<body onload="startTesting()">
<div class='big-notice'>
Please keep this window in focus at all times.
</div>
<div>
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.
</div>
<div class="controller box">
Dart test driver, number of tests: <span id="number"></span><br>
Currently executing: <span id="currently_executing"></span><br>
Unhandled error: <span id="unhandled_error"></span>
</div>
<div id="embedded_iframe_div" class="test box">
<iframe id="embedded_iframe"></iframe>
</div>
</body>
</html>
""";
return driverContent;
}
}