super-productivity/e2e/pages/base.page.ts
Johannes Millan 919b247cf0 test(e2e): fix supersync test flakiness from navigation blocking and unhandled dialogs
- Add waitForLoadState in addTask() to handle in-flight Angular navigation
- Use noWaitAfter on all sync button right-clicks to prevent navigation blocking
- Restructure syncAndWait() to continuously check for dialogs during sync
  instead of checking once then blocking on spinner
- Register native window.confirm/alert handler in syncAndWait() for dialogs
  that appear mid-sync (e.g., data repair, fresh client confirmation)
- Make hasTaskInWorklog() skip re-navigation when already on worklog page
  and increase timeouts for more reliable worklog checks
2026-02-12 20:01:14 +01:00

222 lines
8.1 KiB
TypeScript

import { type Locator, type Page } from '@playwright/test';
import { safeIsVisible } from '../utils/element-helpers';
export abstract class BasePage {
protected page: Page;
protected routerWrapper: Locator;
protected backdrop: Locator;
protected testPrefix: string;
constructor(page: Page, testPrefix: string = '') {
this.page = page;
this.routerWrapper = page.locator('.route-wrapper');
this.backdrop = page.locator('.backdrop');
this.testPrefix = testPrefix;
}
/**
* Apply the test prefix to a value for test isolation.
* Returns the original value if already prefixed or no prefix is set.
* @param value - The value to prefix (task name, project name, etc.)
* @returns The prefixed value
*/
protected applyPrefix(value: string): string {
if (!this.testPrefix || value.startsWith(this.testPrefix)) {
return value;
}
return `${this.testPrefix}-${value}`;
}
/**
* Ensures all overlay backdrops and dialogs are removed from the DOM before proceeding.
* This is critical before interacting with elements that might be blocked by overlays.
* Uses Escape key to dismiss overlays if they don't close naturally.
*/
async ensureOverlaysClosed(): Promise<void> {
const backdrop = this.page.locator('.cdk-overlay-backdrop');
const dialogContainer = this.page.locator('mat-dialog-container');
const menuPanel = this.page.locator('.mat-mdc-menu-panel');
// Check if any overlays, dialogs, or menus are present
const backdropCount = await backdrop.count();
const dialogCount = await dialogContainer.count();
const menuCount = await menuPanel.count();
if (backdropCount === 0 && dialogCount === 0 && menuCount === 0) {
return; // No overlays, dialogs, or menus - nothing to do
}
// Orphaned backdrops (no dialog, no menu) - Escape won't help, just wait for Material cleanup
if (backdropCount > 0 && dialogCount === 0 && menuCount === 0) {
console.log(
`[ensureOverlaysClosed] Found ${backdropCount} orphaned backdrop(s), waiting for cleanup`,
);
await backdrop
.first()
.waitFor({ state: 'detached', timeout: 3000 })
.catch(() => {});
return;
}
// Overlays/dialogs/menus present - try dismissing with Escape
console.log(
`[ensureOverlaysClosed] Found ${backdropCount} backdrop(s), ${dialogCount} dialog(s), ` +
`and ${menuCount} menu(s), attempting to dismiss with Escape`,
);
// Wait for any running animations to complete before dismissing
await this.page
.waitForFunction(
() =>
document.getAnimations().filter((a) => a.playState === 'running').length === 0,
{ timeout: 2000 },
)
.catch(() => {
// Ignore timeout - proceed anyway if animations can't be detected
});
await this.page.keyboard.press('Escape');
try {
// Wait for backdrop, dialog, and menu to be removed
const waitPromises: Promise<void>[] = [];
if (backdropCount > 0) {
waitPromises.push(backdrop.first().waitFor({ state: 'detached', timeout: 3000 }));
}
if (dialogCount > 0) {
waitPromises.push(
dialogContainer.first().waitFor({ state: 'detached', timeout: 3000 }),
);
}
if (menuCount > 0) {
waitPromises.push(
menuPanel.first().waitFor({ state: 'detached', timeout: 3000 }),
);
}
await Promise.all(waitPromises);
} catch (e) {
// Fallback: try Escape again for stacked overlays
const remainingBackdrops = await backdrop.count();
const remainingDialogs = await dialogContainer.count();
const remainingMenus = await menuPanel.count();
if (remainingBackdrops > 0 || remainingDialogs > 0 || remainingMenus > 0) {
console.warn(
`[ensureOverlaysClosed] ${remainingBackdrops} backdrop(s), ${remainingDialogs} dialog(s), ` +
`and ${remainingMenus} menu(s) still present after first Escape, trying again`,
);
await this.page.keyboard.press('Escape');
// Give Angular animations time to complete
await this.page.waitForTimeout(300);
await Promise.all([
backdrop
.first()
.waitFor({ state: 'detached', timeout: 2000 })
.catch(() => {}),
dialogContainer
.first()
.waitFor({ state: 'detached', timeout: 2000 })
.catch(() => {}),
menuPanel
.first()
.waitFor({ state: 'detached', timeout: 2000 })
.catch(() => {}),
]).catch(() => {
console.error(
'[ensureOverlaysClosed] Failed to close overlays after multiple attempts',
);
});
}
}
}
async addTask(taskName: string, skipClose = false): Promise<void> {
// Add test prefix to task name for isolation
const prefixedTaskName = this.applyPrefix(taskName);
// Wait for any in-flight navigation to complete before interacting.
// Angular hash-based routing can block Playwright's fill/click operations.
await this.page.waitForLoadState('domcontentloaded').catch(() => {});
// Dismiss any blocking dialogs/overlays before interacting
await this.ensureOverlaysClosed();
const inputEl = this.page.locator('add-task-bar.global input');
// Check if input is visible - if not, try clicking the add button
const isInputVisible = await inputEl
.first()
.isVisible()
.catch(() => false);
if (!isInputVisible) {
const addBtn = this.page.locator('.tour-addBtn');
// Wait for add button with longer timeout - it depends on config loading
await addBtn.waitFor({ state: 'visible', timeout: 20000 });
await addBtn.click();
}
// Ensure input is visible - Playwright auto-waits for actionability
const input = inputEl.first();
await input.waitFor({ state: 'visible', timeout: 10000 });
// Clear and fill input - Playwright handles waiting for interactability
await input.click();
await input.clear();
await input.fill(prefixedTaskName);
// Store the initial count before submission
const initialCount = await this.page.locator('task').count();
const expectedCount = initialCount + 1;
// Click submit button
const submitBtn = this.page.locator('.e2e-add-task-submit');
await submitBtn.click();
// Check if a dialog appeared (e.g., create tag dialog)
const dialogExists = await safeIsVisible(this.page.locator('mat-dialog-container'));
if (!dialogExists) {
// Wait for task to be created - check for the specific task
const maxWaitTime = 15000; // Increased from 10s to handle slow renders
const taskSelector = `task:has-text("${prefixedTaskName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}")`;
try {
// Primary: wait for the specific task to be visible
await this.page.locator(taskSelector).first().waitFor({
state: 'visible',
timeout: maxWaitTime,
});
} catch (error) {
// Fallback: verify task count increased (captures edge cases)
const finalCount = await this.page.locator('task').count();
if (finalCount < expectedCount) {
// Get fresh snapshot for error message after DOM settles
await this.page.waitForTimeout(500);
const tasks = await this.page.locator('task').allTextContents();
const currentCount = await this.page.locator('task').count();
throw new Error(
`Task creation failed. Expected ${expectedCount} tasks, but got ${currentCount}.\n` +
`Task name: "${prefixedTaskName}"\n` +
`Existing tasks: ${JSON.stringify(tasks, null, 2)}`,
);
}
}
}
if (!skipClose) {
// Close the add task bar by clicking the backdrop
// Use force: true to bypass element coverage checks (overlays may cover backdrop)
const backdropVisible = await safeIsVisible(this.backdrop);
if (backdropVisible) {
await this.backdrop.click({ force: true });
await this.backdrop.waitFor({ state: 'hidden', timeout: 3000 }).catch(() => {
// Non-fatal: backdrop might auto-hide
});
}
}
}
}