super-productivity/e2e/utils/waits.ts
Johannes Millan 77f83c5687
test(e2e): harden failure signals and provider gates (#7753)
* test: harden e2e failure signals

Fail otherwise-passing E2E tests on browser runtime errors, keep Playwright retries disabled, preserve Docker E2E exit codes, and make plugin/WebDAV setup failures hard failures instead of logged or skipped conditions.

* test: harden provider e2e runners

Make WebDAV and SuperSync runner scripts require provider readiness, preserve cleanup and argument forwarding, and fail manual sync clients on uncaught page errors.

* ci: require providers for scheduled e2e

Set required-provider flags in scheduled WebDAV and SuperSync jobs, and remove the duplicate provider runner scripts while keeping local npm aliases inline.

* test: catch e2e teardown pageerrors and tighten fixture

- closeClient now asserts runtime errors AFTER context.close() so
  pageerrors emitted during teardown (Angular destroy hooks, late RxJS
  errors) are captured instead of dropped. Matches the pattern in
  guardContextCloseWithRuntimeErrorCheck.
- test.fixture.ts isolatedContext now spreads Playwright's merged
  contextOptions instead of destructuring 23 fields by hand. Future
  option additions propagate automatically; the page fixture uses the
  shared attachPageErrorCollector and only fails on pageerror (not
  console.error, which is too noisy). Guards against a configured 0
  timeout being treated as undefined.
- plugin-loading.spec.ts second test now hard-asserts that the plugin
  menu entry reappears after re-enable, matching the first test instead
  of silently logging when not visible.

* test(sync): stabilize ImmediateUploadService spec

Two complementary fixes for flaky failures observed under full-suite
random-order runs where the upload pipeline silently never fires:

- Pin navigator.onLine = true in beforeEach (restored in afterEach).
  isOnline() inside _canUpload reads navigator.onLine directly. The
  keyboard-layout spec replaces the whole navigator and the is-online
  spec spies on it; if order or restoration ever drifts, every "should
  fire upload" test fails trivially while the "should NOT" tests pass.
- Replace tick(2100) with tick(2000); flush(). The await chain inside
  withSession() (provider.isReady, withSession entry, uploadPendingOps,
  optional LWW re-upload) requires more microtask drain than tick's
  fixed-time window reliably provides under load. flush() drains the
  pipeline regardless.

* test(e2e): guard skipOnboarding init script against data: frames

The new page-error collector started failing plugin specs because
addInitScript runs in every frame — including the empty data:text/html
iframe that plugin-index swaps in on destroy — and localStorage access
in a data: URL throws SecurityError. Wrap the four setItem calls in
try/catch so the helper noops in storage-less frames.
2026-05-23 20:33:04 +02:00

236 lines
8.3 KiB
TypeScript

import { type Page } from '@playwright/test';
const DEFAULT_ROUTE_REGEX = /#\/(tag|project)\/.+\/tasks/;
type WaitForAppReadyOptions = {
/**
* Additional selector that should be visible before returning.
*/
selector?: string;
/**
* Whether to wait for a work-view style route.
*/
ensureRoute?: boolean;
/**
* Custom route regex if ensureRoute is enabled.
*/
routeRegex?: RegExp;
};
export const skipOnboardingForE2E = (): void => {
// Playwright's addInitScript runs in every frame, including iframes the app
// creates with a `data:` URL (e.g. plugin-index destroys its iframe by
// swapping in `data:text/html,<html><body></body></html>`). data: URLs have
// an opaque origin and accessing localStorage throws SecurityError, which
// the runtime-error collector would surface as a test failure.
try {
localStorage.setItem('SUP_ONBOARDING_PRESET_DONE', 'true');
localStorage.setItem('SUP_ONBOARDING_HINTS_DONE', 'true');
localStorage.setItem('SUP_IS_SHOW_TOUR', 'true');
localStorage.setItem('SUP_EXAMPLE_TASKS_CREATED', 'true');
} catch {
// No localStorage in this frame (data:/sandboxed); the host frame already ran us.
}
};
/**
* Dismiss up to `maxAttempts` blocking confirmation dialogs.
* Some app flows show chained dialogs (e.g., pre-migration + data-repair)
* that must be dismissed before the app shell becomes interactive.
*/
const dismissBlockingDialogs = async (page: Page, maxAttempts = 3): Promise<void> => {
for (let i = 0; i < maxAttempts; i++) {
try {
const dialogConfirmBtn = page.locator('dialog-confirm button[e2e="confirmBtn"]');
await dialogConfirmBtn.waitFor({ state: 'visible', timeout: 2000 });
await dialogConfirmBtn.click();
await page.waitForTimeout(500);
} catch {
break;
}
}
};
/**
* Simplified wait that relies on Playwright's auto-waiting.
* Previously used Angular testability API to check Zone.js stability.
* Now just checks DOM readiness - Playwright handles element actionability.
*
* Experiment showed: Angular stability checks not needed for most UI tests.
* Playwright's auto-waiting (before click, fill, assertions) is sufficient.
*/
export const waitForAngularStability = async (
page: Page,
timeout = 3000,
): Promise<void> => {
await page.waitForFunction(
() =>
document.readyState === 'complete' && !!document.querySelector('.route-wrapper'),
{ timeout },
);
};
/**
* Dismisses the onboarding preset selection screen if present.
* Sets the localStorage flag to skip it without altering app feature config.
*/
const dismissOnboardingPresets = async (page: Page): Promise<void> => {
try {
const isVisible = await page
.locator('onboarding-preset-selection')
.waitFor({ state: 'visible', timeout: 2000 })
.then(() => true)
.catch(() => false);
if (isVisible) {
await page.evaluate(skipOnboardingForE2E);
await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForTimeout(500);
}
} catch {
// Preset selection not present, ignore
}
};
/**
* Shared helper to wait until the application shell and Angular are ready.
* Optimized for speed - removed networkidle wait and redundant checks.
*
* Note: The app shows a loading screen while initial sync and data load completes.
* This screen hides the .route-wrapper, so we must wait for loading to complete first.
*/
export const waitForAppReady = async (
page: Page,
options: WaitForAppReadyOptions = {},
): Promise<void> => {
const { selector, ensureRoute = true, routeRegex = DEFAULT_ROUTE_REGEX } = options;
// Wait for initial page load
await page.waitForLoadState('domcontentloaded');
// Handle any blocking dialogs (pre-migration, confirmation, etc.)
// These dialogs block app until dismissed
await dismissBlockingDialogs(page);
// Dismiss onboarding preset selection if present (blocks entire UI)
await dismissOnboardingPresets(page);
// Wait for the loading screen to disappear (if visible).
// The app shows `.loading-full-page-wrapper` while syncing/importing data.
const loadingWrapper = page.locator('.loading-full-page-wrapper');
try {
const isLoadingVisible = await loadingWrapper.isVisible().catch(() => false);
if (isLoadingVisible) {
await loadingWrapper.waitFor({ state: 'hidden', timeout: 30000 });
}
} catch {
// Loading screen might not appear at all - that's fine
}
// Wait for route to match (if required)
// Use longer timeout with retry for slow-loading apps (especially second client)
if (ensureRoute) {
try {
await page.waitForURL(routeRegex, { timeout: 20000 });
} catch {
// If route doesn't match, try refreshing once and waiting again
console.log('[waitForAppReady] Route timeout, attempting reload...');
await page.reload();
await page.waitForLoadState('domcontentloaded');
await page.waitForURL(routeRegex, { timeout: 15000 });
}
}
// Re-check loading screen (may appear after initial check during Angular bootstrap)
const loadingRecheck = page.locator('.loading-full-page-wrapper');
const isStillLoading = await loadingRecheck.isVisible().catch(() => false);
if (isStillLoading) {
await loadingRecheck.waitFor({ state: 'hidden', timeout: 30000 });
}
// Wait for main route wrapper to be visible (indicates app shell loaded)
try {
await page
.locator('.route-wrapper')
.first()
.waitFor({ state: 'visible', timeout: 15000 });
} catch {
// Safety net: reload and retry if app got stuck
console.log(
'[waitForAppReady] .route-wrapper not visible after 15s, attempting reload...',
);
await page.reload();
await page.waitForLoadState('domcontentloaded');
await dismissBlockingDialogs(page);
const rl = page.locator('.loading-full-page-wrapper');
if (await rl.isVisible().catch(() => false)) {
await rl.waitFor({ state: 'hidden', timeout: 30000 });
}
if (ensureRoute) {
await page.waitForURL(routeRegex, { timeout: 15000 });
}
await page
.locator('.route-wrapper')
.first()
.waitFor({ state: 'visible', timeout: 15000 });
}
// Wait for optional selector
if (selector) {
await page.locator(selector).first().waitFor({ state: 'visible', timeout: 8000 });
}
await waitForAngularStability(page).catch(() => {});
// Small buffer to ensure animations settle.
// Check if page is still open before waiting (handles test timeout scenarios)
if (!page.isClosed()) {
await page.waitForTimeout(200);
}
};
/**
* Wait for UI to settle after an action (e.g., adding a task).
* Uses Angular stability as the primary signal rather than fixed timeouts.
* Falls back to a minimal timeout if Angular stability check fails.
*/
export const waitForUISettle = async (page: Page): Promise<void> => {
if (page.isClosed()) {
return;
}
try {
await waitForAngularStability(page, 2000);
} catch {
// Fall back to minimal fixed timeout if stability check fails
if (!page.isClosed()) {
await page.waitForTimeout(200);
}
}
};
/**
* Wait for local state changes to persist before triggering sync.
* This ensures IndexedDB writes have completed after UI state changes.
* Uses Angular stability + networkidle as indicators that async operations have settled.
*
* Note: IndexedDB writes happen asynchronously outside of Angular's zone and network
* requests, so we add an explicit delay after stability checks to ensure persistence
* completes before operations like page reload.
*/
export const waitForStatePersistence = async (page: Page): Promise<void> => {
// Check if page is still open
if (page.isClosed()) {
return;
}
// Wait for Angular to become stable (async operations complete)
await waitForAngularStability(page, 3000).catch(() => {});
// Wait for any pending network requests to complete
await page.waitForLoadState('networkidle', { timeout: 3000 }).catch(() => {});
// Additional delay for IndexedDB writes to complete (they happen outside Angular zone)
// The operation log effects use concatMap which serializes writes, but the actual
// IndexedDB transaction may still be pending when Angular reports stability.
// Reduced from 500ms to 200ms as a minimal safety buffer - Angular stability check
// already ensures most async work is complete.
if (!page.isClosed()) {
await page.waitForTimeout(200);
}
};