refactor(e2e): improve test reliability with deterministic waits

- Add waitForStatePersistence utility for sync tests to replace hardcoded delays
- Fix nav-list vs nav-list-tree selector inconsistencies across test files
- Replace waitForTimeout calls with proper element waits and Angular stability checks
- Update selectors.ts constants to use correct nav-list-tree component selector
This commit is contained in:
Johannes Millan 2025-12-11 17:16:58 +01:00
parent fe40bdf179
commit ca387ca2bf
6 changed files with 62 additions and 36 deletions

View file

@ -4,10 +4,12 @@ export const cssSelectors = {
NAV_LIST: 'magic-side-nav .nav-list',
NAV_ITEM: 'magic-side-nav nav-item',
NAV_ITEM_BUTTON: 'magic-side-nav nav-item button',
NAV_GROUP_HEADER: 'magic-side-nav nav-list .g-multi-btn-wrapper nav-item button',
NAV_GROUP_CHILDREN: 'magic-side-nav nav-list .nav-children',
NAV_CHILD_ITEM: 'magic-side-nav nav-list .nav-child-item nav-item',
NAV_CHILD_BUTTON: 'magic-side-nav nav-list .nav-child-item nav-item button',
// nav-list-tree is the Angular component for tree items (Projects, Tags)
NAV_LIST_TREE: 'magic-side-nav nav-list-tree',
NAV_GROUP_HEADER: 'magic-side-nav nav-list-tree .g-multi-btn-wrapper nav-item button',
NAV_GROUP_CHILDREN: 'magic-side-nav nav-list-tree .nav-children',
NAV_CHILD_ITEM: 'magic-side-nav nav-list-tree .nav-child-item nav-item',
NAV_CHILD_BUTTON: 'magic-side-nav nav-list-tree .nav-child-item nav-item button',
// Main navigation items (direct children of .nav-list > li.nav-item)
MAIN_NAV_ITEMS: 'magic-side-nav .nav-list > li.nav-item nav-item button',

View file

@ -68,15 +68,20 @@ export class ProjectPage extends BasePage {
await this.page.waitForTimeout(500);
}
// Get the Projects nav-list-tree container to scope the button search
const projectsTree = this.page
.locator('nav-list-tree')
.filter({ hasText: 'Projects' });
// Hover over the Projects group to show additional buttons
await projectsGroup.hover();
// Wait a bit for the hover effect to take place
await this.page.waitForTimeout(500);
// Look for the create project button (add icon) in additional buttons
const createProjectBtn = this.page.locator(
'nav-list .additional-btns button[mat-icon-button]:has(mat-icon:text("add"))',
// Look for the create project button (add icon) within the Projects tree only
const createProjectBtn = projectsTree.locator(
'.additional-btns button[mat-icon-button]:has(mat-icon:text("add"))',
);
// Try to wait for visibility, but if it fails, try forcing click if attached
try {
@ -130,43 +135,48 @@ export class ProjectPage extends BasePage {
// Wait for the nav to be fully loaded
await this.sidenav.waitFor({ state: 'visible', timeout: 5000 });
// Find the Projects group button
const projectsGroup = this.page
// Get the Projects nav-list-tree container
const projectsTree = this.page
.locator('nav-list-tree')
.filter({ hasText: 'Projects' })
.locator('nav-item button')
.filter({ hasText: 'Projects' });
// Find the Projects group button (the header with expand/collapse)
const projectsGroup = projectsTree
.locator('.g-multi-btn-wrapper nav-item button')
.first();
// Ensure Projects group is expanded with retry logic
if (await projectsGroup.isVisible().catch(() => false)) {
for (let i = 0; i < 3; i++) {
const isExpanded = await projectsGroup.getAttribute('aria-expanded');
if (isExpanded === 'true') break;
await projectsGroup.waitFor({ state: 'visible', timeout: 5000 });
for (let i = 0; i < 3; i++) {
const isExpanded = await projectsGroup.getAttribute('aria-expanded');
if (isExpanded === 'true') break;
await projectsGroup.click();
await this.page.waitForTimeout(1000);
}
await projectsGroup.click();
// Wait for expansion animation to complete - scoped to Projects tree
await projectsTree
.locator('.nav-children')
.waitFor({ state: 'visible', timeout: 3000 })
.catch(() => {});
}
// Locate the project button with multiple approaches
// Approach 1: Structured hierarchy
let projectBtn = this.page
// Locate the project button within the Projects tree
let projectBtn = projectsTree
.locator('.nav-children .nav-child-item nav-item button')
.filter({ hasText: fullProjectName })
.first();
// Approach 2: Flat search in side nav
// Fallback: search within the Projects tree more broadly
if (!(await projectBtn.isVisible().catch(() => false))) {
projectBtn = this.page
.locator('magic-side-nav button')
projectBtn = projectsTree
.locator('button')
.filter({ hasText: fullProjectName })
.first();
}
// Approach 3: Global search (last resort)
// Last resort: Global search in side nav
if (!(await projectBtn.isVisible().catch(() => false))) {
projectBtn = this.page
.locator('button')
.locator('magic-side-nav button')
.filter({ hasText: fullProjectName })
.first();
}

View file

@ -50,7 +50,7 @@ test.describe('Project', () => {
.filter({ hasText: 'Create Project' })
.locator('button');
const projectsGroupBtn = page
.locator('nav-list')
.locator('nav-list-tree')
.filter({ hasText: 'Projects' })
.locator('nav-item button')
.first();

View file

@ -2,7 +2,7 @@ import { test, expect } from '../../fixtures/test.fixture';
import { SyncPage } from '../../pages/sync.page';
import { WorkViewPage } from '../../pages/work-view.page';
import { ProjectPage } from '../../pages/project.page';
import { waitForAppReady } from '../../utils/waits';
import { waitForAppReady, waitForStatePersistence } from '../../utils/waits';
import { type Browser, type Page } from '@playwright/test';
import { isWebDavServerUp } from '../../utils/check-webdav';
@ -146,8 +146,8 @@ test.describe('WebDAV Sync Expansion', () => {
await dismissTour(pageB);
// Verify Project on B
// Wait for project navigation
await pageB.waitForTimeout(2000);
// Wait for state persistence after reload
await waitForStatePersistence(pageB);
await projectPageB.navigateToProjectByName(projectName);
@ -225,8 +225,8 @@ test.describe('WebDAV Sync Expansion', () => {
});
// Mark done on A
await pageA.waitForTimeout(1000);
const taskA = pageA.locator('task', { hasText: taskName }).first();
await taskA.waitFor({ state: 'visible' });
await taskA.hover();
const doneBtnA = taskA.locator('.task-done-btn');
await doneBtnA.click({ force: true });
@ -251,7 +251,8 @@ test.describe('WebDAV Sync Expansion', () => {
await doneBtnB.click();
await expect(taskB).not.toHaveClass(/isDone/);
await pageB.waitForTimeout(1000);
// Wait for state persistence before syncing
await waitForStatePersistence(pageB);
await syncPageB.triggerSync();
await waitForSync(pageB, syncPageB);

View file

@ -1,7 +1,7 @@
import { test, expect } from '../../fixtures/test.fixture';
import { SyncPage } from '../../pages/sync.page';
import { WorkViewPage } from '../../pages/work-view.page';
import { waitForAppReady } from '../../utils/waits';
import { waitForAppReady, waitForStatePersistence } from '../../utils/waits';
import { type Browser, type Page } from '@playwright/test';
import { isWebDavServerUp } from '../../utils/check-webdav';
@ -186,7 +186,8 @@ test.describe('WebDAV Sync Full Flow', () => {
// Wait for deletion
await expect(pageA.locator('task')).toHaveCount(1); // Should be 1 left
await pageA.waitForTimeout(1000);
// Wait for state persistence before syncing
await waitForStatePersistence(pageA);
await syncPageA.triggerSync();
await waitForSync(pageA, syncPageA);
@ -214,8 +215,8 @@ test.describe('WebDAV Sync Full Flow', () => {
await titleA.locator('input, textarea').fill('Conflict Task A');
await pageA.keyboard.press('Enter');
// Wait a bit to ensure timestamps differ
await pageA.waitForTimeout(2000);
// Wait for state persistence and ensure timestamps differ between edits
await waitForStatePersistence(pageA);
// Edit on B: "Conflict Task B"
const taskB = pageB.locator('task', { hasText: 'Conflict Task' }).first();

View file

@ -108,3 +108,15 @@ export const waitForAppReady = async (
// Small buffer to ensure animations settle.
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.
*/
export const waitForStatePersistence = async (page: Page): Promise<void> => {
// 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(() => {});
};