Skip to content

Commit e8e6b06

Browse files
committed
Add safari browser, using AppleScript to start/stop
1 parent 2dc891a commit e8e6b06

File tree

6 files changed

+207
-19
lines changed

6 files changed

+207
-19
lines changed

.github/workflows/CI.yaml

+4
Original file line numberDiff line numberDiff line change
@@ -57,3 +57,7 @@ jobs:
5757

5858
- name: Check system browsers
5959
run: node bin/qtap.js -v -b firefox -b chrome -b chromium -b edge test/pass.html
60+
61+
- name: Check system browsers (Safari)
62+
if: ${{ runner.os == 'macOS' }}
63+
run: node bin/qtap.js -v -b safari test/pass.html

ARCHITECTURE.md

+20-7
Original file line numberDiff line numberDiff line change
@@ -69,19 +69,33 @@ One of the passed parameters is a standard [`AbortSignal` object](https://develo
6969

7070
```js
7171
// Using our utility
72-
async function myBrowser(url, signal, logger) {
72+
async function myBrowser (url, signal, logger) {
7373
await LocalBrowser.spawn(['/bin/mybrowser'], ['-headless', url], signal, logger);
7474
}
7575

76-
// Minimal custom implementation on native Node.js
77-
async function myBrowser(url, signal, logger) {
76+
// Minimal sub process
77+
import child_process from 'node:child_process';
78+
async function myBrowser (url, signal, logger) {
7879
logger.debug('Spawning /bin/mybrowser');
7980
const spawned = child_process.spawn('/bin/mybrowser', ['-headless', url], { signal });
8081
await new Promise((resolve, reject) => {
8182
spawned.on('error', (error) => reject(error));
8283
spawned.on('exit', (code) => reject(new Error(`Process exited ${code}`)));
8384
});
8485
}
86+
87+
// Minimal custom
88+
async function myBrowser (url, signal, logger) {
89+
// * start browser and navigate to `url`
90+
// * if you encounter problems, throw
91+
await new Promise((resolve, reject) => {
92+
// * once browser has stopped, call resolve()
93+
// * if you encounter problems, call reject()
94+
signal.addEventListener('abort', () => {
95+
// stop browser
96+
});
97+
});
98+
}
8599
```
86100

87101
Alternatives considered:
@@ -150,14 +164,13 @@ Alternatives considered:
150164
// Using our utility
151165
import qtap from 'qtap';
152166

153-
function myBrowser (url, signal, logger) {
167+
async function myBrowser (url, signal, logger) {
154168
await qtap.LocalBrowser.spawn(['/bin/mybrowser'], ['-headless', url], signal, logger );
155169
}
156170

157-
// Minimal custom implementation
171+
// Minimal sub process
158172
import child_process from 'node:child_process';
159-
160-
function myBrowser (url, signal, logger) {
173+
async function myBrowser (url, signal, logger) {
161174
const spawned = child_process.spawn('/bin/mybrowser', ['-headless', url], { signal });
162175
await new Promise((resolve, reject) => {
163176
spawned.on('error', (error) => {

eslint.config.js

+2
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ export default [
1515
rules: {
1616
'comma-dangle': 'off',
1717
'multiline-ternary': 'off',
18+
'no-throw-literal': 'off',
19+
'object-shorthand': 'off',
1820
'operator-linebreak': ['error', 'before'],
1921
}
2022
}

src/browsers.js

+167-6
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import os from 'node:os';
66
import path from 'node:path';
77

88
import which from 'which';
9-
import { concatGenFn } from './util.js';
9+
import { concatGenFn, globalSignal } from './util.js';
1010

1111
const QTAP_DEBUG = process.env.QTAP_DEBUG === '1';
1212
const tempDirs = [];
@@ -96,7 +96,10 @@ const LocalBrowser = {
9696
/**
9797
* Create a new temporary directory and return its name.
9898
*
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.
100103
*
101104
* @returns {string}
102105
*/
@@ -279,22 +282,180 @@ async function chromium (paths, url, signal, logger) {
279282
'--disable-gpu',
280283
'--disable-dev-shm-usage'
281284
]),
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+
),
283288
url
284289
];
285290
await LocalBrowser.spawn(paths, args, signal, logger);
286291
}
287292

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+
288451
export default {
289452
LocalBrowser,
290453

291454
firefox,
292455
chrome: chromium.bind(null, concatGenFn(getChromePaths, getChromiumPaths, getEdgePaths)),
293456
chromium: chromium.bind(null, concatGenFn(getChromiumPaths, getChromePaths, getEdgePaths)),
294457
edge: chromium.bind(null, concatGenFn(getEdgePaths)),
295-
296-
//
297-
// TODO: safari: [],
458+
safari,
298459

299460
// TODO: browserstack
300461
// - browserstack/firefox_45

src/server.js

+11-6
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ class ControlServer {
9999
const controller = new AbortController();
100100
const summary = { ok: true };
101101

102-
const CLIENT_IDLE_TIMEOUT = 5000;
102+
const CLIENT_IDLE_TIMEOUT = 60_000;
103103
const CLIENT_IDLE_INTERVAL = 1000;
104104
let clientIdleTimer = null;
105105

@@ -137,21 +137,21 @@ class ControlServer {
137137
};
138138

139139
const tapFinishFinder = tapFinished({ wait: 0 }, () => {
140-
logger.debug('browser_tap_finished', 'Requesting browser stop');
140+
logger.debug('browser_tap_finished', 'Test has finished, stopping browser');
141141

142142
stopBrowser('QTap: browser_tap_finished');
143143
});
144144

145145
const tapParser = new TapParser();
146146
tapParser.on('bailout', (reason) => {
147+
logger.warning('browser_tap_bailout', `Test ended unexpectedly, stopping browser. Reason: ${reason}`);
147148
summary.ok = false;
148-
logger.debug('browser_tap_bailout', reason);
149149

150150
stopBrowser('QTap: browser_tap_bailout');
151151
});
152152
tapParser.once('fail', () => {
153+
logger.debug('browser_tap_fail', 'Results indicate at least one test has failed assertions');
153154
summary.ok = false;
154-
logger.debug('browser_tap_fail', 'One or more tests failed');
155155
});
156156
// Debugging
157157
// tapParser.on('assert', logger.debug.bind(logger, 'browser_tap_assert'));
@@ -176,7 +176,7 @@ class ControlServer {
176176
// creating tons of timers when processing a large batch of test results back-to-back.
177177
clientIdleTimer = setTimeout(function qtapClientTimeout () {
178178
if ((performance.now() - browser.clientIdleActive) > CLIENT_IDLE_TIMEOUT) {
179-
logger.debug('browser_idle_timeout', 'Requesting browser stop');
179+
logger.warning('browser_idle_timeout', `Browser timed out after ${CLIENT_IDLE_TIMEOUT}ms, stopping browser`);
180180
// TODO:
181181
// Produce a tap line to report this test failure to CLI output/reporters.
182182
summary.ok = false;
@@ -318,7 +318,12 @@ class ControlServer {
318318

319319
const clientId = url.searchParams.get('qtap_clientId');
320320
if (url.pathname === '/' && clientId !== null) {
321-
this.logger.debug('respond_static_testfile', clientId);
321+
const browser = this.browsers.get(clientId);
322+
if (browser) {
323+
browser.logger.debug('browser_connected', 'Browser connected! Serving test file.');
324+
} else {
325+
this.logger.debug('respond_static_testfile', clientId);
326+
}
322327
resp.writeHead(200, { 'Content-Type': MIME_TYPES[ext] || MIME_TYPES.html });
323328
resp.write(await this.getTestFile(clientId));
324329
resp.end();

src/util.js

+3
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ export const MIME_TYPES = {
2020
woff: 'font/woff',
2121
};
2222

23+
export const globalController = new AbortController();
24+
export const globalSignal = globalController.signal;
25+
2326
/**
2427
* @param {number} msDuration
2528
* @returns {string} Something like "0.7", "2", or "3.1"

0 commit comments

Comments
 (0)