super-productivity/e2e/tests/sync/supersync-import-same-client-ops.spec.ts
Johannes Millan 93cbcd7e44 test(e2e): fix reliability issues in supersync E2E tests
- 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
2026-02-13 14:06:43 +01:00

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);
}
});
});