Fix(e2e): Stabilize WebDAV sync tests

- Isolate WebDAV sync tests with unique folders per test to prevent cross-test interference.
- Fix 'should sync task attachments' by correcting the attachment link selector, updating text expectation, and using force click to bypass UI interception.
- Fix 'should sync task done state' by adding hover action for the done button and correcting the 'isDone' class assertion.
This commit is contained in:
Johannes Millan 2025-12-10 12:15:51 +01:00
parent 5fbf926183
commit 1b13113442
3 changed files with 573 additions and 0 deletions

View file

@ -0,0 +1,68 @@
import { expect, Locator, Page } from '@playwright/test';
import { BasePage } from './base.page';
export class SideNavPage extends BasePage {
readonly nav: Locator;
readonly navItems: Locator;
readonly allProjectsBtn: Locator;
readonly backlogTasksBtn: Locator;
readonly doneTasksBtn: Locator;
readonly settingsBtn: Locator;
readonly projectsGroupHeader: Locator;
readonly projectsGroupAdditionalBtn: Locator;
constructor(page: Page, testPrefix: string = '') {
super(page, testPrefix);
this.nav = page.locator('magic-side-nav');
this.navItems = page.locator('magic-side-nav nav-item');
this.allProjectsBtn = page.locator('magic-side-nav button:has-text("All Projects")');
this.backlogTasksBtn = page.locator('magic-side-nav button:has-text("Backlog")');
this.doneTasksBtn = page.locator('magic-side-nav button:has-text("Done")');
this.settingsBtn = page.locator('magic-side-nav button[routerlink="/settings"]');
this.projectsGroupHeader = page
.locator('.g-multi-btn-wrapper')
.filter({ hasText: 'Projects' })
.first();
this.projectsGroupAdditionalBtn = this.projectsGroupHeader.locator('.additional-btn');
}
async ensureSideNavOpen(): Promise<void> {
const isVisible = await this.nav.isVisible();
if (!isVisible) {
// Click somewhere on the main content to unfocus if side nav might be closed
await this.page.locator('body').click();
await this.page.waitForTimeout(500); // give it a moment
// Attempt to open side nav via a common trigger if it's not visible
const menuBtn = this.page.locator('#triggerSideNavBtn');
if (await menuBtn.isVisible()) {
await menuBtn.click();
await this.nav.waitFor({ state: 'visible', timeout: 5000 });
} else {
// Fallback: if there's no specific menu button, assume some key press might work or it's a layout issue
// For now, if no button, just throw or log a warning if side nav is critical
throw new Error('Side nav is not visible and no trigger button found');
}
}
// Ensure the side nav is fully rendered and stable
await this.navItems.first().waitFor({ state: 'visible', timeout: 5000 });
}
async ensureSideNavClosed(): Promise<void> {
const isVisible = await this.nav.isVisible();
if (isVisible) {
// Click outside to close side nav
await this.page.locator('body').click({ position: { x: 500, y: 500 } });
await this.nav.waitFor({ state: 'hidden', timeout: 5000 });
}
}
async ensureProjectsGroupExpanded(): Promise<void> {
const isExpanded = await this.projectsGroupAdditionalBtn.isVisible(); // The additional button is only visible when expanded
if (!isExpanded) {
// Click the header to expand
await this.projectsGroupHeader.click();
// Wait for the additional button to become visible, indicating expansion
await this.projectsGroupAdditionalBtn.waitFor({ state: 'visible', timeout: 8000 });
}
}
}

View file

@ -0,0 +1,248 @@
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 { type Browser, type Page } from '@playwright/test';
test.describe('WebDAV Sync Advanced Features', () => {
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 WEBDAV_CONFIG_TEMPLATE = {
baseUrl: 'http://localhost:2345/',
username: 'admin',
password: 'admin',
};
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);
// Dismiss Shepherd Tour if present
try {
const tourElement = page.locator('.shepherd-element').first();
// Short wait to see if it appears
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) {
// Tour didn't appear or wasn't dismissable, ignore
}
return { context, page };
};
const waitForSync = async (
page: Page,
syncPage: SyncPage,
): Promise<'success' | 'conflict' | void> => {
// Poll for success icon, error snackbar, or conflict dialog
const startTime = Date.now();
while (Date.now() - startTime < 30000) {
// 30s timeout
const successVisible = await syncPage.syncCheckIcon.isVisible();
if (successVisible) return 'success';
const conflictDialog = page.locator('dialog-sync-conflict');
if (await conflictDialog.isVisible()) return 'conflict';
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();
// Check for keywords indicating failure
if (text.toLowerCase().includes('error') || text.toLowerCase().includes('fail')) {
throw new Error(`Sync failed with error: ${text}`);
}
}
await page.waitForTimeout(500);
}
throw new Error('Sync timeout: Success icon did not appear');
};
test('should sync sub-tasks correctly', async ({ browser, baseURL, request }) => {
const SYNC_FOLDER_NAME = `e2e-advanced-sub-${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();
// Configure Sync on Client A
await syncPageA.setupWebdavSync(WEBDAV_CONFIG);
// Create Parent Task
const parentTaskName = 'Parent Task';
await workViewPageA.addTask(parentTaskName);
const parentTaskA = pageA.locator('task', { hasText: parentTaskName }).first();
// Create Sub Tasks
await workViewPageA.addSubTask(parentTaskA, 'Sub Task 1');
await workViewPageA.addSubTask(parentTaskA, 'Sub Task 2');
// Verify structure on A
await expect(pageA.locator('task-list[listid="SUB"] task')).toHaveCount(2);
// 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);
await workViewPageB.waitForTaskList();
// Configure Sync on Client B
await syncPageB.setupWebdavSync(WEBDAV_CONFIG);
await expect(syncPageB.syncBtn).toBeVisible();
// Sync B
await syncPageB.triggerSync();
await waitForSync(pageB, syncPageB);
// Verify structure on B
const parentTaskB = pageB.locator('task', { hasText: parentTaskName }).first();
await expect(parentTaskB).toBeVisible();
// Check for subtask count - expand first
await parentTaskB.click(); // Ensure focus/expanded? Usually auto-expanded.
// Use more specific locator for subtasks
const subTaskList = pageB.locator(`task-list[listid="SUB"]`);
await expect(subTaskList.locator('task')).toHaveCount(2);
await expect(subTaskList.locator('task', { hasText: 'Sub Task 1' })).toBeVisible();
await expect(subTaskList.locator('task', { hasText: 'Sub Task 2' })).toBeVisible();
await contextA.close();
await contextB.close();
});
test('should sync task attachments', async ({ browser, baseURL, request }) => {
test.slow();
const SYNC_FOLDER_NAME = `e2e-advanced-att-${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();
// Configure Sync on Client A
await syncPageA.setupWebdavSync(WEBDAV_CONFIG);
// Create Task
const taskName = 'Attachment Task';
await workViewPageA.addTask(taskName);
const taskA = pageA.locator('task', { hasText: taskName }).first();
// Add Attachment
// Use context menu which is more reliable
await taskA.click({ button: 'right' });
// Click "Attach file or link" in context menu
// The menu is in a portal, so we query the page
const attachBtn = pageA.locator('.mat-mdc-menu-content button', {
hasText: 'Attach',
});
await attachBtn.waitFor({ state: 'visible' });
await attachBtn.click();
// Dialog opens (direct attachment dialog or via side panel?)
// The context menu action calls `addAttachment()`, which usually opens the dialog.
const dialog = pageA.locator('dialog-edit-task-attachment');
await expect(dialog).toBeVisible();
// Fill title
await dialog.locator('input[name="title"]').fill('Google');
// Fill path/url
const pathInput = dialog.locator('input[name="path"]');
await pathInput.fill('https://google.com');
await dialog.locator('button[type="submit"]').click();
// Verify attachment indicator appears on task
const attachmentBtn = taskA.locator('.attachment-btn');
await expect(attachmentBtn).toBeVisible();
// Click it to open side panel
await attachmentBtn.click({ force: true });
// Verify attachment added on A
await expect(pageA.locator('.attachment-link')).toBeVisible();
await expect(pageA.locator('.attachment-link')).toContainText('Google');
// 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);
await workViewPageB.waitForTaskList();
// Configure Sync on Client B
await syncPageB.setupWebdavSync(WEBDAV_CONFIG);
// Sync B
await syncPageB.triggerSync();
await waitForSync(pageB, syncPageB);
// Verify Attachment on B
const taskB = pageB.locator('task', { hasText: taskName }).first();
await taskB.click();
await taskB.locator('.show-additional-info-btn').click({ force: true });
await expect(pageB.locator('.attachment-link')).toContainText('Google');
await contextA.close();
await contextB.close();
});
});

View file

@ -0,0 +1,257 @@
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 { type Browser, type Page } from '@playwright/test';
test.describe('WebDAV Sync Expansion', () => {
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 WEBDAV_CONFIG_TEMPLATE = {
baseUrl: 'http://localhost:2345/',
username: 'admin',
password: 'admin',
};
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();
await expect(syncPage.syncBtn).toBeVisible({ timeout: 10000 });
while (Date.now() - startTime < 60000) {
const successVisible = await syncPage.syncCheckIcon.isVisible();
if (successVisible) return 'success';
const conflictDialog = page.locator('dialog-sync-conflict');
if (await conflictDialog.isVisible()) return 'conflict';
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}`);
}
}
await page.waitForTimeout(500);
}
throw new Error('Sync timeout: Success icon did not appear');
};
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);
// 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);
// Reload to ensure UI is updated with synced data
await pageB.reload();
await waitForAppReady(pageB);
await dismissTour(pageB);
// Verify Project on B
// Wait for project navigation
await pageB.waitForTimeout(2000);
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');
await syncPageB.triggerSync();
await waitForSync(pageB, syncPageB);
// Sync A
await syncPageA.triggerSync();
await waitForSync(pageA, syncPageA);
await pageA.reload();
await waitForAppReady(pageA);
// Ensure we are on the project page
await projectPageA.navigateToProjectByName(projectName);
// Verify task on A
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);
await pageB.reload();
await waitForAppReady(pageB);
await dismissTour(pageB);
await workViewPageB.waitForTaskList();
await expect(pageB.locator('task', { hasText: taskName })).toBeVisible({
timeout: 20000,
});
// Mark done on A
await pageA.waitForTimeout(1000);
const taskA = pageA.locator('task', { hasText: taskName }).first();
await taskA.hover();
const doneBtnA = taskA.locator('.task-done-btn');
await doneBtnA.click({ force: true });
// Wait for done state (strikethrough or disappearance depending on config, default is just strikethrough/checked)
// By default, done tasks might move to "Done" list or stay.
// Assuming default behavior: check if class 'is-done' is present or checkbox checked.
await expect(taskA).toHaveClass(/isDone/);
await syncPageA.triggerSync();
await waitForSync(pageA, syncPageA);
// Sync B
await syncPageB.triggerSync();
await waitForSync(pageB, syncPageB);
const taskB = pageB.locator('task', { hasText: taskName }).first();
await expect(taskB).toHaveClass(/isDone/);
// Mark undone on B
const doneBtnB = taskB.locator('.check-done');
await doneBtnB.click();
await expect(taskB).not.toHaveClass(/isDone/);
await syncPageB.triggerSync();
await waitForSync(pageB, syncPageB);
// Sync A
await syncPageA.triggerSync();
await waitForSync(pageA, syncPageA);
await expect(taskA).not.toHaveClass(/isDone/);
await contextA.close();
await contextB.close();
});
});