@@ -7,11 +7,21 @@ import 'dart:convert';
77import 'dart:io' ;
88
99import 'package:test_api/backend.dart' ;
10+ import 'package:webdriver/async_io.dart' show WebDriver, createDriver;
1011
12+ import 'browser.dart' ;
1113import 'webdriver_browser.dart' ;
1214
1315/// Provides an environment for the desktop variant of Safari running on macOS.
14- class SafariMacOsEnvironment extends WebDriverBrowserEnvironment {
16+ class SafariMacOsEnvironment extends BrowserEnvironment {
17+ static const Duration _waitBetweenRetries = Duration (seconds: 1 );
18+ static const int _maxRetryCount = 5 ;
19+
20+ late int _portNumber;
21+ late Process _driverProcess;
22+ Uri get _driverUri => Uri (scheme: 'http' , host: 'localhost' , port: _portNumber);
23+ WebDriver ? webDriver;
24+
1525 @override
1626 final String name = 'Safari macOS' ;
1727
@@ -22,20 +32,33 @@ class SafariMacOsEnvironment extends WebDriverBrowserEnvironment {
2232 String get packageTestConfigurationYamlFile => 'dart_test_safari.yaml' ;
2333
2434 @override
25- Uri get driverUri => Uri (scheme: 'http' , host: 'localhost' , port: portNumber);
26-
27- late Process _driverProcess;
28- int _retryCount = 0 ;
29- static const int _waitBetweenRetryInSeconds = 1 ;
30- static const int _maxRetryCount = 10 ;
35+ Future <void > prepare () async {
36+ int retryCount = 0 ;
3137
32- @override
33- Future <Process > spawnDriverProcess () =>
34- Process .start ('safaridriver' , < String > ['-p' , portNumber.toString ()]);
38+ while (true ) {
39+ try {
40+ if (retryCount > 0 ) {
41+ print ('Retry #$retryCount ' );
42+ }
43+ retryCount += 1 ;
44+ await _startDriverProcess ();
45+ return ;
46+ } catch (error, stackTrace) {
47+ if (retryCount < _maxRetryCount) {
48+ print ('''
49+ Failed to start safaridriver:
3550
36- @override
37- Future <void > prepare () async {
38- await _startDriverProcess ();
51+ Error: $error
52+ $stackTrace
53+ ''' );
54+ print ('Will try again.' );
55+ await Future <void >.delayed (_waitBetweenRetries);
56+ } else {
57+ print ('Too many retries. Giving up.' );
58+ rethrow ;
59+ }
60+ }
61+ }
3962 }
4063
4164 /// Pick an unused port and start `safaridriver` using that port.
@@ -45,36 +68,130 @@ class SafariMacOsEnvironment extends WebDriverBrowserEnvironment {
4568 /// again with a different port. Wait [_waitBetweenRetryInSeconds] seconds
4669 /// between retries. Try up to [_maxRetryCount] times.
4770 Future <void > _startDriverProcess () async {
48- _retryCount += 1 ;
49- if (_retryCount > 1 ) {
50- await Future <void >.delayed (const Duration (seconds: _waitBetweenRetryInSeconds));
51- }
52- portNumber = await pickUnusedPort ();
71+ _portNumber = await pickUnusedPort ();
72+ print ('Starting safaridriver on port $_portNumber ' );
73+
74+ try {
75+ _driverProcess = await Process .start ('safaridriver' , < String > ['-p' , _portNumber.toString ()]);
76+
77+ _driverProcess.stdout.transform (utf8.decoder).transform (const LineSplitter ()).listen ((
78+ String log,
79+ ) {
80+ print ('[safaridriver] $log ' );
81+ });
82+
83+ _driverProcess.stderr.transform (utf8.decoder).transform (const LineSplitter ()).listen ((
84+ String error,
85+ ) {
86+ print ('[safaridriver][error] $error ' );
87+ });
88+
89+ await _waitForSafariDriverServerReady ();
5390
54- print ('Attempt $_retryCount to start safaridriver on port $portNumber ' );
91+ // Smoke-test the web driver process by connecting to it and asking for a
92+ // list of windows. It doesn't matter how many windows there are.
93+ webDriver = await createDriver (
94+ uri: _driverUri,
95+ desired: < String , dynamic > {'browserName' : packageTestRuntime.identifier},
96+ );
5597
56- _driverProcess = await spawnDriverProcess ();
98+ await webDriver! .windows.toList ();
99+ } catch (_) {
100+ print ('safaridriver failed to start.' );
57101
58- _driverProcess.stderr.transform (utf8.decoder).transform (const LineSplitter ()).listen ((
59- String error,
60- ) {
61- print ('[Webdriver][Error] $error ' );
62- if (_retryCount > _maxRetryCount) {
63- print ('[Webdriver][Error] Failed to start after $_maxRetryCount tries.' );
64- } else if (error.contains ('Operation not permitted' )) {
65- _driverProcess.kill ();
66- _startDriverProcess ();
102+ final badDriver = webDriver;
103+ webDriver = null ; // let's not keep faulty driver around
104+
105+ if (badDriver != null ) {
106+ // This means the launch process got to a point where a WebDriver
107+ // instance was created, but it failed the smoke test. To make sure no
108+ // stray driver sessions are left hanging, try to close the session.
109+ try {
110+ // The method is called "quit" but all it does is close the session.
111+ //
112+ // See: https://www.w3.org/TR/webdriver2/#delete-session
113+ await badDriver.quit ();
114+ } catch (error, stackTrace) {
115+ // Just print. Do not rethrow. The attempt to close the session is
116+ // only a best-effort thing.
117+ print ('''
118+ Failed to close driver session. Will try to kill the safaridriver process.
119+
120+ Error: $error
121+ $stackTrace
122+ ''' );
123+ }
67124 }
68- });
69- _driverProcess.stdout.transform (utf8.decoder).transform (const LineSplitter ()).listen ((
70- String log,
71- ) {
72- print ('[Webdriver] $log ' );
73- });
125+
126+ // Try to kill gracefully using SIGTERM first.
127+ _driverProcess.kill ();
128+ await _driverProcess.exitCode.timeout (
129+ const Duration (seconds: 2 ),
130+ onTimeout: () async {
131+ // If the process fails to exit gracefully in a reasonable amount of
132+ // time, kill it forcefully.
133+ print ('safaridriver failed to exit normally. Killing with SIGKILL.' );
134+ _driverProcess.kill (ProcessSignal .sigkill);
135+ return 0 ;
136+ },
137+ );
138+
139+ // Rethrow the error to allow the caller to retry, if need be.
140+ rethrow ;
141+ }
142+ }
143+
144+ /// The Safari Driver process cannot instantly spawn a server, so this function
145+ /// attempts to connect to the server in a loop until it succeeds.
146+ ///
147+ /// A healthy driver process is expected to respond to a `GET /status` HTTP
148+ /// request with `{value: {ready: true}}` JSON response.
149+ ///
150+ /// See also: https://www.w3.org/TR/webdriver2/#status
151+ Future <void > _waitForSafariDriverServerReady () async {
152+ // Wait just a tiny bit before connecting for the very first time because
153+ // frequently safaridriver isn't quick enough to bring up the server.
154+ //
155+ // 100ms seems enough in most cases, but feel free to revisit this.
156+ await Future <void >.delayed (const Duration (milliseconds: 100 ));
157+
158+ int retryCount = 0 ;
159+ while (true ) {
160+ retryCount += 1 ;
161+ final httpClient = HttpClient ();
162+ try {
163+ final request = await httpClient.get ('localhost' , _portNumber, '/status' );
164+ final response = await request.close ();
165+ final stringData = await response.transform (utf8.decoder).join ();
166+ final jsonResponse = json.decode (stringData) as Map <String , Object ?>;
167+ final value = jsonResponse['value' ]! as Map <String , Object ?>;
168+ final ready = value['ready' ]! as bool ;
169+ if (ready) {
170+ break ;
171+ }
172+ } catch (_) {
173+ if (retryCount < 10 ) {
174+ print ('safaridriver not ready yet. Waiting...' );
175+ await Future <void >.delayed (const Duration (milliseconds: 100 ));
176+ } else {
177+ print (
178+ 'safaridriver failed to reach ready state in a reasonable amount of time. Giving up.' ,
179+ );
180+ rethrow ;
181+ }
182+ }
183+ }
184+ }
185+
186+ @override
187+ Future <Browser > launchBrowserInstance (Uri url, {bool debug = false }) async {
188+ return WebDriverBrowser (webDriver! , url);
74189 }
75190
76191 @override
77192 Future <void > cleanup () async {
193+ // WebDriver.quit() is not called here, because that's done in
194+ // WebDriverBrowser.close().
78195 _driverProcess.kill ();
79196 }
80197}
0 commit comments