super-productivity/e2e/tests/sync/webdav-sync-expansion.spec.ts
Johannes Millan 6c098b6eaa Merge branch 'master' into feat/operation-logs
Resolve conflict in webdav-sync-expansion.spec.ts:
- Use simplified sync verification without reload (sync updates NgRx directly)
- Test: B marks task done -> sync -> verify A sees task as done
2025-12-29 21:54:15 +01:00

338 lines
12 KiB
TypeScript

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<void> => {
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<void> => {
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();
});
});