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.
337 lines
12 KiB
TypeScript
337 lines
12 KiB
TypeScript
import { test, expect } from '../../fixtures/supersync.fixture';
|
|
import {
|
|
createTestUser,
|
|
getSuperSyncConfig,
|
|
createSimulatedClient,
|
|
closeClient,
|
|
waitForTask,
|
|
getTaskElement,
|
|
markTaskDone,
|
|
deleteTask,
|
|
countTasks,
|
|
type SimulatedE2EClient,
|
|
} from '../../utils/supersync-helpers';
|
|
import { expectTaskNotVisible } from '../../utils/supersync-assertions';
|
|
import { waitForAppReady } from '../../utils/waits';
|
|
|
|
/**
|
|
* SuperSync Advanced E2E Tests
|
|
*
|
|
* Covers more complex scenarios like large datasets, advanced conflicts,
|
|
* and error conditions.
|
|
*/
|
|
|
|
test.describe('@supersync SuperSync Advanced', () => {
|
|
/**
|
|
* Scenario: Large Dataset Sync
|
|
*
|
|
* Tests performance and reliability when syncing a larger number of items.
|
|
*
|
|
* Actions:
|
|
* 1. Client A creates 50 tasks
|
|
* 2. Client A syncs (upload)
|
|
* 3. Client B syncs (download)
|
|
* 4. Verify all 50 tasks exist on Client B
|
|
*/
|
|
test('Large dataset sync (50 tasks)', async ({
|
|
browser,
|
|
baseURL,
|
|
testRunId,
|
|
}, testInfo) => {
|
|
// Increase timeout for this test as creating/syncing 50 tasks takes time
|
|
testInfo.setTimeout(180000);
|
|
let clientA: SimulatedE2EClient | null = null;
|
|
let clientB: SimulatedE2EClient | null = null;
|
|
const TASK_COUNT = 50;
|
|
|
|
try {
|
|
const user = await createTestUser(testRunId);
|
|
const syncConfig = getSuperSyncConfig(user);
|
|
|
|
// Setup Client A
|
|
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
|
|
await clientA.sync.setupSuperSync(syncConfig);
|
|
|
|
// Create tasks in batch
|
|
console.log(`[LargeData] Creating ${TASK_COUNT} tasks on Client A...`);
|
|
|
|
// We can optimize creation by not waiting for every single task to appear
|
|
// if the UI allows rapid entry, but the helper might need adjustment.
|
|
// For now, let's just loop.
|
|
for (let i = 1; i <= TASK_COUNT; i++) {
|
|
await clientA.workView.addTask(`Task-${testRunId}-${i}`);
|
|
// Small pause every 10 tasks to let UI breathe/save
|
|
if (i % 10 === 0) await clientA.page.waitForTimeout(200);
|
|
}
|
|
|
|
console.log(`[LargeData] Creation complete. Verifying local count...`);
|
|
const countA = await countTasks(clientA.page);
|
|
// Expect at least TASK_COUNT (there might be default tasks? usually not in fresh profile)
|
|
expect(countA).toBeGreaterThanOrEqual(TASK_COUNT);
|
|
|
|
// Sync A
|
|
console.log(`[LargeData] Syncing Client A...`);
|
|
await clientA.sync.syncAndWait();
|
|
|
|
// Setup Client B
|
|
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
|
|
await clientB.sync.setupSuperSync(syncConfig);
|
|
|
|
// Sync B
|
|
console.log(`[LargeData] Syncing Client B...`);
|
|
await clientB.sync.syncAndWait();
|
|
|
|
// Verify B has all tasks
|
|
console.log(`[LargeData] Verifying Client B count...`);
|
|
|
|
// Wait for first task to appear (ensures sync operations have rendered)
|
|
await waitForTask(clientB.page, `Task-${testRunId}-1`);
|
|
|
|
// If the page needs a refresh to show all synced tasks, reload and wait
|
|
// This handles bulk dispatch UI update timing issues
|
|
// Use goto instead of reload - more reliable with service workers
|
|
await clientB.page.goto(clientB.page.url(), {
|
|
waitUntil: 'domcontentloaded',
|
|
timeout: 30000,
|
|
});
|
|
await waitForAppReady(clientB.page);
|
|
await waitForTask(clientB.page, `Task-${testRunId}-1`);
|
|
|
|
const countB = await countTasks(clientB.page);
|
|
expect(countB).toBe(countA);
|
|
|
|
// Spot check first and last
|
|
await waitForTask(clientB.page, `Task-${testRunId}-1`);
|
|
await waitForTask(clientB.page, `Task-${testRunId}-${TASK_COUNT}`);
|
|
|
|
console.log(`[LargeData] ✓ Success: Synced ${countB} tasks.`);
|
|
} finally {
|
|
if (clientA) await closeClient(clientA);
|
|
if (clientB) await closeClient(clientB);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Scenario: Tag Management
|
|
*
|
|
* Verify that adding or removing tags on a task syncs across clients.
|
|
* This tests the relational data sync (Task-Tag relationships).
|
|
*
|
|
* Actions:
|
|
* 1. Client A creates "Task with Tag" and adds "Tag A"
|
|
* 2. Client A syncs (upload)
|
|
* 3. Client B syncs (download)
|
|
* 4. Verify Client B sees "Tag A" on the task
|
|
* 5. Client B removes "Tag A" from the task
|
|
* 6. Client B syncs (upload)
|
|
* 7. Client A syncs (download)
|
|
* 8. Verify Client A sees "Tag A" removed
|
|
*/
|
|
test('Tag Management: Add/Remove tags syncs correctly', async ({
|
|
browser,
|
|
baseURL,
|
|
testRunId,
|
|
}) => {
|
|
let clientA: SimulatedE2EClient | null = null;
|
|
let clientB: SimulatedE2EClient | null = null;
|
|
const taskName = `Task-Tag-${testRunId}`;
|
|
const tagName = `TagA-${testRunId}`;
|
|
|
|
try {
|
|
const user = await createTestUser(testRunId);
|
|
const syncConfig = getSuperSyncConfig(user);
|
|
|
|
// Setup Client A
|
|
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
|
|
await clientA.sync.setupSuperSync(syncConfig);
|
|
|
|
// Client A creates task with tag
|
|
// We type "#TagName" to trigger tag creation
|
|
// skipClose=true because we expect a dialog (create tag) which blocks closing
|
|
await clientA.workView.addTask(`${taskName} #${tagName}`, true);
|
|
|
|
// Handle tag creation confirmation dialog if it appears
|
|
const confirmBtn = clientA.page.locator('button[e2e="confirmBtn"]');
|
|
if (await confirmBtn.isVisible({ timeout: 5000 }).catch(() => false)) {
|
|
await confirmBtn.click();
|
|
}
|
|
|
|
// Close the add task bar manually if it's still open
|
|
if (await clientA.workView.backdrop.isVisible().catch(() => false)) {
|
|
await clientA.workView.backdrop.click();
|
|
}
|
|
|
|
// Wait for task
|
|
await waitForTask(clientA.page, taskName);
|
|
|
|
// Verify tag is present on Client A
|
|
await expect(clientA.page.locator(`task tag:has-text("${tagName}")`)).toBeVisible();
|
|
|
|
// Sync A
|
|
await clientA.sync.syncAndWait();
|
|
|
|
// Setup Client B
|
|
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
|
|
await clientB.sync.setupSuperSync(syncConfig);
|
|
|
|
// Sync B (Download)
|
|
await clientB.sync.syncAndWait();
|
|
|
|
// Verify B has task and tag
|
|
await waitForTask(clientB.page, taskName);
|
|
await expect(clientB.page.locator(`task tag:has-text("${tagName}")`)).toBeVisible();
|
|
|
|
// Client B removes tag
|
|
// Right click task -> Toggle Tags -> Click Tag
|
|
const taskB = getTaskElement(clientB, taskName);
|
|
await taskB.click({ button: 'right' });
|
|
|
|
// Click "Toggle Tags" in context menu (using text match or class if available)
|
|
// Based on i18n: T.F.TASK.CMP.TOGGLE_TAGS -> 'Toggle Tags' (en)
|
|
const toggleTagsItem = clientB.page
|
|
.locator('.mat-mdc-menu-item')
|
|
.filter({ hasText: 'Toggle Tags' });
|
|
await toggleTagsItem.click();
|
|
|
|
// Wait for tag list submenu
|
|
// Exclude nav-link items (which might be "Go to Project" links) to avoid strict mode violation
|
|
const tagItem = clientB.page.locator(
|
|
`.mat-mdc-menu-item:not(.nav-link):has-text("${tagName}")`,
|
|
);
|
|
await tagItem.waitFor({ state: 'visible' });
|
|
await tagItem.click();
|
|
|
|
// Close menu by pressing Escape multiple times (submenu + main menu)
|
|
// First escape closes the submenu, second closes the main context menu
|
|
await clientB.page.keyboard.press('Escape');
|
|
await clientB.page.waitForTimeout(200);
|
|
await clientB.page.keyboard.press('Escape');
|
|
await clientB.page.waitForTimeout(200);
|
|
// If menus are still open, click on the page body to dismiss
|
|
const menuCount = await clientB.page.locator('.mat-mdc-menu-panel').count();
|
|
if (menuCount > 0) {
|
|
// Click on the task list area to dismiss any remaining menus
|
|
await clientB.page.locator('.work-view').click({ position: { x: 10, y: 10 } });
|
|
}
|
|
// Ensure all overlays are closed (backdrops, etc.)
|
|
await clientB.sync.ensureOverlaysClosed();
|
|
|
|
// Verify tag is gone on B
|
|
await expect(
|
|
clientB.page.locator(`task tag:has-text("${tagName}")`),
|
|
).not.toBeVisible();
|
|
|
|
// Wait for the operation to be written to IndexedDB before syncing
|
|
await clientB.page.waitForTimeout(1000);
|
|
|
|
// Sync B (Upload removal)
|
|
await clientB.sync.syncAndWait();
|
|
console.log('[TagTest] Client B synced (uploaded tag removal)');
|
|
|
|
// Sync A (Download removal) — use multiple rounds for convergence
|
|
await clientA.sync.syncAndWait();
|
|
console.log('[TagTest] Client A synced round 1');
|
|
await clientB.sync.syncAndWait();
|
|
await clientA.sync.syncAndWait();
|
|
console.log('[TagTest] Extra convergence sync rounds complete');
|
|
|
|
// Debug: Check what tags are visible on Client A's task
|
|
const tagCount = await clientA.page.locator('task tag').count();
|
|
const tagTexts = await clientA.page.locator('task tag').allTextContents();
|
|
console.log(
|
|
`[TagTest] Client A has ${tagCount} tag(s) on task: [${tagTexts.join(', ')}]`,
|
|
);
|
|
|
|
// Verify tag is gone on A
|
|
await expect(
|
|
clientA.page.locator(`task tag:has-text("${tagName}")`),
|
|
).not.toBeVisible();
|
|
|
|
console.log('[TagTest] ✓ Tag added and removed successfully across clients');
|
|
} finally {
|
|
if (clientA) await closeClient(clientA);
|
|
if (clientB) await closeClient(clientB);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Scenario: Concurrent Delete vs. Update
|
|
*
|
|
* Simulate a conflict where Client A deletes a task while Client B updates it.
|
|
* The LWW system is purely timestamp-based — the newer operation wins regardless
|
|
* of type. Since Client B's update (mark done) happens after Client A's delete,
|
|
* the update wins and the task is preserved.
|
|
*
|
|
* Actions:
|
|
* 1. Client A creates Task, syncs
|
|
* 2. Client B syncs (download task)
|
|
* 3. Concurrent changes (no syncs):
|
|
* - Client A: Deletes the task
|
|
* - Client B: Marks the task as done (update, later timestamp)
|
|
* 4. Client A syncs (delete goes to server)
|
|
* 5. Client B syncs (update conflicts with deletion)
|
|
* 6. Verify final state is consistent (update wins, task visible and done)
|
|
*/
|
|
test('Concurrent Delete vs. Update (Conflict Handling)', async ({
|
|
browser,
|
|
baseURL,
|
|
testRunId,
|
|
}, testInfo) => {
|
|
testInfo.setTimeout(90000);
|
|
let clientA: SimulatedE2EClient | null = null;
|
|
let clientB: SimulatedE2EClient | null = null;
|
|
const taskName = `Task-Conflict-${testRunId}`;
|
|
|
|
try {
|
|
const user = await createTestUser(testRunId);
|
|
const syncConfig = getSuperSyncConfig(user);
|
|
|
|
// Setup 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);
|
|
|
|
// Create initial task on A and sync to B
|
|
await clientA.workView.addTask(taskName);
|
|
await clientA.sync.syncAndWait();
|
|
|
|
await clientB.sync.syncAndWait();
|
|
await waitForTask(clientB.page, taskName);
|
|
|
|
// Client A deletes the task
|
|
await deleteTask(clientA, taskName);
|
|
await expectTaskNotVisible(clientA, taskName);
|
|
|
|
// Client B updates the task (marks as done - reliable operation)
|
|
await markTaskDone(clientB, taskName);
|
|
|
|
// A syncs first (uploads DELETE)
|
|
await clientA.sync.syncAndWait();
|
|
|
|
// B syncs (downloads DELETE, has local UPDATE) -> conflict resolved by LWW
|
|
await clientB.sync.syncAndWait();
|
|
|
|
// Final sync roundtrips to ensure convergence
|
|
await clientA.sync.syncAndWait();
|
|
await clientB.sync.syncAndWait();
|
|
await clientA.sync.syncAndWait();
|
|
|
|
// LWW: Update wins (later timestamp) — task should be visible and done on both
|
|
const taskOnA = clientA.page.locator(`task:has-text("${taskName}")`);
|
|
const taskOnB = clientB.page.locator(`task:has-text("${taskName}")`);
|
|
await expect(taskOnA).toBeVisible({ timeout: 5000 });
|
|
await expect(taskOnA).toHaveClass(/isDone/, { timeout: 5000 });
|
|
await expect(taskOnB).toBeVisible({ timeout: 5000 });
|
|
await expect(taskOnB).toHaveClass(/isDone/, { timeout: 5000 });
|
|
|
|
console.log(
|
|
'[ConflictTest] ✓ Concurrent Delete/Update resolved: update wins (later timestamp)',
|
|
);
|
|
} finally {
|
|
if (clientA) await closeClient(clientA);
|
|
if (clientB) await closeClient(clientB);
|
|
}
|
|
});
|
|
});
|