mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-17 16:37:43 +00:00
343 lines
14 KiB
TypeScript
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.
|
|
});
|