3581fa7850
Change-Id: I353e3dbd693d8ce83f75059a2d6dd72b277d3677 Reviewed-on: https://dart-review.googlesource.com/c/sdk/+/406842 Commit-Queue: Martin Kustermann <kustermann@google.com> Reviewed-by: Ömer Ağacan <omersa@google.com>
1552 lines
51 KiB
Dart
1552 lines
51 KiB
Dart
// 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<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<void>();
|
|
var stderrDone = Completer<void>();
|
|
|
|
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);
|
|
|
|
@override
|
|
String toString() => '$runtimeType';
|
|
}
|
|
|
|
abstract class WebDriverBrowser extends Browser {
|
|
WebDriver? _driver;
|
|
final int _port;
|
|
final Map<String, dynamic> _desiredCapabilities;
|
|
bool _terminated = false;
|
|
|
|
WebDriverBrowser(this._port, this._desiredCapabilities);
|
|
|
|
@override
|
|
Future<bool> 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<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));
|
|
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<bool> 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<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",
|
|
"--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<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;
|
|
}
|
|
|
|
@override
|
|
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);';
|
|
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<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 {
|
|
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.textContent = number_of_tests;
|
|
executing_div.textContent = 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, columnNumber, err) {
|
|
// Ensure the stack is included in the reported error, if available.
|
|
if (err && err.stack) {
|
|
message = message + '\\n' + err.stack;
|
|
}
|
|
if (url) {
|
|
message = url + ':' + lineNumber + ':' + columnNumber + ':' + message;
|
|
}
|
|
reportError(message);
|
|
}
|
|
|
|
function reportError(msg) {
|
|
function handleReady() {
|
|
if (this.readyState == this.DONE && this.status != 200) {
|
|
var error = 'Sending back error did not succeed: ' + 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;
|
|
}
|
|
}
|