71 lines
3.4 KiB
JavaScript
71 lines
3.4 KiB
JavaScript
/**
|
|
* Recover once from an idempotent document GET's transient proxy failure.
|
|
* This never retries hydration, theme assertions, or mutations, and shares one
|
|
* 60-second budget across both attempts rather than extending the deadline.
|
|
* @param {(timeout: number) => Promise<{ok(): boolean, status(): number} | null>} navigate
|
|
* @param {string} url
|
|
* @param {() => number} now
|
|
*/
|
|
export async function navigateBrandDocument(navigate, url, now = Date.now) {
|
|
const deadline = now() + 60000;
|
|
for (let attempt = 1; attempt <= 2; attempt++) {
|
|
const remaining = deadline - now();
|
|
if (remaining <= 0) throw new Error(`Document navigation budget exhausted: ${url}`);
|
|
try {
|
|
const response = await navigate(remaining);
|
|
if (response?.ok()) return response;
|
|
const status = response?.status();
|
|
if (attempt === 1 && [500, 502, 503, 504].includes(status ?? 0)) {
|
|
console.warn('Brand navigation proxy recovery', JSON.stringify({url, status, attempt}));
|
|
continue;
|
|
}
|
|
throw new Error(`Document navigation failed: ${url}, HTTP ${status ?? 'no response'}`);
|
|
} catch (error) {
|
|
if (attempt === 1 && /ECONNRESET|net::ERR_(?:CONNECTION_RESET|CONNECTION_CLOSED|EMPTY_RESPONSE|ABORTED)/.test(String(error))) {
|
|
console.warn('Brand navigation connection recovery', JSON.stringify({url, error: String(error), attempt}));
|
|
continue;
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
throw new Error(`Document navigation failed: ${url}`);
|
|
}
|
|
|
|
/** Load a document fully before relying on server-rendered text or leaving it.
|
|
* Only document GETs may recover; application assertions remain outside retry.
|
|
* @param {import('playwright-core').Page} page
|
|
* @param {string} url
|
|
* @param {boolean} reload
|
|
*/
|
|
export async function visitBrowserDocument(page, url, reload = false) {
|
|
return navigateBrandDocument(async timeout => {
|
|
const started = Date.now();
|
|
const scripts = new Set(), failures = [];
|
|
const requested = request => { if (request.resourceType() === 'script') scripts.add(request); };
|
|
const failed = request => { if (scripts.has(request)) failures.push(request.failure()?.errorText || 'unknown script failure'); };
|
|
page.on('request', requested);
|
|
page.on('requestfailed', failed);
|
|
try {
|
|
const response = await (reload ? page.reload({waitUntil: 'domcontentloaded', timeout}) : page.goto(url, {waitUntil: 'domcontentloaded', timeout}));
|
|
if (response?.ok()) {
|
|
const remaining = timeout - (Date.now() - started);
|
|
if (remaining <= 0) throw new Error('Document hydration budget exhausted: ' + url);
|
|
try {
|
|
// Responsive layouts may hide the toggle. Its mounted ready marker
|
|
// proves hydration; each caller still checks the controls it needs.
|
|
// A cancelled script may be a preload duplicate. Hydration is the
|
|
// authority; recover only when cancellation actually blocks startup.
|
|
await page.locator('button[data-theme-ready="true"]').first().waitFor({state: 'attached', timeout: failures.length ? Math.min(5000, remaining) : remaining});
|
|
} catch (error) {
|
|
if (failures.length) throw new Error('Document bootstrap script request failed: ' + failures.join('; '), {cause: error});
|
|
throw error;
|
|
}
|
|
}
|
|
return response;
|
|
} finally {
|
|
page.off('request', requested);
|
|
page.off('requestfailed', failed);
|
|
}
|
|
}, url);
|
|
}
|