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