Skip to content

Commit 8318451

Browse files
committed
server: Implement automatic retry
Browser plugins may disable via `Browser.allowRetries = false;`, e.g. for implementations that talk to a remote service (which may have its own internal retry guruantees), or in-process emulation (e.g. jsdom, which might not warrant retries).
1 parent 43a5143 commit 8318451

File tree

7 files changed

+209
-107
lines changed

7 files changed

+209
-107
lines changed

eslint.config.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ export default [
2121
'no-throw-literal': 'off',
2222
'object-shorthand': 'off',
2323
'operator-linebreak': ['error', 'before'],
24+
'prefer-promise-reject-errors': 'off',
2425
}
2526
},
2627
{

src/qtap.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,7 @@ function run (files, browserNames = 'detect', runOptions = {}) {
169169
if (typeof browserFn !== 'function') {
170170
throw new Error('Unknown browser ' + browserName);
171171
}
172+
browserFn.getDisplayName = () => browserFn.displayName || browserName;
172173
for (const server of servers) {
173174
// Each launchBrowser() returns a Promise that settles when the browser exits.
174175
// Launch concurrently, and await afterwards.

src/safari.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ async function safari (url, signals, logger) {
111111

112112
// Back off from 50ms upto 1.0s each attempt
113113
const wait = Math.min(i * 50, 1000);
114-
logger.debug('safaridriver_waiting', `Attempt #${i}: ${e.code || e.cause.code}. Try again in ${wait}ms.`);
114+
logger.debug('safaridriver_waiting', `Attempt ${i}: ${e.code || e.cause.code}. Try again in ${wait}ms.`);
115115
await delay(wait);
116116
continue;
117117
}

src/server.js

Lines changed: 143 additions & 99 deletions
Original file line numberDiff line numberDiff line change
@@ -157,8 +157,85 @@ class ControlServer {
157157
async launchBrowser (browserFn, browserName, globalSignal) {
158158
const clientId = 'client_' + ControlServer.nextClientId++;
159159
const logger = this.logger.channel(`qtap_browser_${clientId}_${browserName}`);
160-
let clientIdleTimer = null;
161160

161+
// Serve the a test file from URL that looks like the original path when possible.
162+
//
163+
// - For static files, serve it from a URL that matches were it would be among the
164+
// other static files (even though it is treated special).
165+
// "foo/bar" => "/foo/bar"
166+
// "/tmp/foo/bar" => "/tmp/foo/bar"
167+
// - For external URLs, match the URL path, including query params, so that these
168+
// can be seen both server-side and client-side.
169+
//
170+
// NOTE: This is entirely cosmetic. For how the actual fetch, see fetchTestFile().
171+
// For how resources are requested client side, we use <base href> to ensure correctness.
172+
//
173+
// TODO: Add test case to validate the URL resemblance to test file path.
174+
//
175+
// Example: WordPress password-strength-meter.js inspects the hostname and path name
176+
// (e.g. www.mysite.test/mysite/). The test case for defaults observes this.
177+
// https://github.com/WordPress/wordpress-develop/blob/6.7.1/tests/qunit/wp-admin/js/password-strength-meter.js#L100
178+
let qtapUrlPath;
179+
if (this.isURL(this.testFile)) {
180+
const tmpUrl = new URL(this.testFile);
181+
tmpUrl.searchParams.set('qtap_clientId', clientId);
182+
qtapUrlPath = tmpUrl.pathname + tmpUrl.search;
183+
} else {
184+
qtapUrlPath = '/' + this.testFile + '?qtap_clientId=' + clientId;
185+
}
186+
const url = await this.getProxyBase() + qtapUrlPath;
187+
188+
const maxTries = browserFn.allowRetries === false ? 1 : 3;
189+
let i = 1;
190+
while (true) {
191+
try {
192+
// The 'client' event must be emitted:
193+
// * ... early, so that reporters can indicate the browser is starting.
194+
// * ... exactly once, regardless of retries.
195+
// * ... with the correct display name from browserFn.displayName, which may be set
196+
// dynamically by browserFn() before any async logic, as used by "detect"
197+
// (to expand to the selected browser), and by the BrowserStack plugin
198+
// (to expand chrome_latest).
199+
//
200+
// Separate launchBrowserAttempt() and its browserFn() call from the "await" statement,
201+
// so that we can emit the 'client' event right after calling browserFn.
202+
// For this to work, launchBrowserAttempt() must have no async logic before calling browserFn.
203+
// If we awaited here directly, the event would not be emitted until after the client has
204+
// finished, which defeats its purpose for reporters.
205+
const browserPromise = this.launchBrowserAttempt(browserFn, browserName, globalSignal, clientId, url, logger);
206+
207+
if (i === 1) {
208+
this.eventbus.emit('client', {
209+
clientId,
210+
testFile: this.testFile,
211+
browserName,
212+
displayName: browserFn.getDisplayName(),
213+
});
214+
}
215+
216+
const result = await browserPromise;
217+
this.eventbus.emit('result', result);
218+
return;
219+
} catch (e) {
220+
// Do not retry for test-specific bail reasons, which are expected to be consistent,
221+
// and unrelated to the browser.
222+
// Only retry for uncaught errors from browserFn, and for BrowserConnectTimeout.
223+
if (i >= maxTries || e instanceof util.BrowserStopSignal) {
224+
if (e instanceof util.BrowserStopSignal || e instanceof util.BrowserConnectTimeout) {
225+
this.eventbus.emit('bail', { clientId, reason: e.message });
226+
return;
227+
} else {
228+
throw e;
229+
}
230+
}
231+
232+
i++;
233+
logger.debug('browser_connect_retry', `Retrying, attempt ${i} of ${maxTries}`);
234+
}
235+
}
236+
}
237+
238+
async launchBrowserAttempt (browserFn, browserName, globalSignal, clientId, url, logger) {
162239
const controller = new AbortController();
163240
let signal = controller.signal;
164241
if (this.debugMode) {
@@ -168,58 +245,61 @@ class ControlServer {
168245
logger.warning('browser_signal_debugging', 'Keeping browser open for debugging');
169246
});
170247
}
248+
const signals = { browser: signal, global: globalSignal };
171249

172-
/**
173-
* Reasons to stop a browser, whichever comes first:
174-
* 1. tap-finished.
175-
* 2. tap-parser 'bailout' event (client knows it crashed).
176-
* 3. timeout (client didn't connect, client idle and presumed lost, or a silent crashed).
177-
*
178-
* @param {string} messageCode
179-
* @param {string} [reason]
180-
* @param {Object} [finalResult]
181-
*/
182-
const stopBrowser = async (messageCode, reason = '', finalResult = null) => {
183-
// Ignore any duplicate or late reasons to stop
184-
if (!this.browsers.has(clientId)) return;
185-
186-
clearTimeout(clientIdleTimer);
187-
this.browsers.delete(clientId);
188-
controller.abort(`QTap: ${messageCode}`);
189-
190-
if (finalResult) {
191-
this.eventbus.emit('result', {
192-
clientId,
193-
ok: finalResult.ok,
194-
total: finalResult.count,
195-
// avoid precomputed `finalResult.todo` because that would double-count passing todos
196-
passed: finalResult.pass + finalResult.todos.length,
197-
// avoid precomputed `finalResult.fail` because that includes todos (expected failure)
198-
failed: finalResult.failures.length,
199-
skips: finalResult.skips,
200-
todos: finalResult.todos,
201-
failures: finalResult.failures,
202-
});
203-
} else {
204-
this.eventbus.emit('bail', {
205-
clientId,
206-
reason,
207-
});
250+
let clientIdleTimer;
251+
252+
const browser = {
253+
logger,
254+
clientIdleActive: null,
255+
getDisplayName () {
256+
return browserFn.getDisplayName();
257+
},
258+
/**
259+
* Reasons to stop a browser, whichever comes first:
260+
* 1. tap-finished (client has sent us the test results).
261+
* 2. tap-parser 'bailout' event (client knows it crashed).
262+
* 3. timeout (client didn't connect, client idle and presumed lost, or a silent crash).
263+
*
264+
* @param {any} reason
265+
*/
266+
stop: async (reason) => {
267+
if (!this.browsers.has(clientId)) {
268+
// Ignore any duplicate or late reasons to stop
269+
return;
270+
}
271+
272+
clearTimeout(clientIdleTimer);
273+
this.browsers.delete(clientId);
274+
controller.abort(reason);
208275
}
209276
};
210277

278+
let result;
211279
const tapParser = tapFinished({ wait: 0 }, (finalResult) => {
212-
logger.debug('browser_tap_finished', 'Test run finished, stopping browser', {
280+
result = {
281+
clientId,
213282
ok: finalResult.ok,
214283
total: finalResult.count,
284+
// avoid precomputed `finalResult.todo` because that would double-count passing todos
285+
passed: finalResult.pass + finalResult.todos.length,
286+
// avoid precomputed `finalResult.fail` because that includes todos (expected failure)
215287
failed: finalResult.failures.length,
288+
skips: finalResult.skips,
289+
todos: finalResult.todos,
290+
failures: finalResult.failures,
291+
};
292+
logger.debug('browser_tap_finished', 'Test run finished, stopping browser', {
293+
ok: result.ok,
294+
total: result.total,
295+
failed: result.failed,
216296
});
217-
stopBrowser('browser_tap_finished', '', finalResult);
297+
browser.stop(new util.BrowserStopSignal('browser_tap_finished'));
218298
});
219299

220300
tapParser.on('bailout', (reason) => {
221-
logger.warning('browser_tap_bailout', `Test run bailed, stopping browser. Reason: ${reason}`);
222-
stopBrowser('browser_tap_bailout', reason);
301+
logger.warning('browser_tap_bailout', 'Test run bailed, stopping browser');
302+
browser.stop(new util.BrowserStopSignal(reason));
223303
});
224304

225305
tapParser.on('comment', (comment) => {
@@ -252,14 +332,7 @@ class ControlServer {
252332
// tapParser.once('fail', () => logger.debug('browser_tap_fail', 'Found one or more failing tests'));
253333
// tapParser.on('plan', logger.debug.bind(logger, 'browser_tap_plan'));
254334

255-
const browser = {
256-
logger,
257-
tapParser,
258-
clientIdleActive: null,
259-
getDisplayName () {
260-
return (browserFn.displayName || browserFn.name || browserName || 'Browser').slice(0, 50);
261-
}
262-
};
335+
browser.tapParser = tapParser;
263336
this.browsers.set(clientId, browser);
264337

265338
// Optimization: The naive approach would be to clearTimeout+setTimeout on every tap line,
@@ -271,78 +344,49 @@ class ControlServer {
271344
const qtapCheckTimeout = () => {
272345
if (!browser.clientIdleActive) {
273346
if ((performance.now() - browserStart) > (this.connectTimeout * 1000)) {
274-
logger.warning('browser_connect_timeout', `Browser did not start within ${this.connectTimeout}s, stopping browser`);
275-
stopBrowser('browser_connect_timeout', `Browser did not start within ${this.connectTimeout}s`);
347+
const reason = `Browser did not start within ${this.connectTimeout}s`;
348+
logger.warning('browser_connect_timeout', reason);
349+
browser.stop(new util.BrowserConnectTimeout(reason));
276350
return;
277351
}
278352
} else {
279353
if ((performance.now() - browser.clientIdleActive) > (this.idleTimeout * 1000)) {
280-
logger.warning('browser_idle_timeout', `Browser idle for ${this.idleTimeout}s, stopping browser`);
281-
stopBrowser('browser_idle_timeout', `Browser idle for ${this.idleTimeout}s`);
354+
const reason = `Browser idle for ${this.idleTimeout}s`;
355+
logger.warning('browser_idle_timeout', reason);
356+
browser.stop(new util.BrowserStopSignal(reason));
282357
return;
283358
}
284359
}
285360
clientIdleTimer = setTimeout(qtapCheckTimeout, TIMEOUT_CHECK_MS);
286361
};
287362
clientIdleTimer = setTimeout(qtapCheckTimeout, TIMEOUT_CHECK_MS);
288363

289-
// Serve the a test file from URL that looks like the original path when possible.
290-
//
291-
// - For static files, serve it from a URL that matches were it would be among the
292-
// other static files (even though it is treated special).
293-
// "foo/bar" => "/foo/bar"
294-
// "/tmp/foo/bar" => "/tmp/foo/bar"
295-
// - For external URLs, match the URL path, including query params, so that these
296-
// can be seen both server-side and client-side.
297-
//
298-
// NOTE: This is entirely cosmetic. For how the actual fetch, see fetchTestFile().
299-
// For how resources are requested client side, we use <base href> to ensure correctness.
300-
//
301-
// TODO: Add test case to validate the URL resemblance to test file path.
302-
//
303-
// Example: WordPress password-strength-meter.js inspects the hostname and path name
304-
// (e.g. www.mysite.test/mysite/). The test case for defaults observes this.
305-
// https://github.com/WordPress/wordpress-develop/blob/6.7.1/tests/qunit/wp-admin/js/password-strength-meter.js#L100
306-
let qtapUrlPath;
307-
if (this.isURL(this.testFile)) {
308-
const tmpUrl = new URL(this.testFile);
309-
tmpUrl.searchParams.set('qtap_clientId', clientId);
310-
qtapUrlPath = tmpUrl.pathname + tmpUrl.search;
311-
} else {
312-
qtapUrlPath = '/' + this.testFile + '?qtap_clientId=' + clientId;
313-
}
314-
315-
const url = await this.getProxyBase() + qtapUrlPath;
316-
const signals = { browser: signal, global: globalSignal };
317-
318364
try {
319365
logger.debug('browser_launch_call');
320366

321-
// Separate "browserFn()" call from the "await", so that we can emit an event
322-
// right after calling it (which may set Browser.displayName). If we awaited here,
323-
// then the event would be emitted after the client is done instead of when it starts.
324-
const browerPromise = browserFn(url, signals, logger, this.debugMode);
325-
this.eventbus.emit('client', {
326-
clientId,
327-
testFile: this.testFile,
328-
browserName,
329-
displayName: browser.getDisplayName(),
330-
});
331-
await browerPromise;
367+
await browserFn(url, signals, logger, this.debugMode);
332368

333-
// This particular stopBrowser() is most likely a no-op (e.g. we test results
334-
// are complete, or there was an error, and we already asked the browser to stop).
335-
// In case the browser ended by itself for some other reason, call it again here
336-
// so that we can convey it as an error if we didn't ask it to stop.
369+
// Usually browserFn() will return because we asked via browser.stop(), e.g. tests finished,
370+
// bailed, or timed out. In case the browser ended by itself, we call browser.stop() here,
371+
// so that if we didn't called it before, this will report an error.
372+
// Also, this ensures the signal can clean up any resources created by browserFn.
337373
logger.debug('browser_launch_exit');
338-
stopBrowser('browser_launch_exit', 'Browser ended unexpectedly');
374+
browser.stop(new util.BrowserStopSignal('Browser ended unexpectedly'));
339375
} catch (e) {
376+
// Silence any errors from browserFn that happen after we called browser.stop().
340377
if (!signal.aborted) {
341378
logger.warning('browser_launch_error', e);
342-
stopBrowser('browser_launch_error', 'Browser ended unexpectedly');
379+
browser.stop(new util.BrowserStopSignal('Browser ended unexpectedly'));
343380
throw e;
344381
}
345382
}
383+
384+
if (!result && signal.reason) {
385+
// Throw BrowserConnectTimeout for retry purposes.
386+
throw signal.reason;
387+
}
388+
389+
return result;
346390
}
347391

348392
async getTestFile (clientId) {

src/util.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,10 @@ export function replaceOnce (input, patterns, replacement) {
8787

8888
export class CommandNotFoundError extends Error {}
8989

90+
export class BrowserStopSignal extends Error {}
91+
92+
export class BrowserConnectTimeout extends Error {}
93+
9094
export const LocalBrowser = {
9195
/**
9296
* @param {string|Array<string|null>|Generator<string|null|undefined>} paths

test/fixtures/qtap.config.js

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
async function fake (url) {
22
fake.displayName = 'FakeBrowser';
33

4-
// Fetch page to indicate that we're online, and to fetch fake results.
4+
// Fetch page to indicate that the client connected, and to fetch the fake results.
55
// We use the response for real to validate that `files` and `cwd` are
66
// resolved and served correctly.
77
const body = await (await fetch(url)).text();
@@ -20,21 +20,44 @@ async function fake (url) {
2020
});
2121
}
2222

23-
async function fakeSlow (url, signals) {
23+
async function fakeSlowFail (url, signals) {
2424
await new Promise((resolve, reject) => {
2525
setTimeout(() => {
26-
reject(new Error('Still alive after 3s. connectTimeout not working?'));
26+
reject('Still alive after 3s. connectTimeout not working?');
2727
}, 3000);
2828

2929
signals.browser.addEventListener('abort', () => {
3030
reject(new Error('Bye'));
3131
});
3232
});
3333
}
34+
fakeSlowFail.allowRetries = false;
35+
36+
async function fakeRefuse (_url, signals) {
37+
await new Promise((resolve, reject) => {
38+
signals.browser.addEventListener('abort', () => {
39+
reject('I dare you, I double dare you. Do not try to restart me.');
40+
});
41+
});
42+
}
43+
fakeRefuse.allowRetries = false;
44+
45+
const snoozedFiles = {};
46+
async function fakeLazy (url) {
47+
const path = new URL(url).pathname;
48+
snoozedFiles[path] ??= 0;
49+
snoozedFiles[path]++;
50+
if (snoozedFiles[path] < 3) {
51+
throw 'Meh, I do not want to start. Ask me again later!';
52+
}
53+
return await fake(url);
54+
}
3455

3556
export default {
3657
browsers: {
3758
fake,
38-
fakeSlow,
59+
fakeSlowFail,
60+
fakeRefuse,
61+
fakeLazy,
3962
}
4063
};

0 commit comments

Comments
 (0)