@@ -7,11 +7,21 @@ import 'dart:convert';
7
7
import 'dart:io' ;
8
8
9
9
import 'package:test_api/backend.dart' ;
10
+ import 'package:webdriver/async_io.dart' show WebDriver, createDriver;
10
11
12
+ import 'browser.dart' ;
11
13
import 'webdriver_browser.dart' ;
12
14
13
15
/// 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
+
15
25
@override
16
26
final String name = 'Safari macOS' ;
17
27
@@ -22,20 +32,33 @@ class SafariMacOsEnvironment extends WebDriverBrowserEnvironment {
22
32
String get packageTestConfigurationYamlFile => 'dart_test_safari.yaml' ;
23
33
24
34
@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 ;
31
37
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:
35
50
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
+ }
39
62
}
40
63
41
64
/// Pick an unused port and start `safaridriver` using that port.
@@ -45,36 +68,130 @@ class SafariMacOsEnvironment extends WebDriverBrowserEnvironment {
45
68
/// again with a different port. Wait [_waitBetweenRetryInSeconds] seconds
46
69
/// between retries. Try up to [_maxRetryCount] times.
47
70
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 ();
53
90
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
+ );
55
97
56
- _driverProcess = await spawnDriverProcess ();
98
+ await webDriver! .windows.toList ();
99
+ } catch (_) {
100
+ print ('safaridriver failed to start.' );
57
101
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
+ }
67
124
}
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);
74
189
}
75
190
76
191
@override
77
192
Future <void > cleanup () async {
193
+ // WebDriver.quit() is not called here, because that's done in
194
+ // WebDriverBrowser.close().
78
195
_driverProcess.kill ();
79
196
}
80
197
}
0 commit comments