From ca387ca2bfefc613c58a43be61e5f2f354f70158 Mon Sep 17 00:00:00 2001 From: Johannes Millan Date: Thu, 11 Dec 2025 17:16:58 +0100 Subject: [PATCH] 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 --- e2e/constants/selectors.ts | 10 ++-- e2e/pages/project.page.ts | 54 ++++++++++++-------- e2e/tests/project/project.spec.ts | 2 +- e2e/tests/sync/webdav-sync-expansion.spec.ts | 11 ++-- e2e/tests/sync/webdav-sync-full.spec.ts | 9 ++-- e2e/utils/waits.ts | 12 +++++ 6 files changed, 62 insertions(+), 36 deletions(-) diff --git a/e2e/constants/selectors.ts b/e2e/constants/selectors.ts index e3c0e42e1a..7417b1cb07 100644 --- a/e2e/constants/selectors.ts +++ b/e2e/constants/selectors.ts @@ -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', diff --git a/e2e/pages/project.page.ts b/e2e/pages/project.page.ts index a05a96ade2..faacde3d29 100644 --- a/e2e/pages/project.page.ts +++ b/e2e/pages/project.page.ts @@ -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(); } diff --git a/e2e/tests/project/project.spec.ts b/e2e/tests/project/project.spec.ts index ba91a645bd..69d39a67d0 100644 --- a/e2e/tests/project/project.spec.ts +++ b/e2e/tests/project/project.spec.ts @@ -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(); diff --git a/e2e/tests/sync/webdav-sync-expansion.spec.ts b/e2e/tests/sync/webdav-sync-expansion.spec.ts index e2e4aa56c6..219ec4e4f6 100644 --- a/e2e/tests/sync/webdav-sync-expansion.spec.ts +++ b/e2e/tests/sync/webdav-sync-expansion.spec.ts @@ -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); diff --git a/e2e/tests/sync/webdav-sync-full.spec.ts b/e2e/tests/sync/webdav-sync-full.spec.ts index 9148e30433..c8bd0be243 100644 --- a/e2e/tests/sync/webdav-sync-full.spec.ts +++ b/e2e/tests/sync/webdav-sync-full.spec.ts @@ -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(); diff --git a/e2e/utils/waits.ts b/e2e/utils/waits.ts index b8c153d405..598c9da751 100644 --- a/e2e/utils/waits.ts +++ b/e2e/utils/waits.ts @@ -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 => { + // 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(() => {}); +};