mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-20 18:08:55 +00:00
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.
364 lines
12 KiB
TypeScript
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);
|
|
}
|
|
});
|
|
});
|