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:
Johannes Millan 2026-01-06 11:52:59 +01:00
parent b544131921
commit 4ba9b72597
6 changed files with 10 additions and 607 deletions

View file

@ -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;

View file

@ -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

View file

@ -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/);
};

View file

@ -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.

View file

@ -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();
};

View file

@ -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);
};