@@ -6,7 +6,7 @@ import os from 'node:os';
6
6
import path from 'node:path' ;
7
7
8
8
import which from 'which' ;
9
- import { concatGenFn } from './util.js' ;
9
+ import { concatGenFn , globalSignal } from './util.js' ;
10
10
11
11
const QTAP_DEBUG = process . env . QTAP_DEBUG === '1' ;
12
12
const tempDirs = [ ] ;
@@ -96,7 +96,10 @@ const LocalBrowser = {
96
96
/**
97
97
* Create a new temporary directory and return its name.
98
98
*
99
- * The newly created directory will automatically will cleaned up.
99
+ * This creates subdirectories inside Node.js `os.tmpdir`, which honors
100
+ * any TMPDIR, TMP, or TEMP environment variable.
101
+ *
102
+ * The newly created directory is automatically cleaned up at the end of the process.
100
103
*
101
104
* @returns {string }
102
105
*/
@@ -279,22 +282,180 @@ async function chromium (paths, url, signal, logger) {
279
282
'--disable-gpu' ,
280
283
'--disable-dev-shm-usage'
281
284
] ) ,
282
- ...( process . env . CHROMIUM_FLAGS ? process . env . CHROMIUM_FLAGS . split ( / \s + / ) : [ ] ) ,
285
+ ...( process . env . CHROMIUM_FLAGS ? process . env . CHROMIUM_FLAGS . split ( / \s + / ) : (
286
+ process . env . CI ? [ '--no-sandbox' ] : [ ] )
287
+ ) ,
283
288
url
284
289
] ;
285
290
await LocalBrowser . spawn ( paths , args , signal , logger ) ;
286
291
}
287
292
293
+ /**
294
+ * Known approaches:
295
+ *
296
+ * - `Safari <file>`. This does not allow URLs. Safari allows only local files to be passed.
297
+ *
298
+ * - `Safari redirect.html`, without other arguments, worked from 2012-2018, as used by Karma.
299
+ * This "trampoline" approach involves creating a temporary HTML file
300
+ * with `<script>window.location='<url>';</script>`, which we open instead.
301
+ * https://github.com/karma-runner/karma-safari-launcher/blob/v1.0.0/index.js
302
+ * https://github.com/karma-runner/karma/blob/v0.3.5/lib/launcher.js#L213
303
+ * https://github.com/karma-runner/karma/commit/5513fd66ae
304
+ *
305
+ * This is no longer viable after macOS 10.14 Mojave, because macOS SIP prompts the user
306
+ * due to our temporary file being outside `~/Library/Containers/com.apple.Safari`.
307
+ * https://github.com/karma-runner/karma-safari-launcher/issues/29
308
+ *
309
+ * - `open -F -W -n -b com.apple.Safari <url>`. This starts correctly, but doesn't expose
310
+ * a PID to cleanly end the process.
311
+ * https://github.com/karma-runner/karma-safari-launcher/issues/29
312
+ *
313
+ * - `Safari container/redirect.html`. macOS SIP denies this by default for the same reason.
314
+ * But, as long as you grant an exemption to Terminal to write to Safari's container, or
315
+ * grant it Full Disk Access, this is viable.
316
+ * https://github.com/flutter/engine/pull/27567
317
+ * https://github.com/marcoscaceres/karma-safaritechpreview-launcher/issues/7
318
+ *
319
+ * It seems that GitHub CI has pre-approved limited access in its macOS images, to make
320
+ * this work [1][2]. This might be viable if it is tolerable to prompt on first local use,
321
+ * and require granting said access to the Terminal in general (which has lasting
322
+ * consequences beyond QTap).
323
+ *
324
+ * - native app Swift/ObjectiveC proxy. This reportedly works but requires
325
+ * a binary which requires compilation and makes auditing significantly harder.
326
+ * https://github.com/karma-runner/karma-safari-launcher/issues/29
327
+ * https://github.com/muthu90ec/karma-safarinative-launcher/
328
+ *
329
+ * - `osascript -e <script>`
330
+ * As of macOS 13 Ventura (or earlier?), this results in a prompt for
331
+ * "Terminal wants access to control Safari", from which osascript will eventually
332
+ * timeout and report "Safari got an error: AppleEvent timed out".
333
+ *
334
+ * While past discussions suggest that GitHub CI has this pre-approved [1][2],
335
+ * as of writing in Jan 2025 with macOS 13 images, this approval does not include
336
+ * access from Terminal to Safari, thus causing the same "AppleEvent timed out".
337
+ *
338
+ * https://github.com/brandonocasey/karma-safari-applescript-launcher
339
+ * https://github.com/brandonocasey/karma-safari-applescript-launcher/issues/5
340
+ *
341
+ * - `osascript MyScript.scpt`. This avoids the need for quote escaping in the URL, by
342
+ * injecting it properly as a parameter instead. Used by Google's karma-webkit-launcher
343
+ * https://github.com/google/karma-webkit-launcher/commit/31a2ad8037
344
+ *
345
+ * - `safaridriver -p <port>`, and then make an HTTP request to create a session,
346
+ * navigate the session, and to delete the session. This addresses all the concerns,
347
+ * and seems to be the best as of 2025. The only downside is that it requires a bit
348
+ * more code (available port, and HTTP requests).
349
+ * https://github.com/flutter/engine/pull/33757
350
+ *
351
+ * See also:
352
+ * - Unresolved as of writing, https://github.com/testem/testem/issues/1387
353
+ * - Unresolved as of writing, https://github.com/emberjs/data/issues/7170
354
+ *
355
+ * [1]: https://github.com/actions/runner-images/issues/4201
356
+ * [2]: https://github.com/actions/runner-images/issues/7531
357
+ */
358
+ let pSafariDriverPort = null ;
359
+
360
+ async function launchSafariDriver ( safaridriverBin , logger ) {
361
+ async function findAvailablePort ( ) {
362
+ const net = await import ( 'node:net' ) ;
363
+ return new Promise ( ( resolve , reject ) => {
364
+ const srv = net . createServer ( ) ;
365
+ srv . listen ( 0 , ( ) => {
366
+ const port = srv . address ( ) . port ;
367
+ srv . close ( ( ) => resolve ( port ) ) ;
368
+ } ) ;
369
+ } ) ;
370
+ }
371
+
372
+ const port = await findAvailablePort ( ) ;
373
+ LocalBrowser . spawn ( safaridriverBin , [ '-p' , port ] , globalSignal , logger ) ;
374
+ return port ;
375
+ }
376
+
377
+ async function safari ( url , signal , logger ) {
378
+ if ( ! pSafariDriverPort ) {
379
+ // Support overriding via SAFARIDRIVER_BIN to Safari Technology Preview.
380
+ // https://developer.apple.com/documentation/webkit/testing-with-webdriver-in-safari
381
+ const safaridriverBin = process . env . SAFARIDRIVER_BIN || which . sync ( 'safaridriver' , { nothrow : true } ) ;
382
+ if ( process . platform !== 'darwin' || ! safaridriverBin ) {
383
+ throw new Error ( 'Safari requires macOS and safaridriver' ) ;
384
+ }
385
+ pSafariDriverPort = launchSafariDriver ( safaridriverBin , logger ) ;
386
+ } else {
387
+ // This is not an optimization. Safari can only be claimed by one safaridriver.
388
+ logger . debug ( 'safaridriver_reuse' , 'Found existing safaridriver process' ) ;
389
+ }
390
+ const port = await pSafariDriverPort ;
391
+
392
+ // https://developer.apple.com/documentation/webkit/macos-webdriver-commands-for-safari-12-and-later
393
+ async function webdriverReq ( method , endpoint , body ) {
394
+ // Since Node.js 18, connecting to "localhost" favours IPv6 (::1), whereas safaridriver
395
+ // listens exclusively on IPv4 (127.0.0.1). This was fixed in Node.js 20 by trying both.
396
+ // https://github.com/nodejs/node/issues/40702
397
+ // https://github.com/nodejs/node/pull/44731
398
+ // https://github.com/node-fetch/node-fetch/issues/1624
399
+ const resp = await fetch ( `http://127.0.0.1:${ port } ${ endpoint } ` , {
400
+ method : method ,
401
+ body : JSON . stringify ( body )
402
+ } ) ;
403
+ const data = await resp . json ( ) ;
404
+ if ( ! resp . ok ) {
405
+ throw `HTTP ${ resp . status } ${ data ?. value ?. error } , ${ data ?. value ?. message || '' } ` ;
406
+ }
407
+ return data . value ;
408
+ }
409
+
410
+ let sessionId ;
411
+ for ( let i = 1 ; i <= 20 ; i ++ ) {
412
+ try {
413
+ const session = await webdriverReq ( 'POST' , '/session/' , { capabilities : { browserName : 'safari' } } ) ;
414
+ sessionId = session . sessionId ;
415
+ // Connected!
416
+ break ;
417
+ } catch ( e ) {
418
+ if ( e && ( e . code === 'ECONNREFUSED' || ( e . cause && e . cause . code === 'ECONNREFUSED' ) ) ) {
419
+ // Wait another 10ms-200ms for safaridriver to start, upto ~2s in total.
420
+ logger . debug ( 'safaridriver_waiting' , `Attempt #${ i } : ${ e . code || e . cause . code } . Try again in ${ i * 10 } ms.` ) ;
421
+ await new Promise ( resolve => setTimeout ( resolve , i * 10 ) ) ;
422
+ continue ;
423
+ }
424
+ logger . warning ( 'safaridriver_session_error' , e ) ;
425
+ throw new Error ( 'Failed to create new session' ) ;
426
+ }
427
+ }
428
+
429
+ try {
430
+ await webdriverReq ( 'POST' , `/session/${ sessionId } /url` , { url : url } ) ;
431
+ } catch ( e ) {
432
+ logger . warning ( 'safaridriver_url_error' , e ) ;
433
+ throw new Error ( 'Failed to create new tab' ) ;
434
+ }
435
+
436
+ // NOTE: If we didn't support concurrency, the `signal` could kill the safaridriver process,
437
+ // which would automatically closes our tabs, not needing an 'abort' listener and DELETE.
438
+ await new Promise ( ( resolve , reject ) => {
439
+ signal . addEventListener ( 'abort' , async ( ) => {
440
+ try {
441
+ await webdriverReq ( 'DELETE' , `/session/${ sessionId } ` ) ;
442
+ resolve ( ) ;
443
+ } catch ( e ) {
444
+ logger . warning ( 'safaridriver_delete_error' , e ) ;
445
+ reject ( new Error ( 'Unable to stop safaridriver session' ) ) ;
446
+ }
447
+ } ) ;
448
+ } ) ;
449
+ }
450
+
288
451
export default {
289
452
LocalBrowser,
290
453
291
454
firefox,
292
455
chrome : chromium . bind ( null , concatGenFn ( getChromePaths , getChromiumPaths , getEdgePaths ) ) ,
293
456
chromium : chromium . bind ( null , concatGenFn ( getChromiumPaths , getChromePaths , getEdgePaths ) ) ,
294
457
edge : chromium . bind ( null , concatGenFn ( getEdgePaths ) ) ,
295
-
296
- //
297
- // TODO: safari: [],
458
+ safari,
298
459
299
460
// TODO: browserstack
300
461
// - browserstack/firefox_45
0 commit comments