super-productivity/e2e/utils/runtime-errors.ts
Johannes Millan 238b91aa59
test(e2e): accept beforeunload prompt in shared dialog fallback (#9122)
The USE_REMOTE crash-resume spec reloads Client B mid-sync. While
isSyncInProgress is set, startup.service's beforeunload handler calls
preventDefault(), so Chrome raises a beforeunload prompt (the preceding
button click supplies the user activation it requires). Playwright
auto-accepts such prompts only when no 'dialog' listener is registered —
but installDevErrorDialogHandler registers one on every page and early-
returned on beforeunload, leaving the prompt unanswered. The navigation
then never starts and reload() times out (observed on SuperSync shard
6/6; the earlier load→domcontentloaded de-flake could not help because
navigation is blocked before any lifecycle event).

Accept beforeunload in the shared fallback, restoring Playwright's
default; other dialog types keep flowing to spec-specific handlers.
Corrects the two crash-resume comments that encoded the wrong theory.
2026-07-17 17:34:29 +02:00

110 lines
3.5 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import type { BrowserContext, Page } from '@playwright/test';
export type RuntimeBrowserError = {
type: 'pageerror';
message: string;
};
export const attachPageErrorCollector = (
page: Page,
label: string,
): RuntimeBrowserError[] => {
const errors: RuntimeBrowserError[] = [];
page.on('pageerror', (error) => {
const message = error.stack ?? error.message;
console.error(`[${label}] Page error:`, message);
errors.push({
type: 'pageerror',
message,
});
});
return errors;
};
// devError() in non-production builds opens window.alert("devERR: …") and then
// window.confirm("Throw an error for error? …"). Both are page-blocking and
// will hang any Playwright action (goto/click/waitFor) until handled. Tests
// that observe these dialogs explicitly can attach their own listener first;
// this fallback dismisses anything matching the devError shape so a stray call
// (e.g. selectProjectById on a project that sync just removed) fails the test
// with its real assertion instead of a 9-minute timeout.
//
// Call this on every Page created in E2E. It is wired into the default test
// fixture, setupSyncClient, createSimulatedClient, and createLegacyMigratedClient;
// any spec that builds its own context via browser.newContext() must invoke it
// explicitly, or it will be vulnerable to the same hang.
export const installDevErrorDialogHandler = (page: Page, label: string): void => {
page.on('dialog', async (dialog) => {
// Registering ANY 'dialog' listener switches off Playwright's built-in
// handling (accept for beforeunload, dismiss for everything else). This
// fallback is installed on every page, so it must restore that default for
// beforeunload: startup.service raises one whenever a sync is in flight, and
// an unanswered prompt blocks the navigation before it starts — reload()
// then times out on any waitUntil, however early.
if (dialog.type() === 'beforeunload') {
try {
await dialog.accept();
} catch {
// Already handled by another listener — ignore.
}
return;
}
const message = dialog.message();
const isDevErrorAlert = dialog.type() === 'alert' && message.startsWith('devERR:');
const isDevErrorConfirm =
dialog.type() === 'confirm' && message.startsWith('Throw an error for error?');
if (!isDevErrorAlert && !isDevErrorConfirm) {
return;
}
console.warn(
`[${label}] Auto-dismissing devError dialog (${dialog.type()}): ${message}`,
);
try {
await dialog.dismiss();
} catch {
// Already handled by another listener — ignore.
}
});
};
export const assertNoRuntimeBrowserErrors = (
errors: RuntimeBrowserError[],
label: string,
): void => {
if (errors.length === 0) {
return;
}
throw new Error(
`[${label}] Browser runtime errors were emitted during the test:\n${errors
.map((error, index) => `${index + 1}. ${error.type}: ${error.message}`)
.join('\n')}`,
);
};
export const guardContextCloseWithRuntimeErrorCheck = (
context: BrowserContext,
errors: RuntimeBrowserError[],
label: string,
): void => {
const originalClose = context.close.bind(context);
context.close = async (...args: Parameters<BrowserContext['close']>) => {
let closeError: unknown;
try {
await originalClose(...args);
} catch (error) {
closeError = error;
}
assertNoRuntimeBrowserErrors(errors, label);
if (closeError) {
throw closeError;
}
};
};