import { expect, Locator, Page } from '@playwright/test'; import { BasePage } from './base.page'; export class ProjectPage extends BasePage { readonly sidenav: Locator; readonly createProjectBtn: Locator; readonly projectAccordion: Locator; readonly projectNameInput: Locator; readonly submitBtn: Locator; readonly workCtxMenu: Locator; readonly workCtxTitle: Locator; readonly projectSettingsBtn: Locator; readonly moveToArchiveBtn: Locator; readonly globalErrorAlert: Locator; constructor(page: Page, testPrefix: string = '') { super(page, testPrefix); this.sidenav = page.locator('magic-side-nav'); this.createProjectBtn = page.locator( 'button[aria-label="Create New Project"], button:has-text("Create Project")', ); this.projectAccordion = page.locator('nav-item button:has-text("Projects")'); this.projectNameInput = page.getByRole('textbox', { name: 'Project Name' }); this.submitBtn = page.locator('dialog-create-project button[type=submit]:enabled'); this.workCtxMenu = page.locator('work-context-menu'); // Use more specific selector to avoid matching titles in other areas this.workCtxTitle = page .locator('main .page-title, .route-wrapper .page-title') .first(); this.projectSettingsBtn = this.workCtxMenu .locator('button[aria-label="Project Settings"]') .or(this.workCtxMenu.locator('button').nth(3)); this.moveToArchiveBtn = page.locator('.e2e-move-done-to-archive'); this.globalErrorAlert = page.locator('.global-error-alert'); } async createProject(projectName: string): Promise { // Add test prefix to project name const prefixedProjectName = this.testPrefix ? `${this.testPrefix}-${projectName}` : projectName; try { // Ensure page is stable before starting await this.page.waitForLoadState('networkidle'); // Check for empty state first (single "Create Project" button) const emptyStateBtn = this.page .locator('nav-item') .filter({ hasText: 'Create Project' }) .locator('button'); try { await emptyStateBtn.waitFor({ state: 'visible', timeout: 5000 }); await emptyStateBtn.click(); // Continue to dialog handling below } catch { // Not in empty state, continue with normal flow // Find the Projects group item and wait for it to be visible const projectsGroup = this.page .locator('nav-list-tree') .filter({ hasText: 'Projects' }) .locator('nav-item button') .first(); await projectsGroup.waitFor({ state: 'visible', timeout: 10000 }); // Increased for CI stability // Ensure expanded const isExpanded = await projectsGroup.getAttribute('aria-expanded'); if (isExpanded !== 'true') { await projectsGroup.click(); 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) 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 { await createProjectBtn.waitFor({ state: 'visible', timeout: 5000 }); await createProjectBtn.click(); } catch (e) { console.log('Additional button not visible via hover, trying force click...'); await createProjectBtn.click({ force: true }); } } } catch (error) { // If the specific selectors fail, try a more general approach console.warn('Primary project creation approach failed, trying fallback:', error); // Fallback: try to find any add button near Projects text const addButton = this.page .locator('button[mat-icon-button]:has(mat-icon:text("add"))') .first(); try { await addButton.waitFor({ state: 'visible', timeout: 5000 }); await addButton.click(); } catch (e) { console.log('Fallback button not visible, trying force click...'); await addButton.click({ force: true }); } } // Wait for the dialog to appear await this.projectNameInput.waitFor({ state: 'visible' }); await this.projectNameInput.fill(prefixedProjectName); await this.submitBtn.click(); // Wait for dialog to close by waiting for input to be hidden await this.projectNameInput.waitFor({ state: 'hidden', timeout: 3000 }); // Increased for CI stability } async getProject(index: number): Promise { // Projects are in a menuitem structure, not side-nav-item // Get all project menuitems that follow the Projects header const projectMenuItems = this.page.locator( '[role="menuitem"]:has-text("Projects") ~ [role="menuitem"]', ); return projectMenuItems.nth(index - 1); } async navigateToProjectByName(projectName: string): Promise { const fullProjectName = this.testPrefix ? `${this.testPrefix}-${projectName}` : projectName; // Wait for page to be fully loaded before checking await this.page.waitForLoadState('networkidle'); // Wait for Angular to fully render after any navigation await this.page.waitForTimeout(2000); // Helper function to check if we're already on the project const isAlreadyOnProject = async (): Promise => { try { // Use page.evaluate for direct DOM check (most reliable) return await this.page.evaluate((name) => { const main = document.querySelector('main'); return main?.textContent?.includes(name) ?? false; }, fullProjectName); } catch { return false; } }; // Check if we're already on the project if (await isAlreadyOnProject()) { return; } // Wait for the nav to be fully loaded await this.sidenav.waitFor({ state: 'visible', timeout: 5000 }); // Get the Projects nav-list-tree container const projectsTree = this.page .locator('nav-list-tree') .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 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(); // Wait for expansion animation to complete - scoped to Projects tree await projectsTree .locator('.nav-children') .waitFor({ state: 'visible', timeout: 3000 }) .catch(() => {}); } // Wait for the project to appear in the tree (may take time after sync/reload) // Scope to the Projects tree to avoid matching tags or other trees const projectTreeItem = projectsTree .locator('[role="treeitem"]') .filter({ hasText: fullProjectName }) .first(); // Wait for the project to appear with extended timeout (projects load after sync) await projectTreeItem.waitFor({ state: 'visible', timeout: 20000 }); // Now get the clickable menuitem inside the treeitem const projectBtn = projectTreeItem.locator('[role="menuitem"]').first(); // Wait for the menuitem to be visible and stable await projectBtn.waitFor({ state: 'visible', timeout: 5000 }); // Click with retry - catch errors and check if we're already on the project for (let attempt = 0; attempt < 3; attempt++) { try { await projectBtn.click({ timeout: 5000 }); // Wait for navigation to complete - wait for URL to change to project route const navigated = await this.page .waitForURL(/\/#\/project\//, { timeout: 5000 }) .then(() => true) .catch(() => false); if (navigated) break; } catch { // Click timed out - check if we're already on the project if (await isAlreadyOnProject()) { return; } } // If navigation didn't happen, wait a bit and retry if (attempt < 2) { await this.page.waitForTimeout(500); } } await this.page.waitForLoadState('networkidle'); // Final verification - wait for the project to appear in main // Use a locator-based wait for better reliability try { await this.page .locator('main') .getByText(fullProjectName, { exact: false }) .first() .waitFor({ state: 'visible', timeout: 15000 }); } catch { // If verification fails, continue anyway - the test will catch real issues } } async navigateToProject(projectLocator: Locator): Promise { const projectBtn = projectLocator.locator('button').first(); await projectBtn.waitFor({ state: 'visible' }); await projectBtn.click(); } async openProjectMenu(projectLocator: Locator): Promise { const projectBtn = projectLocator.locator('.mat-mdc-menu-item'); const advBtn = projectLocator.locator('.additional-btn'); await projectBtn.hover(); await advBtn.waitFor({ state: 'visible' }); await advBtn.click(); await this.workCtxMenu.waitFor({ state: 'visible' }); } async navigateToProjectSettings(): Promise { await this.projectSettingsBtn.waitFor({ state: 'visible' }); await this.projectSettingsBtn.click(); } async archiveDoneTasks(): Promise { // Check if the collapsible needs to be expanded const moveToArchiveBtnVisible = await this.moveToArchiveBtn.isVisible(); if (!moveToArchiveBtnVisible) { const collapsibleHeader = this.page.locator('.collapsible-header'); if ((await collapsibleHeader.count()) > 0) { await collapsibleHeader.click(); // Wait for the section to expand await this.moveToArchiveBtn.waitFor({ state: 'visible', timeout: 1000 }); } } await this.moveToArchiveBtn.waitFor({ state: 'visible' }); await this.moveToArchiveBtn.click(); } async createAndGoToTestProject(): Promise { // Ensure the page context is stable before starting await this.page.waitForLoadState('networkidle'); // Wait for the nav to be fully loaded await this.sidenav.waitFor({ state: 'visible', timeout: 3000 }); // Reduced from 5s to 3s // Handle empty state vs existing projects scenario const addProjectBtn = this.page .locator('nav-item') .filter({ hasText: 'Create Project' }) .locator('button'); const projectsGroupBtn = this.page .locator('nav-list-tree') .filter({ hasText: 'Projects' }) .locator('nav-item button') .first(); // Check if we're in empty state (no projects yet) or if projects group exists try { await addProjectBtn.waitFor({ state: 'visible', timeout: 1000 }); // Empty state: just create project directly since button is already visible } catch { // Normal state: expand projects group first await projectsGroupBtn.waitFor({ state: 'visible', timeout: 5000 }); const projectsGroupExpanded = await projectsGroupBtn.getAttribute('aria-expanded'); if (projectsGroupExpanded !== 'true') { await projectsGroupBtn.click(); await this.page.waitForTimeout(500); // Wait for expansion animation } } // Create a new default project await this.createProject('Test Project'); // Navigate to the created project const projectName = this.testPrefix ? `${this.testPrefix}-Test Project` : 'Test Project'; // After creating a project, ensure Projects group is visible and expanded await this.page.waitForTimeout(2000); // Increased wait for DOM updates // Find the Projects group button (should exist since we have a project) const projectsGroupAfterCreation = this.page .locator('nav-list-tree') .filter({ hasText: 'Projects' }) .locator('nav-item button') .first(); await projectsGroupAfterCreation.waitFor({ state: 'visible', timeout: 5000 }); // Check if projects group is expanded, use similar logic to project.spec.ts let isExpanded = await projectsGroupAfterCreation.getAttribute('aria-expanded'); if (isExpanded !== 'true') { // Multiple approaches to expand the Projects section // First: Try clicking the expand icon within the Projects button const expandIcon = projectsGroupAfterCreation .locator('mat-icon, .expand-icon, [class*="expand"]') .first(); if (await expandIcon.isVisible({ timeout: 1000 }).catch(() => false)) { await expandIcon.click(); await this.page.waitForTimeout(1500); isExpanded = await projectsGroupAfterCreation.getAttribute('aria-expanded'); } // If still not expanded, try clicking the main button if (isExpanded !== 'true') { await projectsGroupAfterCreation.click(); await this.page.waitForTimeout(1500); isExpanded = await projectsGroupAfterCreation.getAttribute('aria-expanded'); } // If still not expanded, try double-clicking as last resort if (isExpanded !== 'true') { await projectsGroupAfterCreation.dblclick(); await this.page.waitForTimeout(1500); } } // Wait for the project to appear in the navigation - use improved approach from project.spec.ts await this.page.waitForTimeout(1000); // Allow time for project to appear let newProject; let projectFound = false; // Check if .nav-children container exists after expansion const navChildren = this.page.locator('.nav-children'); const navChildrenExists = await navChildren.count(); if (navChildrenExists > 0) { await navChildren.waitFor({ state: 'visible', timeout: 5000 }); try { // Primary approach: nav-child-item structure with nav-item button newProject = this.page .locator('.nav-children .nav-child-item nav-item button') .filter({ hasText: projectName }); await newProject.waitFor({ state: 'visible', timeout: 3000 }); projectFound = true; } catch { try { // Second approach: any nav-child-item with the project name newProject = this.page .locator('.nav-child-item') .filter({ hasText: projectName }) .locator('button'); await newProject.waitFor({ state: 'visible', timeout: 3000 }); projectFound = true; } catch { // Continue to fallback approaches } } } // Fallback approaches if structured approach didn't work if (!projectFound) { try { // Fallback: find any button with project name in the nav area newProject = this.page .locator('magic-side-nav button') .filter({ hasText: projectName }); await newProject.waitFor({ state: 'visible', timeout: 3000 }); projectFound = true; } catch { // Ultimate fallback: search entire page for project button newProject = this.page.locator('button').filter({ hasText: projectName }); await newProject.waitFor({ state: 'visible', timeout: 3000 }); projectFound = true; } } // Verify the project is found and click it if (!projectFound) { throw new Error(`Project "${projectName}" not found in navigation after creation`); } await newProject.click(); // Wait for navigation to complete await this.page.waitForLoadState('networkidle'); // Verify we're in the project await expect(this.workCtxTitle).toContainText(projectName); } async addNote(noteContent: string, projectId?: string): Promise { // Ensure we are on the correct project route if an id is provided if (projectId && !this.page.url().includes(projectId)) { await this.page.goto(`/#/project/${projectId}`); await this.page.waitForLoadState('networkidle'); } // Wait for the app to be ready const routerWrapper = this.page.locator('.route-wrapper'); await routerWrapper.waitFor({ state: 'visible', timeout: 6000 }); // Reduced from 10s to 6s // Wait for the page to be fully loaded await this.page.waitForLoadState('networkidle'); // Wait for project view to be present (not necessarily visible immediately) const workView = this.page.locator('work-view'); await workView.waitFor({ state: 'attached', timeout: 15000 }); const isWorkViewVisible = await workView .isVisible({ timeout: 5000 }) .catch(() => false); if (!isWorkViewVisible) { // Allow a brief moment for the view to finish rendering await this.page.waitForTimeout(500); } await this.page.waitForTimeout(100); // First ensure notes section is visible by clicking toggle if needed const toggleNotesBtn = this.page.locator('.e2e-toggle-notes-btn'); const isToggleBtnVisible = await toggleNotesBtn .isVisible({ timeout: 2000 }) .catch(() => false); if (isToggleBtnVisible) { await toggleNotesBtn.click(); // Wait for notes section to appear after toggle await this.page.locator('notes').waitFor({ state: 'visible', timeout: 5000 }); } // Hover header to ensure buttons might appear const projectHeader = this.page .locator('.project-header, .page-title-wrapper') .first(); if (await projectHeader.isVisible()) { await projectHeader.hover(); await this.page.waitForTimeout(500); } // Retry loop to open dialog for (let i = 0; i < 3; i++) { try { // Ensure any previous overlays are closed const backdrop = this.page.locator('.cdk-overlay-backdrop'); if (await backdrop.isVisible()) { await backdrop.click({ force: true }); await this.page.waitForTimeout(200); } // Try multiple approaches to open the note dialog let dialogOpened = false; // Approach 1: Try to click the add note button const addNoteBtn = this.page .locator( '#add-note-btn, button[title="Add Note"], button[aria-label="Add Note"], button mat-icon:has-text("note_add")', ) .first(); const isAddBtnVisible = await addNoteBtn .isVisible({ timeout: 2000 }) .catch(() => false); if (isAddBtnVisible) { // Move cursor away to avoid tooltip overlays blocking the click, then force-click await this.page.mouse.move(0, 0); await addNoteBtn.click({ force: true }); dialogOpened = true; } else { // Check overflow menu - scope to work context menu to avoid picking up task menus const moreBtn = this.workCtxMenu .locator('button mat-icon:has-text("more_vert")') .first(); if (await moreBtn.isVisible()) { await moreBtn.click({ force: true }); const menuAddNote = this.page .locator('.mat-mdc-menu-item') .filter({ hasText: 'Add Note' }) .first(); // Wait briefly for menu try { await menuAddNote.waitFor({ state: 'visible', timeout: 2000 }); await menuAddNote.click({ force: true }); dialogOpened = true; } catch { // Close menu if item not found await this.page.keyboard.press('Escape'); } } } // Approach 2: If button not visible, try keyboard shortcut if (!dialogOpened) { // Focus on the work view area first if (await workView.isVisible()) { await workView.click({ force: true }); } else { await this.page.locator('body').click({ force: true }); } await this.page.waitForTimeout(500); await this.page.keyboard.press('n'); } // Wait for dialog to appear with better error handling await this.page.locator('dialog-fullscreen-markdown').waitFor({ state: 'visible', timeout: 5000, // Short timeout for retry }); break; // Success } catch (e) { console.log(`Attempt ${i + 1} to open note dialog failed, retrying...`); await this.page.waitForTimeout(1000); } } // Final check/throw await this.page.locator('dialog-fullscreen-markdown').waitFor({ state: 'visible', timeout: 10000, }); // Try different selectors for the textarea let noteTextarea = this.page.locator('dialog-fullscreen-markdown textarea').first(); let textareaVisible = await noteTextarea .isVisible({ timeout: 2000 }) .catch(() => false); if (!textareaVisible) { // Try alternative selector noteTextarea = this.page.locator('textarea').first(); textareaVisible = await noteTextarea .isVisible({ timeout: 2000 }) .catch(() => false); } if (!textareaVisible) { throw new Error('Note dialog textarea not found after trying multiple approaches'); } await noteTextarea.fill(noteContent); // Click the save button - try multiple selectors let saveBtn = this.page.locator('#T-save-note'); let saveBtnVisible = await saveBtn.isVisible({ timeout: 2000 }).catch(() => false); if (!saveBtnVisible) { // Try button with save icon saveBtn = this.page.locator('button:has(mat-icon:has-text("save"))'); saveBtnVisible = await saveBtn.isVisible({ timeout: 2000 }).catch(() => false); } if (saveBtnVisible) { await saveBtn.click(); } else { // Fallback: press Enter to save await noteTextarea.press('Control+Enter'); } // Wait for dialog to close await this.page.locator('dialog-fullscreen-markdown').waitFor({ state: 'hidden', timeout: 5000, }); // After saving, check if notes panel is visible // If not, toggle it const notesWrapper = this.page.locator('notes'); const isNotesVisible = await notesWrapper .isVisible({ timeout: 1000 }) .catch(() => false); if (!isNotesVisible) { // Toggle the notes panel const toggleBtn = this.page.locator('.e2e-toggle-notes-btn'); await toggleBtn.waitFor({ state: 'visible' }); await toggleBtn.click(); await notesWrapper.waitFor({ state: 'visible', timeout: 5000 }); } // Hover over the notes area like in Nightwatch await notesWrapper.hover({ position: { x: 10, y: 50 } }); } }