super-productivity/e2e/tests/sync/supersync-divergence-bug-6571.spec.ts
Johannes Millan d32f7037a3 fix(sync): preserve own vector clock counter across full-state op resets
When a full-state op (SYNC_IMPORT/BACKUP_IMPORT) arrived from another
client, mergeRemoteOpClocks reset the local clock to a "minimal" form
but only preserved the current client's counter from the incoming op's
clock. If the current client had issued ops (e.g. GLOBAL_CONFIG) not
reflected in the incoming full-state op's clock, its counter was dropped,
causing subsequent ops to reuse the same counter value. Downstream clients
then saw these ops as EQUAL (duplicate) and skipped them silently.

Fix: take max(mergedClock[clientId], currentClock[clientId]) when
rebuilding the clock after a full-state op reset.

Also add __SP_E2E_BLOCK_WS_DOWNLOAD flag to WsTriggeredDownloadService
to allow E2E tests to block automatic WS-triggered downloads during
concurrent conflict scenarios.

Fix archive conflict test by blocking WS downloads on Client A during
the concurrent edit phase so it doesn't auto-receive B's rename via
WebSocket before archiving (restoring the intended conflict scenario).

Fix LWW singleton test to assert convergence rather than specific winner.
Fix renameTask helper to avoid Playwright/Angular re-render races.
Fix shepherd.js import paths that broke the Angular dev server build.
2026-04-01 15:41:13 +02:00

208 lines
7.5 KiB
TypeScript

import { test, expect } from '../../fixtures/supersync.fixture';
import {
createTestUser,
getSuperSyncConfig,
createSimulatedClient,
closeClient,
waitForTask,
getTaskCount,
type SimulatedE2EClient,
} from '../../utils/supersync-helpers';
import { expectTaskOnAllClients } from '../../utils/supersync-assertions';
/**
* Bug #6571 Reproduction: Sync Divergence While Reporting IN_SYNC
*
* Root cause: Multiple error paths in the sync pipeline silently swallow
* errors, allowing sync to complete and report success even when operations
* were lost during processing. Confirmed bugs (unit-tested):
* 1. DownloadResult.success=false treated as "no new ops"
* 2. LWW conflict apply failure does not throw (swallowed)
* 3. handleRejectedOps error is swallowed
* 4. validateAfterSync result is discarded
*
* This e2e test uses Playwright route interception to drop ops from the
* download response (simulating what happens when any of the 4 bugs fires),
* then verifies the divergence persists while both clients show IN_SYNC.
*/
test.describe('@supersync Bug #6571: Sync divergence reproduction', () => {
test('ops lost during download cause permanent divergence while showing IN_SYNC', async ({
browser,
baseURL,
testRunId,
}) => {
let clientA: SimulatedE2EClient | null = null;
let clientB: SimulatedE2EClient | null = null;
try {
const user = await createTestUser(testRunId);
const syncConfig = getSuperSyncConfig(user);
// ─── Step 1: Set up both clients on empty server ───
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
await clientA.sync.setupSuperSync(syncConfig);
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
await clientB.sync.setupSuperSync(syncConfig);
// ─── Step 2: Install route interception on Client B BEFORE tasks are created ───
// Must be installed early to catch ops that arrive via auto-sync or
// piggybacked ops, not just during explicit syncAndWait().
let intercepted = false;
let droppedOpCount = 0;
// Intercept sync API to drop one TASK Create op from the response.
// entityType/opType are NOT encrypted (only payload is).
// On the wire, opType uses abbreviations: CRT, UPD, DEL.
// Ops can arrive via GET (download) or as piggybackedOps in POST (upload response).
const dropOneCrtOp = (ops: any[]): any[] => {
let droppedOne = false;
return ops.filter((serverOp: any) => {
if (droppedOne) return true;
const op = serverOp.op;
if (op && op.entityType === 'TASK' && op.opType === 'CRT') {
droppedOne = true;
droppedOpCount++;
return false;
}
return true;
});
};
await clientB.page.route('**/api/sync/ops**', async (route) => {
if (intercepted) {
await route.continue();
return;
}
const response = await route.fetch();
const body = await response.json();
// Check GET response (body.ops) and POST response (body.piggybackedOps)
if (body.ops && Array.isArray(body.ops) && body.ops.length > 0) {
body.ops = dropOneCrtOp(body.ops);
} else if (
body.piggybackedOps &&
Array.isArray(body.piggybackedOps) &&
body.piggybackedOps.length > 0
) {
body.piggybackedOps = dropOneCrtOp(body.piggybackedOps);
}
if (droppedOpCount > 0) {
intercepted = true;
}
await route.fulfill({
status: response.status(),
headers: response.headers(),
body: JSON.stringify(body),
});
});
// ─── Step 3: Client A creates 3 tasks and syncs ───
const task1 = `Task1-${testRunId}`;
const task2 = `Task2-${testRunId}`;
const task3 = `Task3-${testRunId}`;
await clientA.workView.addTask(task1);
await clientA.workView.addTask(task2);
await clientA.workView.addTask(task3);
// Wait for IndexedDB persistence before syncing
await clientA.page.waitForTimeout(1000);
await clientA.sync.syncAndWait();
// Verify A has all 3
await waitForTask(clientA.page, task1);
await waitForTask(clientA.page, task2);
await waitForTask(clientA.page, task3);
const countA = await getTaskCount(clientA);
expect(countA).toBe(3);
// ─── Step 4: Client B syncs — downloads ops with one dropped ───
await clientB.sync.syncAndWait();
// Verify interception triggered
expect(intercepted).toBe(true);
expect(droppedOpCount).toBe(1);
// ─── Step 5: Verify the divergence ───
// Client B should have only 2 tasks (one Create op was dropped)
const countB = await getTaskCount(clientB);
expect(countB).toBe(2); // Missing one task
// THE BUG: Both clients show sync success despite different state
const syncStateA = await clientA.sync.getSyncState();
const syncStateB = await clientB.sync.getSyncState();
expect(syncStateA).toBe('success');
expect(syncStateB).toBe('success');
// ─── Step 6: Verify divergence is PERMANENT ───
// Sync again — the dropped op will NOT reappear because
// lastServerSeq has advanced past it
await clientB.sync.syncAndWait();
const countBAfterResync = await getTaskCount(clientB);
expect(countBAfterResync).toBe(2); // Still missing
const syncStateBAfter = await clientB.sync.getSyncState();
expect(syncStateBAfter).toBe('success');
console.log(
`[Bug6571] CONFIRMED: A=${countA} tasks, B=${countBAfterResync} tasks. ` +
`Sync state: A=${syncStateA}, B=${syncStateBAfter}. Permanent divergence.`,
);
} finally {
if (clientA) await closeClient(clientA);
if (clientB) await closeClient(clientB);
}
});
test('convergence check: without interception both clients have identical state', async ({
browser,
baseURL,
testRunId,
}) => {
let clientA: SimulatedE2EClient | null = null;
let clientB: SimulatedE2EClient | null = null;
try {
const user = await createTestUser(testRunId);
const syncConfig = getSuperSyncConfig(user);
// Set up both clients
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
await clientA.sync.setupSuperSync(syncConfig);
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
await clientB.sync.setupSuperSync(syncConfig);
// Client A creates tasks and syncs
const task1 = `Task1-${testRunId}`;
const task2 = `Task2-${testRunId}`;
const task3 = `Task3-${testRunId}`;
await clientA.workView.addTask(task1);
await clientA.workView.addTask(task2);
await clientA.workView.addTask(task3);
await clientA.sync.syncAndWait();
// Client B syncs (no interception — happy path)
await clientB.sync.syncAndWait();
// Both should have all 3 tasks
await expectTaskOnAllClients([clientA, clientB], task1);
await expectTaskOnAllClients([clientA, clientB], task2);
await expectTaskOnAllClients([clientA, clientB], task3);
const countA = await getTaskCount(clientA);
const countB = await getTaskCount(clientB);
expect(countA).toBe(3);
expect(countB).toBe(3);
} finally {
if (clientA) await closeClient(clientA);
if (clientB) await closeClient(clientB);
}
});
});