super-productivity/e2e/tests/sync/supersync-error-handling.spec.ts
Johannes Millan dbeca43876 test(e2e): fix done-toggle selector to use element instead of class
The done-toggle was refactored from an inline SVG with class="done-toggle"
into a standalone Angular component <done-toggle>. Update all e2e selectors
from '.done-toggle' (class) to 'done-toggle' (element) to match.
2026-03-24 16:25:42 +01:00

364 lines
12 KiB
TypeScript

import { test, expect } from '../../fixtures/supersync.fixture';
import {
createTestUser,
getSuperSyncConfig,
createSimulatedClient,
closeClient,
waitForTask,
type SimulatedE2EClient,
} from '../../utils/supersync-helpers';
/**
* SuperSync Error Handling E2E Tests
*
* These tests verify the client correctly handles various error scenarios:
* - Conflict resolution with multiple clients
* - Concurrent modification detection
* - Error recovery after sync failures
*/
test.describe('@supersync SuperSync Error Handling', () => {
/**
* Scenario: Concurrent Modification Creates Conflict
*
* Tests that when two clients modify the same entity concurrently,
* the second client to sync receives a CONFLICT_CONCURRENT error
* and handles it via LWW resolution.
*
* Actions:
* 1. Client A creates Task, syncs
* 2. Client B syncs (downloads task)
* 3. Both clients go offline (don't sync)
* 4. Client A modifies task title
* 5. Client B modifies same task title (different value)
* 6. Client A syncs first (succeeds)
* 7. Client B syncs (gets CONFLICT_CONCURRENT, auto-resolves via LWW)
* 8. Verify both clients converge to same state
*/
test('Concurrent modification triggers LWW conflict resolution', async ({
browser,
baseURL,
testRunId,
}) => {
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);
// Setup 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);
// 1. Client A creates task
const taskName = `Conflict-Test-${testRunId}`;
await clientA.workView.addTask(taskName);
await clientA.sync.syncAndWait();
// 2. Client B downloads the task
await clientB.sync.syncAndWait();
await waitForTask(clientB.page, taskName);
// 3-5. Both clients modify the task while "offline"
// Client A changes title first using inline editing (dblclick)
const taskLocatorA = clientA.page
.locator(`task:not(.ng-animating):has-text("${taskName}")`)
.first();
// Wait for task to be stable before double-clicking
await taskLocatorA.waitFor({ state: 'visible', timeout: 5000 });
await clientA.page.waitForTimeout(500);
await taskLocatorA.dblclick();
const editInputA = clientA.page.locator(
'input.mat-mdc-input-element:focus, textarea:focus',
);
await editInputA.waitFor({ state: 'visible', timeout: 5000 });
await editInputA.fill(`${taskName}-ModifiedByA`);
await editInputA.press('Enter');
// Wait for edit to be saved
await clientA.page.waitForTimeout(500);
// Client B modifies the same task with a different value using inline editing
const taskLocatorB = clientB.page
.locator(`task:not(.ng-animating):has-text("${taskName}")`)
.first();
// Wait for task to be stable before double-clicking
await taskLocatorB.waitFor({ state: 'visible', timeout: 5000 });
await clientB.page.waitForTimeout(500);
await taskLocatorB.dblclick();
const editInputB = clientB.page.locator(
'input.mat-mdc-input-element:focus, textarea:focus',
);
await editInputB.waitFor({ state: 'visible', timeout: 5000 });
await editInputB.fill(`${taskName}-ModifiedByB`);
await editInputB.press('Enter');
// Wait for edit to be saved
await clientB.page.waitForTimeout(500);
// 6. Client A syncs first (succeeds)
await clientA.sync.syncAndWait();
// 7. Client B syncs (gets conflict, should auto-resolve via LWW)
// The newer timestamp wins, so B's change might win or A's depending on timing
await clientB.sync.syncAndWait();
// Give time for any conflict resolution UI and state to settle
await clientB.page.waitForTimeout(2000);
// 8. Final sync to ensure convergence
await clientA.sync.syncAndWait();
await clientB.sync.syncAndWait();
// Additional wait for UI to update after sync
await clientA.page.waitForTimeout(500);
await clientB.page.waitForTimeout(500);
// Verify both clients have converged - they should show the same task title
// The exact title depends on LWW resolution (whichever timestamp was later)
// Use the task title specifically to avoid capturing other text
const finalTaskA = await clientA.page
.locator('task:not(.ng-animating) .task-title')
.first()
.textContent();
const finalTaskB = await clientB.page
.locator('task:not(.ng-animating) .task-title')
.first()
.textContent();
// Both clients should show the same content (convergence)
expect(finalTaskA).toBe(finalTaskB);
console.log(
'[Conflict-Resolution] ✓ Concurrent modification handled via LWW - clients converged',
);
} finally {
if (clientA) await closeClient(clientA);
if (clientB) await closeClient(clientB);
}
});
/**
* Scenario: Sync Recovery After Network Failure
*
* Tests that when sync fails due to network issues, the client
* can recover and successfully sync on retry.
*
* Actions:
* 1. Client creates task
* 2. Client syncs successfully
* 3. Client creates another task
* 4. Simulate network being restored
* 5. Client syncs again (should succeed)
* 6. Verify all tasks are synced
*/
test('Sync recovers after initial connection', async ({
browser,
baseURL,
testRunId,
}) => {
const appUrl = baseURL || 'http://localhost:4242';
let client: SimulatedE2EClient | null = null;
try {
const user = await createTestUser(testRunId);
const syncConfig = getSuperSyncConfig(user);
// Setup client
client = await createSimulatedClient(browser, appUrl, 'Recovery', testRunId);
await client.sync.setupSuperSync(syncConfig);
// 1. Create first task
const task1 = `Recovery-Task1-${testRunId}`;
await client.workView.addTask(task1);
// 2. Sync successfully
await client.sync.syncAndWait();
// 3. Create second task
const task2 = `Recovery-Task2-${testRunId}`;
await client.workView.addTask(task2);
// 4-5. Sync again
await client.sync.syncAndWait();
// 6. Verify both tasks exist
await waitForTask(client.page, task1);
await waitForTask(client.page, task2);
console.log('[Sync-Recovery] ✓ Multiple syncs completed successfully');
} finally {
if (client) await closeClient(client);
}
});
/**
* Scenario: Duplicate Operation Handling (Idempotency)
*
* Tests that retrying the same operation doesn't cause issues.
* This simulates what happens when a sync completes but the client
* doesn't receive the response (network issue) and retries.
*
* Actions:
* 1. Client creates task
* 2. Client syncs
* 3. Client syncs again immediately (same ops might be in queue)
* 4. Verify no duplicate tasks created
*/
test('Duplicate sync attempts are handled gracefully', async ({
browser,
baseURL,
testRunId,
}) => {
const appUrl = baseURL || 'http://localhost:4242';
let client: SimulatedE2EClient | null = null;
try {
const user = await createTestUser(testRunId);
const syncConfig = getSuperSyncConfig(user);
// Setup client
client = await createSimulatedClient(browser, appUrl, 'Idempotent', testRunId);
await client.sync.setupSuperSync(syncConfig);
// 1. Create task
const taskName = `Idempotent-${testRunId}`;
await client.workView.addTask(taskName);
// 2-3. Sync multiple times rapidly
await client.sync.syncAndWait();
await client.sync.syncAndWait();
await client.sync.syncAndWait();
// 4. Verify exactly one task with this name exists
const matchingTasks = client.page.locator(`task:has-text("${taskName}")`);
const count = await matchingTasks.count();
expect(count).toBe(1);
console.log(
'[Idempotency] ✓ Multiple sync attempts handled correctly - no duplicates',
);
} finally {
if (client) await closeClient(client);
}
});
/**
* Scenario: Three-Client Convergence
*
* Tests that three clients all modifying different aspects of tasks
* eventually converge to the same state.
*
* Actions:
* 1. Client A creates Task 1
* 2. All clients sync
* 3. Client A creates Task 2
* 4. Client B marks Task 1 as done
* 5. Client C adds a note to Task 1 (if supported, else skipped)
* 6. All clients sync
* 7. Verify all clients have same state
*/
test('Three clients converge to same state', async ({
browser,
baseURL,
testRunId,
}) => {
const appUrl = baseURL || 'http://localhost:4242';
let clientA: SimulatedE2EClient | null = null;
let clientB: SimulatedE2EClient | null = null;
let clientC: SimulatedE2EClient | null = null;
try {
const user = await createTestUser(testRunId);
const syncConfig = getSuperSyncConfig(user);
// Setup all three 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);
clientC = await createSimulatedClient(browser, appUrl, 'C', testRunId);
await clientC.sync.setupSuperSync(syncConfig);
// 1. Client A creates Task 1
const task1 = `ThreeClient-Task1-${testRunId}`;
await clientA.workView.addTask(task1);
await clientA.sync.syncAndWait();
// 2. All clients sync to get Task 1
await clientB.sync.syncAndWait();
await clientC.sync.syncAndWait();
await waitForTask(clientB.page, task1);
await waitForTask(clientC.page, task1);
// 3. Client A creates Task 2
const task2 = `ThreeClient-Task2-${testRunId}`;
await clientA.workView.addTask(task2);
// 4. Client B marks Task 1 as done
const taskLocatorB = clientB.page
.locator(`task:not(.ng-animating):has-text("${task1}")`)
.first();
await taskLocatorB.hover();
await taskLocatorB.locator('done-toggle').click();
await expect(taskLocatorB).toHaveClass(/isDone/);
// 5. Client C just syncs (no modification, but will pick up B's change)
// 6. All clients sync
await clientA.sync.syncAndWait();
await clientB.sync.syncAndWait();
await clientC.sync.syncAndWait();
// Final round to ensure convergence
await clientA.sync.syncAndWait();
await clientB.sync.syncAndWait();
await clientC.sync.syncAndWait();
// 7. Verify all clients have both tasks
// Task 1 is done, so it may be in the "Completed Tasks" collapsible section
// which uses @if and removes elements from the DOM when collapsed.
// Expand it on all clients before checking.
for (const client of [clientA!, clientB!, clientC!]) {
const collapsedDoneSection = client.page.locator(
'collapsible:not(.isExpanded) .collapsible-header:has-text("Completed")',
);
if ((await collapsedDoneSection.count()) > 0) {
await collapsedDoneSection.click();
await client.page.waitForTimeout(500);
}
}
await waitForTask(clientA.page, task1);
await waitForTask(clientA.page, task2);
await waitForTask(clientB.page, task1);
await waitForTask(clientB.page, task2);
await waitForTask(clientC.page, task1);
await waitForTask(clientC.page, task2);
// Verify Task 1 is marked done on all clients
const task1LocatorA = clientA.page
.locator(`task:not(.ng-animating):has-text("${task1}")`)
.first();
const task1LocatorC = clientC.page
.locator(`task:not(.ng-animating):has-text("${task1}")`)
.first();
await expect(task1LocatorA).toHaveClass(/isDone/);
await expect(taskLocatorB).toHaveClass(/isDone/);
await expect(task1LocatorC).toHaveClass(/isDone/);
console.log('[Three-Client] ✓ All three clients converged to same state');
} finally {
if (clientA) await closeClient(clientA);
if (clientB) await closeClient(clientB);
if (clientC) await closeClient(clientC);
}
});
});