diff --git a/e2e/tests/sync/supersync-use-remote-crash-resume.spec.ts b/e2e/tests/sync/supersync-use-remote-crash-resume.spec.ts new file mode 100644 index 0000000000..a6deae61d7 --- /dev/null +++ b/e2e/tests/sync/supersync-use-remote-crash-resume.spec.ts @@ -0,0 +1,177 @@ +import { test, expect } from '../../fixtures/supersync.fixture'; +import { ImportPage } from '../../pages/import.page'; +import { + closeClient, + createSimulatedClient, + createTestUser, + getSuperSyncConfig, + type SimulatedE2EClient, +} from '../../utils/supersync-helpers'; +import { waitForAppReady } from '../../utils/waits'; + +const CRASH_STATE_KEY = 'e2e-use-remote-crash-state'; +const CRASH_LOG = '[CrashResume] Simulating reload after remote baseline commit'; +const REBUILD_COMMITTED_LOG = + 'OperationLogSyncService: Replaced local persistence with remote baseline.'; +const RESUME_DETECTED_LOG = + 'OperationLogSyncService: Interrupted USE_REMOTE rebuild detected'; + +/** + * SuperSync USE_REMOTE crash recovery. + * + * The test reloads Client B immediately after runRemoteStateReplacement() has + * atomically committed the remote baseline and its raw-rebuild-incomplete marker, + * but before replay can finish and clear that marker. On the next sync, B must: + * + * 1. detect the interrupted rebuild, + * 2. redo the raw server-history download, + * 3. keep the FIRST attempt's pre-replace import backup, + * 4. finish on the remote state, and + * 5. offer Undo that restores B's original imported state. + * + * Run with: + * npm run e2e:supersync:file e2e/tests/sync/supersync-use-remote-crash-resume.spec.ts + */ +test.describe('@supersync USE_REMOTE interrupted rebuild recovery', () => { + test.describe.configure({ mode: 'serial' }); + + test('resumes after reload and preserves the original Undo backup', async ({ + browser, + baseURL, + testRunId, + }) => { + test.setTimeout(180000); + + let clientA: SimulatedE2EClient | null = null; + let clientB: SimulatedE2EClient | null = null; + + try { + const user = await createTestUser(testRunId); + const syncConfig = getSuperSyncConfig(user); + const remoteTask = `CrashResumeRemote-${testRunId}`; + const importedTask = 'E2E Import Test - Active Task With Subtask'; + + // ===== PHASE 1: A establishes the remote state ===== + clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); + await clientA.sync.setupSuperSync(syncConfig); + await clientA.workView.addTask(remoteTask); + await clientA.sync.syncAndWait(); + + // ===== PHASE 2: B installs a one-shot crash failpoint before app boot ===== + clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); + await clientB.page.addInitScript( + ({ crashLog, crashStateKey, rebuildCommittedLog }) => { + const e2eGlobal = globalThis as typeof globalThis & { + __SP_E2E_BLOCK_AUTO_SYNC?: boolean; + __SP_E2E_BLOCK_IMMEDIATE_UPLOAD?: boolean; + __SP_E2E_BLOCK_WS_DOWNLOAD?: boolean; + }; + // Allow setup's initial sync before the crash. On the reload, block + // automatic sync so the test can observe and trigger recovery itself. + e2eGlobal.__SP_E2E_BLOCK_AUTO_SYNC = + sessionStorage.getItem(crashStateKey) === 'crashed'; + e2eGlobal.__SP_E2E_BLOCK_IMMEDIATE_UPLOAD = true; + e2eGlobal.__SP_E2E_BLOCK_WS_DOWNLOAD = true; + + // Install before Angular modules load so Log's pre-bound console method + // captures this wrapper. The session marker makes the reload one-shot. + const originalLog = console.log.bind(console); + console.log = (...args: unknown[]): void => { + originalLog(...args); + const message = args.map(String).join(' '); + if ( + sessionStorage.getItem(crashStateKey) === 'armed' && + message.includes(rebuildCommittedLog) + ) { + sessionStorage.setItem(crashStateKey, 'crashed'); + originalLog(crashLog); + // Abort the current replay task at the exact committed-baseline + // cutoff. Playwright reloads the document after observing crashLog. + throw new Error(crashLog); + } + }; + }, + { + crashLog: CRASH_LOG, + crashStateKey: CRASH_STATE_KEY, + rebuildCommittedLog: REBUILD_COMMITTED_LOG, + }, + ); + await clientB.page.reload(); + await waitForAppReady(clientB.page); + + // Importing gives B a known local state and creates a full-state local op, + // guaranteeing the incoming server history requires an explicit choice. + const importPageB = new ImportPage(clientB.page); + await importPageB.navigateToImportPage(); + await importPageB.importBackupFile(ImportPage.getFixturePath('test-backup.json')); + await clientB.page.goto('/#/tag/TODAY/tasks'); + await clientB.page.waitForURL(/(active\/tasks|tag\/TODAY\/tasks)/); + await expect(clientB.page.locator('task', { hasText: importedTask })).toBeVisible({ + timeout: 15000, + }); + + await clientB.page.evaluate( + ({ crashStateKey }) => sessionStorage.setItem(crashStateKey, 'armed'), + { crashStateKey: CRASH_STATE_KEY }, + ); + + // Setup starts the first sync but leaves its conflict dialog for the test. + await clientB.sync.setupSuperSync({ + ...syncConfig, + waitForInitialSync: false, + }); + await expect(clientB.sync.syncImportConflictDialog).toBeVisible({ timeout: 30000 }); + + // ===== PHASE 3: choose USE_REMOTE and reload at the atomic baseline cutoff ===== + const crashObserved = clientB.page.waitForEvent('console', { + predicate: (message) => message.text().includes(CRASH_LOG), + timeout: 30000, + }); + + await clientB.sync.syncImportUseRemoteBtn.click(); + await crashObserved; + await clientB.page.reload(); + await waitForAppReady(clientB.page); + expect( + await clientB.page.evaluate( + ({ crashStateKey }) => sessionStorage.getItem(crashStateKey), + { crashStateKey: CRASH_STATE_KEY }, + ), + ).toBe('crashed'); + + // ===== PHASE 4: next sync must resume raw rebuild and keep first backup ===== + const resumeDetected = clientB.page.waitForEvent('console', { + predicate: (message) => message.text().includes(RESUME_DETECTED_LOG), + timeout: 30000, + }); + await clientB.sync.syncAndWait({ timeout: 60000 }); + await resumeDetected; + + // B was already on Today before the reload. Keep the current work context: + // changing it intentionally dismisses snacks, including the persistent Undo. + await expect(clientB.page.locator('task', { hasText: remoteTask })).toBeVisible({ + timeout: 15000, + }); + await expect( + clientB.page.locator('task', { hasText: importedTask }), + ).not.toBeVisible(); + + const undoSnack = clientB.page.locator('snack-custom', { + hasText: /replaced with the server/i, + }); + await expect(undoSnack).toBeVisible({ timeout: 30000 }); + await undoSnack.locator('button.action').click(); + + // Undo must restore the backup from BEFORE the first, interrupted replace, + // not a backup of the partial remote baseline captured during crash resume. + await expect(clientB.page.locator('task', { hasText: importedTask })).toBeVisible({ + timeout: 15000, + }); + console.log('[CrashResume] ✓ Original imported state restored from first backup'); + } finally { + if (clientA) await closeClient(clientA); + if (clientB) await closeClient(clientB); + } + }); +});