super-productivity/e2e/tests/sync/webdav-provider-switch.spec.ts
Johannes Millan b7839e9615 fix(e2e): accept overwrite confirmation in provider-switch conflict test
Since #8785, a fresh client with no last-synced baseline gets an
OVERWRITE_WARNING_UNKNOWN confirmation after clicking Keep remote. The
test never accepted it, so the confirm backdrop blocked the later
triggerSync() click and timed out. Accept the confirmation, mirroring
the sibling webdav conflict tests.
2026-07-07 18:27:49 +02:00

812 lines
31 KiB
TypeScript

import { test, expect } from '../../fixtures/webdav.fixture';
import { SyncPage } from '../../pages/sync.page';
import { WorkViewPage } from '../../pages/work-view.page';
import { waitForStatePersistence } from '../../utils/waits';
import {
WEBDAV_CONFIG_TEMPLATE,
setupSyncClient,
createSyncFolder,
waitForSyncComplete,
generateSyncFolderName,
closeContextsSafely,
} from '../../utils/sync-helpers';
/**
* Tests for provider switch scenario where Client B connects to an existing WebDAV
* sync folder that Client A has been using.
*
* This test specifically verifies the fix for the "mutual SYNC_IMPORT discarding" bug:
* - When Client B joins an existing sync, it creates a SYNC_IMPORT
* - The SYNC_IMPORT's vector clock must include Client A's clock entries
* - Otherwise, Client A's ops are CONCURRENT with Client B's SYNC_IMPORT and get discarded
*
* @tags @webdav
*/
test.describe('@webdav WebDAV Provider Switch', () => {
// Run sync tests serially to avoid WebDAV server contention
test.describe.configure({ mode: 'serial' });
// Use a unique folder for each test run
const SYNC_FOLDER_NAME = generateSyncFolderName('e2e-provider-switch');
const WEBDAV_CONFIG = {
...WEBDAV_CONFIG_TEMPLATE,
syncFolderPath: `/${SYNC_FOLDER_NAME}`,
};
test('should sync tasks when Client B connects to existing WebDAV server (provider switch)', async ({
browser,
baseURL,
request,
}) => {
test.slow(); // Sync tests take longer
const url = baseURL || 'http://localhost:4242';
const uniqueId = Date.now();
// Create the sync folder on WebDAV server
await createSyncFolder(request, SYNC_FOLDER_NAME);
console.log(`[Provider Switch Test] Created sync folder: ${SYNC_FOLDER_NAME}`);
// === CLIENT A: Establish sync and create initial tasks ===
const { context: contextA, page: pageA } = await setupSyncClient(browser, url);
const syncPageA = new SyncPage(pageA);
const workViewPageA = new WorkViewPage(pageA);
// Console logging for debugging
pageA.on('console', (msg) => {
if (
msg.text().includes('SyncHydrationService') ||
msg.text().includes('SyncImportFilterService') ||
msg.text().includes('RemoteOpsProcessingService') ||
msg.text().includes('OperationLogSyncService') ||
msg.text().includes('FileBasedSyncAdapter')
) {
console.log(`[Client A] ${msg.type()}: ${msg.text()}`);
}
});
await workViewPageA.waitForTaskList();
// Setup WebDAV sync on Client A
await syncPageA.setupWebdavSync(WEBDAV_CONFIG);
await expect(syncPageA.syncBtn).toBeVisible();
console.log('[Provider Switch Test] Client A: Sync configured');
// Create tasks on Client A
const taskA1 = `Task-${uniqueId}-from-A-1`;
const taskA2 = `Task-${uniqueId}-from-A-2`;
await workViewPageA.addTask(taskA1);
await workViewPageA.addTask(taskA2);
await expect(pageA.locator('task')).toHaveCount(2);
console.log('[Provider Switch Test] Client A: Created 2 tasks');
// Sync Client A (upload)
await waitForStatePersistence(pageA);
await syncPageA.triggerSync();
await waitForSyncComplete(pageA, syncPageA);
console.log('[Provider Switch Test] Client A: Initial sync complete');
// === CLIENT B: Fresh client connecting to existing sync (simulates provider switch) ===
const { context: contextB, page: pageB } = await setupSyncClient(browser, url);
const syncPageB = new SyncPage(pageB);
const workViewPageB = new WorkViewPage(pageB);
// Console logging for debugging
pageB.on('console', (msg) => {
if (
msg.text().includes('SyncHydrationService') ||
msg.text().includes('SyncImportFilterService') ||
msg.text().includes('RemoteOpsProcessingService') ||
msg.text().includes('OperationLogSyncService') ||
msg.text().includes('FileBasedSyncAdapter')
) {
console.log(`[Client B] ${msg.type()}: ${msg.text()}`);
}
});
await workViewPageB.waitForTaskList();
// Setup WebDAV sync on Client B (fresh client joining existing sync)
await syncPageB.setupWebdavSync(WEBDAV_CONFIG);
await expect(syncPageB.syncBtn).toBeVisible();
console.log('[Provider Switch Test] Client B: Sync configured (fresh client)');
// Sync Client B (download from existing sync)
await syncPageB.triggerSync();
await waitForSyncComplete(pageB, syncPageB);
console.log('[Provider Switch Test] Client B: Initial sync complete');
// Verify Client B received Client A's tasks
await expect(pageB.locator('task')).toHaveCount(2);
await expect(pageB.locator('task', { hasText: taskA1 })).toBeVisible();
await expect(pageB.locator('task', { hasText: taskA2 })).toBeVisible();
console.log('[Provider Switch Test] Client B: Received 2 tasks from Client A');
// === CLIENT B: Create new task and sync ===
const taskB = `Task-${uniqueId}-from-B`;
await workViewPageB.addTask(taskB);
await expect(pageB.locator('task')).toHaveCount(3);
console.log('[Provider Switch Test] Client B: Created 1 new task');
await waitForStatePersistence(pageB);
await syncPageB.triggerSync();
await waitForSyncComplete(pageB, syncPageB);
console.log('[Provider Switch Test] Client B: Synced new task');
// === CLIENT A: Sync to receive Client B's task ===
await syncPageA.triggerSync();
await waitForSyncComplete(pageA, syncPageA);
console.log('[Provider Switch Test] Client A: Synced to receive Client B task');
// Verify Client A received Client B's task (THIS IS THE KEY ASSERTION)
// Before the fix, Client B's ops were CONCURRENT with Client A's SYNC_IMPORT
// and would be discarded. With the fix, they should be kept.
await expect(pageA.locator('task')).toHaveCount(3);
await expect(pageA.locator('task', { hasText: taskA1 })).toBeVisible();
await expect(pageA.locator('task', { hasText: taskA2 })).toBeVisible();
await expect(pageA.locator('task', { hasText: taskB })).toBeVisible();
console.log('[Provider Switch Test] Client A: Received task from Client B');
// Final verification - both clients should have identical state
const countA = await pageA.locator('task').count();
const countB = await pageB.locator('task').count();
expect(countA).toBe(countB);
expect(countA).toBe(3);
console.log('[Provider Switch Test] SUCCESS: Both clients have 3 tasks');
// Cleanup
await closeContextsSafely(contextA, contextB);
});
/**
* CRITICAL TEST: Concurrent task creation after late join
*
* This tests the specific bug scenario:
* 1. Client A establishes sync and creates initial tasks
* 2. Client C joins and downloads A's data (receives snapshot)
* 3. BOTH A and C create tasks WITHOUT syncing to each other
* 4. C syncs first (uploads its task)
* 5. A syncs (should receive C's task AND C should receive A's task)
*
* Before the fix: When C joins and receives a snapshot, it created a SYNC_IMPORT.
* When A created a task after C joined (without knowledge of C), A's task had
* a vector clock like { A: 6 } which was CONCURRENT with C's SYNC_IMPORT clock
* { A: 5, C: 1 }. This caused A's task to be filtered by SyncImportFilterService
* on C, resulting in data loss.
*
* The fix: File-based sync bootstrap no longer creates SYNC_IMPORT, avoiding
* the "clean slate" semantics that filter concurrent ops.
*/
test('should sync concurrent tasks created after late join (CRITICAL BUG FIX)', async ({
browser,
baseURL,
request,
}) => {
test.slow();
const url = baseURL || 'http://localhost:4242';
const uniqueId = Date.now();
// Use a unique folder for this test
const FOLDER_NAME = generateSyncFolderName('e2e-concurrent-after-join');
const config = {
...WEBDAV_CONFIG_TEMPLATE,
syncFolderPath: `/${FOLDER_NAME}`,
};
await createSyncFolder(request, FOLDER_NAME);
console.log(`[Concurrent Join Test] Created sync folder: ${FOLDER_NAME}`);
// === STEP 1: Client A establishes sync and creates initial tasks ===
const { context: contextA, page: pageA } = await setupSyncClient(browser, url);
const syncPageA = new SyncPage(pageA);
const workViewPageA = new WorkViewPage(pageA);
// Debug logging
pageA.on('console', (msg) => {
if (
msg.text().includes('Discarded') ||
msg.text().includes('SYNC_IMPORT') ||
msg.text().includes('SyncImportFilterService') ||
msg.text().includes('SyncHydrationService')
) {
console.log(`[Client A] ${msg.type()}: ${msg.text()}`);
}
});
await workViewPageA.waitForTaskList();
await syncPageA.setupWebdavSync(config);
const initialTaskA = `Task-${uniqueId}-initial-A`;
await workViewPageA.addTask(initialTaskA);
await expect(pageA.locator('task')).toHaveCount(1);
console.log('[Concurrent Join Test] Client A: Created initial task');
await waitForStatePersistence(pageA);
await syncPageA.triggerSync();
await waitForSyncComplete(pageA, syncPageA);
console.log('[Concurrent Join Test] Client A: Initial sync complete');
// === STEP 2: Client C joins and downloads A's data ===
const { context: contextC, page: pageC } = await setupSyncClient(browser, url);
const syncPageC = new SyncPage(pageC);
const workViewPageC = new WorkViewPage(pageC);
// Debug logging - CRITICAL: watch for SYNC_IMPORT filtering messages
pageC.on('console', (msg) => {
if (
msg.text().includes('Discarded') ||
msg.text().includes('SYNC_IMPORT') ||
msg.text().includes('SyncImportFilterService') ||
msg.text().includes('SyncHydrationService') ||
msg.text().includes('Skipping SYNC_IMPORT')
) {
console.log(`[Client C] ${msg.type()}: ${msg.text()}`);
}
});
await workViewPageC.waitForTaskList();
await syncPageC.setupWebdavSync(config);
// C syncs to get A's data (this is the "late join" / bootstrap)
await syncPageC.triggerSync();
await waitForSyncComplete(pageC, syncPageC);
// Verify C received A's initial task
await expect(pageC.locator('task')).toHaveCount(1);
await expect(pageC.locator('task', { hasText: initialTaskA })).toBeVisible();
console.log('[Concurrent Join Test] Client C: Received initial task from A');
// === STEP 3: BOTH clients create tasks WITHOUT syncing ===
// This is the critical scenario - concurrent task creation after C's bootstrap
// A creates a task (A doesn't know about C yet)
const taskFromA = `Task-${uniqueId}-concurrent-from-A`;
await workViewPageA.addTask(taskFromA);
await expect(pageA.locator('task')).toHaveCount(2);
console.log('[Concurrent Join Test] Client A: Created concurrent task');
// C creates a task
const taskFromC = `Task-${uniqueId}-concurrent-from-C`;
await workViewPageC.addTask(taskFromC);
await expect(pageC.locator('task')).toHaveCount(2);
console.log('[Concurrent Join Test] Client C: Created concurrent task');
// Wait for both to persist before syncing
await waitForStatePersistence(pageA);
await waitForStatePersistence(pageC);
// === STEP 4: C syncs first ===
await syncPageC.triggerSync();
await waitForSyncComplete(pageC, syncPageC);
console.log('[Concurrent Join Test] Client C: Synced concurrent task');
// === STEP 5: A syncs - should receive C's task ===
await syncPageA.triggerSync();
await waitForSyncComplete(pageA, syncPageA);
console.log('[Concurrent Join Test] Client A: Synced to receive C task');
// A should now have 3 tasks: initial + concurrent from A + concurrent from C
await expect(pageA.locator('task')).toHaveCount(3);
await expect(pageA.locator('task', { hasText: initialTaskA })).toBeVisible();
await expect(pageA.locator('task', { hasText: taskFromA })).toBeVisible();
await expect(pageA.locator('task', { hasText: taskFromC })).toBeVisible();
console.log('[Concurrent Join Test] Client A: Has all 3 tasks');
// === STEP 6: C syncs again to receive A's concurrent task ===
// THIS IS THE KEY TEST - before the fix, A's concurrent task would be filtered
// because it was CONCURRENT with C's SYNC_IMPORT
await syncPageC.triggerSync();
await waitForSyncComplete(pageC, syncPageC);
console.log('[Concurrent Join Test] Client C: Synced to receive A concurrent task');
// C should now have 3 tasks
const taskCountC = await pageC.locator('task').count();
console.log(`[Concurrent Join Test] Client C: Has ${taskCountC} tasks`);
// List tasks for debugging
const taskTitlesC = await pageC.locator('.task-title').allInnerTexts();
console.log(`[Concurrent Join Test] Client C tasks: ${JSON.stringify(taskTitlesC)}`);
// CRITICAL ASSERTIONS - these would fail before the fix
await expect(pageC.locator('task')).toHaveCount(3);
await expect(pageC.locator('task', { hasText: initialTaskA })).toBeVisible();
await expect(pageC.locator('task', { hasText: taskFromC })).toBeVisible();
await expect(pageC.locator('task', { hasText: taskFromA })).toBeVisible({
timeout: 5000,
});
console.log(
'[Concurrent Join Test] SUCCESS: Client C received A concurrent task (bug is fixed!)',
);
// Final verification - both clients have identical state
const countA = await pageA.locator('task').count();
const countC = await pageC.locator('task').count();
expect(countA).toBe(countC);
expect(countA).toBe(3);
console.log('[Concurrent Join Test] PASSED: Both clients have 3 tasks');
await closeContextsSafely(contextA, contextC);
});
/**
* Test: Three-client sync scenario (original bug report)
*
* Simulates the exact scenario from the bug report:
* - Client A and B are syncing
* - Client C joins and gets data from B
* - Tasks created on C and B after C joins
* - After syncing: C's task appears on B, but B's task should also appear on C
*/
test('should sync three clients with late joiner (original bug report scenario)', async ({
browser,
baseURL,
request,
}) => {
test.slow();
const url = baseURL || 'http://localhost:4242';
const uniqueId = Date.now();
const FOLDER_NAME = generateSyncFolderName('e2e-three-client');
const config = {
...WEBDAV_CONFIG_TEMPLATE,
syncFolderPath: `/${FOLDER_NAME}`,
};
await createSyncFolder(request, FOLDER_NAME);
console.log(`[Three Client Test] Created sync folder: ${FOLDER_NAME}`);
// === Setup Client A ===
const { context: contextA, page: pageA } = await setupSyncClient(browser, url);
const syncPageA = new SyncPage(pageA);
const workViewPageA = new WorkViewPage(pageA);
await workViewPageA.waitForTaskList();
await syncPageA.setupWebdavSync(config);
// === Setup Client B ===
const { context: contextB, page: pageB } = await setupSyncClient(browser, url);
const syncPageB = new SyncPage(pageB);
const workViewPageB = new WorkViewPage(pageB);
await workViewPageB.waitForTaskList();
await syncPageB.setupWebdavSync(config);
// A creates initial task and syncs
const taskA1 = `Task-${uniqueId}-A1`;
await workViewPageA.addTask(taskA1);
await waitForStatePersistence(pageA);
await syncPageA.triggerSync();
await waitForSyncComplete(pageA, syncPageA);
console.log('[Three Client Test] A: Created and synced initial task');
// B syncs to get A's task
await syncPageB.triggerSync();
await waitForSyncComplete(pageB, syncPageB);
await expect(pageB.locator('task')).toHaveCount(1, { timeout: 10000 });
console.log('[Three Client Test] B: Got A task');
// B creates task and syncs
const taskB1 = `Task-${uniqueId}-B1`;
await workViewPageB.addTask(taskB1);
await waitForStatePersistence(pageB);
await syncPageB.triggerSync();
await waitForSyncComplete(pageB, syncPageB);
console.log('[Three Client Test] B: Created and synced task');
// === Client C joins (late joiner) ===
const { context: contextC, page: pageC } = await setupSyncClient(browser, url);
const syncPageC = new SyncPage(pageC);
const workViewPageC = new WorkViewPage(pageC);
// Debug logging for C
pageC.on('console', (msg) => {
if (
msg.text().includes('Discarded') ||
msg.text().includes('SYNC_IMPORT') ||
msg.text().includes('Skipping SYNC_IMPORT')
) {
console.log(`[Client C] ${msg.type()}: ${msg.text()}`);
}
});
await workViewPageC.waitForTaskList();
await syncPageC.setupWebdavSync(config);
await syncPageC.triggerSync();
await waitForSyncComplete(pageC, syncPageC);
// C should have 2 tasks (from A and B)
await expect(pageC.locator('task')).toHaveCount(2, { timeout: 30000 });
console.log('[Three Client Test] C: Joined and received 2 tasks');
// === Critical scenario: Both B and C create tasks before syncing ===
const taskB2 = `Task-${uniqueId}-B2-after-C-joins`;
await workViewPageB.addTask(taskB2);
await waitForStatePersistence(pageB);
const taskC1 = `Task-${uniqueId}-C1`;
await workViewPageC.addTask(taskC1);
await waitForStatePersistence(pageC);
console.log('[Three Client Test] B and C: Created concurrent tasks');
// C syncs first
await syncPageC.triggerSync();
await waitForSyncComplete(pageC, syncPageC);
console.log('[Three Client Test] C: Synced');
// B syncs
await syncPageB.triggerSync();
await waitForSyncComplete(pageB, syncPageB);
console.log('[Three Client Test] B: Synced');
// B should have C's task
await expect(pageB.locator('task')).toHaveCount(4);
await expect(pageB.locator('task', { hasText: taskC1 })).toBeVisible();
console.log('[Three Client Test] B: Has C task');
// C syncs again to get B's concurrent task
await syncPageC.triggerSync();
await waitForSyncComplete(pageC, syncPageC);
// CRITICAL: C should have B's task (this was the bug!)
await expect(pageC.locator('task')).toHaveCount(4, { timeout: 30000 });
await expect(pageC.locator('task', { hasText: taskB2 })).toBeVisible({
timeout: 5000,
});
console.log('[Three Client Test] C: Has B concurrent task (BUG FIXED!)');
// A syncs to verify full sync
await syncPageA.triggerSync();
await waitForSyncComplete(pageA, syncPageA);
// All clients should have 4 tasks
await expect(pageA.locator('task')).toHaveCount(4, { timeout: 30000 });
await expect(pageB.locator('task')).toHaveCount(4, { timeout: 30000 });
await expect(pageC.locator('task')).toHaveCount(4, { timeout: 30000 });
console.log('[Three Client Test] PASSED: All 3 clients have 4 tasks');
await closeContextsSafely(contextA, contextB, contextC);
});
test('should handle bidirectional sync after provider switch', async ({
browser,
baseURL,
request,
}) => {
test.slow();
const url = baseURL || 'http://localhost:4242';
const uniqueId = Date.now();
// Use a different folder for this test
const FOLDER_NAME = generateSyncFolderName('e2e-bidirectional');
const config = {
...WEBDAV_CONFIG_TEMPLATE,
syncFolderPath: `/${FOLDER_NAME}`,
};
await createSyncFolder(request, FOLDER_NAME);
// === Setup both clients ===
const { context: contextA, page: pageA } = await setupSyncClient(browser, url);
const syncPageA = new SyncPage(pageA);
const workViewPageA = new WorkViewPage(pageA);
const { context: contextB, page: pageB } = await setupSyncClient(browser, url);
const syncPageB = new SyncPage(pageB);
const workViewPageB = new WorkViewPage(pageB);
await workViewPageA.waitForTaskList();
await workViewPageB.waitForTaskList();
// Client A: Create task and sync first
await syncPageA.setupWebdavSync(config);
const taskA = `Task-${uniqueId}-A`;
await workViewPageA.addTask(taskA);
await waitForStatePersistence(pageA);
await syncPageA.triggerSync();
await waitForSyncComplete(pageA, syncPageA);
// Client B: Join sync and verify task
await syncPageB.setupWebdavSync(config);
await syncPageB.triggerSync();
await waitForSyncComplete(pageB, syncPageB);
await expect(pageB.locator('task')).toHaveCount(1);
// === Bidirectional sync rounds ===
// Round 1: B creates task
const taskB1 = `Task-${uniqueId}-B1`;
await workViewPageB.addTask(taskB1);
await waitForStatePersistence(pageB);
await syncPageB.triggerSync();
await waitForSyncComplete(pageB, syncPageB);
// A syncs
await syncPageA.triggerSync();
await waitForSyncComplete(pageA, syncPageA);
await expect(pageA.locator('task')).toHaveCount(2);
// Round 2: A creates task
const taskA2 = `Task-${uniqueId}-A2`;
await workViewPageA.addTask(taskA2);
await waitForStatePersistence(pageA);
await syncPageA.triggerSync();
await waitForSyncComplete(pageA, syncPageA);
// B syncs
await syncPageB.triggerSync();
await waitForSyncComplete(pageB, syncPageB);
await expect(pageB.locator('task')).toHaveCount(3);
// Round 3: B creates another task
const taskB2 = `Task-${uniqueId}-B2`;
await workViewPageB.addTask(taskB2);
await waitForStatePersistence(pageB);
await syncPageB.triggerSync();
await waitForSyncComplete(pageB, syncPageB);
// A syncs
await syncPageA.triggerSync();
await waitForSyncComplete(pageA, syncPageA);
// Final check: both should have 4 tasks
await expect(pageA.locator('task')).toHaveCount(4);
await expect(pageB.locator('task')).toHaveCount(4);
console.log(
'[Bidirectional Test] SUCCESS: Both clients have 4 tasks after multiple sync rounds',
);
await closeContextsSafely(contextA, contextB);
});
/**
* Test: Client with multiple local ops joining sync
*
* Verifies that confirmation shows correct count and accepting replaces all local data.
*/
test('should replace multiple local ops when user accepts confirmation', async ({
browser,
baseURL,
request,
}) => {
test.slow();
const url = baseURL || 'http://localhost:4242';
const uniqueId = Date.now();
const FOLDER_NAME = generateSyncFolderName('e2e-multi-local-ops');
const config = {
...WEBDAV_CONFIG_TEMPLATE,
syncFolderPath: `/${FOLDER_NAME}`,
};
await createSyncFolder(request, FOLDER_NAME);
// === Client A: Create tasks and sync ===
const { context: contextA, page: pageA } = await setupSyncClient(browser, url);
const syncPageA = new SyncPage(pageA);
const workViewPageA = new WorkViewPage(pageA);
await workViewPageA.waitForTaskList();
await syncPageA.setupWebdavSync(config);
// A creates multiple tasks
const taskA1 = `Task-${uniqueId}-A1`;
const taskA2 = `Task-${uniqueId}-A2`;
const taskA3 = `Task-${uniqueId}-A3`;
await workViewPageA.addTask(taskA1);
await workViewPageA.addTask(taskA2);
await workViewPageA.addTask(taskA3);
await waitForStatePersistence(pageA);
await syncPageA.triggerSync();
await waitForSyncComplete(pageA, syncPageA);
console.log('[Multi Local Ops Test] Client A: Created and synced 3 tasks');
// === Client B: Create multiple local tasks ===
const { context: contextB, page: pageB } = await setupSyncClient(browser, url);
const syncPageB = new SyncPage(pageB);
const workViewPageB = new WorkViewPage(pageB);
await workViewPageB.waitForTaskList();
// B creates multiple tasks before sync
const taskB1 = `Task-${uniqueId}-LOCAL-B1`;
const taskB2 = `Task-${uniqueId}-LOCAL-B2`;
await workViewPageB.addTask(taskB1);
await workViewPageB.addTask(taskB2);
await expect(pageB.locator('task')).toHaveCount(2);
await waitForStatePersistence(pageB);
console.log('[Multi Local Ops Test] Client B: Created 2 local tasks');
// Configure sync - this triggers auto-sync which may show conflict dialog
await syncPageB.setupWebdavSync(config);
// Handle the sync conflict dialog (Material dialog, not browser confirm)
// We want to keep remote data, so click "Keep remote"
const conflictDialog = pageB.locator('mat-dialog-container', {
hasText: 'Conflicting Data',
});
const conflictVisible = await conflictDialog
.waitFor({ state: 'visible', timeout: 10000 })
.then(() => true)
.catch(() => false);
if (conflictVisible) {
const keepRemoteBtn = conflictDialog.locator('button', { hasText: /Keep remote/i });
await keepRemoteBtn.click();
// A fresh client with no last-synced baseline now gets an overwrite
// confirmation (getChangeCount → null ⇒ shouldConfirmOverwrite, since #8785).
// Accept it — this is the "when user accepts confirmation" the test name
// describes. The confirmation is optional, so tolerate its absence.
const confirmDialog = pageB.locator('dialog-confirm');
try {
await confirmDialog.waitFor({ state: 'visible', timeout: 3000 });
await confirmDialog.locator('[e2e="confirmBtn"]').click();
} catch {
// No confirmation shown — fine.
}
// Wait for dialog to close
await conflictDialog.waitFor({ state: 'hidden', timeout: 5000 }).catch(() => {});
}
// Trigger sync if not already syncing and wait for completion
await syncPageB.triggerSync();
await waitForSyncComplete(pageB, syncPageB);
// B should now have A's 3 tasks (local tasks replaced)
await expect(pageB.locator('task')).toHaveCount(3);
await expect(pageB.locator('task', { hasText: taskA1 })).toBeVisible();
await expect(pageB.locator('task', { hasText: taskA2 })).toBeVisible();
await expect(pageB.locator('task', { hasText: taskA3 })).toBeVisible();
// Local tasks should be gone
await expect(pageB.locator('task', { hasText: taskB1 })).not.toBeVisible();
await expect(pageB.locator('task', { hasText: taskB2 })).not.toBeVisible();
console.log('[Multi Local Ops Test] PASSED: Local tasks replaced with remote');
await closeContextsSafely(contextA, contextB);
});
/**
* Test: Fresh client should not duplicate tasks on repeated syncs (b2b63da9)
*
* Validates the fix from commit b2b63da9 which prevents task duplication when a
* fresh client syncs with a file-based provider. Without the fix, two duplication
* paths existed:
*
* 1. Download path: On second sync cycle, recentOps bypass the appliedOpIds filter
* (not yet in IndexedDB) and get re-applied → duplicates
* 2. Piggyback path: The adapter's isForceFromZero guard skipped marking ops as
* processed, so they were returned as piggybacked during upload → duplicates
*/
test('should not duplicate tasks when fresh client syncs multiple times', async ({
browser,
baseURL,
request,
}) => {
test.slow();
const url = baseURL || 'http://localhost:4242';
const uniqueId = Date.now();
const FOLDER_NAME = generateSyncFolderName('e2e-fresh-client-dup');
const config = {
...WEBDAV_CONFIG_TEMPLATE,
syncFolderPath: `/${FOLDER_NAME}`,
};
await createSyncFolder(request, FOLDER_NAME);
console.log(`[Fresh Client Dup Test] Created sync folder: ${FOLDER_NAME}`);
// === STEP 1: Client A creates tasks and syncs ===
const { context: contextA, page: pageA } = await setupSyncClient(browser, url);
const syncPageA = new SyncPage(pageA);
const workViewPageA = new WorkViewPage(pageA);
pageA.on('console', (msg) => {
if (
msg.text().includes('OperationLogSyncService') ||
msg.text().includes('FileBasedSyncAdapter')
) {
console.log(`[Client A] ${msg.type()}: ${msg.text()}`);
}
});
await workViewPageA.waitForTaskList();
await syncPageA.setupWebdavSync(config);
const task1 = `Task-${uniqueId}-1`;
const task2 = `Task-${uniqueId}-2`;
const task3 = `Task-${uniqueId}-3`;
await workViewPageA.addTask(task1);
await workViewPageA.addTask(task2);
await workViewPageA.addTask(task3);
await expect(pageA.locator('task')).toHaveCount(3);
console.log('[Fresh Client Dup Test] Client A: Created 3 tasks');
await waitForStatePersistence(pageA);
await syncPageA.triggerSync();
await waitForSyncComplete(pageA, syncPageA);
console.log('[Fresh Client Dup Test] Client A: Initial sync complete');
// === STEP 2: Client B (fresh) joins and syncs ===
const { context: contextB, page: pageB } = await setupSyncClient(browser, url);
const syncPageB = new SyncPage(pageB);
const workViewPageB = new WorkViewPage(pageB);
pageB.on('console', (msg) => {
if (
msg.text().includes('OperationLogSyncService') ||
msg.text().includes('FileBasedSyncAdapter')
) {
console.log(`[Client B] ${msg.type()}: ${msg.text()}`);
}
});
await workViewPageB.waitForTaskList();
await syncPageB.setupWebdavSync(config);
// First sync: Client B receives snapshot + recentOps
await syncPageB.triggerSync();
await waitForSyncComplete(pageB, syncPageB);
// Assert Client B has exactly 3 tasks (no more, no less)
await expect(pageB.locator('task')).toHaveCount(3);
await expect(pageB.locator('task', { hasText: task1 })).toBeVisible();
await expect(pageB.locator('task', { hasText: task2 })).toBeVisible();
await expect(pageB.locator('task', { hasText: task3 })).toBeVisible();
console.log('[Fresh Client Dup Test] Client B: Received 3 tasks on first sync');
// === STEP 3: Client B syncs again (no new remote changes) ===
// Without the fix, recentOps would be re-applied → duplicates (download path)
await syncPageB.triggerSync();
await waitForSyncComplete(pageB, syncPageB);
await expect(pageB.locator('task')).toHaveCount(3);
console.log(
'[Fresh Client Dup Test] Client B: Still 3 tasks after second sync (download path OK)',
);
// === STEP 4: Client B creates a new task and syncs (upload) ===
// Without the fix, piggyback would return the 3 snapshot ops again → duplicates
const taskB = `Task-${uniqueId}-from-B`;
await workViewPageB.addTask(taskB);
await expect(pageB.locator('task')).toHaveCount(4);
await waitForStatePersistence(pageB);
await syncPageB.triggerSync();
await waitForSyncComplete(pageB, syncPageB);
// CRITICAL: Client B should have exactly 4 tasks, not 7 (piggyback path)
await expect(pageB.locator('task')).toHaveCount(4);
console.log(
'[Fresh Client Dup Test] Client B: 4 tasks after upload sync (piggyback path OK)',
);
// === STEP 5: Client A syncs to receive Client B's new task ===
await syncPageA.triggerSync();
await waitForSyncComplete(pageA, syncPageA);
// Client A should have exactly 4 tasks (no phantom duplicates propagated)
await expect(pageA.locator('task')).toHaveCount(4);
await expect(pageA.locator('task', { hasText: task1 })).toBeVisible();
await expect(pageA.locator('task', { hasText: task2 })).toBeVisible();
await expect(pageA.locator('task', { hasText: task3 })).toBeVisible();
await expect(pageA.locator('task', { hasText: taskB })).toBeVisible();
console.log(
'[Fresh Client Dup Test] Client A: 4 tasks (received B task, no duplicates)',
);
// Final consistency check
const countA = await pageA.locator('task').count();
const countB = await pageB.locator('task').count();
expect(countA).toBe(countB);
expect(countA).toBe(4);
console.log('[Fresh Client Dup Test] PASSED: No task duplication on repeated syncs');
await closeContextsSafely(contextA, contextB);
});
});