mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-08-04 13:32:33 +00:00
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:
parent
fe40bdf179
commit
ca387ca2bf
6 changed files with 62 additions and 36 deletions
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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(() => {});
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue