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