super-productivity/e2e/tests/sync/supersync-vector-clock-pruning.spec.ts

343 lines
14 KiB
TypeScript

import { test, expect } from '../../fixtures/supersync.fixture';
import {
createTestUser,
getSuperSyncConfig,
createSimulatedClient,
closeClient,
waitForTask,
type SimulatedE2EClient,
} from '../../utils/supersync-helpers';
import { ImportPage } from '../../pages/import.page';
import { expectTaskOnAllClients } from '../../utils/supersync-assertions';
/**
* SuperSync Vector Clock Pruning E2E Tests
*
* These tests verify the fix for the vector clock pruning bug that caused
* new operations to be incorrectly filtered after a SYNC_IMPORT.
*
* BUG SCENARIO:
* 1. Client A creates SYNC_IMPORT with clock {A: 1}
* 2. Client B receives it, merges clocks → {A: 1, B: x, ...other clients...}
* 3. When B has 21+ clients, pruning triggers (MAX_VECTOR_CLOCK_SIZE = 20)
* 4. A's entry (counter=1, lowest) gets PRUNED
* 5. New tasks from B have clock {B: y} - MISSING A's entry!
* 6. Comparison: {A: 0} vs {A: 1} → CONCURRENT
* 7. Tasks incorrectly filtered as "invalidated by SYNC_IMPORT"
*
* FIX:
* - After applying SYNC_IMPORT, store the import client ID as "protected"
* - limitVectorClockSize() preserves protected client IDs even with low counters
* - New ops include the import client entry → comparison yields GREATER_THAN
*
* NOTE: E2E tests can't easily simulate 91+ clients to trigger pruning.
* These tests verify the end-to-end flow that would fail without the fix.
* Unit tests in vector-clock.spec.ts verify the pruning-specific behavior.
*
* Prerequisites:
* - super-sync-server running on localhost:1901 with TEST_MODE=true
* - Frontend running on localhost:4242
*
* Run with: npm run e2e:supersync:file e2e/tests/sync/supersync-vector-clock-pruning.spec.ts
*/
test.describe.configure({ mode: 'serial' });
test.describe('@supersync @pruning Vector Clock Pruning Fix', () => {
/**
* Scenario: Tasks created after receiving SYNC_IMPORT sync correctly
*
* This test verifies the core fix: after receiving a SYNC_IMPORT, the
* receiving client can create new tasks that sync back to the import client.
*
* Without the fix, these tasks would be filtered as "concurrent with import"
* because their vector clocks wouldn't include the import client's entry
* (it would be pruned due to having the lowest counter value).
*
* Setup: Client A and B with shared SuperSync account
*
* Actions:
* 1. Client A imports backup (creates SYNC_IMPORT)
* 2. Client A syncs (uploads SYNC_IMPORT)
* 3. Client B syncs (receives SYNC_IMPORT, merges clocks)
* 4. Client B creates new tasks
* 5. Client B syncs (uploads tasks)
* 6. Client A syncs (downloads tasks from B)
*
* Verify:
* - Both clients have the tasks created by B after the import
* - No sync errors or data loss
*/
test('Tasks created after receiving SYNC_IMPORT sync correctly', async ({
browser,
baseURL,
testRunId,
}) => {
const uniqueId = `${testRunId}-${Date.now()}-${Math.random().toString(36).substring(2, 8)}`;
let clientA: SimulatedE2EClient | null = null;
let clientB: SimulatedE2EClient | null = null;
try {
const user = await createTestUser(testRunId);
const syncConfig = getSuperSyncConfig(user);
// ============ PHASE 1: Setup Both Clients ============
console.log('[VC Pruning] Phase 1: Setting up both clients');
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
await clientA.sync.setupSuperSync(syncConfig);
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
await clientB.sync.setupSuperSync(syncConfig);
// Initial sync to establish connection
await clientA.sync.syncAndWait();
await clientB.sync.syncAndWait();
console.log('[VC Pruning] Both clients synced initially');
// ============ PHASE 2: Client A Imports Backup ============
console.log('[VC Pruning] Phase 2: Client A importing backup');
const importPage = new ImportPage(clientA.page);
await importPage.navigateToImportPage();
// Import backup (creates SYNC_IMPORT with A's clock)
const backupPath = ImportPage.getFixturePath('test-backup.json');
await importPage.importBackupFile(backupPath);
console.log('[VC Pruning] Client A imported backup');
// Reload page after import - use goto instead of reload for reliability
await clientA.page.goto(clientA.page.url(), {
waitUntil: 'domcontentloaded',
timeout: 30000,
});
await clientA.page.waitForLoadState('networkidle');
// Re-enable sync after import - use 'local' to preserve imported data
await clientA.sync.setupSuperSync({ ...syncConfig, syncImportChoice: 'local' });
// Verify imported data is visible
await waitForTask(clientA.page, 'E2E Import Test - Active Task With Subtask');
console.log('[VC Pruning] Client A showing imported data');
// ============ PHASE 3: Sync SYNC_IMPORT to Server ============
console.log('[VC Pruning] Phase 3: Syncing SYNC_IMPORT to server');
await clientA.sync.syncAndWait();
console.log('[VC Pruning] Client A synced (SYNC_IMPORT uploaded)');
// ============ PHASE 4: Client B Receives SYNC_IMPORT ============
console.log('[VC Pruning] Phase 4: Client B receiving SYNC_IMPORT');
await clientB.sync.syncAndWait();
console.log('[VC Pruning] Client B synced (received SYNC_IMPORT)');
// Navigate to work view and verify import applied
await clientB.page.goto('/#/work-view');
await clientB.page.waitForLoadState('networkidle');
await waitForTask(clientB.page, 'E2E Import Test - Active Task With Subtask');
console.log('[VC Pruning] Client B showing imported data');
// ============ PHASE 5: Client B Creates New Tasks ============
console.log('[VC Pruning] Phase 5: Client B creating new tasks after import');
// Create multiple tasks to verify the fix works for multiple ops
const task1 = `PostImport-Task1-${uniqueId}`;
const task2 = `PostImport-Task2-${uniqueId}`;
const task3 = `PostImport-Task3-${uniqueId}`;
await clientB.workView.addTask(task1);
await waitForTask(clientB.page, task1);
console.log(`[VC Pruning] Client B created: ${task1}`);
await clientB.workView.addTask(task2);
await waitForTask(clientB.page, task2);
console.log(`[VC Pruning] Client B created: ${task2}`);
await clientB.workView.addTask(task3);
await waitForTask(clientB.page, task3);
console.log(`[VC Pruning] Client B created: ${task3}`);
// ============ PHASE 6: Client B Syncs New Tasks ============
console.log('[VC Pruning] Phase 6: Client B syncing new tasks');
// This is the CRITICAL step - with the bug, these tasks would be filtered
// by SyncImportFilterService as "concurrent with import" and not uploaded
await clientB.sync.syncAndWait();
console.log('[VC Pruning] Client B synced (new tasks uploaded)');
// Check for sync errors (would appear if tasks were rejected)
const errorSnack = clientB.page.locator('simple-snack-bar.error');
await expect(errorSnack).not.toBeVisible({ timeout: 5000 });
console.log('[VC Pruning] No sync errors on Client B');
// ============ PHASE 7: Client A Downloads New Tasks ============
console.log('[VC Pruning] Phase 7: Client A downloading new tasks from B');
await clientA.sync.syncAndWait();
console.log('[VC Pruning] Client A synced (downloaded B tasks)');
// Navigate to work view
await clientA.page.goto('/#/work-view');
await clientA.page.waitForLoadState('networkidle');
// ============ PHASE 8: Verify Both Clients Have All Tasks ============
console.log('[VC Pruning] Phase 8: Verifying both clients have all tasks');
// Verify tasks on Client A (originally created by B)
await waitForTask(clientA.page, task1);
await waitForTask(clientA.page, task2);
await waitForTask(clientA.page, task3);
console.log('[VC Pruning] Client A has all tasks created by B');
// Verify tasks on Client B (confirm they still exist)
await expect(clientB.page.locator(`task:has-text("${task1}")`)).toBeVisible();
await expect(clientB.page.locator(`task:has-text("${task2}")`)).toBeVisible();
await expect(clientB.page.locator(`task:has-text("${task3}")`)).toBeVisible();
console.log('[VC Pruning] Client B still has all tasks');
// Also verify imported task is on both
await expectTaskOnAllClients(
[clientA, clientB],
'E2E Import Test - Active Task With Subtask',
);
console.log('[VC Pruning] Vector clock pruning fix E2E test PASSED!');
} finally {
if (clientA) await closeClient(clientA);
if (clientB) await closeClient(clientB);
}
});
/**
* Scenario: Multiple sync cycles after SYNC_IMPORT maintain consistency
*
* This test verifies that the fix is stable across multiple sync cycles.
* The import client's entry should remain protected across all operations.
*
* Actions:
* 1. Client A imports backup, syncs
* 2. Client B receives import, creates tasks, syncs
* 3. Client A creates tasks, syncs
* 4. Client B receives A's tasks, creates more tasks, syncs
* 5. Verify all tasks are on both clients
*/
test('Multiple sync cycles after SYNC_IMPORT maintain task consistency', async ({
browser,
baseURL,
testRunId,
}) => {
const uniqueId = `${testRunId}-${Date.now()}-${Math.random().toString(36).substring(2, 8)}`;
let clientA: SimulatedE2EClient | null = null;
let clientB: SimulatedE2EClient | null = null;
try {
const user = await createTestUser(testRunId);
const syncConfig = getSuperSyncConfig(user);
// ============ Setup ============
console.log('[Multi-Cycle] Setting up clients');
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
await clientA.sync.setupSuperSync(syncConfig);
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
await clientB.sync.setupSuperSync(syncConfig);
await clientA.sync.syncAndWait();
await clientB.sync.syncAndWait();
// ============ Import Backup ============
console.log('[Multi-Cycle] Client A importing backup');
const importPage = new ImportPage(clientA.page);
await importPage.navigateToImportPage();
const backupPath = ImportPage.getFixturePath('test-backup.json');
await importPage.importBackupFile(backupPath);
await clientA.page.goto(clientA.page.url(), {
waitUntil: 'domcontentloaded',
timeout: 30000,
});
await clientA.page.waitForLoadState('networkidle');
await clientA.sync.setupSuperSync({ ...syncConfig, syncImportChoice: 'local' });
await waitForTask(clientA.page, 'E2E Import Test - Active Task With Subtask');
await clientA.sync.syncAndWait();
console.log('[Multi-Cycle] SYNC_IMPORT uploaded');
// ============ Cycle 1: B receives import, creates task ============
console.log('[Multi-Cycle] Cycle 1: B receives import and creates task');
await clientB.sync.syncAndWait();
await clientB.page.goto('/#/work-view');
await clientB.page.waitForLoadState('networkidle');
await waitForTask(clientB.page, 'E2E Import Test - Active Task With Subtask');
const taskB1 = `TaskB1-${uniqueId}`;
await clientB.workView.addTask(taskB1);
await clientB.sync.syncAndWait();
console.log(`[Multi-Cycle] B created and synced: ${taskB1}`);
// ============ Cycle 2: A receives B's task, creates own task ============
console.log('[Multi-Cycle] Cycle 2: A receives B task and creates own');
await clientA.sync.syncAndWait();
await clientA.page.goto('/#/work-view');
await clientA.page.waitForLoadState('networkidle');
await waitForTask(clientA.page, taskB1);
const taskA1 = `TaskA1-${uniqueId}`;
await clientA.workView.addTask(taskA1);
await clientA.sync.syncAndWait();
console.log(`[Multi-Cycle] A created and synced: ${taskA1}`);
// ============ Cycle 3: B receives A's task, creates another ============
console.log('[Multi-Cycle] Cycle 3: B receives A task and creates another');
await clientB.sync.syncAndWait();
await waitForTask(clientB.page, taskA1);
const taskB2 = `TaskB2-${uniqueId}`;
await clientB.workView.addTask(taskB2);
await clientB.sync.syncAndWait();
console.log(`[Multi-Cycle] B created and synced: ${taskB2}`);
// ============ Final sync and verification ============
console.log('[Multi-Cycle] Final sync and verification');
await clientA.sync.syncAndWait();
await clientA.page.goto('/#/work-view');
await clientA.page.waitForLoadState('networkidle');
await clientB.page.goto('/#/work-view');
await clientB.page.waitForLoadState('networkidle');
// All created tasks should be on both clients
await expectTaskOnAllClients([clientA, clientB], taskB1);
await expectTaskOnAllClients([clientA, clientB], taskA1);
await expectTaskOnAllClients([clientA, clientB], taskB2);
// Imported task should still be there
await expectTaskOnAllClients(
[clientA, clientB],
'E2E Import Test - Active Task With Subtask',
);
// Check for errors
const errorSnackA = clientA.page.locator('simple-snack-bar.error');
const errorSnackB = clientB.page.locator('simple-snack-bar.error');
await expect(errorSnackA).not.toBeVisible({ timeout: 5000 });
await expect(errorSnackB).not.toBeVisible({ timeout: 5000 });
console.log('[Multi-Cycle] Multi-cycle test PASSED!');
} finally {
if (clientA) await closeClient(clientA);
if (clientB) await closeClient(clientB);
}
});
// NOTE: The "Reload after SYNC_IMPORT" scenario is already covered by
// supersync-superseded-clock-regression.spec.ts tests. That test verifies that
// reloading after receiving SYNC_IMPORT doesn't cause superseded clock issues.
// Combined with the pruning fix verified above, the full scenario is covered.
});