mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-01-23 02:36:05 +00:00
refactor(e2e): simplify improvements per KISS/YAGNI review
Remove unused speculative abstractions: - Delete material-helpers.ts (223 lines, zero usages) - Delete retry-helpers.ts (186 lines, zero usages) - Revert unused assertion helpers - Revert unused timeout constants Keep valuable changes: - applyPrefix() helper in BasePage (used 4x) - ProjectPage now uses applyPrefix() for DRY - Simplified utils/index.ts barrel export
This commit is contained in:
parent
b544131921
commit
4ba9b72597
6 changed files with 10 additions and 607 deletions
|
|
@ -1,11 +1,6 @@
|
|||
/**
|
||||
* Standardized timeout constants for e2e tests.
|
||||
* Use these to ensure consistent timeout handling across all tests.
|
||||
*
|
||||
* Guidelines:
|
||||
* - Prefer Playwright's auto-waiting over explicit timeouts
|
||||
* - Use these constants when explicit waits are needed
|
||||
* - Document WHY a specific timeout value was chosen
|
||||
*/
|
||||
export const TIMEOUTS = {
|
||||
/** Standard wait for dialogs to appear/disappear */
|
||||
|
|
@ -23,7 +18,7 @@ export const TIMEOUTS = {
|
|||
/** Wait for tasks to become visible */
|
||||
TASK_VISIBLE: 10000,
|
||||
|
||||
/** Wait for UI animations to complete (Material Design transitions) */
|
||||
/** Wait for UI animations to complete */
|
||||
ANIMATION: 500,
|
||||
|
||||
/** Wait for Angular stability after state changes */
|
||||
|
|
@ -34,37 +29,6 @@ export const TIMEOUTS = {
|
|||
|
||||
/** Extended timeout for complex operations */
|
||||
EXTENDED: 20000,
|
||||
|
||||
/**
|
||||
* Wait for IndexedDB writes to persist.
|
||||
* NgRx effects write to IndexedDB outside Angular's zone,
|
||||
* so we need an explicit wait after state changes before page navigation.
|
||||
* 500ms is empirically sufficient for most write operations.
|
||||
*/
|
||||
INDEXEDDB_WRITE: 500,
|
||||
|
||||
/**
|
||||
* Playwright action timeout (click, fill, etc.)
|
||||
* Should match playwright.config.ts actionTimeout
|
||||
*/
|
||||
ACTION: 15000,
|
||||
|
||||
/**
|
||||
* Playwright assertion timeout (expect calls)
|
||||
* Should match playwright.config.ts expect.timeout
|
||||
*/
|
||||
ASSERTION: 20000,
|
||||
|
||||
/**
|
||||
* Short delay for UI to settle after actions.
|
||||
* Use sparingly - prefer Playwright auto-waiting.
|
||||
*/
|
||||
UI_SETTLE: 200,
|
||||
|
||||
/**
|
||||
* Retry delay between attempts when using retry logic.
|
||||
*/
|
||||
RETRY_DELAY: 1000,
|
||||
} as const;
|
||||
|
||||
export type TimeoutKey = keyof typeof TIMEOUTS;
|
||||
|
|
|
|||
|
|
@ -36,10 +36,7 @@ export class ProjectPage extends BasePage {
|
|||
}
|
||||
|
||||
async createProject(projectName: string): Promise<void> {
|
||||
// Add test prefix to project name
|
||||
const prefixedProjectName = this.testPrefix
|
||||
? `${this.testPrefix}-${projectName}`
|
||||
: projectName;
|
||||
const prefixedProjectName = this.applyPrefix(projectName);
|
||||
|
||||
try {
|
||||
// Check for empty state first (single "Create Project" button)
|
||||
|
|
@ -128,9 +125,7 @@ export class ProjectPage extends BasePage {
|
|||
}
|
||||
|
||||
async navigateToProjectByName(projectName: string): Promise<void> {
|
||||
const fullProjectName = this.testPrefix
|
||||
? `${this.testPrefix}-${projectName}`
|
||||
: projectName;
|
||||
const fullProjectName = this.applyPrefix(projectName);
|
||||
|
||||
// Wait for Angular to fully render after any navigation
|
||||
await this.page.waitForTimeout(2000);
|
||||
|
|
@ -304,9 +299,7 @@ export class ProjectPage extends BasePage {
|
|||
await this.createProject('Test Project');
|
||||
|
||||
// Navigate to the created project
|
||||
const projectName = this.testPrefix
|
||||
? `${this.testPrefix}-Test Project`
|
||||
: 'Test Project';
|
||||
const projectName = this.applyPrefix('Test Project');
|
||||
|
||||
// After creating a project, ensure Projects group is visible and expanded
|
||||
await this.page.waitForTimeout(2000); // Increased wait for DOM updates
|
||||
|
|
|
|||
|
|
@ -1,10 +1,7 @@
|
|||
import { expect } from '@playwright/test';
|
||||
import type { Locator, Page } from '@playwright/test';
|
||||
import type { Page } from '@playwright/test';
|
||||
import type { TaskPage } from '../pages/task.page';
|
||||
import type { DialogPage } from '../pages/dialog.page';
|
||||
import type { ProjectPage } from '../pages/project.page';
|
||||
import type { TagPage } from '../pages/tag.page';
|
||||
import { TIMEOUTS } from '../constants/timeouts';
|
||||
|
||||
/**
|
||||
* Assert that the task list has the expected number of tasks.
|
||||
|
|
@ -27,17 +24,6 @@ export const expectTaskVisible = async (
|
|||
await expect(task).toBeVisible();
|
||||
};
|
||||
|
||||
/**
|
||||
* Assert that a task with the given text is NOT visible.
|
||||
*/
|
||||
export const expectTaskNotVisible = async (
|
||||
taskPage: TaskPage,
|
||||
text: string,
|
||||
): Promise<void> => {
|
||||
const task = taskPage.getTaskByText(text);
|
||||
await expect(task).not.toBeVisible();
|
||||
};
|
||||
|
||||
/**
|
||||
* Assert that a dialog is currently visible.
|
||||
*/
|
||||
|
|
@ -46,14 +32,6 @@ export const expectDialogVisible = async (dialogPage: DialogPage): Promise<void>
|
|||
await expect(dialog).toBeVisible();
|
||||
};
|
||||
|
||||
/**
|
||||
* Assert that no dialog is currently visible.
|
||||
*/
|
||||
export const expectNoDialog = async (page: Page): Promise<void> => {
|
||||
const dialog = page.locator('mat-dialog-container, .mat-mdc-dialog-container');
|
||||
await expect(dialog).not.toBeVisible();
|
||||
};
|
||||
|
||||
/**
|
||||
* Assert that no global error alert is displayed.
|
||||
*/
|
||||
|
|
@ -70,17 +48,6 @@ export const expectTaskDone = async (taskPage: TaskPage, text: string): Promise<
|
|||
await expect(task).toHaveClass(/isDone/);
|
||||
};
|
||||
|
||||
/**
|
||||
* Assert that a task is NOT marked as done.
|
||||
*/
|
||||
export const expectTaskNotDone = async (
|
||||
taskPage: TaskPage,
|
||||
text: string,
|
||||
): Promise<void> => {
|
||||
const task = taskPage.getTaskByText(text);
|
||||
await expect(task).not.toHaveClass(/isDone/);
|
||||
};
|
||||
|
||||
/**
|
||||
* Assert that the done task count matches expected.
|
||||
*/
|
||||
|
|
@ -90,111 +57,3 @@ export const expectDoneTaskCount = async (
|
|||
): Promise<void> => {
|
||||
await expect(taskPage.getDoneTasks()).toHaveCount(count);
|
||||
};
|
||||
|
||||
/**
|
||||
* Assert that the undone task count matches expected.
|
||||
*/
|
||||
export const expectUndoneTaskCount = async (
|
||||
taskPage: TaskPage,
|
||||
count: number,
|
||||
): Promise<void> => {
|
||||
await expect(taskPage.getUndoneTasks()).toHaveCount(count);
|
||||
};
|
||||
|
||||
/**
|
||||
* Assert that a task has the expected number of subtasks.
|
||||
*/
|
||||
export const expectSubTaskCount = async (
|
||||
taskPage: TaskPage,
|
||||
task: Locator,
|
||||
count: number,
|
||||
): Promise<void> => {
|
||||
const subtasks = taskPage.getSubTasks(task);
|
||||
await expect(subtasks).toHaveCount(count);
|
||||
};
|
||||
|
||||
/**
|
||||
* Assert that a project exists in the sidebar navigation.
|
||||
*/
|
||||
export const expectProjectExists = async (
|
||||
projectPage: ProjectPage,
|
||||
projectName: string,
|
||||
): Promise<void> => {
|
||||
const projectsTree = projectPage['page']
|
||||
.locator('nav-list-tree')
|
||||
.filter({ hasText: 'Projects' });
|
||||
const project = projectsTree
|
||||
.locator('[role="treeitem"]')
|
||||
.filter({ hasText: projectName });
|
||||
await expect(project).toBeVisible({ timeout: TIMEOUTS.NAVIGATION });
|
||||
};
|
||||
|
||||
/**
|
||||
* Assert that a tag exists in the sidebar navigation.
|
||||
*/
|
||||
export const expectTagExists = async (
|
||||
tagPage: TagPage,
|
||||
tagName: string,
|
||||
): Promise<void> => {
|
||||
const exists = await tagPage.tagExistsInSidebar(tagName);
|
||||
expect(exists).toBe(true);
|
||||
};
|
||||
|
||||
/**
|
||||
* Assert that sync completed successfully (check icon visible).
|
||||
*/
|
||||
export const expectSyncComplete = async (page: Page): Promise<void> => {
|
||||
const syncCheckIcon = page.locator('.sync-btn mat-icon.sync-state-ico');
|
||||
await expect(syncCheckIcon).toBeVisible({ timeout: TIMEOUTS.SYNC });
|
||||
};
|
||||
|
||||
/**
|
||||
* Assert that no sync conflict dialog is visible.
|
||||
*/
|
||||
export const expectNoConflictDialog = async (page: Page): Promise<void> => {
|
||||
const conflictDialog = page.locator('dialog-sync-conflict');
|
||||
await expect(conflictDialog).not.toBeVisible();
|
||||
};
|
||||
|
||||
/**
|
||||
* Assert that the page title contains the expected text.
|
||||
*/
|
||||
export const expectPageTitle = async (page: Page, title: string): Promise<void> => {
|
||||
const pageTitle = page.locator('.page-title').first();
|
||||
await expect(pageTitle).toContainText(title);
|
||||
};
|
||||
|
||||
/**
|
||||
* Assert that no snackbar error is displayed.
|
||||
*/
|
||||
export const expectNoSnackbarError = async (page: Page): Promise<void> => {
|
||||
const snackBars = page.locator('.mat-mdc-snack-bar-container');
|
||||
const count = await snackBars.count();
|
||||
for (let i = 0; i < count; i++) {
|
||||
const text = await snackBars.nth(i).innerText();
|
||||
expect(text.toLowerCase()).not.toContain('error');
|
||||
expect(text.toLowerCase()).not.toContain('fail');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Assert that a task is currently being tracked (has isCurrent class).
|
||||
*/
|
||||
export const expectTaskTracking = async (
|
||||
taskPage: TaskPage,
|
||||
text: string,
|
||||
): Promise<void> => {
|
||||
const task = taskPage.getTaskByText(text);
|
||||
await expect(task).toHaveClass(/isCurrent/);
|
||||
};
|
||||
|
||||
/**
|
||||
* Assert that a task is NOT currently being tracked.
|
||||
*/
|
||||
export const expectTaskNotTracking = async (
|
||||
taskPage: TaskPage,
|
||||
text: string,
|
||||
): Promise<void> => {
|
||||
const task = taskPage.getTaskByText(text);
|
||||
await expect(task).not.toHaveClass(/isCurrent/);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,18 +1,14 @@
|
|||
/**
|
||||
* Barrel export for all e2e utility modules.
|
||||
* Barrel export for e2e utility modules.
|
||||
* Import from this file for cleaner imports:
|
||||
* @example import { expectTaskCount, waitForAppReady, safeIsVisible } from '../../utils';
|
||||
* @example import { expectTaskCount, waitForAppReady } from '../../utils';
|
||||
*/
|
||||
|
||||
export * from './assertions';
|
||||
export * from './element-helpers';
|
||||
export * from './waits';
|
||||
export * from './tour-helpers';
|
||||
export * from './time-input-helper';
|
||||
export * from './schedule-task-helper';
|
||||
export * from './material-helpers';
|
||||
export * from './retry-helpers';
|
||||
|
||||
// Note: sync-helpers is intentionally not exported here
|
||||
// as it contains test-specific setup that shouldn't be imported broadly.
|
||||
// Import it directly when needed for sync tests.
|
||||
// Note: sync-helpers, time-input-helper, and schedule-task-helper
|
||||
// are not exported here as they are specialized utilities.
|
||||
// Import them directly when needed.
|
||||
|
|
|
|||
|
|
@ -1,223 +0,0 @@
|
|||
import { type Page, type Locator } from '@playwright/test';
|
||||
import { TIMEOUTS } from '../constants/timeouts';
|
||||
|
||||
/**
|
||||
* Material Design component helpers for Playwright E2E tests.
|
||||
* Centralizes complex Material UI interactions with retry logic.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Opens a mat-select dropdown and selects an option by text.
|
||||
* Handles the complexity of Material select components with retry logic.
|
||||
*
|
||||
* @param page - Playwright Page instance
|
||||
* @param selectLocator - Locator for the mat-select element
|
||||
* @param optionText - Text of the option to select
|
||||
* @param maxRetries - Maximum number of retry attempts (default: 5)
|
||||
*/
|
||||
export const selectMaterialOption = async (
|
||||
page: Page,
|
||||
selectLocator: Locator,
|
||||
optionText: string,
|
||||
maxRetries: number = 5,
|
||||
): Promise<void> => {
|
||||
const option = page.locator('mat-option').filter({ hasText: optionText });
|
||||
|
||||
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||
// Ensure the select is in view
|
||||
await selectLocator
|
||||
.scrollIntoViewIfNeeded({ timeout: TIMEOUTS.DIALOG })
|
||||
.catch(async () => {
|
||||
// If scrollIntoViewIfNeeded fails, try scrolling the dialog content
|
||||
const dialogContent = page.locator('mat-dialog-content');
|
||||
if (await dialogContent.isVisible()) {
|
||||
await dialogContent.evaluate((el) => el.scrollTo(0, 0));
|
||||
}
|
||||
});
|
||||
await page.waitForTimeout(TIMEOUTS.UI_SETTLE);
|
||||
|
||||
// Focus and try to open the dropdown
|
||||
await selectLocator.focus().catch(() => {});
|
||||
await page.waitForTimeout(TIMEOUTS.UI_SETTLE);
|
||||
|
||||
// Try different methods to open the dropdown
|
||||
if (attempt === 0) {
|
||||
await selectLocator.click().catch(() => {});
|
||||
} else if (attempt === 1) {
|
||||
await page.keyboard.press('Space');
|
||||
} else if (attempt === 2) {
|
||||
await page.keyboard.press('ArrowDown');
|
||||
} else {
|
||||
await selectLocator.click({ force: true }).catch(() => {});
|
||||
}
|
||||
await page.waitForTimeout(TIMEOUTS.ANIMATION);
|
||||
|
||||
// Wait for any mat-option to appear (dropdown opened)
|
||||
const anyOption = page.locator('mat-option').first();
|
||||
const anyOptionVisible = await anyOption
|
||||
.waitFor({ state: 'visible', timeout: TIMEOUTS.ANGULAR_STABILITY })
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
|
||||
if (anyOptionVisible) {
|
||||
// Wait for the specific option
|
||||
const optionVisible = await option
|
||||
.waitFor({ state: 'visible', timeout: TIMEOUTS.ANGULAR_STABILITY })
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
|
||||
if (optionVisible) {
|
||||
await option.click();
|
||||
await page.waitForTimeout(TIMEOUTS.ANIMATION);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Close dropdown if it opened but option not found, then retry
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForTimeout(TIMEOUTS.ANIMATION);
|
||||
}
|
||||
|
||||
throw new Error(`Failed to select option "${optionText}" after ${maxRetries} attempts`);
|
||||
};
|
||||
|
||||
/**
|
||||
* Waits for a Material dialog to open.
|
||||
*
|
||||
* @param page - Playwright Page instance
|
||||
* @param timeout - Maximum wait time in ms (default: TIMEOUTS.DIALOG)
|
||||
* @returns The dialog locator
|
||||
*/
|
||||
export const waitForMatDialogOpen = async (
|
||||
page: Page,
|
||||
timeout: number = TIMEOUTS.DIALOG,
|
||||
): Promise<Locator> => {
|
||||
const dialog = page.locator('mat-dialog-container, .mat-mdc-dialog-container');
|
||||
await dialog.waitFor({ state: 'visible', timeout });
|
||||
return dialog;
|
||||
};
|
||||
|
||||
/**
|
||||
* Waits for a Material dialog to close.
|
||||
*
|
||||
* @param page - Playwright Page instance
|
||||
* @param timeout - Maximum wait time in ms (default: TIMEOUTS.DIALOG)
|
||||
*/
|
||||
export const waitForMatDialogClose = async (
|
||||
page: Page,
|
||||
timeout: number = TIMEOUTS.DIALOG,
|
||||
): Promise<void> => {
|
||||
const dialog = page.locator('mat-dialog-container, .mat-mdc-dialog-container');
|
||||
await dialog.waitFor({ state: 'hidden', timeout });
|
||||
};
|
||||
|
||||
/**
|
||||
* Dismisses any visible Material snackbar/toast.
|
||||
*
|
||||
* @param page - Playwright Page instance
|
||||
* @returns true if a snackbar was dismissed, false otherwise
|
||||
*/
|
||||
export const dismissSnackbar = async (page: Page): Promise<boolean> => {
|
||||
const snackBar = page.locator('.mat-mdc-snack-bar-container');
|
||||
if (await snackBar.isVisible({ timeout: TIMEOUTS.ANIMATION }).catch(() => false)) {
|
||||
const dismissBtn = snackBar.locator('button');
|
||||
if (await dismissBtn.isVisible({ timeout: TIMEOUTS.ANIMATION }).catch(() => false)) {
|
||||
await dismissBtn.click().catch(() => {});
|
||||
await page.waitForTimeout(TIMEOUTS.ANIMATION);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Clicks a Material menu item, scrolling if necessary.
|
||||
* Handles hover states and visibility checks.
|
||||
*
|
||||
* @param page - Playwright Page instance
|
||||
* @param menuTrigger - Locator for the element that triggers the menu
|
||||
* @param menuItemText - Text of the menu item to click
|
||||
*/
|
||||
export const clickMatMenuItem = async (
|
||||
page: Page,
|
||||
menuTrigger: Locator,
|
||||
menuItemText: string,
|
||||
): Promise<void> => {
|
||||
// Hover to reveal menu trigger if needed
|
||||
await menuTrigger.hover();
|
||||
await page.waitForTimeout(TIMEOUTS.UI_SETTLE);
|
||||
|
||||
// Click to open menu
|
||||
await menuTrigger.click();
|
||||
|
||||
// Wait for menu to open
|
||||
const menu = page.locator('.mat-mdc-menu-panel, .mat-menu-panel');
|
||||
await menu.waitFor({ state: 'visible', timeout: TIMEOUTS.DIALOG });
|
||||
|
||||
// Find and click the menu item
|
||||
const menuItem = menu
|
||||
.locator('.mat-mdc-menu-item, .mat-menu-item')
|
||||
.filter({ hasText: menuItemText });
|
||||
await menuItem.scrollIntoViewIfNeeded();
|
||||
await menuItem.click();
|
||||
|
||||
// Wait for menu to close
|
||||
await menu.waitFor({ state: 'hidden', timeout: TIMEOUTS.DIALOG });
|
||||
};
|
||||
|
||||
/**
|
||||
* Fills a Material form field input.
|
||||
* Handles the complexity of mat-form-field components.
|
||||
*
|
||||
* @param formFieldLocator - Locator for the mat-form-field or input container
|
||||
* @param value - Value to fill
|
||||
*/
|
||||
export const fillMatInput = async (
|
||||
formFieldLocator: Locator,
|
||||
value: string,
|
||||
): Promise<void> => {
|
||||
const input = formFieldLocator.locator('input, textarea').first();
|
||||
await input.waitFor({ state: 'visible', timeout: TIMEOUTS.DIALOG });
|
||||
await input.clear();
|
||||
await input.fill(value);
|
||||
};
|
||||
|
||||
/**
|
||||
* Expands a Material expansion panel if not already expanded.
|
||||
*
|
||||
* @param panelHeader - Locator for the expansion panel header
|
||||
*/
|
||||
export const expandMatExpansionPanel = async (panelHeader: Locator): Promise<void> => {
|
||||
const isExpanded = await panelHeader.getAttribute('aria-expanded');
|
||||
if (isExpanded !== 'true') {
|
||||
await panelHeader.click();
|
||||
// Wait for expansion animation
|
||||
const panel = panelHeader.locator('..').locator('.mat-expansion-panel-content');
|
||||
await panel.waitFor({ state: 'visible', timeout: TIMEOUTS.ANIMATION * 2 });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Waits for a Material button to be enabled and clicks it.
|
||||
*
|
||||
* @param button - Locator for the button
|
||||
* @param timeout - Maximum wait time in ms (default: TIMEOUTS.ELEMENT_ENABLED)
|
||||
*/
|
||||
export const clickMatButton = async (
|
||||
button: Locator,
|
||||
timeout: number = TIMEOUTS.ELEMENT_ENABLED,
|
||||
): Promise<void> => {
|
||||
await button.waitFor({ state: 'visible', timeout });
|
||||
// Wait for button to be enabled (not have disabled attribute)
|
||||
await button.waitFor({ state: 'attached', timeout });
|
||||
const isDisabled = await button.isDisabled();
|
||||
if (isDisabled) {
|
||||
// Poll for enabled state
|
||||
const startTime = Date.now();
|
||||
while (Date.now() - startTime < timeout) {
|
||||
if (!(await button.isDisabled())) break;
|
||||
await button.page().waitForTimeout(100);
|
||||
}
|
||||
}
|
||||
await button.click();
|
||||
};
|
||||
|
|
@ -1,186 +0,0 @@
|
|||
import { type Locator } from '@playwright/test';
|
||||
import { TIMEOUTS } from '../constants/timeouts';
|
||||
|
||||
/**
|
||||
* Options for retry operations
|
||||
*/
|
||||
export interface RetryOptions {
|
||||
/** Maximum number of retry attempts (default: 3) */
|
||||
maxRetries?: number;
|
||||
/** Delay between retries in ms (default: TIMEOUTS.RETRY_DELAY) */
|
||||
delay?: number;
|
||||
/** Callback invoked before each retry attempt */
|
||||
onRetry?: (attempt: number, error: Error) => void;
|
||||
}
|
||||
|
||||
const defaultRetryOptions: Required<RetryOptions> = {
|
||||
maxRetries: 3,
|
||||
delay: TIMEOUTS.RETRY_DELAY,
|
||||
onRetry: () => {},
|
||||
};
|
||||
|
||||
/**
|
||||
* Executes an async action with retry logic.
|
||||
* Catches errors and retries the action up to maxRetries times.
|
||||
*
|
||||
* @param action - The async action to execute
|
||||
* @param options - Retry configuration options
|
||||
* @returns The result of the action
|
||||
* @throws The last error if all retries fail
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const result = await retryAction(
|
||||
* async () => await page.locator('.unstable-element').click(),
|
||||
* { maxRetries: 5, delay: 500 }
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
export const retryAction = async <T>(
|
||||
action: () => Promise<T>,
|
||||
options: RetryOptions = {},
|
||||
): Promise<T> => {
|
||||
const { maxRetries, delay, onRetry } = { ...defaultRetryOptions, ...options };
|
||||
let lastError: Error = new Error('No attempts made');
|
||||
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
return await action();
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error : new Error(String(error));
|
||||
|
||||
if (attempt < maxRetries) {
|
||||
onRetry(attempt, lastError);
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError;
|
||||
};
|
||||
|
||||
/**
|
||||
* Clicks a locator with retry logic.
|
||||
* Useful for elements that may be temporarily obscured or not yet interactive.
|
||||
*
|
||||
* @param locator - The Playwright locator to click
|
||||
* @param options - Retry configuration options
|
||||
*/
|
||||
export const retryClick = async (
|
||||
locator: Locator,
|
||||
options: RetryOptions = {},
|
||||
): Promise<void> => {
|
||||
await retryAction(async () => {
|
||||
await locator.click({ timeout: TIMEOUTS.ACTION });
|
||||
}, options);
|
||||
};
|
||||
|
||||
/**
|
||||
* Fills a locator with retry logic.
|
||||
* Clears existing content before filling.
|
||||
*
|
||||
* @param locator - The Playwright locator to fill
|
||||
* @param value - The value to fill
|
||||
* @param options - Retry configuration options
|
||||
*/
|
||||
export const retryFill = async (
|
||||
locator: Locator,
|
||||
value: string,
|
||||
options: RetryOptions = {},
|
||||
): Promise<void> => {
|
||||
await retryAction(async () => {
|
||||
await locator.clear();
|
||||
await locator.fill(value);
|
||||
}, options);
|
||||
};
|
||||
|
||||
/**
|
||||
* Waits for an element to be visible with retry logic.
|
||||
* Useful when an element may take time to appear after an action.
|
||||
*
|
||||
* @param locator - The Playwright locator to wait for
|
||||
* @param options - Retry configuration options
|
||||
*/
|
||||
export const retryWaitForVisible = async (
|
||||
locator: Locator,
|
||||
options: RetryOptions = {},
|
||||
): Promise<void> => {
|
||||
await retryAction(async () => {
|
||||
await locator.waitFor({ state: 'visible', timeout: TIMEOUTS.ELEMENT_ENABLED });
|
||||
}, options);
|
||||
};
|
||||
|
||||
/**
|
||||
* Executes an action and waits for a condition to be true.
|
||||
* Retries if the condition is not met within the timeout.
|
||||
*
|
||||
* @param action - The async action to execute
|
||||
* @param condition - Function that returns true when the expected state is reached
|
||||
* @param options - Retry configuration options
|
||||
*/
|
||||
export const retryUntilCondition = async (
|
||||
action: () => Promise<void>,
|
||||
condition: () => Promise<boolean>,
|
||||
options: RetryOptions = {},
|
||||
): Promise<void> => {
|
||||
const { maxRetries, delay, onRetry } = { ...defaultRetryOptions, ...options };
|
||||
let lastError: Error = new Error('Condition never met');
|
||||
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
await action();
|
||||
|
||||
// Poll for condition
|
||||
const startTime = Date.now();
|
||||
while (Date.now() - startTime < TIMEOUTS.ANGULAR_STABILITY) {
|
||||
if (await condition()) {
|
||||
return;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
|
||||
lastError = new Error('Condition not met within timeout');
|
||||
|
||||
if (attempt < maxRetries) {
|
||||
onRetry(attempt, lastError);
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
}
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error : new Error(String(error));
|
||||
|
||||
if (attempt < maxRetries) {
|
||||
onRetry(attempt, lastError);
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError;
|
||||
};
|
||||
|
||||
/**
|
||||
* Expands a collapsible element if not already expanded.
|
||||
* Uses retry logic to handle timing issues.
|
||||
*
|
||||
* @param trigger - The locator for the expand trigger (button/header)
|
||||
* @param expandedContainer - The locator for the container that appears when expanded
|
||||
* @param options - Retry configuration options
|
||||
*/
|
||||
export const retryExpand = async (
|
||||
trigger: Locator,
|
||||
expandedContainer: Locator,
|
||||
options: RetryOptions = {},
|
||||
): Promise<void> => {
|
||||
await retryAction(async () => {
|
||||
const isExpanded = await trigger.getAttribute('aria-expanded');
|
||||
if (isExpanded === 'true') {
|
||||
return;
|
||||
}
|
||||
|
||||
await trigger.click();
|
||||
await expandedContainer.waitFor({
|
||||
state: 'visible',
|
||||
timeout: TIMEOUTS.ANIMATION * 2,
|
||||
});
|
||||
}, options);
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue