Skip to content

Commit

Permalink
Add safari browser, using AppleScript to start/stop
Browse files Browse the repository at this point in the history
  • Loading branch information
Krinkle committed Jan 5, 2025
1 parent 2dc891a commit eee845d
Show file tree
Hide file tree
Showing 3 changed files with 107 additions and 11 deletions.
4 changes: 4 additions & 0 deletions .github/workflows/CI.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,7 @@ jobs:

- name: Check system browsers
run: node bin/qtap.js -v -b firefox -b chrome -b chromium -b edge test/pass.html

- name: Check system browsers (Safari)
if: ${{ runner.os == 'macOS' }}
run: node bin/qtap.js -v -b safari test/pass.html
106 changes: 99 additions & 7 deletions src/browsers.js
Original file line number Diff line number Diff line change
Expand Up @@ -96,13 +96,16 @@ const LocalBrowser = {
/**
* Create a new temporary directory and return its name.
*
* The newly created directory will automatically will cleaned up.
* This defaults to creating subdirectories inside Node.js `os.tmpdir`, which honors
* any TMPDIR, TMP, or TEMP environment variable.
*
* The newly created directory is automatically cleaned up at the end of the process.
*
* @returns {string}
*/
makeTempDir () {
makeTempDir (parentDir = os.tmpdir()) {
// Use mkdtemp (instead of only tmpdir) to avoid clash with past or concurrent qtap procesess.
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'qtap_'));
const dir = fs.mkdtempSync(path.join(parentDir, 'qtap_'));
tempDirs.push(dir);
return dir;
},
Expand Down Expand Up @@ -279,22 +282,111 @@ async function chromium (paths, url, signal, logger) {
'--disable-gpu',
'--disable-dev-shm-usage'
]),
...(process.env.CHROMIUM_FLAGS ? process.env.CHROMIUM_FLAGS.split(/\s+/) : []),
...(process.env.CHROMIUM_FLAGS ? process.env.CHROMIUM_FLAGS.split(/\s+/) : (
process.env.CI ? ['--no-sandbox'] : [])
),
url
];
await LocalBrowser.spawn(paths, args, signal, logger);
}

/**
* Known approaches:
*
* - `Safari <file>`. This does not allow URLs.
*
* - `Safari redirect.html`, without other arguments. This worked from 2012-2018 as used by Karma.
* Safari allows only local files to be passed, no URLs. Karma created a temporary HTML file
* with `<script>window.location='<url>';</script>`, and opened that instead.
* https://github.com/karma-runner/karma-safari-launcher/blob/v1.0.0/index.js
* https://github.com/karma-runner/karma/blob/v0.3.5/lib/launcher.js#L213
* https://github.com/karma-runner/karma/commit/5513fd66ae
*
* This is no longer viable after macOS 10.14 Mojave, because macOS SIP prompts the user
* due to our temporary file being outside `~/Library/Containers/com.apple.Safari`.
* https://github.com/karma-runner/karma-safari-launcher/issues/29
*
* - `Safari container/redirect.html`. macOS SIP denies this by default for the same reason.
* It is used by by some packages, with the caveat that you grant an exemption to Terminal
* to write to Safari's container and/or grant it Full Disk Access.
* https://github.com/marcoscaceres/karma-safaritechpreview-launcher/issues/7
*
* - `open -F -W -n -b com.apple.Safari <url>`. This starts correctly but exposes
* no PID to cleanly end the process.
* https://github.com/karma-runner/karma-safari-launcher/issues/29#issuecomment-501101074
*
* - native app Swift/ObjectiveC proxy. This reportedly works but requires
* a binary which requires compilation and makes auditing significantly harder.
* https://github.com/karma-runner/karma-safari-launcher/issues/29
* https://github.com/muthu90ec/karma-safarinative-launcher/
*
* - `osascript -e <script>`
* As of macOS 13 Ventura (or earlier?), this results in a prompt for
* "Terminal wants access to control Safari", from which osascript will eventually
* timeout and report "Safari got an error: AppleEvent timed out".
* https://github.com/brandonocasey/karma-safari-applescript-launcher
* https://github.com/brandonocasey/karma-safari-applescript-launcher/issues/5
*
* This approach does, however, work in GitHub CI where macOS images have this pre-approved.
* https://github.com/actions/runner-images/issues/4201
* https://github.com/actions/runner-images/issues/7531
*
* - `osascript MyScript.scpt`. This avoids the need for quote escaping in the URL, by
* injecting it properly as a parameter instead. Used by Google's karma-webkit-launcher
* https://github.com/google/karma-webkit-launcher/commit/31a2ad8037
*
* See also:
* - Unresolved as of writing, https://github.com/testem/testem/issues/1387
* - Unresolved as of writing, https://github.com/emberjs/data/issues/7170
*/
async function safari (url, signal, logger) {
const osascriptBin = which.sync('osascript', { nothrow: true });
if (process.platform !== 'darwin' || !osascriptBin) {
throw new Error('Safari requires macOS and osascript');
}

// TODO: Replace with argv to avoid escaping issue.
// https://github.com/google/karma-webkit-launcher/blob/ff5e7903ee4eca16fc8d1c1c40b96c70701797b3/scripts/LaunchSafari.scpt
const startScript = `
set wasopen to false
if application "Safari" is running then set wasopen to true
tell application "Safari"
make new document with properties {URL:${JSON.stringify(url)}}
end tell
return wasopen
`;

const wasOpen = cp.execFileSync(osascriptBin, ['-e', startScript], {
encoding: 'utf8',
timeout: 3 * 60 * 1000 // 3 minutes, SIP prompt times out after 2.5 min
}).trim();

await new Promise((resolve, reject) => {
signal.addEventListener('abort', () => {
const stopScript = `
tell application "Safari"
close documents where URL = ${JSON.stringify(url)}
${wasOpen === 'true' ? '' : 'quit'}
end tell
`;
try {
cp.execFileSync(osascriptBin, ['-e', stopScript], { timeout: 6000 });
resolve();
} catch (e) {
reject(e);
}
});
});
}

export default {
LocalBrowser,

firefox,
chrome: chromium.bind(null, concatGenFn(getChromePaths, getChromiumPaths, getEdgePaths)),
chromium: chromium.bind(null, concatGenFn(getChromiumPaths, getChromePaths, getEdgePaths)),
edge: chromium.bind(null, concatGenFn(getEdgePaths)),

//
// TODO: safari: [],
safari,

// TODO: browserstack
// - browserstack/firefox_45
Expand Down
8 changes: 4 additions & 4 deletions src/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -137,21 +137,21 @@ class ControlServer {
};

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

stopBrowser('QTap: browser_tap_finished');
});

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

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

0 comments on commit eee845d

Please sign in to comment.