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, waitForStatePersistence } from '../../utils/waits'; import { type Browser, type Page } from '@playwright/test'; import { isWebDavServerUp } from '../../utils/check-webdav'; // Timing constants for sync detection const SYNC_TIMEOUT_MS = 60000; const SPINNER_START_WAIT_MS = 3000; const SPINNER_POLL_INTERVAL_MS = 100; const SYNC_POLL_INTERVAL_MS = 500; const STABLE_COUNT_WITH_SPINNER = 3; const STABLE_COUNT_WITHOUT_SPINNER = 6; const WEBDAV_TIMESTAMP_DELAY_MS = 2000; test.describe('WebDAV Sync Expansion', () => { // Run sync tests serially to avoid WebDAV server contention test.describe.configure({ mode: 'serial' }); const WEBDAV_CONFIG_TEMPLATE = { baseUrl: 'http://127.0.0.1:2345/', username: 'admin', password: 'admin', }; test.beforeAll(async () => { const isUp = await isWebDavServerUp(WEBDAV_CONFIG_TEMPLATE.baseUrl); if (!isUp) { console.warn('WebDAV server not reachable. Skipping WebDAV tests.'); test.skip(true, 'WebDAV server not reachable'); } }); const createSyncFolder = async (request: any, folderName: string): Promise => { const mkcolUrl = `${WEBDAV_CONFIG_TEMPLATE.baseUrl}${folderName}`; console.log(`Creating WebDAV folder: ${mkcolUrl}`); try { const response = await request.fetch(mkcolUrl, { method: 'MKCOL', headers: { Authorization: 'Basic ' + Buffer.from('admin:admin').toString('base64'), }, }); if (!response.ok() && response.status() !== 405) { console.warn( `Failed to create WebDAV folder: ${response.status()} ${response.statusText()}`, ); } } catch (e) { console.warn('Error creating WebDAV folder:', e); } }; const dismissTour = async (page: Page): Promise => { try { const tourElement = page.locator('.shepherd-element').first(); await tourElement.waitFor({ state: 'visible', timeout: 4000 }); const cancelIcon = page.locator('.shepherd-cancel-icon').first(); if (await cancelIcon.isVisible()) { await cancelIcon.click(); } else { await page.keyboard.press('Escape'); } await tourElement.waitFor({ state: 'hidden', timeout: 3000 }); } catch (e) { // Ignore } }; const setupClient = async ( browser: Browser, baseURL: string | undefined, ): Promise<{ context: any; page: Page }> => { const context = await browser.newContext({ baseURL }); const page = await context.newPage(); await page.goto('/'); await waitForAppReady(page); await dismissTour(page); return { context, page }; }; const waitForSync = async ( page: Page, syncPage: SyncPage, ): Promise<'success' | 'conflict' | void> => { const startTime = Date.now(); let sawSpinner = false; let stableCount = 0; await expect(syncPage.syncBtn).toBeVisible({ timeout: 10000 }); // First, wait for sync to START (spinner appears) or immediately complete const spinnerStartWait = Date.now(); while (Date.now() - spinnerStartWait < SPINNER_START_WAIT_MS) { const isSpinning = await syncPage.syncSpinner.isVisible(); if (isSpinning) { sawSpinner = true; break; } await page.waitForTimeout(SPINNER_POLL_INTERVAL_MS); } while (Date.now() - startTime < SYNC_TIMEOUT_MS) { // Check for conflict dialog const conflictDialog = page.locator('dialog-sync-conflict'); if (await conflictDialog.isVisible()) return 'conflict'; // Check for error snackbar 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(); if (text.toLowerCase().includes('error') || text.toLowerCase().includes('fail')) { throw new Error(`Sync failed with error: ${text}`); } } // Check if sync is in progress (spinner visible) const isSpinning = await syncPage.syncSpinner.isVisible(); if (isSpinning) { sawSpinner = true; stableCount = 0; // Reset stable count while spinning } else if (sawSpinner) { // Spinner was visible before, now it's gone - sync completed const successVisible = await syncPage.syncCheckIcon.isVisible(); if (successVisible) { return 'success'; } // No check icon but spinner stopped - wait a bit more stableCount++; if (stableCount >= STABLE_COUNT_WITH_SPINNER) { return 'success'; } } else { // Never saw spinner - might have completed instantly // Check for snackbar indicating sync result for (let i = 0; i < count; ++i) { const text = await snackBars.nth(i).innerText(); if ( text.toLowerCase().includes('sync') || text.toLowerCase().includes('already in sync') ) { return 'success'; } } stableCount++; if (stableCount >= STABLE_COUNT_WITHOUT_SPINNER) { return 'success'; } } await page.waitForTimeout(SYNC_POLL_INTERVAL_MS); } throw new Error('Sync timeout: Sync did not complete'); }; test('should sync projects', async ({ browser, baseURL, request }) => { test.slow(); const SYNC_FOLDER_NAME = `e2e-expansion-proj-${Date.now()}`; await createSyncFolder(request, SYNC_FOLDER_NAME); const WEBDAV_CONFIG = { ...WEBDAV_CONFIG_TEMPLATE, syncFolderPath: `/${SYNC_FOLDER_NAME}`, }; const url = baseURL || 'http://localhost:4242'; // --- Client A --- const { context: contextA, page: pageA } = await setupClient(browser, url); const syncPageA = new SyncPage(pageA); const workViewPageA = new WorkViewPage(pageA); const projectPageA = new ProjectPage(pageA); await workViewPageA.waitForTaskList(); // Configure Sync A await syncPageA.setupWebdavSync(WEBDAV_CONFIG); // Create Project on A const projectName = 'Synced Project'; await projectPageA.createProject(projectName); // Navigate to the newly created project (createProject doesn't auto-navigate) await projectPageA.navigateToProjectByName(projectName); // Add task to new project on A await workViewPageA.addTask('Task in Project A'); // Sync A await syncPageA.triggerSync(); await waitForSync(pageA, syncPageA); // --- Client B --- const { context: contextB, page: pageB } = await setupClient(browser, url); const syncPageB = new SyncPage(pageB); const workViewPageB = new WorkViewPage(pageB); const projectPageB = new ProjectPage(pageB); await workViewPageB.waitForTaskList(); // Configure Sync B await syncPageB.setupWebdavSync(WEBDAV_CONFIG); await syncPageB.triggerSync(); await waitForSync(pageB, syncPageB); // Wait for state persistence to complete after sync await waitForStatePersistence(pageB); // Note: We DON'T reload here because: // 1. The sync already updates NgRx store via reInitFromRemoteSync() // 2. Reload would require re-initializing the sync provider which has timing issues // 3. Without reload, the sync button remains functional // Wait for the synced project to appear in the sidebar // First ensure Projects group is expanded const projectsTree = pageB.locator('nav-list-tree').filter({ hasText: 'Projects' }); const projectsGroupBtn = projectsTree .locator('.g-multi-btn-wrapper nav-item button') .first(); await projectsGroupBtn.waitFor({ state: 'visible', timeout: 5000 }); const isExpanded = await projectsGroupBtn.getAttribute('aria-expanded'); if (isExpanded !== 'true') { await projectsGroupBtn.click(); } // Now wait for the project to appear const projectBtn = projectsTree.locator('button').filter({ hasText: projectName }); await projectBtn.waitFor({ state: 'visible', timeout: 15000 }); await projectPageB.navigateToProjectByName(projectName); // Verify task await expect(pageB.locator('task')).toHaveCount(1); await expect(pageB.locator('task').first()).toContainText('Task in Project A'); // Add task on B in project await workViewPageB.addTask('Task in Project B'); // Wait for state persistence before syncing await waitForStatePersistence(pageB); await syncPageB.triggerSync(); await waitForSync(pageB, syncPageB); // Wait for server to process and ensure Last-Modified timestamp differs // WebDAV servers often have second-level timestamp precision await pageB.waitForTimeout(WEBDAV_TIMESTAMP_DELAY_MS); // Sync A - trigger sync to download changes from B await syncPageA.triggerSync(); await waitForSync(pageA, syncPageA); // Wait for state persistence to complete after sync await waitForStatePersistence(pageA); // Navigate to project page to verify task synced await projectPageA.navigateToProjectByName(projectName); // Check if task B is visible immediately after sync (no reload) await expect(pageA.locator('task', { hasText: 'Task in Project B' })).toBeVisible({ timeout: 20000, }); await contextA.close(); await contextB.close(); }); test('should sync task done state', async ({ browser, baseURL, request }) => { test.slow(); const SYNC_FOLDER_NAME = `e2e-expansion-done-${Date.now()}`; await createSyncFolder(request, SYNC_FOLDER_NAME); const WEBDAV_CONFIG = { ...WEBDAV_CONFIG_TEMPLATE, syncFolderPath: `/${SYNC_FOLDER_NAME}`, }; const url = baseURL || 'http://localhost:4242'; // --- Client A --- const { context: contextA, page: pageA } = await setupClient(browser, url); const syncPageA = new SyncPage(pageA); const workViewPageA = new WorkViewPage(pageA); await workViewPageA.waitForTaskList(); await syncPageA.setupWebdavSync(WEBDAV_CONFIG); const taskName = 'Task to be done'; await workViewPageA.addTask(taskName); await syncPageA.triggerSync(); await waitForSync(pageA, syncPageA); // --- Client B --- const { context: contextB, page: pageB } = await setupClient(browser, url); const syncPageB = new SyncPage(pageB); const workViewPageB = new WorkViewPage(pageB); await workViewPageB.waitForTaskList(); // Configure Sync B await syncPageB.setupWebdavSync(WEBDAV_CONFIG); await syncPageB.triggerSync(); await waitForSync(pageB, syncPageB); // Wait for state persistence to complete after sync await waitForStatePersistence(pageB); // Verify task synced to B const taskB = pageB.locator('task', { hasText: taskName }).first(); await expect(taskB).toBeVisible({ timeout: 20000 }); await expect(taskB).not.toHaveClass(/isDone/); // --- Test 1: Mark done on B, verify on A --- await taskB.hover(); const doneBtnB = taskB.locator('.task-done-btn'); await doneBtnB.click({ force: true }); await expect(taskB).toHaveClass(/isDone/); // Wait for state persistence before syncing await waitForStatePersistence(pageB); await syncPageB.triggerSync(); await waitForSync(pageB, syncPageB); // Sync A to get done state from B await syncPageA.triggerSync(); await waitForSync(pageA, syncPageA); // Wait for state persistence to complete after sync await waitForStatePersistence(pageA); // Note: We DON'T reload - sync updates NgRx directly // Verify task is marked as done on A after sync const taskA = pageA.locator('task', { hasText: taskName }).first(); await expect(taskA).toHaveClass(/isDone/, { timeout: 10000 }); await contextA.close(); await contextB.close(); }); });