mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-18 17:05:48 +00:00
- Replace page.reload() with close-and-reopen pattern after backup imports to prevent hangs with active sync connections - Add proper sync-import-conflict dialog handling via Promise.race - Fix browser context leak in provider-switch test - Replace baseURL! non-null assertions with fallback pattern - Replace fixed timeouts with element-based waits in cascade-delete - Extract duplicate dialog-dismissal loop into dismissBlockingDialogs helper in waits.ts - Add missing WorkViewPage/SuperSyncPage imports in same-client test - Replace any type with proper VectorClockEntry interface
332 lines
14 KiB
TypeScript
332 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 { SuperSyncPage } from '../../pages/supersync.page';
|
|
import { WorkViewPage } from '../../pages/work-view.page';
|
|
import { expectTaskOnAllClients } from '../../utils/supersync-assertions';
|
|
import { waitForAppReady } from '../../utils/waits';
|
|
|
|
/**
|
|
* SuperSync: Import Client's Own Post-Import Operations
|
|
*
|
|
* These tests verify that operations created by the SAME client that performed
|
|
* a SYNC_IMPORT are correctly synced to other clients.
|
|
*
|
|
* BUG SCENARIO (vector clock pruning asymmetry):
|
|
* 1. Client A creates SYNC_IMPORT with a 20-entry (MAX) vector clock
|
|
* 2. Client A continues creating ops; over time, new clients join, pushing
|
|
* A's clock past MAX → server prunes entries
|
|
* 3. A's ops have different pruned entries than A's frozen import clock
|
|
* 4. Receiving client sees CONCURRENT (both have MAX entries, different keys)
|
|
* 5. CONCURRENT comparison → ops incorrectly filtered
|
|
*
|
|
* FIX: Same-client counter comparison is definitive. If op.clientId matches
|
|
* import.clientId and op's counter > import's counter, the op is post-import.
|
|
*
|
|
* NOTE: This E2E test can't trigger actual pruning (needs 21+ unique client IDs with MAX=20).
|
|
* It verifies the end-to-end flow: import client's own post-import ops
|
|
* must sync correctly to other clients. The unit tests verify the pruning-
|
|
* specific detection logic.
|
|
*
|
|
* Run with: npm run e2e:supersync:file e2e/tests/sync/supersync-import-same-client-ops.spec.ts
|
|
*/
|
|
|
|
test.describe.configure({ mode: 'serial' });
|
|
|
|
test.describe('@supersync @pruning Import client post-import ops sync correctly', () => {
|
|
/**
|
|
* Scenario: Import client creates tasks after import — tasks sync to other client
|
|
*
|
|
* This is the core regression test. The import client (A) creates a SYNC_IMPORT,
|
|
* then continues creating tasks. Those tasks must be received by client B.
|
|
*
|
|
* Without the same-client pruning fix, when vector clocks are at MAX size and
|
|
* have diverged due to asymmetric pruning, these ops would be incorrectly
|
|
* classified as CONCURRENT and filtered out on client B.
|
|
*/
|
|
test('Import client own post-import tasks sync to other client', async ({
|
|
browser,
|
|
baseURL,
|
|
testRunId,
|
|
}) => {
|
|
const uniqueId = `${testRunId}-${Date.now()}-${Math.random().toString(36).substring(2, 8)}`;
|
|
const appUrl = baseURL || 'http://localhost:4242';
|
|
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('[Same-Client Import] Phase 1: Setting up both clients');
|
|
|
|
clientA = await createSimulatedClient(browser, appUrl, 'A', testRunId);
|
|
await clientA.sync.setupSuperSync(syncConfig);
|
|
|
|
clientB = await createSimulatedClient(browser, appUrl, 'B', testRunId);
|
|
await clientB.sync.setupSuperSync(syncConfig);
|
|
|
|
// Initial sync to establish vector clocks
|
|
await clientA.sync.syncAndWait();
|
|
await clientB.sync.syncAndWait();
|
|
console.log('[Same-Client Import] Both clients synced initially');
|
|
|
|
// ============ PHASE 2: Client A Imports Backup ============
|
|
console.log('[Same-Client Import] Phase 2: Client A importing backup');
|
|
|
|
const importPage = new ImportPage(clientA.page);
|
|
await importPage.navigateToImportPage();
|
|
|
|
const backupPath = ImportPage.getFixturePath('test-backup.json');
|
|
await importPage.importBackupFile(backupPath);
|
|
console.log('[Same-Client Import] Client A imported backup');
|
|
|
|
// Close and re-open page to pick up imported data with fresh Angular services.
|
|
// Using page.reload() can hang when active sync connections prevent navigation.
|
|
await clientA.page.close();
|
|
clientA.page = await clientA.context.newPage();
|
|
await clientA.page.goto('/');
|
|
clientA.workView = new WorkViewPage(clientA.page, `A-${testRunId}`);
|
|
clientA.sync = new SuperSyncPage(clientA.page);
|
|
await waitForAppReady(clientA.page, { ensureRoute: false });
|
|
|
|
// Configure sync WITHOUT waiting for initial sync
|
|
// (initial sync will show sync-import-conflict dialog since we have a local BackupImport)
|
|
await clientA.sync.setupSuperSync({ ...syncConfig, waitForInitialSync: false });
|
|
|
|
// Wait for either sync import conflict dialog OR sync completion
|
|
// The dialog appears because download-first order finds server ops that conflict with our local import
|
|
const syncImportDialog = clientA.sync.syncImportConflictDialog;
|
|
const syncResult = await Promise.race([
|
|
syncImportDialog
|
|
.waitFor({ state: 'visible', timeout: 30000 })
|
|
.then(() => 'dialog' as const),
|
|
clientA.sync.syncCheckIcon
|
|
.waitFor({ state: 'visible', timeout: 30000 })
|
|
.then(() => 'complete' as const),
|
|
]);
|
|
|
|
if (syncResult === 'dialog') {
|
|
// Choose "Use My Data" to preserve the import (not "Use Server Data" which discards it)
|
|
await clientA.sync.syncImportUseLocalBtn.click();
|
|
await syncImportDialog.waitFor({ state: 'hidden', timeout: 5000 });
|
|
// Wait for sync to complete after dialog handling
|
|
await clientA.sync.syncCheckIcon.waitFor({ state: 'visible', timeout: 30000 });
|
|
}
|
|
|
|
await waitForTask(clientA.page, 'E2E Import Test - Active Task With Subtask');
|
|
console.log('[Same-Client Import] Client A showing imported data');
|
|
|
|
// ============ PHASE 3: Client A syncs SYNC_IMPORT ============
|
|
console.log('[Same-Client Import] Phase 3: Client A syncing SYNC_IMPORT');
|
|
|
|
await clientA.sync.syncAndWait();
|
|
console.log('[Same-Client Import] Client A synced (SYNC_IMPORT uploaded)');
|
|
|
|
// ============ PHASE 4: Client A creates tasks AFTER import ============
|
|
console.log(
|
|
'[Same-Client Import] Phase 4: Import client (A) creating post-import tasks',
|
|
);
|
|
|
|
const taskA1 = `ImportClient-PostImport-Task1-${uniqueId}`;
|
|
const taskA2 = `ImportClient-PostImport-Task2-${uniqueId}`;
|
|
|
|
await clientA.workView.addTask(taskA1);
|
|
await waitForTask(clientA.page, taskA1);
|
|
console.log(`[Same-Client Import] Client A created: ${taskA1}`);
|
|
|
|
await clientA.workView.addTask(taskA2);
|
|
await waitForTask(clientA.page, taskA2);
|
|
console.log(`[Same-Client Import] Client A created: ${taskA2}`);
|
|
|
|
// ============ PHASE 5: Client A syncs post-import tasks ============
|
|
console.log('[Same-Client Import] Phase 5: Client A syncing post-import tasks');
|
|
|
|
await clientA.sync.syncAndWait();
|
|
console.log('[Same-Client Import] Client A synced (post-import tasks uploaded)');
|
|
|
|
// ============ PHASE 6: Client B syncs and receives everything ============
|
|
console.log(
|
|
'[Same-Client Import] Phase 6: Client B syncing (receives import + post-import tasks)',
|
|
);
|
|
|
|
await clientB.sync.syncAndWait();
|
|
console.log('[Same-Client Import] Client B synced');
|
|
|
|
// Navigate to work view
|
|
await clientB.page.goto('/#/work-view');
|
|
await clientB.page.waitForLoadState('networkidle');
|
|
|
|
// ============ PHASE 7: Verify both clients have all tasks ============
|
|
console.log('[Same-Client Import] Phase 7: Verifying both clients have all tasks');
|
|
|
|
// Client B should have the imported task
|
|
await waitForTask(clientB.page, 'E2E Import Test - Active Task With Subtask');
|
|
console.log('[Same-Client Import] Client B has imported task');
|
|
|
|
// CRITICAL: Client B should have A's post-import tasks
|
|
// This is where the bug would manifest — these tasks would be missing
|
|
// on B if the same-client pruning fix didn't work
|
|
await waitForTask(clientB.page, taskA1);
|
|
await waitForTask(clientB.page, taskA2);
|
|
console.log('[Same-Client Import] Client B has import client post-import tasks');
|
|
|
|
// Verify on both clients
|
|
await clientA.page.goto('/#/work-view');
|
|
await clientA.page.waitForLoadState('networkidle');
|
|
|
|
await expectTaskOnAllClients([clientA, clientB], taskA1);
|
|
await expectTaskOnAllClients([clientA, clientB], taskA2);
|
|
await expectTaskOnAllClients(
|
|
[clientA, clientB],
|
|
'E2E Import Test - Active Task With Subtask',
|
|
);
|
|
|
|
// No sync 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('[Same-Client Import] Import client post-import ops test PASSED!');
|
|
} finally {
|
|
if (clientA) await closeClient(clientA);
|
|
if (clientB) await closeClient(clientB);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Scenario: Multiple sync cycles — import client's ops survive across cycles
|
|
*
|
|
* Verifies that the import client can create tasks across multiple sync cycles
|
|
* and they all appear on the other client. Each cycle increments the import
|
|
* client's counter further from the import's counter, exercising the
|
|
* same-client detection across multiple rounds.
|
|
*/
|
|
test('Import client ops sync correctly across multiple cycles', async ({
|
|
browser,
|
|
baseURL,
|
|
testRunId,
|
|
}) => {
|
|
const uniqueId = `${testRunId}-${Date.now()}-${Math.random().toString(36).substring(2, 8)}`;
|
|
const appUrl = baseURL || 'http://localhost:4242';
|
|
let clientA: SimulatedE2EClient | null = null;
|
|
let clientB: SimulatedE2EClient | null = null;
|
|
|
|
try {
|
|
const user = await createTestUser(testRunId);
|
|
const syncConfig = getSuperSyncConfig(user);
|
|
|
|
// Setup
|
|
clientA = await createSimulatedClient(browser, appUrl, 'A', testRunId);
|
|
await clientA.sync.setupSuperSync(syncConfig);
|
|
|
|
clientB = await createSimulatedClient(browser, appUrl, 'B', testRunId);
|
|
await clientB.sync.setupSuperSync(syncConfig);
|
|
|
|
await clientA.sync.syncAndWait();
|
|
await clientB.sync.syncAndWait();
|
|
|
|
// Import
|
|
const importPage = new ImportPage(clientA.page);
|
|
await importPage.navigateToImportPage();
|
|
const backupPath = ImportPage.getFixturePath('test-backup.json');
|
|
await importPage.importBackupFile(backupPath);
|
|
|
|
// Close and re-open page to pick up imported data with fresh Angular services.
|
|
await clientA.page.close();
|
|
clientA.page = await clientA.context.newPage();
|
|
await clientA.page.goto('/');
|
|
clientA.workView = new WorkViewPage(clientA.page, `A-${testRunId}`);
|
|
clientA.sync = new SuperSyncPage(clientA.page);
|
|
await waitForAppReady(clientA.page, { ensureRoute: false });
|
|
|
|
// Configure sync WITHOUT waiting for initial sync (same dialog handling as test 1)
|
|
await clientA.sync.setupSuperSync({ ...syncConfig, waitForInitialSync: false });
|
|
|
|
const syncImportDialog2 = clientA.sync.syncImportConflictDialog;
|
|
const syncResult2 = await Promise.race([
|
|
syncImportDialog2
|
|
.waitFor({ state: 'visible', timeout: 30000 })
|
|
.then(() => 'dialog' as const),
|
|
clientA.sync.syncCheckIcon
|
|
.waitFor({ state: 'visible', timeout: 30000 })
|
|
.then(() => 'complete' as const),
|
|
]);
|
|
|
|
if (syncResult2 === 'dialog') {
|
|
await clientA.sync.syncImportUseLocalBtn.click();
|
|
await syncImportDialog2.waitFor({ state: 'hidden', timeout: 5000 });
|
|
await clientA.sync.syncCheckIcon.waitFor({ state: 'visible', timeout: 30000 });
|
|
}
|
|
|
|
await waitForTask(clientA.page, 'E2E Import Test - Active Task With Subtask');
|
|
|
|
await clientA.sync.syncAndWait();
|
|
console.log('[Multi-Cycle Same-Client] SYNC_IMPORT uploaded');
|
|
|
|
// Client B receives import
|
|
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');
|
|
|
|
// Cycle 1: A creates task, syncs, B receives
|
|
const taskA1 = `Cycle1-A-${uniqueId}`;
|
|
await clientA.workView.addTask(taskA1);
|
|
await clientA.sync.syncAndWait();
|
|
await clientB.sync.syncAndWait();
|
|
await waitForTask(clientB.page, taskA1);
|
|
console.log(`[Multi-Cycle Same-Client] Cycle 1: B received ${taskA1}`);
|
|
|
|
// Cycle 2: B creates task, syncs, A receives, A creates task
|
|
const taskB1 = `Cycle2-B-${uniqueId}`;
|
|
await clientB.workView.addTask(taskB1);
|
|
await clientB.sync.syncAndWait();
|
|
await clientA.sync.syncAndWait();
|
|
await clientA.page.goto('/#/work-view');
|
|
await clientA.page.waitForLoadState('networkidle');
|
|
await waitForTask(clientA.page, taskB1);
|
|
|
|
const taskA2 = `Cycle2-A-${uniqueId}`;
|
|
await clientA.workView.addTask(taskA2);
|
|
await clientA.sync.syncAndWait();
|
|
console.log(`[Multi-Cycle Same-Client] Cycle 2: A created ${taskA2}`);
|
|
|
|
// Cycle 3: B receives A's task, A creates another
|
|
await clientB.sync.syncAndWait();
|
|
await waitForTask(clientB.page, taskA2);
|
|
|
|
const taskA3 = `Cycle3-A-${uniqueId}`;
|
|
await clientA.workView.addTask(taskA3);
|
|
await clientA.sync.syncAndWait();
|
|
await clientB.sync.syncAndWait();
|
|
await waitForTask(clientB.page, taskA3);
|
|
console.log(`[Multi-Cycle Same-Client] Cycle 3: B received ${taskA3}`);
|
|
|
|
// Final verification
|
|
await clientA.page.goto('/#/work-view');
|
|
await clientA.page.waitForLoadState('networkidle');
|
|
await clientB.page.goto('/#/work-view');
|
|
await clientB.page.waitForLoadState('networkidle');
|
|
|
|
await expectTaskOnAllClients([clientA, clientB], taskA1);
|
|
await expectTaskOnAllClients([clientA, clientB], taskB1);
|
|
await expectTaskOnAllClients([clientA, clientB], taskA2);
|
|
await expectTaskOnAllClients([clientA, clientB], taskA3);
|
|
|
|
console.log('[Multi-Cycle Same-Client] Multi-cycle test PASSED!');
|
|
} finally {
|
|
if (clientA) await closeClient(clientA);
|
|
if (clientB) await closeClient(clientB);
|
|
}
|
|
});
|
|
});
|