super-productivity/e2e/tests/sync/webdav-sync-delete-cascade.spec.ts
Johannes Millan 2f8d871042 feat(shepherd): remove all tours except keyboard navigation
Remove the full welcome tour (Welcome, AddTask, DeleteTask, Projects,
Sync, IssueProviders, ProductivityHelper, FinalCongrats, StartTourAgain)
and keep only the KeyboardNav tour, triggered from the Help menu.

- Delete ShepherdComponent (auto-start on load)
- Strip shepherd-steps.const.ts to KeyboardNav steps only
- Simplify ShepherdService with _initPromise guard and fresh tour on re-open
- Remove 3 tour menu items from Help menu, keep keyboard tour
- Remove waitForEl/waitForElObs$ helpers (unused by KeyboardNav)
- Delete e2e/utils/tour-helpers.ts and all tour dismissal calls
- Clean up stale tour comments across e2e tests
- Fix pre-existing build error: add isFinishDayEnabled to AppFeaturesConfig
  type, defaults, form toggle, component signal, and translations
2026-03-22 17:00:42 +01:00

460 lines
16 KiB
TypeScript

import { expect } from '@playwright/test';
import { test } from '../../fixtures/webdav.fixture';
import { SyncPage } from '../../pages/sync.page';
import { WorkViewPage } from '../../pages/work-view.page';
import { TaskPage } from '../../pages/task.page';
import { TagPage } from '../../pages/tag.page';
import { ProjectPage } from '../../pages/project.page';
import { waitForStatePersistence, waitForAppReady } from '../../utils/waits';
import {
WEBDAV_CONFIG_TEMPLATE,
setupSyncClient,
createSyncFolder,
waitForSyncComplete,
generateSyncFolderName,
waitForArchivePersistence,
closeContextsSafely,
} from '../../utils/sync-helpers';
import { Page } from 'playwright';
/**
* WebDAV Delete Cascade E2E Tests
*
* These tests verify that deleting entities (tags/projects) properly cascades
* to archived tasks across multiple clients:
* - Delete tag with archived tasks syncs to other client
* - Delete project with archived tasks syncs to other client
* - Concurrent delete tag + archive task
*/
/**
* Archive done tasks via Daily Summary flow
* Adapted from webdav-sync-archive.spec.ts
*/
const archiveDoneTasks = async (page: Page): Promise<void> => {
const finishDayBtn = page.locator('.e2e-finish-day');
await finishDayBtn.waitFor({ state: 'visible', timeout: 10000 });
await finishDayBtn.click();
await page.waitForURL(/daily-summary/, { timeout: 15000 });
const saveAndGoHomeBtn = page.locator(
'daily-summary button[mat-flat-button]:has(mat-icon:has-text("wb_sunny"))',
);
await saveAndGoHomeBtn.waitFor({ state: 'visible', timeout: 10000 });
await saveAndGoHomeBtn.click();
await page.waitForURL(/(active\/tasks|tag\/TODAY\/tasks)/, { timeout: 15000 });
};
test.describe('@webdav WebDAV Delete Cascade Sync', () => {
test.describe.configure({ mode: 'serial' });
/**
* Test 1.1: Delete tag with archived tasks syncs to other client
*
* Setup:
* - Client A creates Tag1
* - Client A creates Task1 with Tag1
* - Client A marks Task1 done, archives it
* - Client A syncs
* - Client B syncs (downloads archived Task1)
*
* Test:
* 1. Client A deletes Tag1
* 2. Client A syncs
* 3. Client B syncs
* 4. Verify: No errors, tag cascade completes successfully
*/
test('Delete tag with archived tasks syncs to other client', async ({
browser,
baseURL,
request,
}) => {
test.slow();
const SYNC_FOLDER_NAME = generateSyncFolderName('e2e-del-tag-archive');
const WEBDAV_CONFIG = {
...WEBDAV_CONFIG_TEMPLATE,
syncFolderPath: `/${SYNC_FOLDER_NAME}`,
};
const url = baseURL || 'http://localhost:4242';
await createSyncFolder(request, SYNC_FOLDER_NAME);
// --- Setup Client A ---
const { context: contextA, page: pageA } = await setupSyncClient(browser, url);
const syncPageA = new SyncPage(pageA);
const workViewPageA = new WorkViewPage(pageA);
const taskPageA = new TaskPage(pageA);
const tagPageA = new TagPage(pageA);
await workViewPageA.waitForTaskList();
await syncPageA.setupWebdavSync(WEBDAV_CONFIG);
console.log('[Delete Tag] Client A configured');
// Client A creates a tag
const tagName = 'TestTag-ToDelete';
await tagPageA.createTag(tagName);
console.log('[Delete Tag] Client A created tag');
// Client A creates task and assigns tag
const taskName = 'Task-WithTag';
await workViewPageA.addTask(taskName);
const task = taskPageA.getTaskByText(taskName).first();
await tagPageA.assignTagToTask(task, tagName);
await expect(tagPageA.getTagOnTask(task, tagName)).toBeVisible();
console.log('[Delete Tag] Client A created task with tag');
// Mark task done and archive
await taskPageA.markTaskAsDone(task);
await pageA.waitForTimeout(300);
await archiveDoneTasks(pageA);
await waitForArchivePersistence(pageA);
await expect(pageA.locator('task')).toHaveCount(0);
console.log('[Delete Tag] Client A archived task');
// Client A syncs
await waitForStatePersistence(pageA);
await syncPageA.triggerSync();
await waitForSyncComplete(pageA, syncPageA);
console.log('[Delete Tag] Client A initial sync complete');
// --- Setup Client B ---
const { context: contextB, page: pageB } = await setupSyncClient(browser, url);
const syncPageB = new SyncPage(pageB);
const workViewPageB = new WorkViewPage(pageB);
const tagPageB = new TagPage(pageB);
await workViewPageB.waitForTaskList();
await syncPageB.setupWebdavSync(WEBDAV_CONFIG);
console.log('[Delete Tag] Client B configured');
// Client B syncs (downloads archived task with tag)
await syncPageB.triggerSync();
await waitForSyncComplete(pageB, syncPageB);
console.log('[Delete Tag] Client B synced, downloaded archived task');
// Verify tag exists on Client B
const tagExistsB = await tagPageB.tagExistsInSidebar(tagName);
expect(tagExistsB).toBe(true);
console.log('[Delete Tag] Verified tag exists on Client B');
// --- Client A deletes the tag ---
await tagPageA.deleteTag(tagName);
console.log('[Delete Tag] Client A deleted tag');
// Client A syncs
await waitForStatePersistence(pageA);
await syncPageA.triggerSync();
await waitForSyncComplete(pageA, syncPageA);
console.log('[Delete Tag] Client A synced deletion');
// Client B syncs
await syncPageB.triggerSync();
await waitForSyncComplete(pageB, syncPageB);
console.log('[Delete Tag] Client B synced');
// --- Verify final state ---
// Tag should be gone on both clients
const tagExistsAFinal = await tagPageA.tagExistsInSidebar(tagName);
expect(tagExistsAFinal).toBe(false);
console.log('[Delete Tag] Tag deleted on Client A');
const tagExistsBFinal = await tagPageB.tagExistsInSidebar(tagName);
expect(tagExistsBFinal).toBe(false);
console.log('[Delete Tag] Tag deleted on Client B');
// Reload Client B and verify no errors (archive should be cleaned)
await pageB.reload();
await waitForAppReady(pageB);
await workViewPageB.waitForTaskList();
// Check for no global errors
const globalError = pageB.locator('.global-error-alert');
await expect(globalError).not.toBeVisible({ timeout: 3000 });
console.log('[Delete Tag] No errors after reload on Client B');
console.log('[Delete Tag] ✓ Delete tag with archived tasks synced correctly');
// Cleanup
await closeContextsSafely(contextA, contextB);
});
/**
* Test 1.2: Delete project with archived tasks syncs to other client
*
* Setup:
* - Client A creates Project1
* - Client A creates Task1 in Project1
* - Client A marks Task1 done, archives it
* - Client A syncs
* - Client B syncs (downloads archived Task1)
*
* Test:
* 1. Client A deletes Project1
* 2. Client A syncs
* 3. Client B syncs
* 4. Verify: Archived task is deleted on both clients
*/
test('Delete project with archived tasks syncs to other client', async ({
browser,
baseURL,
request,
}) => {
test.slow();
const SYNC_FOLDER_NAME = generateSyncFolderName('e2e-del-proj-archive');
const WEBDAV_CONFIG = {
...WEBDAV_CONFIG_TEMPLATE,
syncFolderPath: `/${SYNC_FOLDER_NAME}`,
};
const url = baseURL || 'http://localhost:4242';
await createSyncFolder(request, SYNC_FOLDER_NAME);
// --- Setup Client A ---
const { context: contextA, page: pageA } = await setupSyncClient(browser, url);
const syncPageA = new SyncPage(pageA);
const workViewPageA = new WorkViewPage(pageA);
const taskPageA = new TaskPage(pageA);
const projectPageA = new ProjectPage(pageA);
await workViewPageA.waitForTaskList();
await syncPageA.setupWebdavSync(WEBDAV_CONFIG);
console.log('[Delete Project] Client A configured');
// Client A creates a project
const projectName = 'TestProject-ToDelete';
await projectPageA.createProject(projectName);
await projectPageA.navigateToProjectByName(projectName);
console.log('[Delete Project] Client A created and navigated to project');
// Client A creates task in project
const taskName = 'Task-InProject';
await workViewPageA.addTask(taskName);
await expect(pageA.locator('task')).toHaveCount(1);
console.log('[Delete Project] Client A created task in project');
// Mark task done
const task = taskPageA.getTaskByText(taskName).first();
await taskPageA.markTaskAsDone(task);
await pageA.waitForTimeout(500);
// For project view, use "Move done to archive" button instead of Daily Summary
const moveToArchiveBtn = pageA.locator('.e2e-move-done-to-archive');
await moveToArchiveBtn.waitFor({ state: 'visible', timeout: 5000 });
await moveToArchiveBtn.click();
await pageA.waitForTimeout(500);
await expect(pageA.locator('task')).toHaveCount(0);
console.log('[Delete Project] Client A archived task');
// Navigate to TODAY view before syncing
await pageA.goto(`${url}/#/tag/TODAY/tasks`);
await workViewPageA.waitForTaskList();
// Client A syncs
await waitForStatePersistence(pageA);
await syncPageA.triggerSync();
await waitForSyncComplete(pageA, syncPageA);
console.log('[Delete Project] Client A initial sync complete');
// --- Setup Client B ---
const { context: contextB, page: pageB } = await setupSyncClient(browser, url);
const syncPageB = new SyncPage(pageB);
const workViewPageB = new WorkViewPage(pageB);
const projectPageB = new ProjectPage(pageB);
await workViewPageB.waitForTaskList();
await syncPageB.setupWebdavSync(WEBDAV_CONFIG);
console.log('[Delete Project] Client B configured');
// Client B syncs (downloads project and archived task)
await syncPageB.triggerSync();
await waitForSyncComplete(pageB, syncPageB);
console.log('[Delete Project] Client B synced');
// Navigate to the project directly to verify it exists on Client B
await projectPageB.navigateToProjectByName(projectName);
console.log('[Delete Project] Verified project exists on Client B (navigated to it)');
// --- Client A deletes the project ---
await projectPageA.deleteProject(projectName);
console.log('[Delete Project] Client A deleted project');
// Client A syncs
await waitForStatePersistence(pageA);
await syncPageA.triggerSync();
await waitForSyncComplete(pageA, syncPageA);
console.log('[Delete Project] Client A synced deletion');
// Client B syncs
await syncPageB.triggerSync();
await waitForSyncComplete(pageB, syncPageB);
console.log('[Delete Project] Client B synced');
// --- Verify final state ---
// Navigate to TODAY view on Client B
await pageB.goto(`${url}/#/tag/TODAY/tasks`);
await workViewPageB.waitForTaskList();
// Check for no global errors
const globalError = pageB.locator('.global-error-alert');
await expect(globalError).not.toBeVisible({ timeout: 3000 });
// Project should not exist in sidebar
const projectInSidebar = pageB
.locator('magic-side-nav')
.locator(`text=${projectName}`);
await expect(projectInSidebar).not.toBeVisible({ timeout: 3000 });
console.log('[Delete Project] Project deleted on Client B');
console.log('[Delete Project] ✓ Delete project with archived tasks synced correctly');
// Cleanup
await closeContextsSafely(contextA, contextB);
});
/**
* Test 1.3: Concurrent delete tag + archive task
*
* Setup:
* - Client A creates Tag1
* - Client A creates Task1 with Tag1, Task2 with Tag1
* - Both sync
*
* Test:
* 1. Client A deletes Tag1 (before sync)
* 2. Client B marks Task1 done, archives it (before sync)
* 3. Client A syncs
* 4. Client B syncs
* 5. Verify: Task1 is archived but without Tag1 reference
* 6. Verify: Consistent state on both clients
*/
test('Concurrent delete tag and archive task', async ({
browser,
baseURL,
request,
}) => {
test.slow();
const SYNC_FOLDER_NAME = generateSyncFolderName('e2e-del-tag-concurrent');
const WEBDAV_CONFIG = {
...WEBDAV_CONFIG_TEMPLATE,
syncFolderPath: `/${SYNC_FOLDER_NAME}`,
};
const url = baseURL || 'http://localhost:4242';
await createSyncFolder(request, SYNC_FOLDER_NAME);
// --- Setup Client A ---
const { context: contextA, page: pageA } = await setupSyncClient(browser, url);
const syncPageA = new SyncPage(pageA);
const workViewPageA = new WorkViewPage(pageA);
const taskPageA = new TaskPage(pageA);
const tagPageA = new TagPage(pageA);
await workViewPageA.waitForTaskList();
await syncPageA.setupWebdavSync(WEBDAV_CONFIG);
console.log('[Concurrent] Client A configured');
// Client A creates a tag
const tagName = 'TestTag-Concurrent';
await tagPageA.createTag(tagName);
console.log('[Concurrent] Client A created tag');
// Client A creates two tasks and assigns tag
const task1Name = 'Task1-ToArchive';
const task2Name = 'Task2-Remains';
await workViewPageA.addTask(task1Name);
await workViewPageA.addTask(task2Name);
const task1 = taskPageA.getTaskByText(task1Name).first();
const task2 = taskPageA.getTaskByText(task2Name).first();
await tagPageA.assignTagToTask(task1, tagName);
await tagPageA.assignTagToTask(task2, tagName);
console.log('[Concurrent] Client A created 2 tasks with tag');
// Client A syncs
await waitForStatePersistence(pageA);
await syncPageA.triggerSync();
await waitForSyncComplete(pageA, syncPageA);
console.log('[Concurrent] Client A initial sync complete');
// --- Setup Client B ---
const { context: contextB, page: pageB } = await setupSyncClient(browser, url);
const syncPageB = new SyncPage(pageB);
const workViewPageB = new WorkViewPage(pageB);
const taskPageB = new TaskPage(pageB);
await workViewPageB.waitForTaskList();
await syncPageB.setupWebdavSync(WEBDAV_CONFIG);
console.log('[Concurrent] Client B configured');
// Client B syncs (downloads tasks with tag)
await syncPageB.triggerSync();
await waitForSyncComplete(pageB, syncPageB);
await expect(pageB.locator('task')).toHaveCount(2);
console.log('[Concurrent] Client B downloaded 2 tasks');
// --- Concurrent operations ---
// Client A deletes the tag (before sync)
await tagPageA.deleteTag(tagName);
console.log('[Concurrent] Client A deleted tag (not synced yet)');
// Client B archives Task1 (before sync)
const task1OnB = taskPageB.getTaskByText(task1Name).first();
await taskPageB.markTaskAsDone(task1OnB);
await pageB.waitForTimeout(300);
await archiveDoneTasks(pageB);
await waitForArchivePersistence(pageB);
await expect(pageB.locator('task')).toHaveCount(1); // Only Task2 visible
console.log('[Concurrent] Client B archived Task1 (not synced yet)');
// --- Both clients sync ---
await waitForStatePersistence(pageA);
await syncPageA.triggerSync();
await waitForSyncComplete(pageA, syncPageA);
console.log('[Concurrent] Client A synced tag deletion');
await waitForStatePersistence(pageB);
await syncPageB.triggerSync();
await waitForSyncComplete(pageB, syncPageB);
console.log('[Concurrent] Client B synced archive');
// Sync again for eventual consistency
await syncPageA.triggerSync();
await waitForSyncComplete(pageA, syncPageA);
await syncPageB.triggerSync();
await waitForSyncComplete(pageB, syncPageB);
console.log('[Concurrent] Both synced again for consistency');
// --- Verify final state ---
// Tag should be deleted on both clients
const tagExistsAFinal = await tagPageA.tagExistsInSidebar(tagName);
expect(tagExistsAFinal).toBe(false);
// Reload Client B and verify no errors
await pageB.reload();
await waitForAppReady(pageB);
await workViewPageB.waitForTaskList();
const globalError = pageB.locator('.global-error-alert');
await expect(globalError).not.toBeVisible({ timeout: 3000 });
// Task2 should still be visible (but without tag since it was deleted)
await expect(taskPageB.getTaskByText(task2Name).first()).toBeVisible();
console.log('[Concurrent] Task2 still visible on Client B');
// The tag should not be on Task2 anymore
const task2AfterReload = taskPageB.getTaskByText(task2Name).first();
const tagOnTask2 = tagPageA.getTagOnTask(task2AfterReload, tagName);
await expect(tagOnTask2).not.toBeVisible({ timeout: 2000 });
console.log('[Concurrent] Tag no longer on Task2');
console.log(
'[Concurrent] ✓ Concurrent delete tag and archive task resolved correctly',
);
// Cleanup
await closeContextsSafely(contextA, contextB);
});
});