[web] robustify safaridriver launch sequence (#162919)
Improve safaridriver launch sequence as follows: - Fix retry: the previous version would call `_startDriverProcess` recursively from within a listener to the stderr output. But by then the outer `_startDriverProcess` call would have completed its future, so the retry mechanism would kick in while tests are already running. - Wait for `safaridriver` server process to start listening and responding to WebDriver commands (using `/status` as the smoke test). - Smoke-test the driver session by attempting to list all windows (`WebDriver.windows`). - When `safaridriver` fails to pass all of the above checks, both close the session and kill the `safaridriver` process. Killing the process alone leaves Safari windows open. Closing the session alone leaves `safaridriver` processes open. - When tests are finished use `quit()` instead of `window.close()`, because the latter does not close the session.
This commit is contained in:
parent
01c2866780
commit
c5fbe83809
@ -89,10 +89,11 @@ abstract class ProcessStep implements PipelineStep {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _PipelineStepFailure {
|
class _PipelineStepFailure {
|
||||||
_PipelineStepFailure(this.step, this.error);
|
_PipelineStepFailure(this.step, this.error, this.stackTrace);
|
||||||
|
|
||||||
final PipelineStep step;
|
final PipelineStep step;
|
||||||
final Object error;
|
final Object error;
|
||||||
|
final StackTrace stackTrace;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Executes a sequence of asynchronous tasks, typically as part of a build/test
|
/// Executes a sequence of asynchronous tasks, typically as part of a build/test
|
||||||
@ -133,8 +134,8 @@ class Pipeline {
|
|||||||
_currentStepFuture = step.run();
|
_currentStepFuture = step.run();
|
||||||
try {
|
try {
|
||||||
await _currentStepFuture;
|
await _currentStepFuture;
|
||||||
} catch (e) {
|
} catch (error, stackTrace) {
|
||||||
failures.add(_PipelineStepFailure(step, e));
|
failures.add(_PipelineStepFailure(step, error, stackTrace));
|
||||||
} finally {
|
} finally {
|
||||||
_currentStep = null;
|
_currentStep = null;
|
||||||
}
|
}
|
||||||
@ -145,7 +146,7 @@ class Pipeline {
|
|||||||
_status = PipelineStatus.error;
|
_status = PipelineStatus.error;
|
||||||
print('Pipeline experienced the following failures:');
|
print('Pipeline experienced the following failures:');
|
||||||
for (final _PipelineStepFailure failure in failures) {
|
for (final _PipelineStepFailure failure in failures) {
|
||||||
print(' "${failure.step.description}": ${failure.error}');
|
print(' "${failure.step.description}": ${failure.error}\n${failure.stackTrace}');
|
||||||
}
|
}
|
||||||
throw ToolExit('Test pipeline failed.');
|
throw ToolExit('Test pipeline failed.');
|
||||||
}
|
}
|
||||||
|
@ -7,11 +7,21 @@ import 'dart:convert';
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:test_api/backend.dart';
|
import 'package:test_api/backend.dart';
|
||||||
|
import 'package:webdriver/async_io.dart' show WebDriver, createDriver;
|
||||||
|
|
||||||
|
import 'browser.dart';
|
||||||
import 'webdriver_browser.dart';
|
import 'webdriver_browser.dart';
|
||||||
|
|
||||||
/// Provides an environment for the desktop variant of Safari running on macOS.
|
/// Provides an environment for the desktop variant of Safari running on macOS.
|
||||||
class SafariMacOsEnvironment extends WebDriverBrowserEnvironment {
|
class SafariMacOsEnvironment extends BrowserEnvironment {
|
||||||
|
static const Duration _waitBetweenRetries = Duration(seconds: 1);
|
||||||
|
static const int _maxRetryCount = 5;
|
||||||
|
|
||||||
|
late int _portNumber;
|
||||||
|
late Process _driverProcess;
|
||||||
|
Uri get _driverUri => Uri(scheme: 'http', host: 'localhost', port: _portNumber);
|
||||||
|
WebDriver? webDriver;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
final String name = 'Safari macOS';
|
final String name = 'Safari macOS';
|
||||||
|
|
||||||
@ -21,21 +31,34 @@ class SafariMacOsEnvironment extends WebDriverBrowserEnvironment {
|
|||||||
@override
|
@override
|
||||||
String get packageTestConfigurationYamlFile => 'dart_test_safari.yaml';
|
String get packageTestConfigurationYamlFile => 'dart_test_safari.yaml';
|
||||||
|
|
||||||
@override
|
|
||||||
Uri get driverUri => Uri(scheme: 'http', host: 'localhost', port: portNumber);
|
|
||||||
|
|
||||||
late Process _driverProcess;
|
|
||||||
int _retryCount = 0;
|
|
||||||
static const int _waitBetweenRetryInSeconds = 1;
|
|
||||||
static const int _maxRetryCount = 10;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<Process> spawnDriverProcess() =>
|
|
||||||
Process.start('safaridriver', <String>['-p', portNumber.toString()]);
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> prepare() async {
|
Future<void> prepare() async {
|
||||||
await _startDriverProcess();
|
int retryCount = 0;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
try {
|
||||||
|
if (retryCount > 0) {
|
||||||
|
print('Retry #$retryCount');
|
||||||
|
}
|
||||||
|
retryCount += 1;
|
||||||
|
await _startDriverProcess();
|
||||||
|
return;
|
||||||
|
} catch (error, stackTrace) {
|
||||||
|
if (retryCount < _maxRetryCount) {
|
||||||
|
print('''
|
||||||
|
Failed to start safaridriver:
|
||||||
|
|
||||||
|
Error: $error
|
||||||
|
$stackTrace
|
||||||
|
''');
|
||||||
|
print('Will try again.');
|
||||||
|
await Future<void>.delayed(_waitBetweenRetries);
|
||||||
|
} else {
|
||||||
|
print('Too many retries. Giving up.');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pick an unused port and start `safaridriver` using that port.
|
/// Pick an unused port and start `safaridriver` using that port.
|
||||||
@ -45,36 +68,130 @@ class SafariMacOsEnvironment extends WebDriverBrowserEnvironment {
|
|||||||
/// again with a different port. Wait [_waitBetweenRetryInSeconds] seconds
|
/// again with a different port. Wait [_waitBetweenRetryInSeconds] seconds
|
||||||
/// between retries. Try up to [_maxRetryCount] times.
|
/// between retries. Try up to [_maxRetryCount] times.
|
||||||
Future<void> _startDriverProcess() async {
|
Future<void> _startDriverProcess() async {
|
||||||
_retryCount += 1;
|
_portNumber = await pickUnusedPort();
|
||||||
if (_retryCount > 1) {
|
print('Starting safaridriver on port $_portNumber');
|
||||||
await Future<void>.delayed(const Duration(seconds: _waitBetweenRetryInSeconds));
|
|
||||||
}
|
|
||||||
portNumber = await pickUnusedPort();
|
|
||||||
|
|
||||||
print('Attempt $_retryCount to start safaridriver on port $portNumber');
|
try {
|
||||||
|
_driverProcess = await Process.start('safaridriver', <String>['-p', _portNumber.toString()]);
|
||||||
|
|
||||||
_driverProcess = await spawnDriverProcess();
|
_driverProcess.stdout.transform(utf8.decoder).transform(const LineSplitter()).listen((
|
||||||
|
String log,
|
||||||
|
) {
|
||||||
|
print('[safaridriver] $log');
|
||||||
|
});
|
||||||
|
|
||||||
_driverProcess.stderr.transform(utf8.decoder).transform(const LineSplitter()).listen((
|
_driverProcess.stderr.transform(utf8.decoder).transform(const LineSplitter()).listen((
|
||||||
String error,
|
String error,
|
||||||
) {
|
) {
|
||||||
print('[Webdriver][Error] $error');
|
print('[safaridriver][error] $error');
|
||||||
if (_retryCount > _maxRetryCount) {
|
});
|
||||||
print('[Webdriver][Error] Failed to start after $_maxRetryCount tries.');
|
|
||||||
} else if (error.contains('Operation not permitted')) {
|
await _waitForSafariDriverServerReady();
|
||||||
_driverProcess.kill();
|
|
||||||
_startDriverProcess();
|
// Smoke-test the web driver process by connecting to it and asking for a
|
||||||
|
// list of windows. It doesn't matter how many windows there are.
|
||||||
|
webDriver = await createDriver(
|
||||||
|
uri: _driverUri,
|
||||||
|
desired: <String, dynamic>{'browserName': packageTestRuntime.identifier},
|
||||||
|
);
|
||||||
|
|
||||||
|
await webDriver!.windows.toList();
|
||||||
|
} catch (_) {
|
||||||
|
print('safaridriver failed to start.');
|
||||||
|
|
||||||
|
final badDriver = webDriver;
|
||||||
|
webDriver = null; // let's not keep faulty driver around
|
||||||
|
|
||||||
|
if (badDriver != null) {
|
||||||
|
// This means the launch process got to a point where a WebDriver
|
||||||
|
// instance was created, but it failed the smoke test. To make sure no
|
||||||
|
// stray driver sessions are left hanging, try to close the session.
|
||||||
|
try {
|
||||||
|
// The method is called "quit" but all it does is close the session.
|
||||||
|
//
|
||||||
|
// See: https://www.w3.org/TR/webdriver2/#delete-session
|
||||||
|
await badDriver.quit();
|
||||||
|
} catch (error, stackTrace) {
|
||||||
|
// Just print. Do not rethrow. The attempt to close the session is
|
||||||
|
// only a best-effort thing.
|
||||||
|
print('''
|
||||||
|
Failed to close driver session. Will try to kill the safaridriver process.
|
||||||
|
|
||||||
|
Error: $error
|
||||||
|
$stackTrace
|
||||||
|
''');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
|
||||||
_driverProcess.stdout.transform(utf8.decoder).transform(const LineSplitter()).listen((
|
// Try to kill gracefully using SIGTERM first.
|
||||||
String log,
|
_driverProcess.kill();
|
||||||
) {
|
await _driverProcess.exitCode.timeout(
|
||||||
print('[Webdriver] $log');
|
const Duration(seconds: 2),
|
||||||
});
|
onTimeout: () async {
|
||||||
|
// If the process fails to exit gracefully in a reasonable amount of
|
||||||
|
// time, kill it forcefully.
|
||||||
|
print('safaridriver failed to exit normally. Killing with SIGKILL.');
|
||||||
|
_driverProcess.kill(ProcessSignal.sigkill);
|
||||||
|
return 0;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Rethrow the error to allow the caller to retry, if need be.
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The Safari Driver process cannot instantly spawn a server, so this function
|
||||||
|
/// attempts to connect to the server in a loop until it succeeds.
|
||||||
|
///
|
||||||
|
/// A healthy driver process is expected to respond to a `GET /status` HTTP
|
||||||
|
/// request with `{value: {ready: true}}` JSON response.
|
||||||
|
///
|
||||||
|
/// See also: https://www.w3.org/TR/webdriver2/#status
|
||||||
|
Future<void> _waitForSafariDriverServerReady() async {
|
||||||
|
// Wait just a tiny bit before connecting for the very first time because
|
||||||
|
// frequently safaridriver isn't quick enough to bring up the server.
|
||||||
|
//
|
||||||
|
// 100ms seems enough in most cases, but feel free to revisit this.
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||||
|
|
||||||
|
int retryCount = 0;
|
||||||
|
while (true) {
|
||||||
|
retryCount += 1;
|
||||||
|
final httpClient = HttpClient();
|
||||||
|
try {
|
||||||
|
final request = await httpClient.get('localhost', _portNumber, '/status');
|
||||||
|
final response = await request.close();
|
||||||
|
final stringData = await response.transform(utf8.decoder).join();
|
||||||
|
final jsonResponse = json.decode(stringData) as Map<String, Object?>;
|
||||||
|
final value = jsonResponse['value']! as Map<String, Object?>;
|
||||||
|
final ready = value['ready']! as bool;
|
||||||
|
if (ready) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
if (retryCount < 10) {
|
||||||
|
print('safaridriver not ready yet. Waiting...');
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||||
|
} else {
|
||||||
|
print(
|
||||||
|
'safaridriver failed to reach ready state in a reasonable amount of time. Giving up.',
|
||||||
|
);
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Browser> launchBrowserInstance(Uri url, {bool debug = false}) async {
|
||||||
|
return WebDriverBrowser(webDriver!, url);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> cleanup() async {
|
Future<void> cleanup() async {
|
||||||
|
// WebDriver.quit() is not called here, because that's done in
|
||||||
|
// WebDriverBrowser.close().
|
||||||
_driverProcess.kill();
|
_driverProcess.kill();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -3,80 +3,25 @@
|
|||||||
// found in the LICENSE file.
|
// found in the LICENSE file.
|
||||||
|
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'dart:math';
|
import 'dart:math';
|
||||||
|
|
||||||
import 'package:image/image.dart';
|
import 'package:image/image.dart';
|
||||||
import 'package:webdriver/async_io.dart' show WebDriver, createDriver;
|
import 'package:webdriver/async_io.dart' show WebDriver;
|
||||||
|
|
||||||
import 'browser.dart';
|
import 'browser.dart';
|
||||||
|
|
||||||
abstract class WebDriverBrowserEnvironment extends BrowserEnvironment {
|
/// Finds and returns an unused port on the test host in the local port range.
|
||||||
late int portNumber;
|
Future<int> pickUnusedPort() async {
|
||||||
late final Process _driverProcess;
|
// Use bind to allocate an unused port, then unbind from that port to
|
||||||
|
// make it available for use.
|
||||||
Future<Process> spawnDriverProcess();
|
final ServerSocket socket = await ServerSocket.bind('localhost', 0);
|
||||||
Uri get driverUri;
|
final int port = socket.port;
|
||||||
|
await socket.close();
|
||||||
/// Finds and returns an unused port on the test host in the local port range.
|
return port;
|
||||||
Future<int> pickUnusedPort() async {
|
|
||||||
// Use bind to allocate an unused port, then unbind from that port to
|
|
||||||
// make it available for use.
|
|
||||||
final ServerSocket socket = await ServerSocket.bind('localhost', 0);
|
|
||||||
final int port = socket.port;
|
|
||||||
await socket.close();
|
|
||||||
|
|
||||||
return port;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<void> prepare() async {
|
|
||||||
portNumber = await pickUnusedPort();
|
|
||||||
|
|
||||||
_driverProcess = await spawnDriverProcess();
|
|
||||||
|
|
||||||
_driverProcess.stderr.transform(utf8.decoder).transform(const LineSplitter()).listen((
|
|
||||||
String error,
|
|
||||||
) {
|
|
||||||
print('[Webdriver][Error] $error');
|
|
||||||
});
|
|
||||||
|
|
||||||
_driverProcess.stdout.transform(utf8.decoder).transform(const LineSplitter()).listen((
|
|
||||||
String log,
|
|
||||||
) {
|
|
||||||
print('[Webdriver] $log');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<void> cleanup() async {
|
|
||||||
_driverProcess.kill();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<Browser> launchBrowserInstance(Uri url, {bool debug = false}) async {
|
|
||||||
while (true) {
|
|
||||||
try {
|
|
||||||
final WebDriver driver = await createDriver(
|
|
||||||
uri: driverUri,
|
|
||||||
desired: <String, dynamic>{'browserName': packageTestRuntime.identifier},
|
|
||||||
);
|
|
||||||
return WebDriverBrowser(driver, url);
|
|
||||||
} on SocketException {
|
|
||||||
// Sometimes we may try to connect before the web driver port is ready.
|
|
||||||
// So we should retry here. Note that if there was some issue with the
|
|
||||||
// webdriver process, we may loop infinitely here, so we're relying on
|
|
||||||
// the test timeout to kill us if it takes too long to connect.
|
|
||||||
print('Failed to connect to webdriver process. Retrying in 100 ms');
|
|
||||||
await Future<void>.delayed(const Duration(milliseconds: 100));
|
|
||||||
} catch (exception) {
|
|
||||||
rethrow;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A browser managed through a WebDriver connection.
|
||||||
class WebDriverBrowser extends Browser {
|
class WebDriverBrowser extends Browser {
|
||||||
WebDriverBrowser(this._driver, this._url) {
|
WebDriverBrowser(this._driver, this._url) {
|
||||||
_driver.get(_url);
|
_driver.get(_url);
|
||||||
@ -104,7 +49,7 @@ class WebDriverBrowser extends Browser {
|
|||||||
_shouldStopActivating = true;
|
_shouldStopActivating = true;
|
||||||
await _activateLoopFuture;
|
await _activateLoopFuture;
|
||||||
|
|
||||||
await (await _driver.window).close();
|
await _driver.quit();
|
||||||
if (!_onExitCompleter.isCompleted) {
|
if (!_onExitCompleter.isCompleted) {
|
||||||
_onExitCompleter.complete();
|
_onExitCompleter.complete();
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user