Skip to content

Commit 54d4797

Browse files
committed
build: switch to macos-13 to fix missing Firefox
macOS 14 and macOS 15 are exclusively available as Arm64, which Microsoft has catered to Google and its Chrome browser only, omitting Firefox. The paid "large" GitHub Action runners do offer macOS 14/15 on Intel and those do include Firefox, even on the latest version. Ref actions/runner-images#9974
1 parent 95b5d00 commit 54d4797

File tree

5 files changed

+137
-130
lines changed

5 files changed

+137
-130
lines changed

.github/workflows/CI.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,9 @@ jobs:
2727
node: 20.x
2828

2929
# Includes Firefox, Google Chrome, Safari
30-
# https://github.com/actions/runner-images/blob/macos-15/20241217.493/images/macos/macos-15-Readme.md
30+
# https://github.com/actions/runner-images/blob/macos-13/20241216.479/images/macos/macos-13-Readme.md
3131
- name: "macOS: Node 20"
32-
os: macos-15
32+
os: macos-13
3333
node: 20.x
3434

3535
name: ${{ matrix.name }}

API.md

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,21 @@ function that returns a Promise.
3636

3737
```js
3838
/**
39+
* A browser launcher is responsible for knowing whether the process failed to
40+
* launch or spawn, and whether it exited unexpectedly.
41+
*
42+
* A launcher is not responsible for knowing whether it succeeded in
43+
* opening or navigating to the given URL.
44+
*
45+
* It is the responsiblity of ControlServer to send the "abort" event
46+
* to AbortSignal if it believes the browser has failed to load the
47+
* URL within a reasonable timeout, or if the browser has not sent
48+
* any message for a while.
49+
*
50+
* If a browser exits on its own (i.e. ControlServer did not call send
51+
* an abort signal), then launch() should throw an Error or reject its
52+
* returned Promise.
53+
*
3954
* @param {string} url
4055
* URL that the browser should navigate to, HTTP or HTTPS.
4156
* @param {AbortSignal} signal
@@ -100,10 +115,6 @@ class MyBrowser {
100115
async launch(url, signal, logger) {
101116
// ...
102117
}
103-
104-
async cleanupOnce() {
105-
// Optional
106-
}
107118
};
108119

109120
export default {

src/browsers.js

Lines changed: 89 additions & 118 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import which from 'which';
99

1010
const QTAP_DEBUG = process.env.QTAP_DEBUG === '1';
1111

12-
class LocalBrowser {
12+
const LocalBrowser = {
1313
/**
1414
* @param {string|Array<string|null>|Iterator<string|null>} candidates
1515
* Path to an executable command or an iterable list of candidate paths to
@@ -26,22 +26,23 @@ class LocalBrowser {
2626
* @param {AbortSignal} signal
2727
* @return {Promise}
2828
*/
29-
static async spawn (candidates, args, signal, logger) {
29+
async spawn (candidates, args, signal, logger) {
3030
if (typeof candidates === 'string') {
3131
candidates = [candidates];
3232
}
3333
let exe;
3434
for (const candidate of candidates) {
3535
if (candidate !== null) {
36-
logger.debug('browser_exe_check', candidate);
3736
// Optimization: Use fs.existsSync. It is on par with accessSync and statSync,
3837
// and beats concurrent fs/promises.access(cb) via Promise.all().
3938
// Starting the promise chain alone takes the same time as a loop with
4039
// 5x existsSync(), not even counting the await and boilerplate to manage it all.
4140
if (fs.existsSync(candidate)) {
42-
logger.debug('browser_exe_found');
41+
logger.debug('browser_exe_found', candidate);
4342
exe = candidate;
4443
break;
44+
} else {
45+
logger.debug('browser_exe_check', candidate);
4546
}
4647
}
4748
}
@@ -85,24 +86,24 @@ class LocalBrowser {
8586
}
8687
});
8788
});
88-
}
89+
},
8990

9091
/**
9192
* Create a new temporary directory and return its name.
9293
*
9394
* @returns {string}
9495
*/
95-
static makeTempDir () {
96+
makeTempDir () {
9697
// Use mkdtemp (instead of only tmpdir) to avoid clash with past or concurrent qtap procesess.
9798
return fs.mkdtempSync(path.join(os.tmpdir(), 'qtap_'));
98-
}
99+
},
99100

100101
/**
101102
* Detect Windows Subsystem for Linux
102103
*
103104
* @returns {bool}
104105
*/
105-
static isWsl () {
106+
isWsl () {
106107
try {
107108
return (
108109
process.platform === 'linux'
@@ -118,133 +119,103 @@ class LocalBrowser {
118119
return false;
119120
}
120121
}
122+
};
121123

122-
/**
123-
* A browser is responsible for knowing whether the process failed to
124-
* launch or spawn, and whether it exited unexpectedly.
125-
*
126-
* A browser is not responsible for knowing whether it succeeded in
127-
* navigating to the given URL.
128-
*
129-
* It is the responsiblity of ControlServer to call controller.abort(),
130-
* if it believes the browser has likely failed to load the start URL
131-
* (e.g. a reasonable timeout if a browser has not sent its first TAP
132-
* message, or has not sent anything else for a while).
133-
*
134-
* If a browser exits on its own (i.e. ControlServer did not call
135-
* controller.abort), then start() should throw an Error or reject its
136-
* returned Promise.
137-
*
138-
* @param {string} url
139-
* @param {AbortSignal} signal
140-
* @param {qtap-Logger} logger
141-
* @return {Promise}
142-
*/
143-
static async launch (url, signal, logger) {
144-
throw new Error('not implemented');
124+
async function firefox (url, signal, logger) {
125+
const profileDir = LocalBrowser.makeTempDir();
126+
const args = [url, '-profile', profileDir, '-no-remote', '-wait-for-browser'];
127+
if (!QTAP_DEBUG) {
128+
args.push('-headless');
145129
}
146130

147-
/**
148-
* Clean up any shared resources.
149-
*
150-
* The same browser may start() several times concurrently in order
151-
* to test multiple URLs. In general, anything started or created
152-
* by start() should also be stopped or otherwise cleaned up by start().
153-
*
154-
* If you lazy-create any shared resources (such as a tunnel connection
155-
* for a cloud browser provider, a server or other socket, a cache directory,
156-
* etc) then this method can be used to tear those down once at the end
157-
* of the qtap process.
158-
*/
159-
// TODO: Implement cleanupOnce support. Use case: browserstack tunnel.
160-
// async cleanupOnce () {
161-
// }
131+
// http://kb.mozillazine.org/About:config_entries
132+
// https://github.com/sitespeedio/browsertime/blob/v23.5.0/lib/firefox/settings/firefoxPreferences.js
133+
// https://github.com/airtap/the-last-browser-launcher/blob/v0.1.1/lib/launch/index.js
134+
// https://github.com/karma-runner/karma-firefox-launcher/blob/v2.1.3/index.js
135+
logger.debug('firefox_prefs_create', 'Creating temporary prefs.js file');
136+
fs.writeFileSync(path.join(profileDir, 'prefs.js'), firefox.createPrefsJs({
137+
'app.update.disabledForTesting': true, // Disable auto-updates
138+
'browser.sessionstore.resume_from_crash': false,
139+
'browser.shell.checkDefaultBrowser': false,
140+
'dom.disable_open_during_load': false,
141+
'dom.max_script_run_time': 0, // Disable "slow script" dialogs
142+
'extensions.autoDisableScopes': 1,
143+
'extensions.update.enabled': false, // Disable auto-updates
144+
145+
// Blank home, blank new tab, disable extra welcome tabs for "first launch"
146+
'browser.EULA.override': true,
147+
'browser.startup.firstrunSkipsHomepage': false,
148+
'browser.startup.page': 0,
149+
'datareporting.policy.dataSubmissionPolicyBypassNotification': true, // Avoid extra tab for mozilla.org/en-US/privacy/firefox/
150+
'startup.homepage_override_url': '',
151+
'startup.homepage_welcome_url': '',
152+
'startup.homepage_welcome_url.additional': '',
153+
154+
// Performance optimizations
155+
'browser.bookmarks.max_backups': 0, // Optimization, via sitespeedio/browsertime
156+
'browser.bookmarks.restore_default_bookmarks': false, // Optimization
157+
'browser.cache.disk.capacity': 0, // Optimization: Avoid disk writes
158+
'browser.cache.disk.smart_size.enabled': false, // Optimization
159+
'browser.chrome.guess_favicon': false, // Optimization: Avoid likely 404 for unspecified favicon
160+
'browser.pagethumbnails.capturing_disabled': true, // Optimization, via sitespeedio/browsertime
161+
'browser.search.region': 'US', // Optimization: Avoid internal geoip lookup
162+
'browser.sessionstore.enabled': false, // Optimization
163+
'dom.min_background_timeout_value': 10, // Optimization, via https://github.com/karma-runner/karma-firefox-launcher/issues/19
164+
}));
165+
166+
try {
167+
await LocalBrowser.spawn(firefox.getCandidates(), args, signal, logger);
168+
} finally {
169+
// On Windows, when spawn() returns after firefox.exe has stopped, it sometimes can't delete
170+
// some temporary files yet as they're somehow still in use (EBUSY). Perhaps a race condition,
171+
// or an lagged background process?
172+
// > BUSY: resource busy or locked,
173+
// > unlink 'C:\Users\RUNNER~1\AppData\Local\Temp\qtap_EZ4IoO\bounce-tracking-protection.sqlite'
174+
// Enable `maxRetries` to cover the common case, and beyond that try-catch
175+
// as it is not critical for test completion.
176+
// TODO: Can we abstract this so that it happens automatically for any directory
177+
// obtained via LocalBrowser.makeTempDir?
178+
try {
179+
fs.rmSync(profileDir, { recursive: true, force: true, maxRetries: 2 });
180+
} catch (e) {
181+
logger.warning('firefox_cleanup_fail', e);
182+
}
183+
}
162184
}
163185

164-
class FirefoxBrowser {
165-
* getCandidates () {
166-
if (process.env.FIREFOX_BIN) yield process.env.FIREFOX_BIN;
167-
168-
// Find /usr/bin/firefox on platforms like linux (including WSL), freebsd, openbsd.
169-
yield which.sync('firefox', { nothrow: true });
170-
171-
if (process.platform === 'darwin') {
172-
if (process.env.HOME) yield process.env.HOME + '/Applications/Firefox.app/Contents/MacOS/firefox';
173-
yield '/Applications/Firefox.app/Contents/MacOS/firefox';
174-
}
186+
firefox.getCandidates = function * () {
187+
if (process.env.FIREFOX_BIN) yield process.env.FIREFOX_BIN;
175188

176-
if (process.platform === 'win32') {
177-
if (process.env.PROGRAMFILES) yield process.env.PROGRAMFILES + '\\Mozilla Firefox\\firefox.exe';
178-
if (process.env['PROGRAMFILES(X86)']) yield process.env['PROGRAMFILES(X86)'] + '\\Mozilla Firefox\\firefox.exe';
179-
yield 'C:\\Program Files\\Mozilla Firefox\\firefox.exe';
180-
}
189+
// Find /usr/bin/firefox on platforms like linux (including WSL), freebsd, openbsd.
190+
yield which.sync('firefox', { nothrow: true });
181191

182-
// TODO: Support launching Firefox on Windows from inside WSL
183-
// if (LocalBrowser.isWsl()) { }
192+
if (process.platform === 'darwin') {
193+
if (process.env.HOME) yield process.env.HOME + '/Applications/Firefox.app/Contents/MacOS/firefox';
194+
yield '/Applications/Firefox.app/Contents/MacOS/firefox';
184195
}
185196

186-
static createPrefsJs (prefs) {
187-
let js = '';
188-
for (const key in prefs) {
189-
js += 'user_pref("' + key + '", ' + JSON.stringify(prefs[key]) + ');\n';
190-
}
191-
return js;
197+
if (process.platform === 'win32') {
198+
if (process.env.PROGRAMFILES) yield process.env.PROGRAMFILES + '\\Mozilla Firefox\\firefox.exe';
199+
if (process.env['PROGRAMFILES(X86)']) yield process.env['PROGRAMFILES(X86)'] + '\\Mozilla Firefox\\firefox.exe';
200+
yield 'C:\\Program Files\\Mozilla Firefox\\firefox.exe';
192201
}
193202

194-
async launch (url, signal, logger) {
195-
const profileDir = LocalBrowser.makeTempDir();
196-
const args = [url, '-profile', profileDir, '-no-remote', '-wait-for-browser'];
197-
if (!QTAP_DEBUG) {
198-
args.push('-headless');
199-
}
200-
201-
// http://kb.mozillazine.org/About:config_entries
202-
// https://github.com/sitespeedio/browsertime/blob/v23.5.0/lib/firefox/settings/firefoxPreferences.js
203-
// https://github.com/airtap/the-last-browser-launcher/blob/v0.1.1/lib/launch/index.js
204-
// https://github.com/karma-runner/karma-firefox-launcher/blob/v2.1.3/index.js
205-
logger.debug('firefox_prefs_create', 'Creating temporary prefs.js file');
206-
fs.writeFileSync(path.join(profileDir, 'prefs.js'), FirefoxBrowser.createPrefsJs({
207-
'app.update.disabledForTesting': true, // Disable auto-updates
208-
'browser.sessionstore.resume_from_crash': false,
209-
'browser.shell.checkDefaultBrowser': false,
210-
'dom.disable_open_during_load': false,
211-
'dom.max_script_run_time': 0, // Disable "slow script" dialogs
212-
'extensions.autoDisableScopes': 1,
213-
'extensions.update.enabled': false, // Disable auto-updates
214-
215-
// Blank home, blank new tab, disable extra welcome tabs for "first launch"
216-
'browser.EULA.override': true,
217-
'browser.startup.firstrunSkipsHomepage': false,
218-
'browser.startup.page': 0,
219-
'datareporting.policy.dataSubmissionPolicyBypassNotification': true, // Avoid extra tab for mozilla.org/en-US/privacy/firefox/
220-
'startup.homepage_override_url': '',
221-
'startup.homepage_welcome_url': '',
222-
'startup.homepage_welcome_url.additional': '',
223-
224-
// Performance optimizations
225-
'browser.bookmarks.max_backups': 0, // Optimization, via sitespeedio/browsertime
226-
'browser.bookmarks.restore_default_bookmarks': false, // Optimization
227-
'browser.cache.disk.capacity': 0, // Optimization: Avoid disk writes
228-
'browser.cache.disk.smart_size.enabled': false, // Optimization
229-
'browser.chrome.guess_favicon': false, // Optimization: Avoid likely 404 for unspecified favicon
230-
'browser.pagethumbnails.capturing_disabled': true, // Optimization, via sitespeedio/browsertime
231-
'browser.search.region': 'US', // Optimization: Avoid internal geoip lookup
232-
'browser.sessionstore.enabled': false, // Optimization
233-
'dom.min_background_timeout_value': 10, // Optimization, via https://github.com/karma-runner/karma-firefox-launcher/issues/19
234-
}));
203+
// TODO: Support launching Firefox on Windows from inside WSL
204+
// if (LocalBrowser.isWsl()) { }
205+
};
235206

236-
try {
237-
await LocalBrowser.spawn(this.getCandidates(), args, signal, logger);
238-
} finally {
239-
fs.rmSync(profileDir, { recursive: true, force: true });
240-
}
207+
firefox.createPrefsJs = function (prefs) {
208+
let js = '';
209+
for (const key in prefs) {
210+
js += 'user_pref("' + key + '", ' + JSON.stringify(prefs[key]) + ');\n';
241211
}
242-
}
212+
return js;
213+
};
243214

244215
export default {
245216
LocalBrowser,
246217

247-
firefox: FirefoxBrowser,
218+
firefox,
248219
// https://github.com/vweevers/win-detect-browsers/blob/v7.0.0/lib/browsers.js
249220
//
250221
// TODO: safari: [],

src/qtap.js

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -69,15 +69,14 @@ async function run (browserNames, files, options) {
6969
const browserLaunches = [];
7070
for (const browserName of browserNames) {
7171
logger.debug('get_browser', browserName);
72-
const Browser = browsers[browserName] || await getNonDefaultBrowser(browserName, options);
73-
if (!Browser) {
72+
const browserFn = browsers[browserName] || await getNonDefaultBrowser(browserName, options);
73+
if (typeof browserFn !== 'function') {
7474
throw new Error('Unknown browser ' + browserName);
7575
}
76-
const browser = new Browser(logger.channel('qtap_browser_' + browserName));
7776
for (const server of servers) {
7877
// Each launchBrowser() returns a Promise that settles when the browser exits.
7978
// Launch concurrently, and await afterwards.
80-
browserLaunches.push(server.launchBrowser(browser, browserName));
79+
browserLaunches.push(server.launchBrowser(browserFn, browserName));
8180
}
8281
}
8382

@@ -87,6 +86,12 @@ async function run (browserNames, files, options) {
8786
// exits naturally by itself.
8887
// TODO: Consider just calling process.exit after this await.
8988
// Is that faster and safe? What if any important clean up would we miss?
89+
// 1. Removing of temp directories is generally done in browser "launch" functions
90+
// after the child process has properly ended (and has to, as otherwise the
91+
// files are likely still locked and/or may end up re-created). If we were to
92+
// exit earlier, we may leave temp directories behind. This is fine when running
93+
// in an ephemeral environment (e.g. CI), but not great for local dev.
94+
//
9095
await Promise.allSettled(browserLaunches);
9196
// Await again, so that any error gets thrown accordingly,
9297
// we don't do this directly because we first want to wait for all tests
@@ -105,6 +110,26 @@ async function run (browserNames, files, options) {
105110
}
106111
}
107112

113+
/**
114+
* Clean up any shared resources.
115+
*
116+
* The same browser may start() several times concurrently in order
117+
* to test multiple URLs. In general, anything started or created
118+
* by start() should also be stopped or otherwise cleaned up by start().
119+
*
120+
* If you lazy-create any shared resources (such as a tunnel connection
121+
* for a cloud browser provider, a server or other socket, a cache directory,
122+
* etc) then this method can be used to tear those down once at the end
123+
* of the qtap process.
124+
*/
125+
// TODO: Implement Browser.cleanupOnce somehow. Use case: browserstack tunnel.
126+
// Each browser launched by it will presumably lazily start the tunnel
127+
// on the first browser launch, but only after the last browser stopped
128+
// should the tunnel be cleaned up.
129+
// Alternative: Some kind of global callback for clean up.
130+
// Perhpas implmement a global qtap.on('cleanup'), which could be use for
131+
// temp dirs as well.
132+
108133
// TODO: Return exit status, to ease programmatic use and testing.
109134
// TODO: Add parameter for stdout used by reporters.
110135
}

0 commit comments

Comments
 (0)