super-productivity/e2e/tests/sync/supersync-import-clean-slate.spec.ts
Johannes Millan cce9576946 test(e2e): fix supersync E2E tests for mandatory encryption flow
- Extract shared encryption warning helper and fix race conditions
- Restructure concurrent-import tests to import before enabling sync
- Fix encryption password preservation and change tests
- Add "Change Password" button detection in supersync page object
- Mark backup-import ID mismatch test as fixme (known limitation)
2026-03-06 16:36:38 +01:00

802 lines
33 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 { waitForAppReady } from '../../utils/waits';
/**
* SuperSync Import Clean Slate E2E Tests
*
* These tests verify the "clean slate" semantics of SYNC_IMPORT:
* When a backup is imported, ALL concurrent work from ALL clients is dropped.
* The import is an explicit user action to restore to a specific state.
*
* 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-import-clean-slate.spec.ts
*/
test.describe('@supersync @cleanslate Import Clean Slate Semantics', () => {
/**
* Scenario: Import drops all concurrent work from both clients
*
* This is the core "clean slate" test. After an import, ONLY the imported
* data should exist - all work created by any client before the import
* (that the import didn't know about) should be gone.
*
* Setup: Client A and B with shared SuperSync account
*
* Actions:
* 1. Client A creates task "Task-A-Before"
* 2. Client A syncs
* 3. Client B syncs (gets Task-A-Before)
* 4. Client B creates task "Task-B-Concurrent"
* 5. Client B syncs (uploads Task-B-Concurrent)
* 6. Client A creates task "Task-A-Concurrent"
* 7. Client A imports backup (contains different tasks)
* 8. Client A syncs (uploads SYNC_IMPORT)
* 9. Client B syncs (receives SYNC_IMPORT)
*
* Verify:
* - Client A ONLY has imported tasks (no Task-A-Before, Task-A-Concurrent, Task-B-Concurrent)
* - Client B ONLY has imported tasks (no Task-A-Before, Task-A-Concurrent, Task-B-Concurrent)
*
* Why: The import creates a SYNC_IMPORT operation that represents a complete
* fresh start. All operations created before it (even if already synced)
* are dropped because they reference state that no longer exists.
*/
test('Import drops ALL concurrent work from both clients (clean slate)', async ({
browser,
baseURL,
testRunId,
}) => {
const uniqueId = Date.now();
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('[Clean Slate] 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);
// ============ PHASE 2: Create Pre-Import Tasks on Both Clients ============
console.log('[Clean Slate] Phase 2: Creating pre-import tasks');
// Client A creates and syncs first task
const taskABefore = `Task-A-Before-${uniqueId}`;
await clientA.workView.addTask(taskABefore);
await clientA.sync.syncAndWait();
console.log(`[Clean Slate] Client A created: ${taskABefore}`);
// Client B syncs to get A's task
await clientB.sync.syncAndWait();
await waitForTask(clientB.page, taskABefore);
console.log('[Clean Slate] Client B received Task-A-Before');
// Client B creates and syncs a concurrent task
const taskBConcurrent = `Task-B-Concurrent-${uniqueId}`;
await clientB.workView.addTask(taskBConcurrent);
await clientB.sync.syncAndWait();
console.log(`[Clean Slate] Client B created: ${taskBConcurrent}`);
// Client A creates another task (will NOT sync before import)
const taskAConcurrent = `Task-A-Concurrent-${uniqueId}`;
await clientA.workView.addTask(taskAConcurrent);
console.log(`[Clean Slate] Client A created: ${taskAConcurrent}`);
// ============ PHASE 3: Client A Imports Backup ============
console.log('[Clean Slate] Phase 3: Client A importing backup');
// Navigate to import page
const importPage = new ImportPage(clientA.page);
await importPage.navigateToImportPage();
// Import the backup file (contains "E2E Import Test" tasks)
const backupPath = ImportPage.getFixturePath('test-backup.json');
// Diagnostic: check ops BEFORE import
const preImportState = await clientA.page.evaluate(async () => {
const db = await new Promise<IDBDatabase>((resolve, reject) => {
const req = indexedDB.open('SUP_OPS');
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
const ops = await new Promise<Array<{ seq: number }>>((resolve, reject) => {
const tx = db.transaction('ops', 'readonly');
const store = tx.objectStore('ops');
const req = store.getAll();
req.onsuccess = () => resolve(req.result || []);
req.onerror = () => reject(req.error);
});
db.close();
return { count: ops.length };
});
console.log('[Clean Slate] PRE-IMPORT IndexedDB op count:', preImportState.count);
await importPage.importBackupFile(backupPath);
console.log('[Clean Slate] Client A imported backup');
// Diagnostic: check ops IMMEDIATELY after import (before goto)
const postImportState = await clientA.page.evaluate(async () => {
const db = await new Promise<IDBDatabase>((resolve, reject) => {
const req = indexedDB.open('SUP_OPS');
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
const ops = await new Promise<
Array<{ seq: number; op: { o?: string }; syncedAt?: number }>
>((resolve, reject) => {
const tx = db.transaction('ops', 'readonly');
const store = tx.objectStore('ops');
const req = store.getAll();
req.onsuccess = () => resolve(req.result || []);
req.onerror = () => reject(req.error);
});
db.close();
return {
count: ops.length,
ops: ops.map((o) => ({ seq: o.seq, type: o.op?.o, syncedAt: !!o.syncedAt })),
};
});
console.log(
'[Clean Slate] POST-IMPORT IndexedDB op count:',
postImportState.count,
JSON.stringify(postImportState.ops),
);
// Navigate to app root to restart Angular with the imported backup state.
// IMPORTANT: Do NOT close and reopen the page - Playwright's browser contexts use
// in-memory-only IndexedDB ("Persistence not allowed"). Closing the page would
// destroy the imported backup data. Instead, navigate within the same page context
// to restart Angular while preserving the in-memory IndexedDB data.
await clientA.page.goto('/', { waitUntil: 'commit', timeout: 30000 });
clientA.workView = new WorkViewPage(clientA.page, `A-${testRunId}`);
clientA.sync = new SuperSyncPage(clientA.page);
await waitForAppReady(clientA.page, { ensureRoute: false });
// Diagnostic: check what's in IndexedDB before sync
const dbState = await clientA.page.evaluate(async () => {
const db = await new Promise<IDBDatabase>((resolve, reject) => {
const req = indexedDB.open('SUP_OPS');
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
const ops = await new Promise<
Array<{
seq: number;
op: { o?: string; opType?: string; id?: string };
syncedAt?: number;
source?: string;
}>
>((resolve, reject) => {
const tx = db.transaction('ops', 'readonly');
const store = tx.objectStore('ops');
const req = store.getAll();
req.onsuccess = () => resolve(req.result || []);
req.onerror = () => reject(req.error);
});
db.close();
return {
count: ops.length,
ops: ops.map((o) => ({
seq: o.seq,
type: o.op?.o || o.op?.opType,
syncedAt: o.syncedAt,
source: o.source,
})),
};
});
console.log(
'[Clean Slate] IndexedDB op count:',
dbState.count,
'ops:',
JSON.stringify(dbState.ops),
);
// 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
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
await clientA.sync.syncImportUseLocalBtn.click();
await syncImportDialog.waitFor({ state: 'hidden', timeout: 5000 });
await clientA.sync.syncCheckIcon.waitFor({ state: 'visible', timeout: 30000 });
}
// Navigate to INBOX_PROJECT to verify imported tasks
// (test-backup.json tasks are in INBOX_PROJECT, not the TODAY virtual tag)
await clientA.page.goto('/#/project/INBOX_PROJECT/tasks', {
waitUntil: 'domcontentloaded',
timeout: 30000,
});
await clientA.page.waitForLoadState('networkidle');
// Wait for imported task to be visible
await waitForTask(clientA.page, 'E2E Import Test - Active Task With Subtask');
console.log('[Clean Slate] Client A has imported tasks');
// ============ PHASE 4: Sync to Propagate SYNC_IMPORT ============
console.log('[Clean Slate] Phase 4: Syncing to propagate SYNC_IMPORT');
// Client A syncs (uploads SYNC_IMPORT)
await clientA.sync.syncAndWait();
console.log('[Clean Slate] Client A synced (SYNC_IMPORT uploaded)');
// Client B syncs (receives SYNC_IMPORT - should drop all concurrent work)
await clientB.sync.syncAndWait();
console.log('[Clean Slate] Client B synced (received SYNC_IMPORT)');
// Wait for state to settle - allow UI to update
await clientA.page.waitForTimeout(1000);
await clientB.page.waitForTimeout(1000);
// ============ PHASE 5: Verify Clean Slate on Both Clients ============
console.log('[Clean Slate] Phase 5: Verifying clean slate');
// Navigate to INBOX_PROJECT to see imported tasks
// (test-backup.json tasks are in INBOX_PROJECT, not the TODAY virtual tag)
await clientA.page.goto('/#/project/INBOX_PROJECT/tasks', {
waitUntil: 'domcontentloaded',
timeout: 30000,
});
await clientA.page.waitForLoadState('networkidle');
await clientB.page.goto('/#/project/INBOX_PROJECT/tasks', {
waitUntil: 'domcontentloaded',
timeout: 30000,
});
await clientB.page.waitForLoadState('networkidle');
// Wait for imported task to appear
await waitForTask(clientA.page, 'E2E Import Test - Active Task With Subtask');
await waitForTask(clientB.page, 'E2E Import Test - Active Task With Subtask');
// CRITICAL VERIFICATION: Pre-import tasks should be GONE
console.log('[Clean Slate] Verifying pre-import tasks are gone...');
// Check Client A - should NOT have pre-import tasks
const taskABeforeOnA = clientA.page.locator(`task:has-text("${taskABefore}")`);
const taskAConcurrentOnA = clientA.page.locator(
`task:has-text("${taskAConcurrent}")`,
);
const taskBConcurrentOnA = clientA.page.locator(
`task:has-text("${taskBConcurrent}")`,
);
await expect(taskABeforeOnA).not.toBeVisible({ timeout: 5000 });
await expect(taskAConcurrentOnA).not.toBeVisible({ timeout: 5000 });
await expect(taskBConcurrentOnA).not.toBeVisible({ timeout: 5000 });
console.log('[Clean Slate] ✓ Client A: All pre-import tasks are GONE');
// Check Client B - should NOT have pre-import tasks
const taskABeforeOnB = clientB.page.locator(`task:has-text("${taskABefore}")`);
const taskAConcurrentOnB = clientB.page.locator(
`task:has-text("${taskAConcurrent}")`,
);
const taskBConcurrentOnB = clientB.page.locator(
`task:has-text("${taskBConcurrent}")`,
);
await expect(taskABeforeOnB).not.toBeVisible({ timeout: 5000 });
await expect(taskAConcurrentOnB).not.toBeVisible({ timeout: 5000 });
await expect(taskBConcurrentOnB).not.toBeVisible({ timeout: 5000 });
console.log('[Clean Slate] ✓ Client B: All pre-import tasks are GONE');
// Verify both clients have imported tasks
const importedTask1OnA = clientA.page.locator(
'task:has-text("E2E Import Test - Active Task With Subtask")',
);
const importedTask1OnB = clientB.page.locator(
'task:has-text("E2E Import Test - Active Task With Subtask")',
);
await expect(importedTask1OnA).toBeVisible({ timeout: 5000 });
await expect(importedTask1OnB).toBeVisible({ timeout: 5000 });
console.log('[Clean Slate] ✓ Both clients have imported tasks');
console.log('[Clean Slate] ✓ Clean slate test PASSED!');
} finally {
if (clientA) await closeClient(clientA);
if (clientB) await closeClient(clientB);
}
});
/**
* Scenario: Late joiner with synced ops - ops are dropped after import
*
* This tests the "late joiner" scenario that was previously handled by
* _replayLocalSyncedOpsAfterImport (now removed).
*
* Setup: Client A and B with shared SuperSync account
*
* Actions:
* 1. Client B creates task "Task-B-Local"
* 2. Client B syncs (uploads Task-B-Local to server)
* 3. Client A imports backup (doesn't know about Task-B-Local)
* 4. Client A syncs (uploads SYNC_IMPORT)
* 5. Client B syncs (receives SYNC_IMPORT, piggybacked with own ops)
*
* Verify:
* - Client B does NOT have "Task-B-Local" after sync
* - Client B ONLY has imported tasks
*
* Why: Even though Client B's ops were already on the server, the SYNC_IMPORT
* represents a complete state reset. Client B's ops are not replayed because
* they reference state that no longer exists after the import.
*/
test('Late joiner synced ops are dropped after import (no replay)', async ({
browser,
baseURL,
testRunId,
}) => {
const uniqueId = Date.now();
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: Client B Creates and Syncs ============
console.log('[Late Joiner] Phase 1: Client B creates and syncs');
clientB = await createSimulatedClient(browser, appUrl, 'B', testRunId);
await clientB.sync.setupSuperSync(syncConfig);
// Client B creates and syncs a task
const taskBLocal = `Task-B-Local-${uniqueId}`;
await clientB.workView.addTask(taskBLocal);
await clientB.sync.syncAndWait();
console.log(`[Late Joiner] Client B created and synced: ${taskBLocal}`);
// Verify task exists on B
await waitForTask(clientB.page, taskBLocal);
console.log('[Late Joiner] Task-B-Local confirmed on Client B');
// ============ PHASE 2: Client A Imports (Without Syncing First) ============
console.log('[Late Joiner] Phase 2: Client A imports backup');
clientA = await createSimulatedClient(browser, appUrl, 'A', testRunId);
await clientA.sync.setupSuperSync(syncConfig);
// DO NOT sync before import - A doesn't know about B's task
// Navigate to import page
const importPage = new ImportPage(clientA.page);
await importPage.navigateToImportPage();
// Import backup
const backupPath = ImportPage.getFixturePath('test-backup.json');
await importPage.importBackupFile(backupPath);
console.log('[Late Joiner] Client A imported backup');
// Navigate to restart Angular while preserving in-memory IndexedDB backup data.
await clientA.page.goto('/', { waitUntil: 'commit', timeout: 30000 });
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
await clientA.sync.setupSuperSync({ ...syncConfig, waitForInitialSync: false });
// Wait for either sync import conflict dialog OR sync completion
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 });
}
// ============ PHASE 3: Both Clients Sync ============
console.log('[Late Joiner] Phase 3: Both clients sync');
// Client A syncs (uploads SYNC_IMPORT)
await clientA.sync.syncAndWait();
console.log('[Late Joiner] Client A synced');
// Client B syncs (receives SYNC_IMPORT)
// This is where the old code would replay Task-B-Local
// With clean slate semantics, Task-B-Local should be dropped
await clientB.sync.syncAndWait();
console.log('[Late Joiner] Client B synced');
// Wait for state to settle - allow UI to update
await clientB.page.waitForTimeout(1000);
// ============ PHASE 4: Verify Clean Slate on Client B ============
console.log('[Late Joiner] Phase 4: Verifying clean slate on Client B');
// Navigate to INBOX_PROJECT to see imported tasks
await clientB.page.goto('/#/project/INBOX_PROJECT/tasks', {
waitUntil: 'domcontentloaded',
timeout: 30000,
});
await clientB.page.waitForLoadState('networkidle');
// Wait for imported task
await waitForTask(clientB.page, 'E2E Import Test - Active Task With Subtask');
// CRITICAL: Task-B-Local should be GONE (not replayed)
const taskBLocalOnB = clientB.page.locator(`task:has-text("${taskBLocal}")`);
await expect(taskBLocalOnB).not.toBeVisible({ timeout: 5000 });
console.log('[Late Joiner] ✓ Task-B-Local is GONE (not replayed)');
// Verify imported task is present
const importedTaskOnB = clientB.page.locator(
'task:has-text("E2E Import Test - Active Task With Subtask")',
);
await expect(importedTaskOnB).toBeVisible({ timeout: 5000 });
console.log('[Late Joiner] ✓ Client B has imported tasks');
console.log('[Late Joiner] ✓ Late joiner test PASSED!');
} finally {
if (clientA) await closeClient(clientA);
if (clientB) await closeClient(clientB);
}
});
/**
* Scenario: Import invalidates PENDING (unsynced) operations from other clients
*
* This tests the critical case where Client B has pending operations that
* haven't been uploaded yet when Client A imports a backup. These pending
* ops are CONCURRENT to the import (no knowledge of import) and should be
* filtered out.
*
* Setup: Client A and B with shared SuperSync account
*
* Actions:
* 1. Client A and B both sync (empty state, both know each other)
* 2. Client B creates task "Task-B-Pending" but does NOT sync (pending locally)
* 3. Client A imports backup (doesn't know about B's pending task)
* 4. Client A syncs (uploads SYNC_IMPORT)
* 5. Client B syncs (receives SYNC_IMPORT, B's pending ops are CONCURRENT)
*
* Verify:
* - Client B does NOT have "Task-B-Pending" after sync
* - Client B ONLY has imported tasks
* - B's pending task is correctly filtered due to clean slate semantics
*
* Why: The import creates a SYNC_IMPORT operation. Client B's pending ops
* have a vector clock that is CONCURRENT with the import (created without
* knowledge of the import). These must be dropped per clean slate semantics.
*/
test('Import invalidates PENDING (unsynced) operations from other clients', async ({
browser,
baseURL,
testRunId,
}) => {
const uniqueId = Date.now();
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 with Initial Sync ============
console.log(
'[Pending Invalidation] Phase 1: Setting up both clients with initial sync',
);
clientA = await createSimulatedClient(browser, appUrl, 'A', testRunId);
await clientA.sync.setupSuperSync(syncConfig);
await clientA.sync.syncAndWait(); // Initial sync to establish vector clock
clientB = await createSimulatedClient(browser, appUrl, 'B', testRunId);
await clientB.sync.setupSuperSync(syncConfig);
await clientB.sync.syncAndWait(); // Initial sync to establish vector clock
console.log('[Pending Invalidation] Both clients synced initially');
// ============ PHASE 2: Client B Creates Task but Does NOT Sync ============
console.log(
'[Pending Invalidation] Phase 2: Client B creates task (pending, not synced)',
);
const taskBPending = `Task-B-Pending-${uniqueId}`;
await clientB.workView.addTask(taskBPending);
await waitForTask(clientB.page, taskBPending);
console.log(
`[Pending Invalidation] Client B created: ${taskBPending} (NOT synced)`,
);
// Verify task exists locally on B
const taskBLocator = clientB.page.locator(`task:has-text("${taskBPending}")`);
await expect(taskBLocator).toBeVisible();
// ============ PHASE 3: Client A Imports Backup ============
console.log('[Pending Invalidation] Phase 3: Client A importing backup');
// Navigate to import page
const importPage = new ImportPage(clientA.page);
await importPage.navigateToImportPage();
// Import the backup file (contains "E2E Import Test" tasks)
const backupPath = ImportPage.getFixturePath('test-backup.json');
await importPage.importBackupFile(backupPath);
console.log('[Pending Invalidation] Client A imported backup');
// Navigate to restart Angular while preserving in-memory IndexedDB backup data.
await clientA.page.goto('/', { waitUntil: 'commit', timeout: 30000 });
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
await clientA.sync.setupSuperSync({ ...syncConfig, waitForInitialSync: false });
// Wait for either sync import conflict dialog OR sync completion
const syncImportDialog3 = clientA.sync.syncImportConflictDialog;
const syncResult3 = await Promise.race([
syncImportDialog3
.waitFor({ state: 'visible', timeout: 30000 })
.then(() => 'dialog' as const),
clientA.sync.syncCheckIcon
.waitFor({ state: 'visible', timeout: 30000 })
.then(() => 'complete' as const),
]);
if (syncResult3 === 'dialog') {
await clientA.sync.syncImportUseLocalBtn.click();
await syncImportDialog3.waitFor({ state: 'hidden', timeout: 5000 });
await clientA.sync.syncCheckIcon.waitFor({ state: 'visible', timeout: 30000 });
}
// Navigate to INBOX_PROJECT to verify imported tasks
await clientA.page.goto('/#/project/INBOX_PROJECT/tasks', {
waitUntil: 'domcontentloaded',
timeout: 30000,
});
await clientA.page.waitForLoadState('networkidle');
// Wait for imported task to be visible
await waitForTask(clientA.page, 'E2E Import Test - Active Task With Subtask');
console.log('[Pending Invalidation] Client A has imported tasks');
// ============ PHASE 4: Sync to Propagate SYNC_IMPORT ============
console.log('[Pending Invalidation] Phase 4: Syncing to propagate SYNC_IMPORT');
// Client A syncs (uploads SYNC_IMPORT)
await clientA.sync.syncAndWait();
console.log('[Pending Invalidation] Client A synced (SYNC_IMPORT uploaded)');
// ============ PHASE 5: Client B Syncs (Pending Ops Should Be Invalidated) ============
console.log(
'[Pending Invalidation] Phase 5: Client B syncs (pending ops invalidated)',
);
// This is the CRITICAL sync - B's pending task was created BEFORE the import
// but B hasn't synced it yet. When B syncs now:
// 1. B tries to upload Task-B-Pending (has old vector clock)
// 2. B receives SYNC_IMPORT
// 3. B's pending ops are CONCURRENT to SYNC_IMPORT
// 4. Clean slate semantics: B's pending ops are filtered/dropped
await clientB.sync.syncAndWait();
console.log('[Pending Invalidation] Client B synced (received SYNC_IMPORT)');
// Wait for state to settle - allow UI to update
await clientB.page.waitForTimeout(1000);
// ============ PHASE 6: Verify Clean Slate on Client B ============
console.log('[Pending Invalidation] Phase 6: Verifying clean slate');
// Navigate to INBOX_PROJECT to see imported tasks
await clientB.page.goto('/#/project/INBOX_PROJECT/tasks', {
waitUntil: 'domcontentloaded',
timeout: 30000,
});
await clientB.page.waitForLoadState('networkidle');
// Wait for imported task to appear
await waitForTask(clientB.page, 'E2E Import Test - Active Task With Subtask');
// CRITICAL VERIFICATION: B's pending task should be GONE
console.log('[Pending Invalidation] Verifying pending task is gone...');
const taskBPendingOnB = clientB.page.locator(`task:has-text("${taskBPending}")`);
await expect(taskBPendingOnB).not.toBeVisible({ timeout: 5000 });
console.log(
'[Pending Invalidation] ✓ Task-B-Pending is GONE (correctly invalidated)',
);
// Verify imported task is present on B
const importedTaskOnB = clientB.page.locator(
'task:has-text("E2E Import Test - Active Task With Subtask")',
);
await expect(importedTaskOnB).toBeVisible({ timeout: 5000 });
console.log('[Pending Invalidation] ✓ Client B has imported tasks');
// Also verify on Client A - should NOT have B's pending task
await clientA.page.goto('/#/project/INBOX_PROJECT/tasks', {
waitUntil: 'domcontentloaded',
timeout: 30000,
});
await clientA.page.waitForLoadState('networkidle');
const taskBPendingOnA = clientA.page.locator(`task:has-text("${taskBPending}")`);
await expect(taskBPendingOnA).not.toBeVisible({ timeout: 5000 });
console.log(
"[Pending Invalidation] ✓ Client A also does not have B's pending task",
);
console.log('[Pending Invalidation] ✓ Pending invalidation test PASSED!');
} finally {
if (clientA) await closeClient(clientA);
if (clientB) await closeClient(clientB);
}
});
/**
* Scenario D.4: Remote ops concurrent with accepted remote import are silently filtered
*
* When Client B imports a backup and Client A accepts it (USE_REMOTE),
* any remaining old ops from before the import should be silently filtered
* on subsequent syncs — no dialog, no errors.
*
* Actions:
* 1. Client A creates tasks, syncs
* 2. Client B imports backup, syncs (creates SYNC_IMPORT)
* 3. Client A syncs, gets SYNC_IMPORT, accepts remote import (USE_REMOTE)
* 4. Client A syncs again — old ops should be silently filtered
*
* Verify:
* - No dialog on the second sync after accepting import
* - Sync completes successfully (IN_SYNC)
* - Only imported data remains
*/
test('Remote ops concurrent with accepted remote import are silently filtered (D.4)', async ({
browser,
baseURL,
testRunId,
}) => {
const uniqueId = Date.now();
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: Client A creates tasks ============
console.log('[D.4] Phase 1: Client A creates tasks');
clientA = await createSimulatedClient(browser, appUrl, 'A', testRunId);
await clientA.sync.setupSuperSync(syncConfig);
const taskAOld = `Task-A-Old-${uniqueId}`;
await clientA.workView.addTask(taskAOld);
await clientA.sync.syncAndWait();
console.log(`[D.4] Client A created and synced: ${taskAOld}`);
// ============ PHASE 2: Client B imports backup ============
console.log('[D.4] Phase 2: Client B imports backup');
clientB = await createSimulatedClient(browser, appUrl, 'B', testRunId);
await clientB.sync.setupSuperSync(syncConfig);
// Perform import on Client B
const importPage = new ImportPage(clientB.page);
await importPage.navigateToImportPage();
const backupPath = ImportPage.getFixturePath('test-backup.json');
await importPage.importBackupFile(backupPath);
console.log('[D.4] Client B imported backup');
// Navigate to restart Angular while preserving in-memory IndexedDB backup data.
await clientB.page.goto('/', { waitUntil: 'commit', timeout: 30000 });
clientB.workView = new WorkViewPage(clientB.page, `B-${testRunId}`);
clientB.sync = new SuperSyncPage(clientB.page);
await waitForAppReady(clientB.page, { ensureRoute: false });
// Configure sync WITHOUT waiting for initial sync
await clientB.sync.setupSuperSync({ ...syncConfig, waitForInitialSync: false });
// Wait for sync import conflict dialog or sync completion
const syncImportDialog = clientB.sync.syncImportConflictDialog;
const syncResult = await Promise.race([
syncImportDialog
.waitFor({ state: 'visible', timeout: 30000 })
.then(() => 'dialog' as const),
clientB.sync.syncCheckIcon
.waitFor({ state: 'visible', timeout: 30000 })
.then(() => 'complete' as const),
]);
if (syncResult === 'dialog') {
await clientB.sync.syncImportUseLocalBtn.click();
await syncImportDialog.waitFor({ state: 'hidden', timeout: 5000 });
await clientB.sync.syncCheckIcon.waitFor({ state: 'visible', timeout: 30000 });
}
// Client B syncs to upload SYNC_IMPORT
await clientB.sync.syncAndWait();
console.log('[D.4] Client B synced (SYNC_IMPORT uploaded)');
// ============ PHASE 3: Client A syncs and accepts remote import ============
console.log('[D.4] Phase 3: Client A syncs and accepts remote import');
// Client A syncs — should get SYNC_IMPORT, may show conflict dialog
// Use syncAndWait which handles dialogs automatically (uses remote by default)
await clientA.sync.syncAndWait();
console.log('[D.4] Client A accepted remote import');
// Wait for state to settle
await clientA.page.waitForTimeout(1000);
// ============ PHASE 4: Client A syncs AGAIN — old ops should be silently filtered ============
console.log(
'[D.4] Phase 4: Client A syncs again (old ops should be silently filtered)',
);
// This is the CRITICAL sync — any remaining old ops from before the import
// should be silently filtered by SyncImportFilterService with no dialog
await clientA.sync.syncAndWait();
// Verify no error
const hasError = await clientA.sync.hasSyncError();
expect(hasError).toBe(false);
// Verify sync is IN_SYNC
await expect(clientA.sync.syncCheckIcon).toBeVisible({ timeout: 5000 });
// Navigate to INBOX_PROJECT to verify imported tasks
await clientA.page.goto('/#/project/INBOX_PROJECT/tasks', {
waitUntil: 'domcontentloaded',
timeout: 30000,
});
await clientA.page.waitForLoadState('networkidle');
// Verify imported task is present
await waitForTask(clientA.page, 'E2E Import Test - Active Task With Subtask');
// Verify old task is gone (clean slate)
const oldTaskOnA = clientA.page.locator(`task:has-text("${taskAOld}")`);
await expect(oldTaskOnA).not.toBeVisible({ timeout: 5000 });
console.log('[D.4] ✓ Old ops silently filtered after accepting remote import');
} finally {
if (clientA) await closeClient(clientA);
if (clientB) await closeClient(clientB);
}
});
});