diff --git a/e2e/fixtures/supersync.fixture.ts b/e2e/fixtures/supersync.fixture.ts new file mode 100644 index 000000000..9d505f097 --- /dev/null +++ b/e2e/fixtures/supersync.fixture.ts @@ -0,0 +1,140 @@ +import { test as base } from '@playwright/test'; +import { + isServerHealthy, + generateTestRunId, + type SimulatedE2EClient, +} from '../utils/supersync-helpers'; + +/** + * Extended test fixture for SuperSync E2E tests. + * + * Provides: + * - Automatic server health check (skips tests if server unavailable) + * - Unique testRunId per test for data isolation + * - Client tracking for automatic cleanup + * + * Usage: + * ```typescript + * import { test, expect } from '../../fixtures/supersync.fixture'; + * + * test.describe('@supersync My Tests', () => { + * test('should sync', async ({ browser, baseURL, testRunId }) => { + * // testRunId is automatically generated and unique per test + * }); + * }); + * ``` + */ + +export interface SuperSyncFixtures { + /** Unique test run ID for data isolation (e.g., "1704067200000-0") */ + testRunId: string; + /** Whether the SuperSync server is healthy and available */ + serverHealthy: boolean; +} + +// Cache server health check result per worker to avoid repeated checks +let serverHealthyCache: boolean | null = null; + +export const test = base.extend({ + /** + * Generate a unique test run ID for this test. + * Used to isolate test data between parallel test runs. + */ + testRunId: async ({}, use, testInfo) => { + const id = generateTestRunId(testInfo.workerIndex); + await use(id); + }, + + /** + * Check server health once per worker and cache the result. + * Tests are automatically skipped if the server is not healthy. + */ + serverHealthy: async ({}, use, testInfo) => { + // Only check once per worker + if (serverHealthyCache === null) { + serverHealthyCache = await isServerHealthy(); + if (!serverHealthyCache) { + console.warn( + 'SuperSync server not healthy at http://localhost:1901 - skipping tests', + ); + } + } + + // Skip the test if server is not healthy + testInfo.skip(!serverHealthyCache, 'SuperSync server not running'); + + await use(serverHealthyCache); + }, +}); + +/** + * Helper to create a describe block that auto-checks server health. + * Use this instead of manually adding beforeEach health checks. + * + * @param title - Test suite title (will be prefixed with @supersync) + * @param fn - Test suite function + * + * @example + * ```typescript + * supersyncDescribe('Basic Sync', () => { + * test('should create and sync task', async ({ browser, baseURL, testRunId }) => { + * // Server health already checked, testRunId ready + * }); + * }); + * ``` + */ +export const supersyncDescribe = (title: string, fn: () => void): void => { + test.describe(`@supersync ${title}`, () => { + // The serverHealthy fixture will auto-skip if server unavailable + test.beforeEach(async ({ serverHealthy }) => { + // This line ensures the fixture is evaluated and test is skipped if needed + void serverHealthy; + }); + fn(); + }); +}; + +/** + * Track clients for automatic cleanup in afterEach. + * Use with `trackClient` and `cleanupTrackedClients`. + */ +const trackedClients = new Map(); + +/** + * Track a client for automatic cleanup. + * Call this when creating clients so they're cleaned up even if the test fails. + * + * @param testId - A unique ID for this test (use testInfo.testId) + * @param client - The client to track + */ +export const trackClient = (testId: string, client: SimulatedE2EClient): void => { + if (!trackedClients.has(testId)) { + trackedClients.set(testId, []); + } + trackedClients.get(testId)!.push(client); +}; + +/** + * Clean up all tracked clients for a test. + * Call this in afterEach or finally blocks. + * + * @param testId - The test ID used when tracking clients + */ +export const cleanupTrackedClients = async (testId: string): Promise => { + const clients = trackedClients.get(testId); + if (clients) { + for (const client of clients) { + try { + if (!client.page.isClosed()) { + await client.context.close(); + } + } catch { + // Ignore cleanup errors + } + } + trackedClients.delete(testId); + } +}; + +// Re-export expect for convenience +export { expect } from '@playwright/test'; diff --git a/e2e/pages/supersync.page.ts b/e2e/pages/supersync.page.ts index 6a3d8432e..85632eb04 100644 --- a/e2e/pages/supersync.page.ts +++ b/e2e/pages/supersync.page.ts @@ -80,6 +80,11 @@ export class SuperSyncPage extends BasePage { await this.syncBtn.waitFor({ state: 'visible', timeout: syncBtnTimeout }); } + // Wait for network to be idle - helps ensure Angular has finished loading + await this.page.waitForLoadState('networkidle').catch(() => { + console.log('[SuperSyncPage] Network idle timeout (non-fatal)'); + }); + // Retry loop for opening the sync settings dialog via right-click // Sometimes the right-click doesn't register, especially under load let dialogOpened = false; @@ -97,8 +102,17 @@ export class SuperSyncPage extends BasePage { await this.syncBtn.click({ button: 'right' }); try { - // Wait for dialog to be fully loaded - use shorter timeout to retry faster + // Wait for dialog container first + const dialogContainer = this.page.locator('mat-dialog-container'); + await dialogContainer.waitFor({ state: 'visible', timeout: 5000 }); + + // Wait for the formly form to be rendered inside the dialog + // Under heavy load, the dialog may appear but form rendering is delayed await this.providerSelect.waitFor({ state: 'visible', timeout: 5000 }); + + // Ensure the element is actually attached and stable + await expect(this.providerSelect).toBeAttached({ timeout: 2000 }); + dialogOpened = true; console.log('[SuperSyncPage] Sync settings dialog opened successfully'); break; @@ -116,69 +130,69 @@ export class SuperSyncPage extends BasePage { // Last attempt with longer timeout console.log('[SuperSyncPage] Final attempt to open sync settings dialog...'); await this.syncBtn.click({ button: 'right', force: true }); + const dialogContainer = this.page.locator('mat-dialog-container'); + await dialogContainer.waitFor({ state: 'visible', timeout: 10000 }); await this.providerSelect.waitFor({ state: 'visible', timeout: 10000 }); + await expect(this.providerSelect).toBeAttached({ timeout: 5000 }); } // Additional wait for the element to be stable/interactive await this.page.waitForTimeout(300); - // Retry loop for opening the dropdown - let dropdownOpen = false; + // Retry loop for opening the dropdown - use toPass() for more robust retries const superSyncOption = this.page .locator('mat-option') .filter({ hasText: 'SuperSync' }); + const dropdownPanel = this.page.locator('.mat-mdc-select-panel'); + const dropdownBackdrop = this.page.locator( + '.cdk-overlay-backdrop.cdk-overlay-transparent-backdrop', + ); - for (let i = 0; i < 5; i++) { - // Check if page is still open before each attempt - if (this.page.isClosed()) { - throw new Error('Page was closed during SuperSync setup'); - } - - try { - // Use shorter timeout for click to fail fast and retry - await this.providerSelect.click({ timeout: 5000 }); - // Wait for dropdown animation - await this.page.waitForTimeout(500); - - if (await superSyncOption.isVisible()) { - dropdownOpen = true; - break; - } else { - console.log(`[SuperSyncPage] Dropdown not open attempt ${i + 1}, retrying...`); - // If not visible, close any partial dropdown and wait before retry - if (!this.page.isClosed()) { - await this.page.keyboard.press('Escape'); - await this.page.waitForTimeout(300); - } - } - } catch (e) { - // Check if page is closed before trying to recover - if (this.page.isClosed()) { - throw new Error('Page was closed during SuperSync setup'); - } - console.log(`[SuperSyncPage] Error opening dropdown attempt ${i + 1}: ${e}`); - // On click timeout, try to dismiss any blocking overlays - await this.page.keyboard.press('Escape'); - await this.page.waitForTimeout(500); - } - } - - if (!dropdownOpen) { + await expect(async () => { // Check if page is still open if (this.page.isClosed()) { throw new Error('Page was closed during SuperSync setup'); } - // Last ditch effort - force click - console.log('[SuperSyncPage] Last attempt - force clicking provider select'); - await this.providerSelect.click({ force: true, timeout: 10000 }); - await this.page.waitForTimeout(500); - } - await superSyncOption.waitFor({ state: 'visible', timeout: 10000 }); - await superSyncOption.click(); + // If a dropdown backdrop is showing, dismiss it first + if (await dropdownBackdrop.isVisible()) { + console.log('[SuperSyncPage] Dismissing existing dropdown overlay...'); + await this.page.keyboard.press('Escape'); + await dropdownBackdrop + .waitFor({ state: 'hidden', timeout: 2000 }) + .catch(() => {}); + await this.page.waitForTimeout(200); + } - // Wait for the dropdown overlay to close - await this.page.locator('.mat-mdc-select-panel').waitFor({ state: 'detached' }); + // Ensure the select is still attached (may have been re-rendered) + await expect(this.providerSelect).toBeAttached({ timeout: 2000 }); + + // Click to open dropdown - use force to bypass any lingering overlays + await this.providerSelect.click({ timeout: 3000, force: true }); + + // Wait for dropdown panel to appear + await dropdownPanel.waitFor({ state: 'visible', timeout: 3000 }); + + // Verify the option is visible + await expect(superSyncOption).toBeVisible({ timeout: 2000 }); + }).toPass({ + timeout: 30000, + intervals: [500, 1000, 1500, 2000, 2500, 3000], + }); + + // Click the SuperSync option with retry to handle dropdown closing issues + await expect(async () => { + // Check if option is visible - if not, dropdown may have closed unexpectedly + if (await superSyncOption.isVisible()) { + await superSyncOption.click({ timeout: 2000 }); + } + + // Wait for dropdown panel to close + await dropdownPanel.waitFor({ state: 'detached', timeout: 3000 }); + }).toPass({ + timeout: 15000, + intervals: [500, 1000, 1500, 2000], + }); // Fill Access Token first (it's outside the collapsible) await this.accessTokenInput.waitFor({ state: 'visible' }); @@ -327,8 +341,15 @@ export class SuperSyncPage extends BasePage { // Check if fresh client confirmation dialog appeared if (await this.freshClientDialog.isVisible()) { console.log('[SuperSyncPage] Fresh client dialog detected, confirming...'); - await this.freshClientConfirmBtn.click(); - await this.page.waitForTimeout(500); + try { + await this.freshClientConfirmBtn.click({ timeout: 2000 }); + await this.page.waitForTimeout(500); + } catch (e) { + // Dialog may have auto-closed or been detached - that's OK + console.log( + '[SuperSyncPage] Fresh client dialog closed before click completed', + ); + } stableCount = 0; continue; } @@ -490,10 +511,16 @@ export class SuperSyncPage extends BasePage { ); await newPasswordInput.fill(newPassword); + await newPasswordInput.blur(); // Trigger ngModel update await confirmPasswordInput.fill(newPassword); + await confirmPasswordInput.blur(); // Trigger ngModel update - // Click the confirm button + // Wait for Angular to process form validation + await this.page.waitForTimeout(200); + + // Click the confirm button - wait for it to be enabled first const confirmBtn = changePasswordDialog.locator('button[color="warn"]'); + await expect(confirmBtn).toBeEnabled({ timeout: 5000 }); await confirmBtn.click(); // Wait for the dialog to close (password change complete) diff --git a/e2e/tests/sync/supersync-account-deletion.spec.ts b/e2e/tests/sync/supersync-account-deletion.spec.ts index 551692e65..fc70ad25b 100644 --- a/e2e/tests/sync/supersync-account-deletion.spec.ts +++ b/e2e/tests/sync/supersync-account-deletion.spec.ts @@ -1,4 +1,4 @@ -import { test as base, expect } from '@playwright/test'; +import { test, expect } from '../../fixtures/supersync.fixture'; import { createTestUser, getSuperSyncConfig, @@ -6,7 +6,6 @@ import { closeClient, deleteTestUser, waitForTask, - isServerHealthy, type SimulatedE2EClient, } from '../../utils/supersync-helpers'; @@ -24,25 +23,7 @@ import { * Run with: npm run e2e:supersync:file e2e/tests/sync/supersync-account-deletion.spec.ts */ -const generateTestRunId = (workerIndex: number): string => { - return `${Date.now()}-${workerIndex}`; -}; - -base.describe('@supersync SuperSync Account Deletion', () => { - let serverHealthy: boolean | null = null; - - base.beforeEach(async ({}, testInfo) => { - if (serverHealthy === null) { - serverHealthy = await isServerHealthy(); - if (!serverHealthy) { - console.warn( - 'SuperSync server not healthy at http://localhost:1901 - skipping tests', - ); - } - } - testInfo.skip(!serverHealthy, 'SuperSync server not running'); - }); - +test.describe('@supersync SuperSync Account Deletion', () => { /** * Scenario: Client detects auth error after account deletion * @@ -55,58 +36,57 @@ base.describe('@supersync SuperSync Account Deletion', () => { * 3. Client tries to sync again * 4. Verify client shows error (snackbar or error icon) */ - base( - 'Client shows error after account deletion', - async ({ browser, baseURL }, testInfo) => { - testInfo.setTimeout(90000); - const testRunId = generateTestRunId(testInfo.workerIndex); - const appUrl = baseURL || 'http://localhost:4242'; - let client: SimulatedE2EClient | null = null; + test('Client shows error after account deletion', async ({ + browser, + baseURL, + testRunId, + }) => { + const appUrl = baseURL || 'http://localhost:4242'; + let client: SimulatedE2EClient | null = null; - try { - // 1. Create user and set up client - const user = await createTestUser(testRunId); - const syncConfig = getSuperSyncConfig(user); - console.log(`[Account-Deletion] Created user ${user.userId}`); + try { + // 1. Create user and set up client + const user = await createTestUser(testRunId); + const syncConfig = getSuperSyncConfig(user); + console.log(`[Account-Deletion] Created user ${user.userId}`); - client = await createSimulatedClient(browser, appUrl, 'A', testRunId); - await client.sync.setupSuperSync(syncConfig); + client = await createSimulatedClient(browser, appUrl, 'A', testRunId); + await client.sync.setupSuperSync(syncConfig); - // Create a task and sync to verify everything works - const taskName = `Pre-Delete-Task-${testRunId}`; - await client.workView.addTask(taskName); - await client.sync.syncAndWait(); - await waitForTask(client.page, taskName); - console.log('[Account-Deletion] ✓ Initial sync successful'); + // Create a task and sync to verify everything works + const taskName = `Pre-Delete-Task-${testRunId}`; + await client.workView.addTask(taskName); + await client.sync.syncAndWait(); + await waitForTask(client.page, taskName); + console.log('[Account-Deletion] ✓ Initial sync successful'); - // 2. Delete the user account on the server - await deleteTestUser(user.userId); - console.log(`[Account-Deletion] ✓ Deleted user ${user.userId}`); + // 2. Delete the user account on the server + await deleteTestUser(user.userId); + console.log(`[Account-Deletion] ✓ Deleted user ${user.userId}`); - // 3. Try to sync again - should fail with auth error - await client.sync.triggerSync(); + // 3. Try to sync again - should fail with auth error + await client.sync.triggerSync(); - // 4. Wait for error indicator to appear - // The sync button should show error state (sync_problem icon), - // or a snackbar with error message should appear - const errorIndicator = client.page.locator( - // Error snackbar with any of these indicators - 'simple-snack-bar:has-text("Configure"), ' + - 'simple-snack-bar:has-text("error"), ' + - 'simple-snack-bar:has-text("401"), ' + - 'simple-snack-bar:has-text("403"), ' + - 'simple-snack-bar:has-text("Authentication"), ' + - // Or sync button showing error icon - '.sync-btn mat-icon:has-text("sync_problem")', - ); + // 4. Wait for error indicator to appear + // The sync button should show error state (sync_problem icon), + // or a snackbar with error message should appear + const errorIndicator = client.page.locator( + // Error snackbar with any of these indicators + 'simple-snack-bar:has-text("Configure"), ' + + 'simple-snack-bar:has-text("error"), ' + + 'simple-snack-bar:has-text("401"), ' + + 'simple-snack-bar:has-text("403"), ' + + 'simple-snack-bar:has-text("Authentication"), ' + + // Or sync button showing error icon + '.sync-btn mat-icon:has-text("sync_problem")', + ); - await expect(errorIndicator.first()).toBeVisible({ timeout: 20000 }); - console.log('[Account-Deletion] ✓ Client detected auth failure'); - } finally { - if (client) await closeClient(client); - } - }, - ); + await expect(errorIndicator.first()).toBeVisible({ timeout: 20000 }); + console.log('[Account-Deletion] ✓ Client detected auth failure'); + } finally { + if (client) await closeClient(client); + } + }); /** * Scenario: Client can reconfigure after account deletion and re-registration @@ -121,70 +101,69 @@ base.describe('@supersync SuperSync Account Deletion', () => { * 4. Reconfigure client with user B credentials * 5. Verify sync works with new account */ - base( - 'Client can reconfigure with new account after deletion', - async ({ browser, baseURL }, testInfo) => { - testInfo.setTimeout(120000); - const testRunId = generateTestRunId(testInfo.workerIndex); - const appUrl = baseURL || 'http://localhost:4242'; - let client: SimulatedE2EClient | null = null; + test('Client can reconfigure with new account after deletion', async ({ + browser, + baseURL, + testRunId, + }) => { + const appUrl = baseURL || 'http://localhost:4242'; + let client: SimulatedE2EClient | null = null; - try { - // 1. Create first user and sync - const userA = await createTestUser(`${testRunId}-A`); - const configA = getSuperSyncConfig(userA); - console.log(`[Reconfigure] Created user A: ${userA.userId}`); + try { + // 1. Create first user and sync + const userA = await createTestUser(`${testRunId}-A`); + const configA = getSuperSyncConfig(userA); + console.log(`[Reconfigure] Created user A: ${userA.userId}`); - client = await createSimulatedClient(browser, appUrl, 'Client', testRunId); - await client.sync.setupSuperSync(configA); + client = await createSimulatedClient(browser, appUrl, 'Client', testRunId); + await client.sync.setupSuperSync(configA); - const task1 = `UserA-Task-${testRunId}`; - await client.workView.addTask(task1); - await client.sync.syncAndWait(); - console.log('[Reconfigure] ✓ User A sync successful'); + const task1 = `UserA-Task-${testRunId}`; + await client.workView.addTask(task1); + await client.sync.syncAndWait(); + console.log('[Reconfigure] ✓ User A sync successful'); - // 2. Delete user A account - await deleteTestUser(userA.userId); - console.log('[Reconfigure] ✓ Deleted user A'); + // 2. Delete user A account + await deleteTestUser(userA.userId); + console.log('[Reconfigure] ✓ Deleted user A'); - // 3. Create user B (new account) - const userB = await createTestUser(`${testRunId}-B`); - const configB = getSuperSyncConfig(userB); - console.log(`[Reconfigure] ✓ Created user B: ${userB.userId}`); + // 3. Create user B (new account) + const userB = await createTestUser(`${testRunId}-B`); + const configB = getSuperSyncConfig(userB); + console.log(`[Reconfigure] ✓ Created user B: ${userB.userId}`); - // 4. Reconfigure client with user B credentials - // Open sync settings via right-click - await client.sync.syncBtn.click({ button: 'right' }); - await client.sync.providerSelect.waitFor({ state: 'visible', timeout: 10000 }); + // 4. Reconfigure client with user B credentials + // Open sync settings via right-click + await client.sync.syncBtn.click({ button: 'right' }); + await client.sync.providerSelect.waitFor({ state: 'visible', timeout: 10000 }); - // Update access token - await client.sync.accessTokenInput.clear(); - await client.sync.accessTokenInput.fill(configB.accessToken); + // Update access token + await client.sync.accessTokenInput.clear(); + await client.sync.accessTokenInput.fill(configB.accessToken); - // Save configuration - await client.sync.saveBtn.click(); - await client.page - .locator('mat-dialog-container') - .waitFor({ state: 'detached', timeout: 5000 }) - .catch(() => {}); + // Save configuration + await client.sync.saveBtn.click(); + await client.page + .locator('mat-dialog-container') + .waitFor({ state: 'detached', timeout: 5000 }) + .catch(() => {}); - // Wait for any dialogs to settle - await client.page.waitForTimeout(1000); + // Wait for any dialogs to settle + await client.page.waitForTimeout(1000); - // 5. Sync and verify it works with user B - await client.sync.syncAndWait(); + // 5. Sync and verify it works with user B + await client.sync.syncAndWait(); - // Create a new task with user B - const task2 = `UserB-Task-${testRunId}`; - await client.workView.addTask(task2); - await client.sync.syncAndWait(); + // Create a new task with user B + const task2 = `UserB-Task-${testRunId}`; + await client.workView.addTask(task2); + await client.sync.syncAndWait(); - // Verify task exists - await waitForTask(client.page, task2); - console.log('[Reconfigure] ✓ User B sync successful - reconfiguration complete'); - } finally { - if (client) await closeClient(client); - } - }, - ); + // Verify task exists + await waitForTask(client.page, task2); + console.log('[Reconfigure] ✓ User B sync successful - reconfiguration complete'); + } finally { + if (client) await closeClient(client); + } + }); }); diff --git a/e2e/tests/sync/supersync-advanced-edge-cases.spec.ts b/e2e/tests/sync/supersync-advanced-edge-cases.spec.ts index d91554c98..073497a5f 100644 --- a/e2e/tests/sync/supersync-advanced-edge-cases.spec.ts +++ b/e2e/tests/sync/supersync-advanced-edge-cases.spec.ts @@ -1,11 +1,10 @@ -import { test as base, expect } from '@playwright/test'; +import { test, expect } from '../../fixtures/supersync.fixture'; import { createTestUser, getSuperSyncConfig, createSimulatedClient, closeClient, waitForTask, - isServerHealthy, type SimulatedE2EClient, } from '../../utils/supersync-helpers'; @@ -21,35 +20,14 @@ import { * as E2E tests for these scenarios are too fragile due to UI timing issues. */ -const generateTestRunId = (workerIndex: number): string => { - return `${Date.now()}-${workerIndex}`; -}; - -base.describe('@supersync SuperSync Advanced Edge Cases', () => { - let serverHealthy: boolean | null = null; - - base.beforeEach(async ({}, testInfo) => { - if (serverHealthy === null) { - serverHealthy = await isServerHealthy(); - if (!serverHealthy) { - console.warn( - 'SuperSync server not healthy at http://localhost:1901 - skipping tests', - ); - } - } - testInfo.skip(!serverHealthy, 'SuperSync server not running'); - }); - +test.describe('@supersync SuperSync Advanced Edge Cases', () => { /** * Bulk Operations: Creating many tasks at once * * Verifies that creating multiple tasks in quick succession * syncs correctly without data loss. */ - base('Bulk task creation syncs correctly', async ({ browser, baseURL }, testInfo) => { - testInfo.setTimeout(90000); - const testRunId = generateTestRunId(testInfo.workerIndex); - const appUrl = baseURL || 'http://localhost:4242'; + test('Bulk task creation syncs correctly', async ({ browser, baseURL, testRunId }) => { let clientA: SimulatedE2EClient | null = null; let clientB: SimulatedE2EClient | null = null; @@ -57,10 +35,10 @@ base.describe('@supersync SuperSync Advanced Edge Cases', () => { const user = await createTestUser(testRunId); const syncConfig = getSuperSyncConfig(user); - clientA = await createSimulatedClient(browser, appUrl, 'A', testRunId); + clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); await clientA.sync.setupSuperSync(syncConfig); - clientB = await createSimulatedClient(browser, appUrl, 'B', testRunId); + clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); await clientB.sync.setupSuperSync(syncConfig); // Create 10 tasks in rapid succession @@ -99,86 +77,80 @@ base.describe('@supersync SuperSync Advanced Edge Cases', () => { * Simulates a client that was offline for a period while * other clients made many changes, then reconnects. */ - base( - 'Stale client reconnection after many changes', - async ({ browser, baseURL }, testInfo) => { - testInfo.setTimeout(120000); - const testRunId = generateTestRunId(testInfo.workerIndex); - const appUrl = baseURL || 'http://localhost:4242'; - let clientA: SimulatedE2EClient | null = null; - let clientB: SimulatedE2EClient | null = null; + test('Stale client reconnection after many changes', async ({ + browser, + baseURL, + testRunId, + }) => { + let clientA: SimulatedE2EClient | null = null; + let clientB: SimulatedE2EClient | null = null; - try { - const user = await createTestUser(testRunId); - const syncConfig = getSuperSyncConfig(user); + try { + const user = await createTestUser(testRunId); + const syncConfig = getSuperSyncConfig(user); - // Client A starts syncing - clientA = await createSimulatedClient(browser, appUrl, 'A', testRunId); - await clientA.sync.setupSuperSync(syncConfig); + // Client A starts syncing + clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); + await clientA.sync.setupSuperSync(syncConfig); - // Client B joins initially - clientB = await createSimulatedClient(browser, appUrl, 'B', testRunId); - await clientB.sync.setupSuperSync(syncConfig); + // Client B joins initially + clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); + await clientB.sync.setupSuperSync(syncConfig); - // Initial sync - await clientA.sync.syncAndWait(); - await clientB.sync.syncAndWait(); + // Initial sync + await clientA.sync.syncAndWait(); + await clientB.sync.syncAndWait(); - // Create initial task - const initialTask = `Initial-${testRunId}`; - await clientA.workView.addTask(initialTask); - await clientA.sync.syncAndWait(); - await clientB.sync.syncAndWait(); + // Create initial task + const initialTask = `Initial-${testRunId}`; + await clientA.workView.addTask(initialTask); + await clientA.sync.syncAndWait(); + await clientB.sync.syncAndWait(); - // Verify both have it - await waitForTask(clientA.page, initialTask); - await waitForTask(clientB.page, initialTask); + // Verify both have it + await waitForTask(clientA.page, initialTask); + await waitForTask(clientB.page, initialTask); - // Now Client B goes "offline" (we just don't sync) - // Client A makes many changes - const offlineTask1 = `WhileOffline1-${testRunId}`; - const offlineTask2 = `WhileOffline2-${testRunId}`; - const offlineTask3 = `WhileOffline3-${testRunId}`; + // Now Client B goes "offline" (we just don't sync) + // Client A makes many changes + const offlineTask1 = `WhileOffline1-${testRunId}`; + const offlineTask2 = `WhileOffline2-${testRunId}`; + const offlineTask3 = `WhileOffline3-${testRunId}`; - await clientA.workView.addTask(offlineTask1); - await clientA.sync.syncAndWait(); + await clientA.workView.addTask(offlineTask1); + await clientA.sync.syncAndWait(); - await clientA.workView.addTask(offlineTask2); - await clientA.sync.syncAndWait(); + await clientA.workView.addTask(offlineTask2); + await clientA.sync.syncAndWait(); - await clientA.workView.addTask(offlineTask3); - await clientA.sync.syncAndWait(); + await clientA.workView.addTask(offlineTask3); + await clientA.sync.syncAndWait(); - // Mark initial task as done - const initialTaskLocator = clientA.page.locator( - `task:has-text("${initialTask}")`, - ); - await initialTaskLocator.hover(); - await initialTaskLocator.locator('.task-done-btn').click(); - await clientA.sync.syncAndWait(); + // Mark initial task as done + const initialTaskLocator = clientA.page.locator(`task:has-text("${initialTask}")`); + await initialTaskLocator.hover(); + await initialTaskLocator.locator('.task-done-btn').click(); + await clientA.sync.syncAndWait(); - // Client B "reconnects" (syncs after missing many updates) - await clientB.sync.syncAndWait(); + // Client B "reconnects" (syncs after missing many updates) + await clientB.sync.syncAndWait(); - // Verify B has all the changes - await waitForTask(clientB.page, offlineTask1); - await waitForTask(clientB.page, offlineTask2); - await waitForTask(clientB.page, offlineTask3); + // Verify B has all the changes + await waitForTask(clientB.page, offlineTask1); + await waitForTask(clientB.page, offlineTask2); + await waitForTask(clientB.page, offlineTask3); - // Initial task should be marked as done - // Use more specific locator to target the done task (avoids matching both backlog and done section) - const initialTaskB = clientB.page.locator( - `task.isDone:has-text("${initialTask}")`, - ); - await expect(initialTaskB).toBeVisible(); + // Initial task should be marked as done + // Use more specific locator to target the done task (avoids matching both backlog and done section) + const initialTaskB = clientB.page.locator(`task.isDone:has-text("${initialTask}")`); + await expect(initialTaskB).toBeVisible(); - console.log('[Stale] Stale client reconnected and received all changes'); - } finally { - if (clientA) await closeClient(clientA); - if (clientB) await closeClient(clientB); - } - }, - ); + console.log('[Stale] Stale client reconnected and received all changes'); + } finally { + if (clientA) await closeClient(clientA); + if (clientB) await closeClient(clientB); + } + }); /** * Data Integrity: Special characters in task names @@ -186,52 +158,51 @@ base.describe('@supersync SuperSync Advanced Edge Cases', () => { * Verifies that tasks with quotes and special characters * sync correctly without data corruption. */ - base( - 'Special characters in task names sync correctly', - async ({ browser, baseURL }, testInfo) => { - const testRunId = generateTestRunId(testInfo.workerIndex); - const appUrl = baseURL || 'http://localhost:4242'; - let clientA: SimulatedE2EClient | null = null; - let clientB: SimulatedE2EClient | null = null; + test('Special characters in task names sync correctly', async ({ + browser, + baseURL, + testRunId, + }) => { + let clientA: SimulatedE2EClient | null = null; + let clientB: SimulatedE2EClient | null = null; - try { - const user = await createTestUser(testRunId); - const syncConfig = getSuperSyncConfig(user); + try { + const user = await createTestUser(testRunId); + const syncConfig = getSuperSyncConfig(user); - clientA = await createSimulatedClient(browser, appUrl, 'A', testRunId); - await clientA.sync.setupSuperSync(syncConfig); + clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); + await clientA.sync.setupSuperSync(syncConfig); - clientB = await createSimulatedClient(browser, appUrl, 'B', testRunId); - await clientB.sync.setupSuperSync(syncConfig); + clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); + await clientB.sync.setupSuperSync(syncConfig); - // Create tasks with special characters (avoiding complex unicode that may render differently) - const task1 = `Task-quotes-${testRunId}`; - const task2 = `Task-ampersand-${testRunId}`; - const task3 = `Task-numbers-123-${testRunId}`; + // Create tasks with special characters (avoiding complex unicode that may render differently) + const task1 = `Task-quotes-${testRunId}`; + const task2 = `Task-ampersand-${testRunId}`; + const task3 = `Task-numbers-123-${testRunId}`; - await clientA.workView.addTask(task1); - await clientA.workView.addTask(task2); - await clientA.workView.addTask(task3); + await clientA.workView.addTask(task1); + await clientA.workView.addTask(task2); + await clientA.workView.addTask(task3); - // Sync A -> B - await clientA.sync.syncAndWait(); - await clientB.sync.syncAndWait(); + // Sync A -> B + await clientA.sync.syncAndWait(); + await clientB.sync.syncAndWait(); - // Verify all tasks synced correctly - await waitForTask(clientB.page, task1); - await waitForTask(clientB.page, task2); - await waitForTask(clientB.page, task3); + // Verify all tasks synced correctly + await waitForTask(clientB.page, task1); + await waitForTask(clientB.page, task2); + await waitForTask(clientB.page, task3); - // Verify count - const taskLocator = clientB.page.locator(`task:has-text("${testRunId}")`); - const count = await taskLocator.count(); - expect(count).toBe(3); + // Verify count + const taskLocator = clientB.page.locator(`task:has-text("${testRunId}")`); + const count = await taskLocator.count(); + expect(count).toBe(3); - console.log('[SpecialChars] Special characters synced correctly'); - } finally { - if (clientA) await closeClient(clientA); - if (clientB) await closeClient(clientB); - } - }, - ); + console.log('[SpecialChars] Special characters synced correctly'); + } finally { + if (clientA) await closeClient(clientA); + if (clientB) await closeClient(clientB); + } + }); }); diff --git a/e2e/tests/sync/supersync-advanced.spec.ts b/e2e/tests/sync/supersync-advanced.spec.ts index 4f26a24f5..fed7a58e3 100644 --- a/e2e/tests/sync/supersync-advanced.spec.ts +++ b/e2e/tests/sync/supersync-advanced.spec.ts @@ -1,14 +1,17 @@ -import { test as base, expect } from '@playwright/test'; +import { test, expect } from '../../fixtures/supersync.fixture'; import { createTestUser, getSuperSyncConfig, createSimulatedClient, closeClient, waitForTask, - isServerHealthy, - type SimulatedE2EClient, + getTaskElement, + markTaskDone, + deleteTask, countTasks, + type SimulatedE2EClient, } from '../../utils/supersync-helpers'; +import { expectTaskNotVisible } from '../../utils/supersync-assertions'; /** * SuperSync Advanced E2E Tests @@ -17,25 +20,7 @@ import { * and error conditions. */ -const generateTestRunId = (workerIndex: number): string => { - return `${Date.now()}-${workerIndex}`; -}; - -base.describe('@supersync SuperSync Advanced', () => { - let serverHealthy: boolean | null = null; - - base.beforeEach(async ({}, testInfo) => { - if (serverHealthy === null) { - serverHealthy = await isServerHealthy(); - if (!serverHealthy) { - console.warn( - 'SuperSync server not healthy at http://localhost:1901 - skipping tests', - ); - } - } - testInfo.skip(!serverHealthy, 'SuperSync server not running'); - }); - +test.describe('@supersync SuperSync Advanced', () => { /** * Scenario: Large Dataset Sync * @@ -47,11 +32,13 @@ base.describe('@supersync SuperSync Advanced', () => { * 3. Client B syncs (download) * 4. Verify all 50 tasks exist on Client B */ - base('Large dataset sync (50 tasks)', async ({ browser, baseURL }, testInfo) => { + 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(120000); - - const testRunId = generateTestRunId(testInfo.workerIndex); let clientA: SimulatedE2EClient | null = null; let clientB: SimulatedE2EClient | null = null; const TASK_COUNT = 50; @@ -129,109 +116,105 @@ base.describe('@supersync SuperSync Advanced', () => { * 7. Client A syncs (download) * 8. Verify Client A sees "Tag A" removed */ - base( - 'Tag Management: Add/Remove tags syncs correctly', - async ({ browser, baseURL }, testInfo) => { - const testRunId = generateTestRunId(testInfo.workerIndex); - let clientA: SimulatedE2EClient | null = null; - let clientB: SimulatedE2EClient | null = null; - const taskName = `Task-Tag-${testRunId}`; - const tagName = `TagA-${testRunId}`; + 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); + 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); + // 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); + // 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 = clientB.page.locator(`task:has-text("${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 (press Escape) - await clientB.page.keyboard.press('Escape'); - - // Verify tag is gone on B - await expect( - clientB.page.locator(`task tag:has-text("${tagName}")`), - ).not.toBeVisible(); - - // Sync B (Upload removal) - await clientB.sync.syncAndWait(); - - // Sync A (Download removal) - await clientA.sync.syncAndWait(); - - // 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); + // 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 (press Escape) + await clientB.page.keyboard.press('Escape'); + + // Verify tag is gone on B + await expect( + clientB.page.locator(`task tag:has-text("${tagName}")`), + ).not.toBeVisible(); + + // Sync B (Upload removal) + await clientB.sync.syncAndWait(); + + // Sync A (Download removal) + await clientA.sync.syncAndWait(); + + // 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 @@ -250,81 +233,68 @@ base.describe('@supersync SuperSync Advanced', () => { * 5. Client B syncs (update conflicts with deletion) * 6. Verify final state is consistent (both clients agree) */ - base( - 'Concurrent Delete vs. Update (Conflict Handling)', - async ({ browser, baseURL }, testInfo) => { - testInfo.setTimeout(90000); - const testRunId = generateTestRunId(testInfo.workerIndex); - let clientA: SimulatedE2EClient | null = null; - let clientB: SimulatedE2EClient | null = null; - const taskName = `Task-Conflict-${testRunId}`; + 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); + try { + const user = await createTestUser(testRunId); + const syncConfig = getSuperSyncConfig(user); - // Setup clients - clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); - await clientA.sync.setupSuperSync(syncConfig); + // 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); + 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(); + // 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); + await clientB.sync.syncAndWait(); + await waitForTask(clientB.page, taskName); - // Client A deletes the task - const taskLocatorA = clientA.page.locator(`task:has-text("${taskName}")`); - await taskLocatorA.click({ button: 'right' }); - await clientA.page - .locator('.mat-mdc-menu-item') - .filter({ hasText: 'Delete' }) - .click(); + // Client A deletes the task + await deleteTask(clientA, taskName); + await expectTaskNotVisible(clientA, taskName); - // Handle confirmation dialog if present - const dialogA = clientA.page.locator('dialog-confirm'); - if (await dialogA.isVisible({ timeout: 2000 }).catch(() => false)) { - await dialogA.locator('button[type=submit]').click(); - } - await expect(taskLocatorA).not.toBeVisible(); + // Client B updates the task (marks as done - reliable operation) + await markTaskDone(clientB, taskName); - // Client B updates the task (marks as done - reliable operation) - const taskLocatorB = clientB.page.locator(`task:has-text("${taskName}")`); - await taskLocatorB.hover(); - await taskLocatorB.locator('.task-done-btn').click(); + // A syncs first (uploads DELETE) + await clientA.sync.syncAndWait(); - // A syncs first (uploads DELETE) - await clientA.sync.syncAndWait(); + // B syncs (downloads DELETE, has local UPDATE) -> potential conflict + // The conflict resolution may show a dialog or auto-resolve + await clientB.sync.syncAndWait(); - // B syncs (downloads DELETE, has local UPDATE) -> potential conflict - // The conflict resolution may show a dialog or auto-resolve - await clientB.sync.syncAndWait(); + // Final sync to converge + await clientA.sync.syncAndWait(); + await clientB.sync.syncAndWait(); - // Final sync to converge - await clientA.sync.syncAndWait(); - await clientB.sync.syncAndWait(); + // Verify consistent state + // Both clients should have the same view (either both have task or neither) + const hasTaskA = + (await clientA.page.locator(`task:has-text("${taskName}")`).count()) > 0; + const hasTaskB = + (await clientB.page.locator(`task:has-text("${taskName}")`).count()) > 0; - // Verify consistent state - // Both clients should have the same view (either both have task or neither) - const hasTaskA = - (await clientA.page.locator(`task:has-text("${taskName}")`).count()) > 0; - const hasTaskB = - (await clientB.page.locator(`task:has-text("${taskName}")`).count()) > 0; + // State should be consistent (doesn't matter which wins, just that they agree) + expect(hasTaskA).toBe(hasTaskB); - // State should be consistent (doesn't matter which wins, just that they agree) - expect(hasTaskA).toBe(hasTaskB); - - console.log( - `[ConflictTest] ✓ Concurrent Delete/Update resolved consistently (task ${hasTaskA ? 'restored' : 'deleted'})`, - ); - } finally { - if (clientA) await closeClient(clientA); - if (clientB) await closeClient(clientB); - } - }, - ); + console.log( + `[ConflictTest] ✓ Concurrent Delete/Update resolved consistently (task ${hasTaskA ? 'restored' : 'deleted'})`, + ); + } finally { + if (clientA) await closeClient(clientA); + if (clientB) await closeClient(clientB); + } + }); }); diff --git a/e2e/tests/sync/supersync-archive-subtasks.spec.ts b/e2e/tests/sync/supersync-archive-subtasks.spec.ts index 5c3dcfa88..b2109765f 100644 --- a/e2e/tests/sync/supersync-archive-subtasks.spec.ts +++ b/e2e/tests/sync/supersync-archive-subtasks.spec.ts @@ -1,11 +1,10 @@ -import { test as base, expect } from '@playwright/test'; +import { test, expect } from '../../fixtures/supersync.fixture'; import { createTestUser, getSuperSyncConfig, createSimulatedClient, closeClient, waitForTask, - isServerHealthy, type SimulatedE2EClient, } from '../../utils/supersync-helpers'; @@ -30,10 +29,6 @@ import { * parentId, not just the subTaskIds from the operation payload. */ -const generateTestRunId = (workerIndex: number): string => { - return `${Date.now()}-${workerIndex}`; -}; - /** * Helper to add a subtask to a task using keyboard shortcut * This is more reliable than using the context menu @@ -130,21 +125,7 @@ const checkForOrphanSubtaskError = (consoleMessages: string[]): boolean => { ); }; -base.describe('@supersync Archive Subtasks Sync', () => { - let serverHealthy: boolean | null = null; - - base.beforeEach(async ({}, testInfo) => { - if (serverHealthy === null) { - serverHealthy = await isServerHealthy(); - if (!serverHealthy) { - console.warn( - 'SuperSync server not healthy at http://localhost:1901 - skipping tests', - ); - } - } - testInfo.skip(!serverHealthy, 'SuperSync server not running'); - }); - +test.describe('@supersync Archive Subtasks Sync', () => { /** * Scenario: Archive parent with subtasks syncs correctly * @@ -160,91 +141,89 @@ base.describe('@supersync Archive Subtasks Sync', () => { * 7. Verify Client B has no tasks in Today view (all archived) * 8. Verify no orphan subtask errors in console */ - base( - 'Archive parent task with subtasks syncs without leaving orphans', - async ({ browser, baseURL }, testInfo) => { - testInfo.setTimeout(120000); - const testRunId = generateTestRunId(testInfo.workerIndex); - const appUrl = baseURL || 'http://localhost:4242'; - let clientA: SimulatedE2EClient | null = null; - let clientB: SimulatedE2EClient | null = null; - const consoleErrors: string[] = []; + test('Archive parent task with subtasks syncs without leaving orphans', async ({ + browser, + baseURL, + testRunId, + }) => { + let clientA: SimulatedE2EClient | null = null; + let clientB: SimulatedE2EClient | null = null; + const consoleErrors: string[] = []; - try { - const user = await createTestUser(testRunId); - const syncConfig = getSuperSyncConfig(user); + try { + const user = await createTestUser(testRunId); + const syncConfig = getSuperSyncConfig(user); - // Setup Client A - clientA = await createSimulatedClient(browser, appUrl, 'A', testRunId); - await clientA.sync.setupSuperSync(syncConfig); + // Setup Client A + clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); + await clientA.sync.setupSuperSync(syncConfig); - // Setup Client B with console monitoring - clientB = await createSimulatedClient(browser, appUrl, 'B', testRunId); - clientB.page.on('console', (msg) => { - if (msg.type() === 'error') { - consoleErrors.push(msg.text()); - } - }); - await clientB.sync.setupSuperSync(syncConfig); + // Setup Client B with console monitoring + clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); + clientB.page.on('console', (msg) => { + if (msg.type() === 'error') { + consoleErrors.push(msg.text()); + } + }); + await clientB.sync.setupSuperSync(syncConfig); - // 1. Client A creates parent task - const parentName = `Parent-${testRunId}`; - await clientA.workView.addTask(parentName); - await waitForTask(clientA.page, parentName); - console.log('[ArchiveSubtasks] Created parent task'); + // 1. Client A creates parent task + const parentName = `Parent-${testRunId}`; + await clientA.workView.addTask(parentName); + await waitForTask(clientA.page, parentName); + console.log('[ArchiveSubtasks] Created parent task'); - // 2. Client A adds subtasks - const subtask1Name = `Sub1-${testRunId}`; - const subtask2Name = `Sub2-${testRunId}`; - await addSubtask(clientA.page, parentName, subtask1Name); - console.log('[ArchiveSubtasks] Added subtask 1'); - await addSubtask(clientA.page, parentName, subtask2Name); - console.log('[ArchiveSubtasks] Added subtask 2'); + // 2. Client A adds subtasks + const subtask1Name = `Sub1-${testRunId}`; + const subtask2Name = `Sub2-${testRunId}`; + await addSubtask(clientA.page, parentName, subtask1Name); + console.log('[ArchiveSubtasks] Added subtask 1'); + await addSubtask(clientA.page, parentName, subtask2Name); + console.log('[ArchiveSubtasks] Added subtask 2'); - // 3. Mark subtasks as done first (parent requires all subtasks done) - await markTaskDone(clientA.page, subtask1Name, true); - console.log('[ArchiveSubtasks] Marked subtask 1 as done'); - await markTaskDone(clientA.page, subtask2Name, true); - console.log('[ArchiveSubtasks] Marked subtask 2 as done'); + // 3. Mark subtasks as done first (parent requires all subtasks done) + await markTaskDone(clientA.page, subtask1Name, true); + console.log('[ArchiveSubtasks] Marked subtask 1 as done'); + await markTaskDone(clientA.page, subtask2Name, true); + console.log('[ArchiveSubtasks] Marked subtask 2 as done'); - // 4. Now mark parent as done - await markTaskDone(clientA.page, parentName, false); - console.log('[ArchiveSubtasks] Marked parent as done'); + // 4. Now mark parent as done + await markTaskDone(clientA.page, parentName, false); + console.log('[ArchiveSubtasks] Marked parent as done'); - // 4. Archive via Daily Summary - await archiveDoneTasks(clientA.page); - console.log('[ArchiveSubtasks] Archived tasks'); + // 4. Archive via Daily Summary + await archiveDoneTasks(clientA.page); + console.log('[ArchiveSubtasks] Archived tasks'); - // 5. Client A syncs - await clientA.sync.syncAndWait(); - console.log('[ArchiveSubtasks] Client A synced'); + // 5. Client A syncs + await clientA.sync.syncAndWait(); + console.log('[ArchiveSubtasks] Client A synced'); - // 6. Client B syncs - await clientB.sync.syncAndWait(); - console.log('[ArchiveSubtasks] Client B synced'); + // 6. Client B syncs + await clientB.sync.syncAndWait(); + console.log('[ArchiveSubtasks] Client B synced'); - // Wait for UI to settle - await clientB.page.waitForTimeout(1000); + // Wait for UI to settle + await clientB.page.waitForTimeout(1000); - // 7. Verify Client B has no tasks with testRunId in Today view - // (they should all be archived) - const tasksOnB = clientB.page.locator(`task:has-text("${testRunId}")`); - const taskCount = await tasksOnB.count(); + // 7. Verify Client B has no tasks with testRunId in Today view + // (they should all be archived) + const tasksOnB = clientB.page.locator(`task:has-text("${testRunId}")`); + const taskCount = await tasksOnB.count(); - expect(taskCount).toBe(0); - console.log('[ArchiveSubtasks] Verified no tasks on Client B (all archived)'); + expect(taskCount).toBe(0); + console.log('[ArchiveSubtasks] Verified no tasks on Client B (all archived)'); - // 8. Verify no orphan subtask errors - const hasOrphanError = checkForOrphanSubtaskError(consoleErrors); - expect(hasOrphanError).toBe(false); + // 8. Verify no orphan subtask errors + const hasOrphanError = checkForOrphanSubtaskError(consoleErrors); + expect(hasOrphanError).toBe(false); - console.log('[ArchiveSubtasks] ✓ Archive with subtasks synced successfully'); - } finally { - if (clientA) await closeClient(clientA); - if (clientB) await closeClient(clientB); - } - }, - ); + console.log('[ArchiveSubtasks] ✓ Archive with subtasks synced successfully'); + } finally { + if (clientA) await closeClient(clientA); + if (clientB) await closeClient(clientB); + } + }); /** * Scenario: Multiple subtask levels archive correctly @@ -260,91 +239,89 @@ base.describe('@supersync Archive Subtasks Sync', () => { * 6. Both clients sync * 7. Verify both clients have no orphan tasks */ - base( - 'Multiple subtasks archive and sync without orphans', - async ({ browser, baseURL }, testInfo) => { - testInfo.setTimeout(120000); - const testRunId = generateTestRunId(testInfo.workerIndex); - const appUrl = baseURL || 'http://localhost:4242'; - let clientA: SimulatedE2EClient | null = null; - let clientB: SimulatedE2EClient | null = null; - const consoleErrorsA: string[] = []; - const consoleErrorsB: string[] = []; + test('Multiple subtasks archive and sync without orphans', async ({ + browser, + baseURL, + testRunId, + }) => { + let clientA: SimulatedE2EClient | null = null; + let clientB: SimulatedE2EClient | null = null; + const consoleErrorsA: string[] = []; + const consoleErrorsB: string[] = []; - try { - const user = await createTestUser(testRunId); - const syncConfig = getSuperSyncConfig(user); + try { + const user = await createTestUser(testRunId); + const syncConfig = getSuperSyncConfig(user); - // Setup clients with console monitoring - clientA = await createSimulatedClient(browser, appUrl, 'A', testRunId); - clientA.page.on('console', (msg) => { - if (msg.type() === 'error') { - consoleErrorsA.push(msg.text()); - } - }); - await clientA.sync.setupSuperSync(syncConfig); - - clientB = await createSimulatedClient(browser, appUrl, 'B', testRunId); - clientB.page.on('console', (msg) => { - if (msg.type() === 'error') { - consoleErrorsB.push(msg.text()); - } - }); - await clientB.sync.setupSuperSync(syncConfig); - - // 1. Create parent task - const parentName = `MultiSub-${testRunId}`; - await clientA.workView.addTask(parentName); - await waitForTask(clientA.page, parentName); - - // 2. Add multiple subtasks - for (let i = 1; i <= 3; i++) { - await addSubtask(clientA.page, parentName, `Sub${i}-${testRunId}`); + // Setup clients with console monitoring + clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); + clientA.page.on('console', (msg) => { + if (msg.type() === 'error') { + consoleErrorsA.push(msg.text()); } - console.log('[MultiSubtask] Created parent with 3 subtasks'); + }); + await clientA.sync.setupSuperSync(syncConfig); - // 3. Sync A - await clientA.sync.syncAndWait(); - - // 4. Sync B and verify - await clientB.sync.syncAndWait(); - await waitForTask(clientB.page, parentName); - console.log('[MultiSubtask] Client B received tasks'); - - // 5. Client A marks all subtasks and parent done - for (let i = 1; i <= 3; i++) { - await markTaskDone(clientA.page, `Sub${i}-${testRunId}`, true); + clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); + clientB.page.on('console', (msg) => { + if (msg.type() === 'error') { + consoleErrorsB.push(msg.text()); } - await markTaskDone(clientA.page, parentName, false); - await archiveDoneTasks(clientA.page); - console.log('[MultiSubtask] Client A archived all'); + }); + await clientB.sync.setupSuperSync(syncConfig); - // 6. Both clients sync - await clientA.sync.syncAndWait(); - await clientB.sync.syncAndWait(); - await clientB.page.waitForTimeout(1000); + // 1. Create parent task + const parentName = `MultiSub-${testRunId}`; + await clientA.workView.addTask(parentName); + await waitForTask(clientA.page, parentName); - // 7. Verify no orphan tasks - const tasksOnA = await clientA.page - .locator(`task:has-text("${testRunId}")`) - .count(); - const tasksOnB = await clientB.page - .locator(`task:has-text("${testRunId}")`) - .count(); - - expect(tasksOnA).toBe(0); - expect(tasksOnB).toBe(0); - - expect(checkForOrphanSubtaskError(consoleErrorsA)).toBe(false); - expect(checkForOrphanSubtaskError(consoleErrorsB)).toBe(false); - - console.log('[MultiSubtask] ✓ Multiple subtasks archived without orphans'); - } finally { - if (clientA) await closeClient(clientA); - if (clientB) await closeClient(clientB); + // 2. Add multiple subtasks + for (let i = 1; i <= 3; i++) { + await addSubtask(clientA.page, parentName, `Sub${i}-${testRunId}`); } - }, - ); + console.log('[MultiSubtask] Created parent with 3 subtasks'); + + // 3. Sync A + await clientA.sync.syncAndWait(); + + // 4. Sync B and verify + await clientB.sync.syncAndWait(); + await waitForTask(clientB.page, parentName); + console.log('[MultiSubtask] Client B received tasks'); + + // 5. Client A marks all subtasks and parent done + for (let i = 1; i <= 3; i++) { + await markTaskDone(clientA.page, `Sub${i}-${testRunId}`, true); + } + await markTaskDone(clientA.page, parentName, false); + await archiveDoneTasks(clientA.page); + console.log('[MultiSubtask] Client A archived all'); + + // 6. Both clients sync + await clientA.sync.syncAndWait(); + await clientB.sync.syncAndWait(); + await clientB.page.waitForTimeout(1000); + + // 7. Verify no orphan tasks + const tasksOnA = await clientA.page + .locator(`task:has-text("${testRunId}")`) + .count(); + const tasksOnB = await clientB.page + .locator(`task:has-text("${testRunId}")`) + .count(); + + expect(tasksOnA).toBe(0); + expect(tasksOnB).toBe(0); + + expect(checkForOrphanSubtaskError(consoleErrorsA)).toBe(false); + expect(checkForOrphanSubtaskError(consoleErrorsB)).toBe(false); + + console.log('[MultiSubtask] ✓ Multiple subtasks archived without orphans'); + } finally { + if (clientA) await closeClient(clientA); + if (clientB) await closeClient(clientB); + } + }); /** * Scenario: Add subtask then immediately archive syncs correctly @@ -360,72 +337,70 @@ base.describe('@supersync Archive Subtasks Sync', () => { * 5. Client B syncs * 6. Verify no orphan subtasks */ - base( - 'Add subtask then immediately archive syncs correctly', - async ({ browser, baseURL }, testInfo) => { - testInfo.setTimeout(120000); - const testRunId = generateTestRunId(testInfo.workerIndex); - const appUrl = baseURL || 'http://localhost:4242'; - let clientA: SimulatedE2EClient | null = null; - let clientB: SimulatedE2EClient | null = null; - const consoleErrorsB: string[] = []; + test('Add subtask then immediately archive syncs correctly', async ({ + browser, + baseURL, + testRunId, + }) => { + let clientA: SimulatedE2EClient | null = null; + let clientB: SimulatedE2EClient | null = null; + const consoleErrorsB: string[] = []; - try { - const user = await createTestUser(testRunId); - const syncConfig = getSuperSyncConfig(user); + try { + const user = await createTestUser(testRunId); + const syncConfig = getSuperSyncConfig(user); - // Setup clients - clientA = await createSimulatedClient(browser, appUrl, 'A', testRunId); - await clientA.sync.setupSuperSync(syncConfig); + // Setup clients + clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); + await clientA.sync.setupSuperSync(syncConfig); - clientB = await createSimulatedClient(browser, appUrl, 'B', testRunId); - clientB.page.on('console', (msg) => { - if (msg.type() === 'error') { - consoleErrorsB.push(msg.text()); - } - }); - await clientB.sync.setupSuperSync(syncConfig); + clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); + clientB.page.on('console', (msg) => { + if (msg.type() === 'error') { + consoleErrorsB.push(msg.text()); + } + }); + await clientB.sync.setupSuperSync(syncConfig); - // Create parent and sync both clients - const parentName = `RaceTest-${testRunId}`; - await clientA.workView.addTask(parentName); - await clientA.sync.syncAndWait(); - await clientB.sync.syncAndWait(); - console.log('[RaceTest] Both clients synced with parent task'); + // Create parent and sync both clients + const parentName = `RaceTest-${testRunId}`; + await clientA.workView.addTask(parentName); + await clientA.sync.syncAndWait(); + await clientB.sync.syncAndWait(); + console.log('[RaceTest] Both clients synced with parent task'); - // 2. Client A adds subtask - const subtaskName = `NewSub-${testRunId}`; - await addSubtask(clientA.page, parentName, subtaskName); - console.log('[RaceTest] Added subtask (no sync yet)'); + // 2. Client A adds subtask + const subtaskName = `NewSub-${testRunId}`; + await addSubtask(clientA.page, parentName, subtaskName); + console.log('[RaceTest] Added subtask (no sync yet)'); - // 3. Immediately mark subtask and parent done, then archive (NO SYNC BETWEEN) - await markTaskDone(clientA.page, subtaskName, true); - await markTaskDone(clientA.page, parentName, false); - await archiveDoneTasks(clientA.page); - console.log('[RaceTest] Archived (without intermediate sync)'); + // 3. Immediately mark subtask and parent done, then archive (NO SYNC BETWEEN) + await markTaskDone(clientA.page, subtaskName, true); + await markTaskDone(clientA.page, parentName, false); + await archiveDoneTasks(clientA.page); + console.log('[RaceTest] Archived (without intermediate sync)'); - // 4. Client A syncs (sends both add subtask + archive) - await clientA.sync.syncAndWait(); - console.log('[RaceTest] Client A synced'); + // 4. Client A syncs (sends both add subtask + archive) + await clientA.sync.syncAndWait(); + console.log('[RaceTest] Client A synced'); - // 5. Client B syncs - await clientB.sync.syncAndWait(); - await clientB.page.waitForTimeout(1000); - console.log('[RaceTest] Client B synced'); + // 5. Client B syncs + await clientB.sync.syncAndWait(); + await clientB.page.waitForTimeout(1000); + console.log('[RaceTest] Client B synced'); - // 6. Verify no orphan subtasks - const tasksOnB = await clientB.page - .locator(`task:has-text("${testRunId}")`) - .count(); - expect(tasksOnB).toBe(0); + // 6. Verify no orphan subtasks + const tasksOnB = await clientB.page + .locator(`task:has-text("${testRunId}")`) + .count(); + expect(tasksOnB).toBe(0); - expect(checkForOrphanSubtaskError(consoleErrorsB)).toBe(false); + expect(checkForOrphanSubtaskError(consoleErrorsB)).toBe(false); - console.log('[RaceTest] ✓ Add subtask + immediate archive synced correctly'); - } finally { - if (clientA) await closeClient(clientA); - if (clientB) await closeClient(clientB); - } - }, - ); + console.log('[RaceTest] ✓ Add subtask + immediate archive synced correctly'); + } finally { + if (clientA) await closeClient(clientA); + if (clientB) await closeClient(clientB); + } + }); }); diff --git a/e2e/tests/sync/supersync-cross-entity.spec.ts b/e2e/tests/sync/supersync-cross-entity.spec.ts index acf7c81c2..9d8ffb57e 100644 --- a/e2e/tests/sync/supersync-cross-entity.spec.ts +++ b/e2e/tests/sync/supersync-cross-entity.spec.ts @@ -1,11 +1,10 @@ -import { test as base, expect } from '@playwright/test'; +import { test, expect } from '../../fixtures/supersync.fixture'; import { createTestUser, getSuperSyncConfig, createSimulatedClient, closeClient, waitForTask, - isServerHealthy, type SimulatedE2EClient, } from '../../utils/supersync-helpers'; import { waitForUISettle } from '../../utils/waits'; @@ -19,24 +18,7 @@ import { waitForUISettle } from '../../utils/waits'; * - Marking tasks done syncs across clients */ -let testCounter = 0; -const generateTestRunId = (workerIndex: number): string => { - return `cross-${Date.now()}-${workerIndex}-${testCounter++}`; -}; - -base.describe('@supersync Cross-Entity Operations Sync', () => { - let serverHealthy: boolean | null = null; - - base.beforeEach(async ({}, testInfo) => { - if (serverHealthy === null) { - serverHealthy = await isServerHealthy(); - if (!serverHealthy) { - console.warn('SuperSync server not healthy - skipping tests'); - } - } - testInfo.skip(!serverHealthy, 'SuperSync server not running'); - }); - +test.describe('@supersync Cross-Entity Operations Sync', () => { /** * Test: Multiple tasks sync as a batch * @@ -46,8 +28,7 @@ base.describe('@supersync Cross-Entity Operations Sync', () => { * 3. Client B syncs * 4. Verify all 5 tasks appear on Client B */ - base('Multiple tasks sync together', async ({ browser, baseURL }, testInfo) => { - const testRunId = generateTestRunId(testInfo.workerIndex); + test('Multiple tasks sync together', async ({ browser, baseURL, testRunId }) => { const uniqueId = Date.now(); let clientA: SimulatedE2EClient | null = null; let clientB: SimulatedE2EClient | null = null; @@ -110,8 +91,7 @@ base.describe('@supersync Cross-Entity Operations Sync', () => { * 3. Client B syncs * 4. Verify parent and all subtasks on Client B */ - base('Task with subtasks syncs correctly', async ({ browser, baseURL }, testInfo) => { - const testRunId = generateTestRunId(testInfo.workerIndex); + test('Task with subtasks syncs correctly', async ({ browser, baseURL, testRunId }) => { const uniqueId = Date.now(); let clientA: SimulatedE2EClient | null = null; let clientB: SimulatedE2EClient | null = null; diff --git a/e2e/tests/sync/supersync-daily-summary.spec.ts b/e2e/tests/sync/supersync-daily-summary.spec.ts index d5da57c88..a145f05ea 100644 --- a/e2e/tests/sync/supersync-daily-summary.spec.ts +++ b/e2e/tests/sync/supersync-daily-summary.spec.ts @@ -1,10 +1,10 @@ -import { test as base, expect, type ConsoleMessage } from '@playwright/test'; +import { type ConsoleMessage } from '@playwright/test'; +import { test, expect } from '../../fixtures/supersync.fixture'; import { createTestUser, getSuperSyncConfig, createSimulatedClient, closeClient, - isServerHealthy, type SimulatedE2EClient, } from '../../utils/supersync-helpers'; @@ -12,10 +12,6 @@ import { * SuperSync Daily Summary E2E Tests */ -const generateTestRunId = (workerIndex: number): string => { - return `${Date.now()}-${workerIndex}`; -}; - /** * Checks if a console message indicates a DB lock error. * These errors would indicate the fix for synchronous flush is broken. @@ -25,21 +21,7 @@ const isDbLockError = (msg: ConsoleMessage): boolean => { return text.includes('Attempting to write DB') && text.includes('while locked'); }; -base.describe('@supersync Daily Summary Sync', () => { - let serverHealthy: boolean | null = null; - - base.beforeEach(async ({}, testInfo) => { - if (serverHealthy === null) { - serverHealthy = await isServerHealthy(); - if (!serverHealthy) { - console.warn( - 'SuperSync server not healthy at http://localhost:1901 - skipping tests', - ); - } - } - testInfo.skip(!serverHealthy, 'SuperSync server not running'); - }); - +test.describe('@supersync Daily Summary Sync', () => { /** * Scenario: Archived tasks and time tracking appear on Daily Summary * @@ -53,137 +35,137 @@ base.describe('@supersync Daily Summary Sync', () => { * 7. Client B navigates to Daily Summary. * 8. Verify Client B sees both tasks and correct time. */ - base( - 'Archived tasks and time tracking appear on Daily Summary', - async ({ browser, baseURL }, testInfo) => { - testInfo.setTimeout(120000); - const testRunId = generateTestRunId(testInfo.workerIndex); - const uniqueId = Date.now(); - let clientA: SimulatedE2EClient | null = null; - let clientB: SimulatedE2EClient | null = null; + test('Archived tasks and time tracking appear on Daily Summary', async ({ + browser, + baseURL, + testRunId, + }) => { + test.setTimeout(120000); + const uniqueId = Date.now(); + let clientA: SimulatedE2EClient | null = null; + let clientB: SimulatedE2EClient | null = null; - try { - const user = await createTestUser(testRunId); - const syncConfig = getSuperSyncConfig(user); + try { + const user = await createTestUser(testRunId); + const syncConfig = getSuperSyncConfig(user); - // ============ PHASE 1: Client A Setup & Work ============ - clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); - await clientA.sync.setupSuperSync(syncConfig); + // ============ PHASE 1: Client A Setup & Work ============ + clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); + await clientA.sync.setupSuperSync(syncConfig); - // Create Task A and track time - const taskAName = `TaskA-${uniqueId}`; - await clientA.workView.addTask(taskAName); - const taskALocator = clientA.page.locator(`task:has-text("${taskAName}")`); + // Create Task A and track time + const taskAName = `TaskA-${uniqueId}`; + await clientA.workView.addTask(taskAName); + const taskALocator = clientA.page.locator(`task:has-text("${taskAName}")`); - // Manually set time via Detail Panel -> Time Estimate Dialog - // 1. Open detail panel - await taskALocator.hover(); - const detailBtn = taskALocator.locator('.show-additional-info-btn'); - await detailBtn.click(); + // Manually set time via Detail Panel -> Time Estimate Dialog + // 1. Open detail panel + await taskALocator.hover(); + const detailBtn = taskALocator.locator('.show-additional-info-btn'); + await detailBtn.click(); - const panel = clientA.page.locator('task-detail-panel'); - await expect(panel).toBeVisible(); + const panel = clientA.page.locator('task-detail-panel'); + await expect(panel).toBeVisible(); - // 2. Click time item to open dialog - // Look for the item with the timer icon - const timeItem = panel.locator('task-detail-item:has(mat-icon:text("timer"))'); - await timeItem.click(); + // 2. Click time item to open dialog + // Look for the item with the timer icon + const timeItem = panel.locator('task-detail-item:has(mat-icon:text("timer"))'); + await timeItem.click(); - // 3. Wait for dialog - const dialog = clientA.page.locator('dialog-time-estimate'); - await expect(dialog).toBeVisible(); + // 3. Wait for dialog + const dialog = clientA.page.locator('dialog-time-estimate'); + await expect(dialog).toBeVisible(); - // 4. Fill time spent (assuming first input is time spent or allow flexible input) - // Usually the dialog focuses the relevant input or has labeled inputs. - // We'll try filling the first input found in the dialog. - const timeInput = dialog.locator('input').first(); - await timeInput.fill('10m'); - await clientA.page.keyboard.press('Enter'); + // 4. Fill time spent (assuming first input is time spent or allow flexible input) + // Usually the dialog focuses the relevant input or has labeled inputs. + // We'll try filling the first input found in the dialog. + const timeInput = dialog.locator('input').first(); + await timeInput.fill('10m'); + await clientA.page.keyboard.press('Enter'); - // 5. Verify update in panel - // The time item should now show 10m - await expect(timeItem).toContainText('10m'); - console.log('Client A manually set time to: 10m'); + // 5. Verify update in panel + // The time item should now show 10m + await expect(timeItem).toContainText('10m'); + console.log('Client A manually set time to: 10m'); - // Create Task B (no time) - const taskBName = `TaskB-${uniqueId}`; - await clientA.workView.addTask(taskBName); - const taskBLocator = clientA.page.locator(`task:has-text("${taskBName}")`); + // Create Task B (no time) + const taskBName = `TaskB-${uniqueId}`; + await clientA.workView.addTask(taskBName); + const taskBLocator = clientA.page.locator(`task:has-text("${taskBName}")`); - // Mark both done - await taskALocator.hover(); - await taskALocator.locator('.task-done-btn').click(); + // Mark both done + await taskALocator.hover(); + await taskALocator.locator('.task-done-btn').click(); - await taskBLocator.hover(); - await taskBLocator.locator('.task-done-btn').click(); + await taskBLocator.hover(); + await taskBLocator.locator('.task-done-btn').click(); - // Archive Tasks (Finish Day) - const finishDayBtn = clientA.page.locator('.e2e-finish-day'); - await finishDayBtn.click(); + // Archive Tasks (Finish Day) + const finishDayBtn = clientA.page.locator('.e2e-finish-day'); + await finishDayBtn.click(); - // Wait for Daily Summary - await clientA.page.waitForURL(/daily-summary/); + // Wait for Daily Summary + await clientA.page.waitForURL(/daily-summary/); - // Click "Save and go home" to archive - const saveAndGoHomeBtn = clientA.page.locator( - 'daily-summary button[mat-flat-button]:has(mat-icon:has-text("wb_sunny"))', - ); - await saveAndGoHomeBtn.waitFor({ state: 'visible' }); - await saveAndGoHomeBtn.click(); + // Click "Save and go home" to archive + const saveAndGoHomeBtn = clientA.page.locator( + 'daily-summary button[mat-flat-button]:has(mat-icon:has-text("wb_sunny"))', + ); + await saveAndGoHomeBtn.waitFor({ state: 'visible' }); + await saveAndGoHomeBtn.click(); - // Wait for Work View (Archived) - // Accept either active/tasks or tag/TODAY (with or without /tasks suffix) - await clientA.page.waitForURL(/(active\/tasks|tag\/TODAY)/); - console.log('Client A archived tasks.'); + // Wait for Work View (Archived) + // Accept either active/tasks or tag/TODAY (with or without /tasks suffix) + await clientA.page.waitForURL(/(active\/tasks|tag\/TODAY)/); + console.log('Client A archived tasks.'); - // Sync A (upload archive) - await clientA.sync.syncAndWait(); - console.log('Client A synced.'); + // Sync A (upload archive) + await clientA.sync.syncAndWait(); + console.log('Client A synced.'); - // ============ PHASE 2: Client B Sync & Verify ============ - clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); - await clientB.sync.setupSuperSync(syncConfig); + // ============ PHASE 2: Client B Sync & Verify ============ + clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); + await clientB.sync.setupSuperSync(syncConfig); - // Sync B (download archive) - await clientB.sync.syncAndWait(); - console.log('Client B synced.'); + // Sync B (download archive) + await clientB.sync.syncAndWait(); + console.log('Client B synced.'); - // Navigate B to Daily Summary - // We manually go there because there might be no active tasks to trigger "Finish Day" button - await clientB.page.goto('/#/tag/TODAY/daily-summary'); + // Navigate B to Daily Summary + // We manually go there because there might be no active tasks to trigger "Finish Day" button + await clientB.page.goto('/#/tag/TODAY/daily-summary'); - // Wait for table - await clientB.page.waitForSelector('task-summary-tables', { state: 'visible' }); - console.log('Client B on Daily Summary.'); + // Wait for table + await clientB.page.waitForSelector('task-summary-tables', { state: 'visible' }); + console.log('Client B on Daily Summary.'); - // Verify Content - // Check Task A Name - // Use specific selector for summary table cells - const rowA = clientB.page.locator('tr', { hasText: taskAName }); - await expect(rowA).toBeVisible({ timeout: 10000 }); + // Verify Content + // Check Task A Name + // Use specific selector for summary table cells + const rowA = clientB.page.locator('tr', { hasText: taskAName }); + await expect(rowA).toBeVisible({ timeout: 10000 }); - // Check Task A Time - const rowAText = await rowA.textContent(); - console.log(`Row A Content: ${rowAText}`); - // Expect "10m" or "00:10" or "0:10" - expect(rowAText).toMatch(/10m|00:10|0:10/); + // Check Task A Time + const rowAText = await rowA.textContent(); + console.log(`Row A Content: ${rowAText}`); + // Expect "10m" or "00:10" or "0:10" + expect(rowAText).toMatch(/10m|00:10|0:10/); - // Check Task B Name - const rowB = clientB.page.locator('tr', { hasText: taskBName }); - await expect(rowB).toBeVisible({ timeout: 10000 }); + // Check Task B Name + const rowB = clientB.page.locator('tr', { hasText: taskBName }); + await expect(rowB).toBeVisible({ timeout: 10000 }); - // Check Task B Time (should be 0 or empty) - // It likely shows "-" or "0s" - const rowBText = await rowB.textContent(); - console.log(`Row B Content: ${rowBText}`); + // Check Task B Time (should be 0 or empty) + // It likely shows "-" or "0s" + const rowBText = await rowB.textContent(); + console.log(`Row B Content: ${rowBText}`); - console.log('✓ Daily Summary verification passed!'); - } finally { - if (clientA) await closeClient(clientA); - if (clientB) await closeClient(clientB); - } - }, - ); + console.log('Daily Summary verification passed!'); + } finally { + if (clientA) await closeClient(clientA); + if (clientB) await closeClient(clientB); + } + }); /** * Scenario: Finish day completes without DB lock errors @@ -199,71 +181,71 @@ base.describe('@supersync Daily Summary Sync', () => { * 3. Client completes daily summary (archives tasks, triggers sync). * 4. Verify NO "DB lock" console errors occurred. */ - base( - 'Finish day completes without DB lock errors', - async ({ browser, baseURL }, testInfo) => { - testInfo.setTimeout(60000); - const testRunId = generateTestRunId(testInfo.workerIndex); - const uniqueId = Date.now(); - let client: SimulatedE2EClient | null = null; - const dbLockErrors: string[] = []; + test('Finish day completes without DB lock errors', async ({ + browser, + baseURL, + testRunId, + }) => { + test.setTimeout(60000); + const uniqueId = Date.now(); + let client: SimulatedE2EClient | null = null; + const dbLockErrors: string[] = []; - try { - const user = await createTestUser(testRunId); - const syncConfig = getSuperSyncConfig(user); + try { + const user = await createTestUser(testRunId); + const syncConfig = getSuperSyncConfig(user); - // Create client and set up sync - client = await createSimulatedClient(browser, baseURL!, 'A', testRunId); - await client.sync.setupSuperSync(syncConfig); + // Create client and set up sync + client = await createSimulatedClient(browser, baseURL!, 'A', testRunId); + await client.sync.setupSuperSync(syncConfig); - // Monitor for DB lock errors - client.page.on('console', (msg) => { - if (isDbLockError(msg)) { - dbLockErrors.push(msg.text()); - } - }); - - // Create multiple tasks to increase chance of triggering flush - const tasks = [`Task1-${uniqueId}`, `Task2-${uniqueId}`, `Task3-${uniqueId}`]; - - for (const taskName of tasks) { - await client.workView.addTask(taskName); + // Monitor for DB lock errors + client.page.on('console', (msg) => { + if (isDbLockError(msg)) { + dbLockErrors.push(msg.text()); } + }); - // Mark all tasks as done - for (const taskName of tasks) { - const taskLocator = client.page.locator(`task:has-text("${taskName}")`); - await taskLocator.hover(); - await taskLocator.locator('.task-done-btn').click(); - } + // Create multiple tasks to increase chance of triggering flush + const tasks = [`Task1-${uniqueId}`, `Task2-${uniqueId}`, `Task3-${uniqueId}`]; - // Click finish day - wait for button to be visible and stable - const finishDayBtn = client.page.locator('.e2e-finish-day'); - await finishDayBtn.waitFor({ state: 'visible', timeout: 10000 }); - await finishDayBtn.click(); - - // Wait for Daily Summary - await client.page.waitForURL(/daily-summary/); - - // Click "Save and go home" to archive and trigger sync - const saveAndGoHomeBtn = client.page.locator( - 'daily-summary button[mat-flat-button]:has(mat-icon:has-text("wb_sunny"))', - ); - await saveAndGoHomeBtn.waitFor({ state: 'visible' }); - await saveAndGoHomeBtn.click(); - - // Wait for navigation back to work view - await client.page.waitForURL(/(active\/tasks|tag\/TODAY)/); - - // Wait a moment for any async effects to complete - await client.page.waitForTimeout(1000); - - // Verify no DB lock errors occurred - expect(dbLockErrors).toEqual([]); - console.log('✓ Finish day completed without DB lock errors'); - } finally { - if (client) await closeClient(client); + for (const taskName of tasks) { + await client.workView.addTask(taskName); } - }, - ); + + // Mark all tasks as done + for (const taskName of tasks) { + const taskLocator = client.page.locator(`task:has-text("${taskName}")`); + await taskLocator.hover(); + await taskLocator.locator('.task-done-btn').click(); + } + + // Click finish day - wait for button to be visible and stable + const finishDayBtn = client.page.locator('.e2e-finish-day'); + await finishDayBtn.waitFor({ state: 'visible', timeout: 10000 }); + await finishDayBtn.click(); + + // Wait for Daily Summary + await client.page.waitForURL(/daily-summary/); + + // Click "Save and go home" to archive and trigger sync + const saveAndGoHomeBtn = client.page.locator( + 'daily-summary button[mat-flat-button]:has(mat-icon:has-text("wb_sunny"))', + ); + await saveAndGoHomeBtn.waitFor({ state: 'visible' }); + await saveAndGoHomeBtn.click(); + + // Wait for navigation back to work view + await client.page.waitForURL(/(active\/tasks|tag\/TODAY)/); + + // Wait a moment for any async effects to complete + await client.page.waitForTimeout(1000); + + // Verify no DB lock errors occurred + expect(dbLockErrors).toEqual([]); + console.log('Finish day completed without DB lock errors'); + } finally { + if (client) await closeClient(client); + } + }); }); diff --git a/e2e/tests/sync/supersync-edge-cases.spec.ts b/e2e/tests/sync/supersync-edge-cases.spec.ts index f38b4c7ed..552173254 100644 --- a/e2e/tests/sync/supersync-edge-cases.spec.ts +++ b/e2e/tests/sync/supersync-edge-cases.spec.ts @@ -1,11 +1,11 @@ -import { test as base, expect, Page } from '@playwright/test'; +import { test, expect } from '../../fixtures/supersync.fixture'; +import { Page } from '@playwright/test'; import { createTestUser, getSuperSyncConfig, createSimulatedClient, closeClient, waitForTask, - isServerHealthy, type SimulatedE2EClient, } from '../../utils/supersync-helpers'; @@ -16,10 +16,6 @@ import { * offline bursts, and conflict handling. */ -const generateTestRunId = (workerIndex: number): string => { - return `${Date.now()}-${workerIndex}`; -}; - // Robust helper to create a project (copied from supersync-models.spec.ts for self-containment) const createProjectReliably = async (page: Page, projectName: string): Promise => { await page.goto('/#/tag/TODAY/work'); @@ -80,20 +76,8 @@ const createProjectReliably = async (page: Page, projectName: string): Promise { - let serverHealthy: boolean | null = null; - - base.beforeEach(async ({}, testInfo) => { - if (serverHealthy === null) { - serverHealthy = await isServerHealthy(); - if (!serverHealthy) { - console.warn( - 'SuperSync server not healthy at http://localhost:1901 - skipping tests', - ); - } - } - testInfo.skip(!serverHealthy, 'SuperSync server not running'); - }); +test.describe('@supersync SuperSync Edge Cases', () => { + // Server health check is handled automatically by the supersync fixture /** * Scenario 1: Move Task Between Projects @@ -109,159 +93,159 @@ base.describe('@supersync SuperSync Edge Cases', () => { * 6. Verify Task is in Project 2 on Client B * 7. Verify Task is NOT in Project 1 on Client B */ - base( - 'Move task between projects syncs correctly', - async ({ browser, baseURL }, testInfo) => { - const testRunId = generateTestRunId(testInfo.workerIndex); - const appUrl = baseURL || 'http://localhost:4242'; - let clientA: SimulatedE2EClient | null = null; - let clientB: SimulatedE2EClient | null = null; + test('Move task between projects syncs correctly', async ({ + browser, + baseURL, + testRunId, + serverHealthy, + }) => { + void serverHealthy; // Ensure fixture is evaluated for server health check + 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); + try { + const user = await createTestUser(testRunId); + const syncConfig = getSuperSyncConfig(user); - // Setup Clients - clientA = await createSimulatedClient(browser, appUrl, 'A', testRunId); - await clientA.sync.setupSuperSync(syncConfig); + // 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); + clientB = await createSimulatedClient(browser, appUrl, 'B', testRunId); + await clientB.sync.setupSuperSync(syncConfig); - // 1. Create Projects on A - const proj1Name = `Proj1-${testRunId}`; - const proj2Name = `Proj2-${testRunId}`; + // 1. Create Projects on A + const proj1Name = `Proj1-${testRunId}`; + const proj2Name = `Proj2-${testRunId}`; - await createProjectReliably(clientA.page, proj1Name); - await createProjectReliably(clientA.page, proj2Name); + await createProjectReliably(clientA.page, proj1Name); + await createProjectReliably(clientA.page, proj2Name); - // 2. Create Task in Project 1 - // Navigate to Project 1 using sidebar nav item (button with nav-link class) - const projectBtn1 = clientA.page.locator( - `.nav-sidenav .nav-link:has-text("${proj1Name}")`, - ); - await projectBtn1.waitFor({ state: 'visible', timeout: 10000 }); - await projectBtn1.click(); - await clientA.page.waitForLoadState('networkidle'); - // Extra wait to ensure project view is fully loaded - await clientA.page.waitForTimeout(1000); + // 2. Create Task in Project 1 + // Navigate to Project 1 using sidebar nav item (button with nav-link class) + const projectBtn1 = clientA.page.locator( + `.nav-sidenav .nav-link:has-text("${proj1Name}")`, + ); + await projectBtn1.waitFor({ state: 'visible', timeout: 10000 }); + await projectBtn1.click(); + await clientA.page.waitForLoadState('networkidle'); + // Extra wait to ensure project view is fully loaded + await clientA.page.waitForTimeout(1000); - const taskName = `MovingTask-${testRunId}`; - await clientA.workView.addTask(taskName); - // Wait for task to be fully created before syncing - await waitForTask(clientA.page, taskName); - console.log('[MoveTask] Task created in Project 1'); + const taskName = `MovingTask-${testRunId}`; + await clientA.workView.addTask(taskName); + // Wait for task to be fully created before syncing + await waitForTask(clientA.page, taskName); + console.log('[MoveTask] Task created in Project 1'); - // 3. Sync A -> Sync B - await clientA.sync.syncAndWait(); - await clientB.sync.syncAndWait(); + // 3. Sync A -> Sync B + await clientA.sync.syncAndWait(); + await clientB.sync.syncAndWait(); - // Verify B has projects and task in Proj 1 - const projectBtnB1 = clientB.page.locator( - `.nav-sidenav .nav-link:has-text("${proj1Name}")`, - ); - await expect(projectBtnB1).toBeVisible({ timeout: 10000 }); - await projectBtnB1.click(); - await clientB.page.waitForLoadState('networkidle'); - await clientB.page.waitForTimeout(1000); - await waitForTask(clientB.page, taskName); - console.log('[MoveTask] Task synced to Client B in Project 1'); + // Verify B has projects and task in Proj 1 + const projectBtnB1 = clientB.page.locator( + `.nav-sidenav .nav-link:has-text("${proj1Name}")`, + ); + await expect(projectBtnB1).toBeVisible({ timeout: 10000 }); + await projectBtnB1.click(); + await clientB.page.waitForLoadState('networkidle'); + await clientB.page.waitForTimeout(1000); + await waitForTask(clientB.page, taskName); + console.log('[MoveTask] Task synced to Client B in Project 1'); - // 4. Client A moves Task to Project 2 - // First ensure we're on Project 1 view - await projectBtn1.click(); - await clientA.page.waitForLoadState('networkidle'); - await clientA.page.waitForTimeout(500); + // 4. Client A moves Task to Project 2 + // First ensure we're on Project 1 view + await projectBtn1.click(); + await clientA.page.waitForLoadState('networkidle'); + await clientA.page.waitForTimeout(500); - // Using context menu or drag and drop. Context menu is more reliable for e2e. - const taskLocatorA = clientA.page - .locator(`task:not(.ng-animating):has-text("${taskName}")`) - .first(); - await taskLocatorA.waitFor({ state: 'visible', timeout: 10000 }); + // Using context menu or drag and drop. Context menu is more reliable for e2e. + const taskLocatorA = clientA.page + .locator(`task:not(.ng-animating):has-text("${taskName}")`) + .first(); + await taskLocatorA.waitFor({ state: 'visible', timeout: 10000 }); - // Context menu retry loop - menus can be flaky due to overlay timing - let moveSuccess = false; - for (let attempt = 0; attempt < 3 && !moveSuccess; attempt++) { - try { - await taskLocatorA.click({ button: 'right' }); + // Context menu retry loop - menus can be flaky due to overlay timing + let moveSuccess = false; + for (let attempt = 0; attempt < 3 && !moveSuccess; attempt++) { + try { + await taskLocatorA.click({ button: 'right' }); - // Click "Move to project" - const moveItem = clientA.page - .locator('.mat-mdc-menu-item') - .filter({ hasText: 'Move to project' }); - await moveItem.waitFor({ state: 'visible', timeout: 3000 }); - await moveItem.click(); + // Click "Move to project" + const moveItem = clientA.page + .locator('.mat-mdc-menu-item') + .filter({ hasText: 'Move to project' }); + await moveItem.waitFor({ state: 'visible', timeout: 3000 }); + await moveItem.click(); - // Select Project 2 from the submenu - const proj2Item = clientA.page - .locator('.mat-mdc-menu-item:not(.nav-link)') - .filter({ hasText: proj2Name }); - await proj2Item.waitFor({ state: 'visible', timeout: 3000 }); - await proj2Item.click(); - moveSuccess = true; - } catch (e) { - console.log( - `[MoveTask] Context menu attempt ${attempt + 1} failed, retrying...`, - ); - // Close any open menus by pressing Escape - await clientA.page.keyboard.press('Escape'); - await clientA.page.waitForTimeout(300); - } + // Select Project 2 from the submenu + const proj2Item = clientA.page + .locator('.mat-mdc-menu-item:not(.nav-link)') + .filter({ hasText: proj2Name }); + await proj2Item.waitFor({ state: 'visible', timeout: 3000 }); + await proj2Item.click(); + moveSuccess = true; + } catch (e) { + console.log( + `[MoveTask] Context menu attempt ${attempt + 1} failed, retrying...`, + ); + // Close any open menus by pressing Escape + await clientA.page.keyboard.press('Escape'); + await clientA.page.waitForTimeout(300); } - - if (!moveSuccess) { - throw new Error('Failed to move task via context menu after 3 attempts'); - } - - // Verify move locally on A - // Should disappear from current view (Proj 1) - await clientA.page.waitForTimeout(500); // Wait for move animation - const taskInProj1A = clientA.page.locator(`task:has-text("${taskName}")`); - await expect(taskInProj1A).not.toBeVisible({ timeout: 5000 }); - - // Go to Proj 2 and check - const projectBtn2 = clientA.page.locator( - `.nav-sidenav .nav-link:has-text("${proj2Name}")`, - ); - await projectBtn2.click(); - await clientA.page.waitForLoadState('networkidle'); - await clientA.page.waitForTimeout(500); - await waitForTask(clientA.page, taskName); - console.log('[MoveTask] Task verified in Project 2 on Client A'); - - // 5. Sync A -> Sync B - await clientA.sync.syncAndWait(); - await clientB.sync.syncAndWait(); - - // 6. Verify Task is in Project 2 on Client B - const projectBtnB2 = clientB.page.locator( - `.nav-sidenav .nav-link:has-text("${proj2Name}")`, - ); - await projectBtnB2.click(); - await clientB.page.waitForLoadState('networkidle'); - await clientB.page.waitForTimeout(500); - await waitForTask(clientB.page, taskName); - console.log('[MoveTask] Task verified in Project 2 on Client B'); - - // 7. Verify Task is NOT in Project 1 on Client B - await projectBtnB1.click(); - await clientB.page.waitForLoadState('networkidle'); - // Wait for UI to settle after navigation - await clientB.page.waitForTimeout(1000); - // Should not be visible in Project 1 list - const taskInProj1B = clientB.page.locator(`task:has-text("${taskName}")`); - await expect(taskInProj1B).not.toBeVisible({ timeout: 5000 }); - console.log('[MoveTask] Task correctly NOT in Project 1 on Client B'); - - console.log( - '[MoveTask] ✓ Task moved between projects successfully across clients', - ); - } finally { - if (clientA) await closeClient(clientA); - if (clientB) await closeClient(clientB); } - }, - ); + + if (!moveSuccess) { + throw new Error('Failed to move task via context menu after 3 attempts'); + } + + // Verify move locally on A + // Should disappear from current view (Proj 1) + await clientA.page.waitForTimeout(500); // Wait for move animation + const taskInProj1A = clientA.page.locator(`task:has-text("${taskName}")`); + await expect(taskInProj1A).not.toBeVisible({ timeout: 5000 }); + + // Go to Proj 2 and check + const projectBtn2 = clientA.page.locator( + `.nav-sidenav .nav-link:has-text("${proj2Name}")`, + ); + await projectBtn2.click(); + await clientA.page.waitForLoadState('networkidle'); + await clientA.page.waitForTimeout(500); + await waitForTask(clientA.page, taskName); + console.log('[MoveTask] Task verified in Project 2 on Client A'); + + // 5. Sync A -> Sync B + await clientA.sync.syncAndWait(); + await clientB.sync.syncAndWait(); + + // 6. Verify Task is in Project 2 on Client B + const projectBtnB2 = clientB.page.locator( + `.nav-sidenav .nav-link:has-text("${proj2Name}")`, + ); + await projectBtnB2.click(); + await clientB.page.waitForLoadState('networkidle'); + await clientB.page.waitForTimeout(500); + await waitForTask(clientB.page, taskName); + console.log('[MoveTask] Task verified in Project 2 on Client B'); + + // 7. Verify Task is NOT in Project 1 on Client B + await projectBtnB1.click(); + await clientB.page.waitForLoadState('networkidle'); + // Wait for UI to settle after navigation + await clientB.page.waitForTimeout(1000); + // Should not be visible in Project 1 list + const taskInProj1B = clientB.page.locator(`task:has-text("${taskName}")`); + await expect(taskInProj1B).not.toBeVisible({ timeout: 5000 }); + console.log('[MoveTask] Task correctly NOT in Project 1 on Client B'); + + console.log('[MoveTask] ✓ Task moved between projects successfully across clients'); + } finally { + if (clientA) await closeClient(clientA); + if (clientB) await closeClient(clientB); + } + }); /** * Scenario 2: Offline Bursts @@ -278,150 +262,152 @@ base.describe('@supersync SuperSync Edge Cases', () => { * 7. Client B syncs * 8. Verify B has Task 1 (Done), No Task 2, Task 3 (Open) */ - base( - 'Offline burst of changes syncs correctly', - async ({ browser, baseURL }, testInfo) => { - const testRunId = generateTestRunId(testInfo.workerIndex); - const appUrl = baseURL || 'http://localhost:4242'; - let clientA: SimulatedE2EClient | null = null; - let clientB: SimulatedE2EClient | null = null; + test('Offline burst of changes syncs correctly', async ({ + browser, + baseURL, + testRunId, + serverHealthy, + }) => { + void serverHealthy; // Ensure fixture is evaluated for server health check + 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); + try { + const user = await createTestUser(testRunId); + const syncConfig = getSuperSyncConfig(user); - // Setup Clients - clientA = await createSimulatedClient(browser, appUrl, 'A', testRunId); - await clientA.sync.setupSuperSync(syncConfig); + // 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); + clientB = await createSimulatedClient(browser, appUrl, 'B', testRunId); + await clientB.sync.setupSuperSync(syncConfig); - // 1. Client A goes "offline" (we just accumulate changes) - const task1 = `Burst1-${testRunId}`; - const task2 = `Burst2-${testRunId}`; - const task3 = `Burst3-${testRunId}`; + // 1. Client A goes "offline" (we just accumulate changes) + const task1 = `Burst1-${testRunId}`; + const task2 = `Burst2-${testRunId}`; + const task3 = `Burst3-${testRunId}`; - // 3. Create Tasks - wait for each to be visible before creating next - await clientA.workView.addTask(task1); - await waitForTask(clientA.page, task1); - console.log('[BurstTest] Task 1 created'); - await clientA.workView.addTask(task2); - await waitForTask(clientA.page, task2); - console.log('[BurstTest] Task 2 created'); - await clientA.workView.addTask(task3); - await waitForTask(clientA.page, task3); - console.log('[BurstTest] Task 3 created'); + // 3. Create Tasks - wait for each to be visible before creating next + await clientA.workView.addTask(task1); + await waitForTask(clientA.page, task1); + console.log('[BurstTest] Task 1 created'); + await clientA.workView.addTask(task2); + await waitForTask(clientA.page, task2); + console.log('[BurstTest] Task 2 created'); + await clientA.workView.addTask(task3); + await waitForTask(clientA.page, task3); + console.log('[BurstTest] Task 3 created'); - // 4. Mark Task 1 Done with retry logic - let doneSuccess = false; - for (let attempt = 0; attempt < 3 && !doneSuccess; attempt++) { - try { - const taskLocator1 = clientA.page - .locator(`task:not(.ng-animating):has-text("${task1}")`) - .first(); - await taskLocator1.waitFor({ state: 'visible', timeout: 10000 }); - await taskLocator1.hover(); - const doneBtn1 = taskLocator1.locator('.task-done-btn'); - await doneBtn1.waitFor({ state: 'visible', timeout: 5000 }); - await doneBtn1.click(); - await expect(taskLocator1).toHaveClass(/isDone/, { timeout: 5000 }); - doneSuccess = true; - console.log('[BurstTest] Task 1 marked as done'); - } catch (e) { - console.log(`[BurstTest] Done attempt ${attempt + 1} failed, retrying...`); - await clientA.page.waitForTimeout(300); - } + // 4. Mark Task 1 Done with retry logic + let doneSuccess = false; + for (let attempt = 0; attempt < 3 && !doneSuccess; attempt++) { + try { + const taskLocator1 = clientA.page + .locator(`task:not(.ng-animating):has-text("${task1}")`) + .first(); + await taskLocator1.waitFor({ state: 'visible', timeout: 10000 }); + await taskLocator1.hover(); + const doneBtn1 = taskLocator1.locator('.task-done-btn'); + await doneBtn1.waitFor({ state: 'visible', timeout: 5000 }); + await doneBtn1.click(); + await expect(taskLocator1).toHaveClass(/isDone/, { timeout: 5000 }); + doneSuccess = true; + console.log('[BurstTest] Task 1 marked as done'); + } catch (e) { + console.log(`[BurstTest] Done attempt ${attempt + 1} failed, retrying...`); + await clientA.page.waitForTimeout(300); } - if (!doneSuccess) { - throw new Error('Failed to mark task 1 as done after 3 attempts'); - } - // Wait for done animation to complete - await clientA.page.waitForTimeout(500); - - // 5. Delete Task 2 with retry logic for context menu - let deleteSuccess = false; - for (let attempt = 0; attempt < 3 && !deleteSuccess; attempt++) { - try { - const taskLocator2 = clientA.page - .locator(`task:not(.ng-animating):has-text("${task2}")`) - .first(); - await taskLocator2.waitFor({ state: 'visible', timeout: 5000 }); - await taskLocator2.click({ button: 'right' }); - - const deleteMenuItem = clientA.page - .locator('.mat-mdc-menu-item') - .filter({ hasText: 'Delete' }); - await deleteMenuItem.waitFor({ state: 'visible', timeout: 3000 }); - await deleteMenuItem.click(); - - // Handle confirmation dialog - const dialog = clientA.page.locator('dialog-confirm'); - try { - await dialog.waitFor({ state: 'visible', timeout: 2000 }); - await dialog.locator('button[type=submit]').click(); - await dialog.waitFor({ state: 'hidden', timeout: 5000 }); - } catch { - // Dialog may not appear for all delete operations - } - - // Verify task is deleted - await expect(taskLocator2).not.toBeVisible({ timeout: 5000 }); - deleteSuccess = true; - } catch (e) { - console.log(`[BurstTest] Delete attempt ${attempt + 1} failed, retrying...`); - // Close any open menus - await clientA.page.keyboard.press('Escape'); - await clientA.page.waitForTimeout(300); - } - } - - if (!deleteSuccess) { - throw new Error('Failed to delete task after 3 attempts'); - } - - // 6. Client A syncs (burst) - await clientA.sync.syncAndWait(); - console.log('[BurstTest] Client A synced burst changes'); - - // 7. Client B syncs - await clientB.sync.syncAndWait(); - console.log('[BurstTest] Client B synced'); - - // 8. Verify B state - // Wait for sync and UI to settle - await clientB.page.waitForTimeout(1000); - - // Task 1: Visible and Done - use waitForTask for reliability - await waitForTask(clientB.page, task1); - const taskLocatorB1 = clientB.page - .locator(`task:not(.ng-animating):has-text("${task1}")`) - .first(); - await expect(taskLocatorB1).toBeVisible({ timeout: 10000 }); - await expect(taskLocatorB1).toHaveClass(/isDone/, { timeout: 5000 }); - console.log('[BurstTest] Task 1 verified as done on Client B'); - - // Task 2: Not Visible (was deleted) - const taskLocatorB2 = clientB.page.locator(`task:has-text("${task2}")`); - await expect(taskLocatorB2).not.toBeVisible({ timeout: 5000 }); - console.log('[BurstTest] Task 2 verified as deleted on Client B'); - - // Task 3: Visible and Open - use waitForTask for reliability - await waitForTask(clientB.page, task3); - const taskLocatorB3 = clientB.page - .locator(`task:not(.ng-animating):has-text("${task3}")`) - .first(); - await expect(taskLocatorB3).toBeVisible({ timeout: 10000 }); - await expect(taskLocatorB3).not.toHaveClass(/isDone/, { timeout: 5000 }); - console.log('[BurstTest] Task 3 verified as open on Client B'); - - console.log('[BurstTest] ✓ Offline burst changes synced successfully'); - } finally { - if (clientA) await closeClient(clientA); - if (clientB) await closeClient(clientB); } - }, - ); + if (!doneSuccess) { + throw new Error('Failed to mark task 1 as done after 3 attempts'); + } + // Wait for done animation to complete + await clientA.page.waitForTimeout(500); + + // 5. Delete Task 2 with retry logic for context menu + let deleteSuccess = false; + for (let attempt = 0; attempt < 3 && !deleteSuccess; attempt++) { + try { + const taskLocator2 = clientA.page + .locator(`task:not(.ng-animating):has-text("${task2}")`) + .first(); + await taskLocator2.waitFor({ state: 'visible', timeout: 5000 }); + await taskLocator2.click({ button: 'right' }); + + const deleteMenuItem = clientA.page + .locator('.mat-mdc-menu-item') + .filter({ hasText: 'Delete' }); + await deleteMenuItem.waitFor({ state: 'visible', timeout: 3000 }); + await deleteMenuItem.click(); + + // Handle confirmation dialog + const dialog = clientA.page.locator('dialog-confirm'); + try { + await dialog.waitFor({ state: 'visible', timeout: 2000 }); + await dialog.locator('button[type=submit]').click(); + await dialog.waitFor({ state: 'hidden', timeout: 5000 }); + } catch { + // Dialog may not appear for all delete operations + } + + // Verify task is deleted + await expect(taskLocator2).not.toBeVisible({ timeout: 5000 }); + deleteSuccess = true; + } catch (e) { + console.log(`[BurstTest] Delete attempt ${attempt + 1} failed, retrying...`); + // Close any open menus + await clientA.page.keyboard.press('Escape'); + await clientA.page.waitForTimeout(300); + } + } + + if (!deleteSuccess) { + throw new Error('Failed to delete task after 3 attempts'); + } + + // 6. Client A syncs (burst) + await clientA.sync.syncAndWait(); + console.log('[BurstTest] Client A synced burst changes'); + + // 7. Client B syncs + await clientB.sync.syncAndWait(); + console.log('[BurstTest] Client B synced'); + + // 8. Verify B state + // Wait for sync and UI to settle + await clientB.page.waitForTimeout(1000); + + // Task 1: Visible and Done - use waitForTask for reliability + await waitForTask(clientB.page, task1); + const taskLocatorB1 = clientB.page + .locator(`task:not(.ng-animating):has-text("${task1}")`) + .first(); + await expect(taskLocatorB1).toBeVisible({ timeout: 10000 }); + await expect(taskLocatorB1).toHaveClass(/isDone/, { timeout: 5000 }); + console.log('[BurstTest] Task 1 verified as done on Client B'); + + // Task 2: Not Visible (was deleted) + const taskLocatorB2 = clientB.page.locator(`task:has-text("${task2}")`); + await expect(taskLocatorB2).not.toBeVisible({ timeout: 5000 }); + console.log('[BurstTest] Task 2 verified as deleted on Client B'); + + // Task 3: Visible and Open - use waitForTask for reliability + await waitForTask(clientB.page, task3); + const taskLocatorB3 = clientB.page + .locator(`task:not(.ng-animating):has-text("${task3}")`) + .first(); + await expect(taskLocatorB3).toBeVisible({ timeout: 10000 }); + await expect(taskLocatorB3).not.toHaveClass(/isDone/, { timeout: 5000 }); + console.log('[BurstTest] Task 3 verified as open on Client B'); + + console.log('[BurstTest] ✓ Offline burst changes synced successfully'); + } finally { + if (clientA) await closeClient(clientA); + if (clientB) await closeClient(clientB); + } + }); /** * Scenario 5: 3-Way Conflict @@ -442,156 +428,158 @@ base.describe('@supersync SuperSync Edge Cases', () => { * 7. All clients sync again to converge * 8. Verify all 3 clients have identical final state */ - base( - '3-way conflict: 3 clients edit same task concurrently', - async ({ browser, baseURL }, testInfo) => { - testInfo.setTimeout(120000); // 3-way conflicts need more time - const testRunId = generateTestRunId(testInfo.workerIndex); - const appUrl = baseURL || 'http://localhost:4242'; - let clientA: SimulatedE2EClient | null = null; - let clientB: SimulatedE2EClient | null = null; - let clientC: SimulatedE2EClient | null = null; + test('3-way conflict: 3 clients edit same task concurrently', async ({ + browser, + baseURL, + testRunId, + serverHealthy, + }, testInfo) => { + void serverHealthy; // Ensure fixture is evaluated for server health check + testInfo.setTimeout(120000); // 3-way conflicts need more time + 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); + try { + const user = await createTestUser(testRunId); + const syncConfig = getSuperSyncConfig(user); - // Setup all 3 clients - clientA = await createSimulatedClient(browser, appUrl, 'A', testRunId); - await clientA.sync.setupSuperSync(syncConfig); + // Setup all 3 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); + clientB = await createSimulatedClient(browser, appUrl, 'B', testRunId); + await clientB.sync.setupSuperSync(syncConfig); - clientC = await createSimulatedClient(browser, appUrl, 'C', testRunId); - await clientC.sync.setupSuperSync(syncConfig); + clientC = await createSimulatedClient(browser, appUrl, 'C', testRunId); + await clientC.sync.setupSuperSync(syncConfig); - // 1. Client A creates task - const taskName = `3Way-${testRunId}`; - await clientA.workView.addTask(taskName); - // Wait for task to be fully created before syncing - await waitForTask(clientA.page, taskName); - console.log('[3WayConflict] Client A created task'); - await clientA.sync.syncAndWait(); + // 1. Client A creates task + const taskName = `3Way-${testRunId}`; + await clientA.workView.addTask(taskName); + // Wait for task to be fully created before syncing + await waitForTask(clientA.page, taskName); + console.log('[3WayConflict] Client A created task'); + await clientA.sync.syncAndWait(); - // 2. Clients B and C download the task - await clientB.sync.syncAndWait(); - await clientC.sync.syncAndWait(); + // 2. Clients B and C download the task + await clientB.sync.syncAndWait(); + await clientC.sync.syncAndWait(); - // Verify all 3 clients have the task with settling time - await clientA.page.waitForTimeout(500); - await clientB.page.waitForTimeout(500); - await clientC.page.waitForTimeout(500); - await waitForTask(clientA.page, taskName); - await waitForTask(clientB.page, taskName); - await waitForTask(clientC.page, taskName); - console.log('[3WayConflict] All clients have the task'); + // Verify all 3 clients have the task with settling time + await clientA.page.waitForTimeout(500); + await clientB.page.waitForTimeout(500); + await clientC.page.waitForTimeout(500); + await waitForTask(clientA.page, taskName); + await waitForTask(clientB.page, taskName); + await waitForTask(clientC.page, taskName); + console.log('[3WayConflict] All clients have the task'); - // 3. All 3 clients make concurrent changes (no syncs between) - // Helper to mark task as done with retry for stability - const markTaskDone = async ( - page: typeof clientA.page, - name: string, - clientLabel: string, - ): Promise => { - let success = false; - for (let attempt = 0; attempt < 3 && !success; attempt++) { - try { - const taskLoc = page - .locator(`task:not(.ng-animating):has-text("${name}")`) - .first(); - await taskLoc.waitFor({ state: 'visible', timeout: 10000 }); - await taskLoc.hover(); - const doneBtn = taskLoc.locator('.task-done-btn'); - await doneBtn.waitFor({ state: 'visible', timeout: 5000 }); - await doneBtn.click(); - // Wait for done state to be applied - await expect(taskLoc).toHaveClass(/isDone/, { timeout: 5000 }); - // Settling time after marking done - await page.waitForTimeout(300); - success = true; - console.log(`[3WayConflict] Client ${clientLabel} marked task as done`); - } catch (e) { - console.log( - `[3WayConflict] Client ${clientLabel} attempt ${attempt + 1} failed, retrying...`, - ); - await page.waitForTimeout(300); - } - } - if (!success) { - throw new Error( - `Client ${clientLabel} failed to mark task as done after 3 attempts`, + // 3. All 3 clients make concurrent changes (no syncs between) + // Helper to mark task as done with retry for stability + const markTaskDone = async ( + page: typeof clientA.page, + name: string, + clientLabel: string, + ): Promise => { + let success = false; + for (let attempt = 0; attempt < 3 && !success; attempt++) { + try { + const taskLoc = page + .locator(`task:not(.ng-animating):has-text("${name}")`) + .first(); + await taskLoc.waitFor({ state: 'visible', timeout: 10000 }); + await taskLoc.hover(); + const doneBtn = taskLoc.locator('.task-done-btn'); + await doneBtn.waitFor({ state: 'visible', timeout: 5000 }); + await doneBtn.click(); + // Wait for done state to be applied + await expect(taskLoc).toHaveClass(/isDone/, { timeout: 5000 }); + // Settling time after marking done + await page.waitForTimeout(300); + success = true; + console.log(`[3WayConflict] Client ${clientLabel} marked task as done`); + } catch (e) { + console.log( + `[3WayConflict] Client ${clientLabel} attempt ${attempt + 1} failed, retrying...`, ); + await page.waitForTimeout(300); } - }; + } + if (!success) { + throw new Error( + `Client ${clientLabel} failed to mark task as done after 3 attempts`, + ); + } + }; - // Client A: Mark as done - await markTaskDone(clientA.page, taskName, 'A'); + // Client A: Mark as done + await markTaskDone(clientA.page, taskName, 'A'); - // Client B: Mark as done (same action, should merge cleanly) - await markTaskDone(clientB.page, taskName, 'B'); + // Client B: Mark as done (same action, should merge cleanly) + await markTaskDone(clientB.page, taskName, 'B'); - // Client C: Mark as done (same action from third client) - await markTaskDone(clientC.page, taskName, 'C'); + // Client C: Mark as done (same action from third client) + await markTaskDone(clientC.page, taskName, 'C'); - // 4-6. Sequential syncs to resolve conflicts - await clientA.sync.syncAndWait(); - await clientB.sync.syncAndWait(); - await clientC.sync.syncAndWait(); + // 4-6. Sequential syncs to resolve conflicts + await clientA.sync.syncAndWait(); + await clientB.sync.syncAndWait(); + await clientC.sync.syncAndWait(); - // 7. Final round of syncs to converge - await clientA.sync.syncAndWait(); - await clientB.sync.syncAndWait(); - await clientC.sync.syncAndWait(); + // 7. Final round of syncs to converge + await clientA.sync.syncAndWait(); + await clientB.sync.syncAndWait(); + await clientC.sync.syncAndWait(); - // Wait for UI to settle after final sync - await clientA.page.waitForTimeout(1000); - await clientB.page.waitForTimeout(1000); - await clientC.page.waitForTimeout(1000); + // Wait for UI to settle after final sync + await clientA.page.waitForTimeout(1000); + await clientB.page.waitForTimeout(1000); + await clientC.page.waitForTimeout(1000); - // 8. Verify all 3 clients have identical state - // First use waitForTask to ensure tasks are present - await waitForTask(clientA.page, taskName); - await waitForTask(clientB.page, taskName); - await waitForTask(clientC.page, taskName); + // 8. Verify all 3 clients have identical state + // First use waitForTask to ensure tasks are present + await waitForTask(clientA.page, taskName); + await waitForTask(clientB.page, taskName); + await waitForTask(clientC.page, taskName); - // Re-query locators after sync to get fresh references - const finalTaskA = clientA.page - .locator(`task:not(.ng-animating):has-text("${taskName}")`) - .first(); - const finalTaskB = clientB.page - .locator(`task:not(.ng-animating):has-text("${taskName}")`) - .first(); - const finalTaskC = clientC.page - .locator(`task:not(.ng-animating):has-text("${taskName}")`) - .first(); + // Re-query locators after sync to get fresh references + const finalTaskA = clientA.page + .locator(`task:not(.ng-animating):has-text("${taskName}")`) + .first(); + const finalTaskB = clientB.page + .locator(`task:not(.ng-animating):has-text("${taskName}")`) + .first(); + const finalTaskC = clientC.page + .locator(`task:not(.ng-animating):has-text("${taskName}")`) + .first(); - // Task should exist and be marked as done on all clients - await expect(finalTaskA).toBeVisible({ timeout: 10000 }); - await expect(finalTaskB).toBeVisible({ timeout: 10000 }); - await expect(finalTaskC).toBeVisible({ timeout: 10000 }); + // Task should exist and be marked as done on all clients + await expect(finalTaskA).toBeVisible({ timeout: 10000 }); + await expect(finalTaskB).toBeVisible({ timeout: 10000 }); + await expect(finalTaskC).toBeVisible({ timeout: 10000 }); - // All should show task as done - await expect(finalTaskA).toHaveClass(/isDone/, { timeout: 5000 }); - await expect(finalTaskB).toHaveClass(/isDone/, { timeout: 5000 }); - await expect(finalTaskC).toHaveClass(/isDone/, { timeout: 5000 }); - console.log('[3WayConflict] All clients verified with done state'); + // All should show task as done + await expect(finalTaskA).toHaveClass(/isDone/, { timeout: 5000 }); + await expect(finalTaskB).toHaveClass(/isDone/, { timeout: 5000 }); + await expect(finalTaskC).toHaveClass(/isDone/, { timeout: 5000 }); + console.log('[3WayConflict] All clients verified with done state'); - // Count tasks - should be identical - const countA = await clientA.page.locator('task').count(); - const countB = await clientB.page.locator('task').count(); - const countC = await clientC.page.locator('task').count(); - expect(countA).toBe(countB); - expect(countB).toBe(countC); + // Count tasks - should be identical + const countA = await clientA.page.locator('task').count(); + const countB = await clientB.page.locator('task').count(); + const countC = await clientC.page.locator('task').count(); + expect(countA).toBe(countB); + expect(countB).toBe(countC); - console.log('[3WayConflict] ✓ 3-way conflict resolved, all clients converged'); - } finally { - if (clientA) await closeClient(clientA); - if (clientB) await closeClient(clientB); - if (clientC) await closeClient(clientC); - } - }, - ); + console.log('[3WayConflict] ✓ 3-way conflict resolved, all clients converged'); + } finally { + if (clientA) await closeClient(clientA); + if (clientB) await closeClient(clientB); + if (clientC) await closeClient(clientC); + } + }); /** * Scenario 6: Delete vs Update Conflict @@ -609,87 +597,89 @@ base.describe('@supersync SuperSync Edge Cases', () => { * 5. Client B syncs (update conflicts with deletion) * 6. Verify final state is consistent (delete wins or conflict resolved) */ - base( - 'Delete vs Update conflict: one client deletes while another updates', - async ({ browser, baseURL }, testInfo) => { - testInfo.setTimeout(90000); - const testRunId = generateTestRunId(testInfo.workerIndex); - const appUrl = baseURL || 'http://localhost:4242'; - let clientA: SimulatedE2EClient | null = null; - let clientB: SimulatedE2EClient | null = null; + test('Delete vs Update conflict: one client deletes while another updates', async ({ + browser, + baseURL, + testRunId, + serverHealthy, + }, testInfo) => { + void serverHealthy; // Ensure fixture is evaluated for server health check + testInfo.setTimeout(90000); + 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); + try { + const user = await createTestUser(testRunId); + const syncConfig = getSuperSyncConfig(user); - // Setup clients - clientA = await createSimulatedClient(browser, appUrl, 'A', testRunId); - await clientA.sync.setupSuperSync(syncConfig); + // 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); + clientB = await createSimulatedClient(browser, appUrl, 'B', testRunId); + await clientB.sync.setupSuperSync(syncConfig); - // 1. Client A creates task - const taskName = `DelUpd-${testRunId}`; - await clientA.workView.addTask(taskName); - await clientA.sync.syncAndWait(); + // 1. Client A creates task + const taskName = `DelUpd-${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); + // 2. Client B downloads the task + await clientB.sync.syncAndWait(); + await waitForTask(clientB.page, taskName); - // 3. Concurrent changes + // 3. Concurrent changes - // Client A: Delete the task - const taskLocatorA = clientA.page.locator(`task:has-text("${taskName}")`); - await taskLocatorA.click({ button: 'right' }); - await clientA.page - .locator('.mat-mdc-menu-item') - .filter({ hasText: 'Delete' }) - .click(); + // Client A: Delete the task + const taskLocatorA = clientA.page.locator(`task:has-text("${taskName}")`); + await taskLocatorA.click({ button: 'right' }); + await clientA.page + .locator('.mat-mdc-menu-item') + .filter({ hasText: 'Delete' }) + .click(); - // Handle confirmation dialog if present - const dialogA = clientA.page.locator('dialog-confirm'); - if (await dialogA.isVisible({ timeout: 2000 }).catch(() => false)) { - await dialogA.locator('button[type=submit]').click(); - } - await expect(taskLocatorA).not.toBeVisible(); - - // Client B: Mark as done (concurrent with deletion) - const taskLocatorB = clientB.page.locator(`task:has-text("${taskName}")`); - await taskLocatorB.hover(); - await taskLocatorB.locator('.task-done-btn').click(); - - // 4. Client A syncs (delete goes to server first) - await clientA.sync.syncAndWait(); - - // 5. Client B syncs (update conflicts with deletion) - // The conflict resolution may show a dialog or auto-resolve - await clientB.sync.syncAndWait(); - - // 6. Final sync to converge - await clientA.sync.syncAndWait(); - await clientB.sync.syncAndWait(); - - // Verify consistent state - // Both clients should have the same view (either both have task or neither) - const hasTaskA = - (await clientA.page.locator(`task:has-text("${taskName}")`).count()) > 0; - const hasTaskB = - (await clientB.page.locator(`task:has-text("${taskName}")`).count()) > 0; - - // State should be consistent (doesn't matter which wins, just that they agree) - expect(hasTaskA).toBe(hasTaskB); - - console.log( - `[DeleteVsUpdate] ✓ Conflict resolved consistently (task ${hasTaskA ? 'restored' : 'deleted'})`, - ); - } finally { - if (clientA) await closeClient(clientA); - if (clientB) await closeClient(clientB); + // Handle confirmation dialog if present + const dialogA = clientA.page.locator('dialog-confirm'); + if (await dialogA.isVisible({ timeout: 2000 }).catch(() => false)) { + await dialogA.locator('button[type=submit]').click(); } - }, - ); + await expect(taskLocatorA).not.toBeVisible(); + + // Client B: Mark as done (concurrent with deletion) + const taskLocatorB = clientB.page.locator(`task:has-text("${taskName}")`); + await taskLocatorB.hover(); + await taskLocatorB.locator('.task-done-btn').click(); + + // 4. Client A syncs (delete goes to server first) + await clientA.sync.syncAndWait(); + + // 5. Client B syncs (update conflicts with deletion) + // The conflict resolution may show a dialog or auto-resolve + await clientB.sync.syncAndWait(); + + // 6. Final sync to converge + await clientA.sync.syncAndWait(); + await clientB.sync.syncAndWait(); + + // Verify consistent state + // Both clients should have the same view (either both have task or neither) + const hasTaskA = + (await clientA.page.locator(`task:has-text("${taskName}")`).count()) > 0; + const hasTaskB = + (await clientB.page.locator(`task:has-text("${taskName}")`).count()) > 0; + + // State should be consistent (doesn't matter which wins, just that they agree) + expect(hasTaskA).toBe(hasTaskB); + + console.log( + `[DeleteVsUpdate] ✓ Conflict resolved consistently (task ${hasTaskA ? 'restored' : 'deleted'})`, + ); + } finally { + if (clientA) await closeClient(clientA); + if (clientB) await closeClient(clientB); + } + }); /** * Scenario 7: Undo Task Delete Syncs Across Devices @@ -706,102 +696,102 @@ base.describe('@supersync SuperSync Edge Cases', () => { * 6. Client B syncs * 7. Verify Task exists on both clients */ - base( - 'Undo task delete syncs restored task to other client', - async ({ browser, baseURL }, testInfo) => { - const testRunId = generateTestRunId(testInfo.workerIndex); - const appUrl = baseURL || 'http://localhost:4242'; - let clientA: SimulatedE2EClient | null = null; - let clientB: SimulatedE2EClient | null = null; + test('Undo task delete syncs restored task to other client', async ({ + browser, + baseURL, + testRunId, + serverHealthy, + }) => { + void serverHealthy; // Ensure fixture is evaluated for server health check + 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); + try { + const user = await createTestUser(testRunId); + const syncConfig = getSuperSyncConfig(user); - // Setup Clients - clientA = await createSimulatedClient(browser, appUrl, 'A', testRunId); - await clientA.sync.setupSuperSync(syncConfig); + // 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); + clientB = await createSimulatedClient(browser, appUrl, 'B', testRunId); + await clientB.sync.setupSuperSync(syncConfig); - // 1. Client A creates task - const taskName = `UndoDelete-${testRunId}`; - await clientA.workView.addTask(taskName); - await clientA.sync.syncAndWait(); + // 1. Client A creates task + const taskName = `UndoDelete-${testRunId}`; + await clientA.workView.addTask(taskName); + await clientA.sync.syncAndWait(); - // 2. Client B syncs (download task) - await clientB.sync.syncAndWait(); + // 2. Client B syncs (download task) + await clientB.sync.syncAndWait(); - // Verify task exists on both clients - await waitForTask(clientA.page, taskName); - await waitForTask(clientB.page, taskName); + // Verify task exists on both clients + await waitForTask(clientA.page, taskName); + await waitForTask(clientB.page, taskName); - // 3. Client A deletes the task - const taskLocatorA = clientA.page - .locator(`task:not(.ng-animating):has-text("${taskName}")`) - .first(); - await taskLocatorA.click({ button: 'right' }); - await clientA.page - .locator('.mat-mdc-menu-item') - .filter({ hasText: 'Delete' }) - .click(); + // 3. Client A deletes the task + const taskLocatorA = clientA.page + .locator(`task:not(.ng-animating):has-text("${taskName}")`) + .first(); + await taskLocatorA.click({ button: 'right' }); + await clientA.page + .locator('.mat-mdc-menu-item') + .filter({ hasText: 'Delete' }) + .click(); - // Handle confirmation dialog if present - const dialog = clientA.page.locator('dialog-confirm'); - if (await dialog.isVisible()) { - await dialog.locator('button[type=submit]').click(); - } - - // Verify task is deleted locally - await expect(taskLocatorA).not.toBeVisible({ timeout: 5000 }); - - // 4. Client A clicks Undo (snackbar should be visible) - // The snackbar appears for 5 seconds with an "Undo" action - // Use snack-custom .action selector (app uses custom snackbar component) - const undoButton = clientA.page.locator('snack-custom button.action'); - await undoButton.waitFor({ state: 'visible', timeout: 5000 }); - await undoButton.click(); - - // Wait for undo to complete - await clientA.page.waitForTimeout(500); - - // Verify task is restored locally on A - const restoredTaskA = clientA.page - .locator(`task:not(.ng-animating):has-text("${taskName}")`) - .first(); - await expect(restoredTaskA).toBeVisible({ timeout: 5000 }); - - // 5. Client A syncs - await clientA.sync.syncAndWait(); - - // 6. Client B syncs (should receive the restore action) - await clientB.sync.syncAndWait(); - - // Wait for UI to update - await clientB.page.waitForTimeout(500); - - // 7. Verify Task exists on Client B after sync - const taskLocatorB = clientB.page - .locator(`task:not(.ng-animating):has-text("${taskName}")`) - .first(); - await expect(taskLocatorB).toBeVisible({ timeout: 10000 }); - - // Verify both clients have exactly the same task count - const countA = await clientA.page.locator(`task:has-text("${taskName}")`).count(); - const countB = await clientB.page.locator(`task:has-text("${taskName}")`).count(); - expect(countA).toBe(1); - expect(countB).toBe(1); - - console.log( - '[UndoDelete] ✓ Undo task delete synced successfully to other client', - ); - } finally { - if (clientA) await closeClient(clientA); - if (clientB) await closeClient(clientB); + // Handle confirmation dialog if present + const dialog = clientA.page.locator('dialog-confirm'); + if (await dialog.isVisible()) { + await dialog.locator('button[type=submit]').click(); } - }, - ); + + // Verify task is deleted locally + await expect(taskLocatorA).not.toBeVisible({ timeout: 5000 }); + + // 4. Client A clicks Undo (snackbar should be visible) + // The snackbar appears for 5 seconds with an "Undo" action + // Use snack-custom .action selector (app uses custom snackbar component) + const undoButton = clientA.page.locator('snack-custom button.action'); + await undoButton.waitFor({ state: 'visible', timeout: 5000 }); + await undoButton.click(); + + // Wait for undo to complete + await clientA.page.waitForTimeout(500); + + // Verify task is restored locally on A + const restoredTaskA = clientA.page + .locator(`task:not(.ng-animating):has-text("${taskName}")`) + .first(); + await expect(restoredTaskA).toBeVisible({ timeout: 5000 }); + + // 5. Client A syncs + await clientA.sync.syncAndWait(); + + // 6. Client B syncs (should receive the restore action) + await clientB.sync.syncAndWait(); + + // Wait for UI to update + await clientB.page.waitForTimeout(500); + + // 7. Verify Task exists on Client B after sync + const taskLocatorB = clientB.page + .locator(`task:not(.ng-animating):has-text("${taskName}")`) + .first(); + await expect(taskLocatorB).toBeVisible({ timeout: 10000 }); + + // Verify both clients have exactly the same task count + const countA = await clientA.page.locator(`task:has-text("${taskName}")`).count(); + const countB = await clientB.page.locator(`task:has-text("${taskName}")`).count(); + expect(countA).toBe(1); + expect(countB).toBe(1); + + console.log('[UndoDelete] ✓ Undo task delete synced successfully to other client'); + } finally { + if (clientA) await closeClient(clientA); + if (clientB) await closeClient(clientB); + } + }); /** * Scenario 8: Rejected Op Does Not Pollute Entity Frontier @@ -822,97 +812,95 @@ base.describe('@supersync SuperSync Edge Cases', () => { * 8. Verify Task2 syncs successfully to Client A * (proves frontier wasn't polluted by rejected op) */ - base( - 'Rejected op does not pollute entity frontier for subsequent syncs', - async ({ browser, baseURL }, testInfo) => { - testInfo.setTimeout(120000); - const testRunId = generateTestRunId(testInfo.workerIndex); - const appUrl = baseURL || 'http://localhost:4242'; - let clientA: SimulatedE2EClient | null = null; - let clientB: SimulatedE2EClient | null = null; + test('Rejected op does not pollute entity frontier for subsequent syncs', async ({ + browser, + baseURL, + testRunId, + serverHealthy, + }, testInfo) => { + void serverHealthy; // Ensure fixture is evaluated for server health check + testInfo.setTimeout(120000); + 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); + try { + const user = await createTestUser(testRunId); + const syncConfig = getSuperSyncConfig(user); - // Setup clients - clientA = await createSimulatedClient(browser, appUrl, 'A', testRunId); - await clientA.sync.setupSuperSync(syncConfig); + // 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); + clientB = await createSimulatedClient(browser, appUrl, 'B', testRunId); + await clientB.sync.setupSuperSync(syncConfig); - // 1. Client A creates Task1 - const task1Name = `ConflictTask-${testRunId}`; - await clientA.workView.addTask(task1Name); - await clientA.sync.syncAndWait(); + // 1. Client A creates Task1 + const task1Name = `ConflictTask-${testRunId}`; + await clientA.workView.addTask(task1Name); + await clientA.sync.syncAndWait(); - // 2. Client B downloads Task1 - await clientB.sync.syncAndWait(); - await waitForTask(clientB.page, task1Name); + // 2. Client B downloads Task1 + await clientB.sync.syncAndWait(); + await waitForTask(clientB.page, task1Name); - // 3. Both clients concurrently edit Task1 (mark as done) - // Client A marks done - use .first() to avoid strict mode violation from animation duplicates - const taskLocatorA = clientA.page - .locator(`task:not(.ng-animating):has-text("${task1Name}")`) - .first(); - await taskLocatorA.hover(); - await taskLocatorA.locator('.task-done-btn').click(); - await expect(taskLocatorA).toHaveClass(/isDone/, { timeout: 5000 }); + // 3. Both clients concurrently edit Task1 (mark as done) + // Client A marks done - use .first() to avoid strict mode violation from animation duplicates + const taskLocatorA = clientA.page + .locator(`task:not(.ng-animating):has-text("${task1Name}")`) + .first(); + await taskLocatorA.hover(); + await taskLocatorA.locator('.task-done-btn').click(); + await expect(taskLocatorA).toHaveClass(/isDone/, { timeout: 5000 }); - // Client B also marks done (concurrent change, will conflict) - const taskLocatorB = clientB.page - .locator(`task:not(.ng-animating):has-text("${task1Name}")`) - .first(); - await taskLocatorB.hover(); - await taskLocatorB.locator('.task-done-btn').click(); - await expect(taskLocatorB).toHaveClass(/isDone/, { timeout: 5000 }); + // Client B also marks done (concurrent change, will conflict) + const taskLocatorB = clientB.page + .locator(`task:not(.ng-animating):has-text("${task1Name}")`) + .first(); + await taskLocatorB.hover(); + await taskLocatorB.locator('.task-done-btn').click(); + await expect(taskLocatorB).toHaveClass(/isDone/, { timeout: 5000 }); - // 4. Client A syncs first (succeeds) - await clientA.sync.syncAndWait(); + // 4. Client A syncs first (succeeds) + await clientA.sync.syncAndWait(); - // 5. Client B syncs (will get conflict/rejection for Task1 edit) - // The conflict may be auto-resolved or show dialog - either way, B's op is rejected - await clientB.sync.syncAndWait(); + // 5. Client B syncs (will get conflict/rejection for Task1 edit) + // The conflict may be auto-resolved or show dialog - either way, B's op is rejected + await clientB.sync.syncAndWait(); - // 6. Client B creates a NEW, UNRELATED Task2 - // This is the critical part: if rejected op polluted the frontier, - // this new task might fail to sync or cause unexpected conflicts - const task2Name = `NewTaskAfterReject-${testRunId}`; - await clientB.workView.addTask(task2Name); + // 6. Client B creates a NEW, UNRELATED Task2 + // This is the critical part: if rejected op polluted the frontier, + // this new task might fail to sync or cause unexpected conflicts + const task2Name = `NewTaskAfterReject-${testRunId}`; + await clientB.workView.addTask(task2Name); - // Verify Task2 exists locally on B - await waitForTask(clientB.page, task2Name); + // Verify Task2 exists locally on B + await waitForTask(clientB.page, task2Name); - // 7. Client B syncs again (Task2 should sync successfully) - await clientB.sync.syncAndWait(); + // 7. Client B syncs again (Task2 should sync successfully) + await clientB.sync.syncAndWait(); - // 8. Client A syncs to receive Task2 - await clientA.sync.syncAndWait(); + // 8. Client A syncs to receive Task2 + await clientA.sync.syncAndWait(); - // Verify Task2 appeared on Client A - // This proves the frontier wasn't polluted by the rejected op - await waitForTask(clientA.page, task2Name); + // Verify Task2 appeared on Client A + // This proves the frontier wasn't polluted by the rejected op + await waitForTask(clientA.page, task2Name); - // Additional verification: count tasks on both clients - const countA = await clientA.page - .locator(`task:has-text("${testRunId}")`) - .count(); - const countB = await clientB.page - .locator(`task:has-text("${testRunId}")`) - .count(); + // Additional verification: count tasks on both clients + const countA = await clientA.page.locator(`task:has-text("${testRunId}")`).count(); + const countB = await clientB.page.locator(`task:has-text("${testRunId}")`).count(); - // Both should have 2 tasks (Task1 and Task2) - expect(countA).toBe(2); - expect(countB).toBe(2); + // Both should have 2 tasks (Task1 and Task2) + expect(countA).toBe(2); + expect(countB).toBe(2); - console.log( - '[RejectedOpFrontier] ✓ Rejected op did not pollute frontier - subsequent sync succeeded', - ); - } finally { - if (clientA) await closeClient(clientA); - if (clientB) await closeClient(clientB); - } - }, - ); + console.log( + '[RejectedOpFrontier] ✓ Rejected op did not pollute frontier - subsequent sync succeeded', + ); + } finally { + if (clientA) await closeClient(clientA); + if (clientB) await closeClient(clientB); + } + }); }); diff --git a/e2e/tests/sync/supersync-encryption-password-change.spec.ts b/e2e/tests/sync/supersync-encryption-password-change.spec.ts index c622f3046..394195009 100644 --- a/e2e/tests/sync/supersync-encryption-password-change.spec.ts +++ b/e2e/tests/sync/supersync-encryption-password-change.spec.ts @@ -1,13 +1,13 @@ -import { test as base, expect } from '@playwright/test'; +import { test, expect } from '../../fixtures/supersync.fixture'; import { createTestUser, getSuperSyncConfig, createSimulatedClient, closeClient, waitForTask, - isServerHealthy, type SimulatedE2EClient, } from '../../utils/supersync-helpers'; +import { expectTaskVisible } from '../../utils/supersync-assertions'; /** * SuperSync Encryption Password Change E2E Tests @@ -21,337 +21,319 @@ import { * Run with E2E_VERBOSE=1 to see browser console logs for debugging. */ -const generateTestRunId = (workerIndex: number): string => { - return `${Date.now()}-${workerIndex}`; -}; +test.describe('@supersync SuperSync Encryption Password Change', () => { + test('Password change preserves existing data', async ({ + browser, + baseURL, + testRunId, + }) => { + let clientA: SimulatedE2EClient | null = null; -base.describe('@supersync SuperSync Encryption Password Change', () => { - let serverHealthy: boolean | null = null; + try { + const user = await createTestUser(testRunId); + const baseConfig = getSuperSyncConfig(user); + const oldPassword = `oldpass-${testRunId}`; + const newPassword = `newpass-${testRunId}`; - base.beforeEach(async ({}, testInfo) => { - if (serverHealthy === null) { - serverHealthy = await isServerHealthy(); - if (!serverHealthy) { - console.warn( - 'SuperSync server not healthy at http://localhost:1901 - skipping tests', - ); - } + // --- Setup with initial password --- + clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); + await clientA.sync.setupSuperSync({ + ...baseConfig, + isEncryptionEnabled: true, + password: oldPassword, + }); + + // Create tasks (names must be distinct - no substring overlap) + const task1 = `TaskA-${testRunId}`; + const task2 = `TaskB-${testRunId}`; + await clientA.workView.addTask(task1); + await clientA.page.waitForTimeout(100); + await clientA.workView.addTask(task2); + + // Sync with old password + await clientA.sync.syncAndWait(); + + // Verify tasks exist + await waitForTask(clientA.page, task1); + await waitForTask(clientA.page, task2); + + // --- Change password --- + await clientA.sync.changeEncryptionPassword(newPassword); + + // --- Verify tasks still exist after password change --- + await expectTaskVisible(clientA, task1); + await expectTaskVisible(clientA, task2); + + // Trigger another sync to verify everything works + await clientA.sync.syncAndWait(); + + // Tasks should still be there + await expectTaskVisible(clientA, task1); + await expectTaskVisible(clientA, task2); + } finally { + if (clientA) await closeClient(clientA); } - testInfo.skip(!serverHealthy, 'SuperSync server not running'); }); - base( - 'Password change preserves existing data', - async ({ browser, baseURL }, testInfo) => { - const testRunId = generateTestRunId(testInfo.workerIndex); - let clientA: SimulatedE2EClient | null = null; + test('New client can sync with new password after password change', async ({ + browser, + baseURL, + testRunId, + }) => { + let clientA: SimulatedE2EClient | null = null; + let clientB: SimulatedE2EClient | null = null; + try { + const user = await createTestUser(testRunId); + const baseConfig = getSuperSyncConfig(user); + const oldPassword = `oldpass-${testRunId}`; + const newPassword = `newpass-${testRunId}`; + + // --- Client A: Setup and create data --- + clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); + await clientA.sync.setupSuperSync({ + ...baseConfig, + isEncryptionEnabled: true, + password: oldPassword, + }); + + const taskName = `BeforeChange-${testRunId}`; + await clientA.workView.addTask(taskName); + await clientA.sync.syncAndWait(); + + // --- Client A: Change password --- + await clientA.sync.changeEncryptionPassword(newPassword); + + // --- Client B: Setup with NEW password --- + clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); + await clientB.sync.setupSuperSync({ + ...baseConfig, + isEncryptionEnabled: true, + password: newPassword, + }); + + // Sync should succeed with new password + await clientB.sync.syncAndWait(); + + // Verify task synced to Client B + await waitForTask(clientB.page, taskName); + await expectTaskVisible(clientB, taskName); + } finally { + if (clientA) await closeClient(clientA); + if (clientB) await closeClient(clientB); + } + }); + + test('Old password fails after password change', async ({ + browser, + baseURL, + testRunId, + }) => { + let clientA: SimulatedE2EClient | null = null; + let clientC: SimulatedE2EClient | null = null; + + try { + const user = await createTestUser(testRunId); + const baseConfig = getSuperSyncConfig(user); + const oldPassword = `oldpass-${testRunId}`; + const newPassword = `newpass-${testRunId}`; + + // --- Client A: Setup, create data, and change password --- + clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); + await clientA.sync.setupSuperSync({ + ...baseConfig, + isEncryptionEnabled: true, + password: oldPassword, + }); + + const taskName = `SecretTask-${testRunId}`; + await clientA.workView.addTask(taskName); + await clientA.sync.syncAndWait(); + + // Change password + await clientA.sync.changeEncryptionPassword(newPassword); + + // --- Client C: Try to sync with OLD password --- + clientC = await createSimulatedClient(browser, baseURL!, 'C', testRunId); + await clientC.sync.setupSuperSync({ + ...baseConfig, + isEncryptionEnabled: true, + password: oldPassword, // Using OLD password! + }); + + // Try to sync - should fail or not get the data try { - const user = await createTestUser(testRunId); - const baseConfig = getSuperSyncConfig(user); - const oldPassword = `oldpass-${testRunId}`; - const newPassword = `newpass-${testRunId}`; - - // --- Setup with initial password --- - clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); - await clientA.sync.setupSuperSync({ - ...baseConfig, - isEncryptionEnabled: true, - password: oldPassword, - }); - - // Create tasks (names must be distinct - no substring overlap) - const task1 = `TaskA-${testRunId}`; - const task2 = `TaskB-${testRunId}`; - await clientA.workView.addTask(task1); - await clientA.page.waitForTimeout(100); - await clientA.workView.addTask(task2); - - // Sync with old password - await clientA.sync.syncAndWait(); - - // Verify tasks exist - await waitForTask(clientA.page, task1); - await waitForTask(clientA.page, task2); - - // --- Change password --- - await clientA.sync.changeEncryptionPassword(newPassword); - - // --- Verify tasks still exist after password change --- - await expect(clientA.page.locator(`task:has-text("${task1}")`)).toBeVisible(); - await expect(clientA.page.locator(`task:has-text("${task2}")`)).toBeVisible(); - - // Trigger another sync to verify everything works - await clientA.sync.syncAndWait(); - - // Tasks should still be there - await expect(clientA.page.locator(`task:has-text("${task1}")`)).toBeVisible(); - await expect(clientA.page.locator(`task:has-text("${task2}")`)).toBeVisible(); - } finally { - if (clientA) await closeClient(clientA); + await clientC.sync.triggerSync(); + await clientC.sync.waitForSyncComplete(); + } catch (e) { + // Expected - sync may throw an error + console.log('Sync with old password failed as expected:', e); } - }, - ); - base( - 'New client can sync with new password after password change', - async ({ browser, baseURL }, testInfo) => { - const testRunId = generateTestRunId(testInfo.workerIndex); - let clientA: SimulatedE2EClient | null = null; - let clientB: SimulatedE2EClient | null = null; + // Verify Client C does NOT have the task + await expect( + clientC.page.locator(`task:has-text("${taskName}")`), + ).not.toBeVisible(); - try { - const user = await createTestUser(testRunId); - const baseConfig = getSuperSyncConfig(user); - const oldPassword = `oldpass-${testRunId}`; - const newPassword = `newpass-${testRunId}`; + // Check for error state + const hasError = await clientC.sync.hasSyncError(); + const snackbar = clientC.page.locator('simple-snack-bar'); + const snackbarVisible = await snackbar.isVisible().catch(() => false); - // --- Client A: Setup and create data --- - clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); - await clientA.sync.setupSuperSync({ - ...baseConfig, - isEncryptionEnabled: true, - password: oldPassword, - }); - - const taskName = `BeforeChange-${testRunId}`; - await clientA.workView.addTask(taskName); - await clientA.sync.syncAndWait(); - - // --- Client A: Change password --- - await clientA.sync.changeEncryptionPassword(newPassword); - - // --- Client B: Setup with NEW password --- - clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); - await clientB.sync.setupSuperSync({ - ...baseConfig, - isEncryptionEnabled: true, - password: newPassword, - }); - - // Sync should succeed with new password - await clientB.sync.syncAndWait(); - - // Verify task synced to Client B - await waitForTask(clientB.page, taskName); - await expect(clientB.page.locator(`task:has-text("${taskName}")`)).toBeVisible(); - } finally { - if (clientA) await closeClient(clientA); - if (clientB) await closeClient(clientB); + // Either error icon or error snackbar should be visible + if (!hasError && !snackbarVisible) { + // If no visible error, at least verify no data was synced + const taskCount = await clientC.page.locator('task').count(); + console.log(`Client C has ${taskCount} tasks (should be 0 real tasks)`); } - }, - ); + } finally { + if (clientA) await closeClient(clientA); + if (clientC) await closeClient(clientC); + } + }); - base( - 'Old password fails after password change', - async ({ browser, baseURL }, testInfo) => { - const testRunId = generateTestRunId(testInfo.workerIndex); - let clientA: SimulatedE2EClient | null = null; - let clientC: SimulatedE2EClient | null = null; + test('Bidirectional sync works after password change', async ({ + browser, + baseURL, + testRunId, + }) => { + let clientA: SimulatedE2EClient | null = null; + let clientB: SimulatedE2EClient | null = null; - try { - const user = await createTestUser(testRunId); - const baseConfig = getSuperSyncConfig(user); - const oldPassword = `oldpass-${testRunId}`; - const newPassword = `newpass-${testRunId}`; + try { + const user = await createTestUser(testRunId); + const baseConfig = getSuperSyncConfig(user); + const oldPassword = `oldpass-${testRunId}`; + const newPassword = `newpass-${testRunId}`; - // --- Client A: Setup, create data, and change password --- - clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); - await clientA.sync.setupSuperSync({ - ...baseConfig, - isEncryptionEnabled: true, - password: oldPassword, - }); + // --- Setup both clients with old password --- + clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); + await clientA.sync.setupSuperSync({ + ...baseConfig, + isEncryptionEnabled: true, + password: oldPassword, + }); - const taskName = `SecretTask-${testRunId}`; - await clientA.workView.addTask(taskName); - await clientA.sync.syncAndWait(); + clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); + await clientB.sync.setupSuperSync({ + ...baseConfig, + isEncryptionEnabled: true, + password: oldPassword, + }); - // Change password - await clientA.sync.changeEncryptionPassword(newPassword); + // --- Create initial tasks and sync --- + const taskFromA = `FromA-${testRunId}`; + await clientA.workView.addTask(taskFromA); + await clientA.sync.syncAndWait(); - // --- Client C: Try to sync with OLD password --- - clientC = await createSimulatedClient(browser, baseURL!, 'C', testRunId); - await clientC.sync.setupSuperSync({ - ...baseConfig, - isEncryptionEnabled: true, - password: oldPassword, // Using OLD password! - }); + await clientB.sync.syncAndWait(); + await waitForTask(clientB.page, taskFromA); - // Try to sync - should fail or not get the data - try { - await clientC.sync.triggerSync(); - await clientC.sync.waitForSyncComplete(); - } catch (e) { - // Expected - sync may throw an error - console.log('Sync with old password failed as expected:', e); - } + // --- Client A changes password --- + await clientA.sync.changeEncryptionPassword(newPassword); - // Verify Client C does NOT have the task - await expect( - clientC.page.locator(`task:has-text("${taskName}")`), - ).not.toBeVisible(); + // --- Client B must reconfigure with new password --- + // Close and recreate with new password (simulating user entering new password) + await closeClient(clientB); + clientB = await createSimulatedClient(browser, baseURL!, 'B2', testRunId); + await clientB.sync.setupSuperSync({ + ...baseConfig, + isEncryptionEnabled: true, + password: newPassword, + }); + await clientB.sync.syncAndWait(); - // Check for error state - const hasError = await clientC.sync.hasSyncError(); - const snackbar = clientC.page.locator('simple-snack-bar'); - const snackbarVisible = await snackbar.isVisible().catch(() => false); + // Verify B has the task + await waitForTask(clientB.page, taskFromA); - // Either error icon or error snackbar should be visible - if (!hasError && !snackbarVisible) { - // If no visible error, at least verify no data was synced - const taskCount = await clientC.page.locator('task').count(); - console.log(`Client C has ${taskCount} tasks (should be 0 real tasks)`); - } - } finally { - if (clientA) await closeClient(clientA); - if (clientC) await closeClient(clientC); - } - }, - ); + // --- Client B creates a new task --- + const taskFromB = `FromB-${testRunId}`; + await clientB.workView.addTask(taskFromB); + await clientB.sync.syncAndWait(); - base( - 'Bidirectional sync works after password change', - async ({ browser, baseURL }, testInfo) => { - const testRunId = generateTestRunId(testInfo.workerIndex); - let clientA: SimulatedE2EClient | null = null; - let clientB: SimulatedE2EClient | null = null; + // --- Client A syncs and should get B's task --- + await clientA.sync.syncAndWait(); + await waitForTask(clientA.page, taskFromB); - try { - const user = await createTestUser(testRunId); - const baseConfig = getSuperSyncConfig(user); - const oldPassword = `oldpass-${testRunId}`; - const newPassword = `newpass-${testRunId}`; + // Verify both clients have both tasks + await expectTaskVisible(clientA, taskFromA); + await expectTaskVisible(clientA, taskFromB); + await expectTaskVisible(clientB, taskFromA); + await expectTaskVisible(clientB, taskFromB); + } finally { + if (clientA) await closeClient(clientA); + if (clientB) await closeClient(clientB); + } + }); - // --- Setup both clients with old password --- - clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); - await clientA.sync.setupSuperSync({ - ...baseConfig, - isEncryptionEnabled: true, - password: oldPassword, - }); + test('Multiple password changes work correctly', async ({ + browser, + baseURL, + testRunId, + }) => { + let clientA: SimulatedE2EClient | null = null; + let clientB: SimulatedE2EClient | null = null; - clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); - await clientB.sync.setupSuperSync({ - ...baseConfig, - isEncryptionEnabled: true, - password: oldPassword, - }); + try { + const user = await createTestUser(testRunId); + const baseConfig = getSuperSyncConfig(user); + const password1 = `pass1-${testRunId}`; + const password2 = `pass2-${testRunId}`; + const password3 = `pass3-${testRunId}`; - // --- Create initial tasks and sync --- - const taskFromA = `FromA-${testRunId}`; - await clientA.workView.addTask(taskFromA); - await clientA.sync.syncAndWait(); + // --- Setup with password1 --- + clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); + await clientA.sync.setupSuperSync({ + ...baseConfig, + isEncryptionEnabled: true, + password: password1, + }); - await clientB.sync.syncAndWait(); - await waitForTask(clientB.page, taskFromA); + // Create and sync task1 + const task1 = `Task1-${testRunId}`; + await clientA.workView.addTask(task1); + await clientA.sync.syncAndWait(); - // --- Client A changes password --- - await clientA.sync.changeEncryptionPassword(newPassword); + // --- Change to password2 --- + await clientA.sync.changeEncryptionPassword(password2); - // --- Client B must reconfigure with new password --- - // Close and recreate with new password (simulating user entering new password) - await closeClient(clientB); - clientB = await createSimulatedClient(browser, baseURL!, 'B2', testRunId); - await clientB.sync.setupSuperSync({ - ...baseConfig, - isEncryptionEnabled: true, - password: newPassword, - }); - await clientB.sync.syncAndWait(); + // Create and sync task2 + const task2 = `Task2-${testRunId}`; + await clientA.workView.addTask(task2); + await clientA.sync.syncAndWait(); - // Verify B has the task - await waitForTask(clientB.page, taskFromA); + // --- Change to password3 --- + await clientA.sync.changeEncryptionPassword(password3); - // --- Client B creates a new task --- - const taskFromB = `FromB-${testRunId}`; - await clientB.workView.addTask(taskFromB); - await clientB.sync.syncAndWait(); + // Create and sync task3 + const task3 = `Task3-${testRunId}`; + await clientA.workView.addTask(task3); + await clientA.sync.syncAndWait(); - // --- Client A syncs and should get B's task --- - await clientA.sync.syncAndWait(); - await waitForTask(clientA.page, taskFromB); + // --- Verify all tasks still exist --- + await expectTaskVisible(clientA, task1); + await expectTaskVisible(clientA, task2); + await expectTaskVisible(clientA, task3); - // Verify both clients have both tasks - await expect(clientA.page.locator(`task:has-text("${taskFromA}")`)).toBeVisible(); - await expect(clientA.page.locator(`task:has-text("${taskFromB}")`)).toBeVisible(); - await expect(clientB.page.locator(`task:has-text("${taskFromA}")`)).toBeVisible(); - await expect(clientB.page.locator(`task:has-text("${taskFromB}")`)).toBeVisible(); - } finally { - if (clientA) await closeClient(clientA); - if (clientB) await closeClient(clientB); - } - }, - ); + // --- New client with password3 should see all tasks --- + clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); + await clientB.sync.setupSuperSync({ + ...baseConfig, + isEncryptionEnabled: true, + password: password3, + }); + await clientB.sync.syncAndWait(); - base( - 'Multiple password changes work correctly', - async ({ browser, baseURL }, testInfo) => { - const testRunId = generateTestRunId(testInfo.workerIndex); - let clientA: SimulatedE2EClient | null = null; - let clientB: SimulatedE2EClient | null = null; + await waitForTask(clientB.page, task1); + await waitForTask(clientB.page, task2); + await waitForTask(clientB.page, task3); - try { - const user = await createTestUser(testRunId); - const baseConfig = getSuperSyncConfig(user); - const password1 = `pass1-${testRunId}`; - const password2 = `pass2-${testRunId}`; - const password3 = `pass3-${testRunId}`; - - // --- Setup with password1 --- - clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); - await clientA.sync.setupSuperSync({ - ...baseConfig, - isEncryptionEnabled: true, - password: password1, - }); - - // Create and sync task1 - const task1 = `Task1-${testRunId}`; - await clientA.workView.addTask(task1); - await clientA.sync.syncAndWait(); - - // --- Change to password2 --- - await clientA.sync.changeEncryptionPassword(password2); - - // Create and sync task2 - const task2 = `Task2-${testRunId}`; - await clientA.workView.addTask(task2); - await clientA.sync.syncAndWait(); - - // --- Change to password3 --- - await clientA.sync.changeEncryptionPassword(password3); - - // Create and sync task3 - const task3 = `Task3-${testRunId}`; - await clientA.workView.addTask(task3); - await clientA.sync.syncAndWait(); - - // --- Verify all tasks still exist --- - await expect(clientA.page.locator(`task:has-text("${task1}")`)).toBeVisible(); - await expect(clientA.page.locator(`task:has-text("${task2}")`)).toBeVisible(); - await expect(clientA.page.locator(`task:has-text("${task3}")`)).toBeVisible(); - - // --- New client with password3 should see all tasks --- - clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); - await clientB.sync.setupSuperSync({ - ...baseConfig, - isEncryptionEnabled: true, - password: password3, - }); - await clientB.sync.syncAndWait(); - - await waitForTask(clientB.page, task1); - await waitForTask(clientB.page, task2); - await waitForTask(clientB.page, task3); - - await expect(clientB.page.locator(`task:has-text("${task1}")`)).toBeVisible(); - await expect(clientB.page.locator(`task:has-text("${task2}")`)).toBeVisible(); - await expect(clientB.page.locator(`task:has-text("${task3}")`)).toBeVisible(); - } finally { - if (clientA) await closeClient(clientA); - if (clientB) await closeClient(clientB); - } - }, - ); + await expectTaskVisible(clientB, task1); + await expectTaskVisible(clientB, task2); + await expectTaskVisible(clientB, task3); + } finally { + if (clientA) await closeClient(clientA); + if (clientB) await closeClient(clientB); + } + }); }); diff --git a/e2e/tests/sync/supersync-encryption.spec.ts b/e2e/tests/sync/supersync-encryption.spec.ts index 406fb950c..09c5d47cf 100644 --- a/e2e/tests/sync/supersync-encryption.spec.ts +++ b/e2e/tests/sync/supersync-encryption.spec.ts @@ -1,11 +1,10 @@ -import { test as base, expect } from '@playwright/test'; +import { test, expect } from '../../fixtures/supersync.fixture'; import { createTestUser, getSuperSyncConfig, createSimulatedClient, closeClient, waitForTask, - isServerHealthy, type SimulatedE2EClient, } from '../../utils/supersync-helpers'; @@ -23,341 +22,337 @@ import { * Run with E2E_VERBOSE=1 to see browser console logs for debugging. */ -const generateTestRunId = (workerIndex: number): string => { - return `${Date.now()}-${workerIndex}`; -}; +test.describe('@supersync SuperSync Encryption', () => { + // Server health check is handled automatically by the supersync fixture -base.describe('@supersync SuperSync Encryption', () => { - let serverHealthy: boolean | null = null; + test('Encrypted data syncs correctly with valid password', async ({ + browser, + baseURL, + testRunId, + serverHealthy, + }) => { + void serverHealthy; // Ensure fixture is evaluated for server health check + let clientA: SimulatedE2EClient | null = null; + let clientB: SimulatedE2EClient | null = null; - base.beforeEach(async ({}, testInfo) => { - if (serverHealthy === null) { - serverHealthy = await isServerHealthy(); - if (!serverHealthy) { - console.warn( - 'SuperSync server not healthy at http://localhost:1901 - skipping tests', - ); - } + try { + const user = await createTestUser(testRunId); + const baseConfig = getSuperSyncConfig(user); + const encryptionPassword = `pass-${testRunId}`; + const syncConfig = { + ...baseConfig, + isEncryptionEnabled: true, + password: encryptionPassword, + }; + + // --- Client A: Encrypt & Upload --- + clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); + await clientA.sync.setupSuperSync(syncConfig); + + const secretTaskName = `SecretTask-${testRunId}`; + await clientA.workView.addTask(secretTaskName); + + // Sync A (Encrypts and uploads) + await clientA.sync.syncAndWait(); + + // --- Client B: Download & Decrypt --- + clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); + // Use SAME config (same password) + await clientB.sync.setupSuperSync(syncConfig); + + // Sync B (Downloads and decrypts) + await clientB.sync.syncAndWait(); + + // Verify B has the task + await waitForTask(clientB.page, secretTaskName); + await expect( + clientB.page.locator(`task:has-text("${secretTaskName}")`), + ).toBeVisible(); + } finally { + if (clientA) await closeClient(clientA); + if (clientB) await closeClient(clientB); } - testInfo.skip(!serverHealthy, 'SuperSync server not running'); }); - base( - 'Encrypted data syncs correctly with valid password', - async ({ browser, baseURL }, testInfo) => { - const testRunId = generateTestRunId(testInfo.workerIndex); - let clientA: SimulatedE2EClient | null = null; - let clientB: SimulatedE2EClient | null = null; + test('Encrypted data fails to sync with wrong password', async ({ + browser, + baseURL, + testRunId, + serverHealthy, + }) => { + void serverHealthy; // Ensure fixture is evaluated for server health check + let clientA: SimulatedE2EClient | null = null; + let clientC: SimulatedE2EClient | null = null; + try { + const user = await createTestUser(testRunId); + const baseConfig = getSuperSyncConfig(user); + const correctPassword = `correct-${testRunId}`; + const wrongPassword = `wrong-${testRunId}`; + + // --- Client A: Encrypt & Upload (Correct Password) --- + clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); + await clientA.sync.setupSuperSync({ + ...baseConfig, + isEncryptionEnabled: true, + password: correctPassword, + }); + + const secretTaskName = `SecretTask-${testRunId}`; + await clientA.workView.addTask(secretTaskName); + await clientA.sync.syncAndWait(); + + // --- Client C: Attempt Download (Wrong Password) --- + clientC = await createSimulatedClient(browser, baseURL!, 'C', testRunId); + + // Setup with WRONG password + await clientC.sync.setupSuperSync({ + ...baseConfig, + isEncryptionEnabled: true, + password: wrongPassword, + }); + + // Try to sync - expectation is that it completes (download happens) but processing fails + // The SyncPage helper might throw if it detects an error icon, or we check for error manually + + // We expect the sync to technically "fail" or show an error state because decryption failed try { - const user = await createTestUser(testRunId); - const baseConfig = getSuperSyncConfig(user); - const encryptionPassword = `pass-${testRunId}`; - const syncConfig = { - ...baseConfig, - isEncryptionEnabled: true, - password: encryptionPassword, - }; - - // --- Client A: Encrypt & Upload --- - clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); - await clientA.sync.setupSuperSync(syncConfig); - - const secretTaskName = `SecretTask-${testRunId}`; - await clientA.workView.addTask(secretTaskName); - - // Sync A (Encrypts and uploads) - await clientA.sync.syncAndWait(); - - // --- Client B: Download & Decrypt --- - clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); - // Use SAME config (same password) - await clientB.sync.setupSuperSync(syncConfig); - - // Sync B (Downloads and decrypts) - await clientB.sync.syncAndWait(); - - // Verify B has the task - await waitForTask(clientB.page, secretTaskName); - await expect( - clientB.page.locator(`task:has-text("${secretTaskName}")`), - ).toBeVisible(); - } finally { - if (clientA) await closeClient(clientA); - if (clientB) await closeClient(clientB); + await clientC.sync.triggerSync(); + // It might not throw immediately if waitForSyncComplete handles the error state gracefully or we catch it + await clientC.sync.waitForSyncComplete(); + } catch (e) { + // Expected error or timeout due to failure + console.log('Sync failed as expected:', e); } - }, - ); - base( - 'Encrypted data fails to sync with wrong password', - async ({ browser, baseURL }, testInfo) => { - const testRunId = generateTestRunId(testInfo.workerIndex); - let clientA: SimulatedE2EClient | null = null; - let clientC: SimulatedE2EClient | null = null; + // Verify Client C DOES NOT have the task + await expect( + clientC.page.locator(`task:has-text("${secretTaskName}")`), + ).not.toBeVisible(); - try { - const user = await createTestUser(testRunId); - const baseConfig = getSuperSyncConfig(user); - const correctPassword = `correct-${testRunId}`; - const wrongPassword = `wrong-${testRunId}`; - - // --- Client A: Encrypt & Upload (Correct Password) --- - clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); - await clientA.sync.setupSuperSync({ - ...baseConfig, - isEncryptionEnabled: true, - password: correctPassword, - }); - - const secretTaskName = `SecretTask-${testRunId}`; - await clientA.workView.addTask(secretTaskName); - await clientA.sync.syncAndWait(); - - // --- Client C: Attempt Download (Wrong Password) --- - clientC = await createSimulatedClient(browser, baseURL!, 'C', testRunId); - - // Setup with WRONG password - await clientC.sync.setupSuperSync({ - ...baseConfig, - isEncryptionEnabled: true, - password: wrongPassword, - }); - - // Try to sync - expectation is that it completes (download happens) but processing fails - // The SyncPage helper might throw if it detects an error icon, or we check for error manually - - // We expect the sync to technically "fail" or show an error state because decryption failed - try { - await clientC.sync.triggerSync(); - // It might not throw immediately if waitForSyncComplete handles the error state gracefully or we catch it - await clientC.sync.waitForSyncComplete(); - } catch (e) { - // Expected error or timeout due to failure - console.log('Sync failed as expected:', e); - } - - // Verify Client C DOES NOT have the task - await expect( - clientC.page.locator(`task:has-text("${secretTaskName}")`), - ).not.toBeVisible(); - - // Verify Error UI - // Check for error icon or snackbar - const hasError = await clientC.sync.hasSyncError(); - const snackbar = clientC.page.locator('simple-snack-bar'); - // "Decryption failed" or "Wrong encryption password" - if (!hasError && !(await snackbar.isVisible())) { - // If sync didn't report error, maybe it just silently ignored ops (shouldn't happen with current logic) - // But let's check if we at least DON'T have the data. - } else { - expect(hasError || (await snackbar.isVisible())).toBe(true); - } - } finally { - if (clientA) await closeClient(clientA); - if (clientC) await closeClient(clientC); + // Verify Error UI + // Check for error icon or snackbar + const hasError = await clientC.sync.hasSyncError(); + const snackbar = clientC.page.locator('simple-snack-bar'); + // "Decryption failed" or "Wrong encryption password" + if (!hasError && !(await snackbar.isVisible())) { + // If sync didn't report error, maybe it just silently ignored ops (shouldn't happen with current logic) + // But let's check if we at least DON'T have the data. + } else { + expect(hasError || (await snackbar.isVisible())).toBe(true); } - }, - ); + } finally { + if (clientA) await closeClient(clientA); + if (clientC) await closeClient(clientC); + } + }); - base( - 'Multiple tasks sync correctly with encryption', - async ({ browser, baseURL }, testInfo) => { - const testRunId = generateTestRunId(testInfo.workerIndex); - let clientA: SimulatedE2EClient | null = null; - let clientB: SimulatedE2EClient | null = null; + test('Multiple tasks sync correctly with encryption', async ({ + browser, + baseURL, + testRunId, + serverHealthy, + }) => { + void serverHealthy; // Ensure fixture is evaluated for server health check + let clientA: SimulatedE2EClient | null = null; + let clientB: SimulatedE2EClient | null = null; - try { - const user = await createTestUser(testRunId); - const baseConfig = getSuperSyncConfig(user); - const encryptionPassword = `multi-${testRunId}`; - const syncConfig = { - ...baseConfig, - isEncryptionEnabled: true, - password: encryptionPassword, - }; + try { + const user = await createTestUser(testRunId); + const baseConfig = getSuperSyncConfig(user); + const encryptionPassword = `multi-${testRunId}`; + const syncConfig = { + ...baseConfig, + isEncryptionEnabled: true, + password: encryptionPassword, + }; - // --- Client A: Create multiple tasks --- - clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); - await clientA.sync.setupSuperSync(syncConfig); + // --- Client A: Create multiple tasks --- + clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); + await clientA.sync.setupSuperSync(syncConfig); - const task1 = `Task1-${testRunId}`; - const task2 = `Task2-${testRunId}`; - const task3 = `Task3-${testRunId}`; + const task1 = `Task1-${testRunId}`; + const task2 = `Task2-${testRunId}`; + const task3 = `Task3-${testRunId}`; - await clientA.workView.addTask(task1); - await clientA.page.waitForTimeout(100); - await clientA.workView.addTask(task2); - await clientA.page.waitForTimeout(100); - await clientA.workView.addTask(task3); + await clientA.workView.addTask(task1); + await clientA.page.waitForTimeout(100); + await clientA.workView.addTask(task2); + await clientA.page.waitForTimeout(100); + await clientA.workView.addTask(task3); - // Sync all tasks - await clientA.sync.syncAndWait(); + // Sync all tasks + await clientA.sync.syncAndWait(); - // --- Client B: Verify all tasks arrive --- - clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); - await clientB.sync.setupSuperSync(syncConfig); - await clientB.sync.syncAndWait(); + // --- Client B: Verify all tasks arrive --- + clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); + await clientB.sync.setupSuperSync(syncConfig); + await clientB.sync.syncAndWait(); - // Verify all 3 tasks exist - await waitForTask(clientB.page, task1); - await waitForTask(clientB.page, task2); - await waitForTask(clientB.page, task3); + // Verify all 3 tasks exist + await waitForTask(clientB.page, task1); + await waitForTask(clientB.page, task2); + await waitForTask(clientB.page, task3); - await expect(clientB.page.locator(`task:has-text("${task1}")`)).toBeVisible(); - await expect(clientB.page.locator(`task:has-text("${task2}")`)).toBeVisible(); - await expect(clientB.page.locator(`task:has-text("${task3}")`)).toBeVisible(); - } finally { - if (clientA) await closeClient(clientA); - if (clientB) await closeClient(clientB); - } - }, - ); + await expect(clientB.page.locator(`task:has-text("${task1}")`)).toBeVisible(); + await expect(clientB.page.locator(`task:has-text("${task2}")`)).toBeVisible(); + await expect(clientB.page.locator(`task:has-text("${task3}")`)).toBeVisible(); + } finally { + if (clientA) await closeClient(clientA); + if (clientB) await closeClient(clientB); + } + }); - base( - 'Bidirectional sync works with encryption', - async ({ browser, baseURL }, testInfo) => { - const testRunId = generateTestRunId(testInfo.workerIndex); - let clientA: SimulatedE2EClient | null = null; - let clientB: SimulatedE2EClient | null = null; + test('Bidirectional sync works with encryption', async ({ + browser, + baseURL, + testRunId, + serverHealthy, + }) => { + void serverHealthy; // Ensure fixture is evaluated for server health check + let clientA: SimulatedE2EClient | null = null; + let clientB: SimulatedE2EClient | null = null; - try { - const user = await createTestUser(testRunId); - const baseConfig = getSuperSyncConfig(user); - const encryptionPassword = `bidi-${testRunId}`; - const syncConfig = { - ...baseConfig, - isEncryptionEnabled: true, - password: encryptionPassword, - }; + try { + const user = await createTestUser(testRunId); + const baseConfig = getSuperSyncConfig(user); + const encryptionPassword = `bidi-${testRunId}`; + const syncConfig = { + ...baseConfig, + isEncryptionEnabled: true, + password: encryptionPassword, + }; - // --- Setup both clients --- - clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); - await clientA.sync.setupSuperSync(syncConfig); + // --- Setup 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); + clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); + await clientB.sync.setupSuperSync(syncConfig); - // --- Client A creates a task --- - const taskFromA = `FromA-${testRunId}`; - await clientA.workView.addTask(taskFromA); - await clientA.sync.syncAndWait(); + // --- Client A creates a task --- + const taskFromA = `FromA-${testRunId}`; + await clientA.workView.addTask(taskFromA); + await clientA.sync.syncAndWait(); - // --- Client B syncs and creates a task --- - await clientB.sync.syncAndWait(); - await waitForTask(clientB.page, taskFromA); + // --- Client B syncs and creates a task --- + await clientB.sync.syncAndWait(); + await waitForTask(clientB.page, taskFromA); - const taskFromB = `FromB-${testRunId}`; - await clientB.workView.addTask(taskFromB); - await clientB.sync.syncAndWait(); + const taskFromB = `FromB-${testRunId}`; + await clientB.workView.addTask(taskFromB); + await clientB.sync.syncAndWait(); - // --- Client A syncs again and should have both tasks --- - await clientA.sync.syncAndWait(); - await waitForTask(clientA.page, taskFromB); + // --- Client A syncs again and should have both tasks --- + await clientA.sync.syncAndWait(); + await waitForTask(clientA.page, taskFromB); - // Verify both clients have both tasks - await expect(clientA.page.locator(`task:has-text("${taskFromA}")`)).toBeVisible(); - await expect(clientA.page.locator(`task:has-text("${taskFromB}")`)).toBeVisible(); - await expect(clientB.page.locator(`task:has-text("${taskFromA}")`)).toBeVisible(); - await expect(clientB.page.locator(`task:has-text("${taskFromB}")`)).toBeVisible(); - } finally { - if (clientA) await closeClient(clientA); - if (clientB) await closeClient(clientB); - } - }, - ); + // Verify both clients have both tasks + await expect(clientA.page.locator(`task:has-text("${taskFromA}")`)).toBeVisible(); + await expect(clientA.page.locator(`task:has-text("${taskFromB}")`)).toBeVisible(); + await expect(clientB.page.locator(`task:has-text("${taskFromA}")`)).toBeVisible(); + await expect(clientB.page.locator(`task:has-text("${taskFromB}")`)).toBeVisible(); + } finally { + if (clientA) await closeClient(clientA); + if (clientB) await closeClient(clientB); + } + }); - base( - 'Task update syncs correctly with encryption', - async ({ browser, baseURL }, testInfo) => { - const testRunId = generateTestRunId(testInfo.workerIndex); - let clientA: SimulatedE2EClient | null = null; - let clientB: SimulatedE2EClient | null = null; + test('Task update syncs correctly with encryption', async ({ + browser, + baseURL, + testRunId, + serverHealthy, + }) => { + void serverHealthy; // Ensure fixture is evaluated for server health check + let clientA: SimulatedE2EClient | null = null; + let clientB: SimulatedE2EClient | null = null; - try { - const user = await createTestUser(testRunId); - const baseConfig = getSuperSyncConfig(user); - const encryptionPassword = `update-${testRunId}`; - const syncConfig = { - ...baseConfig, - isEncryptionEnabled: true, - password: encryptionPassword, - }; + try { + const user = await createTestUser(testRunId); + const baseConfig = getSuperSyncConfig(user); + const encryptionPassword = `update-${testRunId}`; + const syncConfig = { + ...baseConfig, + isEncryptionEnabled: true, + password: encryptionPassword, + }; - // --- Client A: Create a task --- - clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); - await clientA.sync.setupSuperSync(syncConfig); + // --- Client A: Create a task --- + clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); + await clientA.sync.setupSuperSync(syncConfig); - const taskName = `UpdatableTask-${testRunId}`; - await clientA.workView.addTask(taskName); - await clientA.page.waitForTimeout(300); - await clientA.sync.syncAndWait(); + const taskName = `UpdatableTask-${testRunId}`; + await clientA.workView.addTask(taskName); + await clientA.page.waitForTimeout(300); + await clientA.sync.syncAndWait(); - // --- Client B: Sync and verify task exists --- - clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); - await clientB.sync.setupSuperSync(syncConfig); - await clientB.sync.syncAndWait(); + // --- Client B: Sync and verify task exists --- + clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); + await clientB.sync.setupSuperSync(syncConfig); + await clientB.sync.syncAndWait(); - await waitForTask(clientB.page, taskName); - await expect(clientB.page.locator(`task:has-text("${taskName}")`)).toBeVisible(); + await waitForTask(clientB.page, taskName); + await expect(clientB.page.locator(`task:has-text("${taskName}")`)).toBeVisible(); - // --- Client B: Create another task --- - const task2Name = `UpdatedByB-${testRunId}`; - await clientB.workView.addTask(task2Name); - await clientB.page.waitForTimeout(300); - await clientB.sync.syncAndWait(); + // --- Client B: Create another task --- + const task2Name = `UpdatedByB-${testRunId}`; + await clientB.workView.addTask(task2Name); + await clientB.page.waitForTimeout(300); + await clientB.sync.syncAndWait(); - // --- Client A: Sync and verify both tasks exist --- - await clientA.sync.syncAndWait(); - await waitForTask(clientA.page, task2Name); + // --- Client A: Sync and verify both tasks exist --- + await clientA.sync.syncAndWait(); + await waitForTask(clientA.page, task2Name); - await expect(clientA.page.locator(`task:has-text("${taskName}")`)).toBeVisible(); - await expect(clientA.page.locator(`task:has-text("${task2Name}")`)).toBeVisible(); - } finally { - if (clientA) await closeClient(clientA); - if (clientB) await closeClient(clientB); - } - }, - ); + await expect(clientA.page.locator(`task:has-text("${taskName}")`)).toBeVisible(); + await expect(clientA.page.locator(`task:has-text("${task2Name}")`)).toBeVisible(); + } finally { + if (clientA) await closeClient(clientA); + if (clientB) await closeClient(clientB); + } + }); - base( - 'Long encryption password works correctly', - async ({ browser, baseURL }, testInfo) => { - const testRunId = generateTestRunId(testInfo.workerIndex); - let clientA: SimulatedE2EClient | null = null; - let clientB: SimulatedE2EClient | null = null; + test('Long encryption password works correctly', async ({ + browser, + baseURL, + testRunId, + serverHealthy, + }) => { + void serverHealthy; // Ensure fixture is evaluated for server health check + let clientA: SimulatedE2EClient | null = null; + let clientB: SimulatedE2EClient | null = null; - try { - const user = await createTestUser(testRunId); - const baseConfig = getSuperSyncConfig(user); - // Use a very long password with special characters - const longPassword = `This-Is-A-Very-Long-Password-With-Special-Chars!@#$%^&*()-${testRunId}`; - const syncConfig = { - ...baseConfig, - isEncryptionEnabled: true, - password: longPassword, - }; + try { + const user = await createTestUser(testRunId); + const baseConfig = getSuperSyncConfig(user); + // Use a very long password with special characters + const longPassword = `This-Is-A-Very-Long-Password-With-Special-Chars!@#$%^&*()-${testRunId}`; + const syncConfig = { + ...baseConfig, + isEncryptionEnabled: true, + password: longPassword, + }; - // --- Client A: Create task with long password --- - clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); - await clientA.sync.setupSuperSync(syncConfig); + // --- Client A: Create task with long password --- + clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); + await clientA.sync.setupSuperSync(syncConfig); - const taskName = `LongPassTask-${testRunId}`; - await clientA.workView.addTask(taskName); - await clientA.sync.syncAndWait(); + const taskName = `LongPassTask-${testRunId}`; + await clientA.workView.addTask(taskName); + await clientA.sync.syncAndWait(); - // --- Client B: Sync with same long password --- - clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); - await clientB.sync.setupSuperSync(syncConfig); - await clientB.sync.syncAndWait(); + // --- Client B: Sync with same long password --- + clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); + await clientB.sync.setupSuperSync(syncConfig); + await clientB.sync.syncAndWait(); - // Verify task synced correctly - await waitForTask(clientB.page, taskName); - await expect(clientB.page.locator(`task:has-text("${taskName}")`)).toBeVisible(); - } finally { - if (clientA) await closeClient(clientA); - if (clientB) await closeClient(clientB); - } - }, - ); + // Verify task synced correctly + await waitForTask(clientB.page, taskName); + await expect(clientB.page.locator(`task:has-text("${taskName}")`)).toBeVisible(); + } finally { + if (clientA) await closeClient(clientA); + if (clientB) await closeClient(clientB); + } + }); }); diff --git a/e2e/tests/sync/supersync-error-handling.spec.ts b/e2e/tests/sync/supersync-error-handling.spec.ts index f38207b1a..c2ac5e8a8 100644 --- a/e2e/tests/sync/supersync-error-handling.spec.ts +++ b/e2e/tests/sync/supersync-error-handling.spec.ts @@ -1,11 +1,10 @@ -import { test as base, expect } from '@playwright/test'; +import { test, expect } from '../../fixtures/supersync.fixture'; import { createTestUser, getSuperSyncConfig, createSimulatedClient, closeClient, waitForTask, - isServerHealthy, type SimulatedE2EClient, } from '../../utils/supersync-helpers'; @@ -18,25 +17,7 @@ import { * - Error recovery after sync failures */ -const generateTestRunId = (workerIndex: number): string => { - return `${Date.now()}-${workerIndex}`; -}; - -base.describe('@supersync SuperSync Error Handling', () => { - let serverHealthy: boolean | null = null; - - base.beforeEach(async ({}, testInfo) => { - if (serverHealthy === null) { - serverHealthy = await isServerHealthy(); - if (!serverHealthy) { - console.warn( - 'SuperSync server not healthy at http://localhost:1901 - skipping tests', - ); - } - } - testInfo.skip(!serverHealthy, 'SuperSync server not running'); - }); - +test.describe('@supersync SuperSync Error Handling', () => { /** * Scenario: Concurrent Modification Creates Conflict * @@ -54,99 +35,98 @@ base.describe('@supersync SuperSync Error Handling', () => { * 7. Client B syncs (gets CONFLICT_CONCURRENT, auto-resolves via LWW) * 8. Verify both clients converge to same state */ - base( - 'Concurrent modification triggers LWW conflict resolution', - async ({ browser, baseURL }, testInfo) => { - testInfo.setTimeout(120000); - const testRunId = generateTestRunId(testInfo.workerIndex); - const appUrl = baseURL || 'http://localhost:4242'; - let clientA: SimulatedE2EClient | null = null; - let clientB: SimulatedE2EClient | null = null; + 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); + try { + const user = await createTestUser(testRunId); + const syncConfig = getSuperSyncConfig(user); - // Setup clients - clientA = await createSimulatedClient(browser, appUrl, 'A', testRunId); - await clientA.sync.setupSuperSync(syncConfig); + // 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); + 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(); + // 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); + // 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(); - 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'); - await clientA.page.waitForTimeout(300); + // 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(); + 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'); + await clientA.page.waitForTimeout(300); - // 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(); - 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'); - await clientB.page.waitForTimeout(300); + // 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(); + 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'); + await clientB.page.waitForTimeout(300); - // 6. Client A syncs first (succeeds) - await clientA.sync.syncAndWait(); + // 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(); + // 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 - await clientB.page.waitForTimeout(1000); + // Give time for any conflict resolution UI + await clientB.page.waitForTimeout(1000); - // 8. Final sync to ensure convergence - await clientA.sync.syncAndWait(); - await clientB.sync.syncAndWait(); + // 8. Final sync to ensure convergence + await clientA.sync.syncAndWait(); + await clientB.sync.syncAndWait(); - // Verify both clients have converged - they should show the same task title - // The exact title depends on LWW resolution (whichever timestamp was later) - const finalTaskA = await clientA.page - .locator('task:not(.ng-animating)') - .first() - .textContent(); - const finalTaskB = await clientB.page - .locator('task:not(.ng-animating)') - .first() - .textContent(); + // Verify both clients have converged - they should show the same task title + // The exact title depends on LWW resolution (whichever timestamp was later) + const finalTaskA = await clientA.page + .locator('task:not(.ng-animating)') + .first() + .textContent(); + const finalTaskB = await clientB.page + .locator('task:not(.ng-animating)') + .first() + .textContent(); - // Both clients should show the same content (convergence) - expect(finalTaskA).toBe(finalTaskB); + // 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); - } - }, - ); + 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 @@ -162,46 +142,45 @@ base.describe('@supersync SuperSync Error Handling', () => { * 5. Client syncs again (should succeed) * 6. Verify all tasks are synced */ - base( - 'Sync recovers after initial connection', - async ({ browser, baseURL }, testInfo) => { - testInfo.setTimeout(60000); - const testRunId = generateTestRunId(testInfo.workerIndex); - const appUrl = baseURL || 'http://localhost:4242'; - let client: SimulatedE2EClient | null = null; + 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); + try { + const user = await createTestUser(testRunId); + const syncConfig = getSuperSyncConfig(user); - // Setup client - client = await createSimulatedClient(browser, appUrl, 'Recovery', testRunId); - await client.sync.setupSuperSync(syncConfig); + // 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); + // 1. Create first task + const task1 = `Recovery-Task1-${testRunId}`; + await client.workView.addTask(task1); - // 2. Sync successfully - await client.sync.syncAndWait(); + // 2. Sync successfully + await client.sync.syncAndWait(); - // 3. Create second task - const task2 = `Recovery-Task2-${testRunId}`; - await client.workView.addTask(task2); + // 3. Create second task + const task2 = `Recovery-Task2-${testRunId}`; + await client.workView.addTask(task2); - // 4-5. Sync again - await client.sync.syncAndWait(); + // 4-5. Sync again + await client.sync.syncAndWait(); - // 6. Verify both tasks exist - await waitForTask(client.page, task1); - await waitForTask(client.page, task2); + // 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); - } - }, - ); + console.log('[Sync-Recovery] ✓ Multiple syncs completed successfully'); + } finally { + if (client) await closeClient(client); + } + }); /** * Scenario: Duplicate Operation Handling (Idempotency) @@ -216,45 +195,44 @@ base.describe('@supersync SuperSync Error Handling', () => { * 3. Client syncs again immediately (same ops might be in queue) * 4. Verify no duplicate tasks created */ - base( - 'Duplicate sync attempts are handled gracefully', - async ({ browser, baseURL }, testInfo) => { - testInfo.setTimeout(60000); - const testRunId = generateTestRunId(testInfo.workerIndex); - const appUrl = baseURL || 'http://localhost:4242'; - let client: SimulatedE2EClient | null = null; + 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); + try { + const user = await createTestUser(testRunId); + const syncConfig = getSuperSyncConfig(user); - // Setup client - client = await createSimulatedClient(browser, appUrl, 'Idempotent', testRunId); - await client.sync.setupSuperSync(syncConfig); + // 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); + // 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(); + // 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(); + // 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); + expect(count).toBe(1); - console.log( - '[Idempotency] ✓ Multiple sync attempts handled correctly - no duplicates', - ); - } finally { - if (client) await closeClient(client); - } - }, - ); + console.log( + '[Idempotency] ✓ Multiple sync attempts handled correctly - no duplicates', + ); + } finally { + if (client) await closeClient(client); + } + }); /** * Scenario: Three-Client Convergence @@ -271,9 +249,11 @@ base.describe('@supersync SuperSync Error Handling', () => { * 6. All clients sync * 7. Verify all clients have same state */ - base('Three clients converge to same state', async ({ browser, baseURL }, testInfo) => { - testInfo.setTimeout(120000); - const testRunId = generateTestRunId(testInfo.workerIndex); + 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; diff --git a/e2e/tests/sync/supersync-import-clean-slate.spec.ts b/e2e/tests/sync/supersync-import-clean-slate.spec.ts index ff36c7c87..264b8e6b7 100644 --- a/e2e/tests/sync/supersync-import-clean-slate.spec.ts +++ b/e2e/tests/sync/supersync-import-clean-slate.spec.ts @@ -1,11 +1,10 @@ -import { test as base, expect } from '@playwright/test'; +import { test, expect } from '../../fixtures/supersync.fixture'; import { createTestUser, getSuperSyncConfig, createSimulatedClient, closeClient, waitForTask, - isServerHealthy, type SimulatedE2EClient, } from '../../utils/supersync-helpers'; import { ImportPage } from '../../pages/import.page'; @@ -24,25 +23,7 @@ import { ImportPage } from '../../pages/import.page'; * Run with: npm run e2e:supersync:file e2e/tests/sync/supersync-import-clean-slate.spec.ts */ -const generateTestRunId = (workerIndex: number): string => { - return `${Date.now()}-${workerIndex}`; -}; - -base.describe('@supersync @cleanslate Import Clean Slate Semantics', () => { - let serverHealthy: boolean | null = null; - - base.beforeEach(async ({}, testInfo) => { - if (serverHealthy === null) { - serverHealthy = await isServerHealthy(); - if (!serverHealthy) { - console.warn( - 'SuperSync server not healthy at http://localhost:1901 - skipping tests', - ); - } - } - testInfo.skip(!serverHealthy, 'SuperSync server not running'); - }); - +test.describe('@supersync @cleanslate Import Clean Slate Semantics', () => { /** * Scenario: Import drops all concurrent work from both clients * @@ -71,149 +52,149 @@ base.describe('@supersync @cleanslate Import Clean Slate Semantics', () => { * fresh start. All operations created before it (even if already synced) * are dropped because they reference state that no longer exists. */ - base( - 'Import drops ALL concurrent work from both clients (clean slate)', - async ({ browser, baseURL }, testInfo) => { - const testRunId = generateTestRunId(testInfo.workerIndex); - const uniqueId = Date.now(); - let clientA: SimulatedE2EClient | null = null; - let clientB: SimulatedE2EClient | null = null; + test('Import drops ALL concurrent work from both clients (clean slate)', async ({ + browser, + baseURL, + testRunId, + }) => { + const uniqueId = Date.now(); + let clientA: SimulatedE2EClient | null = null; + let clientB: SimulatedE2EClient | null = null; - try { - const user = await createTestUser(testRunId); - const syncConfig = getSuperSyncConfig(user); + try { + const user = await createTestUser(testRunId); + const syncConfig = getSuperSyncConfig(user); - // ============ PHASE 1: Setup Both Clients ============ - console.log('[Clean Slate] Phase 1: Setting up both clients'); + // ============ PHASE 1: Setup Both Clients ============ + console.log('[Clean Slate] Phase 1: Setting up both clients'); - clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); - await clientA.sync.setupSuperSync(syncConfig); + clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); + await clientA.sync.setupSuperSync(syncConfig); - clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); - await clientB.sync.setupSuperSync(syncConfig); + clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); + await clientB.sync.setupSuperSync(syncConfig); - // ============ PHASE 2: Create Pre-Import Tasks on Both Clients ============ - console.log('[Clean Slate] Phase 2: Creating pre-import tasks'); + // ============ PHASE 2: Create Pre-Import Tasks on Both Clients ============ + console.log('[Clean Slate] Phase 2: Creating pre-import tasks'); - // Client A creates and syncs first task - const taskABefore = `Task-A-Before-${uniqueId}`; - await clientA.workView.addTask(taskABefore); - await clientA.sync.syncAndWait(); - console.log(`[Clean Slate] Client A created: ${taskABefore}`); + // Client A creates and syncs first task + const taskABefore = `Task-A-Before-${uniqueId}`; + await clientA.workView.addTask(taskABefore); + await clientA.sync.syncAndWait(); + console.log(`[Clean Slate] Client A created: ${taskABefore}`); - // Client B syncs to get A's task - await clientB.sync.syncAndWait(); - await waitForTask(clientB.page, taskABefore); - console.log('[Clean Slate] Client B received Task-A-Before'); + // Client B syncs to get A's task + await clientB.sync.syncAndWait(); + await waitForTask(clientB.page, taskABefore); + console.log('[Clean Slate] Client B received Task-A-Before'); - // Client B creates and syncs a concurrent task - const taskBConcurrent = `Task-B-Concurrent-${uniqueId}`; - await clientB.workView.addTask(taskBConcurrent); - await clientB.sync.syncAndWait(); - console.log(`[Clean Slate] Client B created: ${taskBConcurrent}`); + // Client B creates and syncs a concurrent task + const taskBConcurrent = `Task-B-Concurrent-${uniqueId}`; + await clientB.workView.addTask(taskBConcurrent); + await clientB.sync.syncAndWait(); + console.log(`[Clean Slate] Client B created: ${taskBConcurrent}`); - // Client A creates another task (will NOT sync before import) - const taskAConcurrent = `Task-A-Concurrent-${uniqueId}`; - await clientA.workView.addTask(taskAConcurrent); - console.log(`[Clean Slate] Client A created: ${taskAConcurrent}`); + // Client A creates another task (will NOT sync before import) + const taskAConcurrent = `Task-A-Concurrent-${uniqueId}`; + await clientA.workView.addTask(taskAConcurrent); + console.log(`[Clean Slate] Client A created: ${taskAConcurrent}`); - // ============ PHASE 3: Client A Imports Backup ============ - console.log('[Clean Slate] Phase 3: Client A importing backup'); + // ============ PHASE 3: Client A Imports Backup ============ + console.log('[Clean Slate] Phase 3: Client A importing backup'); - // Navigate to import page - const importPage = new ImportPage(clientA.page); - await importPage.navigateToImportPage(); + // Navigate to import page + const importPage = new ImportPage(clientA.page); + await importPage.navigateToImportPage(); - // Import the backup file (contains "E2E Import Test" tasks) - const backupPath = ImportPage.getFixturePath('test-backup.json'); - await importPage.importBackupFile(backupPath); - console.log('[Clean Slate] Client A imported backup'); + // Import the backup file (contains "E2E Import Test" tasks) + const backupPath = ImportPage.getFixturePath('test-backup.json'); + await importPage.importBackupFile(backupPath); + console.log('[Clean Slate] Client A imported backup'); - // Re-enable sync after import (import overwrites globalConfig) - await clientA.sync.setupSuperSync(syncConfig); + // Re-enable sync after import (import overwrites globalConfig) + await clientA.sync.setupSuperSync(syncConfig); - // Wait for imported task to be visible - await waitForTask(clientA.page, 'E2E Import Test - Active Task With Subtask'); - console.log('[Clean Slate] Client A has imported tasks'); + // Wait for imported task to be visible + await waitForTask(clientA.page, 'E2E Import Test - Active Task With Subtask'); + console.log('[Clean Slate] Client A has imported tasks'); - // ============ PHASE 4: Sync to Propagate SYNC_IMPORT ============ - console.log('[Clean Slate] Phase 4: Syncing to propagate SYNC_IMPORT'); + // ============ PHASE 4: Sync to Propagate SYNC_IMPORT ============ + console.log('[Clean Slate] Phase 4: Syncing to propagate SYNC_IMPORT'); - // Client A syncs (uploads SYNC_IMPORT) - await clientA.sync.syncAndWait(); - console.log('[Clean Slate] Client A synced (SYNC_IMPORT uploaded)'); + // Client A syncs (uploads SYNC_IMPORT) + await clientA.sync.syncAndWait(); + console.log('[Clean Slate] Client A synced (SYNC_IMPORT uploaded)'); - // Client B syncs (receives SYNC_IMPORT - should drop all concurrent work) - await clientB.sync.syncAndWait(); - console.log('[Clean Slate] Client B synced (received SYNC_IMPORT)'); + // Client B syncs (receives SYNC_IMPORT - should drop all concurrent work) + await clientB.sync.syncAndWait(); + console.log('[Clean Slate] Client B synced (received SYNC_IMPORT)'); - // Wait for state to settle - allow UI to update - await clientA.page.waitForTimeout(1000); - await clientB.page.waitForTimeout(1000); + // Wait for state to settle - allow UI to update + await clientA.page.waitForTimeout(1000); + await clientB.page.waitForTimeout(1000); - // ============ PHASE 5: Verify Clean Slate on Both Clients ============ - console.log('[Clean Slate] Phase 5: Verifying clean slate'); + // ============ PHASE 5: Verify Clean Slate on Both Clients ============ + console.log('[Clean Slate] Phase 5: Verifying clean slate'); - // Navigate back to work view to see tasks - await clientA.page.goto('/#/work-view'); - await clientA.page.waitForLoadState('networkidle'); - await clientB.page.goto('/#/work-view'); - await clientB.page.waitForLoadState('networkidle'); + // Navigate back to work view to see tasks + await clientA.page.goto('/#/work-view'); + await clientA.page.waitForLoadState('networkidle'); + await clientB.page.goto('/#/work-view'); + await clientB.page.waitForLoadState('networkidle'); - // Wait for imported task to appear - await waitForTask(clientA.page, 'E2E Import Test - Active Task With Subtask'); - await waitForTask(clientB.page, 'E2E Import Test - Active Task With Subtask'); + // Wait for imported task to appear + await waitForTask(clientA.page, 'E2E Import Test - Active Task With Subtask'); + await waitForTask(clientB.page, 'E2E Import Test - Active Task With Subtask'); - // CRITICAL VERIFICATION: Pre-import tasks should be GONE - console.log('[Clean Slate] Verifying pre-import tasks are gone...'); + // CRITICAL VERIFICATION: Pre-import tasks should be GONE + console.log('[Clean Slate] Verifying pre-import tasks are gone...'); - // Check Client A - should NOT have pre-import tasks - const taskABeforeOnA = clientA.page.locator(`task:has-text("${taskABefore}")`); - const taskAConcurrentOnA = clientA.page.locator( - `task:has-text("${taskAConcurrent}")`, - ); - const taskBConcurrentOnA = clientA.page.locator( - `task:has-text("${taskBConcurrent}")`, - ); + // Check Client A - should NOT have pre-import tasks + const taskABeforeOnA = clientA.page.locator(`task:has-text("${taskABefore}")`); + const taskAConcurrentOnA = clientA.page.locator( + `task:has-text("${taskAConcurrent}")`, + ); + const taskBConcurrentOnA = clientA.page.locator( + `task:has-text("${taskBConcurrent}")`, + ); - await expect(taskABeforeOnA).not.toBeVisible({ timeout: 5000 }); - await expect(taskAConcurrentOnA).not.toBeVisible({ timeout: 5000 }); - await expect(taskBConcurrentOnA).not.toBeVisible({ timeout: 5000 }); - console.log('[Clean Slate] ✓ Client A: All pre-import tasks are GONE'); + await expect(taskABeforeOnA).not.toBeVisible({ timeout: 5000 }); + await expect(taskAConcurrentOnA).not.toBeVisible({ timeout: 5000 }); + await expect(taskBConcurrentOnA).not.toBeVisible({ timeout: 5000 }); + console.log('[Clean Slate] ✓ Client A: All pre-import tasks are GONE'); - // Check Client B - should NOT have pre-import tasks - const taskABeforeOnB = clientB.page.locator(`task:has-text("${taskABefore}")`); - const taskAConcurrentOnB = clientB.page.locator( - `task:has-text("${taskAConcurrent}")`, - ); - const taskBConcurrentOnB = clientB.page.locator( - `task:has-text("${taskBConcurrent}")`, - ); + // Check Client B - should NOT have pre-import tasks + const taskABeforeOnB = clientB.page.locator(`task:has-text("${taskABefore}")`); + const taskAConcurrentOnB = clientB.page.locator( + `task:has-text("${taskAConcurrent}")`, + ); + const taskBConcurrentOnB = clientB.page.locator( + `task:has-text("${taskBConcurrent}")`, + ); - await expect(taskABeforeOnB).not.toBeVisible({ timeout: 5000 }); - await expect(taskAConcurrentOnB).not.toBeVisible({ timeout: 5000 }); - await expect(taskBConcurrentOnB).not.toBeVisible({ timeout: 5000 }); - console.log('[Clean Slate] ✓ Client B: All pre-import tasks are GONE'); + await expect(taskABeforeOnB).not.toBeVisible({ timeout: 5000 }); + await expect(taskAConcurrentOnB).not.toBeVisible({ timeout: 5000 }); + await expect(taskBConcurrentOnB).not.toBeVisible({ timeout: 5000 }); + console.log('[Clean Slate] ✓ Client B: All pre-import tasks are GONE'); - // Verify both clients have imported tasks - const importedTask1OnA = clientA.page.locator( - 'task:has-text("E2E Import Test - Active Task With Subtask")', - ); - const importedTask1OnB = clientB.page.locator( - 'task:has-text("E2E Import Test - Active Task With Subtask")', - ); + // Verify both clients have imported tasks + const importedTask1OnA = clientA.page.locator( + 'task:has-text("E2E Import Test - Active Task With Subtask")', + ); + const importedTask1OnB = clientB.page.locator( + 'task:has-text("E2E Import Test - Active Task With Subtask")', + ); - await expect(importedTask1OnA).toBeVisible({ timeout: 5000 }); - await expect(importedTask1OnB).toBeVisible({ timeout: 5000 }); - console.log('[Clean Slate] ✓ Both clients have imported tasks'); + await expect(importedTask1OnA).toBeVisible({ timeout: 5000 }); + await expect(importedTask1OnB).toBeVisible({ timeout: 5000 }); + console.log('[Clean Slate] ✓ Both clients have imported tasks'); - console.log('[Clean Slate] ✓ Clean slate test PASSED!'); - } finally { - if (clientA) await closeClient(clientA); - if (clientB) await closeClient(clientB); - } - }, - ); + console.log('[Clean Slate] ✓ Clean slate test PASSED!'); + } finally { + if (clientA) await closeClient(clientA); + if (clientB) await closeClient(clientB); + } + }); /** * Scenario: Late joiner with synced ops - ops are dropped after import @@ -238,98 +219,98 @@ base.describe('@supersync @cleanslate Import Clean Slate Semantics', () => { * represents a complete state reset. Client B's ops are not replayed because * they reference state that no longer exists after the import. */ - base( - 'Late joiner synced ops are dropped after import (no replay)', - async ({ browser, baseURL }, testInfo) => { - const testRunId = generateTestRunId(testInfo.workerIndex); - const uniqueId = Date.now(); - let clientA: SimulatedE2EClient | null = null; - let clientB: SimulatedE2EClient | null = null; + test('Late joiner synced ops are dropped after import (no replay)', async ({ + browser, + baseURL, + testRunId, + }) => { + const uniqueId = Date.now(); + let clientA: SimulatedE2EClient | null = null; + let clientB: SimulatedE2EClient | null = null; - try { - const user = await createTestUser(testRunId); - const syncConfig = getSuperSyncConfig(user); + try { + const user = await createTestUser(testRunId); + const syncConfig = getSuperSyncConfig(user); - // ============ PHASE 1: Client B Creates and Syncs ============ - console.log('[Late Joiner] Phase 1: Client B creates and syncs'); + // ============ PHASE 1: Client B Creates and Syncs ============ + console.log('[Late Joiner] Phase 1: Client B creates and syncs'); - clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); - await clientB.sync.setupSuperSync(syncConfig); + clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); + await clientB.sync.setupSuperSync(syncConfig); - // Client B creates and syncs a task - const taskBLocal = `Task-B-Local-${uniqueId}`; - await clientB.workView.addTask(taskBLocal); - await clientB.sync.syncAndWait(); - console.log(`[Late Joiner] Client B created and synced: ${taskBLocal}`); + // Client B creates and syncs a task + const taskBLocal = `Task-B-Local-${uniqueId}`; + await clientB.workView.addTask(taskBLocal); + await clientB.sync.syncAndWait(); + console.log(`[Late Joiner] Client B created and synced: ${taskBLocal}`); - // Verify task exists on B - await waitForTask(clientB.page, taskBLocal); - console.log('[Late Joiner] Task-B-Local confirmed on Client B'); + // Verify task exists on B + await waitForTask(clientB.page, taskBLocal); + console.log('[Late Joiner] Task-B-Local confirmed on Client B'); - // ============ PHASE 2: Client A Imports (Without Syncing First) ============ - console.log('[Late Joiner] Phase 2: Client A imports backup'); + // ============ PHASE 2: Client A Imports (Without Syncing First) ============ + console.log('[Late Joiner] Phase 2: Client A imports backup'); - clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); - await clientA.sync.setupSuperSync(syncConfig); - // DO NOT sync before import - A doesn't know about B's task + clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); + await clientA.sync.setupSuperSync(syncConfig); + // DO NOT sync before import - A doesn't know about B's task - // Navigate to import page - const importPage = new ImportPage(clientA.page); - await importPage.navigateToImportPage(); + // Navigate to import page + const importPage = new ImportPage(clientA.page); + await importPage.navigateToImportPage(); - // Import backup - const backupPath = ImportPage.getFixturePath('test-backup.json'); - await importPage.importBackupFile(backupPath); - console.log('[Late Joiner] Client A imported backup'); + // Import backup + const backupPath = ImportPage.getFixturePath('test-backup.json'); + await importPage.importBackupFile(backupPath); + console.log('[Late Joiner] Client A imported backup'); - // Re-enable sync after import - await clientA.sync.setupSuperSync(syncConfig); + // Re-enable sync after import + await clientA.sync.setupSuperSync(syncConfig); - // ============ PHASE 3: Both Clients Sync ============ - console.log('[Late Joiner] Phase 3: Both clients sync'); + // ============ PHASE 3: Both Clients Sync ============ + console.log('[Late Joiner] Phase 3: Both clients sync'); - // Client A syncs (uploads SYNC_IMPORT) - await clientA.sync.syncAndWait(); - console.log('[Late Joiner] Client A synced'); + // Client A syncs (uploads SYNC_IMPORT) + await clientA.sync.syncAndWait(); + console.log('[Late Joiner] Client A synced'); - // Client B syncs (receives SYNC_IMPORT) - // This is where the old code would replay Task-B-Local - // With clean slate semantics, Task-B-Local should be dropped - await clientB.sync.syncAndWait(); - console.log('[Late Joiner] Client B synced'); + // Client B syncs (receives SYNC_IMPORT) + // This is where the old code would replay Task-B-Local + // With clean slate semantics, Task-B-Local should be dropped + await clientB.sync.syncAndWait(); + console.log('[Late Joiner] Client B synced'); - // Wait for state to settle - allow UI to update - await clientB.page.waitForTimeout(1000); + // Wait for state to settle - allow UI to update + await clientB.page.waitForTimeout(1000); - // ============ PHASE 4: Verify Clean Slate on Client B ============ - console.log('[Late Joiner] Phase 4: Verifying clean slate on Client B'); + // ============ PHASE 4: Verify Clean Slate on Client B ============ + console.log('[Late Joiner] Phase 4: Verifying clean slate on Client B'); - // Navigate back to work view to see tasks - await clientB.page.goto('/#/work-view'); - await clientB.page.waitForLoadState('networkidle'); + // Navigate back to work view to see tasks + await clientB.page.goto('/#/work-view'); + await clientB.page.waitForLoadState('networkidle'); - // Wait for imported task - await waitForTask(clientB.page, 'E2E Import Test - Active Task With Subtask'); + // Wait for imported task + await waitForTask(clientB.page, 'E2E Import Test - Active Task With Subtask'); - // CRITICAL: Task-B-Local should be GONE (not replayed) - const taskBLocalOnB = clientB.page.locator(`task:has-text("${taskBLocal}")`); - await expect(taskBLocalOnB).not.toBeVisible({ timeout: 5000 }); - console.log('[Late Joiner] ✓ Task-B-Local is GONE (not replayed)'); + // CRITICAL: Task-B-Local should be GONE (not replayed) + const taskBLocalOnB = clientB.page.locator(`task:has-text("${taskBLocal}")`); + await expect(taskBLocalOnB).not.toBeVisible({ timeout: 5000 }); + console.log('[Late Joiner] ✓ Task-B-Local is GONE (not replayed)'); - // Verify imported task is present - const importedTaskOnB = clientB.page.locator( - 'task:has-text("E2E Import Test - Active Task With Subtask")', - ); - await expect(importedTaskOnB).toBeVisible({ timeout: 5000 }); - console.log('[Late Joiner] ✓ Client B has imported tasks'); + // Verify imported task is present + const importedTaskOnB = clientB.page.locator( + 'task:has-text("E2E Import Test - Active Task With Subtask")', + ); + await expect(importedTaskOnB).toBeVisible({ timeout: 5000 }); + console.log('[Late Joiner] ✓ Client B has imported tasks'); - console.log('[Late Joiner] ✓ Late joiner test PASSED!'); - } finally { - if (clientA) await closeClient(clientA); - if (clientB) await closeClient(clientB); - } - }, - ); + console.log('[Late Joiner] ✓ Late joiner test PASSED!'); + } finally { + if (clientA) await closeClient(clientA); + if (clientB) await closeClient(clientB); + } + }); /** * Scenario: Import invalidates PENDING (unsynced) operations from other clients @@ -357,132 +338,132 @@ base.describe('@supersync @cleanslate Import Clean Slate Semantics', () => { * have a vector clock that is CONCURRENT with the import (created without * knowledge of the import). These must be dropped per clean slate semantics. */ - base( - 'Import invalidates PENDING (unsynced) operations from other clients', - async ({ browser, baseURL }, testInfo) => { - const testRunId = generateTestRunId(testInfo.workerIndex); - const uniqueId = Date.now(); - let clientA: SimulatedE2EClient | null = null; - let clientB: SimulatedE2EClient | null = null; + test('Import invalidates PENDING (unsynced) operations from other clients', async ({ + browser, + baseURL, + testRunId, + }) => { + const uniqueId = Date.now(); + let clientA: SimulatedE2EClient | null = null; + let clientB: SimulatedE2EClient | null = null; - try { - const user = await createTestUser(testRunId); - const syncConfig = getSuperSyncConfig(user); + try { + const user = await createTestUser(testRunId); + const syncConfig = getSuperSyncConfig(user); - // ============ PHASE 1: Setup Both Clients with Initial Sync ============ - console.log( - '[Pending Invalidation] Phase 1: Setting up both clients with initial sync', - ); + // ============ PHASE 1: Setup Both Clients with Initial Sync ============ + console.log( + '[Pending Invalidation] Phase 1: Setting up both clients with initial sync', + ); - clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); - await clientA.sync.setupSuperSync(syncConfig); - await clientA.sync.syncAndWait(); // Initial sync to establish vector clock + clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); + await clientA.sync.setupSuperSync(syncConfig); + await clientA.sync.syncAndWait(); // Initial sync to establish vector clock - clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); - await clientB.sync.setupSuperSync(syncConfig); - await clientB.sync.syncAndWait(); // Initial sync to establish vector clock + clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); + await clientB.sync.setupSuperSync(syncConfig); + await clientB.sync.syncAndWait(); // Initial sync to establish vector clock - console.log('[Pending Invalidation] Both clients synced initially'); + console.log('[Pending Invalidation] Both clients synced initially'); - // ============ PHASE 2: Client B Creates Task but Does NOT Sync ============ - console.log( - '[Pending Invalidation] Phase 2: Client B creates task (pending, not synced)', - ); + // ============ PHASE 2: Client B Creates Task but Does NOT Sync ============ + console.log( + '[Pending Invalidation] Phase 2: Client B creates task (pending, not synced)', + ); - const taskBPending = `Task-B-Pending-${uniqueId}`; - await clientB.workView.addTask(taskBPending); - await waitForTask(clientB.page, taskBPending); - console.log( - `[Pending Invalidation] Client B created: ${taskBPending} (NOT synced)`, - ); + const taskBPending = `Task-B-Pending-${uniqueId}`; + await clientB.workView.addTask(taskBPending); + await waitForTask(clientB.page, taskBPending); + console.log( + `[Pending Invalidation] Client B created: ${taskBPending} (NOT synced)`, + ); - // Verify task exists locally on B - const taskBLocator = clientB.page.locator(`task:has-text("${taskBPending}")`); - await expect(taskBLocator).toBeVisible(); + // Verify task exists locally on B + const taskBLocator = clientB.page.locator(`task:has-text("${taskBPending}")`); + await expect(taskBLocator).toBeVisible(); - // ============ PHASE 3: Client A Imports Backup ============ - console.log('[Pending Invalidation] Phase 3: Client A importing backup'); + // ============ PHASE 3: Client A Imports Backup ============ + console.log('[Pending Invalidation] Phase 3: Client A importing backup'); - // Navigate to import page - const importPage = new ImportPage(clientA.page); - await importPage.navigateToImportPage(); + // Navigate to import page + const importPage = new ImportPage(clientA.page); + await importPage.navigateToImportPage(); - // Import the backup file (contains "E2E Import Test" tasks) - const backupPath = ImportPage.getFixturePath('test-backup.json'); - await importPage.importBackupFile(backupPath); - console.log('[Pending Invalidation] Client A imported backup'); + // Import the backup file (contains "E2E Import Test" tasks) + const backupPath = ImportPage.getFixturePath('test-backup.json'); + await importPage.importBackupFile(backupPath); + console.log('[Pending Invalidation] Client A imported backup'); - // Re-enable sync after import (import overwrites globalConfig) - await clientA.sync.setupSuperSync(syncConfig); + // Re-enable sync after import (import overwrites globalConfig) + await clientA.sync.setupSuperSync(syncConfig); - // Wait for imported task to be visible - await waitForTask(clientA.page, 'E2E Import Test - Active Task With Subtask'); - console.log('[Pending Invalidation] Client A has imported tasks'); + // Wait for imported task to be visible + await waitForTask(clientA.page, 'E2E Import Test - Active Task With Subtask'); + console.log('[Pending Invalidation] Client A has imported tasks'); - // ============ PHASE 4: Sync to Propagate SYNC_IMPORT ============ - console.log('[Pending Invalidation] Phase 4: Syncing to propagate SYNC_IMPORT'); + // ============ PHASE 4: Sync to Propagate SYNC_IMPORT ============ + console.log('[Pending Invalidation] Phase 4: Syncing to propagate SYNC_IMPORT'); - // Client A syncs (uploads SYNC_IMPORT) - await clientA.sync.syncAndWait(); - console.log('[Pending Invalidation] Client A synced (SYNC_IMPORT uploaded)'); + // Client A syncs (uploads SYNC_IMPORT) + await clientA.sync.syncAndWait(); + console.log('[Pending Invalidation] Client A synced (SYNC_IMPORT uploaded)'); - // ============ PHASE 5: Client B Syncs (Pending Ops Should Be Invalidated) ============ - console.log( - '[Pending Invalidation] Phase 5: Client B syncs (pending ops invalidated)', - ); + // ============ PHASE 5: Client B Syncs (Pending Ops Should Be Invalidated) ============ + console.log( + '[Pending Invalidation] Phase 5: Client B syncs (pending ops invalidated)', + ); - // This is the CRITICAL sync - B's pending task was created BEFORE the import - // but B hasn't synced it yet. When B syncs now: - // 1. B tries to upload Task-B-Pending (has old vector clock) - // 2. B receives SYNC_IMPORT - // 3. B's pending ops are CONCURRENT to SYNC_IMPORT - // 4. Clean slate semantics: B's pending ops are filtered/dropped - await clientB.sync.syncAndWait(); - console.log('[Pending Invalidation] Client B synced (received SYNC_IMPORT)'); + // This is the CRITICAL sync - B's pending task was created BEFORE the import + // but B hasn't synced it yet. When B syncs now: + // 1. B tries to upload Task-B-Pending (has old vector clock) + // 2. B receives SYNC_IMPORT + // 3. B's pending ops are CONCURRENT to SYNC_IMPORT + // 4. Clean slate semantics: B's pending ops are filtered/dropped + await clientB.sync.syncAndWait(); + console.log('[Pending Invalidation] Client B synced (received SYNC_IMPORT)'); - // Wait for state to settle - allow UI to update - await clientB.page.waitForTimeout(1000); + // Wait for state to settle - allow UI to update + await clientB.page.waitForTimeout(1000); - // ============ PHASE 6: Verify Clean Slate on Client B ============ - console.log('[Pending Invalidation] Phase 6: Verifying clean slate'); + // ============ PHASE 6: Verify Clean Slate on Client B ============ + console.log('[Pending Invalidation] Phase 6: Verifying clean slate'); - // Navigate back to work view to see tasks - await clientB.page.goto('/#/work-view'); - await clientB.page.waitForLoadState('networkidle'); + // Navigate back to work view to see tasks + await clientB.page.goto('/#/work-view'); + await clientB.page.waitForLoadState('networkidle'); - // Wait for imported task to appear - await waitForTask(clientB.page, 'E2E Import Test - Active Task With Subtask'); + // Wait for imported task to appear + await waitForTask(clientB.page, 'E2E Import Test - Active Task With Subtask'); - // CRITICAL VERIFICATION: B's pending task should be GONE - console.log('[Pending Invalidation] Verifying pending task is gone...'); + // CRITICAL VERIFICATION: B's pending task should be GONE + console.log('[Pending Invalidation] Verifying pending task is gone...'); - const taskBPendingOnB = clientB.page.locator(`task:has-text("${taskBPending}")`); - await expect(taskBPendingOnB).not.toBeVisible({ timeout: 5000 }); - console.log( - '[Pending Invalidation] ✓ Task-B-Pending is GONE (correctly invalidated)', - ); + const taskBPendingOnB = clientB.page.locator(`task:has-text("${taskBPending}")`); + await expect(taskBPendingOnB).not.toBeVisible({ timeout: 5000 }); + console.log( + '[Pending Invalidation] ✓ Task-B-Pending is GONE (correctly invalidated)', + ); - // Verify imported task is present on B - const importedTaskOnB = clientB.page.locator( - 'task:has-text("E2E Import Test - Active Task With Subtask")', - ); - await expect(importedTaskOnB).toBeVisible({ timeout: 5000 }); - console.log('[Pending Invalidation] ✓ Client B has imported tasks'); + // Verify imported task is present on B + const importedTaskOnB = clientB.page.locator( + 'task:has-text("E2E Import Test - Active Task With Subtask")', + ); + await expect(importedTaskOnB).toBeVisible({ timeout: 5000 }); + console.log('[Pending Invalidation] ✓ Client B has imported tasks'); - // Also verify on Client A - should NOT have B's pending task - await clientA.page.goto('/#/work-view'); - await clientA.page.waitForLoadState('networkidle'); - const taskBPendingOnA = clientA.page.locator(`task:has-text("${taskBPending}")`); - await expect(taskBPendingOnA).not.toBeVisible({ timeout: 5000 }); - console.log( - "[Pending Invalidation] ✓ Client A also does not have B's pending task", - ); + // Also verify on Client A - should NOT have B's pending task + await clientA.page.goto('/#/work-view'); + await clientA.page.waitForLoadState('networkidle'); + const taskBPendingOnA = clientA.page.locator(`task:has-text("${taskBPending}")`); + await expect(taskBPendingOnA).not.toBeVisible({ timeout: 5000 }); + console.log( + "[Pending Invalidation] ✓ Client A also does not have B's pending task", + ); - console.log('[Pending Invalidation] ✓ Pending invalidation test PASSED!'); - } finally { - if (clientA) await closeClient(clientA); - if (clientB) await closeClient(clientB); - } - }, - ); + console.log('[Pending Invalidation] ✓ Pending invalidation test PASSED!'); + } finally { + if (clientA) await closeClient(clientA); + if (clientB) await closeClient(clientB); + } + }); }); diff --git a/e2e/tests/sync/supersync-late-join.spec.ts b/e2e/tests/sync/supersync-late-join.spec.ts index 955bed05f..45ea29ff6 100644 --- a/e2e/tests/sync/supersync-late-join.spec.ts +++ b/e2e/tests/sync/supersync-late-join.spec.ts @@ -1,13 +1,13 @@ -import { test as base, expect } from '@playwright/test'; +import { test, expect } from '../../fixtures/supersync.fixture'; import { createTestUser, getSuperSyncConfig, createSimulatedClient, closeClient, waitForTask, - isServerHealthy, type SimulatedE2EClient, } from '../../utils/supersync-helpers'; +import { expectTaskVisible } from '../../utils/supersync-assertions'; /** * SuperSync Late Join E2E Tests @@ -15,144 +15,126 @@ import { * Scenarios where a client works locally for a while before enabling sync. */ -const generateTestRunId = (workerIndex: number): string => { - return `${Date.now()}-${workerIndex}`; -}; +test.describe('@supersync SuperSync Late Join', () => { + test('Late joiner merges correctly with existing server data', async ({ + browser, + baseURL, + testRunId, + }) => { + let clientA: SimulatedE2EClient | null = null; + let clientB: SimulatedE2EClient | null = null; -base.describe('@supersync SuperSync Late Join', () => { - let serverHealthy: boolean | null = null; + try { + const user = await createTestUser(testRunId); + const syncConfig = getSuperSyncConfig(user); - base.beforeEach(async ({}, testInfo) => { - if (serverHealthy === null) { - serverHealthy = await isServerHealthy(); - if (!serverHealthy) { - console.warn( - 'SuperSync server not healthy at http://localhost:1901 - skipping tests', - ); - } - } - testInfo.skip(!serverHealthy, 'SuperSync server not running'); - }); + // Client A: The "Server" Client (syncs immediately) + clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); + await clientA.sync.setupSuperSync(syncConfig); - base( - 'Late joiner merges correctly with existing server data', - async ({ browser, baseURL }, testInfo) => { - const testRunId = generateTestRunId(testInfo.workerIndex); - let clientA: SimulatedE2EClient | null = null; - let clientB: SimulatedE2EClient | null = null; + // A creates initial data + const taskA1 = `A1-${testRunId}`; + const taskA2 = `A2-${testRunId}`; + await clientA.workView.addTask(taskA1); + await clientA.workView.addTask(taskA2); - try { - const user = await createTestUser(testRunId); - const syncConfig = getSuperSyncConfig(user); + // A Syncs + await clientA.sync.syncAndWait(); + console.log('Client A synced initial tasks'); - // Client A: The "Server" Client (syncs immediately) - clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); - await clientA.sync.setupSuperSync(syncConfig); + // Client B: The "Late Joiner" (works locally first) + clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); + // DO NOT SETUP SYNC YET - // A creates initial data - const taskA1 = `A1-${testRunId}`; - const taskA2 = `A2-${testRunId}`; - await clientA.workView.addTask(taskA1); - await clientA.workView.addTask(taskA2); + // B creates local data + const taskB1 = `B1-${testRunId}`; + const taskB2 = `B2-${testRunId}`; + await clientB.workView.addTask(taskB1); + await clientB.workView.addTask(taskB2); + console.log('Client B created local tasks'); - // A Syncs - await clientA.sync.syncAndWait(); - console.log('Client A synced initial tasks'); + // A creates more data (server moves ahead) + const taskA3 = `A3-${testRunId}`; + await clientA.workView.addTask(taskA3); + await clientA.sync.syncAndWait(); + console.log('Client A added more tasks and synced'); - // Client B: The "Late Joiner" (works locally first) - clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); - // DO NOT SETUP SYNC YET + // B creates more local data + const taskB3 = `B3-${testRunId}`; + await clientB.workView.addTask(taskB3); + console.log('Client B added more local tasks'); - // B creates local data - const taskB1 = `B1-${testRunId}`; - const taskB2 = `B2-${testRunId}`; - await clientB.workView.addTask(taskB1); - await clientB.workView.addTask(taskB2); - console.log('Client B created local tasks'); + // NOW B Enables Sync + console.log('Client B enabling sync...'); + await clientB.sync.setupSuperSync(syncConfig); - // A creates more data (server moves ahead) - const taskA3 = `A3-${testRunId}`; - await clientA.workView.addTask(taskA3); - await clientA.sync.syncAndWait(); - console.log('Client A added more tasks and synced'); + // Initial sync happens automatically on setup usually, but let's trigger to be sure + await clientB.sync.syncAndWait(); - // B creates more local data - const taskB3 = `B3-${testRunId}`; - await clientB.workView.addTask(taskB3); - console.log('Client B added more local tasks'); - - // NOW B Enables Sync - console.log('Client B enabling sync...'); - await clientB.sync.setupSuperSync(syncConfig); - - // Initial sync happens automatically on setup usually, but let's trigger to be sure - await clientB.sync.syncAndWait(); - - // Handle Potential Conflict Dialog - // Since B has local data and server has remote data, and they might touch global settings or similar, - // a conflict might occur. However, tasks are distinct IDs, so they should merge cleanly. - // If a conflict dialog appears for Global Config or similar, we should handle it. - // Check multiple times as dialog might appear with slight delay - const conflictDialog = clientB.page.locator('dialog-conflict-resolution'); - for (let attempt = 0; attempt < 3; attempt++) { - try { - await conflictDialog.waitFor({ state: 'visible', timeout: 2000 }); - console.log('Conflict dialog detected on B, resolving...'); - // Pick Remote for singleton models (like Global Config) - const useRemoteBtn = conflictDialog - .locator('button') - .filter({ hasText: 'Remote' }) - .first(); - if (await useRemoteBtn.isVisible()) { - await useRemoteBtn.click(); - // Wait for dialog to close - await conflictDialog.waitFor({ state: 'hidden', timeout: 5000 }); - } else { - // Fallback: dismiss dialog - await clientB.page.keyboard.press('Escape'); - } - // Brief wait for any subsequent dialogs - await clientB.page.waitForTimeout(500); - } catch { - // No conflict dialog visible, proceed - break; + // Handle Potential Conflict Dialog + // Since B has local data and server has remote data, and they might touch global settings or similar, + // a conflict might occur. However, tasks are distinct IDs, so they should merge cleanly. + // If a conflict dialog appears for Global Config or similar, we should handle it. + // Check multiple times as dialog might appear with slight delay + const conflictDialog = clientB.page.locator('dialog-conflict-resolution'); + for (let attempt = 0; attempt < 3; attempt++) { + try { + await conflictDialog.waitFor({ state: 'visible', timeout: 2000 }); + console.log('Conflict dialog detected on B, resolving...'); + // Pick Remote for singleton models (like Global Config) + const useRemoteBtn = conflictDialog + .locator('button') + .filter({ hasText: 'Remote' }) + .first(); + if (await useRemoteBtn.isVisible()) { + await useRemoteBtn.click(); + // Wait for dialog to close + await conflictDialog.waitFor({ state: 'hidden', timeout: 5000 }); + } else { + // Fallback: dismiss dialog + await clientB.page.keyboard.press('Escape'); } + // Brief wait for any subsequent dialogs + await clientB.page.waitForTimeout(500); + } catch { + // No conflict dialog visible, proceed + break; } - - // Wait for sync to fully settle after conflict resolution - await clientB.page.waitForTimeout(2000); - - // Trigger another sync to ensure all data is propagated - await clientB.sync.syncAndWait(); - - // A Syncs to get B's data - await clientA.sync.syncAndWait(); - - // Brief wait for state to propagate - await clientA.page.waitForTimeout(1000); - - // VERIFICATION - // Both clients should have A1, A2, A3, B1, B2, B3 - - const allTasks = [taskA1, taskA2, taskA3, taskB1, taskB2, taskB3]; - - console.log('Verifying all tasks on Client A'); - for (const task of allTasks) { - await waitForTask(clientA.page, task); - await expect(clientA.page.locator(`task:has-text("${task}")`)).toBeVisible(); - } - - console.log('Verifying all tasks on Client B'); - for (const task of allTasks) { - await waitForTask(clientB.page, task); - await expect(clientB.page.locator(`task:has-text("${task}")`)).toBeVisible(); - } - } finally { - if (clientA) await closeClient(clientA); - if (clientB) await closeClient(clientB); } - }, - ); + + // Wait for sync to fully settle after conflict resolution + await clientB.page.waitForTimeout(2000); + + // Trigger another sync to ensure all data is propagated + await clientB.sync.syncAndWait(); + + // A Syncs to get B's data + await clientA.sync.syncAndWait(); + + // Brief wait for state to propagate + await clientA.page.waitForTimeout(1000); + + // VERIFICATION + // Both clients should have A1, A2, A3, B1, B2, B3 + + const allTasks = [taskA1, taskA2, taskA3, taskB1, taskB2, taskB3]; + + console.log('Verifying all tasks on Client A'); + for (const task of allTasks) { + await waitForTask(clientA.page, task); + await expectTaskVisible(clientA, task); + } + + console.log('Verifying all tasks on Client B'); + for (const task of allTasks) { + await waitForTask(clientB.page, task); + await expectTaskVisible(clientB, task); + } + } finally { + if (clientA) await closeClient(clientA); + if (clientB) await closeClient(clientB); + } + }); /** * Multiple clients with existing data joining SuperSync @@ -172,160 +154,160 @@ base.describe('@supersync SuperSync Late Join', () => { * - Client 3 enables sync → same as Client 2 * - All clients end up with ALL data from all three clients */ - base( - 'Multiple clients with existing data all merge correctly when joining', - async ({ browser, baseURL }, testInfo) => { - const testRunId = generateTestRunId(testInfo.workerIndex); - let clientA: SimulatedE2EClient | null = null; - let clientB: SimulatedE2EClient | null = null; - let clientC: SimulatedE2EClient | null = null; + test('Multiple clients with existing data all merge correctly when joining', async ({ + browser, + baseURL, + testRunId, + }) => { + 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); + + // === PHASE 1: Each client creates local data WITHOUT syncing === + console.log('[Test] Phase 1: Creating clients with local data (no sync yet)'); + + // Client A creates local tasks + clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); + const taskA1 = `A1-${testRunId}`; + const taskA2 = `A2-${testRunId}`; + await clientA.workView.addTask(taskA1); + await clientA.workView.addTask(taskA2); + await waitForTask(clientA.page, taskA1); + await waitForTask(clientA.page, taskA2); + console.log('[Test] Client A created 2 local tasks'); + + // Client B creates local tasks + clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); + const taskB1 = `B1-${testRunId}`; + const taskB2 = `B2-${testRunId}`; + await clientB.workView.addTask(taskB1); + await clientB.workView.addTask(taskB2); + await waitForTask(clientB.page, taskB1); + await waitForTask(clientB.page, taskB2); + console.log('[Test] Client B created 2 local tasks'); + + // Client C creates local tasks + clientC = await createSimulatedClient(browser, baseURL!, 'C', testRunId); + const taskC1 = `C1-${testRunId}`; + const taskC2 = `C2-${testRunId}`; + await clientC.workView.addTask(taskC1); + await clientC.workView.addTask(taskC2); + await waitForTask(clientC.page, taskC1); + await waitForTask(clientC.page, taskC2); + console.log('[Test] Client C created 2 local tasks'); + + // === PHASE 2: Clients enable sync one by one === + console.log('[Test] Phase 2: Enabling sync on each client'); + + // Client A enables sync first (should create SYNC_IMPORT since server is empty) + console.log('[Test] Client A enabling sync (first to sync)...'); + await clientA.sync.setupSuperSync(syncConfig); + await clientA.sync.syncAndWait(); + console.log('[Test] Client A synced'); + + // Client B enables sync second (should download A's SYNC_IMPORT, NOT create new one) + console.log('[Test] Client B enabling sync (second to sync)...'); + await clientB.sync.setupSuperSync(syncConfig); + await clientB.sync.syncAndWait(); + // Handle potential conflict dialogs + const conflictDialogB = clientB.page.locator('dialog-conflict-resolution'); try { - const user = await createTestUser(testRunId); - const syncConfig = getSuperSyncConfig(user); - - // === PHASE 1: Each client creates local data WITHOUT syncing === - console.log('[Test] Phase 1: Creating clients with local data (no sync yet)'); - - // Client A creates local tasks - clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); - const taskA1 = `A1-${testRunId}`; - const taskA2 = `A2-${testRunId}`; - await clientA.workView.addTask(taskA1); - await clientA.workView.addTask(taskA2); - await waitForTask(clientA.page, taskA1); - await waitForTask(clientA.page, taskA2); - console.log('[Test] Client A created 2 local tasks'); - - // Client B creates local tasks - clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); - const taskB1 = `B1-${testRunId}`; - const taskB2 = `B2-${testRunId}`; - await clientB.workView.addTask(taskB1); - await clientB.workView.addTask(taskB2); - await waitForTask(clientB.page, taskB1); - await waitForTask(clientB.page, taskB2); - console.log('[Test] Client B created 2 local tasks'); - - // Client C creates local tasks - clientC = await createSimulatedClient(browser, baseURL!, 'C', testRunId); - const taskC1 = `C1-${testRunId}`; - const taskC2 = `C2-${testRunId}`; - await clientC.workView.addTask(taskC1); - await clientC.workView.addTask(taskC2); - await waitForTask(clientC.page, taskC1); - await waitForTask(clientC.page, taskC2); - console.log('[Test] Client C created 2 local tasks'); - - // === PHASE 2: Clients enable sync one by one === - console.log('[Test] Phase 2: Enabling sync on each client'); - - // Client A enables sync first (should create SYNC_IMPORT since server is empty) - console.log('[Test] Client A enabling sync (first to sync)...'); - await clientA.sync.setupSuperSync(syncConfig); - await clientA.sync.syncAndWait(); - console.log('[Test] Client A synced'); - - // Client B enables sync second (should download A's SYNC_IMPORT, NOT create new one) - console.log('[Test] Client B enabling sync (second to sync)...'); - await clientB.sync.setupSuperSync(syncConfig); - await clientB.sync.syncAndWait(); - // Handle potential conflict dialogs - const conflictDialogB = clientB.page.locator('dialog-conflict-resolution'); - try { - await conflictDialogB.waitFor({ state: 'visible', timeout: 2000 }); - console.log('[Test] Conflict dialog on B, resolving...'); - const useRemoteBtn = conflictDialogB - .locator('button') - .filter({ hasText: 'Remote' }) - .first(); - if (await useRemoteBtn.isVisible()) { - await useRemoteBtn.click(); - await conflictDialogB.waitFor({ state: 'hidden', timeout: 5000 }); - } - } catch { - // No conflict dialog, proceed + await conflictDialogB.waitFor({ state: 'visible', timeout: 2000 }); + console.log('[Test] Conflict dialog on B, resolving...'); + const useRemoteBtn = conflictDialogB + .locator('button') + .filter({ hasText: 'Remote' }) + .first(); + if (await useRemoteBtn.isVisible()) { + await useRemoteBtn.click(); + await conflictDialogB.waitFor({ state: 'hidden', timeout: 5000 }); } - await clientB.sync.syncAndWait(); - console.log('[Test] Client B synced'); - - // Client C enables sync third (should also merge, NOT create SYNC_IMPORT) - console.log('[Test] Client C enabling sync (third to sync)...'); - await clientC.sync.setupSuperSync(syncConfig); - await clientC.sync.syncAndWait(); - // Handle potential conflict dialogs - const conflictDialogC = clientC.page.locator('dialog-conflict-resolution'); - try { - await conflictDialogC.waitFor({ state: 'visible', timeout: 2000 }); - console.log('[Test] Conflict dialog on C, resolving...'); - const useRemoteBtn = conflictDialogC - .locator('button') - .filter({ hasText: 'Remote' }) - .first(); - if (await useRemoteBtn.isVisible()) { - await useRemoteBtn.click(); - await conflictDialogC.waitFor({ state: 'hidden', timeout: 5000 }); - } - } catch { - // No conflict dialog, proceed - } - await clientC.sync.syncAndWait(); - console.log('[Test] Client C synced'); - - // === PHASE 3: All clients sync to get each other's data === - console.log('[Test] Phase 3: Final sync round'); - await clientA.sync.syncAndWait(); - await clientB.sync.syncAndWait(); - await clientC.sync.syncAndWait(); - - // Brief wait for state to settle - await clientA.page.waitForTimeout(1000); - - // === PHASE 4: Verification === - console.log('[Test] Phase 4: Verifying all clients have all data'); - - const allTasks = [taskA1, taskA2, taskB1, taskB2, taskC1, taskC2]; - - // Verify Client A has all 6 tasks - console.log('[Test] Verifying Client A has all tasks...'); - for (const task of allTasks) { - await waitForTask(clientA.page, task); - await expect(clientA.page.locator(`task:has-text("${task}")`)).toBeVisible(); - } - - // Verify Client B has all 6 tasks - console.log('[Test] Verifying Client B has all tasks...'); - for (const task of allTasks) { - await waitForTask(clientB.page, task); - await expect(clientB.page.locator(`task:has-text("${task}")`)).toBeVisible(); - } - - // Verify Client C has all 6 tasks - console.log('[Test] Verifying Client C has all tasks...'); - for (const task of allTasks) { - await waitForTask(clientC.page, task); - await expect(clientC.page.locator(`task:has-text("${task}")`)).toBeVisible(); - } - - // Additional verification - count tasks to ensure no duplicates or missing - const taskLocatorA = clientA.page.locator(`task:has-text("${testRunId}")`); - const taskCountA = await taskLocatorA.count(); - expect(taskCountA).toBe(6); - - const taskLocatorB = clientB.page.locator(`task:has-text("${testRunId}")`); - const taskCountB = await taskLocatorB.count(); - expect(taskCountB).toBe(6); - - const taskLocatorC = clientC.page.locator(`task:has-text("${testRunId}")`); - const taskCountC = await taskLocatorC.count(); - expect(taskCountC).toBe(6); - - console.log('[Test] SUCCESS: All 3 clients with existing data merged correctly'); - } finally { - if (clientA) await closeClient(clientA).catch(() => {}); - if (clientB) await closeClient(clientB).catch(() => {}); - if (clientC) await closeClient(clientC).catch(() => {}); + } catch { + // No conflict dialog, proceed } - }, - ); + await clientB.sync.syncAndWait(); + console.log('[Test] Client B synced'); + + // Client C enables sync third (should also merge, NOT create SYNC_IMPORT) + console.log('[Test] Client C enabling sync (third to sync)...'); + await clientC.sync.setupSuperSync(syncConfig); + await clientC.sync.syncAndWait(); + // Handle potential conflict dialogs + const conflictDialogC = clientC.page.locator('dialog-conflict-resolution'); + try { + await conflictDialogC.waitFor({ state: 'visible', timeout: 2000 }); + console.log('[Test] Conflict dialog on C, resolving...'); + const useRemoteBtn = conflictDialogC + .locator('button') + .filter({ hasText: 'Remote' }) + .first(); + if (await useRemoteBtn.isVisible()) { + await useRemoteBtn.click(); + await conflictDialogC.waitFor({ state: 'hidden', timeout: 5000 }); + } + } catch { + // No conflict dialog, proceed + } + await clientC.sync.syncAndWait(); + console.log('[Test] Client C synced'); + + // === PHASE 3: All clients sync to get each other's data === + console.log('[Test] Phase 3: Final sync round'); + await clientA.sync.syncAndWait(); + await clientB.sync.syncAndWait(); + await clientC.sync.syncAndWait(); + + // Brief wait for state to settle + await clientA.page.waitForTimeout(1000); + + // === PHASE 4: Verification === + console.log('[Test] Phase 4: Verifying all clients have all data'); + + const allTasks = [taskA1, taskA2, taskB1, taskB2, taskC1, taskC2]; + + // Verify Client A has all 6 tasks + console.log('[Test] Verifying Client A has all tasks...'); + for (const task of allTasks) { + await waitForTask(clientA.page, task); + await expectTaskVisible(clientA, task); + } + + // Verify Client B has all 6 tasks + console.log('[Test] Verifying Client B has all tasks...'); + for (const task of allTasks) { + await waitForTask(clientB.page, task); + await expectTaskVisible(clientB, task); + } + + // Verify Client C has all 6 tasks + console.log('[Test] Verifying Client C has all tasks...'); + for (const task of allTasks) { + await waitForTask(clientC.page, task); + await expectTaskVisible(clientC, task); + } + + // Additional verification - count tasks to ensure no duplicates or missing + const taskLocatorA = clientA.page.locator(`task:has-text("${testRunId}")`); + const taskCountA = await taskLocatorA.count(); + expect(taskCountA).toBe(6); + + const taskLocatorB = clientB.page.locator(`task:has-text("${testRunId}")`); + const taskCountB = await taskLocatorB.count(); + expect(taskCountB).toBe(6); + + const taskLocatorC = clientC.page.locator(`task:has-text("${testRunId}")`); + const taskCountC = await taskLocatorC.count(); + expect(taskCountC).toBe(6); + + console.log('[Test] SUCCESS: All 3 clients with existing data merged correctly'); + } finally { + if (clientA) await closeClient(clientA).catch(() => {}); + if (clientB) await closeClient(clientB).catch(() => {}); + if (clientC) await closeClient(clientC).catch(() => {}); + } + }); }); diff --git a/e2e/tests/sync/supersync-lww-conflict.spec.ts b/e2e/tests/sync/supersync-lww-conflict.spec.ts index 6cfa557eb..a04ad7b09 100644 --- a/e2e/tests/sync/supersync-lww-conflict.spec.ts +++ b/e2e/tests/sync/supersync-lww-conflict.spec.ts @@ -1,11 +1,10 @@ -import { test as base, expect } from '@playwright/test'; +import { test, expect } from '../../fixtures/supersync.fixture'; import { createTestUser, getSuperSyncConfig, createSimulatedClient, closeClient, waitForTask, - isServerHealthy, type SimulatedE2EClient, } from '../../utils/supersync-helpers'; @@ -20,25 +19,7 @@ import { * - All clients converge to the same state */ -const generateTestRunId = (workerIndex: number): string => { - return `${Date.now()}-${workerIndex}`; -}; - -base.describe('@supersync SuperSync LWW Conflict Resolution', () => { - let serverHealthy: boolean | null = null; - - base.beforeEach(async ({}, testInfo) => { - if (serverHealthy === null) { - serverHealthy = await isServerHealthy(); - if (!serverHealthy) { - console.warn( - 'SuperSync server not healthy at http://localhost:1901 - skipping tests', - ); - } - } - testInfo.skip(!serverHealthy, 'SuperSync server not running'); - }); - +test.describe('@supersync SuperSync LWW Conflict Resolution', () => { /** * Scenario: LWW Auto-Resolution with Newer Remote * @@ -54,77 +35,76 @@ base.describe('@supersync SuperSync LWW Conflict Resolution', () => { * 7. Client A syncs (LWW: server has T2 > T1, so remote wins) * 8. Verify A's state matches B's state (B's title change wins) */ - base( - 'LWW: Remote wins when remote timestamp is newer', - async ({ browser, baseURL }, testInfo) => { - testInfo.setTimeout(90000); - const testRunId = generateTestRunId(testInfo.workerIndex); - const appUrl = baseURL || 'http://localhost:4242'; - let clientA: SimulatedE2EClient | null = null; - let clientB: SimulatedE2EClient | null = null; + test('LWW: Remote wins when remote timestamp is newer', 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); + try { + const user = await createTestUser(testRunId); + const syncConfig = getSuperSyncConfig(user); - // Setup clients - clientA = await createSimulatedClient(browser, appUrl, 'A', testRunId); - await clientA.sync.setupSuperSync(syncConfig); + // 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); + clientB = await createSimulatedClient(browser, appUrl, 'B', testRunId); + await clientB.sync.setupSuperSync(syncConfig); - // 1. Client A creates task - const taskName = `LWW-Remote-${testRunId}`; - await clientA.workView.addTask(taskName); - await clientA.sync.syncAndWait(); + // 1. Client A creates task + const taskName = `LWW-Remote-${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); + // 2. Client B downloads the task + await clientB.sync.syncAndWait(); + await waitForTask(clientB.page, taskName); - // 3. Client A marks task done (earlier timestamp) - const taskLocatorA = clientA.page - .locator(`task:not(.ng-animating):has-text("${taskName}")`) - .first(); - await taskLocatorA.hover(); - await taskLocatorA.locator('.task-done-btn').click(); - await expect(taskLocatorA).toHaveClass(/isDone/); + // 3. Client A marks task done (earlier timestamp) + const taskLocatorA = clientA.page + .locator(`task:not(.ng-animating):has-text("${taskName}")`) + .first(); + await taskLocatorA.hover(); + await taskLocatorA.locator('.task-done-btn').click(); + await expect(taskLocatorA).toHaveClass(/isDone/); - // 4. Wait for time to advance (ensures B's timestamp will be newer) - await clientA.page.waitForTimeout(500); + // 4. Wait for time to advance (ensures B's timestamp will be newer) + await clientA.page.waitForTimeout(500); - // 5. Client B also marks task done (later timestamp, but same logical change) - const taskLocatorB = clientB.page - .locator(`task:not(.ng-animating):has-text("${taskName}")`) - .first(); - await taskLocatorB.hover(); - await taskLocatorB.locator('.task-done-btn').click(); - await expect(taskLocatorB).toHaveClass(/isDone/); + // 5. Client B also marks task done (later timestamp, but same logical change) + const taskLocatorB = clientB.page + .locator(`task:not(.ng-animating):has-text("${taskName}")`) + .first(); + await taskLocatorB.hover(); + await taskLocatorB.locator('.task-done-btn').click(); + await expect(taskLocatorB).toHaveClass(/isDone/); - // 6. Client B syncs first (B's change goes to server) - await clientB.sync.syncAndWait(); + // 6. Client B syncs first (B's change goes to server) + await clientB.sync.syncAndWait(); - // 7. Client A syncs (LWW: B's timestamp is newer, so remote wins) - await clientA.sync.syncAndWait(); + // 7. Client A syncs (LWW: B's timestamp is newer, so remote wins) + await clientA.sync.syncAndWait(); - // 8. Both clients should have consistent state - // (Both should show task as done - the outcome is the same for this test) - await expect(taskLocatorA).toHaveClass(/isDone/); - await expect(taskLocatorB).toHaveClass(/isDone/); + // 8. Both clients should have consistent state + // (Both should show task as done - the outcome is the same for this test) + await expect(taskLocatorA).toHaveClass(/isDone/); + await expect(taskLocatorB).toHaveClass(/isDone/); - // Final convergence check - await clientB.sync.syncAndWait(); + // Final convergence check + await clientB.sync.syncAndWait(); - console.log( - '[LWW-Remote] ✓ Remote wins when timestamp is newer - clients converged', - ); - } finally { - if (clientA) await closeClient(clientA); - if (clientB) await closeClient(clientB); - } - }, - ); + console.log( + '[LWW-Remote] ✓ Remote wins when timestamp is newer - clients converged', + ); + } finally { + if (clientA) await closeClient(clientA); + if (clientB) await closeClient(clientB); + } + }); /** * Scenario: LWW Notification Appears @@ -139,90 +119,87 @@ base.describe('@supersync SuperSync LWW Conflict Resolution', () => { * 5. Client B syncs (triggers LWW resolution) * 6. Verify notification appears indicating auto-resolution */ - base( - 'LWW: Notification appears after conflict auto-resolution', - async ({ browser, baseURL }, testInfo) => { - testInfo.setTimeout(90000); - const testRunId = generateTestRunId(testInfo.workerIndex); - const appUrl = baseURL || 'http://localhost:4242'; - let clientA: SimulatedE2EClient | null = null; - let clientB: SimulatedE2EClient | null = null; + test('LWW: Notification appears after conflict auto-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 = `LWW-Notify-${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. Both clients make concurrent changes + // Client A marks done + const taskLocatorA = clientA.page + .locator(`task:not(.ng-animating):has-text("${taskName}")`) + .first(); + await taskLocatorA.hover(); + await taskLocatorA.locator('.task-done-btn').click(); + + // Client B also marks done + const taskLocatorB = clientB.page + .locator(`task:not(.ng-animating):has-text("${taskName}")`) + .first(); + await taskLocatorB.hover(); + await taskLocatorB.locator('.task-done-btn').click(); + + // 4. Client A syncs first + await clientA.sync.syncAndWait(); + + // 5. Client B syncs (triggers LWW resolution) + await clientB.sync.syncAndWait(); + + // 6. Verify LWW notification appears on Client B + // The notification contains "auto-resolved" or similar text + // Note: The exact text depends on translation; we check for the snack appearing + const snackBar = clientB.page.locator('snack-custom, .mat-mdc-snack-bar-container'); + + // Try to catch the notification (it may disappear quickly) 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 = `LWW-Notify-${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. Both clients make concurrent changes - // Client A marks done - const taskLocatorA = clientA.page - .locator(`task:not(.ng-animating):has-text("${taskName}")`) - .first(); - await taskLocatorA.hover(); - await taskLocatorA.locator('.task-done-btn').click(); - - // Client B also marks done - const taskLocatorB = clientB.page - .locator(`task:not(.ng-animating):has-text("${taskName}")`) - .first(); - await taskLocatorB.hover(); - await taskLocatorB.locator('.task-done-btn').click(); - - // 4. Client A syncs first - await clientA.sync.syncAndWait(); - - // 5. Client B syncs (triggers LWW resolution) - await clientB.sync.syncAndWait(); - - // 6. Verify LWW notification appears on Client B - // The notification contains "auto-resolved" or similar text - // Note: The exact text depends on translation; we check for the snack appearing - const snackBar = clientB.page.locator( - 'snack-custom, .mat-mdc-snack-bar-container', - ); - - // Try to catch the notification (it may disappear quickly) - try { - await snackBar.waitFor({ state: 'visible', timeout: 3000 }); - console.log('[LWW-Notify] Snackbar appeared after sync'); - } catch { - // Snackbar may have already disappeared - that's okay - console.log('[LWW-Notify] Snackbar not visible (may have auto-dismissed)'); - } - - // The key assertion is that sync completed without blocking dialogs - // and both clients have consistent state - - // Final convergence - await clientA.sync.syncAndWait(); - await clientB.sync.syncAndWait(); - - // Both should have same state - await expect(taskLocatorA).toHaveClass(/isDone/); - await expect(taskLocatorB).toHaveClass(/isDone/); - - console.log('[LWW-Notify] ✓ Conflict auto-resolved without blocking dialog'); - } finally { - if (clientA) await closeClient(clientA); - if (clientB) await closeClient(clientB); + await snackBar.waitFor({ state: 'visible', timeout: 3000 }); + console.log('[LWW-Notify] Snackbar appeared after sync'); + } catch { + // Snackbar may have already disappeared - that's okay + console.log('[LWW-Notify] Snackbar not visible (may have auto-dismissed)'); } - }, - ); + + // The key assertion is that sync completed without blocking dialogs + // and both clients have consistent state + + // Final convergence + await clientA.sync.syncAndWait(); + await clientB.sync.syncAndWait(); + + // Both should have same state + await expect(taskLocatorA).toHaveClass(/isDone/); + await expect(taskLocatorB).toHaveClass(/isDone/); + + console.log('[LWW-Notify] ✓ Conflict auto-resolved without blocking dialog'); + } finally { + if (clientA) await closeClient(clientA); + if (clientB) await closeClient(clientB); + } + }); /** * Scenario: LWW Convergence with Three Clients @@ -237,94 +214,93 @@ base.describe('@supersync SuperSync LWW Conflict Resolution', () => { * 5. Final sync round to converge * 6. Verify all three clients have identical state */ - base( - 'LWW: Three clients converge to same state', - async ({ browser, baseURL }, testInfo) => { - testInfo.setTimeout(120000); - const testRunId = generateTestRunId(testInfo.workerIndex); - const appUrl = baseURL || 'http://localhost:4242'; - let clientA: SimulatedE2EClient | null = null; - let clientB: SimulatedE2EClient | null = null; - let clientC: SimulatedE2EClient | null = null; + test('LWW: 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); + try { + const user = await createTestUser(testRunId); + const syncConfig = getSuperSyncConfig(user); - // Setup all 3 clients - clientA = await createSimulatedClient(browser, appUrl, 'A', testRunId); - await clientA.sync.setupSuperSync(syncConfig); + // Setup all 3 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); + clientB = await createSimulatedClient(browser, appUrl, 'B', testRunId); + await clientB.sync.setupSuperSync(syncConfig); - clientC = await createSimulatedClient(browser, appUrl, 'C', testRunId); - await clientC.sync.setupSuperSync(syncConfig); + clientC = await createSimulatedClient(browser, appUrl, 'C', testRunId); + await clientC.sync.setupSuperSync(syncConfig); - // 1. Client A creates task - const taskName = `LWW-3Way-${testRunId}`; - await clientA.workView.addTask(taskName); - await clientA.sync.syncAndWait(); + // 1. Client A creates task + const taskName = `LWW-3Way-${testRunId}`; + await clientA.workView.addTask(taskName); + await clientA.sync.syncAndWait(); - // 2. Clients B and C download the task - await clientB.sync.syncAndWait(); - await clientC.sync.syncAndWait(); + // 2. Clients B and C download the task + await clientB.sync.syncAndWait(); + await clientC.sync.syncAndWait(); - // Verify all have the task - await waitForTask(clientA.page, taskName); - await waitForTask(clientB.page, taskName); - await waitForTask(clientC.page, taskName); + // Verify all have the task + await waitForTask(clientA.page, taskName); + await waitForTask(clientB.page, taskName); + await waitForTask(clientC.page, taskName); - // 3. All three clients make concurrent changes (mark as done) - const taskLocatorA = clientA.page - .locator(`task:not(.ng-animating):has-text("${taskName}")`) - .first(); - await taskLocatorA.hover(); - await taskLocatorA.locator('.task-done-btn').click(); + // 3. All three clients make concurrent changes (mark as done) + const taskLocatorA = clientA.page + .locator(`task:not(.ng-animating):has-text("${taskName}")`) + .first(); + await taskLocatorA.hover(); + await taskLocatorA.locator('.task-done-btn').click(); - const taskLocatorB = clientB.page - .locator(`task:not(.ng-animating):has-text("${taskName}")`) - .first(); - await taskLocatorB.hover(); - await taskLocatorB.locator('.task-done-btn').click(); + const taskLocatorB = clientB.page + .locator(`task:not(.ng-animating):has-text("${taskName}")`) + .first(); + await taskLocatorB.hover(); + await taskLocatorB.locator('.task-done-btn').click(); - const taskLocatorC = clientC.page - .locator(`task:not(.ng-animating):has-text("${taskName}")`) - .first(); - await taskLocatorC.hover(); - await taskLocatorC.locator('.task-done-btn').click(); + const taskLocatorC = clientC.page + .locator(`task:not(.ng-animating):has-text("${taskName}")`) + .first(); + await taskLocatorC.hover(); + await taskLocatorC.locator('.task-done-btn').click(); - // 4. Sequential syncs (LWW auto-resolves conflicts) - await clientA.sync.syncAndWait(); - await clientB.sync.syncAndWait(); - await clientC.sync.syncAndWait(); + // 4. Sequential syncs (LWW auto-resolves conflicts) + await clientA.sync.syncAndWait(); + await clientB.sync.syncAndWait(); + await clientC.sync.syncAndWait(); - // 5. Final round to ensure convergence - await clientA.sync.syncAndWait(); - await clientB.sync.syncAndWait(); - await clientC.sync.syncAndWait(); + // 5. Final round to ensure convergence + await clientA.sync.syncAndWait(); + await clientB.sync.syncAndWait(); + await clientC.sync.syncAndWait(); - // 6. Verify all three clients have identical state - await expect(taskLocatorA).toHaveClass(/isDone/); - await expect(taskLocatorB).toHaveClass(/isDone/); - await expect(taskLocatorC).toHaveClass(/isDone/); + // 6. Verify all three clients have identical state + await expect(taskLocatorA).toHaveClass(/isDone/); + await expect(taskLocatorB).toHaveClass(/isDone/); + await expect(taskLocatorC).toHaveClass(/isDone/); - // Count tasks should be identical - const countA = await clientA.page.locator('task').count(); - const countB = await clientB.page.locator('task').count(); - const countC = await clientC.page.locator('task').count(); + // Count tasks should be identical + const countA = await clientA.page.locator('task').count(); + const countB = await clientB.page.locator('task').count(); + const countC = await clientC.page.locator('task').count(); - expect(countA).toBe(countB); - expect(countB).toBe(countC); + expect(countA).toBe(countB); + expect(countB).toBe(countC); - console.log('[LWW-3Way] ✓ All three clients converged via LWW auto-resolution'); - } finally { - if (clientA) await closeClient(clientA); - if (clientB) await closeClient(clientB); - if (clientC) await closeClient(clientC); - } - }, - ); + console.log('[LWW-3Way] ✓ All three clients converged via LWW auto-resolution'); + } finally { + if (clientA) await closeClient(clientA); + if (clientB) await closeClient(clientB); + if (clientC) await closeClient(clientC); + } + }); /** * Scenario: LWW Local Wins Creates Update Op @@ -344,85 +320,84 @@ base.describe('@supersync SuperSync LWW Conflict Resolution', () => { * 8. Client B syncs again (receives A's winning state) * 9. Verify B now has A's state (proves local-win update op was created) */ - base( - 'LWW: Local wins when local timestamp is newer, propagates to other clients', - async ({ browser, baseURL }, testInfo) => { - testInfo.setTimeout(120000); - const testRunId = generateTestRunId(testInfo.workerIndex); - const appUrl = baseURL || 'http://localhost:4242'; - let clientA: SimulatedE2EClient | null = null; - let clientB: SimulatedE2EClient | null = null; + test('LWW: Local wins when local timestamp is newer, propagates to other clients', 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); + try { + const user = await createTestUser(testRunId); + const syncConfig = getSuperSyncConfig(user); - // Setup clients - clientA = await createSimulatedClient(browser, appUrl, 'A', testRunId); - await clientA.sync.setupSuperSync(syncConfig); + // 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); + clientB = await createSimulatedClient(browser, appUrl, 'B', testRunId); + await clientB.sync.setupSuperSync(syncConfig); - // 1. Client A creates task - const taskName = `LWW-Local-${testRunId}`; - await clientA.workView.addTask(taskName); - await clientA.sync.syncAndWait(); + // 1. Client A creates task + const taskName = `LWW-Local-${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); + // 2. Client B downloads the task + await clientB.sync.syncAndWait(); + await waitForTask(clientB.page, taskName); - // 3. Client B makes change first (earlier timestamp) - const taskLocatorB = clientB.page - .locator(`task:not(.ng-animating):has-text("${taskName}")`) - .first(); - await taskLocatorB.hover(); - await taskLocatorB.locator('.task-done-btn').click(); - await expect(taskLocatorB).toHaveClass(/isDone/); + // 3. Client B makes change first (earlier timestamp) + const taskLocatorB = clientB.page + .locator(`task:not(.ng-animating):has-text("${taskName}")`) + .first(); + await taskLocatorB.hover(); + await taskLocatorB.locator('.task-done-btn').click(); + await expect(taskLocatorB).toHaveClass(/isDone/); - // 4. Wait for time to advance significantly - await clientB.page.waitForTimeout(1000); + // 4. Wait for time to advance significantly + await clientB.page.waitForTimeout(1000); - // 5. Client A makes same change (later timestamp) - const taskLocatorA = clientA.page - .locator(`task:not(.ng-animating):has-text("${taskName}")`) - .first(); - await taskLocatorA.hover(); - await taskLocatorA.locator('.task-done-btn').click(); - await expect(taskLocatorA).toHaveClass(/isDone/); + // 5. Client A makes same change (later timestamp) + const taskLocatorA = clientA.page + .locator(`task:not(.ng-animating):has-text("${taskName}")`) + .first(); + await taskLocatorA.hover(); + await taskLocatorA.locator('.task-done-btn').click(); + await expect(taskLocatorA).toHaveClass(/isDone/); - // 6. Client B syncs (uploads B's earlier change to server) - await clientB.sync.syncAndWait(); + // 6. Client B syncs (uploads B's earlier change to server) + await clientB.sync.syncAndWait(); - // 7. Client A syncs (LWW: A's local timestamp > server's timestamp for this entity) - // Since A's change is newer, local wins, and A creates new update op - await clientA.sync.syncAndWait(); + // 7. Client A syncs (LWW: A's local timestamp > server's timestamp for this entity) + // Since A's change is newer, local wins, and A creates new update op + await clientA.sync.syncAndWait(); - // 8. Client B syncs again (should receive A's winning state via new update op) - await clientB.sync.syncAndWait(); + // 8. Client B syncs again (should receive A's winning state via new update op) + await clientB.sync.syncAndWait(); - // 9. Both clients should have consistent state - // In this case, both marked as done, so state should be isDone - await expect(taskLocatorA).toHaveClass(/isDone/); - await expect(taskLocatorB).toHaveClass(/isDone/); + // 9. Both clients should have consistent state + // In this case, both marked as done, so state should be isDone + await expect(taskLocatorA).toHaveClass(/isDone/); + await expect(taskLocatorB).toHaveClass(/isDone/); - // Final convergence - await clientA.sync.syncAndWait(); + // Final convergence + await clientA.sync.syncAndWait(); - // Both should have same task count - const countA = await clientA.page.locator(`task:has-text("${taskName}")`).count(); - const countB = await clientB.page.locator(`task:has-text("${taskName}")`).count(); - expect(countA).toBe(1); - expect(countB).toBe(1); + // Both should have same task count + const countA = await clientA.page.locator(`task:has-text("${taskName}")`).count(); + const countB = await clientB.page.locator(`task:has-text("${taskName}")`).count(); + expect(countA).toBe(1); + expect(countB).toBe(1); - console.log('[LWW-Local] ✓ Local wins and propagates state to other clients'); - } finally { - if (clientA) await closeClient(clientA); - if (clientB) await closeClient(clientB); - } - }, - ); + console.log('[LWW-Local] ✓ Local wins and propagates state to other clients'); + } finally { + if (clientA) await closeClient(clientA); + if (clientB) await closeClient(clientB); + } + }); /** * Scenario: LWW Local-Win Op Uploaded in Same Sync Cycle @@ -445,115 +420,114 @@ base.describe('@supersync SuperSync LWW Conflict Resolution', () => { * Before the fix: B would keep "B-Title" because A's local-win op wasn't uploaded * After the fix: B gets "A-Title" because A re-uploads in the same sync cycle */ - base( - 'LWW: Local-win update op is uploaded in same sync cycle', - async ({ browser, baseURL }, testInfo) => { - testInfo.setTimeout(120000); - const testRunId = generateTestRunId(testInfo.workerIndex); - const appUrl = baseURL || 'http://localhost:4242'; - let clientA: SimulatedE2EClient | null = null; - let clientB: SimulatedE2EClient | null = null; + test('LWW: Local-win update op is uploaded in same sync cycle', 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); + try { + const user = await createTestUser(testRunId); + const syncConfig = getSuperSyncConfig(user); - // Setup clients - clientA = await createSimulatedClient(browser, appUrl, 'A', testRunId); - await clientA.sync.setupSuperSync(syncConfig); + // 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); + clientB = await createSimulatedClient(browser, appUrl, 'B', testRunId); + await clientB.sync.setupSuperSync(syncConfig); - // 1. Client A creates task with original title - const originalTitle = `Original-${testRunId}`; - const titleA = `A-Title-${testRunId}`; - const titleB = `B-Title-${testRunId}`; + // 1. Client A creates task with original title + const originalTitle = `Original-${testRunId}`; + const titleA = `A-Title-${testRunId}`; + const titleB = `B-Title-${testRunId}`; - await clientA.workView.addTask(originalTitle); - await clientA.sync.syncAndWait(); + await clientA.workView.addTask(originalTitle); + await clientA.sync.syncAndWait(); - // 2. Client B downloads the task - await clientB.sync.syncAndWait(); - await waitForTask(clientB.page, originalTitle); + // 2. Client B downloads the task + await clientB.sync.syncAndWait(); + await waitForTask(clientB.page, originalTitle); - // 3. Client B edits title FIRST (earlier timestamp) - const taskLocatorB = clientB.page - .locator(`task:not(.ng-animating):has-text("${originalTitle}")`) - .first(); - await taskLocatorB.dblclick(); // Double-click to edit - const editInputB = clientB.page.locator( - 'input.mat-mdc-input-element:focus, textarea:focus', - ); - await editInputB.waitFor({ state: 'visible', timeout: 5000 }); - await editInputB.fill(titleB); - await clientB.page.keyboard.press('Enter'); - await clientB.page.waitForTimeout(500); + // 3. Client B edits title FIRST (earlier timestamp) + const taskLocatorB = clientB.page + .locator(`task:not(.ng-animating):has-text("${originalTitle}")`) + .first(); + await taskLocatorB.dblclick(); // Double-click to edit + const editInputB = clientB.page.locator( + 'input.mat-mdc-input-element:focus, textarea:focus', + ); + await editInputB.waitFor({ state: 'visible', timeout: 5000 }); + await editInputB.fill(titleB); + await clientB.page.keyboard.press('Enter'); + await clientB.page.waitForTimeout(500); - // Verify B has the new title locally - await waitForTask(clientB.page, titleB); + // Verify B has the new title locally + await waitForTask(clientB.page, titleB); - // 4. Wait for timestamp gap (ensures A's timestamp > B's timestamp) - await clientB.page.waitForTimeout(1000); + // 4. Wait for timestamp gap (ensures A's timestamp > B's timestamp) + await clientB.page.waitForTimeout(1000); - // 5. Client A edits title (later timestamp) - const taskLocatorA = clientA.page - .locator(`task:not(.ng-animating):has-text("${originalTitle}")`) - .first(); - await taskLocatorA.dblclick(); // Double-click to edit - const editInputA = clientA.page.locator( - 'input.mat-mdc-input-element:focus, textarea:focus', - ); - await editInputA.waitFor({ state: 'visible', timeout: 5000 }); - await editInputA.fill(titleA); - await clientA.page.keyboard.press('Enter'); - await clientA.page.waitForTimeout(500); + // 5. Client A edits title (later timestamp) + const taskLocatorA = clientA.page + .locator(`task:not(.ng-animating):has-text("${originalTitle}")`) + .first(); + await taskLocatorA.dblclick(); // Double-click to edit + const editInputA = clientA.page.locator( + 'input.mat-mdc-input-element:focus, textarea:focus', + ); + await editInputA.waitFor({ state: 'visible', timeout: 5000 }); + await editInputA.fill(titleA); + await clientA.page.keyboard.press('Enter'); + await clientA.page.waitForTimeout(500); - // Verify A has the new title locally - await waitForTask(clientA.page, titleA); + // Verify A has the new title locally + await waitForTask(clientA.page, titleA); - // 6. Client B syncs FIRST (uploads "B-Title" to server) - await clientB.sync.syncAndWait(); + // 6. Client B syncs FIRST (uploads "B-Title" to server) + await clientB.sync.syncAndWait(); - // 7. Client A syncs ONCE - // This should: - // - Upload A's "A-Title" op - // - Server rejects it (conflict with B's "B-Title") - // - A receives B's op as piggybacked - // - LWW: A's timestamp > B's, so A wins - // - A creates local-win UPDATE op with "A-Title" - // - FIX: A immediately re-uploads this op - await clientA.sync.syncAndWait(); + // 7. Client A syncs ONCE + // This should: + // - Upload A's "A-Title" op + // - Server rejects it (conflict with B's "B-Title") + // - A receives B's op as piggybacked + // - LWW: A's timestamp > B's, so A wins + // - A creates local-win UPDATE op with "A-Title" + // - FIX: A immediately re-uploads this op + await clientA.sync.syncAndWait(); - // 8. Client B syncs ONCE to receive A's update - // CRITICAL: If the fix works, B gets "A-Title" here - // If the fix is broken, B still has "B-Title" - await clientB.sync.syncAndWait(); + // 8. Client B syncs ONCE to receive A's update + // CRITICAL: If the fix works, B gets "A-Title" here + // If the fix is broken, B still has "B-Title" + await clientB.sync.syncAndWait(); - // 9. Verify BOTH clients have A's title (the winner) - const taskWithATitleOnA = clientA.page.locator( - `task:not(.ng-animating):has-text("${titleA}")`, - ); - const taskWithATitleOnB = clientB.page.locator( - `task:not(.ng-animating):has-text("${titleA}")`, - ); + // 9. Verify BOTH clients have A's title (the winner) + const taskWithATitleOnA = clientA.page.locator( + `task:not(.ng-animating):has-text("${titleA}")`, + ); + const taskWithATitleOnB = clientB.page.locator( + `task:not(.ng-animating):has-text("${titleA}")`, + ); - // A should have A-Title (it's the local state) - await expect(taskWithATitleOnA.first()).toBeVisible({ timeout: 5000 }); + // A should have A-Title (it's the local state) + await expect(taskWithATitleOnA.first()).toBeVisible({ timeout: 5000 }); - // B should have A-Title (received from server via local-win update op) - // This is the KEY assertion that fails without the fix - await expect(taskWithATitleOnB.first()).toBeVisible({ timeout: 5000 }); + // B should have A-Title (received from server via local-win update op) + // This is the KEY assertion that fails without the fix + await expect(taskWithATitleOnB.first()).toBeVisible({ timeout: 5000 }); - console.log( - '[LWW-Single-Cycle] ✓ Local-win op uploaded in same sync cycle, B has A-Title', - ); - } finally { - if (clientA) await closeClient(clientA); - if (clientB) await closeClient(clientB); - } - }, - ); + console.log( + '[LWW-Single-Cycle] ✓ Local-win op uploaded in same sync cycle, B has A-Title', + ); + } finally { + if (clientA) await closeClient(clientA); + if (clientB) await closeClient(clientB); + } + }); /** * Scenario: LWW Update Action Updates UI Without Reload @@ -574,91 +548,90 @@ base.describe('@supersync SuperSync LWW Conflict Resolution', () => { * 5. Client B syncs (receives LWW Update via piggybacked op) * 6. Verify B's UI shows A's changes WITHOUT reload */ - base( - 'LWW: UI updates immediately after receiving LWW Update operation', - async ({ browser, baseURL }, testInfo) => { - testInfo.setTimeout(120000); - const testRunId = generateTestRunId(testInfo.workerIndex); - const appUrl = baseURL || 'http://localhost:4242'; - let clientA: SimulatedE2EClient | null = null; - let clientB: SimulatedE2EClient | null = null; + test('LWW: UI updates immediately after receiving LWW Update operation', 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); + try { + const user = await createTestUser(testRunId); + const syncConfig = getSuperSyncConfig(user); - // Setup clients - clientA = await createSimulatedClient(browser, appUrl, 'A', testRunId); - await clientA.sync.setupSuperSync(syncConfig); + // 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); + clientB = await createSimulatedClient(browser, appUrl, 'B', testRunId); + await clientB.sync.setupSuperSync(syncConfig); - // 1. Client A creates task with original title - const originalTitle = `UI-Update-${testRunId}`; - const updatedTitle = `Updated-UI-${testRunId}`; + // 1. Client A creates task with original title + const originalTitle = `UI-Update-${testRunId}`; + const updatedTitle = `Updated-UI-${testRunId}`; - await clientA.workView.addTask(originalTitle); - await clientA.sync.syncAndWait(); + await clientA.workView.addTask(originalTitle); + await clientA.sync.syncAndWait(); - // 2. Client B downloads the task - await clientB.sync.syncAndWait(); - await waitForTask(clientB.page, originalTitle); + // 2. Client B downloads the task + await clientB.sync.syncAndWait(); + await waitForTask(clientB.page, originalTitle); - // Verify B has the original title - const taskOnB = clientB.page - .locator(`task:not(.ng-animating):has-text("${originalTitle}")`) - .first(); - await expect(taskOnB).toBeVisible({ timeout: 5000 }); + // Verify B has the original title + const taskOnB = clientB.page + .locator(`task:not(.ng-animating):has-text("${originalTitle}")`) + .first(); + await expect(taskOnB).toBeVisible({ timeout: 5000 }); - // 3. Client A edits the title - const taskOnA = clientA.page - .locator(`task:not(.ng-animating):has-text("${originalTitle}")`) - .first(); - await taskOnA.dblclick(); // Double-click to edit - const editInput = clientA.page.locator( - 'input.mat-mdc-input-element:focus, textarea:focus', - ); - await editInput.waitFor({ state: 'visible', timeout: 5000 }); - await editInput.fill(updatedTitle); - await clientA.page.keyboard.press('Enter'); - await clientA.page.waitForTimeout(500); + // 3. Client A edits the title + const taskOnA = clientA.page + .locator(`task:not(.ng-animating):has-text("${originalTitle}")`) + .first(); + await taskOnA.dblclick(); // Double-click to edit + const editInput = clientA.page.locator( + 'input.mat-mdc-input-element:focus, textarea:focus', + ); + await editInput.waitFor({ state: 'visible', timeout: 5000 }); + await editInput.fill(updatedTitle); + await clientA.page.keyboard.press('Enter'); + await clientA.page.waitForTimeout(500); - // Verify A has the updated title - await waitForTask(clientA.page, updatedTitle); + // Verify A has the updated title + await waitForTask(clientA.page, updatedTitle); - // 4. Client A syncs (uploads the change) - await clientA.sync.syncAndWait(); + // 4. Client A syncs (uploads the change) + await clientA.sync.syncAndWait(); - // 5. Client B syncs (receives A's change) - // This is where the lwwUpdateMetaReducer fix kicks in: - // B receives [TASK] LWW Update action, and the meta-reducer updates the store - await clientB.sync.syncAndWait(); + // 5. Client B syncs (receives A's change) + // This is where the lwwUpdateMetaReducer fix kicks in: + // B receives [TASK] LWW Update action, and the meta-reducer updates the store + await clientB.sync.syncAndWait(); - // 6. Verify B's UI shows A's updated title WITHOUT reload - // This is the KEY assertion - if lwwUpdateMetaReducer isn't working, - // B would still show the old title until a page reload - const updatedTaskOnB = clientB.page.locator( - `task:not(.ng-animating):has-text("${updatedTitle}")`, - ); + // 6. Verify B's UI shows A's updated title WITHOUT reload + // This is the KEY assertion - if lwwUpdateMetaReducer isn't working, + // B would still show the old title until a page reload + const updatedTaskOnB = clientB.page.locator( + `task:not(.ng-animating):has-text("${updatedTitle}")`, + ); - await expect(updatedTaskOnB.first()).toBeVisible({ timeout: 5000 }); + await expect(updatedTaskOnB.first()).toBeVisible({ timeout: 5000 }); - // Also verify the old title is no longer visible - const oldTaskOnB = clientB.page.locator( - `task:not(.ng-animating):has-text("${originalTitle}")`, - ); - await expect(oldTaskOnB).toHaveCount(0); + // Also verify the old title is no longer visible + const oldTaskOnB = clientB.page.locator( + `task:not(.ng-animating):has-text("${originalTitle}")`, + ); + await expect(oldTaskOnB).toHaveCount(0); - console.log( - '[LWW-UI-Update] ✓ UI updated immediately after receiving LWW Update operation', - ); - } finally { - if (clientA) await closeClient(clientA); - if (clientB) await closeClient(clientB); - } - }, - ); + console.log( + '[LWW-UI-Update] ✓ UI updated immediately after receiving LWW Update operation', + ); + } finally { + if (clientA) await closeClient(clientA); + if (clientB) await closeClient(clientB); + } + }); /** * Scenario: Multiple Operations on Same Entity Use Max Timestamp for LWW @@ -677,144 +650,143 @@ base.describe('@supersync SuperSync LWW Conflict Resolution', () => { * 7. Final sync round * 8. Verify both clients converge to same state */ - base( - 'LWW: Multiple operations on same entity use max timestamp', - async ({ browser, baseURL }, testInfo) => { - testInfo.setTimeout(120000); - const testRunId = generateTestRunId(testInfo.workerIndex); - const appUrl = baseURL || 'http://localhost:4242'; - let clientA: SimulatedE2EClient | null = null; - let clientB: SimulatedE2EClient | null = null; + test('LWW: Multiple operations on same entity use max timestamp', 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); + try { + const user = await createTestUser(testRunId); + const syncConfig = getSuperSyncConfig(user); - // Setup clients - clientA = await createSimulatedClient(browser, appUrl, 'A', testRunId); - await clientA.sync.setupSuperSync(syncConfig); + // 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); + clientB = await createSimulatedClient(browser, appUrl, 'B', testRunId); + await clientB.sync.setupSuperSync(syncConfig); - // 1. Client A creates task with original title - const originalTitle = `MultiOp-${testRunId}`; - await clientA.workView.addTask(originalTitle); - await clientA.sync.syncAndWait(); + // 1. Client A creates task with original title + const originalTitle = `MultiOp-${testRunId}`; + await clientA.workView.addTask(originalTitle); + await clientA.sync.syncAndWait(); - // 2. Client B downloads the task - await clientB.sync.syncAndWait(); - await waitForTask(clientB.page, originalTitle); + // 2. Client B downloads the task + await clientB.sync.syncAndWait(); + await waitForTask(clientB.page, originalTitle); - // 3. Client A makes 3 rapid changes - const taskLocatorA = clientA.page - .locator(`task:not(.ng-animating):has-text("${originalTitle}")`) - .first(); + // 3. Client A makes 3 rapid changes + const taskLocatorA = clientA.page + .locator(`task:not(.ng-animating):has-text("${originalTitle}")`) + .first(); - // Change 1: Rename task - const titleA = `A-MultiOp-${testRunId}`; - 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(titleA); - await clientA.page.keyboard.press('Enter'); - await clientA.page.waitForTimeout(300); + // Change 1: Rename task + const titleA = `A-MultiOp-${testRunId}`; + 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(titleA); + await clientA.page.keyboard.press('Enter'); + await clientA.page.waitForTimeout(300); - // Change 2: Mark task done - const taskLocatorAUpdated = clientA.page - .locator(`task:not(.ng-animating):has-text("${titleA}")`) - .first(); - await taskLocatorAUpdated.hover(); - await taskLocatorAUpdated.locator('.task-done-btn').click(); - await clientA.page.waitForTimeout(300); + // Change 2: Mark task done + const taskLocatorAUpdated = clientA.page + .locator(`task:not(.ng-animating):has-text("${titleA}")`) + .first(); + await taskLocatorAUpdated.hover(); + await taskLocatorAUpdated.locator('.task-done-btn').click(); + await clientA.page.waitForTimeout(300); - // Change 3: Add time estimate (another field update) - // This creates a third operation on the same entity - await taskLocatorAUpdated.hover(); - const additionalBtn = taskLocatorAUpdated - .locator('.task-additional-info-btn, button[mat-icon-button]') - .first(); - if (await additionalBtn.isVisible()) { - await additionalBtn.click(); - await clientA.page.waitForTimeout(200); - } - - console.log('[MultiOp] Client A made 3 changes'); - - // 4. Client B makes 3 different changes (offline - hasn't synced yet) - const taskLocatorB = clientB.page - .locator(`task:not(.ng-animating):has-text("${originalTitle}")`) - .first(); - - // Wait for timestamp gap to ensure B's changes are LATER - await clientB.page.waitForTimeout(1500); - - // Change 1: Different rename - const titleB = `B-MultiOp-${testRunId}`; - 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(titleB); - await clientB.page.keyboard.press('Enter'); - await clientB.page.waitForTimeout(300); - - // Change 2: Mark done as well - const taskLocatorBUpdated = clientB.page - .locator(`task:not(.ng-animating):has-text("${titleB}")`) - .first(); - await taskLocatorBUpdated.hover(); - await taskLocatorBUpdated.locator('.task-done-btn').click(); - await clientB.page.waitForTimeout(300); - - console.log('[MultiOp] Client B made 3 changes (B has later timestamps)'); - - // 5. Client B syncs FIRST (uploads B's ops to server) - await clientB.sync.syncAndWait(); - console.log('[MultiOp] Client B synced first'); - - // 6. Client A syncs (downloads B's ops, LWW resolution) - // Since B's changes are later, B should win - await clientA.sync.syncAndWait(); - console.log('[MultiOp] Client A synced, LWW resolution applied'); - - // 7. Final sync round for convergence - await clientB.sync.syncAndWait(); - await clientA.sync.syncAndWait(); - - // 8. Verify BOTH clients have the SAME state (B's title since B was later) - const taskWithBTitleOnA = clientA.page.locator( - `task:not(.ng-animating):has-text("${titleB}")`, - ); - const taskWithBTitleOnB = clientB.page.locator( - `task:not(.ng-animating):has-text("${titleB}")`, - ); - - // Both should have B's title (B's max timestamp was later) - await expect(taskWithBTitleOnA.first()).toBeVisible({ timeout: 10000 }); - await expect(taskWithBTitleOnB.first()).toBeVisible({ timeout: 10000 }); - - // Both should show task as done - await expect(taskWithBTitleOnA.first()).toHaveClass(/isDone/); - await expect(taskWithBTitleOnB.first()).toHaveClass(/isDone/); - - // Verify task counts match - const countA = await clientA.page.locator('task').count(); - const countB = await clientB.page.locator('task').count(); - expect(countA).toBe(countB); - - console.log( - '[MultiOp] ✓ Multiple operations resolved correctly - B won with later max timestamp', - ); - } finally { - if (clientA) await closeClient(clientA); - if (clientB) await closeClient(clientB); + // Change 3: Add time estimate (another field update) + // This creates a third operation on the same entity + await taskLocatorAUpdated.hover(); + const additionalBtn = taskLocatorAUpdated + .locator('.task-additional-info-btn, button[mat-icon-button]') + .first(); + if (await additionalBtn.isVisible()) { + await additionalBtn.click(); + await clientA.page.waitForTimeout(200); } - }, - ); + + console.log('[MultiOp] Client A made 3 changes'); + + // 4. Client B makes 3 different changes (offline - hasn't synced yet) + const taskLocatorB = clientB.page + .locator(`task:not(.ng-animating):has-text("${originalTitle}")`) + .first(); + + // Wait for timestamp gap to ensure B's changes are LATER + await clientB.page.waitForTimeout(1500); + + // Change 1: Different rename + const titleB = `B-MultiOp-${testRunId}`; + 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(titleB); + await clientB.page.keyboard.press('Enter'); + await clientB.page.waitForTimeout(300); + + // Change 2: Mark done as well + const taskLocatorBUpdated = clientB.page + .locator(`task:not(.ng-animating):has-text("${titleB}")`) + .first(); + await taskLocatorBUpdated.hover(); + await taskLocatorBUpdated.locator('.task-done-btn').click(); + await clientB.page.waitForTimeout(300); + + console.log('[MultiOp] Client B made 3 changes (B has later timestamps)'); + + // 5. Client B syncs FIRST (uploads B's ops to server) + await clientB.sync.syncAndWait(); + console.log('[MultiOp] Client B synced first'); + + // 6. Client A syncs (downloads B's ops, LWW resolution) + // Since B's changes are later, B should win + await clientA.sync.syncAndWait(); + console.log('[MultiOp] Client A synced, LWW resolution applied'); + + // 7. Final sync round for convergence + await clientB.sync.syncAndWait(); + await clientA.sync.syncAndWait(); + + // 8. Verify BOTH clients have the SAME state (B's title since B was later) + const taskWithBTitleOnA = clientA.page.locator( + `task:not(.ng-animating):has-text("${titleB}")`, + ); + const taskWithBTitleOnB = clientB.page.locator( + `task:not(.ng-animating):has-text("${titleB}")`, + ); + + // Both should have B's title (B's max timestamp was later) + await expect(taskWithBTitleOnA.first()).toBeVisible({ timeout: 10000 }); + await expect(taskWithBTitleOnB.first()).toBeVisible({ timeout: 10000 }); + + // Both should show task as done + await expect(taskWithBTitleOnA.first()).toHaveClass(/isDone/); + await expect(taskWithBTitleOnB.first()).toHaveClass(/isDone/); + + // Verify task counts match + const countA = await clientA.page.locator('task').count(); + const countB = await clientB.page.locator('task').count(); + expect(countA).toBe(countB); + + console.log( + '[MultiOp] ✓ Multiple operations resolved correctly - B won with later max timestamp', + ); + } finally { + if (clientA) await closeClient(clientA); + if (clientB) await closeClient(clientB); + } + }); /** * Scenario: Concurrent Task Move to Different Projects Resolves via LWW @@ -835,239 +807,234 @@ base.describe('@supersync SuperSync LWW Conflict Resolution', () => { * 9. Final sync round * 10. Verify task is in exactly ONE project on both clients */ - base( - 'LWW: Concurrent task move to different projects resolves correctly', - async ({ browser, baseURL }, testInfo) => { - testInfo.setTimeout(150000); - const testRunId = generateTestRunId(testInfo.workerIndex); - const appUrl = baseURL || 'http://localhost:4242'; - let clientA: SimulatedE2EClient | null = null; - let clientB: SimulatedE2EClient | null = null; + test('LWW: Concurrent task move to different projects resolves correctly', async ({ + browser, + baseURL, + testRunId, + }) => { + const appUrl = baseURL || 'http://localhost:4242'; + let clientA: SimulatedE2EClient | null = null; + let clientB: SimulatedE2EClient | null = null; - // Helper to create a project - const createProject = async (page: any, projectName: string): Promise => { - await page.goto('/#/tag/TODAY/work'); - await page.waitForLoadState('networkidle'); - await page.waitForTimeout(1000); + // Helper to create a project + const createProject = async (page: any, projectName: string): Promise => { + await page.goto('/#/tag/TODAY/work'); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(1000); - const navSidenav = page.locator('.nav-sidenav'); - if (await navSidenav.isVisible()) { - const isCompact = await navSidenav.evaluate((el: Element) => - el.classList.contains('compactMode'), - ); - if (isCompact) { - const toggleBtn = navSidenav.locator('.mode-toggle'); - if (await toggleBtn.isVisible()) { - await toggleBtn.click(); - await page.waitForTimeout(500); - } + const navSidenav = page.locator('.nav-sidenav'); + if (await navSidenav.isVisible()) { + const isCompact = await navSidenav.evaluate((el: Element) => + el.classList.contains('compactMode'), + ); + if (isCompact) { + const toggleBtn = navSidenav.locator('.mode-toggle'); + if (await toggleBtn.isVisible()) { + await toggleBtn.click(); + await page.waitForTimeout(500); } } - - const projectsTree = page - .locator('nav-list-tree') - .filter({ hasText: 'Projects' }) - .first(); - await projectsTree.waitFor({ state: 'visible' }); - - const addBtn = projectsTree - .locator('.additional-btn mat-icon:has-text("add")') - .first(); - const groupNavItem = projectsTree.locator('nav-item').first(); - await groupNavItem.hover(); - await page.waitForTimeout(200); - - if (await addBtn.isVisible()) { - await addBtn.click(); - } else { - throw new Error('Could not find Create Project button'); - } - - const nameInput = page.getByRole('textbox', { name: 'Project Name' }); - await nameInput.waitFor({ state: 'visible', timeout: 10000 }); - await nameInput.fill(projectName); - - const submitBtn = page - .locator('dialog-create-project button[type=submit]') - .first(); - await submitBtn.click(); - await nameInput.waitFor({ state: 'hidden', timeout: 5000 }); - await page.waitForTimeout(1000); - }; - - // Helper to move task to project via context menu - const moveTaskToProject = async ( - page: any, - taskName: string, - projectName: string, - ): Promise => { - const taskLocator = page.locator(`task:has-text("${taskName}")`).first(); - await taskLocator.waitFor({ state: 'visible' }); - - // Right-click to open context menu - await taskLocator.click({ button: 'right' }); - await page.waitForTimeout(300); - - // Find and click "Move to project" / "Add to project" option - // The button has mat-icon "forward" and text containing "project" - const menuPanel = page.locator('.mat-mdc-menu-panel').first(); - await menuPanel.waitFor({ state: 'visible', timeout: 5000 }); - - const moveToProjectBtn = menuPanel - .locator('button[mat-menu-item]') - .filter({ has: page.locator('mat-icon:has-text("forward")') }) - .first(); - await moveToProjectBtn.waitFor({ state: 'visible', timeout: 5000 }); - await moveToProjectBtn.click(); - - // Wait for project submenu - await page.waitForTimeout(300); - const projectMenu = page.locator('.mat-mdc-menu-panel').last(); - await projectMenu.waitFor({ state: 'visible', timeout: 5000 }); - - // Select the target project - const projectOption = projectMenu - .locator('button[mat-menu-item]') - .filter({ hasText: projectName }) - .first(); - await projectOption.waitFor({ state: 'visible', timeout: 3000 }); - await projectOption.click(); - - await page.waitForTimeout(500); - - // Dismiss any remaining overlays - for (let j = 0; j < 3; j++) { - await page.keyboard.press('Escape'); - await page.waitForTimeout(100); - } - }; - - 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 Project1, Project2, and a Task - const project1Name = `Proj1-${testRunId}`; - const project2Name = `Proj2-${testRunId}`; - const project3Name = `Proj3-${testRunId}`; - const taskName = `MoveTask-${testRunId}`; - - await createProject(clientA.page, project1Name); - await createProject(clientA.page, project2Name); - console.log('[MoveConflict] Created Project1 and Project2 on Client A'); - - // Navigate to Project1 and create task - const projectBtnA = clientA.page.getByText(project1Name).first(); - await projectBtnA.waitFor({ state: 'visible' }); - await projectBtnA.click({ force: true }); - await clientA.page.waitForLoadState('networkidle'); - - await clientA.workView.addTask(taskName); - await waitForTask(clientA.page, taskName); - console.log('[MoveConflict] Created task in Project1'); - - // 2. Client A syncs - await clientA.sync.syncAndWait(); - console.log('[MoveConflict] Client A synced'); - - // 3. Client B syncs to get everything - await clientB.sync.syncAndWait(); - console.log('[MoveConflict] Client B synced'); - - // 4. Client B creates Project3 - await createProject(clientB.page, project3Name); - console.log('[MoveConflict] Client B created Project3'); - - // Navigate Client B to Project1 to see the task - const project1BtnB = clientB.page.getByText(project1Name).first(); - await project1BtnB.waitFor({ state: 'visible' }); - await project1BtnB.click({ force: true }); - await clientB.page.waitForLoadState('networkidle'); - await waitForTask(clientB.page, taskName); - - // 5. Client A moves task to Project2 - // First go to Project1 on A - await clientA.page.goto('/#/tag/TODAY/work'); - await clientA.page.waitForLoadState('networkidle'); - const project1BtnA = clientA.page.getByText(project1Name).first(); - await project1BtnA.click({ force: true }); - await clientA.page.waitForLoadState('networkidle'); - await waitForTask(clientA.page, taskName); - - await moveTaskToProject(clientA.page, taskName, project2Name); - console.log('[MoveConflict] Client A moved task to Project2'); - - // Wait for timestamp gap - await clientA.page.waitForTimeout(1000); - - // 6. Client B moves task to Project3 (concurrent - hasn't synced) - await moveTaskToProject(clientB.page, taskName, project3Name); - console.log('[MoveConflict] Client B moved task to Project3 (later timestamp)'); - - // 7. Client A syncs first - await clientA.sync.syncAndWait(); - console.log('[MoveConflict] Client A synced'); - - // 8. Client B syncs (LWW resolution - B's move is later, should win) - await clientB.sync.syncAndWait(); - console.log('[MoveConflict] Client B synced, LWW resolution applied'); - - // 9. Final sync round for convergence - await clientA.sync.syncAndWait(); - await clientB.sync.syncAndWait(); - - // 10. Verify task is in exactly ONE project on both clients - // Since B moved later, task should be in Project3 - - // Check Client A - task should be in Project3 (B won) - await clientA.page.goto('/#/tag/TODAY/work'); - await clientA.page.waitForLoadState('networkidle'); - const project3BtnA = clientA.page.getByText(project3Name).first(); - await project3BtnA.waitFor({ state: 'visible' }); - await project3BtnA.click({ force: true }); - await clientA.page.waitForLoadState('networkidle'); - await waitForTask(clientA.page, taskName); - console.log('[MoveConflict] Client A sees task in Project3'); - - // Check Client B - task should also be in Project3 - await clientB.page.goto('/#/tag/TODAY/work'); - await clientB.page.waitForLoadState('networkidle'); - const project3BtnB = clientB.page.getByText(project3Name).first(); - await project3BtnB.click({ force: true }); - await clientB.page.waitForLoadState('networkidle'); - await waitForTask(clientB.page, taskName); - console.log('[MoveConflict] Client B sees task in Project3'); - - // Verify task is NOT in Project2 (A's move lost) - await clientA.page.goto('/#/tag/TODAY/work'); - await clientA.page.waitForLoadState('networkidle'); - const project2BtnA = clientA.page.getByText(project2Name).first(); - await project2BtnA.click({ force: true }); - await clientA.page.waitForLoadState('networkidle'); - await clientA.page.waitForTimeout(1000); - - const taskInProject2 = clientA.page.locator(`task:has-text("${taskName}")`); - await expect(taskInProject2).not.toBeVisible({ timeout: 3000 }); - console.log( - "[MoveConflict] ✓ Task NOT in Project2 (A's move correctly lost via LWW)", - ); - - console.log( - '[MoveConflict] ✓ Concurrent project move resolved correctly via LWW', - ); - } finally { - if (clientA) await closeClient(clientA); - if (clientB) await closeClient(clientB); } - }, - ); + + const projectsTree = page + .locator('nav-list-tree') + .filter({ hasText: 'Projects' }) + .first(); + await projectsTree.waitFor({ state: 'visible' }); + + const addBtn = projectsTree + .locator('.additional-btn mat-icon:has-text("add")') + .first(); + const groupNavItem = projectsTree.locator('nav-item').first(); + await groupNavItem.hover(); + await page.waitForTimeout(200); + + if (await addBtn.isVisible()) { + await addBtn.click(); + } else { + throw new Error('Could not find Create Project button'); + } + + const nameInput = page.getByRole('textbox', { name: 'Project Name' }); + await nameInput.waitFor({ state: 'visible', timeout: 10000 }); + await nameInput.fill(projectName); + + const submitBtn = page.locator('dialog-create-project button[type=submit]').first(); + await submitBtn.click(); + await nameInput.waitFor({ state: 'hidden', timeout: 5000 }); + await page.waitForTimeout(1000); + }; + + // Helper to move task to project via context menu + const moveTaskToProject = async ( + page: any, + taskName: string, + projectName: string, + ): Promise => { + const taskLocator = page.locator(`task:has-text("${taskName}")`).first(); + await taskLocator.waitFor({ state: 'visible' }); + + // Right-click to open context menu + await taskLocator.click({ button: 'right' }); + await page.waitForTimeout(300); + + // Find and click "Move to project" / "Add to project" option + // The button has mat-icon "forward" and text containing "project" + const menuPanel = page.locator('.mat-mdc-menu-panel').first(); + await menuPanel.waitFor({ state: 'visible', timeout: 5000 }); + + const moveToProjectBtn = menuPanel + .locator('button[mat-menu-item]') + .filter({ has: page.locator('mat-icon:has-text("forward")') }) + .first(); + await moveToProjectBtn.waitFor({ state: 'visible', timeout: 5000 }); + await moveToProjectBtn.click(); + + // Wait for project submenu + await page.waitForTimeout(300); + const projectMenu = page.locator('.mat-mdc-menu-panel').last(); + await projectMenu.waitFor({ state: 'visible', timeout: 5000 }); + + // Select the target project + const projectOption = projectMenu + .locator('button[mat-menu-item]') + .filter({ hasText: projectName }) + .first(); + await projectOption.waitFor({ state: 'visible', timeout: 3000 }); + await projectOption.click(); + + await page.waitForTimeout(500); + + // Dismiss any remaining overlays + for (let j = 0; j < 3; j++) { + await page.keyboard.press('Escape'); + await page.waitForTimeout(100); + } + }; + + 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 Project1, Project2, and a Task + const project1Name = `Proj1-${testRunId}`; + const project2Name = `Proj2-${testRunId}`; + const project3Name = `Proj3-${testRunId}`; + const taskName = `MoveTask-${testRunId}`; + + await createProject(clientA.page, project1Name); + await createProject(clientA.page, project2Name); + console.log('[MoveConflict] Created Project1 and Project2 on Client A'); + + // Navigate to Project1 and create task + const projectBtnA = clientA.page.getByText(project1Name).first(); + await projectBtnA.waitFor({ state: 'visible' }); + await projectBtnA.click({ force: true }); + await clientA.page.waitForLoadState('networkidle'); + + await clientA.workView.addTask(taskName); + await waitForTask(clientA.page, taskName); + console.log('[MoveConflict] Created task in Project1'); + + // 2. Client A syncs + await clientA.sync.syncAndWait(); + console.log('[MoveConflict] Client A synced'); + + // 3. Client B syncs to get everything + await clientB.sync.syncAndWait(); + console.log('[MoveConflict] Client B synced'); + + // 4. Client B creates Project3 + await createProject(clientB.page, project3Name); + console.log('[MoveConflict] Client B created Project3'); + + // Navigate Client B to Project1 to see the task + const project1BtnB = clientB.page.getByText(project1Name).first(); + await project1BtnB.waitFor({ state: 'visible' }); + await project1BtnB.click({ force: true }); + await clientB.page.waitForLoadState('networkidle'); + await waitForTask(clientB.page, taskName); + + // 5. Client A moves task to Project2 + // First go to Project1 on A + await clientA.page.goto('/#/tag/TODAY/work'); + await clientA.page.waitForLoadState('networkidle'); + const project1BtnA = clientA.page.getByText(project1Name).first(); + await project1BtnA.click({ force: true }); + await clientA.page.waitForLoadState('networkidle'); + await waitForTask(clientA.page, taskName); + + await moveTaskToProject(clientA.page, taskName, project2Name); + console.log('[MoveConflict] Client A moved task to Project2'); + + // Wait for timestamp gap + await clientA.page.waitForTimeout(1000); + + // 6. Client B moves task to Project3 (concurrent - hasn't synced) + await moveTaskToProject(clientB.page, taskName, project3Name); + console.log('[MoveConflict] Client B moved task to Project3 (later timestamp)'); + + // 7. Client A syncs first + await clientA.sync.syncAndWait(); + console.log('[MoveConflict] Client A synced'); + + // 8. Client B syncs (LWW resolution - B's move is later, should win) + await clientB.sync.syncAndWait(); + console.log('[MoveConflict] Client B synced, LWW resolution applied'); + + // 9. Final sync round for convergence + await clientA.sync.syncAndWait(); + await clientB.sync.syncAndWait(); + + // 10. Verify task is in exactly ONE project on both clients + // Since B moved later, task should be in Project3 + + // Check Client A - task should be in Project3 (B won) + await clientA.page.goto('/#/tag/TODAY/work'); + await clientA.page.waitForLoadState('networkidle'); + const project3BtnA = clientA.page.getByText(project3Name).first(); + await project3BtnA.waitFor({ state: 'visible' }); + await project3BtnA.click({ force: true }); + await clientA.page.waitForLoadState('networkidle'); + await waitForTask(clientA.page, taskName); + console.log('[MoveConflict] Client A sees task in Project3'); + + // Check Client B - task should also be in Project3 + await clientB.page.goto('/#/tag/TODAY/work'); + await clientB.page.waitForLoadState('networkidle'); + const project3BtnB = clientB.page.getByText(project3Name).first(); + await project3BtnB.click({ force: true }); + await clientB.page.waitForLoadState('networkidle'); + await waitForTask(clientB.page, taskName); + console.log('[MoveConflict] Client B sees task in Project3'); + + // Verify task is NOT in Project2 (A's move lost) + await clientA.page.goto('/#/tag/TODAY/work'); + await clientA.page.waitForLoadState('networkidle'); + const project2BtnA = clientA.page.getByText(project2Name).first(); + await project2BtnA.click({ force: true }); + await clientA.page.waitForLoadState('networkidle'); + await clientA.page.waitForTimeout(1000); + + const taskInProject2 = clientA.page.locator(`task:has-text("${taskName}")`); + await expect(taskInProject2).not.toBeVisible({ timeout: 3000 }); + console.log( + "[MoveConflict] ✓ Task NOT in Project2 (A's move correctly lost via LWW)", + ); + + console.log('[MoveConflict] ✓ Concurrent project move resolved correctly via LWW'); + } finally { + if (clientA) await closeClient(clientA); + if (clientB) await closeClient(clientB); + } + }); /** * Scenario: Concurrent Tag Changes on Same Task Resolves via LWW @@ -1088,222 +1055,221 @@ base.describe('@supersync SuperSync LWW Conflict Resolution', () => { * 9. Final sync round * 10. Verify task has correct tags on both clients */ - base( - 'LWW: Concurrent tag changes on same task resolves correctly', - async ({ browser, baseURL }, testInfo) => { - testInfo.setTimeout(150000); - const testRunId = generateTestRunId(testInfo.workerIndex); - const appUrl = baseURL || 'http://localhost:4242'; - let clientA: SimulatedE2EClient | null = null; - let clientB: SimulatedE2EClient | null = null; + test('LWW: Concurrent tag changes on same task resolves correctly', async ({ + browser, + baseURL, + testRunId, + }) => { + const appUrl = baseURL || 'http://localhost:4242'; + let clientA: SimulatedE2EClient | null = null; + let clientB: SimulatedE2EClient | null = null; - // Helper to toggle a tag on task via context menu "Toggle Tags" submenu - // Used for both adding and removing tags (it's a toggle) - const toggleTagOnTask = async ( - page: any, - taskName: string, - tagName: string, - ): Promise => { - const taskLocator = page.locator(`task:has-text("${taskName}")`).first(); - await taskLocator.waitFor({ state: 'visible' }); + // Helper to toggle a tag on task via context menu "Toggle Tags" submenu + // Used for both adding and removing tags (it's a toggle) + const toggleTagOnTask = async ( + page: any, + taskName: string, + tagName: string, + ): Promise => { + const taskLocator = page.locator(`task:has-text("${taskName}")`).first(); + await taskLocator.waitFor({ state: 'visible' }); - // Right-click to open context menu - await taskLocator.click({ button: 'right' }); - await page.waitForTimeout(300); + // Right-click to open context menu + await taskLocator.click({ button: 'right' }); + await page.waitForTimeout(300); - // Find and click "Toggle Tags" submenu trigger (has label icon and class e2e-edit-tags-btn) - const menuPanel = page.locator('.mat-mdc-menu-panel').first(); - await menuPanel.waitFor({ state: 'visible', timeout: 5000 }); + // Find and click "Toggle Tags" submenu trigger (has label icon and class e2e-edit-tags-btn) + const menuPanel = page.locator('.mat-mdc-menu-panel').first(); + await menuPanel.waitFor({ state: 'visible', timeout: 5000 }); - const toggleTagsBtn = menuPanel.locator('.e2e-edit-tags-btn').first(); - await toggleTagsBtn.waitFor({ state: 'visible', timeout: 5000 }); - await toggleTagsBtn.click(); + const toggleTagsBtn = menuPanel.locator('.e2e-edit-tags-btn').first(); + await toggleTagsBtn.waitFor({ state: 'visible', timeout: 5000 }); + await toggleTagsBtn.click(); - // Wait for tag submenu - await page.waitForTimeout(300); - const tagMenu = page.locator('.mat-mdc-menu-panel').last(); - await tagMenu.waitFor({ state: 'visible', timeout: 5000 }); + // Wait for tag submenu + await page.waitForTimeout(300); + const tagMenu = page.locator('.mat-mdc-menu-panel').last(); + await tagMenu.waitFor({ state: 'visible', timeout: 5000 }); - // Select the target tag to toggle - const tagOption = tagMenu - .locator('button[mat-menu-item]') - .filter({ hasText: tagName }) - .first(); - await tagOption.waitFor({ state: 'visible', timeout: 3000 }); - await tagOption.click(); + // Select the target tag to toggle + const tagOption = tagMenu + .locator('button[mat-menu-item]') + .filter({ hasText: tagName }) + .first(); + await tagOption.waitFor({ state: 'visible', timeout: 3000 }); + await tagOption.click(); - await page.waitForTimeout(500); + await page.waitForTimeout(500); - // Dismiss any remaining overlays - for (let j = 0; j < 3; j++) { - await page.keyboard.press('Escape'); - await page.waitForTimeout(100); - } - }; - - // Helper to add task with tag using short syntax and handle creation dialog - const addTaskWithTag = async ( - client: SimulatedE2EClient, - taskTitle: string, - ): Promise => { - // Use skipClose=true because tag creation dialog may block backdrop click - await client.workView.addTask(taskTitle, true); - - // Handle tag creation confirmation dialog if it appears - const confirmBtn = client.page.locator('button[e2e="confirmBtn"]'); - if (await confirmBtn.isVisible({ timeout: 3000 }).catch(() => false)) { - await confirmBtn.click(); - } - - // Close the add task bar manually if it's still open - if (await client.workView.backdrop.isVisible().catch(() => false)) { - await client.workView.backdrop.click(); - } - - await client.page.waitForTimeout(300); - }; - - 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); - - const tagAName = `TagA-${testRunId}`; - const tagBName = `TagB-${testRunId}`; - const tagCName = `TagC-${testRunId}`; - const taskName = `TagTask-${testRunId}`; - - // 1. Client A creates task with TagA and another with TagB using short syntax - await addTaskWithTag(clientA, `${taskName} #${tagAName}`); - await waitForTask(clientA.page, taskName); - console.log('[TagConflict] Created task with TagA on Client A'); - - await addTaskWithTag(clientA, `TempTask-${testRunId} #${tagBName}`); - await clientA.page.waitForTimeout(500); - console.log('[TagConflict] Created TagB on Client A'); - - // 2. Client A syncs - await clientA.sync.syncAndWait(); - console.log('[TagConflict] Client A synced'); - - // 3. Client B syncs to get everything - await clientB.sync.syncAndWait(); - console.log('[TagConflict] Client B synced'); - - // Verify Client B has the task in Today view (tasks are created with TODAY tag) - await waitForTask(clientB.page, taskName); - console.log('[TagConflict] Client B has the task in Today view'); - - // 4. Client B creates TagC using short syntax - await addTaskWithTag(clientB, `TempTask2-${testRunId} #${tagCName}`); - await clientB.page.waitForTimeout(500); - console.log('[TagConflict] Created TagC on Client B'); - - // 5. Verify task is visible in both clients' Today view before tag operations - await waitForTask(clientA.page, taskName); - await waitForTask(clientB.page, taskName); - console.log('[TagConflict] Task visible in Today view on both clients'); - - // Client A: Toggle TagA off and toggle TagB on (in Today view) - await toggleTagOnTask(clientA.page, taskName, tagAName); - console.log('[TagConflict] Client A toggled TagA off'); - await toggleTagOnTask(clientA.page, taskName, tagBName); - console.log('[TagConflict] Client A toggled TagB on'); - - // 6. Client B: Toggle TagA off and toggle TagC on (concurrent with step 5) - await clientB.page.waitForTimeout(1000); // Ensure later timestamp - await toggleTagOnTask(clientB.page, taskName, tagAName); - console.log('[TagConflict] Client B toggled TagA off'); - await toggleTagOnTask(clientB.page, taskName, tagCName); - console.log('[TagConflict] Client B toggled TagC on'); - - // 7. Client A syncs first - await clientA.sync.syncAndWait(); - console.log('[TagConflict] Client A synced'); - - // 8. Client B syncs (LWW resolution - B should win with later timestamp) - await clientB.sync.syncAndWait(); - console.log('[TagConflict] Client B synced, LWW resolution applied'); - - // 9. Final sync to propagate resolution - await clientA.sync.syncAndWait(); - await clientB.sync.syncAndWait(); - console.log('[TagConflict] Final sync complete'); - - // 10. Verify - navigate to TagC on both clients to check if task is there - // Client B's changes should win (later timestamp), so task should be in TagC - const tagCBtnA = clientA.page - .locator('.nav-sidenav') - .locator('nav-item') - .filter({ hasText: tagCName }) - .first(); - await tagCBtnA.waitFor({ state: 'visible', timeout: 10000 }); - await tagCBtnA.click(); - await clientA.page.waitForLoadState('networkidle'); - await clientA.page.waitForTimeout(1000); - - const taskInTagCOnA = clientA.page.locator(`task:has-text("${taskName}")`); - await expect(taskInTagCOnA).toBeVisible({ timeout: 10000 }); - console.log('[TagConflict] Client A sees task in TagC'); - - // Check Client B - const tagCBtnB = clientB.page - .locator('.nav-sidenav') - .locator('nav-item') - .filter({ hasText: tagCName }) - .first(); - await tagCBtnB.waitFor({ state: 'visible', timeout: 10000 }); - await tagCBtnB.click(); - await clientB.page.waitForLoadState('networkidle'); - await clientB.page.waitForTimeout(1000); - - const taskInTagCOnB = clientB.page.locator(`task:has-text("${taskName}")`); - await expect(taskInTagCOnB).toBeVisible({ timeout: 10000 }); - console.log('[TagConflict] Client B sees task in TagC'); - - // Verify task is NOT in TagA on both clients (both removed it) - const tagABtnAVerify = clientA.page - .locator('.nav-sidenav') - .locator('nav-item') - .filter({ hasText: tagAName }) - .first(); - await tagABtnAVerify.click(); - await clientA.page.waitForLoadState('networkidle'); - await clientA.page.waitForTimeout(1000); - - const taskInTagAOnA = clientA.page.locator(`task:has-text("${taskName}")`); - await expect(taskInTagAOnA).not.toBeVisible({ timeout: 3000 }); - console.log('[TagConflict] ✓ Task NOT in TagA on Client A'); - - // Verify task is NOT in TagB (B's changes won, TagB was only added by A) - const tagBBtnAVerify = clientA.page - .locator('.nav-sidenav') - .locator('nav-item') - .filter({ hasText: tagBName }) - .first(); - await tagBBtnAVerify.click(); - await clientA.page.waitForLoadState('networkidle'); - await clientA.page.waitForTimeout(1000); - - const taskInTagBOnA = clientA.page.locator(`task:has-text("${taskName}")`); - await expect(taskInTagBOnA).not.toBeVisible({ timeout: 3000 }); - console.log( - "[TagConflict] ✓ Task NOT in TagB (A's changes correctly lost via LWW)", - ); - - console.log('[TagConflict] ✓ Concurrent tag changes resolved correctly via LWW'); - } finally { - if (clientA) await closeClient(clientA); - if (clientB) await closeClient(clientB); + // Dismiss any remaining overlays + for (let j = 0; j < 3; j++) { + await page.keyboard.press('Escape'); + await page.waitForTimeout(100); } - }, - ); + }; + + // Helper to add task with tag using short syntax and handle creation dialog + const addTaskWithTag = async ( + client: SimulatedE2EClient, + taskTitle: string, + ): Promise => { + // Use skipClose=true because tag creation dialog may block backdrop click + await client.workView.addTask(taskTitle, true); + + // Handle tag creation confirmation dialog if it appears + const confirmBtn = client.page.locator('button[e2e="confirmBtn"]'); + if (await confirmBtn.isVisible({ timeout: 3000 }).catch(() => false)) { + await confirmBtn.click(); + } + + // Close the add task bar manually if it's still open + if (await client.workView.backdrop.isVisible().catch(() => false)) { + await client.workView.backdrop.click(); + } + + await client.page.waitForTimeout(300); + }; + + 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); + + const tagAName = `TagA-${testRunId}`; + const tagBName = `TagB-${testRunId}`; + const tagCName = `TagC-${testRunId}`; + const taskName = `TagTask-${testRunId}`; + + // 1. Client A creates task with TagA and another with TagB using short syntax + await addTaskWithTag(clientA, `${taskName} #${tagAName}`); + await waitForTask(clientA.page, taskName); + console.log('[TagConflict] Created task with TagA on Client A'); + + await addTaskWithTag(clientA, `TempTask-${testRunId} #${tagBName}`); + await clientA.page.waitForTimeout(500); + console.log('[TagConflict] Created TagB on Client A'); + + // 2. Client A syncs + await clientA.sync.syncAndWait(); + console.log('[TagConflict] Client A synced'); + + // 3. Client B syncs to get everything + await clientB.sync.syncAndWait(); + console.log('[TagConflict] Client B synced'); + + // Verify Client B has the task in Today view (tasks are created with TODAY tag) + await waitForTask(clientB.page, taskName); + console.log('[TagConflict] Client B has the task in Today view'); + + // 4. Client B creates TagC using short syntax + await addTaskWithTag(clientB, `TempTask2-${testRunId} #${tagCName}`); + await clientB.page.waitForTimeout(500); + console.log('[TagConflict] Created TagC on Client B'); + + // 5. Verify task is visible in both clients' Today view before tag operations + await waitForTask(clientA.page, taskName); + await waitForTask(clientB.page, taskName); + console.log('[TagConflict] Task visible in Today view on both clients'); + + // Client A: Toggle TagA off and toggle TagB on (in Today view) + await toggleTagOnTask(clientA.page, taskName, tagAName); + console.log('[TagConflict] Client A toggled TagA off'); + await toggleTagOnTask(clientA.page, taskName, tagBName); + console.log('[TagConflict] Client A toggled TagB on'); + + // 6. Client B: Toggle TagA off and toggle TagC on (concurrent with step 5) + await clientB.page.waitForTimeout(1000); // Ensure later timestamp + await toggleTagOnTask(clientB.page, taskName, tagAName); + console.log('[TagConflict] Client B toggled TagA off'); + await toggleTagOnTask(clientB.page, taskName, tagCName); + console.log('[TagConflict] Client B toggled TagC on'); + + // 7. Client A syncs first + await clientA.sync.syncAndWait(); + console.log('[TagConflict] Client A synced'); + + // 8. Client B syncs (LWW resolution - B should win with later timestamp) + await clientB.sync.syncAndWait(); + console.log('[TagConflict] Client B synced, LWW resolution applied'); + + // 9. Final sync to propagate resolution + await clientA.sync.syncAndWait(); + await clientB.sync.syncAndWait(); + console.log('[TagConflict] Final sync complete'); + + // 10. Verify - navigate to TagC on both clients to check if task is there + // Client B's changes should win (later timestamp), so task should be in TagC + const tagCBtnA = clientA.page + .locator('.nav-sidenav') + .locator('nav-item') + .filter({ hasText: tagCName }) + .first(); + await tagCBtnA.waitFor({ state: 'visible', timeout: 10000 }); + await tagCBtnA.click(); + await clientA.page.waitForLoadState('networkidle'); + await clientA.page.waitForTimeout(1000); + + const taskInTagCOnA = clientA.page.locator(`task:has-text("${taskName}")`); + await expect(taskInTagCOnA).toBeVisible({ timeout: 10000 }); + console.log('[TagConflict] Client A sees task in TagC'); + + // Check Client B + const tagCBtnB = clientB.page + .locator('.nav-sidenav') + .locator('nav-item') + .filter({ hasText: tagCName }) + .first(); + await tagCBtnB.waitFor({ state: 'visible', timeout: 10000 }); + await tagCBtnB.click(); + await clientB.page.waitForLoadState('networkidle'); + await clientB.page.waitForTimeout(1000); + + const taskInTagCOnB = clientB.page.locator(`task:has-text("${taskName}")`); + await expect(taskInTagCOnB).toBeVisible({ timeout: 10000 }); + console.log('[TagConflict] Client B sees task in TagC'); + + // Verify task is NOT in TagA on both clients (both removed it) + const tagABtnAVerify = clientA.page + .locator('.nav-sidenav') + .locator('nav-item') + .filter({ hasText: tagAName }) + .first(); + await tagABtnAVerify.click(); + await clientA.page.waitForLoadState('networkidle'); + await clientA.page.waitForTimeout(1000); + + const taskInTagAOnA = clientA.page.locator(`task:has-text("${taskName}")`); + await expect(taskInTagAOnA).not.toBeVisible({ timeout: 3000 }); + console.log('[TagConflict] ✓ Task NOT in TagA on Client A'); + + // Verify task is NOT in TagB (B's changes won, TagB was only added by A) + const tagBBtnAVerify = clientA.page + .locator('.nav-sidenav') + .locator('nav-item') + .filter({ hasText: tagBName }) + .first(); + await tagBBtnAVerify.click(); + await clientA.page.waitForLoadState('networkidle'); + await clientA.page.waitForTimeout(1000); + + const taskInTagBOnA = clientA.page.locator(`task:has-text("${taskName}")`); + await expect(taskInTagBOnA).not.toBeVisible({ timeout: 3000 }); + console.log( + "[TagConflict] ✓ Task NOT in TagB (A's changes correctly lost via LWW)", + ); + + console.log('[TagConflict] ✓ Concurrent tag changes resolved correctly via LWW'); + } finally { + if (clientA) await closeClient(clientA); + if (clientB) await closeClient(clientB); + } + }); /** * Scenario: Delete vs Update Race @@ -1320,101 +1286,100 @@ base.describe('@supersync SuperSync LWW Conflict Resolution', () => { * 6. Client B syncs (LWW: B's update wins, task should be recreated) * 7. Verify task exists on both clients with B's updates */ - base( - 'LWW: Delete vs Update race resolves correctly', - async ({ browser, baseURL }, testInfo) => { - testInfo.setTimeout(120000); - const testRunId = generateTestRunId(testInfo.workerIndex); - const appUrl = baseURL || 'http://localhost:4242'; - let clientA: SimulatedE2EClient | null = null; - let clientB: SimulatedE2EClient | null = null; + test('LWW: Delete vs Update race resolves correctly', 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); + try { + const user = await createTestUser(testRunId); + const syncConfig = getSuperSyncConfig(user); - // Setup clients - clientA = await createSimulatedClient(browser, appUrl, 'A', testRunId); - await clientA.sync.setupSuperSync(syncConfig); + // 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); + clientB = await createSimulatedClient(browser, appUrl, 'B', testRunId); + await clientB.sync.setupSuperSync(syncConfig); - // 1. Client A creates task - const taskName = `DeleteRace-${testRunId}`; - await clientA.workView.addTask(taskName); - await waitForTask(clientA.page, taskName); - console.log('[DeleteRace] Created task on Client A'); + // 1. Client A creates task + const taskName = `DeleteRace-${testRunId}`; + await clientA.workView.addTask(taskName); + await waitForTask(clientA.page, taskName); + console.log('[DeleteRace] Created task on Client A'); - // 2. Both sync - await clientA.sync.syncAndWait(); - await clientB.sync.syncAndWait(); - await waitForTask(clientB.page, taskName); - console.log('[DeleteRace] Both clients have the task'); + // 2. Both sync + await clientA.sync.syncAndWait(); + await clientB.sync.syncAndWait(); + await waitForTask(clientB.page, taskName); + console.log('[DeleteRace] Both clients have the task'); - // 3. Client A deletes the task - const taskLocatorA = clientA.page - .locator(`task:not(.ng-animating):has-text("${taskName}")`) - .first(); - await taskLocatorA.click({ button: 'right' }); - await clientA.page.waitForTimeout(300); + // 3. Client A deletes the task + const taskLocatorA = clientA.page + .locator(`task:not(.ng-animating):has-text("${taskName}")`) + .first(); + await taskLocatorA.click({ button: 'right' }); + await clientA.page.waitForTimeout(300); - // Wait for menu to be fully visible - await clientA.page - .locator('.mat-mdc-menu-panel') - .waitFor({ state: 'visible', timeout: 5000 }); + // Wait for menu to be fully visible + await clientA.page + .locator('.mat-mdc-menu-panel') + .waitFor({ state: 'visible', timeout: 5000 }); - const deleteBtn = clientA.page - .locator('.mat-mdc-menu-item') - .filter({ hasText: 'Delete' }); - await deleteBtn.waitFor({ state: 'visible', timeout: 5000 }); - await deleteBtn.click(); - await clientA.page.waitForTimeout(500); - console.log('[DeleteRace] Client A deleted task'); + const deleteBtn = clientA.page + .locator('.mat-mdc-menu-item') + .filter({ hasText: 'Delete' }); + await deleteBtn.waitFor({ state: 'visible', timeout: 5000 }); + await deleteBtn.click(); + await clientA.page.waitForTimeout(500); + console.log('[DeleteRace] Client A deleted task'); - // 4. Client B updates the task (with later timestamp) - await clientB.page.waitForTimeout(1000); // Ensure later timestamp + // 4. Client B updates the task (with later timestamp) + await clientB.page.waitForTimeout(1000); // Ensure later timestamp - const taskLocatorB = clientB.page - .locator(`task:not(.ng-animating):has-text("${taskName}")`) - .first(); - await taskLocatorB.dblclick(); - const titleInputB = clientB.page.locator( - 'input.mat-mdc-input-element:focus, textarea:focus', - ); - await titleInputB.waitFor({ state: 'visible', timeout: 5000 }); - await titleInputB.fill(`${taskName}-Updated`); - await clientB.page.keyboard.press('Enter'); - await clientB.page.waitForTimeout(300); - console.log('[DeleteRace] Client B updated task title'); + const taskLocatorB = clientB.page + .locator(`task:not(.ng-animating):has-text("${taskName}")`) + .first(); + await taskLocatorB.dblclick(); + const titleInputB = clientB.page.locator( + 'input.mat-mdc-input-element:focus, textarea:focus', + ); + await titleInputB.waitFor({ state: 'visible', timeout: 5000 }); + await titleInputB.fill(`${taskName}-Updated`); + await clientB.page.keyboard.press('Enter'); + await clientB.page.waitForTimeout(300); + console.log('[DeleteRace] Client B updated task title'); - // 5. Client A syncs (uploads delete) - await clientA.sync.syncAndWait(); - console.log('[DeleteRace] Client A synced delete'); + // 5. Client A syncs (uploads delete) + await clientA.sync.syncAndWait(); + console.log('[DeleteRace] Client A synced delete'); - // 6. Client B syncs (LWW: B's update has later timestamp, should win) - await clientB.sync.syncAndWait(); - console.log('[DeleteRace] Client B synced, LWW applied'); + // 6. Client B syncs (LWW: B's update has later timestamp, should win) + await clientB.sync.syncAndWait(); + console.log('[DeleteRace] Client B synced, LWW applied'); - // 7. Final sync - await clientA.sync.syncAndWait(); - await clientB.sync.syncAndWait(); - console.log('[DeleteRace] Final sync complete'); + // 7. Final sync + await clientA.sync.syncAndWait(); + await clientB.sync.syncAndWait(); + console.log('[DeleteRace] Final sync complete'); - // Verify: Task should exist with B's updated title (update won over delete) - const updatedTaskA = clientA.page.locator(`task:has-text("${taskName}-Updated")`); - const updatedTaskB = clientB.page.locator(`task:has-text("${taskName}-Updated")`); + // Verify: Task should exist with B's updated title (update won over delete) + const updatedTaskA = clientA.page.locator(`task:has-text("${taskName}-Updated")`); + const updatedTaskB = clientB.page.locator(`task:has-text("${taskName}-Updated")`); - await expect(updatedTaskB).toBeVisible({ timeout: 10000 }); - await expect(updatedTaskA).toBeVisible({ timeout: 10000 }); + await expect(updatedTaskB).toBeVisible({ timeout: 10000 }); + await expect(updatedTaskA).toBeVisible({ timeout: 10000 }); - console.log('[DeleteRace] ✓ Update won over delete via LWW'); - } finally { - if (clientA) await closeClient(clientA); - if (clientB) await closeClient(clientB); - } - }, - ); + console.log('[DeleteRace] ✓ Update won over delete via LWW'); + } finally { + if (clientA) await closeClient(clientA); + if (clientB) await closeClient(clientB); + } + }); /** * Scenario: Delete vs Update Race with TODAY_TAG (dueDay) @@ -1437,113 +1402,110 @@ base.describe('@supersync SuperSync LWW Conflict Resolution', () => { * This test specifically validates the fix for the bug where TODAY_TAG.taskIds * wasn't updated when a task was recreated via LWW Update with dueDay = today. */ - base( - 'LWW: Recreated task with dueDay=today appears in TODAY view', - async ({ browser, baseURL }, testInfo) => { - testInfo.setTimeout(120000); - const testRunId = generateTestRunId(testInfo.workerIndex); - const appUrl = baseURL || 'http://localhost:4242'; - let clientA: SimulatedE2EClient | null = null; - let clientB: SimulatedE2EClient | null = null; + test('LWW: Recreated task with dueDay=today appears in TODAY view', 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); + try { + const user = await createTestUser(testRunId); + const syncConfig = getSuperSyncConfig(user); - // Setup clients - clientA = await createSimulatedClient(browser, appUrl, 'A', testRunId); - await clientA.sync.setupSuperSync(syncConfig); + // 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); + clientB = await createSimulatedClient(browser, appUrl, 'B', testRunId); + await clientB.sync.setupSuperSync(syncConfig); - // 1. Client A creates task for TODAY (sd:today sets dueDay) - const taskName = `TodayDeleteRace-${testRunId}`; - await clientA.workView.addTask(`${taskName} sd:today`); - await waitForTask(clientA.page, taskName); - console.log('[TodayDeleteRace] Created task for today on Client A'); + // 1. Client A creates task for TODAY (sd:today sets dueDay) + const taskName = `TodayDeleteRace-${testRunId}`; + await clientA.workView.addTask(`${taskName} sd:today`); + await waitForTask(clientA.page, taskName); + console.log('[TodayDeleteRace] Created task for today on Client A'); - // Verify task is in TODAY view (URL should contain TODAY) - const urlA = clientA.page.url(); - expect(urlA).toContain('TODAY'); + // Verify task is in TODAY view (URL should contain TODAY) + const urlA = clientA.page.url(); + expect(urlA).toContain('TODAY'); - // 2. Both sync - await clientA.sync.syncAndWait(); - await clientB.sync.syncAndWait(); - await waitForTask(clientB.page, taskName); - console.log('[TodayDeleteRace] Both clients have the task'); + // 2. Both sync + await clientA.sync.syncAndWait(); + await clientB.sync.syncAndWait(); + await waitForTask(clientB.page, taskName); + console.log('[TodayDeleteRace] Both clients have the task'); - // 3. Client A deletes the task - const taskLocatorA = clientA.page - .locator(`task:not(.ng-animating):has-text("${taskName}")`) - .first(); - await taskLocatorA.click({ button: 'right' }); - await clientA.page.waitForTimeout(300); + // 3. Client A deletes the task + const taskLocatorA = clientA.page + .locator(`task:not(.ng-animating):has-text("${taskName}")`) + .first(); + await taskLocatorA.click({ button: 'right' }); + await clientA.page.waitForTimeout(300); - await clientA.page - .locator('.mat-mdc-menu-panel') - .waitFor({ state: 'visible', timeout: 5000 }); + await clientA.page + .locator('.mat-mdc-menu-panel') + .waitFor({ state: 'visible', timeout: 5000 }); - const deleteBtn = clientA.page - .locator('.mat-mdc-menu-item') - .filter({ hasText: 'Delete' }); - await deleteBtn.waitFor({ state: 'visible', timeout: 5000 }); - await deleteBtn.click(); - await clientA.page.waitForTimeout(500); - console.log('[TodayDeleteRace] Client A deleted task'); + const deleteBtn = clientA.page + .locator('.mat-mdc-menu-item') + .filter({ hasText: 'Delete' }); + await deleteBtn.waitFor({ state: 'visible', timeout: 5000 }); + await deleteBtn.click(); + await clientA.page.waitForTimeout(500); + console.log('[TodayDeleteRace] Client A deleted task'); - // 4. Client B updates the task (with later timestamp) - await clientB.page.waitForTimeout(1000); // Ensure later timestamp + // 4. Client B updates the task (with later timestamp) + await clientB.page.waitForTimeout(1000); // Ensure later timestamp - const taskLocatorB = clientB.page - .locator(`task:not(.ng-animating):has-text("${taskName}")`) - .first(); - await taskLocatorB.dblclick(); - const titleInputB = clientB.page.locator( - 'input.mat-mdc-input-element:focus, textarea:focus', - ); - await titleInputB.waitFor({ state: 'visible', timeout: 5000 }); - await titleInputB.fill(`${taskName}-Updated`); - await clientB.page.keyboard.press('Enter'); - await clientB.page.waitForTimeout(300); - console.log('[TodayDeleteRace] Client B updated task title'); + const taskLocatorB = clientB.page + .locator(`task:not(.ng-animating):has-text("${taskName}")`) + .first(); + await taskLocatorB.dblclick(); + const titleInputB = clientB.page.locator( + 'input.mat-mdc-input-element:focus, textarea:focus', + ); + await titleInputB.waitFor({ state: 'visible', timeout: 5000 }); + await titleInputB.fill(`${taskName}-Updated`); + await clientB.page.keyboard.press('Enter'); + await clientB.page.waitForTimeout(300); + console.log('[TodayDeleteRace] Client B updated task title'); - // 5. Client A syncs (uploads delete) - await clientA.sync.syncAndWait(); - console.log('[TodayDeleteRace] Client A synced delete'); + // 5. Client A syncs (uploads delete) + await clientA.sync.syncAndWait(); + console.log('[TodayDeleteRace] Client A synced delete'); - // 6. Client B syncs (uploads update, which should win via LWW) - await clientB.sync.syncAndWait(); - console.log('[TodayDeleteRace] Client B synced update'); + // 6. Client B syncs (uploads update, which should win via LWW) + await clientB.sync.syncAndWait(); + console.log('[TodayDeleteRace] Client B synced update'); - // 7. Client A syncs (receives LWW Update, task should be recreated) - await clientA.sync.syncAndWait(); - console.log('[TodayDeleteRace] Client A synced, LWW applied'); + // 7. Client A syncs (receives LWW Update, task should be recreated) + await clientA.sync.syncAndWait(); + console.log('[TodayDeleteRace] Client A synced, LWW applied'); - // 8. CRITICAL ASSERTION: Task should appear in TODAY view on Client A - // This validates that syncTodayTagTaskIds properly updated TODAY_TAG.taskIds - const recreatedTaskA = clientA.page.locator( - `task:has-text("${taskName}-Updated")`, - ); - await expect(recreatedTaskA).toBeVisible({ timeout: 10000 }); + // 8. CRITICAL ASSERTION: Task should appear in TODAY view on Client A + // This validates that syncTodayTagTaskIds properly updated TODAY_TAG.taskIds + const recreatedTaskA = clientA.page.locator(`task:has-text("${taskName}-Updated")`); + await expect(recreatedTaskA).toBeVisible({ timeout: 10000 }); - // Also verify we're still in TODAY context - const finalUrlA = clientA.page.url(); - expect(finalUrlA).toContain('TODAY'); + // Also verify we're still in TODAY context + const finalUrlA = clientA.page.url(); + expect(finalUrlA).toContain('TODAY'); - // Verify task also visible on Client B - const updatedTaskB = clientB.page.locator(`task:has-text("${taskName}-Updated")`); - await expect(updatedTaskB).toBeVisible({ timeout: 10000 }); + // Verify task also visible on Client B + const updatedTaskB = clientB.page.locator(`task:has-text("${taskName}-Updated")`); + await expect(updatedTaskB).toBeVisible({ timeout: 10000 }); - console.log( - '[TodayDeleteRace] ✓ Recreated task with dueDay=today appears in TODAY view', - ); - } finally { - if (clientA) await closeClient(clientA); - if (clientB) await closeClient(clientB); - } - }, - ); + console.log( + '[TodayDeleteRace] ✓ Recreated task with dueDay=today appears in TODAY view', + ); + } finally { + if (clientA) await closeClient(clientA); + if (clientB) await closeClient(clientB); + } + }); /** * LWW: Subtask conflicts resolve independently from parent @@ -1559,216 +1521,211 @@ base.describe('@supersync SuperSync LWW Conflict Resolution', () => { * 5. Both sync * 6. Verify: Both parent title change AND subtask done status applied */ - base( - 'LWW: Subtask conflicts resolve independently from parent', - async ({ browser, baseURL }, testInfo) => { - testInfo.setTimeout(120000); - const testRunId = generateTestRunId(testInfo.workerIndex); - const appUrl = baseURL || 'http://localhost:4242'; - let clientA: SimulatedE2EClient | null = null; - let clientB: SimulatedE2EClient | null = null; + test('LWW: Subtask conflicts resolve independently from parent', 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); + try { + const user = await createTestUser(testRunId); + const syncConfig = getSuperSyncConfig(user); - // Setup clients - clientA = await createSimulatedClient(browser, appUrl, 'A', testRunId); - await clientA.sync.setupSuperSync(syncConfig); + // 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); + clientB = await createSimulatedClient(browser, appUrl, 'B', testRunId); + await clientB.sync.setupSuperSync(syncConfig); - // 1. Client A creates parent task - const parentName = `Parent-${testRunId}`; - const subtaskName = `Subtask-${testRunId}`; - await clientA.workView.addTask(parentName); - await waitForTask(clientA.page, parentName); + // 1. Client A creates parent task + const parentName = `Parent-${testRunId}`; + const subtaskName = `Subtask-${testRunId}`; + await clientA.workView.addTask(parentName); + await waitForTask(clientA.page, parentName); - // Add subtask using keyboard shortcut - const parentTask = clientA.page.locator(`task:has-text("${parentName}")`).first(); - await parentTask.focus(); - await clientA.page.waitForTimeout(100); - await parentTask.press('a'); // Add subtask shortcut + // Add subtask using keyboard shortcut + const parentTask = clientA.page.locator(`task:has-text("${parentName}")`).first(); + await parentTask.focus(); + await clientA.page.waitForTimeout(100); + await parentTask.press('a'); // Add subtask shortcut - // Wait for textarea and fill subtask name - const textarea = clientA.page.locator('task-title textarea'); - await textarea.waitFor({ state: 'visible', timeout: 5000 }); - await textarea.fill(subtaskName); - await clientA.page.keyboard.press('Enter'); - await waitForTask(clientA.page, subtaskName); - await clientA.page.waitForTimeout(300); - console.log('[SubtaskLWW] Created parent with subtask on Client A'); + // Wait for textarea and fill subtask name + const textarea = clientA.page.locator('task-title textarea'); + await textarea.waitFor({ state: 'visible', timeout: 5000 }); + await textarea.fill(subtaskName); + await clientA.page.keyboard.press('Enter'); + await waitForTask(clientA.page, subtaskName); + await clientA.page.waitForTimeout(300); + console.log('[SubtaskLWW] Created parent with subtask on Client A'); - // 2. Both sync - await clientA.sync.syncAndWait(); - await clientB.sync.syncAndWait(); - await waitForTask(clientB.page, parentName); - console.log('[SubtaskLWW] Both clients have parent and subtask'); + // 2. Both sync + await clientA.sync.syncAndWait(); + await clientB.sync.syncAndWait(); + await waitForTask(clientB.page, parentName); + console.log('[SubtaskLWW] Both clients have parent and subtask'); - // 3. Client A updates parent title - // Must click on task-title specifically, not the whole task - // (dblclick on parent task with subtasks expands/collapses them) - const parentLocatorA = clientA.page - .locator( - `task:not(.hasNoSubTasks):not(.ng-animating):has-text("${parentName}")`, - ) - .first(); - const titleElementA = parentLocatorA.locator('.task-title').first(); - await titleElementA.dblclick(); - await clientA.page.waitForTimeout(200); + // 3. Client A updates parent title + // Must click on task-title specifically, not the whole task + // (dblclick on parent task with subtasks expands/collapses them) + const parentLocatorA = clientA.page + .locator(`task:not(.hasNoSubTasks):not(.ng-animating):has-text("${parentName}")`) + .first(); + const titleElementA = parentLocatorA.locator('.task-title').first(); + await titleElementA.dblclick(); + await clientA.page.waitForTimeout(200); - // Wait for inline edit input - const inputA = clientA.page.locator( - 'input.mat-mdc-input-element:focus, textarea:focus', - ); - await inputA.waitFor({ state: 'visible', timeout: 5000 }); - const newParentName = `UpdatedParent-${testRunId}`; - await inputA.fill(newParentName); - await clientA.page.keyboard.press('Enter'); - await clientA.page.waitForTimeout(300); - console.log('[SubtaskLWW] Client A updated parent title'); + // Wait for inline edit input + const inputA = clientA.page.locator( + 'input.mat-mdc-input-element:focus, textarea:focus', + ); + await inputA.waitFor({ state: 'visible', timeout: 5000 }); + const newParentName = `UpdatedParent-${testRunId}`; + await inputA.fill(newParentName); + await clientA.page.keyboard.press('Enter'); + await clientA.page.waitForTimeout(300); + console.log('[SubtaskLWW] Client A updated parent title'); - // 4. Client B marks subtask as done (later timestamp) - await clientB.page.waitForTimeout(500); + // 4. Client B marks subtask as done (later timestamp) + await clientB.page.waitForTimeout(500); - // Find the parent and expand to see subtask - const parentLocatorB = clientB.page - .locator( - `task:not(.hasNoSubTasks):not(.ng-animating):has-text("${parentName}")`, - ) - .first(); - await parentLocatorB.waitFor({ state: 'visible', timeout: 5000 }); + // Find the parent and expand to see subtask + const parentLocatorB = clientB.page + .locator(`task:not(.hasNoSubTasks):not(.ng-animating):has-text("${parentName}")`) + .first(); + await parentLocatorB.waitFor({ state: 'visible', timeout: 5000 }); - // Click the expand button or the task itself to show subtasks - // First check if subtasks are already visible - const subtaskVisible = await clientB.page - .locator(`task.hasNoSubTasks:has-text("${subtaskName}")`) - .first() - .isVisible() - .catch(() => false); + // Click the expand button or the task itself to show subtasks + // First check if subtasks are already visible + const subtaskVisible = await clientB.page + .locator(`task.hasNoSubTasks:has-text("${subtaskName}")`) + .first() + .isVisible() + .catch(() => false); - if (!subtaskVisible) { - // Try clicking expand button - const expandBtn = parentLocatorB.locator('.expand-btn').first(); - if (await expandBtn.isVisible().catch(() => false)) { - await expandBtn.click(); + if (!subtaskVisible) { + // Try clicking expand button + const expandBtn = parentLocatorB.locator('.expand-btn').first(); + if (await expandBtn.isVisible().catch(() => false)) { + await expandBtn.click(); + await clientB.page.waitForTimeout(500); + } else { + // Try toggle-sub-tasks-btn + const toggleBtn = parentLocatorB.locator('.toggle-sub-tasks-btn').first(); + if (await toggleBtn.isVisible().catch(() => false)) { + await toggleBtn.click(); await clientB.page.waitForTimeout(500); } else { - // Try toggle-sub-tasks-btn - const toggleBtn = parentLocatorB.locator('.toggle-sub-tasks-btn').first(); - if (await toggleBtn.isVisible().catch(() => false)) { - await toggleBtn.click(); - await clientB.page.waitForTimeout(500); - } else { - // Click on parent to toggle - await parentLocatorB.click(); - await clientB.page.waitForTimeout(500); - } + // Click on parent to toggle + await parentLocatorB.click(); + await clientB.page.waitForTimeout(500); } } - - // Verify subtask is now visible - const subtaskLocatorB = clientB.page - .locator(`task.hasNoSubTasks:has-text("${subtaskName}")`) - .first(); - await subtaskLocatorB.waitFor({ state: 'visible', timeout: 10000 }); - - // Mark subtask done using keyboard shortcut - await subtaskLocatorB.focus(); - await clientB.page.waitForTimeout(100); - await subtaskLocatorB.press('d'); // Toggle done shortcut - await clientB.page.waitForTimeout(300); - console.log('[SubtaskLWW] Client B marked subtask done'); - - // 5. Both sync - await clientA.sync.syncAndWait(); - await clientB.sync.syncAndWait(); - await clientA.sync.syncAndWait(); - await clientB.sync.syncAndWait(); - console.log('[SubtaskLWW] Final sync complete'); - - // 6. Verify both changes applied - - // Parent should have updated title on both clients - await waitForTask(clientA.page, newParentName); - await waitForTask(clientB.page, newParentName); - console.log('[SubtaskLWW] Parent title updated on both clients'); - - // Expand to see subtasks on Client A - const updatedParentA = clientA.page - .locator(`task:not(.hasNoSubTasks):has-text("${newParentName}")`) - .first(); - await updatedParentA.waitFor({ state: 'visible', timeout: 5000 }); - - // Check if subtask is already visible - const subtaskVisibleA = await clientA.page - .locator(`task.hasNoSubTasks:has-text("${subtaskName}")`) - .first() - .isVisible() - .catch(() => false); - - if (!subtaskVisibleA) { - // Try clicking expand button - const expandBtnA = updatedParentA.locator('.expand-btn').first(); - if (await expandBtnA.isVisible().catch(() => false)) { - await expandBtnA.click(); - } else { - // Click parent to toggle - await updatedParentA.click(); - } - await clientA.page.waitForTimeout(500); - } - - // Expand to see subtasks on Client B - const updatedParentB = clientB.page - .locator(`task:not(.hasNoSubTasks):has-text("${newParentName}")`) - .first(); - await updatedParentB.waitFor({ state: 'visible', timeout: 5000 }); - - // Check if subtask is already visible - const subtaskVisibleB = await clientB.page - .locator(`task.hasNoSubTasks:has-text("${subtaskName}")`) - .first() - .isVisible() - .catch(() => false); - - if (!subtaskVisibleB) { - // Try clicking expand button - const expandBtnB = updatedParentB.locator('.expand-btn').first(); - if (await expandBtnB.isVisible().catch(() => false)) { - await expandBtnB.click(); - } else { - // Click parent to toggle - await updatedParentB.click(); - } - await clientB.page.waitForTimeout(500); - } - - // Wait for subtasks to be visible - const doneSubtaskA = clientA.page - .locator(`task.hasNoSubTasks:has-text("${subtaskName}")`) - .first(); - const doneSubtaskB = clientB.page - .locator(`task.hasNoSubTasks:has-text("${subtaskName}")`) - .first(); - - await doneSubtaskA.waitFor({ state: 'visible', timeout: 10000 }); - await doneSubtaskB.waitFor({ state: 'visible', timeout: 10000 }); - - // Subtask should be marked done on both clients - await expect(doneSubtaskA).toHaveClass(/isDone/, { timeout: 10000 }); - await expect(doneSubtaskB).toHaveClass(/isDone/, { timeout: 10000 }); - - console.log( - '[SubtaskLWW] ✓ Parent title change and subtask done status synced independently', - ); - } finally { - if (clientA) await closeClient(clientA); - if (clientB) await closeClient(clientB); } - }, - ); + + // Verify subtask is now visible + const subtaskLocatorB = clientB.page + .locator(`task.hasNoSubTasks:has-text("${subtaskName}")`) + .first(); + await subtaskLocatorB.waitFor({ state: 'visible', timeout: 10000 }); + + // Mark subtask done using keyboard shortcut + await subtaskLocatorB.focus(); + await clientB.page.waitForTimeout(100); + await subtaskLocatorB.press('d'); // Toggle done shortcut + await clientB.page.waitForTimeout(300); + console.log('[SubtaskLWW] Client B marked subtask done'); + + // 5. Both sync + await clientA.sync.syncAndWait(); + await clientB.sync.syncAndWait(); + await clientA.sync.syncAndWait(); + await clientB.sync.syncAndWait(); + console.log('[SubtaskLWW] Final sync complete'); + + // 6. Verify both changes applied + + // Parent should have updated title on both clients + await waitForTask(clientA.page, newParentName); + await waitForTask(clientB.page, newParentName); + console.log('[SubtaskLWW] Parent title updated on both clients'); + + // Expand to see subtasks on Client A + const updatedParentA = clientA.page + .locator(`task:not(.hasNoSubTasks):has-text("${newParentName}")`) + .first(); + await updatedParentA.waitFor({ state: 'visible', timeout: 5000 }); + + // Check if subtask is already visible + const subtaskVisibleA = await clientA.page + .locator(`task.hasNoSubTasks:has-text("${subtaskName}")`) + .first() + .isVisible() + .catch(() => false); + + if (!subtaskVisibleA) { + // Try clicking expand button + const expandBtnA = updatedParentA.locator('.expand-btn').first(); + if (await expandBtnA.isVisible().catch(() => false)) { + await expandBtnA.click(); + } else { + // Click parent to toggle + await updatedParentA.click(); + } + await clientA.page.waitForTimeout(500); + } + + // Expand to see subtasks on Client B + const updatedParentB = clientB.page + .locator(`task:not(.hasNoSubTasks):has-text("${newParentName}")`) + .first(); + await updatedParentB.waitFor({ state: 'visible', timeout: 5000 }); + + // Check if subtask is already visible + const subtaskVisibleB = await clientB.page + .locator(`task.hasNoSubTasks:has-text("${subtaskName}")`) + .first() + .isVisible() + .catch(() => false); + + if (!subtaskVisibleB) { + // Try clicking expand button + const expandBtnB = updatedParentB.locator('.expand-btn').first(); + if (await expandBtnB.isVisible().catch(() => false)) { + await expandBtnB.click(); + } else { + // Click parent to toggle + await updatedParentB.click(); + } + await clientB.page.waitForTimeout(500); + } + + // Wait for subtasks to be visible + const doneSubtaskA = clientA.page + .locator(`task.hasNoSubTasks:has-text("${subtaskName}")`) + .first(); + const doneSubtaskB = clientB.page + .locator(`task.hasNoSubTasks:has-text("${subtaskName}")`) + .first(); + + await doneSubtaskA.waitFor({ state: 'visible', timeout: 10000 }); + await doneSubtaskB.waitFor({ state: 'visible', timeout: 10000 }); + + // Subtask should be marked done on both clients + await expect(doneSubtaskA).toHaveClass(/isDone/, { timeout: 10000 }); + await expect(doneSubtaskB).toHaveClass(/isDone/, { timeout: 10000 }); + + console.log( + '[SubtaskLWW] ✓ Parent title change and subtask done status synced independently', + ); + } finally { + if (clientA) await closeClient(clientA); + if (clientB) await closeClient(clientB); + } + }); /** * LWW: Subtask deletion cascades with parent delete @@ -1789,199 +1746,198 @@ base.describe('@supersync SuperSync LWW Conflict Resolution', () => { * 7. Client B syncs (delete cascades to subtask despite later edit) * 8. Verify: Both parent and subtask are deleted on both clients */ - base( - 'LWW: Subtask is deleted when parent is deleted concurrently (cascade delete)', - async ({ browser, baseURL }, testInfo) => { - testInfo.setTimeout(120000); - const testRunId = generateTestRunId(testInfo.workerIndex); - const appUrl = baseURL || 'http://localhost:4242'; - let clientA: SimulatedE2EClient | null = null; - let clientB: SimulatedE2EClient | null = null; + test('LWW: Subtask is deleted when parent is deleted concurrently (cascade delete)', 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); + try { + const user = await createTestUser(testRunId); + const syncConfig = getSuperSyncConfig(user); - // Setup clients - clientA = await createSimulatedClient(browser, appUrl, 'A', testRunId); - await clientA.sync.setupSuperSync(syncConfig); + // 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); + clientB = await createSimulatedClient(browser, appUrl, 'B', testRunId); + await clientB.sync.setupSuperSync(syncConfig); - // 1. Client A creates parent task with subtask - const parentName = `ParentDel-${testRunId}`; - const subtaskName = `SubtaskSurvive-${testRunId}`; - await clientA.workView.addTask(parentName); - await waitForTask(clientA.page, parentName); + // 1. Client A creates parent task with subtask + const parentName = `ParentDel-${testRunId}`; + const subtaskName = `SubtaskSurvive-${testRunId}`; + await clientA.workView.addTask(parentName); + await waitForTask(clientA.page, parentName); - // Add subtask using keyboard shortcut - const parentTask = clientA.page.locator(`task:has-text("${parentName}")`).first(); - await parentTask.focus(); - await clientA.page.waitForTimeout(100); - await parentTask.press('a'); // Add subtask shortcut + // Add subtask using keyboard shortcut + const parentTask = clientA.page.locator(`task:has-text("${parentName}")`).first(); + await parentTask.focus(); + await clientA.page.waitForTimeout(100); + await parentTask.press('a'); // Add subtask shortcut - // Wait for textarea and fill subtask name - const textarea = clientA.page.locator('task-title textarea'); - await textarea.waitFor({ state: 'visible', timeout: 5000 }); - await textarea.fill(subtaskName); - await clientA.page.keyboard.press('Enter'); - await waitForTask(clientA.page, subtaskName); - await clientA.page.waitForTimeout(300); - console.log('[OrphanSubtask] Created parent with subtask on Client A'); + // Wait for textarea and fill subtask name + const textarea = clientA.page.locator('task-title textarea'); + await textarea.waitFor({ state: 'visible', timeout: 5000 }); + await textarea.fill(subtaskName); + await clientA.page.keyboard.press('Enter'); + await waitForTask(clientA.page, subtaskName); + await clientA.page.waitForTimeout(300); + console.log('[OrphanSubtask] Created parent with subtask on Client A'); - // 2. Both sync - await clientA.sync.syncAndWait(); - await clientB.sync.syncAndWait(); - await waitForTask(clientB.page, parentName); - console.log('[OrphanSubtask] Both clients have parent and subtask'); + // 2. Both sync + await clientA.sync.syncAndWait(); + await clientB.sync.syncAndWait(); + await waitForTask(clientB.page, parentName); + console.log('[OrphanSubtask] Both clients have parent and subtask'); - // Expand parent on Client B to see subtask before we delete on A - const parentLocatorB = clientB.page - .locator(`task:not(.hasNoSubTasks):has-text("${parentName}")`) - .first(); - await parentLocatorB.waitFor({ state: 'visible', timeout: 5000 }); + // Expand parent on Client B to see subtask before we delete on A + const parentLocatorB = clientB.page + .locator(`task:not(.hasNoSubTasks):has-text("${parentName}")`) + .first(); + await parentLocatorB.waitFor({ state: 'visible', timeout: 5000 }); - // Check if subtask is already visible, if not expand - const subtaskVisibleB = await clientB.page - .locator(`task.hasNoSubTasks:has-text("${subtaskName}")`) - .first() - .isVisible() - .catch(() => false); + // Check if subtask is already visible, if not expand + const subtaskVisibleB = await clientB.page + .locator(`task.hasNoSubTasks:has-text("${subtaskName}")`) + .first() + .isVisible() + .catch(() => false); - if (!subtaskVisibleB) { - const expandBtn = parentLocatorB.locator('.expand-btn').first(); - if (await expandBtn.isVisible().catch(() => false)) { - await expandBtn.click(); - } else { - await parentLocatorB.click(); - } - await clientB.page.waitForTimeout(500); + if (!subtaskVisibleB) { + const expandBtn = parentLocatorB.locator('.expand-btn').first(); + if (await expandBtn.isVisible().catch(() => false)) { + await expandBtn.click(); + } else { + await parentLocatorB.click(); } - - // Verify subtask is visible on Client B - const subtaskLocatorB = clientB.page - .locator(`task.hasNoSubTasks:has-text("${subtaskName}")`) - .first(); - await subtaskLocatorB.waitFor({ state: 'visible', timeout: 10000 }); - console.log('[OrphanSubtask] Subtask visible on Client B'); - - // 3. Client A deletes the parent task - // First, click elsewhere to clear any focus/selection - await clientA.page.locator('body').click({ position: { x: 10, y: 10 } }); - await clientA.page.waitForTimeout(200); - - // Find the parent task - use more specific selector targeting the parent (not subtask) - // Parent tasks have the parent name but subtasks don't - const parentLocatorA = clientA.page - .locator(`task:has-text("${parentName}")`) - .first(); - await parentLocatorA.waitFor({ state: 'visible', timeout: 5000 }); - - // Scroll it into view and wait for any animations - await parentLocatorA.scrollIntoViewIfNeeded(); - await clientA.page.waitForTimeout(200); - - // Open context menu with retry logic - let menuOpened = false; - for (let attempt = 0; attempt < 3 && !menuOpened; attempt++) { - // Click on the task title area specifically - await parentLocatorA.locator('task-title').first().click({ button: 'right' }); - await clientA.page.waitForTimeout(500); - - try { - await clientA.page - .locator('.mat-mdc-menu-panel') - .waitFor({ state: 'visible', timeout: 3000 }); - menuOpened = true; - } catch { - // Menu didn't open, escape any partial state and retry - await clientA.page.keyboard.press('Escape'); - await clientA.page.waitForTimeout(300); - } - } - - if (!menuOpened) { - throw new Error('Failed to open context menu on parent task'); - } - - const deleteBtn = clientA.page - .locator('.mat-mdc-menu-item') - .filter({ hasText: 'Delete' }); - await deleteBtn.waitFor({ state: 'visible', timeout: 5000 }); - await deleteBtn.click(); - await clientA.page.waitForTimeout(500); - console.log('[OrphanSubtask] Client A deleted parent task'); - - // Verify parent (and subtask) are gone on Client A - const parentGoneA = await clientA.page - .locator(`task:has-text("${parentName}")`) - .count(); - expect(parentGoneA).toBe(0); - console.log('[OrphanSubtask] Parent and subtasks gone from Client A'); - - // 4. Wait for timestamp gap (ensures B's edit is later) - await clientB.page.waitForTimeout(1500); - - // 5. Client B edits subtask (marks it done) - doesn't know parent was deleted - // Subtask should still be visible on B - await subtaskLocatorB.focus(); - await clientB.page.waitForTimeout(100); - await subtaskLocatorB.press('d'); // Toggle done shortcut - await clientB.page.waitForTimeout(300); - - // Verify subtask is marked done on B - await expect(subtaskLocatorB).toHaveClass(/isDone/, { timeout: 5000 }); - console.log('[OrphanSubtask] Client B marked subtask done (later timestamp)'); - - // 6. Client A syncs (uploads delete operations) - await clientA.sync.syncAndWait(); - console.log('[OrphanSubtask] Client A synced delete'); - - // 7. Client B syncs (LWW resolution happens) - // B's subtask update has later timestamp, should win over A's delete - await clientB.sync.syncAndWait(); - console.log('[OrphanSubtask] Client B synced, LWW applied'); - - // 8. Final sync round - await clientA.sync.syncAndWait(); - await clientB.sync.syncAndWait(); - - // 9. Verify: Both parent and subtask are deleted on both clients - // Parent deletion cascades to subtasks, even if subtask had later edits - - // On Client B: subtask should be deleted (cascade delete from parent) - const subtaskCountB = await clientB.page - .locator(`task:has-text("${subtaskName}")`) - .count(); - expect(subtaskCountB).toBe(0); - console.log('[CascadeDelete] Subtask deleted on Client B (cascade)'); - - // On Client A: subtask should also be deleted - const subtaskCountA = await clientA.page - .locator(`task:has-text("${subtaskName}")`) - .count(); - expect(subtaskCountA).toBe(0); - console.log('[CascadeDelete] Subtask deleted on Client A'); - - // Parent should be deleted on both clients - const parentCountA = await clientA.page - .locator(`task:has-text("${parentName}")`) - .count(); - const parentCountB = await clientB.page - .locator(`task:has-text("${parentName}")`) - .count(); - - expect(parentCountA).toBe(0); - expect(parentCountB).toBe(0); - - console.log( - '[CascadeDelete] ✓ Parent delete cascaded to subtask - data consistency maintained', - ); - } finally { - if (clientA) await closeClient(clientA); - if (clientB) await closeClient(clientB); + await clientB.page.waitForTimeout(500); } - }, - ); + + // Verify subtask is visible on Client B + const subtaskLocatorB = clientB.page + .locator(`task.hasNoSubTasks:has-text("${subtaskName}")`) + .first(); + await subtaskLocatorB.waitFor({ state: 'visible', timeout: 10000 }); + console.log('[OrphanSubtask] Subtask visible on Client B'); + + // 3. Client A deletes the parent task + // First, click elsewhere to clear any focus/selection + await clientA.page.locator('body').click({ position: { x: 10, y: 10 } }); + await clientA.page.waitForTimeout(200); + + // Find the parent task - use more specific selector targeting the parent (not subtask) + // Parent tasks have the parent name but subtasks don't + const parentLocatorA = clientA.page + .locator(`task:has-text("${parentName}")`) + .first(); + await parentLocatorA.waitFor({ state: 'visible', timeout: 5000 }); + + // Scroll it into view and wait for any animations + await parentLocatorA.scrollIntoViewIfNeeded(); + await clientA.page.waitForTimeout(200); + + // Open context menu with retry logic + let menuOpened = false; + for (let attempt = 0; attempt < 3 && !menuOpened; attempt++) { + // Click on the task title area specifically + await parentLocatorA.locator('task-title').first().click({ button: 'right' }); + await clientA.page.waitForTimeout(500); + + try { + await clientA.page + .locator('.mat-mdc-menu-panel') + .waitFor({ state: 'visible', timeout: 3000 }); + menuOpened = true; + } catch { + // Menu didn't open, escape any partial state and retry + await clientA.page.keyboard.press('Escape'); + await clientA.page.waitForTimeout(300); + } + } + + if (!menuOpened) { + throw new Error('Failed to open context menu on parent task'); + } + + const deleteBtn = clientA.page + .locator('.mat-mdc-menu-item') + .filter({ hasText: 'Delete' }); + await deleteBtn.waitFor({ state: 'visible', timeout: 5000 }); + await deleteBtn.click(); + await clientA.page.waitForTimeout(500); + console.log('[OrphanSubtask] Client A deleted parent task'); + + // Verify parent (and subtask) are gone on Client A + const parentGoneA = await clientA.page + .locator(`task:has-text("${parentName}")`) + .count(); + expect(parentGoneA).toBe(0); + console.log('[OrphanSubtask] Parent and subtasks gone from Client A'); + + // 4. Wait for timestamp gap (ensures B's edit is later) + await clientB.page.waitForTimeout(1500); + + // 5. Client B edits subtask (marks it done) - doesn't know parent was deleted + // Subtask should still be visible on B + await subtaskLocatorB.focus(); + await clientB.page.waitForTimeout(100); + await subtaskLocatorB.press('d'); // Toggle done shortcut + await clientB.page.waitForTimeout(300); + + // Verify subtask is marked done on B + await expect(subtaskLocatorB).toHaveClass(/isDone/, { timeout: 5000 }); + console.log('[OrphanSubtask] Client B marked subtask done (later timestamp)'); + + // 6. Client A syncs (uploads delete operations) + await clientA.sync.syncAndWait(); + console.log('[OrphanSubtask] Client A synced delete'); + + // 7. Client B syncs (LWW resolution happens) + // B's subtask update has later timestamp, should win over A's delete + await clientB.sync.syncAndWait(); + console.log('[OrphanSubtask] Client B synced, LWW applied'); + + // 8. Final sync round + await clientA.sync.syncAndWait(); + await clientB.sync.syncAndWait(); + + // 9. Verify: Both parent and subtask are deleted on both clients + // Parent deletion cascades to subtasks, even if subtask had later edits + + // On Client B: subtask should be deleted (cascade delete from parent) + const subtaskCountB = await clientB.page + .locator(`task:has-text("${subtaskName}")`) + .count(); + expect(subtaskCountB).toBe(0); + console.log('[CascadeDelete] Subtask deleted on Client B (cascade)'); + + // On Client A: subtask should also be deleted + const subtaskCountA = await clientA.page + .locator(`task:has-text("${subtaskName}")`) + .count(); + expect(subtaskCountA).toBe(0); + console.log('[CascadeDelete] Subtask deleted on Client A'); + + // Parent should be deleted on both clients + const parentCountA = await clientA.page + .locator(`task:has-text("${parentName}")`) + .count(); + const parentCountB = await clientB.page + .locator(`task:has-text("${parentName}")`) + .count(); + + expect(parentCountA).toBe(0); + expect(parentCountB).toBe(0); + + console.log( + '[CascadeDelete] ✓ Parent delete cascaded to subtask - data consistency maintained', + ); + } finally { + if (clientA) await closeClient(clientA); + if (clientB) await closeClient(clientB); + } + }); }); diff --git a/e2e/tests/sync/supersync-models.spec.ts b/e2e/tests/sync/supersync-models.spec.ts index dbca56a34..8104e52ec 100644 --- a/e2e/tests/sync/supersync-models.spec.ts +++ b/e2e/tests/sync/supersync-models.spec.ts @@ -1,28 +1,15 @@ -import { test as base, expect, Page } from '@playwright/test'; +import { type Page } from '@playwright/test'; +import { test, expect } from '../../fixtures/supersync.fixture'; import { createTestUser, getSuperSyncConfig, createSimulatedClient, closeClient, waitForTask, - isServerHealthy, type SimulatedE2EClient, } from '../../utils/supersync-helpers'; import { ProjectPage } from '../../pages/project.page'; -/** - * SuperSync Models E2E Tests - * - * Verifies synchronization of non-task models: - * - Projects - * - Tags (including creation and assignment) - * - Project Notes - */ - -const generateTestRunId = (workerIndex: number): string => { - return `${Date.now()}-${workerIndex}`; -}; - // Robust helper to create a project const createProjectReliably = async (page: Page, projectName: string): Promise => { await page.goto('/#/tag/TODAY/work'); @@ -147,95 +134,84 @@ const createTagReliably = async (page: Page, tagName: string): Promise => await dialog.waitFor({ state: 'hidden' }); }; -base.describe('@supersync SuperSync Models', () => { - let serverHealthy: boolean | null = null; +test.describe('@supersync SuperSync Models', () => { + test('Projects and their tasks sync correctly', async ({ + browser, + baseURL, + testRunId, + }) => { + let clientA: SimulatedE2EClient | null = null; + let clientB: SimulatedE2EClient | null = null; - base.beforeEach(async ({}, testInfo) => { - if (serverHealthy === null) { - serverHealthy = await isServerHealthy(); - if (!serverHealthy) { - console.warn( - 'SuperSync server not healthy at http://localhost:1901 - skipping tests', - ); + try { + const user = await createTestUser(testRunId); + const syncConfig = getSuperSyncConfig(user); + + // Client A setup + clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); + await clientA.sync.setupSuperSync(syncConfig); + + // Create Project on Client A + const projectName = `Proj-${testRunId}`; + await createProjectReliably(clientA.page, projectName); + + // Go to project + const projectBtnA = clientA.page.getByText(projectName).first(); + await projectBtnA.waitFor({ state: 'visible' }); + await projectBtnA.click({ force: true }); + + // Add task to project + const taskName = `TaskInProject-${testRunId}`; + await clientA.workView.addTask(taskName); + + // Sync A + await clientA.sync.syncAndWait(); + + // Client B setup + clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); + await clientB.sync.setupSuperSync(syncConfig); + await clientB.sync.syncAndWait(); + + // Verify Project exists on B + const projectsTreeB = clientB.page + .locator('nav-list-tree') + .filter({ hasText: 'Projects' }) + .first(); + await projectsTreeB.waitFor({ state: 'visible' }); + + const groupNavItemB = projectsTreeB.locator('nav-item').first(); + // Ensure expanded + const expandBtnB = groupNavItemB + .locator('button.expand-btn, button.arrow-btn') + .first(); + if (await expandBtnB.isVisible()) { + const isExpanded = await expandBtnB.getAttribute('aria-expanded'); + if (isExpanded !== 'true') { + await groupNavItemB.click(); + await clientB.page.waitForTimeout(500); + } } + + const projectBtnB = clientB.page.getByText(projectName).first(); + await expect(projectBtnB).toBeVisible(); + + // Click project on B + await projectBtnB.click({ force: true }); + + // Verify Task exists in project on B + await waitForTask(clientB.page, taskName); + await expect(clientB.page.locator(`task:has-text("${taskName}")`)).toBeVisible(); + } finally { + if (clientA) await closeClient(clientA); + if (clientB) await closeClient(clientB); } - testInfo.skip(!serverHealthy, 'SuperSync server not running'); }); - base( - 'Projects and their tasks sync correctly', - async ({ browser, baseURL }, testInfo) => { - const testRunId = generateTestRunId(testInfo.workerIndex); - let clientA: SimulatedE2EClient | null = null; - let clientB: SimulatedE2EClient | null = null; - - try { - const user = await createTestUser(testRunId); - const syncConfig = getSuperSyncConfig(user); - - // Client A setup - clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); - await clientA.sync.setupSuperSync(syncConfig); - - // Create Project on Client A - const projectName = `Proj-${testRunId}`; - await createProjectReliably(clientA.page, projectName); - - // Go to project - const projectBtnA = clientA.page.getByText(projectName).first(); - await projectBtnA.waitFor({ state: 'visible' }); - await projectBtnA.click({ force: true }); - - // Add task to project - const taskName = `TaskInProject-${testRunId}`; - await clientA.workView.addTask(taskName); - - // Sync A - await clientA.sync.syncAndWait(); - - // Client B setup - clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); - await clientB.sync.setupSuperSync(syncConfig); - await clientB.sync.syncAndWait(); - - // Verify Project exists on B - const projectsTreeB = clientB.page - .locator('nav-list-tree') - .filter({ hasText: 'Projects' }) - .first(); - await projectsTreeB.waitFor({ state: 'visible' }); - - const groupNavItemB = projectsTreeB.locator('nav-item').first(); - // Ensure expanded - const expandBtnB = groupNavItemB - .locator('button.expand-btn, button.arrow-btn') - .first(); - if (await expandBtnB.isVisible()) { - const isExpanded = await expandBtnB.getAttribute('aria-expanded'); - if (isExpanded !== 'true') { - await groupNavItemB.click(); - await clientB.page.waitForTimeout(500); - } - } - - const projectBtnB = clientB.page.getByText(projectName).first(); - await expect(projectBtnB).toBeVisible(); - - // Click project on B - await projectBtnB.click({ force: true }); - - // Verify Task exists in project on B - await waitForTask(clientB.page, taskName); - await expect(clientB.page.locator(`task:has-text("${taskName}")`)).toBeVisible(); - } finally { - if (clientA) await closeClient(clientA); - if (clientB) await closeClient(clientB); - } - }, - ); - - base('Tags and tagged tasks sync correctly', async ({ browser, baseURL }, testInfo) => { - const testRunId = generateTestRunId(testInfo.workerIndex); + test('Tags and tagged tasks sync correctly', async ({ + browser, + baseURL, + testRunId, + }) => { let clientA: SimulatedE2EClient | null = null; let clientB: SimulatedE2EClient | null = null; @@ -390,8 +366,7 @@ base.describe('@supersync SuperSync Models', () => { } }); - base('Project notes sync correctly', async ({ browser, baseURL }, testInfo) => { - const testRunId = generateTestRunId(testInfo.workerIndex); + test('Project notes sync correctly', async ({ browser, baseURL, testRunId }) => { let clientA: SimulatedE2EClient | null = null; let clientB: SimulatedE2EClient | null = null; @@ -519,212 +494,202 @@ base.describe('@supersync SuperSync Models', () => { * - All tasks exist but NO task has the deleted tag * - Changes were atomic (single operation in sync) */ - base( - 'Tag deletion atomically removes tag from many tasks', - async ({ browser, baseURL }, testInfo) => { - testInfo.setTimeout(180000); - const testRunId = generateTestRunId(testInfo.workerIndex); - let clientA: SimulatedE2EClient | null = null; - let clientB: SimulatedE2EClient | null = null; + test('Tag deletion atomically removes tag from many tasks', async ({ + browser, + baseURL, + testRunId, + }, testInfo) => { + testInfo.setTimeout(180000); + let clientA: SimulatedE2EClient | null = null; + let clientB: SimulatedE2EClient | null = null; - try { - const user = await createTestUser(testRunId); - const syncConfig = getSuperSyncConfig(user); + try { + const user = await createTestUser(testRunId); + const syncConfig = getSuperSyncConfig(user); - clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); - await clientA.sync.setupSuperSync(syncConfig); + clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); + await clientA.sync.setupSuperSync(syncConfig); - const tagName = `BulkTag-${testRunId}`; - const taskCount = 10; - const taskNames: string[] = []; + const tagName = `BulkTag-${testRunId}`; + const taskCount = 10; + const taskNames: string[] = []; - // 1. Create tag first - await createTagReliably(clientA.page, tagName); - console.log(`[BulkTag] Created tag: ${tagName}`); + // 1. Create tag first + await createTagReliably(clientA.page, tagName); + console.log(`[BulkTag] Created tag: ${tagName}`); - // Go back to Today view - await clientA.page.goto('/#/tag/TODAY/work'); - await clientA.page.waitForLoadState('networkidle'); + // Go back to Today view + await clientA.page.goto('/#/tag/TODAY/work'); + await clientA.page.waitForLoadState('networkidle'); - // 2. Create tasks with the tag using short syntax: "task name #tagname" - // This is much faster and more reliable than using the context menu - for (let i = 0; i < taskCount; i++) { - const taskName = `TaggedTask-${testRunId}-${i.toString().padStart(2, '0')}`; - taskNames.push(taskName); + // 2. Create tasks with the tag using short syntax: "task name #tagname" + // This is much faster and more reliable than using the context menu + for (let i = 0; i < taskCount; i++) { + const taskName = `TaggedTask-${testRunId}-${i.toString().padStart(2, '0')}`; + taskNames.push(taskName); - // Create task with tag using short syntax - await clientA.workView.addTask(`${taskName} #${tagName}`); - await waitForTask(clientA.page, taskName); + // Create task with tag using short syntax + await clientA.workView.addTask(`${taskName} #${tagName}`); + await waitForTask(clientA.page, taskName); - // Verify tag is on task - const taskLocator = clientA.page - .locator(`task:has-text("${taskName}")`) - .first(); - await expect(taskLocator).toContainText(tagName); + // Verify tag is on task + const taskLocator = clientA.page.locator(`task:has-text("${taskName}")`).first(); + await expect(taskLocator).toContainText(tagName); - if ((i + 1) % 5 === 0) { - console.log(`[BulkTag] Created and tagged ${i + 1}/${taskCount} tasks`); - } + if ((i + 1) % 5 === 0) { + console.log(`[BulkTag] Created and tagged ${i + 1}/${taskCount} tasks`); } - console.log(`[BulkTag] Created ${taskCount} tasks with tag`); - - // 3. Client A syncs - await clientA.sync.syncAndWait(); - console.log('[BulkTag] Client A synced'); - - // 4. Client B syncs - clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); - await clientB.sync.setupSuperSync(syncConfig); - await clientB.sync.syncAndWait(); - console.log('[BulkTag] Client B synced'); - - // Verify B has the tag in sidebar - const tagsTreeB = clientB.page - .locator('nav-list-tree') - .filter({ hasText: 'Tags' }) - .first(); - await tagsTreeB.waitFor({ state: 'visible' }); - await expect( - clientB.page.locator(`nav-list-tree:has-text("${tagName}")`), - ).toBeVisible(); - console.log('[BulkTag] Client B sees the tag'); - - // 5. Client A deletes the tag - // Navigate to tag settings to delete it - await clientA.page.goto('/#/tag/TODAY/work'); - await clientA.page.waitForLoadState('networkidle'); - - const tagsTreeA = clientA.page - .locator('nav-list-tree') - .filter({ hasText: 'Tags' }) - .first(); - await tagsTreeA.waitFor({ state: 'visible' }); - - // Find the tag nav item and right-click to get context menu - const groupNavItemA = tagsTreeA.locator('nav-item').first(); - const expandBtnA = groupNavItemA - .locator('button.expand-btn, button.arrow-btn') - .first(); - if (await expandBtnA.isVisible()) { - const isExpanded = await expandBtnA.getAttribute('aria-expanded'); - if (isExpanded !== 'true') { - await groupNavItemA.click(); - await clientA.page.waitForTimeout(500); - } - } - - const tagNavItem = tagsTreeA - .locator('nav-item') - .filter({ hasText: tagName }) - .first(); - await tagNavItem.waitFor({ state: 'visible' }); - - // Right-click on tag to get context menu - await tagNavItem.click({ button: 'right' }); - await clientA.page.waitForTimeout(300); - - // Find and click delete option - const deleteBtn = clientA.page - .locator('.mat-mdc-menu-panel button') - .filter({ hasText: /delete/i }) - .first(); - await deleteBtn.waitFor({ state: 'visible', timeout: 5000 }); - await deleteBtn.click(); - - // Confirm deletion if there's a dialog - const confirmBtn = clientA.page - .locator('mat-dialog-container button') - .filter({ hasText: /delete|confirm|yes/i }) - .first(); - try { - await confirmBtn.waitFor({ state: 'visible', timeout: 3000 }); - await confirmBtn.click(); - await clientA.page.waitForTimeout(500); - } catch { - // No confirmation dialog, that's fine - } - - console.log('[BulkTag] Tag deleted on Client A'); - - // 6. Client A syncs - await clientA.sync.syncAndWait(); - console.log('[BulkTag] Client A synced after tag deletion'); - - // 7. Client B syncs - await clientB.sync.syncAndWait(); - console.log('[BulkTag] Client B synced'); - - // Wait for state to settle - await clientA.page.waitForTimeout(1000); - await clientB.page.waitForTimeout(1000); - - // Navigate both clients to Today view - await clientA.page.goto('/#/tag/TODAY/work'); - await clientA.page.waitForLoadState('networkidle'); - await clientB.page.goto('/#/tag/TODAY/work'); - await clientB.page.waitForLoadState('networkidle'); - - // VERIFICATION: Tag should be gone on both clients - console.log('[BulkTag] Verifying tag is gone...'); - - // Check that the tag name doesn't appear in the nav (it should be deleted) - // We check in the Tags section specifically - const tagItemA = clientA.page - .locator('nav-list-tree') - .filter({ hasText: 'Tags' }) - .locator(`nav-item:has-text("${tagName}")`); - const tagItemB = clientB.page - .locator('nav-list-tree') - .filter({ hasText: 'Tags' }) - .locator(`nav-item:has-text("${tagName}")`); - - await expect(tagItemA).not.toBeVisible({ timeout: 5000 }); - await expect(tagItemB).not.toBeVisible({ timeout: 5000 }); - console.log('[BulkTag] ✓ Tag is gone on both clients'); - - // VERIFICATION: All tasks still exist but WITHOUT the tag - console.log('[BulkTag] Verifying tasks exist without the tag...'); - - // Check first few tasks on Client A - for (let i = 0; i < 5; i++) { - const taskName = taskNames[i]; - const taskLocatorA = clientA.page - .locator(`task:has-text("${taskName}")`) - .first(); - await expect(taskLocatorA).toBeVisible({ timeout: 5000 }); - // Task should NOT contain the tag name - await expect(taskLocatorA).not.toContainText(tagName); - } - console.log('[BulkTag] ✓ Tasks on Client A exist without tag'); - - // Check first few tasks on Client B - for (let i = 0; i < 5; i++) { - const taskName = taskNames[i]; - const taskLocatorB = clientB.page - .locator(`task:has-text("${taskName}")`) - .first(); - await expect(taskLocatorB).toBeVisible({ timeout: 5000 }); - // Task should NOT contain the tag name - await expect(taskLocatorB).not.toContainText(tagName); - } - console.log('[BulkTag] ✓ Tasks on Client B exist without tag'); - - // Verify task count matches - const countA = await clientA.page - .locator(`task:has-text("${testRunId}")`) - .count(); - const countB = await clientB.page - .locator(`task:has-text("${testRunId}")`) - .count(); - expect(countA).toBe(countB); - expect(countA).toBeGreaterThanOrEqual(taskCount); - console.log(`[BulkTag] ✓ Both clients have ${countA} tasks`); - - console.log('[BulkTag] ✓ Tag deletion atomic cleanup test PASSED!'); - } finally { - if (clientA) await closeClient(clientA); - if (clientB) await closeClient(clientB); } - }, - ); + console.log(`[BulkTag] Created ${taskCount} tasks with tag`); + + // 3. Client A syncs + await clientA.sync.syncAndWait(); + console.log('[BulkTag] Client A synced'); + + // 4. Client B syncs + clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); + await clientB.sync.setupSuperSync(syncConfig); + await clientB.sync.syncAndWait(); + console.log('[BulkTag] Client B synced'); + + // Verify B has the tag in sidebar + const tagsTreeB = clientB.page + .locator('nav-list-tree') + .filter({ hasText: 'Tags' }) + .first(); + await tagsTreeB.waitFor({ state: 'visible' }); + await expect( + clientB.page.locator(`nav-list-tree:has-text("${tagName}")`), + ).toBeVisible(); + console.log('[BulkTag] Client B sees the tag'); + + // 5. Client A deletes the tag + // Navigate to tag settings to delete it + await clientA.page.goto('/#/tag/TODAY/work'); + await clientA.page.waitForLoadState('networkidle'); + + const tagsTreeA = clientA.page + .locator('nav-list-tree') + .filter({ hasText: 'Tags' }) + .first(); + await tagsTreeA.waitFor({ state: 'visible' }); + + // Find the tag nav item and right-click to get context menu + const groupNavItemA = tagsTreeA.locator('nav-item').first(); + const expandBtnA = groupNavItemA + .locator('button.expand-btn, button.arrow-btn') + .first(); + if (await expandBtnA.isVisible()) { + const isExpanded = await expandBtnA.getAttribute('aria-expanded'); + if (isExpanded !== 'true') { + await groupNavItemA.click(); + await clientA.page.waitForTimeout(500); + } + } + + const tagNavItem = tagsTreeA + .locator('nav-item') + .filter({ hasText: tagName }) + .first(); + await tagNavItem.waitFor({ state: 'visible' }); + + // Right-click on tag to get context menu + await tagNavItem.click({ button: 'right' }); + await clientA.page.waitForTimeout(300); + + // Find and click delete option + const deleteBtn = clientA.page + .locator('.mat-mdc-menu-panel button') + .filter({ hasText: /delete/i }) + .first(); + await deleteBtn.waitFor({ state: 'visible', timeout: 5000 }); + await deleteBtn.click(); + + // Confirm deletion if there's a dialog + const confirmBtn = clientA.page + .locator('mat-dialog-container button') + .filter({ hasText: /delete|confirm|yes/i }) + .first(); + try { + await confirmBtn.waitFor({ state: 'visible', timeout: 3000 }); + await confirmBtn.click(); + await clientA.page.waitForTimeout(500); + } catch { + // No confirmation dialog, that's fine + } + + console.log('[BulkTag] Tag deleted on Client A'); + + // 6. Client A syncs + await clientA.sync.syncAndWait(); + console.log('[BulkTag] Client A synced after tag deletion'); + + // 7. Client B syncs + await clientB.sync.syncAndWait(); + console.log('[BulkTag] Client B synced'); + + // Wait for state to settle + await clientA.page.waitForTimeout(1000); + await clientB.page.waitForTimeout(1000); + + // Navigate both clients to Today view + await clientA.page.goto('/#/tag/TODAY/work'); + await clientA.page.waitForLoadState('networkidle'); + await clientB.page.goto('/#/tag/TODAY/work'); + await clientB.page.waitForLoadState('networkidle'); + + // VERIFICATION: Tag should be gone on both clients + console.log('[BulkTag] Verifying tag is gone...'); + + // Check that the tag name doesn't appear in the nav (it should be deleted) + // We check in the Tags section specifically + const tagItemA = clientA.page + .locator('nav-list-tree') + .filter({ hasText: 'Tags' }) + .locator(`nav-item:has-text("${tagName}")`); + const tagItemB = clientB.page + .locator('nav-list-tree') + .filter({ hasText: 'Tags' }) + .locator(`nav-item:has-text("${tagName}")`); + + await expect(tagItemA).not.toBeVisible({ timeout: 5000 }); + await expect(tagItemB).not.toBeVisible({ timeout: 5000 }); + console.log('[BulkTag] ✓ Tag is gone on both clients'); + + // VERIFICATION: All tasks still exist but WITHOUT the tag + console.log('[BulkTag] Verifying tasks exist without the tag...'); + + // Check first few tasks on Client A + for (let i = 0; i < 5; i++) { + const taskName = taskNames[i]; + const taskLocatorA = clientA.page.locator(`task:has-text("${taskName}")`).first(); + await expect(taskLocatorA).toBeVisible({ timeout: 5000 }); + // Task should NOT contain the tag name + await expect(taskLocatorA).not.toContainText(tagName); + } + console.log('[BulkTag] ✓ Tasks on Client A exist without tag'); + + // Check first few tasks on Client B + for (let i = 0; i < 5; i++) { + const taskName = taskNames[i]; + const taskLocatorB = clientB.page.locator(`task:has-text("${taskName}")`).first(); + await expect(taskLocatorB).toBeVisible({ timeout: 5000 }); + // Task should NOT contain the tag name + await expect(taskLocatorB).not.toContainText(tagName); + } + console.log('[BulkTag] ✓ Tasks on Client B exist without tag'); + + // Verify task count matches + const countA = await clientA.page.locator(`task:has-text("${testRunId}")`).count(); + const countB = await clientB.page.locator(`task:has-text("${testRunId}")`).count(); + expect(countA).toBe(countB); + expect(countA).toBeGreaterThanOrEqual(taskCount); + console.log(`[BulkTag] ✓ Both clients have ${countA} tasks`); + + console.log('[BulkTag] ✓ Tag deletion atomic cleanup test PASSED!'); + } finally { + if (clientA) await closeClient(clientA); + if (clientB) await closeClient(clientB); + } + }); }); diff --git a/e2e/tests/sync/supersync-network-failure.spec.ts b/e2e/tests/sync/supersync-network-failure.spec.ts index 2dc412f72..63c11b624 100644 --- a/e2e/tests/sync/supersync-network-failure.spec.ts +++ b/e2e/tests/sync/supersync-network-failure.spec.ts @@ -1,11 +1,10 @@ -import { test as base, expect } from '@playwright/test'; +import { test, expect } from '../../fixtures/supersync.fixture'; import { createTestUser, getSuperSyncConfig, createSimulatedClient, closeClient, waitForTask, - isServerHealthy, type SimulatedE2EClient, } from '../../utils/supersync-helpers'; @@ -26,25 +25,7 @@ import { * Run with: npm run e2e:playwright:file e2e/tests/sync/supersync-network-failure.spec.ts */ -const generateTestRunId = (workerIndex: number): string => { - return `${Date.now()}-${workerIndex}`; -}; - -base.describe('@supersync Network Failure Recovery', () => { - let serverHealthy: boolean | null = null; - - base.beforeEach(async ({}, testInfo) => { - if (serverHealthy === null) { - serverHealthy = await isServerHealthy(); - if (!serverHealthy) { - console.warn( - 'SuperSync server not healthy at http://localhost:1901 - skipping tests', - ); - } - } - testInfo.skip(!serverHealthy, 'SuperSync server not running'); - }); - +test.describe('@supersync Network Failure Recovery', () => { /** * Test: Upload failure and retry * @@ -54,74 +35,74 @@ base.describe('@supersync Network Failure Recovery', () => { * 3. Second sync attempt succeeds * 4. Client B receives the tasks */ - base( - 'recovers from upload failure and retries successfully', - async ({ browser, baseURL }, testInfo) => { - const testRunId = generateTestRunId(testInfo.workerIndex); - let clientA: SimulatedE2EClient | null = null; - let clientB: SimulatedE2EClient | null = null; - let failNextUpload = true; + test('recovers from upload failure and retries successfully', async ({ + browser, + baseURL, + testRunId, + }) => { + let clientA: SimulatedE2EClient | null = null; + let clientB: SimulatedE2EClient | null = null; + let failNextUpload = true; - try { - const user = await createTestUser(testRunId); - const syncConfig = getSuperSyncConfig(user); + try { + const user = await createTestUser(testRunId); + const syncConfig = getSuperSyncConfig(user); - // Set up Client A - clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); - await clientA.sync.setupSuperSync(syncConfig); + // Set up Client A + clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); + await clientA.sync.setupSuperSync(syncConfig); - // Create tasks - const taskName = `Task-${testRunId}-upload-test`; - await clientA.workView.addTask(taskName); + // Create tasks + const taskName = `Task-${testRunId}-upload-test`; + await clientA.workView.addTask(taskName); - // Set up route interception to fail first upload - await clientA.page.route('**/api/sync/ops/**', async (route) => { - if (failNextUpload && route.request().method() === 'POST') { - failNextUpload = false; - console.log('[Test] Simulating upload failure'); - await route.abort('failed'); - } else { - await route.continue(); - } - }); - - // First sync attempt - should fail - try { - await clientA.sync.triggerSync(); - // Wait a bit for the failure to be processed - await clientA.page.waitForTimeout(2000); - } catch { - // Expected to fail - console.log('[Test] First sync failed as expected'); + // Set up route interception to fail first upload + await clientA.page.route('**/api/sync/ops/**', async (route) => { + if (failNextUpload && route.request().method() === 'POST') { + failNextUpload = false; + console.log('[Test] Simulating upload failure'); + await route.abort('failed'); + } else { + await route.continue(); } + }); - // Remove the failing route - await clientA.page.unroute('**/api/sync/ops/**'); - - // Second sync attempt - should succeed - await clientA.sync.syncAndWait(); - - // Verify task still exists on Client A - await waitForTask(clientA.page, taskName); - - // Set up Client B - clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); - await clientB.sync.setupSuperSync(syncConfig); - - // Client B syncs - await clientB.sync.syncAndWait(); - - // Verify Client B received the task - await waitForTask(clientB.page, taskName); - - const taskLocatorB = clientB.page.locator(`task:has-text("${taskName}")`); - await expect(taskLocatorB).toBeVisible(); - } finally { - if (clientA) await closeClient(clientA); - if (clientB) await closeClient(clientB); + // First sync attempt - should fail + try { + await clientA.sync.triggerSync(); + // Wait a bit for the failure to be processed + await clientA.page.waitForTimeout(2000); + } catch { + // Expected to fail + console.log('[Test] First sync failed as expected'); } - }, - ); + + // Remove the failing route + await clientA.page.unroute('**/api/sync/ops/**'); + + // Second sync attempt - should succeed + await clientA.sync.syncAndWait(); + + // Verify task still exists on Client A + await waitForTask(clientA.page, taskName); + + // Set up Client B + clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); + await clientB.sync.setupSuperSync(syncConfig); + + // Client B syncs + await clientB.sync.syncAndWait(); + + // Verify Client B received the task + await waitForTask(clientB.page, taskName); + + const taskLocatorB = clientB.page.locator(`task:has-text("${taskName}")`); + await expect(taskLocatorB).toBeVisible(); + } finally { + if (clientA) await closeClient(clientA); + if (clientB) await closeClient(clientB); + } + }); /** * Test: Download failure and retry @@ -131,8 +112,7 @@ base.describe('@supersync Network Failure Recovery', () => { * 2. Client B's first download fails * 3. Client B retries and succeeds */ - base('recovers from download failure', async ({ browser, baseURL }, testInfo) => { - const testRunId = generateTestRunId(testInfo.workerIndex); + test('recovers from download failure', async ({ browser, baseURL, testRunId }) => { let clientA: SimulatedE2EClient | null = null; let clientB: SimulatedE2EClient | null = null; let failNextDownload = true; @@ -207,79 +187,79 @@ base.describe('@supersync Network Failure Recovery', () => { * 2. First sync returns 500 error * 3. Second sync succeeds */ - base( - 'handles server error (500) and retries', - async ({ browser, baseURL }, testInfo) => { - const testRunId = generateTestRunId(testInfo.workerIndex); - let clientA: SimulatedE2EClient | null = null; - let clientB: SimulatedE2EClient | null = null; + test('handles server error (500) and retries', async ({ + browser, + baseURL, + testRunId, + }) => { + let clientA: SimulatedE2EClient | null = null; + let clientB: SimulatedE2EClient | null = null; - // Use object to ensure mutable reference is captured correctly - const state = { returnServerError: true }; + // Use object to ensure mutable reference is captured correctly + const state = { returnServerError: true }; + try { + const user = await createTestUser(testRunId); + const syncConfig = getSuperSyncConfig(user); + + clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); + await clientA.sync.setupSuperSync(syncConfig); + + const taskName = `Task-${testRunId}-server-error`; + await clientA.workView.addTask(taskName); + // Wait for task to be fully created in store + await waitForTask(clientA.page, taskName); + + // Intercept and return 500 error on first request + await clientA.page.route('**/api/sync/ops/**', async (route) => { + if (state.returnServerError && route.request().method() === 'POST') { + state.returnServerError = false; + console.log('[Test] Simulating 500 server error'); + await route.fulfill({ + status: 500, + contentType: 'application/json', + body: JSON.stringify({ error: 'Internal Server Error' }), + }); + } else { + await route.continue(); + } + }); + + // First sync - server error try { - const user = await createTestUser(testRunId); - const syncConfig = getSuperSyncConfig(user); - - clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); - await clientA.sync.setupSuperSync(syncConfig); - - const taskName = `Task-${testRunId}-server-error`; - await clientA.workView.addTask(taskName); - // Wait for task to be fully created in store - await waitForTask(clientA.page, taskName); - - // Intercept and return 500 error on first request - await clientA.page.route('**/api/sync/ops/**', async (route) => { - if (state.returnServerError && route.request().method() === 'POST') { - state.returnServerError = false; - console.log('[Test] Simulating 500 server error'); - await route.fulfill({ - status: 500, - contentType: 'application/json', - body: JSON.stringify({ error: 'Internal Server Error' }), - }); - } else { - await route.continue(); - } - }); - - // First sync - server error - try { - await clientA.sync.triggerSync(); - // Wait for the error to be processed - await clientA.page.waitForTimeout(3000); - } catch { - console.log('[Test] First sync got server error as expected'); - } - - // Remove interception before retry - await clientA.page.unroute('**/api/sync/ops/**'); - // Give time for route to be fully removed - await clientA.page.waitForTimeout(500); - - // Retry - should succeed now - await clientA.sync.syncAndWait(); - console.log('[Test] Retry sync succeeded'); - - // Verify with Client B - clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); - await clientB.sync.setupSuperSync(syncConfig); - await clientB.sync.syncAndWait(); - - await waitForTask(clientB.page, taskName); - const taskLocator = clientB.page.locator(`task:has-text("${taskName}")`); - await expect(taskLocator).toBeVisible(); - } finally { - // Ensure routes are cleaned up - if (clientA) { - await clientA.page.unroute('**/api/sync/ops/**').catch(() => {}); - } - if (clientA) await closeClient(clientA); - if (clientB) await closeClient(clientB); + await clientA.sync.triggerSync(); + // Wait for the error to be processed + await clientA.page.waitForTimeout(3000); + } catch { + console.log('[Test] First sync got server error as expected'); } - }, - ); + + // Remove interception before retry + await clientA.page.unroute('**/api/sync/ops/**'); + // Give time for route to be fully removed + await clientA.page.waitForTimeout(500); + + // Retry - should succeed now + await clientA.sync.syncAndWait(); + console.log('[Test] Retry sync succeeded'); + + // Verify with Client B + clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); + await clientB.sync.setupSuperSync(syncConfig); + await clientB.sync.syncAndWait(); + + await waitForTask(clientB.page, taskName); + const taskLocator = clientB.page.locator(`task:has-text("${taskName}")`); + await expect(taskLocator).toBeVisible(); + } finally { + // Ensure routes are cleaned up + if (clientA) { + await clientA.page.unroute('**/api/sync/ops/**').catch(() => {}); + } + if (clientA) await closeClient(clientA); + if (clientB) await closeClient(clientB); + } + }); /** * Test: Multiple tasks sync correctly after network recovery @@ -289,90 +269,90 @@ base.describe('@supersync Network Failure Recovery', () => { * 2. Network recovers * 3. All tasks sync to Client B */ - base( - 'syncs all pending operations after network recovery', - async ({ browser, baseURL }, testInfo) => { - const testRunId = generateTestRunId(testInfo.workerIndex); - let clientA: SimulatedE2EClient | null = null; - let clientB: SimulatedE2EClient | null = null; + test('syncs all pending operations after network recovery', async ({ + browser, + baseURL, + testRunId, + }) => { + let clientA: SimulatedE2EClient | null = null; + let clientB: SimulatedE2EClient | null = null; - // Use object to ensure mutable reference is captured correctly - const state = { blockAllSyncRequests: true }; + // Use object to ensure mutable reference is captured correctly + const state = { blockAllSyncRequests: true }; - try { - const user = await createTestUser(testRunId); - const syncConfig = getSuperSyncConfig(user); + try { + const user = await createTestUser(testRunId); + const syncConfig = getSuperSyncConfig(user); - clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); - await clientA.sync.setupSuperSync(syncConfig); + clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); + await clientA.sync.setupSuperSync(syncConfig); - // Block all sync requests - await clientA.page.route('**/api/sync/**', async (route) => { - if (state.blockAllSyncRequests) { - console.log('[Test] Blocking sync request'); - await route.abort('failed'); - } else { - await route.continue(); - } - }); - - // Create multiple tasks while "offline" - const taskNames = [ - `Task-${testRunId}-offline-1`, - `Task-${testRunId}-offline-2`, - `Task-${testRunId}-offline-3`, - ]; - - for (const taskName of taskNames) { - await clientA.workView.addTask(taskName); - // Ensure task is created before adding next one - await waitForTask(clientA.page, taskName); + // Block all sync requests + await clientA.page.route('**/api/sync/**', async (route) => { + if (state.blockAllSyncRequests) { + console.log('[Test] Blocking sync request'); + await route.abort('failed'); + } else { + await route.continue(); } + }); - // Try to sync (will fail due to route blocking) - try { - await clientA.sync.triggerSync(); - await clientA.page.waitForTimeout(2000); - } catch { - console.log('[Test] Sync blocked as expected'); - } + // Create multiple tasks while "offline" + const taskNames = [ + `Task-${testRunId}-offline-1`, + `Task-${testRunId}-offline-2`, + `Task-${testRunId}-offline-3`, + ]; - // "Restore network" - unblock requests and remove route - state.blockAllSyncRequests = false; - await clientA.page.unroute('**/api/sync/**'); - // Give time for route to be fully removed - await clientA.page.waitForTimeout(500); - - // Sync should now succeed with all pending operations - await clientA.sync.syncAndWait(); - console.log('[Test] Sync after network recovery succeeded'); - - // Verify all tasks on Client A - for (const taskName of taskNames) { - await waitForTask(clientA.page, taskName); - } - - // Verify on Client B - clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); - await clientB.sync.setupSuperSync(syncConfig); - await clientB.sync.syncAndWait(); - - // All tasks should be present on Client B - for (const taskName of taskNames) { - await waitForTask(clientB.page, taskName); - const taskLocator = clientB.page.locator(`task:has-text("${taskName}")`); - await expect(taskLocator).toBeVisible(); - } - } finally { - // Ensure routes are cleaned up - if (clientA) { - await clientA.page.unroute('**/api/sync/**').catch(() => {}); - } - if (clientA) await closeClient(clientA); - if (clientB) await closeClient(clientB); + for (const taskName of taskNames) { + await clientA.workView.addTask(taskName); + // Ensure task is created before adding next one + await waitForTask(clientA.page, taskName); } - }, - ); + + // Try to sync (will fail due to route blocking) + try { + await clientA.sync.triggerSync(); + await clientA.page.waitForTimeout(2000); + } catch { + console.log('[Test] Sync blocked as expected'); + } + + // "Restore network" - unblock requests and remove route + state.blockAllSyncRequests = false; + await clientA.page.unroute('**/api/sync/**'); + // Give time for route to be fully removed + await clientA.page.waitForTimeout(500); + + // Sync should now succeed with all pending operations + await clientA.sync.syncAndWait(); + console.log('[Test] Sync after network recovery succeeded'); + + // Verify all tasks on Client A + for (const taskName of taskNames) { + await waitForTask(clientA.page, taskName); + } + + // Verify on Client B + clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); + await clientB.sync.setupSuperSync(syncConfig); + await clientB.sync.syncAndWait(); + + // All tasks should be present on Client B + for (const taskName of taskNames) { + await waitForTask(clientB.page, taskName); + const taskLocator = clientB.page.locator(`task:has-text("${taskName}")`); + await expect(taskLocator).toBeVisible(); + } + } finally { + // Ensure routes are cleaned up + if (clientA) { + await clientA.page.unroute('**/api/sync/**').catch(() => {}); + } + if (clientA) await closeClient(clientA); + if (clientB) await closeClient(clientB); + } + }); /** * Test: Partial batch upload failure followed by successful retry @@ -386,119 +366,119 @@ base.describe('@supersync Network Failure Recovery', () => { * This verifies that the operation log correctly tracks which ops * have been synced vs pending, and retry doesn't create duplicates. */ - base( - 'partial batch upload failure followed by retry uploads all without duplicates', - async ({ browser, baseURL }, testInfo) => { - testInfo.setTimeout(180000); - const testRunId = generateTestRunId(testInfo.workerIndex); - let clientA: SimulatedE2EClient | null = null; - let clientB: SimulatedE2EClient | null = null; + test('partial batch upload failure followed by retry uploads all without duplicates', async ({ + browser, + baseURL, + testRunId, + }) => { + test.setTimeout(180000); + let clientA: SimulatedE2EClient | null = null; + let clientB: SimulatedE2EClient | null = null; - // Track how many POST requests we've seen - const state = { - requestCount: 0, - failAfter: 2, // Fail after 2 successful requests - }; + // Track how many POST requests we've seen + const state = { + requestCount: 0, + failAfter: 2, // Fail after 2 successful requests + }; - try { - const user = await createTestUser(testRunId); - const syncConfig = getSuperSyncConfig(user); + try { + const user = await createTestUser(testRunId); + const syncConfig = getSuperSyncConfig(user); - clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); - await clientA.sync.setupSuperSync(syncConfig); + clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); + await clientA.sync.setupSuperSync(syncConfig); - // Create 10 tasks rapidly - const taskCount = 10; - const taskNames: string[] = []; - for (let i = 0; i < taskCount; i++) { - const taskName = `Task-${testRunId}-batch-${i.toString().padStart(2, '0')}`; - taskNames.push(taskName); - await clientA.workView.addTask(taskName); - await waitForTask(clientA.page, taskName); - } - console.log(`[PartialBatch] Created ${taskCount} tasks on Client A`); + // Create 10 tasks rapidly + const taskCount = 10; + const taskNames: string[] = []; + for (let i = 0; i < taskCount; i++) { + const taskName = `Task-${testRunId}-batch-${i.toString().padStart(2, '0')}`; + taskNames.push(taskName); + await clientA.workView.addTask(taskName); + await waitForTask(clientA.page, taskName); + } + console.log(`[PartialBatch] Created ${taskCount} tasks on Client A`); - // Verify all tasks exist locally - for (const taskName of taskNames) { - const taskLocator = clientA.page.locator(`task:has-text("${taskName}")`); - await expect(taskLocator).toBeVisible(); - } + // Verify all tasks exist locally + for (const taskName of taskNames) { + const taskLocator = clientA.page.locator(`task:has-text("${taskName}")`); + await expect(taskLocator).toBeVisible(); + } - // Set up route interception to fail after first few requests - await clientA.page.route('**/api/sync/ops/**', async (route) => { - if (route.request().method() === 'POST') { - state.requestCount++; - if (state.requestCount > state.failAfter) { - console.log( - `[PartialBatch] Failing request #${state.requestCount} (after ${state.failAfter} successes)`, - ); - await route.abort('failed'); - } else { - console.log(`[PartialBatch] Allowing request #${state.requestCount}`); - await route.continue(); - } + // Set up route interception to fail after first few requests + await clientA.page.route('**/api/sync/ops/**', async (route) => { + if (route.request().method() === 'POST') { + state.requestCount++; + if (state.requestCount > state.failAfter) { + console.log( + `[PartialBatch] Failing request #${state.requestCount} (after ${state.failAfter} successes)`, + ); + await route.abort('failed'); } else { + console.log(`[PartialBatch] Allowing request #${state.requestCount}`); await route.continue(); } - }); - - // First sync attempt - will partially succeed then fail - console.log('[PartialBatch] Starting first sync (will partially fail)'); - try { - await clientA.sync.triggerSync(); - await clientA.page.waitForTimeout(3000); - } catch { - console.log('[PartialBatch] First sync failed as expected'); + } else { + await route.continue(); } + }); - // Remove the failing route and reset counter - await clientA.page.unroute('**/api/sync/ops/**'); - await clientA.page.waitForTimeout(500); - console.log('[PartialBatch] Route interception removed'); - - // Retry sync - should succeed and upload remaining ops - console.log('[PartialBatch] Retrying sync'); - await clientA.sync.syncAndWait(); - console.log('[PartialBatch] Retry sync completed'); - - // Verify all tasks still exist on Client A (no data loss) - for (const taskName of taskNames) { - await waitForTask(clientA.page, taskName); - } - console.log('[PartialBatch] All tasks still present on Client A'); - - // Set up Client B - clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); - await clientB.sync.setupSuperSync(syncConfig); - await clientB.sync.syncAndWait(); - - // Verify ALL tasks present on Client B (no missing, no duplicates) - for (const taskName of taskNames) { - await waitForTask(clientB.page, taskName); - const taskLocator = clientB.page.locator(`task:has-text("${taskName}")`); - await expect(taskLocator).toBeVisible(); - } - - // Verify exact count (no duplicates) - const countB = await clientB.page - .locator(`task:has-text("${testRunId}-batch")`) - .count(); - expect(countB).toBe(taskCount); - console.log( - `[PartialBatch] ✓ Client B has exactly ${taskCount} tasks (no duplicates)`, - ); - - console.log('[PartialBatch] ✓ Partial batch failure + retry test PASSED!'); - } finally { - // Ensure routes are cleaned up - if (clientA) { - await clientA.page.unroute('**/api/sync/ops/**').catch(() => {}); - } - if (clientA) await closeClient(clientA); - if (clientB) await closeClient(clientB); + // First sync attempt - will partially succeed then fail + console.log('[PartialBatch] Starting first sync (will partially fail)'); + try { + await clientA.sync.triggerSync(); + await clientA.page.waitForTimeout(3000); + } catch { + console.log('[PartialBatch] First sync failed as expected'); } - }, - ); + + // Remove the failing route and reset counter + await clientA.page.unroute('**/api/sync/ops/**'); + await clientA.page.waitForTimeout(500); + console.log('[PartialBatch] Route interception removed'); + + // Retry sync - should succeed and upload remaining ops + console.log('[PartialBatch] Retrying sync'); + await clientA.sync.syncAndWait(); + console.log('[PartialBatch] Retry sync completed'); + + // Verify all tasks still exist on Client A (no data loss) + for (const taskName of taskNames) { + await waitForTask(clientA.page, taskName); + } + console.log('[PartialBatch] All tasks still present on Client A'); + + // Set up Client B + clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); + await clientB.sync.setupSuperSync(syncConfig); + await clientB.sync.syncAndWait(); + + // Verify ALL tasks present on Client B (no missing, no duplicates) + for (const taskName of taskNames) { + await waitForTask(clientB.page, taskName); + const taskLocator = clientB.page.locator(`task:has-text("${taskName}")`); + await expect(taskLocator).toBeVisible(); + } + + // Verify exact count (no duplicates) + const countB = await clientB.page + .locator(`task:has-text("${testRunId}-batch")`) + .count(); + expect(countB).toBe(taskCount); + console.log( + `[PartialBatch] ✓ Client B has exactly ${taskCount} tasks (no duplicates)`, + ); + + console.log('[PartialBatch] ✓ Partial batch failure + retry test PASSED!'); + } finally { + // Ensure routes are cleaned up + if (clientA) { + await clientA.page.unroute('**/api/sync/ops/**').catch(() => {}); + } + if (clientA) await closeClient(clientA); + if (clientB) await closeClient(clientB); + } + }); /** * Test: Rate limit exceeded (429) response handling @@ -512,84 +492,84 @@ base.describe('@supersync Network Failure Recovery', () => { * * This tests that the client properly handles rate limiting and retries. */ - base( - 'handles rate limit exceeded (429) and retries after delay', - async ({ browser, baseURL }, testInfo) => { - testInfo.setTimeout(120000); - const testRunId = generateTestRunId(testInfo.workerIndex); - let clientA: SimulatedE2EClient | null = null; - let clientB: SimulatedE2EClient | null = null; + test('handles rate limit exceeded (429) and retries after delay', async ({ + browser, + baseURL, + testRunId, + }) => { + test.setTimeout(120000); + let clientA: SimulatedE2EClient | null = null; + let clientB: SimulatedE2EClient | null = null; - const state = { returnRateLimitError: true }; + const state = { returnRateLimitError: true }; + try { + const user = await createTestUser(testRunId); + const syncConfig = getSuperSyncConfig(user); + + clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); + await clientA.sync.setupSuperSync(syncConfig); + + const taskName = `Task-${testRunId}-rate-limit`; + await clientA.workView.addTask(taskName); + await waitForTask(clientA.page, taskName); + + // Intercept and return 429 rate limit error on first request + await clientA.page.route('**/api/sync/ops/**', async (route) => { + if (state.returnRateLimitError && route.request().method() === 'POST') { + state.returnRateLimitError = false; + console.log('[Test] Simulating 429 rate limit exceeded'); + // eslint-disable-next-line @typescript-eslint/naming-convention + const headers = { 'Retry-After': '1' }; // Suggest retry after 1 second + await route.fulfill({ + status: 429, + contentType: 'application/json', + headers, + body: JSON.stringify({ + error: 'Too Many Requests', + code: 'RATE_LIMIT_EXCEEDED', + retryAfter: 1, + }), + }); + } else { + await route.continue(); + } + }); + + // First sync - rate limited try { - const user = await createTestUser(testRunId); - const syncConfig = getSuperSyncConfig(user); - - clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); - await clientA.sync.setupSuperSync(syncConfig); - - const taskName = `Task-${testRunId}-rate-limit`; - await clientA.workView.addTask(taskName); - await waitForTask(clientA.page, taskName); - - // Intercept and return 429 rate limit error on first request - await clientA.page.route('**/api/sync/ops/**', async (route) => { - if (state.returnRateLimitError && route.request().method() === 'POST') { - state.returnRateLimitError = false; - console.log('[Test] Simulating 429 rate limit exceeded'); - // eslint-disable-next-line @typescript-eslint/naming-convention - const headers = { 'Retry-After': '1' }; // Suggest retry after 1 second - await route.fulfill({ - status: 429, - contentType: 'application/json', - headers, - body: JSON.stringify({ - error: 'Too Many Requests', - code: 'RATE_LIMIT_EXCEEDED', - retryAfter: 1, - }), - }); - } else { - await route.continue(); - } - }); - - // First sync - rate limited - try { - await clientA.sync.triggerSync(); - await clientA.page.waitForTimeout(3000); - } catch { - console.log('[Test] First sync got rate limit error as expected'); - } - - // Remove interception before retry - await clientA.page.unroute('**/api/sync/ops/**'); - await clientA.page.waitForTimeout(1500); // Wait longer than Retry-After - - // Retry - should succeed now - await clientA.sync.syncAndWait(); - console.log('[Test] Retry sync succeeded after rate limit'); - - // Verify with Client B - clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); - await clientB.sync.setupSuperSync(syncConfig); - await clientB.sync.syncAndWait(); - - await waitForTask(clientB.page, taskName); - const taskLocator = clientB.page.locator(`task:has-text("${taskName}")`); - await expect(taskLocator).toBeVisible(); - - console.log('[RateLimit] ✓ Rate limit handling test PASSED'); - } finally { - if (clientA) { - await clientA.page.unroute('**/api/sync/ops/**').catch(() => {}); - } - if (clientA) await closeClient(clientA); - if (clientB) await closeClient(clientB); + await clientA.sync.triggerSync(); + await clientA.page.waitForTimeout(3000); + } catch { + console.log('[Test] First sync got rate limit error as expected'); } - }, - ); + + // Remove interception before retry + await clientA.page.unroute('**/api/sync/ops/**'); + await clientA.page.waitForTimeout(1500); // Wait longer than Retry-After + + // Retry - should succeed now + await clientA.sync.syncAndWait(); + console.log('[Test] Retry sync succeeded after rate limit'); + + // Verify with Client B + clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); + await clientB.sync.setupSuperSync(syncConfig); + await clientB.sync.syncAndWait(); + + await waitForTask(clientB.page, taskName); + const taskLocator = clientB.page.locator(`task:has-text("${taskName}")`); + await expect(taskLocator).toBeVisible(); + + console.log('[RateLimit] ✓ Rate limit handling test PASSED'); + } finally { + if (clientA) { + await clientA.page.unroute('**/api/sync/ops/**').catch(() => {}); + } + if (clientA) await closeClient(clientA); + if (clientB) await closeClient(clientB); + } + }); /** * Test: Server quota exceeded response handling @@ -603,86 +583,86 @@ base.describe('@supersync Network Failure Recovery', () => { * This tests that quota exceeded is treated as a transient error * that requires user action, not a permanent rejection. */ - base( - 'handles server storage quota exceeded gracefully', - async ({ browser, baseURL }, testInfo) => { - testInfo.setTimeout(60000); - const testRunId = generateTestRunId(testInfo.workerIndex); - let clientA: SimulatedE2EClient | null = null; + test('handles server storage quota exceeded gracefully', async ({ + browser, + baseURL, + testRunId, + }) => { + test.setTimeout(60000); + let clientA: SimulatedE2EClient | null = null; + try { + const user = await createTestUser(testRunId); + const syncConfig = getSuperSyncConfig(user); + + clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); + await clientA.sync.setupSuperSync(syncConfig); + + // Set up dialog handler to dismiss any alerts BEFORE setting up route + let alertShown = false; + clientA.page.on('dialog', async (dialog) => { + console.log(`[Test] Alert shown: ${dialog.message()}`); + alertShown = true; + await dialog.accept(); + }); + + // Wait for initial sync to complete so we have a baseline + await clientA.sync.syncAndWait(); + + // Intercept and return storage quota exceeded BEFORE creating task + // so the immediate upload service gets the error + await clientA.page.route('**/api/sync/ops', async (route) => { + if (route.request().method() === 'POST') { + console.log('[Test] Simulating storage quota exceeded'); + await route.fulfill({ + status: 413, // Payload too large is typical for quota + contentType: 'application/json', + body: JSON.stringify({ + error: 'Storage quota exceeded', + code: 'STORAGE_QUOTA_EXCEEDED', + rejectedOps: [ + { + opId: 'mock-op-id', + error: 'Storage quota exceeded', + errorCode: 'STORAGE_QUOTA_EXCEEDED', + }, + ], + }), + }); + } else { + await route.continue(); + } + }); + + // Now create a task - the immediate upload will get 413 + const taskName = `Task-${testRunId}-quota`; + await clientA.workView.addTask(taskName); + await waitForTask(clientA.page, taskName); + + // Wait for immediate upload to trigger and hit the 413 try { - const user = await createTestUser(testRunId); - const syncConfig = getSuperSyncConfig(user); - - clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); - await clientA.sync.setupSuperSync(syncConfig); - - // Set up dialog handler to dismiss any alerts BEFORE setting up route - let alertShown = false; - clientA.page.on('dialog', async (dialog) => { - console.log(`[Test] Alert shown: ${dialog.message()}`); - alertShown = true; - await dialog.accept(); - }); - - // Wait for initial sync to complete so we have a baseline - await clientA.sync.syncAndWait(); - - // Intercept and return storage quota exceeded BEFORE creating task - // so the immediate upload service gets the error - await clientA.page.route('**/api/sync/ops', async (route) => { - if (route.request().method() === 'POST') { - console.log('[Test] Simulating storage quota exceeded'); - await route.fulfill({ - status: 413, // Payload too large is typical for quota - contentType: 'application/json', - body: JSON.stringify({ - error: 'Storage quota exceeded', - code: 'STORAGE_QUOTA_EXCEEDED', - rejectedOps: [ - { - opId: 'mock-op-id', - error: 'Storage quota exceeded', - errorCode: 'STORAGE_QUOTA_EXCEEDED', - }, - ], - }), - }); - } else { - await route.continue(); - } - }); - - // Now create a task - the immediate upload will get 413 - const taskName = `Task-${testRunId}-quota`; - await clientA.workView.addTask(taskName); - await waitForTask(clientA.page, taskName); - - // Wait for immediate upload to trigger and hit the 413 - try { - await clientA.sync.triggerSync(); - await clientA.page.waitForTimeout(3000); - } catch { - console.log('[Test] Sync failed with quota exceeded as expected'); - } - - // Task should still exist locally (not lost) - await waitForTask(clientA.page, taskName); - const taskLocator = clientA.page.locator(`task:has-text("${taskName}")`); - await expect(taskLocator).toBeVisible(); - - // Verify an alert was shown (quota exceeded shows window.alert) - expect(alertShown).toBe(true); - - console.log('[StorageQuota] ✓ Server quota exceeded handling test PASSED'); - } finally { - if (clientA) { - await clientA.page.unroute('**/api/sync/ops').catch(() => {}); - } - if (clientA) await closeClient(clientA); + await clientA.sync.triggerSync(); + await clientA.page.waitForTimeout(3000); + } catch { + console.log('[Test] Sync failed with quota exceeded as expected'); } - }, - ); + + // Task should still exist locally (not lost) + await waitForTask(clientA.page, taskName); + const taskLocator = clientA.page.locator(`task:has-text("${taskName}")`); + await expect(taskLocator).toBeVisible(); + + // Verify an alert was shown (quota exceeded shows window.alert) + expect(alertShown).toBe(true); + + console.log('[StorageQuota] ✓ Server quota exceeded handling test PASSED'); + } finally { + if (clientA) { + await clientA.page.unroute('**/api/sync/ops').catch(() => {}); + } + if (clientA) await closeClient(clientA); + } + }); /** * Test: Long offline period simulation @@ -698,97 +678,97 @@ base.describe('@supersync Network Failure Recovery', () => { * This tests that clients can sync after extended offline periods * and receive all accumulated operations without issues. */ - base( - 'syncs correctly after simulated long offline period', - async ({ browser, baseURL }, testInfo) => { - testInfo.setTimeout(180000); - const testRunId = generateTestRunId(testInfo.workerIndex); - let clientA: SimulatedE2EClient | null = null; - let clientB: SimulatedE2EClient | null = null; + test('syncs correctly after simulated long offline period', async ({ + browser, + baseURL, + testRunId, + }) => { + test.setTimeout(180000); + let clientA: SimulatedE2EClient | null = null; + let clientB: SimulatedE2EClient | null = null; - try { - const user = await createTestUser(testRunId); - const syncConfig = getSuperSyncConfig(user); + 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); + // 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); + clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); + await clientB.sync.setupSuperSync(syncConfig); - // Initial sync for both - await clientA.sync.syncAndWait(); - await clientB.sync.syncAndWait(); + // Initial sync for both + await clientA.sync.syncAndWait(); + await clientB.sync.syncAndWait(); - // Simulate Client B going offline - console.log('[LongOffline] Client B goes offline...'); - await clientB.page.route('**/api/sync/**', async (route) => { - await route.abort('failed'); - }); + // Simulate Client B going offline + console.log('[LongOffline] Client B goes offline...'); + await clientB.page.route('**/api/sync/**', async (route) => { + await route.abort('failed'); + }); - // Client A creates many tasks while B is offline - const taskCount = 15; - const taskNames: string[] = []; - for (let i = 0; i < taskCount; i++) { - const taskName = `Offline-Task-${testRunId}-${i.toString().padStart(2, '0')}`; - taskNames.push(taskName); - await clientA.workView.addTask(taskName); - await waitForTask(clientA.page, taskName); + // Client A creates many tasks while B is offline + const taskCount = 15; + const taskNames: string[] = []; + for (let i = 0; i < taskCount; i++) { + const taskName = `Offline-Task-${testRunId}-${i.toString().padStart(2, '0')}`; + taskNames.push(taskName); + await clientA.workView.addTask(taskName); + await waitForTask(clientA.page, taskName); - // Sync after each few tasks to simulate real usage over time - if ((i + 1) % 3 === 0) { - await clientA.sync.syncAndWait(); - console.log(`[LongOffline] Client A synced ${i + 1} tasks`); - } - } - - // Final sync for Client A - await clientA.sync.syncAndWait(); - console.log(`[LongOffline] Client A finished syncing all ${taskCount} tasks`); - - // Verify all tasks on Client A - for (const taskName of taskNames) { - await waitForTask(clientA.page, taskName); - } - - // Client B comes back online after "long" period - console.log('[LongOffline] Client B comes back online...'); - await clientB.page.unroute('**/api/sync/**'); - await clientB.page.waitForTimeout(500); - - // Client B syncs - should receive all accumulated operations - await clientB.sync.syncAndWait(); - - // May need multiple syncs if there are many operations (pagination) - await clientB.sync.syncAndWait(); - await clientB.sync.syncAndWait(); - - // Verify ALL tasks are now on Client B - for (const taskName of taskNames) { - await waitForTask(clientB.page, taskName); - const taskLocator = clientB.page.locator(`task:has-text("${taskName}")`); - await expect(taskLocator).toBeVisible(); - } - - // Verify exact count - const countB = await clientB.page - .locator(`task:has-text("Offline-Task-${testRunId}")`) - .count(); - expect(countB).toBe(taskCount); - - console.log( - `[LongOffline] ✓ Client B received all ${taskCount} tasks after coming back online`, - ); - } finally { - if (clientA) await closeClient(clientA); - if (clientB) { - await clientB.page.unroute('**/api/sync/**').catch(() => {}); - await closeClient(clientB); + // Sync after each few tasks to simulate real usage over time + if ((i + 1) % 3 === 0) { + await clientA.sync.syncAndWait(); + console.log(`[LongOffline] Client A synced ${i + 1} tasks`); } } - }, - ); + + // Final sync for Client A + await clientA.sync.syncAndWait(); + console.log(`[LongOffline] Client A finished syncing all ${taskCount} tasks`); + + // Verify all tasks on Client A + for (const taskName of taskNames) { + await waitForTask(clientA.page, taskName); + } + + // Client B comes back online after "long" period + console.log('[LongOffline] Client B comes back online...'); + await clientB.page.unroute('**/api/sync/**'); + await clientB.page.waitForTimeout(500); + + // Client B syncs - should receive all accumulated operations + await clientB.sync.syncAndWait(); + + // May need multiple syncs if there are many operations (pagination) + await clientB.sync.syncAndWait(); + await clientB.sync.syncAndWait(); + + // Verify ALL tasks are now on Client B + for (const taskName of taskNames) { + await waitForTask(clientB.page, taskName); + const taskLocator = clientB.page.locator(`task:has-text("${taskName}")`); + await expect(taskLocator).toBeVisible(); + } + + // Verify exact count + const countB = await clientB.page + .locator(`task:has-text("Offline-Task-${testRunId}")`) + .count(); + expect(countB).toBe(taskCount); + + console.log( + `[LongOffline] ✓ Client B received all ${taskCount} tasks after coming back online`, + ); + } finally { + if (clientA) await closeClient(clientA); + if (clientB) { + await clientB.page.unroute('**/api/sync/**').catch(() => {}); + await closeClient(clientB); + } + } + }); /** * Test: Malformed JSON response handling @@ -801,78 +781,78 @@ base.describe('@supersync Network Failure Recovery', () => { * * This tests that the client handles JSON parsing failures gracefully. */ - base( - 'handles malformed JSON response gracefully', - async ({ browser, baseURL }, testInfo) => { - testInfo.setTimeout(120000); - const testRunId = generateTestRunId(testInfo.workerIndex); - let clientA: SimulatedE2EClient | null = null; - let clientB: SimulatedE2EClient | null = null; + test('handles malformed JSON response gracefully', async ({ + browser, + baseURL, + testRunId, + }) => { + test.setTimeout(120000); + let clientA: SimulatedE2EClient | null = null; + let clientB: SimulatedE2EClient | null = null; - const state = { returnMalformedJson: true }; + const state = { returnMalformedJson: true }; + try { + const user = await createTestUser(testRunId); + const syncConfig = getSuperSyncConfig(user); + + // Client A creates and syncs a task + clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); + await clientA.sync.setupSuperSync(syncConfig); + + const taskName = `Task-${testRunId}-malformed`; + await clientA.workView.addTask(taskName); + await clientA.sync.syncAndWait(); + await waitForTask(clientA.page, taskName); + + // Client B setup + clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); + await clientB.sync.setupSuperSync(syncConfig); + + // Intercept and return malformed JSON on first GET + await clientB.page.route('**/api/sync/ops/**', async (route) => { + if (state.returnMalformedJson && route.request().method() === 'GET') { + state.returnMalformedJson = false; + console.log('[Test] Simulating malformed JSON response'); + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: '{ "ops": [ { "id": "op-1", "malformed', // Intentionally broken JSON + }); + } else { + await route.continue(); + } + }); + + // First sync - should fail due to JSON parse error try { - const user = await createTestUser(testRunId); - const syncConfig = getSuperSyncConfig(user); - - // Client A creates and syncs a task - clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); - await clientA.sync.setupSuperSync(syncConfig); - - const taskName = `Task-${testRunId}-malformed`; - await clientA.workView.addTask(taskName); - await clientA.sync.syncAndWait(); - await waitForTask(clientA.page, taskName); - - // Client B setup - clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); - await clientB.sync.setupSuperSync(syncConfig); - - // Intercept and return malformed JSON on first GET - await clientB.page.route('**/api/sync/ops/**', async (route) => { - if (state.returnMalformedJson && route.request().method() === 'GET') { - state.returnMalformedJson = false; - console.log('[Test] Simulating malformed JSON response'); - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: '{ "ops": [ { "id": "op-1", "malformed', // Intentionally broken JSON - }); - } else { - await route.continue(); - } - }); - - // First sync - should fail due to JSON parse error - try { - await clientB.sync.triggerSync(); - await clientB.page.waitForTimeout(2000); - } catch { - console.log('[Test] First sync failed due to malformed JSON as expected'); - } - - // Remove interception and retry - await clientB.page.unroute('**/api/sync/ops/**'); - await clientB.page.waitForTimeout(500); - - // Retry - should succeed - await clientB.sync.syncAndWait(); - - // Verify task was received - await waitForTask(clientB.page, taskName); - const taskLocator = clientB.page.locator(`task:has-text("${taskName}")`); - await expect(taskLocator).toBeVisible(); - - console.log('[MalformedJSON] ✓ Malformed JSON handling test PASSED'); - } finally { - if (clientA) await closeClient(clientA); - if (clientB) { - await clientB.page.unroute('**/api/sync/ops/**').catch(() => {}); - await closeClient(clientB); - } + await clientB.sync.triggerSync(); + await clientB.page.waitForTimeout(2000); + } catch { + console.log('[Test] First sync failed due to malformed JSON as expected'); } - }, - ); + + // Remove interception and retry + await clientB.page.unroute('**/api/sync/ops/**'); + await clientB.page.waitForTimeout(500); + + // Retry - should succeed + await clientB.sync.syncAndWait(); + + // Verify task was received + await waitForTask(clientB.page, taskName); + const taskLocator = clientB.page.locator(`task:has-text("${taskName}")`); + await expect(taskLocator).toBeVisible(); + + console.log('[MalformedJSON] ✓ Malformed JSON handling test PASSED'); + } finally { + if (clientA) await closeClient(clientA); + if (clientB) { + await clientB.page.unroute('**/api/sync/ops/**').catch(() => {}); + await closeClient(clientB); + } + } + }); /** * Test: Invalid operation structure from server @@ -886,105 +866,105 @@ base.describe('@supersync Network Failure Recovery', () => { * This tests that invalid/corrupted operations from the server * are gracefully skipped without crashing the sync process. */ - base( - 'skips corrupted operations from server without crashing', - async ({ browser, baseURL }, testInfo) => { - testInfo.setTimeout(120000); - const testRunId = generateTestRunId(testInfo.workerIndex); - let clientA: SimulatedE2EClient | null = null; - let clientB: SimulatedE2EClient | null = null; + test('skips corrupted operations from server without crashing', async ({ + browser, + baseURL, + testRunId, + }) => { + test.setTimeout(120000); + let clientA: SimulatedE2EClient | null = null; + let clientB: SimulatedE2EClient | null = null; - const state = { injectCorruptedOps: true }; + const state = { injectCorruptedOps: true }; - try { - const user = await createTestUser(testRunId); - const syncConfig = getSuperSyncConfig(user); + try { + const user = await createTestUser(testRunId); + const syncConfig = getSuperSyncConfig(user); - // Client A creates and syncs a task - clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); - await clientA.sync.setupSuperSync(syncConfig); + // Client A creates and syncs a task + clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); + await clientA.sync.setupSuperSync(syncConfig); - const taskName = `Task-${testRunId}-corrupted-test`; - await clientA.workView.addTask(taskName); - await clientA.sync.syncAndWait(); + const taskName = `Task-${testRunId}-corrupted-test`; + await clientA.workView.addTask(taskName); + await clientA.sync.syncAndWait(); - // Setup Client B - clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); - await clientB.sync.setupSuperSync(syncConfig); + // Setup Client B + clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); + await clientB.sync.setupSuperSync(syncConfig); - // Intercept and inject corrupted operations - await clientB.page.route('**/api/sync/ops/**', async (route) => { - if (route.request().method() === 'GET' && state.injectCorruptedOps) { - state.injectCorruptedOps = false; - console.log('[Test] Injecting corrupted operations into response'); + // Intercept and inject corrupted operations + await clientB.page.route('**/api/sync/ops/**', async (route) => { + if (route.request().method() === 'GET' && state.injectCorruptedOps) { + state.injectCorruptedOps = false; + console.log('[Test] Injecting corrupted operations into response'); - // Get real response then modify it - const response = await route.fetch(); - const json = await response.json(); + // Get real response then modify it + const response = await route.fetch(); + const json = await response.json(); - // Add corrupted operations to the response - if (json.ops) { - json.ops.push( - // Missing required fields - { - id: 'corrupt-1', - opType: 'UPD' /* missing entityType, payload, etc. */, - }, - // Null payload - { - id: 'corrupt-2', - opType: 'UPD', - entityType: 'TASK', - entityId: 'bad-entity', - payload: null, - vectorClock: { bad: 1 }, - timestamp: Date.now(), - }, - // Invalid opType - { - id: 'corrupt-3', - opType: 'INVALID_TYPE', - entityType: 'TASK', - entityId: 'bad-entity', - payload: {}, - vectorClock: { bad: 1 }, - timestamp: Date.now(), - }, - ); - } - - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify(json), - }); - } else { - await route.continue(); + // Add corrupted operations to the response + if (json.ops) { + json.ops.push( + // Missing required fields + { + id: 'corrupt-1', + opType: 'UPD' /* missing entityType, payload, etc. */, + }, + // Null payload + { + id: 'corrupt-2', + opType: 'UPD', + entityType: 'TASK', + entityId: 'bad-entity', + payload: null, + vectorClock: { bad: 1 }, + timestamp: Date.now(), + }, + // Invalid opType + { + id: 'corrupt-3', + opType: 'INVALID_TYPE', + entityType: 'TASK', + entityId: 'bad-entity', + payload: {}, + vectorClock: { bad: 1 }, + timestamp: Date.now(), + }, + ); } - }); - // Sync - should handle corrupted ops gracefully - await clientB.sync.syncAndWait(); - - // Verify valid task was still received (corrupted ops didn't break sync) - await waitForTask(clientB.page, taskName); - const taskLocator = clientB.page.locator(`task:has-text("${taskName}")`); - await expect(taskLocator).toBeVisible(); - - // Remove interception - await clientB.page.unroute('**/api/sync/ops/**'); - - // Final sync should work normally - await clientB.sync.syncAndWait(); - - console.log('[CorruptedOps] ✓ Corrupted operation handling test PASSED'); - } finally { - if (clientA) await closeClient(clientA); - if (clientB) { - await clientB.page.unroute('**/api/sync/ops/**').catch(() => {}); - await closeClient(clientB); + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(json), + }); + } else { + await route.continue(); } + }); + + // Sync - should handle corrupted ops gracefully + await clientB.sync.syncAndWait(); + + // Verify valid task was still received (corrupted ops didn't break sync) + await waitForTask(clientB.page, taskName); + const taskLocator = clientB.page.locator(`task:has-text("${taskName}")`); + await expect(taskLocator).toBeVisible(); + + // Remove interception + await clientB.page.unroute('**/api/sync/ops/**'); + + // Final sync should work normally + await clientB.sync.syncAndWait(); + + console.log('[CorruptedOps] ✓ Corrupted operation handling test PASSED'); + } finally { + if (clientA) await closeClient(clientA); + if (clientB) { + await clientB.page.unroute('**/api/sync/ops/**').catch(() => {}); + await closeClient(clientB); } - }, - ); + } + }); }); diff --git a/e2e/tests/sync/supersync-planner.spec.ts b/e2e/tests/sync/supersync-planner.spec.ts index 45165e7e3..7548ef5be 100644 --- a/e2e/tests/sync/supersync-planner.spec.ts +++ b/e2e/tests/sync/supersync-planner.spec.ts @@ -1,13 +1,13 @@ -import { test as base, expect } from '@playwright/test'; +import { test, expect } from '../../fixtures/supersync.fixture'; import { createTestUser, getSuperSyncConfig, createSimulatedClient, closeClient, waitForTask, - isServerHealthy, type SimulatedE2EClient, } from '../../utils/supersync-helpers'; +import { expectTaskVisible } from '../../utils/supersync-assertions'; /** * SuperSync Planner E2E Tests @@ -18,24 +18,7 @@ import { * - Virtual TODAY_TAG behavior */ -let testCounter = 0; -const generateTestRunId = (workerIndex: number): string => { - return `planner-${Date.now()}-${workerIndex}-${testCounter++}`; -}; - -base.describe('@supersync Planner Sync', () => { - let serverHealthy: boolean | null = null; - - base.beforeEach(async ({}, testInfo) => { - if (serverHealthy === null) { - serverHealthy = await isServerHealthy(); - if (!serverHealthy) { - console.warn('SuperSync server not healthy - skipping tests'); - } - } - testInfo.skip(!serverHealthy, 'SuperSync server not running'); - }); - +test.describe('@supersync Planner Sync', () => { /** * Test: Task scheduled for tomorrow syncs correctly * @@ -46,61 +29,61 @@ base.describe('@supersync Planner Sync', () => { * 4. Client B navigates to planner * 5. Verify task appears on tomorrow in planner */ - base( - 'Task scheduled for tomorrow syncs to planner', - async ({ browser, baseURL }, testInfo) => { - const testRunId = generateTestRunId(testInfo.workerIndex); - const uniqueId = Date.now(); - let clientA: SimulatedE2EClient | null = null; - let clientB: SimulatedE2EClient | null = null; + test('Task scheduled for tomorrow syncs to planner', async ({ + browser, + baseURL, + testRunId, + }) => { + const uniqueId = Date.now(); + let clientA: SimulatedE2EClient | null = null; + let clientB: SimulatedE2EClient | null = null; - try { - const user = await createTestUser(testRunId); - const syncConfig = getSuperSyncConfig(user); + try { + const user = await createTestUser(testRunId); + const syncConfig = getSuperSyncConfig(user); - // ============ PHASE 1: Client A Schedules Task for Tomorrow ============ - clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); - await clientA.sync.setupSuperSync(syncConfig); + // ============ PHASE 1: Client A Schedules Task for Tomorrow ============ + clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); + await clientA.sync.setupSuperSync(syncConfig); - const taskName = `Tomorrow-${uniqueId}`; - // sd:1d means schedule 1 day from now (tomorrow) - await clientA.workView.addTask(`${taskName} sd:1d`); - console.log('[Planner Test] Client A created task for tomorrow'); + const taskName = `Tomorrow-${uniqueId}`; + // sd:1d means schedule 1 day from now (tomorrow) + await clientA.workView.addTask(`${taskName} sd:1d`); + console.log('[Planner Test] Client A created task for tomorrow'); - // Task should NOT be visible in TODAY work view (it's for tomorrow) - // But let's verify it was created - await clientA.page.waitForTimeout(500); + // Task should NOT be visible in TODAY work view (it's for tomorrow) + // But let's verify it was created + await clientA.page.waitForTimeout(500); - // ============ PHASE 2: Sync ============ - await clientA.sync.syncAndWait(); - console.log('[Planner Test] Client A synced'); + // ============ PHASE 2: Sync ============ + await clientA.sync.syncAndWait(); + console.log('[Planner Test] Client A synced'); - // ============ PHASE 3: Client B Downloads and Checks Planner ============ - clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); - await clientB.sync.setupSuperSync(syncConfig); - await clientB.sync.syncAndWait(); - console.log('[Planner Test] Client B synced'); + // ============ PHASE 3: Client B Downloads and Checks Planner ============ + clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); + await clientB.sync.setupSuperSync(syncConfig); + await clientB.sync.syncAndWait(); + console.log('[Planner Test] Client B synced'); - // Navigate to planner - await clientB.page.goto('/#/planner'); - await clientB.page.waitForLoadState('networkidle'); - await clientB.page.waitForSelector('planner', { timeout: 10000 }); - console.log('[Planner Test] Client B on planner page'); + // Navigate to planner + await clientB.page.goto('/#/planner'); + await clientB.page.waitForLoadState('networkidle'); + await clientB.page.waitForSelector('planner', { timeout: 10000 }); + console.log('[Planner Test] Client B on planner page'); - // Look for task in tomorrow's section - // The planner shows days as columns or rows with date headers - const taskInPlanner = clientB.page.locator( - `planner-day:has-text("${taskName}"), .planner-day-tasks:has-text("${taskName}")`, - ); + // Look for task in tomorrow's section + // The planner shows days as columns or rows with date headers + const taskInPlanner = clientB.page.locator( + `planner-day:has-text("${taskName}"), .planner-day-tasks:has-text("${taskName}")`, + ); - await expect(taskInPlanner).toBeVisible({ timeout: 10000 }); - console.log('[Planner Test] Task visible in planner on Client B'); - } finally { - if (clientA) await closeClient(clientA); - if (clientB) await closeClient(clientB); - } - }, - ); + await expect(taskInPlanner).toBeVisible({ timeout: 10000 }); + console.log('[Planner Test] Task visible in planner on Client B'); + } finally { + if (clientA) await closeClient(clientA); + if (clientB) await closeClient(clientB); + } + }); /** * Test: Moving task to today in planner syncs correctly @@ -112,49 +95,49 @@ base.describe('@supersync Planner Sync', () => { * 4. Both sync * 5. Verify task now in TODAY on Client B */ - base( - 'Scheduled task visible in planner on synced client', - async ({ browser, baseURL }, testInfo) => { - const testRunId = generateTestRunId(testInfo.workerIndex); - const uniqueId = Date.now(); - let clientA: SimulatedE2EClient | null = null; - let clientB: SimulatedE2EClient | null = null; + test('Scheduled task visible in planner on synced client', async ({ + browser, + baseURL, + testRunId, + }) => { + const uniqueId = Date.now(); + let clientA: SimulatedE2EClient | null = null; + let clientB: SimulatedE2EClient | null = null; - try { - const user = await createTestUser(testRunId); - const syncConfig = getSuperSyncConfig(user); + try { + const user = await createTestUser(testRunId); + const syncConfig = getSuperSyncConfig(user); - // ============ PHASE 1: Setup ============ - clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); - await clientA.sync.setupSuperSync(syncConfig); + // ============ PHASE 1: Setup ============ + clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); + await clientA.sync.setupSuperSync(syncConfig); - const taskName = `Scheduled-${uniqueId}`; - // sd:2d means schedule for 2 days from now - await clientA.workView.addTask(`${taskName} sd:2d`); - console.log('[Scheduled Test] Client A created task for 2 days from now'); + const taskName = `Scheduled-${uniqueId}`; + // sd:2d means schedule for 2 days from now + await clientA.workView.addTask(`${taskName} sd:2d`); + console.log('[Scheduled Test] Client A created task for 2 days from now'); - await clientA.sync.syncAndWait(); + await clientA.sync.syncAndWait(); - clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); - await clientB.sync.setupSuperSync(syncConfig); - await clientB.sync.syncAndWait(); - console.log('[Scheduled Test] Both synced'); + clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); + await clientB.sync.setupSuperSync(syncConfig); + await clientB.sync.syncAndWait(); + console.log('[Scheduled Test] Both synced'); - // ============ PHASE 2: Verify in Planner on Client B ============ - await clientB.page.goto('/#/planner'); - await clientB.page.waitForLoadState('networkidle'); - await clientB.page.waitForSelector('planner', { timeout: 10000 }); + // ============ PHASE 2: Verify in Planner on Client B ============ + await clientB.page.goto('/#/planner'); + await clientB.page.waitForLoadState('networkidle'); + await clientB.page.waitForSelector('planner', { timeout: 10000 }); - // Task should appear in planner - const taskInPlanner = clientB.page.locator(`text=${taskName}`).first(); - await expect(taskInPlanner).toBeVisible({ timeout: 10000 }); - console.log('[Scheduled Test] Scheduled task visible in planner on Client B'); - } finally { - if (clientA) await closeClient(clientA); - if (clientB) await closeClient(clientB); - } - }, - ); + // Task should appear in planner + const taskInPlanner = clientB.page.locator(`text=${taskName}`).first(); + await expect(taskInPlanner).toBeVisible({ timeout: 10000 }); + console.log('[Scheduled Test] Scheduled task visible in planner on Client B'); + } finally { + if (clientA) await closeClient(clientA); + if (clientB) await closeClient(clientB); + } + }); /** * Test: Task with dueDay maintains virtual TODAY_TAG behavior @@ -166,59 +149,58 @@ base.describe('@supersync Planner Sync', () => { * 4. Client B syncs * 5. Verify task in TODAY on Client B (via dueDay, not tagIds) */ - base( - 'TODAY tag membership via dueDay syncs correctly', - async ({ browser, baseURL }, testInfo) => { - const testRunId = generateTestRunId(testInfo.workerIndex); - const uniqueId = Date.now(); - let clientA: SimulatedE2EClient | null = null; - let clientB: SimulatedE2EClient | null = null; + test('TODAY tag membership via dueDay syncs correctly', async ({ + browser, + baseURL, + testRunId, + }) => { + const uniqueId = Date.now(); + let clientA: SimulatedE2EClient | null = null; + let clientB: SimulatedE2EClient | null = null; - try { - const user = await createTestUser(testRunId); - const syncConfig = getSuperSyncConfig(user); + try { + const user = await createTestUser(testRunId); + const syncConfig = getSuperSyncConfig(user); - // ============ PHASE 1: Client A Creates Task for Today ============ - clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); - await clientA.sync.setupSuperSync(syncConfig); + // ============ PHASE 1: Client A Creates Task for Today ============ + clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); + await clientA.sync.setupSuperSync(syncConfig); - const taskName = `TodayTask-${uniqueId}`; - // sd:today schedules for today, setting dueDay - await clientA.workView.addTask(`${taskName} sd:today`); - console.log('[TODAY Test] Client A created task for today'); + const taskName = `TodayTask-${uniqueId}`; + // sd:today schedules for today, setting dueDay + await clientA.workView.addTask(`${taskName} sd:today`); + console.log('[TODAY Test] Client A created task for today'); - // Verify task appears in TODAY view - await waitForTask(clientA.page, taskName); - console.log('[TODAY Test] Task visible in TODAY on Client A'); + // Verify task appears in TODAY view + await waitForTask(clientA.page, taskName); + console.log('[TODAY Test] Task visible in TODAY on Client A'); - // ============ PHASE 2: Sync ============ - await clientA.sync.syncAndWait(); + // ============ PHASE 2: Sync ============ + await clientA.sync.syncAndWait(); - clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); - await clientB.sync.setupSuperSync(syncConfig); - await clientB.sync.syncAndWait(); - console.log('[TODAY Test] Both synced'); + clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); + await clientB.sync.setupSuperSync(syncConfig); + await clientB.sync.syncAndWait(); + console.log('[TODAY Test] Both synced'); - // ============ PHASE 3: Verify on Client B ============ - // Task should appear in TODAY view on Client B - // This confirms dueDay synced correctly and TODAY_TAG virtual membership works - await waitForTask(clientB.page, taskName); + // ============ PHASE 3: Verify on Client B ============ + // Task should appear in TODAY view on Client B + // This confirms dueDay synced correctly and TODAY_TAG virtual membership works + await waitForTask(clientB.page, taskName); - const taskLocatorB = clientB.page.locator(`task:has-text("${taskName}")`); - await expect(taskLocatorB).toBeVisible(); - console.log('[TODAY Test] Task visible in TODAY on Client B'); + await expectTaskVisible(clientB, taskName); + console.log('[TODAY Test] Task visible in TODAY on Client B'); - // Verify we're in TODAY tag context (URL check) - const currentUrl = clientB.page.url(); - expect(currentUrl).toContain('TODAY'); + // Verify we're in TODAY tag context (URL check) + const currentUrl = clientB.page.url(); + expect(currentUrl).toContain('TODAY'); - console.log('[TODAY Test] Virtual TODAY_TAG membership working'); - } finally { - if (clientA) await closeClient(clientA); - if (clientB) await closeClient(clientB); - } - }, - ); + console.log('[TODAY Test] Virtual TODAY_TAG membership working'); + } finally { + if (clientA) await closeClient(clientA); + if (clientB) await closeClient(clientB); + } + }); /** * Test: Scheduling task for future date syncs @@ -229,48 +211,48 @@ base.describe('@supersync Planner Sync', () => { * 3. Client B syncs * 4. Client B checks planner for future date */ - base( - 'Task scheduled for future date syncs to planner', - async ({ browser, baseURL }, testInfo) => { - const testRunId = generateTestRunId(testInfo.workerIndex); - const uniqueId = Date.now(); - let clientA: SimulatedE2EClient | null = null; - let clientB: SimulatedE2EClient | null = null; + test('Task scheduled for future date syncs to planner', async ({ + browser, + baseURL, + testRunId, + }) => { + const uniqueId = Date.now(); + let clientA: SimulatedE2EClient | null = null; + let clientB: SimulatedE2EClient | null = null; - try { - const user = await createTestUser(testRunId); - const syncConfig = getSuperSyncConfig(user); + try { + const user = await createTestUser(testRunId); + const syncConfig = getSuperSyncConfig(user); - // ============ PHASE 1: Client A Schedules Task for Future ============ - clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); - await clientA.sync.setupSuperSync(syncConfig); + // ============ PHASE 1: Client A Schedules Task for Future ============ + clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); + await clientA.sync.setupSuperSync(syncConfig); - const taskName = `Future-${uniqueId}`; - // sd:3d means schedule 3 days from now - await clientA.workView.addTask(`${taskName} sd:3d`); - console.log('[Future Test] Client A created task for 3 days from now'); + const taskName = `Future-${uniqueId}`; + // sd:3d means schedule 3 days from now + await clientA.workView.addTask(`${taskName} sd:3d`); + console.log('[Future Test] Client A created task for 3 days from now'); - // ============ PHASE 2: Sync ============ - await clientA.sync.syncAndWait(); + // ============ PHASE 2: Sync ============ + await clientA.sync.syncAndWait(); - clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); - await clientB.sync.setupSuperSync(syncConfig); - await clientB.sync.syncAndWait(); - console.log('[Future Test] Both synced'); + clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); + await clientB.sync.setupSuperSync(syncConfig); + await clientB.sync.syncAndWait(); + console.log('[Future Test] Both synced'); - // ============ PHASE 3: Verify in Planner ============ - await clientB.page.goto('/#/planner'); - await clientB.page.waitForLoadState('networkidle'); - await clientB.page.waitForSelector('planner', { timeout: 10000 }); + // ============ PHASE 3: Verify in Planner ============ + await clientB.page.goto('/#/planner'); + await clientB.page.waitForLoadState('networkidle'); + await clientB.page.waitForSelector('planner', { timeout: 10000 }); - // The task should appear somewhere in the planner view - const taskInPlanner = clientB.page.locator(`text=${taskName}`).first(); - await expect(taskInPlanner).toBeVisible({ timeout: 10000 }); - console.log('[Future Test] Task visible in planner on Client B'); - } finally { - if (clientA) await closeClient(clientA); - if (clientB) await closeClient(clientB); - } - }, - ); + // The task should appear somewhere in the planner view + const taskInPlanner = clientB.page.locator(`text=${taskName}`).first(); + await expect(taskInPlanner).toBeVisible({ timeout: 10000 }); + console.log('[Future Test] Task visible in planner on Client B'); + } finally { + if (clientA) await closeClient(clientA); + if (clientB) await closeClient(clientB); + } + }); }); diff --git a/e2e/tests/sync/supersync-repeat-task-advanced.spec.ts b/e2e/tests/sync/supersync-repeat-task-advanced.spec.ts index 3ea42b3cc..87dd3a3b7 100644 --- a/e2e/tests/sync/supersync-repeat-task-advanced.spec.ts +++ b/e2e/tests/sync/supersync-repeat-task-advanced.spec.ts @@ -1,11 +1,10 @@ -import { test as base, expect } from '@playwright/test'; +import { test, expect } from '../../fixtures/supersync.fixture'; import { createTestUser, getSuperSyncConfig, createSimulatedClient, closeClient, waitForTask, - isServerHealthy, type SimulatedE2EClient, } from '../../utils/supersync-helpers'; @@ -19,24 +18,7 @@ import { * - deletedInstanceDates sync */ -let testCounter = 0; -const generateTestRunId = (workerIndex: number): string => { - return `repeat-${Date.now()}-${workerIndex}-${testCounter++}`; -}; - -base.describe('@supersync Repeat Task Advanced Sync', () => { - let serverHealthy: boolean | null = null; - - base.beforeEach(async ({}, testInfo) => { - if (serverHealthy === null) { - serverHealthy = await isServerHealthy(); - if (!serverHealthy) { - console.warn('SuperSync server not healthy - skipping tests'); - } - } - testInfo.skip(!serverHealthy, 'SuperSync server not running'); - }); - +test.describe('@supersync Repeat Task Advanced Sync', () => { /** * Test: Scheduled task (with dueDay) syncs between clients * @@ -48,57 +30,57 @@ base.describe('@supersync Repeat Task Advanced Sync', () => { * 3. Client B syncs * 4. Verify task appears on Client B with scheduled indicator */ - base( - 'Scheduled task with dueDay syncs to other client', - async ({ browser, baseURL }, testInfo) => { - const testRunId = generateTestRunId(testInfo.workerIndex); - const uniqueId = Date.now(); - let clientA: SimulatedE2EClient | null = null; - let clientB: SimulatedE2EClient | null = null; + test('Scheduled task with dueDay syncs to other client', async ({ + browser, + baseURL, + testRunId, + }) => { + const uniqueId = Date.now(); + let clientA: SimulatedE2EClient | null = null; + let clientB: SimulatedE2EClient | null = null; - try { - const user = await createTestUser(testRunId); - const syncConfig = getSuperSyncConfig(user); + try { + const user = await createTestUser(testRunId); + const syncConfig = getSuperSyncConfig(user); - // ============ PHASE 1: Client A Creates Scheduled Task ============ - clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); - await clientA.sync.setupSuperSync(syncConfig); + // ============ PHASE 1: Client A Creates Scheduled Task ============ + clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); + await clientA.sync.setupSuperSync(syncConfig); - // Use sd:today shorthand to set dueDay to today - const taskName = `ScheduledTask-${uniqueId}`; - await clientA.workView.addTask(`${taskName} sd:today`); - console.log('[Scheduled Test] Client A created scheduled task'); + // Use sd:today shorthand to set dueDay to today + const taskName = `ScheduledTask-${uniqueId}`; + await clientA.workView.addTask(`${taskName} sd:today`); + console.log('[Scheduled Test] Client A created scheduled task'); - // Verify task was created - await waitForTask(clientA.page, taskName); + // Verify task was created + await waitForTask(clientA.page, taskName); - // ============ PHASE 2: Sync to Server ============ - await clientA.sync.syncAndWait(); - console.log('[Scheduled Test] Client A synced'); + // ============ PHASE 2: Sync to Server ============ + await clientA.sync.syncAndWait(); + console.log('[Scheduled Test] Client A synced'); - // ============ PHASE 3: Client B Downloads ============ - clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); - await clientB.sync.setupSuperSync(syncConfig); - await clientB.sync.syncAndWait(); - console.log('[Scheduled Test] Client B synced'); + // ============ PHASE 3: Client B Downloads ============ + clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); + await clientB.sync.setupSuperSync(syncConfig); + await clientB.sync.syncAndWait(); + console.log('[Scheduled Test] Client B synced'); - // ============ PHASE 4: Verify on Client B ============ - await waitForTask(clientB.page, taskName); + // ============ PHASE 4: Verify on Client B ============ + await waitForTask(clientB.page, taskName); - const taskLocatorB = clientB.page.locator(`task:has-text("${taskName}")`); - await expect(taskLocatorB).toBeVisible(); + const taskLocatorB = clientB.page.locator(`task:has-text("${taskName}")`); + await expect(taskLocatorB).toBeVisible(); - // Task should show scheduled indicator (sun icon for today) - // The exact indicator depends on UI, but task should exist - console.log('[Scheduled Test] Task visible on Client B'); + // Task should show scheduled indicator (sun icon for today) + // The exact indicator depends on UI, but task should exist + console.log('[Scheduled Test] Task visible on Client B'); - console.log('[Scheduled Test] Scheduled task synced successfully'); - } finally { - if (clientA) await closeClient(clientA); - if (clientB) await closeClient(clientB); - } - }, - ); + console.log('[Scheduled Test] Scheduled task synced successfully'); + } finally { + if (clientA) await closeClient(clientA); + if (clientB) await closeClient(clientB); + } + }); /** * Test: Task with time estimate syncs correctly @@ -109,60 +91,60 @@ base.describe('@supersync Repeat Task Advanced Sync', () => { * 3. Client B syncs * 4. Verify time estimate appears on Client B */ - base( - 'Task with time estimate syncs to other client', - async ({ browser, baseURL }, testInfo) => { - const testRunId = generateTestRunId(testInfo.workerIndex); - const uniqueId = Date.now(); - let clientA: SimulatedE2EClient | null = null; - let clientB: SimulatedE2EClient | null = null; + test('Task with time estimate syncs to other client', async ({ + browser, + baseURL, + testRunId, + }) => { + const uniqueId = Date.now(); + let clientA: SimulatedE2EClient | null = null; + let clientB: SimulatedE2EClient | null = null; - try { - const user = await createTestUser(testRunId); - const syncConfig = getSuperSyncConfig(user); + try { + const user = await createTestUser(testRunId); + const syncConfig = getSuperSyncConfig(user); - // ============ PHASE 1: Client A Creates Task with Estimate ============ - clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); - await clientA.sync.setupSuperSync(syncConfig); + // ============ PHASE 1: Client A Creates Task with Estimate ============ + clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); + await clientA.sync.setupSuperSync(syncConfig); - // Use t:1h shorthand to set 1 hour estimate - const taskName = `EstimateTask-${uniqueId}`; - await clientA.workView.addTask(`${taskName} t:1h`); - console.log('[Estimate Test] Client A created task with 1h estimate'); + // Use t:1h shorthand to set 1 hour estimate + const taskName = `EstimateTask-${uniqueId}`; + await clientA.workView.addTask(`${taskName} t:1h`); + console.log('[Estimate Test] Client A created task with 1h estimate'); - await waitForTask(clientA.page, taskName); + await waitForTask(clientA.page, taskName); - // Task exists on Client A (estimate is stored but may not be visible in compact view) - const taskLocatorA = clientA.page.locator(`task:has-text("${taskName}")`); - await expect(taskLocatorA).toBeVisible({ timeout: 5000 }); - console.log('[Estimate Test] Task visible on Client A'); + // Task exists on Client A (estimate is stored but may not be visible in compact view) + const taskLocatorA = clientA.page.locator(`task:has-text("${taskName}")`); + await expect(taskLocatorA).toBeVisible({ timeout: 5000 }); + console.log('[Estimate Test] Task visible on Client A'); - // ============ PHASE 2: Sync to Server ============ - await clientA.sync.syncAndWait(); - console.log('[Estimate Test] Client A synced'); + // ============ PHASE 2: Sync to Server ============ + await clientA.sync.syncAndWait(); + console.log('[Estimate Test] Client A synced'); - // ============ PHASE 3: Client B Downloads ============ - clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); - await clientB.sync.setupSuperSync(syncConfig); - await clientB.sync.syncAndWait(); - console.log('[Estimate Test] Client B synced'); + // ============ PHASE 3: Client B Downloads ============ + clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); + await clientB.sync.setupSuperSync(syncConfig); + await clientB.sync.syncAndWait(); + console.log('[Estimate Test] Client B synced'); - // ============ PHASE 4: Verify Task on Client B ============ - await waitForTask(clientB.page, taskName); + // ============ PHASE 4: Verify Task on Client B ============ + await waitForTask(clientB.page, taskName); - const taskLocatorB = clientB.page.locator(`task:has-text("${taskName}")`); - await expect(taskLocatorB).toBeVisible({ timeout: 5000 }); - console.log('[Estimate Test] Task visible on Client B'); + const taskLocatorB = clientB.page.locator(`task:has-text("${taskName}")`); + await expect(taskLocatorB).toBeVisible({ timeout: 5000 }); + console.log('[Estimate Test] Task visible on Client B'); - // Note: Time estimate is synced as task data, but UI display varies - // The key test is that the task synced successfully - console.log('[Estimate Test] Task with time estimate synced successfully'); - } finally { - if (clientA) await closeClient(clientA); - if (clientB) await closeClient(clientB); - } - }, - ); + // Note: Time estimate is synced as task data, but UI display varies + // The key test is that the task synced successfully + console.log('[Estimate Test] Task with time estimate synced successfully'); + } finally { + if (clientA) await closeClient(clientA); + if (clientB) await closeClient(clientB); + } + }); /** * Test: Updating scheduled task properties syncs @@ -174,68 +156,68 @@ base.describe('@supersync Repeat Task Advanced Sync', () => { * 4. Both sync * 5. Verify Client B has updated properties */ - base( - 'Updating scheduled task properties syncs', - async ({ browser, baseURL }, testInfo) => { - const testRunId = generateTestRunId(testInfo.workerIndex); - const uniqueId = Date.now(); - let clientA: SimulatedE2EClient | null = null; - let clientB: SimulatedE2EClient | null = null; + test('Updating scheduled task properties syncs', async ({ + browser, + baseURL, + testRunId, + }) => { + const uniqueId = Date.now(); + let clientA: SimulatedE2EClient | null = null; + let clientB: SimulatedE2EClient | null = null; - try { - const user = await createTestUser(testRunId); - const syncConfig = getSuperSyncConfig(user); + try { + const user = await createTestUser(testRunId); + const syncConfig = getSuperSyncConfig(user); - // ============ PHASE 1: Setup ============ - clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); - await clientA.sync.setupSuperSync(syncConfig); + // ============ PHASE 1: Setup ============ + clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); + await clientA.sync.setupSuperSync(syncConfig); - const originalName = `Original-${uniqueId}`; - const updatedName = `Updated-${uniqueId}`; + const originalName = `Original-${uniqueId}`; + const updatedName = `Updated-${uniqueId}`; - await clientA.workView.addTask(`${originalName} sd:today`); - await clientA.sync.syncAndWait(); + await clientA.workView.addTask(`${originalName} sd:today`); + await clientA.sync.syncAndWait(); - clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); - await clientB.sync.setupSuperSync(syncConfig); - await clientB.sync.syncAndWait(); + clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); + await clientB.sync.setupSuperSync(syncConfig); + await clientB.sync.syncAndWait(); - await waitForTask(clientB.page, originalName); - console.log('[Update Test] Both clients have original task'); + await waitForTask(clientB.page, originalName); + console.log('[Update Test] Both clients have original task'); - // ============ PHASE 2: Client A Updates Task ============ - const taskLocatorA = clientA.page.locator(`task:has-text("${originalName}")`); - await taskLocatorA.dblclick(); + // ============ PHASE 2: Client A Updates Task ============ + const taskLocatorA = clientA.page.locator(`task:has-text("${originalName}")`); + await taskLocatorA.dblclick(); - const editInput = clientA.page.locator( - 'input.mat-mdc-input-element:focus, textarea:focus', - ); - await editInput.waitFor({ state: 'visible', timeout: 5000 }); - await editInput.fill(updatedName); - await clientA.page.keyboard.press('Enter'); - await clientA.page.waitForTimeout(500); - console.log('[Update Test] Client A renamed task'); + const editInput = clientA.page.locator( + 'input.mat-mdc-input-element:focus, textarea:focus', + ); + await editInput.waitFor({ state: 'visible', timeout: 5000 }); + await editInput.fill(updatedName); + await clientA.page.keyboard.press('Enter'); + await clientA.page.waitForTimeout(500); + console.log('[Update Test] Client A renamed task'); - // ============ PHASE 3: Sync Update ============ - await clientA.sync.syncAndWait(); - await clientB.sync.syncAndWait(); + // ============ PHASE 3: Sync Update ============ + await clientA.sync.syncAndWait(); + await clientB.sync.syncAndWait(); - // ============ PHASE 4: Verify Update on Client B ============ - await waitForTask(clientB.page, updatedName); + // ============ PHASE 4: Verify Update on Client B ============ + await waitForTask(clientB.page, updatedName); - // Original name should no longer exist - const originalExists = await clientB.page - .locator(`task:has-text("${originalName}")`) - .count(); - expect(originalExists).toBe(0); + // Original name should no longer exist + const originalExists = await clientB.page + .locator(`task:has-text("${originalName}")`) + .count(); + expect(originalExists).toBe(0); - console.log('[Update Test] Task update synced successfully'); - } finally { - if (clientA) await closeClient(clientA); - if (clientB) await closeClient(clientB); - } - }, - ); + console.log('[Update Test] Task update synced successfully'); + } finally { + if (clientA) await closeClient(clientA); + if (clientB) await closeClient(clientB); + } + }); /** * Test: Both clients create same scheduled task concurrently @@ -249,59 +231,59 @@ base.describe('@supersync Repeat Task Advanced Sync', () => { * 3. Both sync * 4. Verify both tasks exist on both clients */ - base( - 'Concurrent scheduled task creation merges correctly', - async ({ browser, baseURL }, testInfo) => { - const testRunId = generateTestRunId(testInfo.workerIndex); - const uniqueId = Date.now(); - let clientA: SimulatedE2EClient | null = null; - let clientB: SimulatedE2EClient | null = null; + test('Concurrent scheduled task creation merges correctly', async ({ + browser, + baseURL, + testRunId, + }) => { + const uniqueId = Date.now(); + let clientA: SimulatedE2EClient | null = null; + let clientB: SimulatedE2EClient | null = null; - try { - const user = await createTestUser(testRunId); - const syncConfig = getSuperSyncConfig(user); + try { + const user = await createTestUser(testRunId); + const syncConfig = getSuperSyncConfig(user); - // ============ PHASE 1: Setup Both Clients ============ - clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); - await clientA.sync.setupSuperSync(syncConfig); + // ============ PHASE 1: Setup 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); + clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); + await clientB.sync.setupSuperSync(syncConfig); - // ============ PHASE 2: Concurrent Task Creation ============ - const taskAName = `TaskA-${uniqueId}`; - const taskBName = `TaskB-${uniqueId}`; + // ============ PHASE 2: Concurrent Task Creation ============ + const taskAName = `TaskA-${uniqueId}`; + const taskBName = `TaskB-${uniqueId}`; - // Both create tasks scheduled for today, without syncing - await clientA.workView.addTask(`${taskAName} sd:today`); - console.log('[Concurrent Test] Client A created scheduled task'); + // Both create tasks scheduled for today, without syncing + await clientA.workView.addTask(`${taskAName} sd:today`); + console.log('[Concurrent Test] Client A created scheduled task'); - await clientB.workView.addTask(`${taskBName} sd:today`); - console.log('[Concurrent Test] Client B created scheduled task'); + await clientB.workView.addTask(`${taskBName} sd:today`); + console.log('[Concurrent Test] Client B created scheduled task'); - // ============ PHASE 3: Sync Both ============ - await clientA.sync.syncAndWait(); - await clientB.sync.syncAndWait(); - await clientA.sync.syncAndWait(); // Final convergence - console.log('[Concurrent Test] All clients synced'); + // ============ PHASE 3: Sync Both ============ + await clientA.sync.syncAndWait(); + await clientB.sync.syncAndWait(); + await clientA.sync.syncAndWait(); // Final convergence + console.log('[Concurrent Test] All clients synced'); - // ============ PHASE 4: Verify Both Tasks Exist on Both Clients ============ - await waitForTask(clientA.page, taskAName); - await waitForTask(clientA.page, taskBName); - await waitForTask(clientB.page, taskAName); - await waitForTask(clientB.page, taskBName); + // ============ PHASE 4: Verify Both Tasks Exist on Both Clients ============ + await waitForTask(clientA.page, taskAName); + await waitForTask(clientA.page, taskBName); + await waitForTask(clientB.page, taskAName); + await waitForTask(clientB.page, taskBName); - const countA = await clientA.page.locator('task').count(); - const countB = await clientB.page.locator('task').count(); + const countA = await clientA.page.locator('task').count(); + const countB = await clientB.page.locator('task').count(); - expect(countA).toBe(countB); - expect(countA).toBe(2); + expect(countA).toBe(countB); + expect(countA).toBe(2); - console.log('[Concurrent Test] Both scheduled tasks exist on both clients'); - } finally { - if (clientA) await closeClient(clientA); - if (clientB) await closeClient(clientB); - } - }, - ); + console.log('[Concurrent Test] Both scheduled tasks exist on both clients'); + } finally { + if (clientA) await closeClient(clientA); + if (clientB) await closeClient(clientB); + } + }); }); diff --git a/e2e/tests/sync/supersync-repeat-task.spec.ts b/e2e/tests/sync/supersync-repeat-task.spec.ts index 779d07bbf..5e86e8019 100644 --- a/e2e/tests/sync/supersync-repeat-task.spec.ts +++ b/e2e/tests/sync/supersync-repeat-task.spec.ts @@ -1,13 +1,13 @@ -import { test as base, expect } from '@playwright/test'; +import { test, expect } from '../../fixtures/supersync.fixture'; import { createTestUser, getSuperSyncConfig, createSimulatedClient, closeClient, waitForTask, - isServerHealthy, type SimulatedE2EClient, } from '../../utils/supersync-helpers'; +import { expectTaskVisible } from '../../utils/supersync-assertions'; /** * SuperSync Repeatable Task E2E Tests @@ -23,10 +23,6 @@ import { * src/app/core/persistence/operation-log/integration/repeat-task-sync.integration.spec.ts */ -const generateTestRunId = (workerIndex: number): string => { - return `${Date.now()}-${workerIndex}`; -}; - // Helper to create a scheduled task using a SimulatedE2EClient // Note: Setting up actual repeat config requires navigating the detail panel // which is complex. This simplified version just schedules the task for today. @@ -57,21 +53,7 @@ const createScheduledTask = async ( await page.waitForTimeout(500); }; -base.describe('@supersync SuperSync Repeatable Task Sync', () => { - let serverHealthy: boolean | null = null; - - base.beforeEach(async ({}, testInfo) => { - if (serverHealthy === null) { - serverHealthy = await isServerHealthy(); - if (!serverHealthy) { - console.warn( - 'SuperSync server not healthy at http://localhost:1901 - skipping tests', - ); - } - } - testInfo.skip(!serverHealthy, 'SuperSync server not running'); - }); - +test.describe('@supersync SuperSync Repeatable Task Sync', () => { /** * Scenario: Scheduled Task Syncs to Second Client * @@ -84,57 +66,55 @@ base.describe('@supersync SuperSync Repeatable Task Sync', () => { * 3. Client B syncs * 4. Verify Client B has the task */ - base( - 'Scheduled task syncs to second client', - async ({ browser, baseURL }, testInfo) => { - testInfo.setTimeout(90000); - const testRunId = generateTestRunId(testInfo.workerIndex); - const appUrl = baseURL || 'http://localhost:4242'; - let clientA: SimulatedE2EClient | null = null; - let clientB: SimulatedE2EClient | null = null; + test('Scheduled task syncs to second client', 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); + try { + const user = await createTestUser(testRunId); + const syncConfig = getSuperSyncConfig(user); - // Setup Client A - clientA = await createSimulatedClient(browser, appUrl, 'A', testRunId); - await clientA.sync.setupSuperSync(syncConfig); + // Setup Client A + clientA = await createSimulatedClient(browser, appUrl, 'A', testRunId); + await clientA.sync.setupSuperSync(syncConfig); - // Setup Client B - clientB = await createSimulatedClient(browser, appUrl, 'B', testRunId); - await clientB.sync.setupSuperSync(syncConfig); + // Setup Client B + clientB = await createSimulatedClient(browser, appUrl, 'B', testRunId); + await clientB.sync.setupSuperSync(syncConfig); - // 1. Client A creates a scheduled task - const taskName = `ScheduledTask-${testRunId}`; - await createScheduledTask(clientA, taskName); + // 1. Client A creates a scheduled task + const taskName = `ScheduledTask-${testRunId}`; + await createScheduledTask(clientA, taskName); - // Verify task exists on Client A - await waitForTask(clientA.page, taskName); + // Verify task exists on Client A + await waitForTask(clientA.page, taskName); - // 2. Client A syncs - await clientA.sync.syncAndWait(); + // 2. Client A syncs + await clientA.sync.syncAndWait(); - // 3. Client B syncs - await clientB.sync.syncAndWait(); + // 3. Client B syncs + await clientB.sync.syncAndWait(); - // 4. Verify Client B has the task - // Wait for UI to update - await clientB.page.waitForTimeout(1000); + // 4. Verify Client B has the task + // Wait for UI to update + await clientB.page.waitForTimeout(1000); - // The task should appear on Client B's today list - const taskOnB = clientB.page.locator(`task:has-text("${taskName}")`).first(); - await expect(taskOnB).toBeVisible({ timeout: 10000 }); + // The task should appear on Client B's today list + await expectTaskVisible(clientB, taskName, 10000); - console.log( - '[ScheduledTaskSync] ✓ Scheduled task synced successfully to second client', - ); - } finally { - if (clientA) await closeClient(clientA); - if (clientB) await closeClient(clientB); - } - }, - ); + console.log( + '[ScheduledTaskSync] ✓ Scheduled task synced successfully to second client', + ); + } finally { + if (clientA) await closeClient(clientA); + if (clientB) await closeClient(clientB); + } + }); /** * Scenario: Scheduled Task After Full Sync Import @@ -150,61 +130,57 @@ base.describe('@supersync SuperSync Repeatable Task Sync', () => { * 4. Client B (fresh) sets up sync * 5. Verify Client B receives the task */ - base( - 'Scheduled task created after import syncs correctly', - async ({ browser, baseURL }, testInfo) => { - testInfo.setTimeout(120000); - const testRunId = generateTestRunId(testInfo.workerIndex); - const appUrl = baseURL || 'http://localhost:4242'; - let clientA: SimulatedE2EClient | null = null; - let clientB: SimulatedE2EClient | null = null; + test('Scheduled task created after import syncs correctly', 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); + try { + const user = await createTestUser(testRunId); + const syncConfig = getSuperSyncConfig(user); - // 1. Client A sets up sync (triggers initial sync) - clientA = await createSimulatedClient(browser, appUrl, 'A', testRunId); - await clientA.sync.setupSuperSync(syncConfig); + // 1. Client A sets up sync (triggers initial sync) + clientA = await createSimulatedClient(browser, appUrl, 'A', testRunId); + await clientA.sync.setupSuperSync(syncConfig); - // 2. Immediately create a scheduled task (simulates the edge case) - const taskName = `ScheduledAfterImport-${testRunId}`; - await createScheduledTask(clientA, taskName); + // 2. Immediately create a scheduled task (simulates the edge case) + const taskName = `ScheduledAfterImport-${testRunId}`; + await createScheduledTask(clientA, taskName); - // Verify task on Client A - await waitForTask(clientA.page, taskName); + // Verify task on Client A + await waitForTask(clientA.page, taskName); - // 3. Client A syncs to upload the task - await clientA.sync.syncAndWait(); + // 3. Client A syncs to upload the task + await clientA.sync.syncAndWait(); - // 4. Client B (fresh setup) joins - clientB = await createSimulatedClient(browser, appUrl, 'B', testRunId); - await clientB.sync.setupSuperSync(syncConfig); + // 4. Client B (fresh setup) joins + clientB = await createSimulatedClient(browser, appUrl, 'B', testRunId); + await clientB.sync.setupSuperSync(syncConfig); - // Wait for sync to complete - await clientB.sync.syncAndWait(); + // Wait for sync to complete + await clientB.sync.syncAndWait(); - // 5. Verify Client B has the task instance - await clientB.page.waitForTimeout(1000); + // 5. Verify Client B has the task instance + await clientB.page.waitForTimeout(1000); - const taskOnB = clientB.page.locator(`task:has-text("${taskName}")`).first(); - await expect(taskOnB).toBeVisible({ timeout: 15000 }); + await expectTaskVisible(clientB, taskName, 15000); - // Verify it's the same task (not a duplicate created by Client B) - const taskCount = await clientB.page - .locator(`task:has-text("${taskName}")`) - .count(); - expect(taskCount).toBe(1); + // Verify it's the same task (not a duplicate created by Client B) + const taskCount = await clientB.page + .locator(`task:has-text("${taskName}")`) + .count(); + expect(taskCount).toBe(1); - console.log( - '[ScheduledAfterImport] ✓ Task created after import synced correctly', - ); - } finally { - if (clientA) await closeClient(clientA); - if (clientB) await closeClient(clientB); - } - }, - ); + console.log('[ScheduledAfterImport] ✓ Task created after import synced correctly'); + } finally { + if (clientA) await closeClient(clientA); + if (clientB) await closeClient(clientB); + } + }); /** * Scenario: Task Deletion Syncs to Other Client @@ -219,9 +195,7 @@ base.describe('@supersync SuperSync Repeatable Task Sync', () => { * 4. Client B syncs * 5. Verify Client B no longer has the task */ - base('Task deletion syncs to other client', async ({ browser, baseURL }, testInfo) => { - testInfo.setTimeout(120000); - const testRunId = generateTestRunId(testInfo.workerIndex); + test('Task deletion syncs to other client', async ({ browser, baseURL, testRunId }) => { const appUrl = baseURL || 'http://localhost:4242'; let clientA: SimulatedE2EClient | null = null; let clientB: SimulatedE2EClient | null = null; @@ -248,8 +222,7 @@ base.describe('@supersync SuperSync Repeatable Task Sync', () => { // 2. Client B syncs and verifies task exists await clientB.sync.syncAndWait(); await clientB.page.waitForTimeout(1000); - const taskOnB = clientB.page.locator(`task:has-text("${taskName}")`).first(); - await expect(taskOnB).toBeVisible({ timeout: 10000 }); + await expectTaskVisible(clientB, taskName, 10000); // 3. Client A deletes the task const taskOnA = clientA.page.locator(`task:has-text("${taskName}")`).first(); diff --git a/e2e/tests/sync/supersync-server-migration.spec.ts b/e2e/tests/sync/supersync-server-migration.spec.ts index 06820d80e..c1228524f 100644 --- a/e2e/tests/sync/supersync-server-migration.spec.ts +++ b/e2e/tests/sync/supersync-server-migration.spec.ts @@ -1,11 +1,10 @@ -import { test as base, expect } from '@playwright/test'; +import { test, expect } from '../../fixtures/supersync.fixture'; import { createTestUser, getSuperSyncConfig, createSimulatedClient, closeClient, waitForTask, - isServerHealthy, type SimulatedE2EClient, } from '../../utils/supersync-helpers'; @@ -25,26 +24,8 @@ import { * to ensure all data is transferred to the new server. */ -const generateTestRunId = (workerIndex: number): string => { - return `${Date.now()}-${workerIndex}`; -}; - // Run server migration tests serially to avoid rate limiting when creating multiple test users -base.describe.serial('@supersync SuperSync Server Migration', () => { - let serverHealthy: boolean | null = null; - - base.beforeEach(async ({}, testInfo) => { - if (serverHealthy === null) { - serverHealthy = await isServerHealthy(); - if (!serverHealthy) { - console.warn( - 'SuperSync server not healthy at http://localhost:1901 - skipping tests', - ); - } - } - testInfo.skip(!serverHealthy, 'SuperSync server not running'); - }); - +test.describe.serial('@supersync SuperSync Server Migration', () => { /** * Server Migration Scenario: Client A migrates to new server, Client B receives all data * @@ -66,96 +47,96 @@ base.describe.serial('@supersync SuperSync Server Migration', () => { * 7. Client B syncs * 8. Client B should have ALL of Client A's data (tasks, projects, tags) */ - base( - 'Client A migrates to new server, Client B receives all data', - async ({ browser, baseURL }, testInfo) => { - testInfo.setTimeout(120000); // 2 minutes - migration tests need extra time - const testRunId = generateTestRunId(testInfo.workerIndex); - let clientA: SimulatedE2EClient | null = null; - let clientB: SimulatedE2EClient | null = null; + test('Client A migrates to new server, Client B receives all data', async ({ + browser, + baseURL, + testRunId, + }, testInfo) => { + testInfo.setTimeout(120000); // 2 minutes - migration tests need extra time + let clientA: SimulatedE2EClient | null = null; + let clientB: SimulatedE2EClient | null = null; - try { - // === PHASE 1: Setup with "old server" (user1) === - console.log('[Test] Phase 1: Setting up Client A with initial server'); - const user1 = await createTestUser(`${testRunId}-server1`); - const syncConfig1 = getSuperSyncConfig(user1); + try { + // === PHASE 1: Setup with "old server" (user1) === + console.log('[Test] Phase 1: Setting up Client A with initial server'); + const user1 = await createTestUser(`${testRunId}-server1`); + const syncConfig1 = getSuperSyncConfig(user1); - clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); - await clientA.sync.setupSuperSync(syncConfig1); + clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); + await clientA.sync.setupSuperSync(syncConfig1); - // Create test data on Client A - const task1 = `Task1-${testRunId}`; - const task2 = `Task2-${testRunId}`; - const task3 = `Task3-${testRunId}`; + // Create test data on Client A + 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.workView.addTask(task1); + await clientA.workView.addTask(task2); + await clientA.workView.addTask(task3); - // Sync to "old server" - await clientA.sync.syncAndWait(); - console.log('[Test] Client A synced to initial server'); + // Sync to "old server" + await clientA.sync.syncAndWait(); + console.log('[Test] Client A synced to initial server'); - // Verify tasks exist on Client A - await waitForTask(clientA.page, task1); - await waitForTask(clientA.page, task2); - await waitForTask(clientA.page, task3); + // Verify tasks exist on Client A + await waitForTask(clientA.page, task1); + await waitForTask(clientA.page, task2); + await waitForTask(clientA.page, task3); - // === PHASE 2: Simulate server migration === - console.log('[Test] Phase 2: Migrating Client A to new server'); + // === PHASE 2: Simulate server migration === + console.log('[Test] Phase 2: Migrating Client A to new server'); - // Create a new test user (simulates a fresh/new server) - const user2 = await createTestUser(`${testRunId}-server2`); - const syncConfig2 = getSuperSyncConfig(user2); + // Create a new test user (simulates a fresh/new server) + const user2 = await createTestUser(`${testRunId}-server2`); + const syncConfig2 = getSuperSyncConfig(user2); - // Client A changes to new server credentials - // This simulates switching sync providers or migrating to a new server - await clientA.sync.setupSuperSync(syncConfig2); + // Client A changes to new server credentials + // This simulates switching sync providers or migrating to a new server + await clientA.sync.setupSuperSync(syncConfig2); - // Sync to the "new server" - // This is where the bug occurs: - // - Server detects gap (sinceSeq > 0 but server is empty) - // - Client resets lastServerSeq to 0 - // - Client should upload FULL STATE (not just pending ops) - await clientA.sync.syncAndWait(); - console.log('[Test] Client A synced to new server (migration complete)'); + // Sync to the "new server" + // This is where the bug occurs: + // - Server detects gap (sinceSeq > 0 but server is empty) + // - Client resets lastServerSeq to 0 + // - Client should upload FULL STATE (not just pending ops) + await clientA.sync.syncAndWait(); + console.log('[Test] Client A synced to new server (migration complete)'); - // Verify Client A still has all tasks after migration - await waitForTask(clientA.page, task1); - await waitForTask(clientA.page, task2); - await waitForTask(clientA.page, task3); + // Verify Client A still has all tasks after migration + await waitForTask(clientA.page, task1); + await waitForTask(clientA.page, task2); + await waitForTask(clientA.page, task3); - // === PHASE 3: Client B joins new server === - console.log('[Test] Phase 3: Client B joining new server'); + // === PHASE 3: Client B joins new server === + console.log('[Test] Phase 3: Client B joining new server'); - clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); - await clientB.sync.setupSuperSync(syncConfig2); + clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); + await clientB.sync.setupSuperSync(syncConfig2); - // Client B syncs - should receive ALL of Client A's data - await clientB.sync.syncAndWait(); - console.log('[Test] Client B synced with new server'); + // Client B syncs - should receive ALL of Client A's data + await clientB.sync.syncAndWait(); + console.log('[Test] Client B synced with new server'); - // === PHASE 4: Verification === - console.log('[Test] Phase 4: Verifying Client B has all data'); + // === PHASE 4: Verification === + console.log('[Test] Phase 4: Verifying Client B has all data'); - // This is the critical assertion - Client B should have ALL tasks - // If the bug exists, Client B will only have partial data or no tasks - await waitForTask(clientB.page, task1); - await waitForTask(clientB.page, task2); - await waitForTask(clientB.page, task3); + // This is the critical assertion - Client B should have ALL tasks + // If the bug exists, Client B will only have partial data or no tasks + await waitForTask(clientB.page, task1); + await waitForTask(clientB.page, task2); + await waitForTask(clientB.page, task3); - // Additional verification - count tasks to ensure no duplicates - const taskLocatorB = clientB.page.locator(`task:has-text("${testRunId}")`); - const taskCountB = await taskLocatorB.count(); - expect(taskCountB).toBe(3); + // Additional verification - count tasks to ensure no duplicates + const taskLocatorB = clientB.page.locator(`task:has-text("${testRunId}")`); + const taskCountB = await taskLocatorB.count(); + expect(taskCountB).toBe(3); - console.log('[Test] SUCCESS: Client B received all data after server migration'); - } finally { - if (clientA) await closeClient(clientA).catch(() => {}); - if (clientB) await closeClient(clientB).catch(() => {}); - } - }, - ); + console.log('[Test] SUCCESS: Client B received all data after server migration'); + } finally { + if (clientA) await closeClient(clientA).catch(() => {}); + if (clientB) await closeClient(clientB).catch(() => {}); + } + }); /** * Server Migration with Pending Local Changes @@ -163,134 +144,134 @@ base.describe.serial('@supersync SuperSync Server Migration', () => { * Tests that pending local operations are preserved during server migration. * Client creates new data, then migrates before syncing. */ - base( - 'Client A migrates with pending local changes, all data syncs', - async ({ browser, baseURL }, testInfo) => { - testInfo.setTimeout(120000); // 2 minutes - migration tests need extra time - const testRunId = generateTestRunId(testInfo.workerIndex); - let clientA: SimulatedE2EClient | null = null; - let clientB: SimulatedE2EClient | null = null; + test('Client A migrates with pending local changes, all data syncs', async ({ + browser, + baseURL, + testRunId, + }, testInfo) => { + testInfo.setTimeout(120000); // 2 minutes - migration tests need extra time + let clientA: SimulatedE2EClient | null = null; + let clientB: SimulatedE2EClient | null = null; - try { - // Setup with initial server - const user1 = await createTestUser(`${testRunId}-server1`); - const syncConfig1 = getSuperSyncConfig(user1); + try { + // Setup with initial server + const user1 = await createTestUser(`${testRunId}-server1`); + const syncConfig1 = getSuperSyncConfig(user1); - clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); - await clientA.sync.setupSuperSync(syncConfig1); + clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); + await clientA.sync.setupSuperSync(syncConfig1); - // Create and sync initial data - const task1 = `Initial-${testRunId}`; - await clientA.workView.addTask(task1); - // Wait for task to be fully created before syncing - await waitForTask(clientA.page, task1); - await clientA.sync.syncAndWait(); - console.log('[Test] Task 1 created and synced to server 1'); + // Create and sync initial data + const task1 = `Initial-${testRunId}`; + await clientA.workView.addTask(task1); + // Wait for task to be fully created before syncing + await waitForTask(clientA.page, task1); + await clientA.sync.syncAndWait(); + console.log('[Test] Task 1 created and synced to server 1'); - // Create MORE data AFTER syncing (pending local changes) - const task2 = `Pending-${testRunId}`; - await clientA.workView.addTask(task2); - // Wait for task to be fully created in store before migration - await waitForTask(clientA.page, task2); - console.log('[Test] Task 2 created (pending local change)'); - // DON'T sync yet - task2 is a pending local change + // Create MORE data AFTER syncing (pending local changes) + const task2 = `Pending-${testRunId}`; + await clientA.workView.addTask(task2); + // Wait for task to be fully created in store before migration + await waitForTask(clientA.page, task2); + console.log('[Test] Task 2 created (pending local change)'); + // DON'T sync yet - task2 is a pending local change - // Migrate to new server - const user2 = await createTestUser(`${testRunId}-server2`); - const syncConfig2 = getSuperSyncConfig(user2); - console.log('[Test] Migrating to new server...'); - await clientA.sync.setupSuperSync(syncConfig2); + // Migrate to new server + const user2 = await createTestUser(`${testRunId}-server2`); + const syncConfig2 = getSuperSyncConfig(user2); + console.log('[Test] Migrating to new server...'); + await clientA.sync.setupSuperSync(syncConfig2); - // Sync to new server (should include both synced and pending data) - await clientA.sync.syncAndWait(); - console.log('[Test] Migration sync completed'); + // Sync to new server (should include both synced and pending data) + await clientA.sync.syncAndWait(); + console.log('[Test] Migration sync completed'); - // Verify Client A still has both tasks after migration - await waitForTask(clientA.page, task1); - await waitForTask(clientA.page, task2); + // Verify Client A still has both tasks after migration + await waitForTask(clientA.page, task1); + await waitForTask(clientA.page, task2); - // Client B joins - clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); - await clientB.sync.setupSuperSync(syncConfig2); - // Add settling time before sync - await clientB.page.waitForTimeout(500); - await clientB.sync.syncAndWait(); + // Client B joins + clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); + await clientB.sync.setupSuperSync(syncConfig2); + // Add settling time before sync + await clientB.page.waitForTimeout(500); + await clientB.sync.syncAndWait(); - // Verify Client B has BOTH tasks - await waitForTask(clientB.page, task1); - await waitForTask(clientB.page, task2); + // Verify Client B has BOTH tasks + await waitForTask(clientB.page, task1); + await waitForTask(clientB.page, task2); - const taskLocatorB = clientB.page.locator(`task:has-text("${testRunId}")`); - const taskCountB = await taskLocatorB.count(); - expect(taskCountB).toBe(2); + const taskLocatorB = clientB.page.locator(`task:has-text("${testRunId}")`); + const taskCountB = await taskLocatorB.count(); + expect(taskCountB).toBe(2); - console.log('[Test] SUCCESS: Pending local changes preserved during migration'); - } finally { - if (clientA) await closeClient(clientA).catch(() => {}); - if (clientB) await closeClient(clientB).catch(() => {}); - } - }, - ); + console.log('[Test] SUCCESS: Pending local changes preserved during migration'); + } finally { + if (clientA) await closeClient(clientA).catch(() => {}); + if (clientB) await closeClient(clientB).catch(() => {}); + } + }); /** * Multiple Migrations: Client A migrates twice * * Tests that multiple server migrations work correctly. */ - base( - 'Client A can migrate multiple times without data loss', - async ({ browser, baseURL }, testInfo) => { - testInfo.setTimeout(120000); // 2 minutes - migration tests need extra time - const testRunId = generateTestRunId(testInfo.workerIndex); - let clientA: SimulatedE2EClient | null = null; - let clientB: SimulatedE2EClient | null = null; + test('Client A can migrate multiple times without data loss', async ({ + browser, + baseURL, + testRunId, + }, testInfo) => { + testInfo.setTimeout(120000); // 2 minutes - migration tests need extra time + let clientA: SimulatedE2EClient | null = null; + let clientB: SimulatedE2EClient | null = null; - try { - // First server - const user1 = await createTestUser(`${testRunId}-server1`); - clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); - await clientA.sync.setupSuperSync(getSuperSyncConfig(user1)); + try { + // First server + const user1 = await createTestUser(`${testRunId}-server1`); + clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); + await clientA.sync.setupSuperSync(getSuperSyncConfig(user1)); - const task1 = `Server1-${testRunId}`; - await clientA.workView.addTask(task1); - await clientA.sync.syncAndWait(); + const task1 = `Server1-${testRunId}`; + await clientA.workView.addTask(task1); + await clientA.sync.syncAndWait(); - // First migration - const user2 = await createTestUser(`${testRunId}-server2`); - await clientA.sync.setupSuperSync(getSuperSyncConfig(user2)); + // First migration + const user2 = await createTestUser(`${testRunId}-server2`); + await clientA.sync.setupSuperSync(getSuperSyncConfig(user2)); - const task2 = `Server2-${testRunId}`; - await clientA.workView.addTask(task2); - await clientA.sync.syncAndWait(); + const task2 = `Server2-${testRunId}`; + await clientA.workView.addTask(task2); + await clientA.sync.syncAndWait(); - // Second migration - const user3 = await createTestUser(`${testRunId}-server3`); - await clientA.sync.setupSuperSync(getSuperSyncConfig(user3)); + // Second migration + const user3 = await createTestUser(`${testRunId}-server3`); + await clientA.sync.setupSuperSync(getSuperSyncConfig(user3)); - const task3 = `Server3-${testRunId}`; - await clientA.workView.addTask(task3); - await clientA.sync.syncAndWait(); + const task3 = `Server3-${testRunId}`; + await clientA.workView.addTask(task3); + await clientA.sync.syncAndWait(); - // Verify Client A has all tasks - await waitForTask(clientA.page, task1); - await waitForTask(clientA.page, task2); - await waitForTask(clientA.page, task3); + // Verify Client A has all tasks + await waitForTask(clientA.page, task1); + await waitForTask(clientA.page, task2); + await waitForTask(clientA.page, task3); - // Client B joins the final server - clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); - await clientB.sync.setupSuperSync(getSuperSyncConfig(user3)); - await clientB.sync.syncAndWait(); + // Client B joins the final server + clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); + await clientB.sync.setupSuperSync(getSuperSyncConfig(user3)); + await clientB.sync.syncAndWait(); - // Client B should have ALL tasks from all migrations - await waitForTask(clientB.page, task1); - await waitForTask(clientB.page, task2); - await waitForTask(clientB.page, task3); + // Client B should have ALL tasks from all migrations + await waitForTask(clientB.page, task1); + await waitForTask(clientB.page, task2); + await waitForTask(clientB.page, task3); - console.log('[Test] SUCCESS: Multiple migrations preserved all data'); - } finally { - if (clientA) await closeClient(clientA).catch(() => {}); - if (clientB) await closeClient(clientB).catch(() => {}); - } - }, - ); + console.log('[Test] SUCCESS: Multiple migrations preserved all data'); + } finally { + if (clientA) await closeClient(clientA).catch(() => {}); + if (clientB) await closeClient(clientB).catch(() => {}); + } + }); }); diff --git a/e2e/tests/sync/supersync-simple-counter.spec.ts b/e2e/tests/sync/supersync-simple-counter.spec.ts index 6581972fd..4aa9cf939 100644 --- a/e2e/tests/sync/supersync-simple-counter.spec.ts +++ b/e2e/tests/sync/supersync-simple-counter.spec.ts @@ -1,10 +1,9 @@ -import { test as base, expect } from '@playwright/test'; +import { test, expect } from '../../fixtures/supersync.fixture'; import { createTestUser, getSuperSyncConfig, createSimulatedClient, closeClient, - isServerHealthy, type SimulatedE2EClient, } from '../../utils/supersync-helpers'; @@ -15,178 +14,160 @@ import { * sync correctly between clients using absolute values. */ -const generateTestRunId = (workerIndex: number): string => { - return `${Date.now()}-${workerIndex}`; +/** + * Helper to navigate to settings and create a simple counter + */ +const createSimpleCounter = async ( + client: SimulatedE2EClient, + title: string, + type: 'click' | 'stopwatch', +): Promise => { + // Navigate to settings using the correct selector + const settingsBtn = client.page.locator( + 'magic-side-nav .tour-settingsMenuBtn, magic-side-nav nav-item:has([icon="settings"]) button', + ); + await settingsBtn.waitFor({ state: 'visible', timeout: 15000 }); + await settingsBtn.click(); + await client.page.waitForURL(/config/); + await client.page.waitForTimeout(500); + + // Click on Simple Counters section (it's inside a collapsible component) + // The translated title is "Simple Counters & Habit Tracking" + // It's under "Productivity Helper" section, may need to scroll to see it + const simpleCountersSection = client.page.locator( + '.collapsible-header:has-text("Simple Counter")', + ); + + // Scroll to section and wait for it + await simpleCountersSection.scrollIntoViewIfNeeded(); + await simpleCountersSection.waitFor({ state: 'visible', timeout: 10000 }); + await simpleCountersSection.click(); + + // Wait for collapsible to expand + await client.page.waitForTimeout(500); + + // Click Add Counter button - text is "Add simple counter/ habit" + // This is a formly repeat type that adds fields inline (not a dialog) + // The repeat section type has a footer with the add button + const addBtn = client.page.locator( + 'repeat-section-type .footer button, button:has-text("Add simple counter")', + ); + await addBtn.scrollIntoViewIfNeeded(); + await addBtn.waitFor({ state: 'visible', timeout: 5000 }); + await addBtn.click(); + + // Wait for inline form fields to appear + await client.page.waitForTimeout(500); + + // Find the newly added counter row (last one in the list) + // The repeat section type creates .row elements inside .list-wrapper + const counterRows = client.page.locator('repeat-section-type .row'); + const lastCounterRow = counterRows.last(); + + // Fill title - find the title input in the last counter row + const titleInput = lastCounterRow.locator('input').first(); + await titleInput.scrollIntoViewIfNeeded(); + await titleInput.waitFor({ state: 'visible', timeout: 5000 }); + await titleInput.fill(title); + + // Select type - find the select in the last counter row + const typeSelect = lastCounterRow.locator('mat-select').first(); + await typeSelect.scrollIntoViewIfNeeded(); + await typeSelect.click(); + await client.page.waitForTimeout(300); + const typeOption = client.page.locator( + `mat-option:has-text("${type === 'click' ? 'Click Counter' : 'Stopwatch'}")`, + ); + await typeOption.click(); + + // Wait for dropdown to close + await client.page.waitForTimeout(300); + + // Save the form - the simple counter cfg has a Save button + const saveBtn = client.page.locator( + 'simple-counter-cfg button:has-text("Save"), .submit-button:has-text("Save")', + ); + await saveBtn.scrollIntoViewIfNeeded(); + await saveBtn.click(); + + // Wait for save to complete + await client.page.waitForTimeout(500); + + // Navigate back to work view using home button or similar + await client.page.goto('/#/tag/TODAY/tasks'); + await client.page.waitForURL(/(active\/tasks|tag\/TODAY\/tasks)/); + await client.page.waitForTimeout(500); }; -base.describe('@supersync Simple Counter Sync', () => { - let serverHealthy: boolean | null = null; +/** + * Helper to get the counter value from the header by title (using mat-tooltip) + * Desktop counters have [matTooltip]="title" which Angular renders as ng-reflect-message + */ +const getCounterValue = async ( + client: SimulatedE2EClient, + counterTitle: string, +): Promise => { + // Wait for simple counters to be rendered + await client.page.waitForTimeout(500); - base.beforeEach(async ({}, testInfo) => { - if (serverHealthy === null) { - serverHealthy = await isServerHealthy(); - if (!serverHealthy) { - console.warn( - 'SuperSync server not healthy at http://localhost:1901 - skipping tests', - ); - } - } - testInfo.skip(!serverHealthy, 'SuperSync server not running'); - }); + // Find the counter by its tooltip (title) + // Angular Material's matTooltip directive sets ng-reflect-message attribute + const counterBtn = client.page.locator( + `simple-counter-button[ng-reflect-message="${counterTitle}"]`, + ); - /** - * Helper to navigate to settings and create a simple counter - */ - const createSimpleCounter = async ( - client: SimulatedE2EClient, - title: string, - type: 'click' | 'stopwatch', - ): Promise => { - // Navigate to settings using the correct selector - const settingsBtn = client.page.locator( - 'magic-side-nav .tour-settingsMenuBtn, magic-side-nav nav-item:has([icon="settings"]) button', - ); - await settingsBtn.waitFor({ state: 'visible', timeout: 15000 }); - await settingsBtn.click(); - await client.page.waitForURL(/config/); - await client.page.waitForTimeout(500); + // If not found by ng-reflect, try finding by the wrapper + if (!(await counterBtn.isVisible({ timeout: 2000 }).catch(() => false))) { + // Alternative: find by checking all counters + const allCounters = client.page.locator('simple-counter-button'); + const count = await allCounters.count(); + console.log(`Found ${count} simple counter buttons`); - // Click on Simple Counters section (it's inside a collapsible component) - // The translated title is "Simple Counters & Habit Tracking" - // It's under "Productivity Helper" section, may need to scroll to see it - const simpleCountersSection = client.page.locator( - '.collapsible-header:has-text("Simple Counter")', - ); - - // Scroll to section and wait for it - await simpleCountersSection.scrollIntoViewIfNeeded(); - await simpleCountersSection.waitFor({ state: 'visible', timeout: 10000 }); - await simpleCountersSection.click(); - - // Wait for collapsible to expand - await client.page.waitForTimeout(500); - - // Click Add Counter button - text is "Add simple counter/ habit" - // This is a formly repeat type that adds fields inline (not a dialog) - // The repeat section type has a footer with the add button - const addBtn = client.page.locator( - 'repeat-section-type .footer button, button:has-text("Add simple counter")', - ); - await addBtn.scrollIntoViewIfNeeded(); - await addBtn.waitFor({ state: 'visible', timeout: 5000 }); - await addBtn.click(); - - // Wait for inline form fields to appear - await client.page.waitForTimeout(500); - - // Find the newly added counter row (last one in the list) - // The repeat section type creates .row elements inside .list-wrapper - const counterRows = client.page.locator('repeat-section-type .row'); - const lastCounterRow = counterRows.last(); - - // Fill title - find the title input in the last counter row - const titleInput = lastCounterRow.locator('input').first(); - await titleInput.scrollIntoViewIfNeeded(); - await titleInput.waitFor({ state: 'visible', timeout: 5000 }); - await titleInput.fill(title); - - // Select type - find the select in the last counter row - const typeSelect = lastCounterRow.locator('mat-select').first(); - await typeSelect.scrollIntoViewIfNeeded(); - await typeSelect.click(); - await client.page.waitForTimeout(300); - const typeOption = client.page.locator( - `mat-option:has-text("${type === 'click' ? 'Click Counter' : 'Stopwatch'}")`, - ); - await typeOption.click(); - - // Wait for dropdown to close - await client.page.waitForTimeout(300); - - // Save the form - the simple counter cfg has a Save button - const saveBtn = client.page.locator( - 'simple-counter-cfg button:has-text("Save"), .submit-button:has-text("Save")', - ); - await saveBtn.scrollIntoViewIfNeeded(); - await saveBtn.click(); - - // Wait for save to complete - await client.page.waitForTimeout(500); - - // Navigate back to work view using home button or similar - await client.page.goto('/#/tag/TODAY/tasks'); - await client.page.waitForURL(/(active\/tasks|tag\/TODAY\/tasks)/); - await client.page.waitForTimeout(500); - }; - - /** - * Helper to get the counter value from the header by title (using mat-tooltip) - * Desktop counters have [matTooltip]="title" which Angular renders as ng-reflect-message - */ - const getCounterValue = async ( - client: SimulatedE2EClient, - counterTitle: string, - ): Promise => { - // Wait for simple counters to be rendered - await client.page.waitForTimeout(500); - - // Find the counter by its tooltip (title) - // Angular Material's matTooltip directive sets ng-reflect-message attribute - const counterBtn = client.page.locator( - `simple-counter-button[ng-reflect-message="${counterTitle}"]`, - ); - - // If not found by ng-reflect, try finding by the wrapper - if (!(await counterBtn.isVisible({ timeout: 2000 }).catch(() => false))) { - // Alternative: find by checking all counters - const allCounters = client.page.locator('simple-counter-button'); - const count = await allCounters.count(); - console.log(`Found ${count} simple counter buttons`); - - // Return last counter's value if we can't find by title - if (count > 0) { - const lastCounter = allCounters.last(); - const label = lastCounter.locator('.label'); - if (await label.isVisible()) { - return (await label.textContent()) || '0'; - } - return '0'; - } - return '0'; - } - - await expect(counterBtn).toBeVisible({ timeout: 10000 }); - const label = counterBtn.locator('.label'); - // If no label exists (count is 0), return '0' - if (!(await label.isVisible())) { - return '0'; - } - return (await label.textContent()) || '0'; - }; - - /** - * Helper to increment a click counter by title - */ - const incrementClickCounter = async ( - client: SimulatedE2EClient, - counterTitle: string, - ): Promise => { - // Find the counter by its tooltip (title) - const counterBtn = client.page.locator( - `simple-counter-button[ng-reflect-message="${counterTitle}"]`, - ); - - // If not found by ng-reflect, use last counter - if (!(await counterBtn.isVisible({ timeout: 2000 }).catch(() => false))) { - const allCounters = client.page.locator('simple-counter-button'); + // Return last counter's value if we can't find by title + if (count > 0) { const lastCounter = allCounters.last(); - await lastCounter.locator('.main-btn').click(); - return; + const label = lastCounter.locator('.label'); + if (await label.isVisible()) { + return (await label.textContent()) || '0'; + } + return '0'; } + return '0'; + } - await counterBtn.locator('.main-btn').click(); - }; + await expect(counterBtn).toBeVisible({ timeout: 10000 }); + const label = counterBtn.locator('.label'); + // If no label exists (count is 0), return '0' + if (!(await label.isVisible())) { + return '0'; + } + return (await label.textContent()) || '0'; +}; +/** + * Helper to increment a click counter by title + */ +const incrementClickCounter = async ( + client: SimulatedE2EClient, + counterTitle: string, +): Promise => { + // Find the counter by its tooltip (title) + const counterBtn = client.page.locator( + `simple-counter-button[ng-reflect-message="${counterTitle}"]`, + ); + + // If not found by ng-reflect, use last counter + if (!(await counterBtn.isVisible({ timeout: 2000 }).catch(() => false))) { + const allCounters = client.page.locator('simple-counter-button'); + const lastCounter = allCounters.last(); + await lastCounter.locator('.main-btn').click(); + return; + } + + await counterBtn.locator('.main-btn').click(); +}; + +test.describe('@supersync Simple Counter Sync', () => { /** * Scenario: Click counter syncs with absolute value * @@ -200,68 +181,67 @@ base.describe('@supersync Simple Counter Sync', () => { * 4. Client B syncs * 5. Verify Client B sees the same value (3) */ - base( - 'Click counter syncs correctly between clients', - async ({ browser, baseURL }, testInfo) => { - testInfo.setTimeout(120000); - const testRunId = generateTestRunId(testInfo.workerIndex); - const uniqueId = Date.now(); - const counterTitle = `ClickTest-${uniqueId}`; - let clientA: SimulatedE2EClient | null = null; - let clientB: SimulatedE2EClient | null = null; + test('Click counter syncs correctly between clients', async ({ + browser, + baseURL, + testRunId, + }) => { + const uniqueId = Date.now(); + const counterTitle = `ClickTest-${uniqueId}`; + let clientA: SimulatedE2EClient | null = null; + let clientB: SimulatedE2EClient | null = null; - try { - const user = await createTestUser(testRunId); - const syncConfig = getSuperSyncConfig(user); + try { + const user = await createTestUser(testRunId); + const syncConfig = getSuperSyncConfig(user); - // ============ PHASE 1: Client A Setup & Increment ============ - clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); - await clientA.sync.setupSuperSync(syncConfig); + // ============ PHASE 1: Client A Setup & Increment ============ + clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); + await clientA.sync.setupSuperSync(syncConfig); - // Create click counter - await createSimpleCounter(clientA, counterTitle, 'click'); + // Create click counter + await createSimpleCounter(clientA, counterTitle, 'click'); - // Wait for counter to appear - await clientA.page.waitForTimeout(500); + // Wait for counter to appear + await clientA.page.waitForTimeout(500); - // Increment 3 times - for (let i = 0; i < 3; i++) { - await incrementClickCounter(clientA, counterTitle); - await clientA.page.waitForTimeout(200); - } - - // Verify Client A shows 3 - const valueA = await getCounterValue(clientA, counterTitle); - expect(valueA).toBe('3'); - console.log(`Client A counter value: ${valueA}`); - - // Sync A - await clientA.sync.syncAndWait(); - console.log('Client A synced.'); - - // ============ PHASE 2: Client B Sync & Verify ============ - clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); - await clientB.sync.setupSuperSync(syncConfig); - - // Sync B - await clientB.sync.syncAndWait(); - console.log('Client B synced.'); - - // Wait for UI to update - await clientB.page.waitForTimeout(1000); - - // Verify Client B sees the same value - const valueB = await getCounterValue(clientB, counterTitle); - console.log(`Client B counter value: ${valueB}`); - expect(valueB).toBe('3'); - - console.log('✓ Click counter sync verification passed!'); - } finally { - if (clientA) await closeClient(clientA); - if (clientB) await closeClient(clientB); + // Increment 3 times + for (let i = 0; i < 3; i++) { + await incrementClickCounter(clientA, counterTitle); + await clientA.page.waitForTimeout(200); } - }, - ); + + // Verify Client A shows 3 + const valueA = await getCounterValue(clientA, counterTitle); + expect(valueA).toBe('3'); + console.log(`Client A counter value: ${valueA}`); + + // Sync A + await clientA.sync.syncAndWait(); + console.log('Client A synced.'); + + // ============ PHASE 2: Client B Sync & Verify ============ + clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); + await clientB.sync.setupSuperSync(syncConfig); + + // Sync B + await clientB.sync.syncAndWait(); + console.log('Client B synced.'); + + // Wait for UI to update + await clientB.page.waitForTimeout(1000); + + // Verify Client B sees the same value + const valueB = await getCounterValue(clientB, counterTitle); + console.log(`Client B counter value: ${valueB}`); + expect(valueB).toBe('3'); + + console.log('✓ Click counter sync verification passed!'); + } finally { + if (clientA) await closeClient(clientA); + if (clientB) await closeClient(clientB); + } + }); /** * Scenario: Click counter on Client B doesn't get wrong value @@ -277,84 +257,83 @@ base.describe('@supersync Simple Counter Sync', () => { * 3. Client C syncs * 4. Verify Client C sees 3 (not 0 or any other wrong value) */ - base( - 'Click counter maintains correct value across multiple clients', - async ({ browser, baseURL }, testInfo) => { - testInfo.setTimeout(180000); - const testRunId = generateTestRunId(testInfo.workerIndex); - const uniqueId = Date.now(); - const counterTitle = `MultiClientClick-${uniqueId}`; - let clientA: SimulatedE2EClient | null = null; - let clientB: SimulatedE2EClient | null = null; - let clientC: SimulatedE2EClient | null = null; + test('Click counter maintains correct value across multiple clients', async ({ + browser, + baseURL, + testRunId, + }) => { + const uniqueId = Date.now(); + const counterTitle = `MultiClientClick-${uniqueId}`; + 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); + try { + const user = await createTestUser(testRunId); + const syncConfig = getSuperSyncConfig(user); - // ============ PHASE 1: Client A creates and increments ============ - clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); - await clientA.sync.setupSuperSync(syncConfig); + // ============ PHASE 1: Client A creates and increments ============ + clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); + await clientA.sync.setupSuperSync(syncConfig); - await createSimpleCounter(clientA, counterTitle, 'click'); - await clientA.page.waitForTimeout(500); + await createSimpleCounter(clientA, counterTitle, 'click'); + await clientA.page.waitForTimeout(500); - // Increment to 2 - await incrementClickCounter(clientA, counterTitle); - await clientA.page.waitForTimeout(200); - await incrementClickCounter(clientA, counterTitle); - await clientA.page.waitForTimeout(200); + // Increment to 2 + await incrementClickCounter(clientA, counterTitle); + await clientA.page.waitForTimeout(200); + await incrementClickCounter(clientA, counterTitle); + await clientA.page.waitForTimeout(200); - const valueA = await getCounterValue(clientA, counterTitle); - expect(valueA).toBe('2'); - console.log(`Client A counter value: ${valueA}`); + const valueA = await getCounterValue(clientA, counterTitle); + expect(valueA).toBe('2'); + console.log(`Client A counter value: ${valueA}`); - await clientA.sync.syncAndWait(); - console.log('Client A synced.'); + await clientA.sync.syncAndWait(); + console.log('Client A synced.'); - // ============ PHASE 2: Client B syncs and increments ============ - clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); - await clientB.sync.setupSuperSync(syncConfig); - await clientB.sync.syncAndWait(); - console.log('Client B initial sync done.'); + // ============ PHASE 2: Client B syncs and increments ============ + clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); + await clientB.sync.setupSuperSync(syncConfig); + await clientB.sync.syncAndWait(); + console.log('Client B initial sync done.'); - await clientB.page.waitForTimeout(1000); + await clientB.page.waitForTimeout(1000); - // Verify B got the value from A - let valueB = await getCounterValue(clientB, counterTitle); - expect(valueB).toBe('2'); - console.log(`Client B after sync: ${valueB}`); + // Verify B got the value from A + let valueB = await getCounterValue(clientB, counterTitle); + expect(valueB).toBe('2'); + console.log(`Client B after sync: ${valueB}`); - // B increments (should be 3) - await incrementClickCounter(clientB, counterTitle); - await clientB.page.waitForTimeout(200); + // B increments (should be 3) + await incrementClickCounter(clientB, counterTitle); + await clientB.page.waitForTimeout(200); - valueB = await getCounterValue(clientB, counterTitle); - expect(valueB).toBe('3'); - console.log(`Client B after increment: ${valueB}`); + valueB = await getCounterValue(clientB, counterTitle); + expect(valueB).toBe('3'); + console.log(`Client B after increment: ${valueB}`); - await clientB.sync.syncAndWait(); - console.log('Client B synced.'); + await clientB.sync.syncAndWait(); + console.log('Client B synced.'); - // ============ PHASE 3: Client C syncs and verifies ============ - clientC = await createSimulatedClient(browser, baseURL!, 'C', testRunId); - await clientC.sync.setupSuperSync(syncConfig); - await clientC.sync.syncAndWait(); - console.log('Client C synced.'); + // ============ PHASE 3: Client C syncs and verifies ============ + clientC = await createSimulatedClient(browser, baseURL!, 'C', testRunId); + await clientC.sync.setupSuperSync(syncConfig); + await clientC.sync.syncAndWait(); + console.log('Client C synced.'); - await clientC.page.waitForTimeout(1000); + await clientC.page.waitForTimeout(1000); - // Verify C sees 3 (not 0 or any other wrong value) - const valueC = await getCounterValue(clientC, counterTitle); - console.log(`Client C counter value: ${valueC}`); - expect(valueC).toBe('3'); + // Verify C sees 3 (not 0 or any other wrong value) + const valueC = await getCounterValue(clientC, counterTitle); + console.log(`Client C counter value: ${valueC}`); + expect(valueC).toBe('3'); - console.log('✓ Multi-client click counter sync verification passed!'); - } finally { - if (clientA) await closeClient(clientA); - if (clientB) await closeClient(clientB); - if (clientC) await closeClient(clientC); - } - }, - ); + console.log('✓ Multi-client click counter sync verification passed!'); + } finally { + if (clientA) await closeClient(clientA); + if (clientB) await closeClient(clientB); + if (clientC) await closeClient(clientC); + } + }); }); diff --git a/e2e/tests/sync/supersync-snapshot-vector-clock.spec.ts b/e2e/tests/sync/supersync-snapshot-vector-clock.spec.ts index 740c8c265..deb81cdad 100644 --- a/e2e/tests/sync/supersync-snapshot-vector-clock.spec.ts +++ b/e2e/tests/sync/supersync-snapshot-vector-clock.spec.ts @@ -1,11 +1,10 @@ -import { test as base, expect } from '@playwright/test'; +import { test, expect } from '../../fixtures/supersync.fixture'; import { createTestUser, getSuperSyncConfig, createSimulatedClient, closeClient, waitForTask, - isServerHealthy, type SimulatedE2EClient, } from '../../utils/supersync-helpers'; @@ -25,25 +24,7 @@ import { * other clients whose ops were skipped). */ -const generateTestRunId = (workerIndex: number): string => { - return `${Date.now()}-${workerIndex}`; -}; - -base.describe('@supersync SuperSync Snapshot Vector Clock', () => { - let serverHealthy: boolean | null = null; - - base.beforeEach(async ({}, testInfo) => { - if (serverHealthy === null) { - serverHealthy = await isServerHealthy(); - if (!serverHealthy) { - console.warn( - 'SuperSync server not healthy at http://localhost:1901 - skipping tests', - ); - } - } - testInfo.skip(!serverHealthy, 'SuperSync server not running'); - }); - +test.describe('@supersync SuperSync Snapshot Vector Clock', () => { /** * Scenario 1: Fresh client joins after snapshot optimization * @@ -63,153 +44,146 @@ base.describe('@supersync SuperSync Snapshot Vector Clock', () => { * With the fix, Client C receives snapshotVectorClock and can create * dominating updates. */ - base( - 'Fresh client syncs correctly after snapshot optimization (no infinite loop)', - async ({ browser, baseURL }, testInfo) => { - testInfo.setTimeout(120000); - const testRunId = generateTestRunId(testInfo.workerIndex); - const appUrl = baseURL || 'http://localhost:4242'; - let clientA: SimulatedE2EClient | null = null; - let clientB: SimulatedE2EClient | null = null; - let clientC: SimulatedE2EClient | null = null; + test('Fresh client syncs correctly after snapshot optimization (no infinite loop)', async ({ + browser, + baseURL, + testRunId, + }) => { + test.setTimeout(120000); + 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); + try { + const user = await createTestUser(testRunId); + const syncConfig = getSuperSyncConfig(user); - // === Phase 1: Client A creates initial data and syncs === - console.log('[SnapshotClock] Phase 1: Client A creates initial data'); - clientA = await createSimulatedClient(browser, appUrl, 'A', testRunId); - await clientA.sync.setupSuperSync(syncConfig); + // === Phase 1: Client A creates initial data and syncs === + console.log('[SnapshotClock] Phase 1: Client A creates initial data'); + clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); + await clientA.sync.setupSuperSync(syncConfig); - // Create several tasks to build up operation history - const taskA1 = `A1-${testRunId}`; - const taskA2 = `A2-${testRunId}`; - const taskA3 = `A3-${testRunId}`; - await clientA.workView.addTask(taskA1); - await waitForTask(clientA.page, taskA1); - await clientA.workView.addTask(taskA2); - await waitForTask(clientA.page, taskA2); - await clientA.workView.addTask(taskA3); - await waitForTask(clientA.page, taskA3); + // Create several tasks to build up operation history + const taskA1 = `A1-${testRunId}`; + const taskA2 = `A2-${testRunId}`; + const taskA3 = `A3-${testRunId}`; + await clientA.workView.addTask(taskA1); + await waitForTask(clientA.page, taskA1); + await clientA.workView.addTask(taskA2); + await waitForTask(clientA.page, taskA2); + await clientA.workView.addTask(taskA3); + await waitForTask(clientA.page, taskA3); - // Sync to server - await clientA.sync.syncAndWait(); - console.log('[SnapshotClock] Client A synced initial tasks'); + // Sync to server + await clientA.sync.syncAndWait(); + console.log('[SnapshotClock] Client A synced initial tasks'); - // === Phase 2: Client B joins and makes changes === - console.log('[SnapshotClock] Phase 2: Client B joins and makes changes'); - clientB = await createSimulatedClient(browser, appUrl, 'B', testRunId); - await clientB.sync.setupSuperSync(syncConfig); - await clientB.sync.syncAndWait(); + // === Phase 2: Client B joins and makes changes === + console.log('[SnapshotClock] Phase 2: Client B joins and makes changes'); + clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); + await clientB.sync.setupSuperSync(syncConfig); + await clientB.sync.syncAndWait(); - // Verify B has all tasks - await waitForTask(clientB.page, taskA1); - await waitForTask(clientB.page, taskA2); - await waitForTask(clientB.page, taskA3); + // Verify B has all tasks + await waitForTask(clientB.page, taskA1); + await waitForTask(clientB.page, taskA2); + await waitForTask(clientB.page, taskA3); - // Client B creates more tasks (these will be after any potential snapshot) - const taskB1 = `B1-${testRunId}`; - const taskB2 = `B2-${testRunId}`; - await clientB.workView.addTask(taskB1); - await waitForTask(clientB.page, taskB1); - await clientB.workView.addTask(taskB2); - await waitForTask(clientB.page, taskB2); + // Client B creates more tasks (these will be after any potential snapshot) + const taskB1 = `B1-${testRunId}`; + const taskB2 = `B2-${testRunId}`; + await clientB.workView.addTask(taskB1); + await waitForTask(clientB.page, taskB1); + await clientB.workView.addTask(taskB2); + await waitForTask(clientB.page, taskB2); - // Client B syncs - await clientB.sync.syncAndWait(); - console.log('[SnapshotClock] Client B created and synced tasks'); + // Client B syncs + await clientB.sync.syncAndWait(); + console.log('[SnapshotClock] Client B created and synced tasks'); - // Client A syncs to get B's changes - await clientA.sync.syncAndWait(); - await waitForTask(clientA.page, taskB1); - await waitForTask(clientA.page, taskB2); + // Client A syncs to get B's changes + await clientA.sync.syncAndWait(); + await waitForTask(clientA.page, taskB1); + await waitForTask(clientA.page, taskB2); - // === Phase 3: Client C joins fresh (simulates snapshot optimization) === - console.log('[SnapshotClock] Phase 3: Client C joins fresh'); - clientC = await createSimulatedClient(browser, appUrl, 'C', testRunId); - await clientC.sync.setupSuperSync(syncConfig); + // === Phase 3: Client C joins fresh (simulates snapshot optimization) === + console.log('[SnapshotClock] Phase 3: Client C joins fresh'); + clientC = await createSimulatedClient(browser, baseURL!, 'C', testRunId); + await clientC.sync.setupSuperSync(syncConfig); - // Initial sync - Client C downloads all data - // Server may use snapshot optimization here depending on server state - await clientC.sync.syncAndWait(); + // Initial sync - Client C downloads all data + // Server may use snapshot optimization here depending on server state + await clientC.sync.syncAndWait(); - // Verify C has all existing tasks - await waitForTask(clientC.page, taskA1); - await waitForTask(clientC.page, taskA2); - await waitForTask(clientC.page, taskA3); - await waitForTask(clientC.page, taskB1); - await waitForTask(clientC.page, taskB2); - console.log('[SnapshotClock] Client C downloaded all tasks'); + // Verify C has all existing tasks + await waitForTask(clientC.page, taskA1); + await waitForTask(clientC.page, taskA2); + await waitForTask(clientC.page, taskA3); + await waitForTask(clientC.page, taskB1); + await waitForTask(clientC.page, taskB2); + console.log('[SnapshotClock] Client C downloaded all tasks'); - // === Phase 4: Client C makes changes === - // This is the critical part - C's changes need to sync without getting stuck - console.log('[SnapshotClock] Phase 4: Client C makes changes'); - const taskC1 = `C1-${testRunId}`; - const taskC2 = `C2-${testRunId}`; - await clientC.workView.addTask(taskC1); - await waitForTask(clientC.page, taskC1); - await clientC.workView.addTask(taskC2); - await waitForTask(clientC.page, taskC2); + // === Phase 4: Client C makes changes === + // This is the critical part - C's changes need to sync without getting stuck + console.log('[SnapshotClock] Phase 4: Client C makes changes'); + const taskC1 = `C1-${testRunId}`; + const taskC2 = `C2-${testRunId}`; + await clientC.workView.addTask(taskC1); + await waitForTask(clientC.page, taskC1); + await clientC.workView.addTask(taskC2); + await waitForTask(clientC.page, taskC2); - // Client C syncs - without the fix, this could get stuck in infinite loop - // Set a shorter timeout to catch infinite loops quickly - const syncPromise = clientC.sync.syncAndWait(); - const timeoutPromise = new Promise((_, reject) => - setTimeout( - () => reject(new Error('Sync timed out - possible infinite loop')), - 30000, - ), - ); + // Client C syncs - without the fix, this could get stuck in infinite loop + // Set a shorter timeout to catch infinite loops quickly + const syncPromise = clientC.sync.syncAndWait(); + const timeoutPromise = new Promise((_, reject) => + setTimeout( + () => reject(new Error('Sync timed out - possible infinite loop')), + 30000, + ), + ); - await Promise.race([syncPromise, timeoutPromise]); - console.log('[SnapshotClock] Client C synced successfully (no infinite loop)'); + await Promise.race([syncPromise, timeoutPromise]); + console.log('[SnapshotClock] Client C synced successfully (no infinite loop)'); - // === Phase 5: Verify all clients converge === - console.log('[SnapshotClock] Phase 5: Verifying convergence'); + // === Phase 5: Verify all clients converge === + console.log('[SnapshotClock] Phase 5: Verifying convergence'); - // Sync all clients to converge - await clientA.sync.syncAndWait(); - await clientB.sync.syncAndWait(); - await clientC.sync.syncAndWait(); + // Sync all clients to converge + await clientA.sync.syncAndWait(); + await clientB.sync.syncAndWait(); + await clientC.sync.syncAndWait(); - // Wait for UI to settle - await clientA.page.waitForTimeout(1000); - await clientB.page.waitForTimeout(1000); - await clientC.page.waitForTimeout(1000); + // Wait for UI to settle + await clientA.page.waitForTimeout(1000); + await clientB.page.waitForTimeout(1000); + await clientC.page.waitForTimeout(1000); - // All tasks should be present on all clients - const allTasks = [taskA1, taskA2, taskA3, taskB1, taskB2, taskC1, taskC2]; + // All tasks should be present on all clients + const allTasks = [taskA1, taskA2, taskA3, taskB1, taskB2, taskC1, taskC2]; - for (const task of allTasks) { - await waitForTask(clientA.page, task); - await waitForTask(clientB.page, task); - await waitForTask(clientC.page, task); - } - - // Count tasks to ensure consistency - const countA = await clientA.page - .locator(`task:has-text("${testRunId}")`) - .count(); - const countB = await clientB.page - .locator(`task:has-text("${testRunId}")`) - .count(); - const countC = await clientC.page - .locator(`task:has-text("${testRunId}")`) - .count(); - - expect(countA).toBe(7); - expect(countB).toBe(7); - expect(countC).toBe(7); - - console.log('[SnapshotClock] ✓ All clients converged with 7 tasks each'); - } finally { - if (clientA) await closeClient(clientA); - if (clientB) await closeClient(clientB); - if (clientC) await closeClient(clientC); + for (const task of allTasks) { + await waitForTask(clientA.page, task); + await waitForTask(clientB.page, task); + await waitForTask(clientC.page, task); } - }, - ); + + // Count tasks to ensure consistency + const countA = await clientA.page.locator(`task:has-text("${testRunId}")`).count(); + const countB = await clientB.page.locator(`task:has-text("${testRunId}")`).count(); + const countC = await clientC.page.locator(`task:has-text("${testRunId}")`).count(); + + expect(countA).toBe(7); + expect(countB).toBe(7); + expect(countC).toBe(7); + + console.log('[SnapshotClock] ✓ All clients converged with 7 tasks each'); + } finally { + if (clientA) await closeClient(clientA); + if (clientB) await closeClient(clientB); + if (clientC) await closeClient(clientC); + } + }); /** * Scenario 2: Multiple fresh clients joining sequentially @@ -217,125 +191,118 @@ base.describe('@supersync SuperSync Snapshot Vector Clock', () => { * Tests that multiple clients can join fresh and sync correctly, * each receiving proper snapshotVectorClock values. */ - base( - 'Multiple fresh clients join and sync correctly after snapshot', - async ({ browser, baseURL }, testInfo) => { - testInfo.setTimeout(180000); - const testRunId = generateTestRunId(testInfo.workerIndex); - const appUrl = baseURL || 'http://localhost:4242'; - let clientA: SimulatedE2EClient | null = null; - let clientB: SimulatedE2EClient | null = null; - let clientC: SimulatedE2EClient | null = null; - let clientD: SimulatedE2EClient | null = null; + test('Multiple fresh clients join and sync correctly after snapshot', async ({ + browser, + baseURL, + testRunId, + }) => { + test.setTimeout(180000); + let clientA: SimulatedE2EClient | null = null; + let clientB: SimulatedE2EClient | null = null; + let clientC: SimulatedE2EClient | null = null; + let clientD: SimulatedE2EClient | null = null; - try { - const user = await createTestUser(testRunId); - const syncConfig = getSuperSyncConfig(user); + try { + const user = await createTestUser(testRunId); + const syncConfig = getSuperSyncConfig(user); - // Client A: Creates initial data - clientA = await createSimulatedClient(browser, appUrl, 'A', testRunId); - await clientA.sync.setupSuperSync(syncConfig); + // Client A: Creates initial data + clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); + await clientA.sync.setupSuperSync(syncConfig); - const taskA1 = `A1-${testRunId}`; - await clientA.workView.addTask(taskA1); - await waitForTask(clientA.page, taskA1); - await clientA.sync.syncAndWait(); - console.log('[MultiClient] Client A created initial task'); + const taskA1 = `A1-${testRunId}`; + await clientA.workView.addTask(taskA1); + await waitForTask(clientA.page, taskA1); + await clientA.sync.syncAndWait(); + console.log('[MultiClient] Client A created initial task'); - // Client B: Joins, adds data - clientB = await createSimulatedClient(browser, appUrl, 'B', testRunId); - await clientB.sync.setupSuperSync(syncConfig); - await clientB.sync.syncAndWait(); - await waitForTask(clientB.page, taskA1); + // Client B: Joins, adds data + clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); + await clientB.sync.setupSuperSync(syncConfig); + await clientB.sync.syncAndWait(); + await waitForTask(clientB.page, taskA1); - const taskB1 = `B1-${testRunId}`; - await clientB.workView.addTask(taskB1); - await waitForTask(clientB.page, taskB1); - await clientB.sync.syncAndWait(); - console.log('[MultiClient] Client B joined and added task'); + const taskB1 = `B1-${testRunId}`; + await clientB.workView.addTask(taskB1); + await waitForTask(clientB.page, taskB1); + await clientB.sync.syncAndWait(); + console.log('[MultiClient] Client B joined and added task'); - // Close Client B to simulate it going offline - await closeClient(clientB); - clientB = null; + // Close Client B to simulate it going offline + await closeClient(clientB); + clientB = null; - // Client A continues working - const taskA2 = `A2-${testRunId}`; - await clientA.workView.addTask(taskA2); - await waitForTask(clientA.page, taskA2); - await clientA.sync.syncAndWait(); + // Client A continues working + const taskA2 = `A2-${testRunId}`; + await clientA.workView.addTask(taskA2); + await waitForTask(clientA.page, taskA2); + await clientA.sync.syncAndWait(); - // Client C: Joins fresh (never saw Client B's changes locally) - clientC = await createSimulatedClient(browser, appUrl, 'C', testRunId); - await clientC.sync.setupSuperSync(syncConfig); - await clientC.sync.syncAndWait(); + // Client C: Joins fresh (never saw Client B's changes locally) + clientC = await createSimulatedClient(browser, baseURL!, 'C', testRunId); + await clientC.sync.setupSuperSync(syncConfig); + await clientC.sync.syncAndWait(); - // C should have all tasks from A and B - await waitForTask(clientC.page, taskA1); - await waitForTask(clientC.page, taskA2); - await waitForTask(clientC.page, taskB1); + // C should have all tasks from A and B + await waitForTask(clientC.page, taskA1); + await waitForTask(clientC.page, taskA2); + await waitForTask(clientC.page, taskB1); - // C adds its own task - const taskC1 = `C1-${testRunId}`; - await clientC.workView.addTask(taskC1); - await waitForTask(clientC.page, taskC1); - await clientC.sync.syncAndWait(); - console.log('[MultiClient] Client C joined and added task'); + // C adds its own task + const taskC1 = `C1-${testRunId}`; + await clientC.workView.addTask(taskC1); + await waitForTask(clientC.page, taskC1); + await clientC.sync.syncAndWait(); + console.log('[MultiClient] Client C joined and added task'); - // Client D: Another fresh client joins - clientD = await createSimulatedClient(browser, appUrl, 'D', testRunId); - await clientD.sync.setupSuperSync(syncConfig); - await clientD.sync.syncAndWait(); + // Client D: Another fresh client joins + clientD = await createSimulatedClient(browser, baseURL!, 'D', testRunId); + await clientD.sync.setupSuperSync(syncConfig); + await clientD.sync.syncAndWait(); - // D should have all tasks - await waitForTask(clientD.page, taskA1); - await waitForTask(clientD.page, taskA2); - await waitForTask(clientD.page, taskB1); - await waitForTask(clientD.page, taskC1); + // D should have all tasks + await waitForTask(clientD.page, taskA1); + await waitForTask(clientD.page, taskA2); + await waitForTask(clientD.page, taskB1); + await waitForTask(clientD.page, taskC1); - // D adds its own task - const taskD1 = `D1-${testRunId}`; - await clientD.workView.addTask(taskD1); - await waitForTask(clientD.page, taskD1); - await clientD.sync.syncAndWait(); - console.log('[MultiClient] Client D joined and added task'); + // D adds its own task + const taskD1 = `D1-${testRunId}`; + await clientD.workView.addTask(taskD1); + await waitForTask(clientD.page, taskD1); + await clientD.sync.syncAndWait(); + console.log('[MultiClient] Client D joined and added task'); - // Final sync for all active clients - await clientA.sync.syncAndWait(); - await clientC.sync.syncAndWait(); - await clientD.sync.syncAndWait(); + // Final sync for all active clients + await clientA.sync.syncAndWait(); + await clientC.sync.syncAndWait(); + await clientD.sync.syncAndWait(); - // Verify all active clients have all 5 tasks - const allTasks = [taskA1, taskA2, taskB1, taskC1, taskD1]; + // Verify all active clients have all 5 tasks + const allTasks = [taskA1, taskA2, taskB1, taskC1, taskD1]; - for (const task of allTasks) { - await waitForTask(clientA.page, task); - await waitForTask(clientC.page, task); - await waitForTask(clientD.page, task); - } - - const countA = await clientA.page - .locator(`task:has-text("${testRunId}")`) - .count(); - const countC = await clientC.page - .locator(`task:has-text("${testRunId}")`) - .count(); - const countD = await clientD.page - .locator(`task:has-text("${testRunId}")`) - .count(); - - expect(countA).toBe(5); - expect(countC).toBe(5); - expect(countD).toBe(5); - - console.log('[MultiClient] ✓ All clients converged with 5 tasks each'); - } finally { - if (clientA) await closeClient(clientA); - if (clientB) await closeClient(clientB); - if (clientC) await closeClient(clientC); - if (clientD) await closeClient(clientD); + for (const task of allTasks) { + await waitForTask(clientA.page, task); + await waitForTask(clientC.page, task); + await waitForTask(clientD.page, task); } - }, - ); + + const countA = await clientA.page.locator(`task:has-text("${testRunId}")`).count(); + const countC = await clientC.page.locator(`task:has-text("${testRunId}")`).count(); + const countD = await clientD.page.locator(`task:has-text("${testRunId}")`).count(); + + expect(countA).toBe(5); + expect(countC).toBe(5); + expect(countD).toBe(5); + + console.log('[MultiClient] ✓ All clients converged with 5 tasks each'); + } finally { + if (clientA) await closeClient(clientA); + if (clientB) await closeClient(clientB); + if (clientC) await closeClient(clientC); + if (clientD) await closeClient(clientD); + } + }); /** * Scenario 3: Fresh client with concurrent modifications @@ -344,117 +311,112 @@ base.describe('@supersync SuperSync Snapshot Vector Clock', () => { * has concurrent modifications with an existing client. This is * the most likely scenario to trigger the infinite loop bug. */ - base( - 'Fresh client handles concurrent modifications after snapshot', - async ({ browser, baseURL }, testInfo) => { - testInfo.setTimeout(120000); - const testRunId = generateTestRunId(testInfo.workerIndex); - const appUrl = baseURL || 'http://localhost:4242'; - let clientA: SimulatedE2EClient | null = null; - let clientB: SimulatedE2EClient | null = null; + test('Fresh client handles concurrent modifications after snapshot', async ({ + browser, + baseURL, + testRunId, + }) => { + test.setTimeout(120000); + let clientA: SimulatedE2EClient | null = null; + let clientB: SimulatedE2EClient | null = null; - try { - const user = await createTestUser(testRunId); - const syncConfig = getSuperSyncConfig(user); + try { + const user = await createTestUser(testRunId); + const syncConfig = getSuperSyncConfig(user); - // Client A: Establishes baseline - clientA = await createSimulatedClient(browser, appUrl, 'A', testRunId); - await clientA.sync.setupSuperSync(syncConfig); + // Client A: Establishes baseline + clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); + await clientA.sync.setupSuperSync(syncConfig); - // Create initial tasks - const sharedTask = `Shared-${testRunId}`; - await clientA.workView.addTask(sharedTask); - await waitForTask(clientA.page, sharedTask); - await clientA.sync.syncAndWait(); - console.log('[ConcurrentFresh] Client A created shared task'); + // Create initial tasks + const sharedTask = `Shared-${testRunId}`; + await clientA.workView.addTask(sharedTask); + await waitForTask(clientA.page, sharedTask); + await clientA.sync.syncAndWait(); + console.log('[ConcurrentFresh] Client A created shared task'); - // Add more operations to build up history - for (let i = 0; i < 5; i++) { - await clientA.workView.addTask(`Filler-${i}-${testRunId}`); - } - await clientA.sync.syncAndWait(); - console.log('[ConcurrentFresh] Client A added filler tasks'); - - // Client B: Joins fresh - clientB = await createSimulatedClient(browser, appUrl, 'B', testRunId); - await clientB.sync.setupSuperSync(syncConfig); - await clientB.sync.syncAndWait(); - - // Verify B has all tasks - await waitForTask(clientB.page, sharedTask); - console.log('[ConcurrentFresh] Client B joined and synced'); - - // Now create concurrent modifications - // Client A: Marks shared task as done - const taskLocatorA = clientA.page - .locator(`task:not(.ng-animating):has-text("${sharedTask}")`) - .first(); - await taskLocatorA.hover(); - await taskLocatorA.locator('.task-done-btn').click(); - await expect(taskLocatorA).toHaveClass(/isDone/, { timeout: 5000 }); - console.log('[ConcurrentFresh] Client A marked shared task done'); - - // Client B: Also marks shared task as done (concurrent change) - const taskLocatorB = clientB.page - .locator(`task:not(.ng-animating):has-text("${sharedTask}")`) - .first(); - await taskLocatorB.hover(); - await taskLocatorB.locator('.task-done-btn').click(); - await expect(taskLocatorB).toHaveClass(/isDone/, { timeout: 5000 }); - console.log('[ConcurrentFresh] Client B marked shared task done (concurrent)'); - - // Client A syncs first - await clientA.sync.syncAndWait(); - - // Client B syncs - this is where the infinite loop could occur - // without the snapshotVectorClock fix - const syncPromise = clientB.sync.syncAndWait(); - const timeoutPromise = new Promise((_, reject) => - setTimeout( - () => reject(new Error('Sync timed out - possible infinite loop')), - 30000, - ), - ); - - await Promise.race([syncPromise, timeoutPromise]); - console.log('[ConcurrentFresh] Client B synced without infinite loop'); - - // Final sync to ensure convergence - await clientA.sync.syncAndWait(); - await clientB.sync.syncAndWait(); - - // Wait for UI to settle - await clientA.page.waitForTimeout(500); - await clientB.page.waitForTimeout(500); - - // Verify both clients show the shared task as done - await waitForTask(clientA.page, sharedTask); - await waitForTask(clientB.page, sharedTask); - - const finalTaskA = clientA.page - .locator(`task:not(.ng-animating):has-text("${sharedTask}")`) - .first(); - const finalTaskB = clientB.page - .locator(`task:not(.ng-animating):has-text("${sharedTask}")`) - .first(); - - await expect(finalTaskA).toHaveClass(/isDone/, { timeout: 5000 }); - await expect(finalTaskB).toHaveClass(/isDone/, { timeout: 5000 }); - - // Verify task counts match - const countA = await clientA.page - .locator(`task:has-text("${testRunId}")`) - .count(); - const countB = await clientB.page - .locator(`task:has-text("${testRunId}")`) - .count(); - expect(countA).toBe(countB); - - console.log('[ConcurrentFresh] ✓ Concurrent modifications resolved correctly'); - } finally { - if (clientA) await closeClient(clientA); - if (clientB) await closeClient(clientB); + // Add more operations to build up history + for (let i = 0; i < 5; i++) { + await clientA.workView.addTask(`Filler-${i}-${testRunId}`); } - }, - ); + await clientA.sync.syncAndWait(); + console.log('[ConcurrentFresh] Client A added filler tasks'); + + // Client B: Joins fresh + clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); + await clientB.sync.setupSuperSync(syncConfig); + await clientB.sync.syncAndWait(); + + // Verify B has all tasks + await waitForTask(clientB.page, sharedTask); + console.log('[ConcurrentFresh] Client B joined and synced'); + + // Now create concurrent modifications + // Client A: Marks shared task as done + const taskLocatorA = clientA.page + .locator(`task:not(.ng-animating):has-text("${sharedTask}")`) + .first(); + await taskLocatorA.hover(); + await taskLocatorA.locator('.task-done-btn').click(); + await expect(taskLocatorA).toHaveClass(/isDone/, { timeout: 5000 }); + console.log('[ConcurrentFresh] Client A marked shared task done'); + + // Client B: Also marks shared task as done (concurrent change) + const taskLocatorB = clientB.page + .locator(`task:not(.ng-animating):has-text("${sharedTask}")`) + .first(); + await taskLocatorB.hover(); + await taskLocatorB.locator('.task-done-btn').click(); + await expect(taskLocatorB).toHaveClass(/isDone/, { timeout: 5000 }); + console.log('[ConcurrentFresh] Client B marked shared task done (concurrent)'); + + // Client A syncs first + await clientA.sync.syncAndWait(); + + // Client B syncs - this is where the infinite loop could occur + // without the snapshotVectorClock fix + const syncPromise = clientB.sync.syncAndWait(); + const timeoutPromise = new Promise((_, reject) => + setTimeout( + () => reject(new Error('Sync timed out - possible infinite loop')), + 30000, + ), + ); + + await Promise.race([syncPromise, timeoutPromise]); + console.log('[ConcurrentFresh] Client B synced without infinite loop'); + + // Final sync to ensure convergence + await clientA.sync.syncAndWait(); + await clientB.sync.syncAndWait(); + + // Wait for UI to settle + await clientA.page.waitForTimeout(500); + await clientB.page.waitForTimeout(500); + + // Verify both clients show the shared task as done + await waitForTask(clientA.page, sharedTask); + await waitForTask(clientB.page, sharedTask); + + const finalTaskA = clientA.page + .locator(`task:not(.ng-animating):has-text("${sharedTask}")`) + .first(); + const finalTaskB = clientB.page + .locator(`task:not(.ng-animating):has-text("${sharedTask}")`) + .first(); + + await expect(finalTaskA).toHaveClass(/isDone/, { timeout: 5000 }); + await expect(finalTaskB).toHaveClass(/isDone/, { timeout: 5000 }); + + // Verify task counts match + const countA = await clientA.page.locator(`task:has-text("${testRunId}")`).count(); + const countB = await clientB.page.locator(`task:has-text("${testRunId}")`).count(); + expect(countA).toBe(countB); + + console.log('[ConcurrentFresh] ✓ Concurrent modifications resolved correctly'); + } finally { + if (clientA) await closeClient(clientA); + if (clientB) await closeClient(clientB); + } + }); }); diff --git a/e2e/tests/sync/supersync-stale-clock-regression.spec.ts b/e2e/tests/sync/supersync-stale-clock-regression.spec.ts index 8a3a662c5..fbef18cc9 100644 --- a/e2e/tests/sync/supersync-stale-clock-regression.spec.ts +++ b/e2e/tests/sync/supersync-stale-clock-regression.spec.ts @@ -1,11 +1,10 @@ -import { test as base, expect } from '@playwright/test'; +import { test, expect } from '../../fixtures/supersync.fixture'; import { createTestUser, getSuperSyncConfig, createSimulatedClient, closeClient, waitForTask, - isServerHealthy, type SimulatedE2EClient, } from '../../utils/supersync-helpers'; import { ImportPage } from '../../pages/import.page'; @@ -37,25 +36,7 @@ import { waitForAppReady } from '../../utils/waits'; * Run with: npm run e2e:supersync:file e2e/tests/sync/supersync-stale-clock-regression.spec.ts */ -const generateTestRunId = (workerIndex: number): string => { - return `${Date.now()}-${workerIndex}`; -}; - -base.describe('@supersync @regression Stale Clock Regression', () => { - let serverHealthy: boolean | null = null; - - base.beforeEach(async ({}, testInfo) => { - if (serverHealthy === null) { - serverHealthy = await isServerHealthy(); - if (!serverHealthy) { - console.warn( - 'SuperSync server not healthy at http://localhost:1901 - skipping tests', - ); - } - } - testInfo.skip(!serverHealthy, 'SuperSync server not running'); - }); - +test.describe('@supersync @regression Stale Clock Regression', () => { /** * Regression test: Operations created during SYNC_IMPORT hydration use correct clock * @@ -81,153 +62,153 @@ base.describe('@supersync @regression Stale Clock Regression', () => { * - No sync errors on either client * - B's reload + sync didn't cause data loss */ - base( - 'Operations created during SYNC_IMPORT hydration use merged clock (regression)', - async ({ browser, baseURL }, testInfo) => { - const testRunId = generateTestRunId(testInfo.workerIndex); - const uniqueId = Date.now(); - let clientA: SimulatedE2EClient | null = null; - let clientB: SimulatedE2EClient | null = null; + test('Operations created during SYNC_IMPORT hydration use merged clock (regression)', async ({ + browser, + baseURL, + testRunId, + }) => { + const uniqueId = Date.now(); + let clientA: SimulatedE2EClient | null = null; + let clientB: SimulatedE2EClient | null = null; - try { - const user = await createTestUser(testRunId); - const syncConfig = getSuperSyncConfig(user); + try { + const user = await createTestUser(testRunId); + const syncConfig = getSuperSyncConfig(user); - // ============ PHASE 1: Setup Both Clients ============ - console.log('[Stale Clock] Phase 1: Setting up both clients'); + // ============ PHASE 1: Setup Both Clients ============ + console.log('[Stale Clock] Phase 1: Setting up both clients'); - clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); - await clientA.sync.setupSuperSync(syncConfig); + clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); + await clientA.sync.setupSuperSync(syncConfig); - clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); - await clientB.sync.setupSuperSync(syncConfig); + clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); + await clientB.sync.setupSuperSync(syncConfig); - // Initial sync to establish vector clocks - await clientA.sync.syncAndWait(); - await clientB.sync.syncAndWait(); - console.log('[Stale Clock] Both clients synced initially'); + // Initial sync to establish vector clocks + await clientA.sync.syncAndWait(); + await clientB.sync.syncAndWait(); + console.log('[Stale Clock] Both clients synced initially'); - // ============ PHASE 2: Create Task Scheduled for Today ============ - console.log('[Stale Clock] Phase 2: Creating task scheduled for today'); + // ============ PHASE 2: Create Task Scheduled for Today ============ + console.log('[Stale Clock] Phase 2: Creating task scheduled for today'); - // This task will affect TODAY_TAG, which may trigger repair during hydration - const taskToday = `Today-Task-${uniqueId}`; - await clientA.workView.addTask(taskToday); + // This task will affect TODAY_TAG, which may trigger repair during hydration + const taskToday = `Today-Task-${uniqueId}`; + await clientA.workView.addTask(taskToday); - // Schedule for today by opening task details and setting due date - // Note: Using the planner page or task context menu might be needed - // For simplicity, just create a regular task - TODAY_TAG repair happens - // when there's any state inconsistency during loadAllData + // Schedule for today by opening task details and setting due date + // Note: Using the planner page or task context menu might be needed + // For simplicity, just create a regular task - TODAY_TAG repair happens + // when there's any state inconsistency during loadAllData - await clientA.sync.syncAndWait(); - console.log(`[Stale Clock] Client A created and synced: ${taskToday}`); + await clientA.sync.syncAndWait(); + console.log(`[Stale Clock] Client A created and synced: ${taskToday}`); - await clientB.sync.syncAndWait(); - await waitForTask(clientB.page, taskToday); - console.log('[Stale Clock] Client B received the task'); + await clientB.sync.syncAndWait(); + await waitForTask(clientB.page, taskToday); + console.log('[Stale Clock] Client B received the task'); - // ============ PHASE 3: Client A Imports Backup ============ - console.log('[Stale Clock] Phase 3: Client A importing backup'); + // ============ PHASE 3: Client A Imports Backup ============ + console.log('[Stale Clock] Phase 3: Client A importing backup'); - const importPage = new ImportPage(clientA.page); - await importPage.navigateToImportPage(); + const importPage = new ImportPage(clientA.page); + await importPage.navigateToImportPage(); - // Import backup (contains different tasks - E2E Import Test tasks) - const backupPath = ImportPage.getFixturePath('test-backup.json'); - await importPage.importBackupFile(backupPath); - console.log('[Stale Clock] Client A imported backup'); + // Import backup (contains different tasks - E2E Import Test tasks) + const backupPath = ImportPage.getFixturePath('test-backup.json'); + await importPage.importBackupFile(backupPath); + console.log('[Stale Clock] Client A imported backup'); - // Re-enable sync after import (import overwrites globalConfig) - await clientA.sync.setupSuperSync(syncConfig); + // Re-enable sync after import (import overwrites globalConfig) + await clientA.sync.setupSuperSync(syncConfig); - // Wait for imported task to be visible - await waitForTask(clientA.page, 'E2E Import Test - Active Task With Subtask'); - console.log('[Stale Clock] Client A has imported tasks'); + // Wait for imported task to be visible + await waitForTask(clientA.page, 'E2E Import Test - Active Task With Subtask'); + console.log('[Stale Clock] Client A has imported tasks'); - // ============ PHASE 4: Sync to Propagate SYNC_IMPORT ============ - console.log('[Stale Clock] Phase 4: Syncing to propagate SYNC_IMPORT'); + // ============ PHASE 4: Sync to Propagate SYNC_IMPORT ============ + console.log('[Stale Clock] Phase 4: Syncing to propagate SYNC_IMPORT'); - await clientA.sync.syncAndWait(); - console.log('[Stale Clock] Client A synced (SYNC_IMPORT uploaded)'); + await clientA.sync.syncAndWait(); + console.log('[Stale Clock] Client A synced (SYNC_IMPORT uploaded)'); - await clientB.sync.syncAndWait(); - console.log('[Stale Clock] Client B synced (received SYNC_IMPORT)'); + await clientB.sync.syncAndWait(); + console.log('[Stale Clock] Client B synced (received SYNC_IMPORT)'); - // ============ PHASE 5: Client B Reloads (Triggers Hydration) ============ - console.log('[Stale Clock] Phase 5: Client B reloading (triggers hydration)'); + // ============ PHASE 5: Client B Reloads (Triggers Hydration) ============ + console.log('[Stale Clock] Phase 5: Client B reloading (triggers hydration)'); - // This is the CRITICAL step - reload triggers fresh hydration - // With the bug, operations created during loadAllData would get stale clocks - await clientB.page.reload(); - await waitForAppReady(clientB.page); - console.log('[Stale Clock] Client B reloaded, hydration complete'); + // This is the CRITICAL step - reload triggers fresh hydration + // With the bug, operations created during loadAllData would get stale clocks + await clientB.page.reload(); + await waitForAppReady(clientB.page); + console.log('[Stale Clock] Client B reloaded, hydration complete'); - // Navigate to work view - await clientB.page.goto('/#/work-view'); - await clientB.page.waitForLoadState('networkidle'); + // Navigate to work view + await clientB.page.goto('/#/work-view'); + await clientB.page.waitForLoadState('networkidle'); - // Wait for imported task to appear - await waitForTask(clientB.page, 'E2E Import Test - Active Task With Subtask'); - console.log('[Stale Clock] Client B showing imported data after reload'); + // Wait for imported task to appear + await waitForTask(clientB.page, 'E2E Import Test - Active Task With Subtask'); + console.log('[Stale Clock] Client B showing imported data after reload'); - // ============ PHASE 6: Client B Syncs After Reload ============ - console.log('[Stale Clock] Phase 6: Client B syncing after reload'); + // ============ PHASE 6: Client B Syncs After Reload ============ + console.log('[Stale Clock] Phase 6: Client B syncing after reload'); - // This sync should succeed - any ops created during hydration should have correct clocks - // With the bug, ops would be rejected as CONFLICT_STALE and treated as permanent rejection - await clientB.sync.syncAndWait(); - console.log('[Stale Clock] Client B synced after reload'); + // This sync should succeed - any ops created during hydration should have correct clocks + // With the bug, ops would be rejected as CONFLICT_STALE and treated as permanent rejection + await clientB.sync.syncAndWait(); + console.log('[Stale Clock] Client B synced after reload'); - // Client A syncs to receive any merged ops from B - await clientA.sync.syncAndWait(); - console.log('[Stale Clock] Client A synced to receive B updates'); + // Client A syncs to receive any merged ops from B + await clientA.sync.syncAndWait(); + console.log('[Stale Clock] Client A synced to receive B updates'); - // Brief wait for state to settle - await clientA.page.waitForTimeout(1000); - await clientB.page.waitForTimeout(1000); + // Brief wait for state to settle + await clientA.page.waitForTimeout(1000); + await clientB.page.waitForTimeout(1000); - // ============ PHASE 7: Verify Consistent State ============ - console.log('[Stale Clock] Phase 7: Verifying consistent state'); + // ============ PHASE 7: Verify Consistent State ============ + console.log('[Stale Clock] Phase 7: Verifying consistent state'); - // Navigate both to work view - await clientA.page.goto('/#/work-view'); - await clientA.page.waitForLoadState('networkidle'); - await clientB.page.goto('/#/work-view'); - await clientB.page.waitForLoadState('networkidle'); + // Navigate both to work view + await clientA.page.goto('/#/work-view'); + await clientA.page.waitForLoadState('networkidle'); + await clientB.page.goto('/#/work-view'); + await clientB.page.waitForLoadState('networkidle'); - // Both clients should have imported tasks - const importedTaskOnA = clientA.page.locator( - 'task:has-text("E2E Import Test - Active Task With Subtask")', - ); - const importedTaskOnB = clientB.page.locator( - 'task:has-text("E2E Import Test - Active Task With Subtask")', - ); + // Both clients should have imported tasks + const importedTaskOnA = clientA.page.locator( + 'task:has-text("E2E Import Test - Active Task With Subtask")', + ); + const importedTaskOnB = clientB.page.locator( + 'task:has-text("E2E Import Test - Active Task With Subtask")', + ); - await expect(importedTaskOnA).toBeVisible({ timeout: 5000 }); - await expect(importedTaskOnB).toBeVisible({ timeout: 5000 }); - console.log('[Stale Clock] ✓ Both clients have imported tasks'); + await expect(importedTaskOnA).toBeVisible({ timeout: 5000 }); + await expect(importedTaskOnB).toBeVisible({ timeout: 5000 }); + console.log('[Stale Clock] Both clients have imported tasks'); - // Original task should be gone (clean slate from import) - const originalTaskOnA = clientA.page.locator(`task:has-text("${taskToday}")`); - const originalTaskOnB = clientB.page.locator(`task:has-text("${taskToday}")`); + // Original task should be gone (clean slate from import) + const originalTaskOnA = clientA.page.locator(`task:has-text("${taskToday}")`); + const originalTaskOnB = clientB.page.locator(`task:has-text("${taskToday}")`); - await expect(originalTaskOnA).not.toBeVisible({ timeout: 5000 }); - await expect(originalTaskOnB).not.toBeVisible({ timeout: 5000 }); - console.log('[Stale Clock] ✓ Original tasks are gone (clean slate)'); + await expect(originalTaskOnA).not.toBeVisible({ timeout: 5000 }); + await expect(originalTaskOnB).not.toBeVisible({ timeout: 5000 }); + console.log('[Stale Clock] Original tasks are gone (clean slate)'); - // Check for sync error indicators - // The snack bar would show if there were rejected ops - const errorSnack = clientB.page.locator('simple-snack-bar.error'); - await expect(errorSnack).not.toBeVisible({ timeout: 2000 }); - console.log('[Stale Clock] ✓ No sync errors on Client B'); + // Check for sync error indicators + // The snack bar would show if there were rejected ops + const errorSnack = clientB.page.locator('simple-snack-bar.error'); + await expect(errorSnack).not.toBeVisible({ timeout: 2000 }); + console.log('[Stale Clock] No sync errors on Client B'); - console.log('[Stale Clock] ✓ Stale clock regression test PASSED!'); - } finally { - if (clientA) await closeClient(clientA); - if (clientB) await closeClient(clientB); - } - }, - ); + console.log('[Stale Clock] Stale clock regression test PASSED!'); + } finally { + if (clientA) await closeClient(clientA); + if (clientB) await closeClient(clientB); + } + }); /** * Regression test: Multiple reloads after SYNC_IMPORT don't cause accumulating errors @@ -235,101 +216,101 @@ base.describe('@supersync @regression Stale Clock Regression', () => { * With the original bug, each reload could create more stale ops that get rejected. * This test verifies that multiple reload cycles don't cause accumulating issues. */ - base( - 'Multiple reloads after SYNC_IMPORT remain stable (no accumulating errors)', - async ({ browser, baseURL }, testInfo) => { - const testRunId = generateTestRunId(testInfo.workerIndex); - let clientA: SimulatedE2EClient | null = null; - let clientB: SimulatedE2EClient | null = null; + test('Multiple reloads after SYNC_IMPORT remain stable (no accumulating errors)', async ({ + browser, + baseURL, + testRunId, + }) => { + let clientA: SimulatedE2EClient | null = null; + let clientB: SimulatedE2EClient | null = null; - try { - const user = await createTestUser(testRunId); - const syncConfig = getSuperSyncConfig(user); + try { + const user = await createTestUser(testRunId); + const syncConfig = getSuperSyncConfig(user); - // ============ Setup ============ - console.log('[Multi-Reload] Setting up clients'); + // ============ Setup ============ + console.log('[Multi-Reload] Setting up clients'); - clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); - await clientA.sync.setupSuperSync(syncConfig); + clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); + await clientA.sync.setupSuperSync(syncConfig); - clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); - await clientB.sync.setupSuperSync(syncConfig); + clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); + await clientB.sync.setupSuperSync(syncConfig); - await clientA.sync.syncAndWait(); + await clientA.sync.syncAndWait(); + await clientB.sync.syncAndWait(); + + // ============ Import Backup ============ + console.log('[Multi-Reload] Client A importing backup'); + + const importPage = new ImportPage(clientA.page); + await importPage.navigateToImportPage(); + const backupPath = ImportPage.getFixturePath('test-backup.json'); + await importPage.importBackupFile(backupPath); + await clientA.sync.setupSuperSync(syncConfig); + await waitForTask(clientA.page, 'E2E Import Test - Active Task With Subtask'); + + await clientA.sync.syncAndWait(); + await clientB.sync.syncAndWait(); + console.log('[Multi-Reload] SYNC_IMPORT propagated'); + + // ============ Multiple Reload Cycles ============ + console.log('[Multi-Reload] Starting reload cycles'); + + for (let cycle = 1; cycle <= 3; cycle++) { + console.log(`[Multi-Reload] Reload cycle ${cycle}/3`); + + // Reload Client B + await clientB.page.reload(); + await waitForAppReady(clientB.page); + + // Navigate and verify state + await clientB.page.goto('/#/work-view'); + await clientB.page.waitForLoadState('networkidle'); + await waitForTask(clientB.page, 'E2E Import Test - Active Task With Subtask'); + + // Sync after reload await clientB.sync.syncAndWait(); - // ============ Import Backup ============ - console.log('[Multi-Reload] Client A importing backup'); - - const importPage = new ImportPage(clientA.page); - await importPage.navigateToImportPage(); - const backupPath = ImportPage.getFixturePath('test-backup.json'); - await importPage.importBackupFile(backupPath); - await clientA.sync.setupSuperSync(syncConfig); - await waitForTask(clientA.page, 'E2E Import Test - Active Task With Subtask'); - - await clientA.sync.syncAndWait(); - await clientB.sync.syncAndWait(); - console.log('[Multi-Reload] SYNC_IMPORT propagated'); - - // ============ Multiple Reload Cycles ============ - console.log('[Multi-Reload] Starting reload cycles'); - - for (let cycle = 1; cycle <= 3; cycle++) { - console.log(`[Multi-Reload] Reload cycle ${cycle}/3`); - - // Reload Client B - await clientB.page.reload(); - await waitForAppReady(clientB.page); - - // Navigate and verify state - await clientB.page.goto('/#/work-view'); - await clientB.page.waitForLoadState('networkidle'); - await waitForTask(clientB.page, 'E2E Import Test - Active Task With Subtask'); - - // Sync after reload - await clientB.sync.syncAndWait(); - - // Check for errors - const errorSnack = clientB.page.locator('simple-snack-bar.error'); - const isErrorVisible = await errorSnack.isVisible().catch(() => false); - if (isErrorVisible) { - throw new Error( - `Sync error appeared on reload cycle ${cycle} - stale clock bug may not be fixed`, - ); - } - - console.log(`[Multi-Reload] ✓ Cycle ${cycle} completed without errors`); + // Check for errors + const errorSnack = clientB.page.locator('simple-snack-bar.error'); + const isErrorVisible = await errorSnack.isVisible().catch(() => false); + if (isErrorVisible) { + throw new Error( + `Sync error appeared on reload cycle ${cycle} - stale clock bug may not be fixed`, + ); } - // ============ Final Verification ============ - console.log('[Multi-Reload] Final verification'); - - // Sync A to get any updates from B - await clientA.sync.syncAndWait(); - - await clientA.page.goto('/#/work-view'); - await clientB.page.goto('/#/work-view'); - await clientA.page.waitForLoadState('networkidle'); - await clientB.page.waitForLoadState('networkidle'); - - // Both should have imported task - await expect( - clientA.page.locator( - 'task:has-text("E2E Import Test - Active Task With Subtask")', - ), - ).toBeVisible(); - await expect( - clientB.page.locator( - 'task:has-text("E2E Import Test - Active Task With Subtask")', - ), - ).toBeVisible(); - - console.log('[Multi-Reload] ✓ Multi-reload regression test PASSED!'); - } finally { - if (clientA) await closeClient(clientA); - if (clientB) await closeClient(clientB); + console.log(`[Multi-Reload] Cycle ${cycle} completed without errors`); } - }, - ); + + // ============ Final Verification ============ + console.log('[Multi-Reload] Final verification'); + + // Sync A to get any updates from B + await clientA.sync.syncAndWait(); + + await clientA.page.goto('/#/work-view'); + await clientB.page.goto('/#/work-view'); + await clientA.page.waitForLoadState('networkidle'); + await clientB.page.waitForLoadState('networkidle'); + + // Both should have imported task + await expect( + clientA.page.locator( + 'task:has-text("E2E Import Test - Active Task With Subtask")', + ), + ).toBeVisible(); + await expect( + clientB.page.locator( + 'task:has-text("E2E Import Test - Active Task With Subtask")', + ), + ).toBeVisible(); + + console.log('[Multi-Reload] Multi-reload regression test PASSED!'); + } finally { + if (clientA) await closeClient(clientA); + if (clientB) await closeClient(clientB); + } + }); }); diff --git a/e2e/tests/sync/supersync-stress.spec.ts b/e2e/tests/sync/supersync-stress.spec.ts index bbd829cdd..276bbb8f8 100644 --- a/e2e/tests/sync/supersync-stress.spec.ts +++ b/e2e/tests/sync/supersync-stress.spec.ts @@ -1,10 +1,9 @@ -import { test as base, expect } from '@playwright/test'; +import { test, expect } from '../../fixtures/supersync.fixture'; import { createTestUser, getSuperSyncConfig, createSimulatedClient, closeClient, - isServerHealthy, type SimulatedE2EClient, } from '../../utils/supersync-helpers'; @@ -18,10 +17,6 @@ import { * - Need for quieter logging */ -const generateTestRunId = (workerIndex: number): string => { - return `${Date.now()}-${workerIndex}`; -}; - /** * Create a simulated client with quiet console logging. * Only errors are logged, other console output is suppressed. @@ -45,20 +40,8 @@ const createQuietClient = async ( return client; }; -base.describe('@supersync SuperSync Stress Tests', () => { - let serverHealthy: boolean | null = null; - - base.beforeEach(async ({}, testInfo) => { - if (serverHealthy === null) { - serverHealthy = await isServerHealthy(); - if (!serverHealthy) { - console.warn( - 'SuperSync server not healthy at http://localhost:1901 - skipping tests', - ); - } - } - testInfo.skip(!serverHealthy, 'SuperSync server not running'); - }); +test.describe('@supersync SuperSync Stress Tests', () => { + // Server health check is handled automatically by the supersync fixture /** * Scenario: Bulk Sync (Slow Device Recovery) @@ -73,102 +56,104 @@ base.describe('@supersync SuperSync Stress Tests', () => { * 3. Verify B has all tasks from A * 4. Verify no spurious conflicts or errors */ - base( - 'Bulk sync: Many operations sync without cascade conflicts', - async ({ browser, baseURL }, testInfo) => { - testInfo.setTimeout(120000); // Bulk operations need more time - const testRunId = generateTestRunId(testInfo.workerIndex); - const appUrl = baseURL || 'http://localhost:4242'; - let clientA: SimulatedE2EClient | null = null; - let clientB: SimulatedE2EClient | null = null; + test('Bulk sync: Many operations sync without cascade conflicts', async ({ + browser, + baseURL, + testRunId, + serverHealthy, + }, testInfo) => { + void serverHealthy; // Ensure fixture is evaluated for server health check + testInfo.setTimeout(120000); // Bulk operations need more time + 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); + try { + const user = await createTestUser(testRunId); + const syncConfig = getSuperSyncConfig(user); - // Setup Client A (quiet mode) - clientA = await createQuietClient(browser, appUrl, 'A', testRunId); - await clientA.sync.setupSuperSync(syncConfig); + // Setup Client A (quiet mode) + clientA = await createQuietClient(browser, appUrl, 'A', testRunId); + await clientA.sync.setupSuperSync(syncConfig); - // 1. Client A creates many tasks (simulating a day's work) - const taskCount = 10; - const taskNames: string[] = []; + // 1. Client A creates many tasks (simulating a day's work) + const taskCount = 10; + const taskNames: string[] = []; - console.log(`[BulkSync] Creating ${taskCount} tasks...`); + console.log(`[BulkSync] Creating ${taskCount} tasks...`); - for (let i = 1; i <= taskCount; i++) { - const taskName = `BulkTask-${i}-${testRunId}`; - taskNames.push(taskName); - // Use skipClose=true for faster creation - await clientA.workView.addTask(taskName, i < taskCount); - } - - // Verify tasks were created - const createdCount = await clientA.page.locator('task').count(); - expect(createdCount).toBeGreaterThanOrEqual(taskCount); - console.log(`[BulkSync] Created ${createdCount} tasks`); - - // Mark some tasks as done using correct selector (need hover to show button) - for (let i = 0; i < 3; i++) { - const taskLocator = clientA.page - .locator(`task:has-text("${taskNames[i]}")`) - .first(); - await taskLocator.hover(); - await taskLocator.locator('.task-done-btn').click(); - await clientA.page.waitForTimeout(100); - } - console.log('[BulkSync] Marked 3 tasks as done'); - - // Sync all changes from A - await clientA.sync.syncAndWait(); - console.log('[BulkSync] Client A synced'); - - // 2. Setup Client B and sync (downloads all operations at once) - clientB = await createQuietClient(browser, appUrl, 'B', testRunId); - await clientB.sync.setupSuperSync(syncConfig); - await clientB.sync.syncAndWait(); - console.log('[BulkSync] Client B synced (bulk download)'); - - // Wait for UI to settle after bulk sync - await clientB.page.waitForTimeout(2000); - - // 3. Verify B has all tasks from A - const taskCountB = await clientB.page - .locator(`task:has-text("${testRunId}")`) - .count(); - expect(taskCountB).toBe(taskCount); - console.log(`[BulkSync] Client B has all ${taskCount} tasks`); - - // Verify done status of first 3 tasks - for (let i = 0; i < 3; i++) { - const taskLocator = clientB.page - .locator(`task:not(.ng-animating):has-text("${taskNames[i]}")`) - .first(); - await expect(taskLocator).toHaveClass(/isDone/, { timeout: 5000 }); - } - console.log('[BulkSync] Done status verified on Client B'); - - // 4. Do another round of sync to verify no spurious conflicts - await clientA.sync.syncAndWait(); - await clientB.sync.syncAndWait(); - - // Verify counts still match - const finalCountA = await clientA.page - .locator(`task:has-text("${testRunId}")`) - .count(); - const finalCountB = await clientB.page - .locator(`task:has-text("${testRunId}")`) - .count(); - expect(finalCountA).toBe(taskCount); - expect(finalCountB).toBe(taskCount); - - console.log('[BulkSync] Bulk sync completed without cascade conflicts'); - } finally { - if (clientA) await closeClient(clientA); - if (clientB) await closeClient(clientB); + for (let i = 1; i <= taskCount; i++) { + const taskName = `BulkTask-${i}-${testRunId}`; + taskNames.push(taskName); + // Use skipClose=true for faster creation + await clientA.workView.addTask(taskName, i < taskCount); } - }, - ); + + // Verify tasks were created + const createdCount = await clientA.page.locator('task').count(); + expect(createdCount).toBeGreaterThanOrEqual(taskCount); + console.log(`[BulkSync] Created ${createdCount} tasks`); + + // Mark some tasks as done using correct selector (need hover to show button) + for (let i = 0; i < 3; i++) { + const taskLocator = clientA.page + .locator(`task:has-text("${taskNames[i]}")`) + .first(); + await taskLocator.hover(); + await taskLocator.locator('.task-done-btn').click(); + await clientA.page.waitForTimeout(100); + } + console.log('[BulkSync] Marked 3 tasks as done'); + + // Sync all changes from A + await clientA.sync.syncAndWait(); + console.log('[BulkSync] Client A synced'); + + // 2. Setup Client B and sync (downloads all operations at once) + clientB = await createQuietClient(browser, appUrl, 'B', testRunId); + await clientB.sync.setupSuperSync(syncConfig); + await clientB.sync.syncAndWait(); + console.log('[BulkSync] Client B synced (bulk download)'); + + // Wait for UI to settle after bulk sync + await clientB.page.waitForTimeout(2000); + + // 3. Verify B has all tasks from A + const taskCountB = await clientB.page + .locator(`task:has-text("${testRunId}")`) + .count(); + expect(taskCountB).toBe(taskCount); + console.log(`[BulkSync] Client B has all ${taskCount} tasks`); + + // Verify done status of first 3 tasks + for (let i = 0; i < 3; i++) { + const taskLocator = clientB.page + .locator(`task:not(.ng-animating):has-text("${taskNames[i]}")`) + .first(); + await expect(taskLocator).toHaveClass(/isDone/, { timeout: 5000 }); + } + console.log('[BulkSync] Done status verified on Client B'); + + // 4. Do another round of sync to verify no spurious conflicts + await clientA.sync.syncAndWait(); + await clientB.sync.syncAndWait(); + + // Verify counts still match + const finalCountA = await clientA.page + .locator(`task:has-text("${testRunId}")`) + .count(); + const finalCountB = await clientB.page + .locator(`task:has-text("${testRunId}")`) + .count(); + expect(finalCountA).toBe(taskCount); + expect(finalCountB).toBe(taskCount); + + console.log('[BulkSync] Bulk sync completed without cascade conflicts'); + } finally { + if (clientA) await closeClient(clientA); + if (clientB) await closeClient(clientB); + } + }); /** * Scenario: High Volume Sync (197 Operations) @@ -191,216 +176,218 @@ base.describe('@supersync SuperSync Stress Tests', () => { * 4. Client B syncs (piggybacks 100, downloads remaining 97) * 5. Verify B has all 50 tasks with correct done states */ - base( - 'High volume sync: 197 operations sync correctly (tests piggyback limit)', - async ({ browser, baseURL }, testInfo) => { - testInfo.setTimeout(300000); // 5 minutes - const testRunId = generateTestRunId(testInfo.workerIndex); - const appUrl = baseURL || 'http://localhost:4242'; - let clientA: SimulatedE2EClient | null = null; - let clientB: SimulatedE2EClient | null = null; + test('High volume sync: 197 operations sync correctly (tests piggyback limit)', async ({ + browser, + baseURL, + testRunId, + serverHealthy, + }, testInfo) => { + void serverHealthy; // Ensure fixture is evaluated for server health check + testInfo.setTimeout(300000); // 5 minutes + 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); + try { + const user = await createTestUser(testRunId); + const syncConfig = getSuperSyncConfig(user); - // Setup Client A (regular mode to see logs) - clientA = await createSimulatedClient(browser, appUrl, 'A', testRunId); - await clientA.sync.setupSuperSync(syncConfig); + // Setup Client A (regular mode to see logs) + clientA = await createSimulatedClient(browser, appUrl, 'A', testRunId); + await clientA.sync.setupSuperSync(syncConfig); - // Create 50 tasks (50 ops) - const taskCount = 50; - const taskNames: string[] = []; + // Create 50 tasks (50 ops) + const taskCount = 50; + const taskNames: string[] = []; - console.log(`[HighVolume] Creating ${taskCount} tasks...`); + console.log(`[HighVolume] Creating ${taskCount} tasks...`); - for (let i = 1; i <= taskCount; i++) { - const taskName = `HighVol-${i}-${testRunId}`; - taskNames.push(taskName); - // Fast task creation - skipClose=true for all but last - await clientA.workView.addTask(taskName, i < taskCount); + for (let i = 1; i <= taskCount; i++) { + const taskName = `HighVol-${i}-${testRunId}`; + taskNames.push(taskName); + // Fast task creation - skipClose=true for all but last + await clientA.workView.addTask(taskName, i < taskCount); - // Progress logging every 5 tasks - if (i % 5 === 0) { - console.log(`[HighVolume] Created ${i}/${taskCount} tasks`); - } + // Progress logging every 5 tasks + if (i % 5 === 0) { + console.log(`[HighVolume] Created ${i}/${taskCount} tasks`); } - - // Extra wait after all tasks created to ensure persistence - await clientA.page.waitForTimeout(1000); - - // Verify tasks created - const createdCount = await clientA.page.locator('task').count(); - expect(createdCount).toBeGreaterThanOrEqual(taskCount); - console.log(`[HighVolume] Created ${createdCount} tasks total`); - - // Mark 49 tasks as done (49 × 3 = 147 more ops = 197 total) - const doneCount = 49; - // Each mark-as-done creates 3 ops: updateTask, planTasksForToday, Tag Update - // eslint-disable-next-line no-mixed-operators - const expectedOpsCount = taskCount + doneCount * 3; - console.log(`[HighVolume] Marking ${doneCount} tasks as done...`); - for (let i = 0; i < doneCount; i++) { - const taskLocator = clientA.page - .locator(`task:not(.ng-animating):has-text("${taskNames[i]}")`) - .first(); - await taskLocator.waitFor({ state: 'visible', timeout: 10000 }); - await taskLocator.hover(); - await taskLocator.locator('.task-done-btn').click(); - // Wait for done state to be applied - await expect(taskLocator).toHaveClass(/isDone/, { timeout: 5000 }); - - // Progress logging every 5 tasks - if ((i + 1) % 5 === 0) { - console.log(`[HighVolume] Marked ${i + 1}/${doneCount} tasks as done`); - } - } - - // Extra wait after all done marking to ensure persistence - await clientA.page.waitForTimeout(1000); - console.log(`[HighVolume] All ${expectedOpsCount} operations created locally`); - - // Sync all changes from A - await clientA.sync.syncAndWait(); - console.log('[HighVolume] Client A synced 99 operations'); - - // VALIDATION: Check pending ops count on Client A after sync - // Query IndexedDB directly for unsynced operations (SUP_OPS database) - const pendingOpsInfoA = await clientA.page.evaluate(async () => { - try { - return new Promise((resolve) => { - // Operation log uses SUP_OPS database, not SUP - const request = indexedDB.open('SUP_OPS'); - request.onerror = () => - resolve({ error: 'Failed to open SUP_OPS IndexedDB' }); - request.onsuccess = () => { - const db = request.result; - const storeNames = Array.from(db.objectStoreNames); - // Check if ops store exists - if (!storeNames.includes('ops')) { - resolve({ - error: 'ops store not found in SUP_OPS', - stores: storeNames, - }); - return; - } - const tx = db.transaction('ops', 'readonly'); - const store = tx.objectStore('ops'); - const getAllRequest = store.getAll(); - getAllRequest.onsuccess = () => { - const allEntries = getAllRequest.result; - // Unsynced = no syncedAt and no rejectedAt - const unsynced = allEntries.filter( - (e: { syncedAt?: number; rejectedAt?: number }) => - !e.syncedAt && !e.rejectedAt, - ); - resolve({ - totalEntries: allEntries.length, - unsyncedCount: unsynced.length, - unsyncedOpTypes: unsynced - .slice(0, 20) - .map( - (e: { op: { actionType: string; opType: string } }) => - `${e.op.opType}:${e.op.actionType}`, - ), - }); - }; - getAllRequest.onerror = () => - resolve({ error: 'Failed to read ops store' }); - }; - }); - } catch (e) { - return { error: String(e) }; - } - }); - console.log( - '[HighVolume] VALIDATION - Client A pending ops after sync:', - JSON.stringify(pendingOpsInfoA), - ); - - // Setup Client B and sync (bulk download) - NOT quiet to see debug logs - clientB = await createSimulatedClient(browser, appUrl, 'B', testRunId); - await clientB.sync.setupSuperSync(syncConfig); - await clientB.sync.syncAndWait(); - console.log('[HighVolume] Client B synced (bulk download)'); - - // Wait for UI to settle after bulk sync - // The done states need time to reflect in Angular change detection - await clientB.page.waitForTimeout(5000); - - // Trigger change detection by scrolling - await clientB.page.evaluate(() => { - window.scrollTo(0, 100); - window.scrollTo(0, 0); - }); - await clientB.page.waitForTimeout(1000); - - // Verify all tasks exist on B - const taskCountB = await clientB.page - .locator(`task:has-text("${testRunId}")`) - .count(); - expect(taskCountB).toBe(taskCount); - console.log(`[HighVolume] Client B has all ${taskCount} tasks`); - - // DEBUG: Check NgRx store state directly using window.__NGRX_STORE__ - const storeState = await clientB.page.evaluate((tid: string) => { - try { - // Access NgRx store via window if available - // @ts-expect-error - accessing app internals - const store = window.__NGRX_STORE__ || window.__store__; - if (store) { - const state = store.getState?.() || store.getValue?.(); - if (state?.task?.entities) { - const allTasks = Object.values(state.task.entities) as Array<{ - id: string; - title: string; - isDone: boolean; - }>; - const testTasks = allTasks.filter((t) => t?.title?.includes(tid)); - const doneTasks = testTasks.filter((t) => t?.isDone); - return { - method: 'NgRx Store', - totalTestTasks: testTasks.length, - doneTestTasks: doneTasks.length, - sampleTasks: testTasks.slice(0, 3).map((t) => ({ - id: t.id, - isDone: t.isDone, - })), - }; - } - } - - // Fallback to DOM check - const allTaskEls = document.querySelectorAll('task'); - const testTasks = Array.from(allTaskEls).filter((el) => - el.textContent?.includes(tid), - ); - const doneTasks = testTasks.filter((el) => el.classList.contains('isDone')); - return { - method: 'DOM Fallback', - totalTestTasks: testTasks.length, - doneTestTasks: doneTasks.length, - sampleClasses: testTasks.slice(0, 3).map((el) => el.className), - }; - } catch (e) { - return { error: String(e) }; - } - }, testRunId); - console.log('[HighVolume] State check:', JSON.stringify(storeState)); - - // Verify done status (49 should be done, 1 should be open) - const doneCountB = await clientB.page - .locator(`task.isDone:has-text("${testRunId}")`) - .count(); - console.log(`[HighVolume] DOM shows ${doneCountB} done tasks`); - expect(doneCountB).toBe(doneCount); - console.log('[HighVolume] Done states verified on Client B'); - - console.log(`[HighVolume] ${expectedOpsCount} operations applied successfully`); - } finally { - if (clientA) await closeClient(clientA); - if (clientB) await closeClient(clientB); } - }, - ); + + // Extra wait after all tasks created to ensure persistence + await clientA.page.waitForTimeout(1000); + + // Verify tasks created + const createdCount = await clientA.page.locator('task').count(); + expect(createdCount).toBeGreaterThanOrEqual(taskCount); + console.log(`[HighVolume] Created ${createdCount} tasks total`); + + // Mark 49 tasks as done (49 × 3 = 147 more ops = 197 total) + const doneCount = 49; + // Each mark-as-done creates 3 ops: updateTask, planTasksForToday, Tag Update + // eslint-disable-next-line no-mixed-operators + const expectedOpsCount = taskCount + doneCount * 3; + console.log(`[HighVolume] Marking ${doneCount} tasks as done...`); + for (let i = 0; i < doneCount; i++) { + const taskLocator = clientA.page + .locator(`task:not(.ng-animating):has-text("${taskNames[i]}")`) + .first(); + await taskLocator.waitFor({ state: 'visible', timeout: 10000 }); + await taskLocator.hover(); + await taskLocator.locator('.task-done-btn').click(); + // Wait for done state to be applied + await expect(taskLocator).toHaveClass(/isDone/, { timeout: 5000 }); + + // Progress logging every 5 tasks + if ((i + 1) % 5 === 0) { + console.log(`[HighVolume] Marked ${i + 1}/${doneCount} tasks as done`); + } + } + + // Extra wait after all done marking to ensure persistence + await clientA.page.waitForTimeout(1000); + console.log(`[HighVolume] All ${expectedOpsCount} operations created locally`); + + // Sync all changes from A + await clientA.sync.syncAndWait(); + console.log('[HighVolume] Client A synced 99 operations'); + + // VALIDATION: Check pending ops count on Client A after sync + // Query IndexedDB directly for unsynced operations (SUP_OPS database) + const pendingOpsInfoA = await clientA.page.evaluate(async () => { + try { + return new Promise((resolve) => { + // Operation log uses SUP_OPS database, not SUP + const request = indexedDB.open('SUP_OPS'); + request.onerror = () => + resolve({ error: 'Failed to open SUP_OPS IndexedDB' }); + request.onsuccess = () => { + const db = request.result; + const storeNames = Array.from(db.objectStoreNames); + // Check if ops store exists + if (!storeNames.includes('ops')) { + resolve({ + error: 'ops store not found in SUP_OPS', + stores: storeNames, + }); + return; + } + const tx = db.transaction('ops', 'readonly'); + const store = tx.objectStore('ops'); + const getAllRequest = store.getAll(); + getAllRequest.onsuccess = () => { + const allEntries = getAllRequest.result; + // Unsynced = no syncedAt and no rejectedAt + const unsynced = allEntries.filter( + (e: { syncedAt?: number; rejectedAt?: number }) => + !e.syncedAt && !e.rejectedAt, + ); + resolve({ + totalEntries: allEntries.length, + unsyncedCount: unsynced.length, + unsyncedOpTypes: unsynced + .slice(0, 20) + .map( + (e: { op: { actionType: string; opType: string } }) => + `${e.op.opType}:${e.op.actionType}`, + ), + }); + }; + getAllRequest.onerror = () => + resolve({ error: 'Failed to read ops store' }); + }; + }); + } catch (e) { + return { error: String(e) }; + } + }); + console.log( + '[HighVolume] VALIDATION - Client A pending ops after sync:', + JSON.stringify(pendingOpsInfoA), + ); + + // Setup Client B and sync (bulk download) - NOT quiet to see debug logs + clientB = await createSimulatedClient(browser, appUrl, 'B', testRunId); + await clientB.sync.setupSuperSync(syncConfig); + await clientB.sync.syncAndWait(); + console.log('[HighVolume] Client B synced (bulk download)'); + + // Wait for UI to settle after bulk sync + // The done states need time to reflect in Angular change detection + await clientB.page.waitForTimeout(5000); + + // Trigger change detection by scrolling + await clientB.page.evaluate(() => { + window.scrollTo(0, 100); + window.scrollTo(0, 0); + }); + await clientB.page.waitForTimeout(1000); + + // Verify all tasks exist on B + const taskCountB = await clientB.page + .locator(`task:has-text("${testRunId}")`) + .count(); + expect(taskCountB).toBe(taskCount); + console.log(`[HighVolume] Client B has all ${taskCount} tasks`); + + // DEBUG: Check NgRx store state directly using window.__NGRX_STORE__ + const storeState = await clientB.page.evaluate((tid: string) => { + try { + // Access NgRx store via window if available + // @ts-expect-error - accessing app internals + const store = window.__NGRX_STORE__ || window.__store__; + if (store) { + const state = store.getState?.() || store.getValue?.(); + if (state?.task?.entities) { + const allTasks = Object.values(state.task.entities) as Array<{ + id: string; + title: string; + isDone: boolean; + }>; + const testTasks = allTasks.filter((t) => t?.title?.includes(tid)); + const doneTasks = testTasks.filter((t) => t?.isDone); + return { + method: 'NgRx Store', + totalTestTasks: testTasks.length, + doneTestTasks: doneTasks.length, + sampleTasks: testTasks.slice(0, 3).map((t) => ({ + id: t.id, + isDone: t.isDone, + })), + }; + } + } + + // Fallback to DOM check + const allTaskEls = document.querySelectorAll('task'); + const testTasks = Array.from(allTaskEls).filter((el) => + el.textContent?.includes(tid), + ); + const doneTasks = testTasks.filter((el) => el.classList.contains('isDone')); + return { + method: 'DOM Fallback', + totalTestTasks: testTasks.length, + doneTestTasks: doneTasks.length, + sampleClasses: testTasks.slice(0, 3).map((el) => el.className), + }; + } catch (e) { + return { error: String(e) }; + } + }, testRunId); + console.log('[HighVolume] State check:', JSON.stringify(storeState)); + + // Verify done status (49 should be done, 1 should be open) + const doneCountB = await clientB.page + .locator(`task.isDone:has-text("${testRunId}")`) + .count(); + console.log(`[HighVolume] DOM shows ${doneCountB} done tasks`); + expect(doneCountB).toBe(doneCount); + console.log('[HighVolume] Done states verified on Client B'); + + console.log(`[HighVolume] ${expectedOpsCount} operations applied successfully`); + } finally { + if (clientA) await closeClient(clientA); + if (clientB) await closeClient(clientB); + } + }); }); diff --git a/e2e/tests/sync/supersync-task-ordering.spec.ts b/e2e/tests/sync/supersync-task-ordering.spec.ts index 6db8f1063..6e5266425 100644 --- a/e2e/tests/sync/supersync-task-ordering.spec.ts +++ b/e2e/tests/sync/supersync-task-ordering.spec.ts @@ -1,11 +1,10 @@ -import { test as base, expect } from '@playwright/test'; +import { test, expect } from '../../fixtures/supersync.fixture'; import { createTestUser, getSuperSyncConfig, createSimulatedClient, closeClient, waitForTask, - isServerHealthy, type SimulatedE2EClient, } from '../../utils/supersync-helpers'; @@ -16,24 +15,7 @@ import { * Uses drag-and-drop to reorder tasks and verifies the order is replicated. */ -let testCounter = 0; -const generateTestRunId = (workerIndex: number): string => { - return `order-${Date.now()}-${workerIndex}-${testCounter++}`; -}; - -base.describe('@supersync Task Ordering Sync', () => { - let serverHealthy: boolean | null = null; - - base.beforeEach(async ({}, testInfo) => { - if (serverHealthy === null) { - serverHealthy = await isServerHealthy(); - if (!serverHealthy) { - console.warn('SuperSync server not healthy - skipping tests'); - } - } - testInfo.skip(!serverHealthy, 'SuperSync server not running'); - }); - +test.describe('@supersync Task Ordering Sync', () => { /** * Test: Task order is consistent between clients after sync * @@ -46,91 +28,91 @@ base.describe('@supersync Task Ordering Sync', () => { * Note: This tests that task ordering is preserved through sync, * not that specific reordering actions work (which is UI-dependent). */ - base( - 'Task reordering syncs to other client', - async ({ browser, baseURL }, testInfo) => { - const testRunId = generateTestRunId(testInfo.workerIndex); - const uniqueId = Date.now(); - let clientA: SimulatedE2EClient | null = null; - let clientB: SimulatedE2EClient | null = null; + test('Task reordering syncs to other client', async ({ + browser, + baseURL, + testRunId, + }) => { + const uniqueId = Date.now(); + let clientA: SimulatedE2EClient | null = null; + let clientB: SimulatedE2EClient | null = null; - try { - const user = await createTestUser(testRunId); - const syncConfig = getSuperSyncConfig(user); + try { + const user = await createTestUser(testRunId); + const syncConfig = getSuperSyncConfig(user); - // ============ PHASE 1: Client A Creates Tasks ============ - clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); - await clientA.sync.setupSuperSync(syncConfig); + // ============ PHASE 1: Client A Creates Tasks ============ + clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); + await clientA.sync.setupSuperSync(syncConfig); - const task1Name = `Task1-${uniqueId}`; - const task2Name = `Task2-${uniqueId}`; - const task3Name = `Task3-${uniqueId}`; + const task1Name = `Task1-${uniqueId}`; + const task2Name = `Task2-${uniqueId}`; + const task3Name = `Task3-${uniqueId}`; - await clientA.workView.addTask(task1Name); - await clientA.workView.addTask(task2Name); - await clientA.workView.addTask(task3Name); - console.log('[Order Test] Client A created 3 tasks'); + await clientA.workView.addTask(task1Name); + await clientA.workView.addTask(task2Name); + await clientA.workView.addTask(task3Name); + console.log('[Order Test] Client A created 3 tasks'); - // Verify all tasks exist on Client A - await clientA.page.waitForTimeout(500); - const tasksA = clientA.page.locator('task'); - await expect(tasksA).toHaveCount(3); + // Verify all tasks exist on Client A + await clientA.page.waitForTimeout(500); + const tasksA = clientA.page.locator('task'); + await expect(tasksA).toHaveCount(3); - // ============ PHASE 2: Sync to Server ============ - await clientA.sync.syncAndWait(); - console.log('[Order Test] Client A synced'); + // ============ PHASE 2: Sync to Server ============ + await clientA.sync.syncAndWait(); + console.log('[Order Test] Client A synced'); - // ============ PHASE 3: Client B Downloads ============ - clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); - await clientB.sync.setupSuperSync(syncConfig); - await clientB.sync.syncAndWait(); - console.log('[Order Test] Client B synced'); + // ============ PHASE 3: Client B Downloads ============ + clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); + await clientB.sync.setupSuperSync(syncConfig); + await clientB.sync.syncAndWait(); + console.log('[Order Test] Client B synced'); - // Verify Client B has all 3 tasks - await waitForTask(clientB.page, task1Name); - await waitForTask(clientB.page, task2Name); - await waitForTask(clientB.page, task3Name); - console.log('[Order Test] Client B has all 3 tasks'); + // Verify Client B has all 3 tasks + await waitForTask(clientB.page, task1Name); + await waitForTask(clientB.page, task2Name); + await waitForTask(clientB.page, task3Name); + console.log('[Order Test] Client B has all 3 tasks'); - // ============ PHASE 4: Verify Order on Both Clients ============ - // Get task order on both clients using innerText to avoid duplicates - const getTaskOrder = async (client: SimulatedE2EClient): Promise => { - const tasks = client.page.locator('task .task-title'); - const count = await tasks.count(); - const order: string[] = []; - for (let i = 0; i < count; i++) { - const titleEl = tasks.nth(i); - const text = await titleEl.innerText(); - order.push(text.trim()); - } - return order; - }; + // ============ PHASE 4: Verify Order on Both Clients ============ + // Get task order on both clients using innerText to avoid duplicates + const getTaskOrder = async (client: SimulatedE2EClient): Promise => { + const tasks = client.page.locator('task .task-title'); + const count = await tasks.count(); + const order: string[] = []; + for (let i = 0; i < count; i++) { + const titleEl = tasks.nth(i); + const text = await titleEl.innerText(); + order.push(text.trim()); + } + return order; + }; - const orderA = await getTaskOrder(clientA); - const orderB = await getTaskOrder(clientB); + const orderA = await getTaskOrder(clientA); + const orderB = await getTaskOrder(clientB); - console.log('[Order Test] Client A order:', orderA); - console.log('[Order Test] Client B order:', orderB); + console.log('[Order Test] Client A order:', orderA); + console.log('[Order Test] Client B order:', orderB); - // Verify both clients have 3 tasks - expect(orderA.length).toBe(3); - expect(orderB.length).toBe(3); + // Verify both clients have 3 tasks + expect(orderA.length).toBe(3); + expect(orderB.length).toBe(3); - // Orders should match exactly - expect(orderA).toEqual(orderB); + // Orders should match exactly + expect(orderA).toEqual(orderB); - // Verify all our tasks are present - expect(orderA.some((t) => t.includes('Task1'))).toBe(true); - expect(orderA.some((t) => t.includes('Task2'))).toBe(true); - expect(orderA.some((t) => t.includes('Task3'))).toBe(true); + // Verify all our tasks are present + expect(orderA.some((t) => t.includes('Task1'))).toBe(true); + expect(orderA.some((t) => t.includes('Task2'))).toBe(true); + expect(orderA.some((t) => t.includes('Task3'))).toBe(true); - console.log('[Order Test] Orders match on both clients'); - } finally { - if (clientA) await closeClient(clientA); - if (clientB) await closeClient(clientB); - } - }, - ); + console.log('[Order Test] Orders match on both clients'); + } finally { + if (clientA) await closeClient(clientA); + if (clientB) await closeClient(clientB); + } + }); /** * Test: Subtask ordering syncs between clients @@ -141,104 +123,102 @@ base.describe('@supersync Task Ordering Sync', () => { * 3. Client B syncs (downloads) * 4. Verify subtask order is consistent on both clients */ - base( - 'Subtask ordering syncs to other client', - async ({ browser, baseURL }, testInfo) => { - const testRunId = generateTestRunId(testInfo.workerIndex); - const uniqueId = Date.now(); - let clientA: SimulatedE2EClient | null = null; - let clientB: SimulatedE2EClient | null = null; + test('Subtask ordering syncs to other client', async ({ + browser, + baseURL, + testRunId, + }) => { + const uniqueId = Date.now(); + let clientA: SimulatedE2EClient | null = null; + let clientB: SimulatedE2EClient | null = null; - try { - const user = await createTestUser(testRunId); - const syncConfig = getSuperSyncConfig(user); + try { + const user = await createTestUser(testRunId); + const syncConfig = getSuperSyncConfig(user); - // ============ PHASE 1: Client A Creates Parent and Subtasks ============ - clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); - await clientA.sync.setupSuperSync(syncConfig); + // ============ PHASE 1: Client A Creates Parent and Subtasks ============ + clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); + await clientA.sync.setupSuperSync(syncConfig); - const parentName = `Parent-${uniqueId}`; - const sub1Name = `Sub1-${uniqueId}`; - const sub2Name = `Sub2-${uniqueId}`; - const sub3Name = `Sub3-${uniqueId}`; + const parentName = `Parent-${uniqueId}`; + const sub1Name = `Sub1-${uniqueId}`; + const sub2Name = `Sub2-${uniqueId}`; + const sub3Name = `Sub3-${uniqueId}`; - await clientA.workView.addTask(parentName); - const parentTask = clientA.page.locator(`task:has-text("${parentName}")`).first(); + await clientA.workView.addTask(parentName); + const parentTask = clientA.page.locator(`task:has-text("${parentName}")`).first(); - await clientA.workView.addSubTask(parentTask, sub1Name); - await clientA.workView.addSubTask(parentTask, sub2Name); - await clientA.workView.addSubTask(parentTask, sub3Name); - console.log('[Subtask Order Test] Client A created parent with 3 subtasks'); + await clientA.workView.addSubTask(parentTask, sub1Name); + await clientA.workView.addSubTask(parentTask, sub2Name); + await clientA.workView.addSubTask(parentTask, sub3Name); + console.log('[Subtask Order Test] Client A created parent with 3 subtasks'); - // Expand parent to see subtasks - const expandBtn = parentTask.locator('.expand-btn'); - if (await expandBtn.isVisible()) { - await expandBtn.click(); - } - - // ============ PHASE 2: Sync to Server ============ - await clientA.sync.syncAndWait(); - console.log('[Subtask Order Test] Client A synced'); - - // ============ PHASE 3: Client B Downloads ============ - clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); - await clientB.sync.setupSuperSync(syncConfig); - await clientB.sync.syncAndWait(); - console.log('[Subtask Order Test] Client B synced'); - - // Expand parent on Client B - const parentTaskB = clientB.page - .locator(`task:has-text("${parentName}")`) - .first(); - const expandBtnB = parentTaskB.locator('.expand-btn'); - if (await expandBtnB.isVisible()) { - await expandBtnB.click(); - } - - // Verify Client B has all subtasks - await waitForTask(clientB.page, sub1Name); - await waitForTask(clientB.page, sub2Name); - await waitForTask(clientB.page, sub3Name); - console.log('[Subtask Order Test] Client B has all subtasks'); - - // ============ PHASE 4: Verify Subtask Order ============ - // Get subtask order using innerText to avoid duplicate text issues - const getSubtaskOrder = async (client: SimulatedE2EClient): Promise => { - const subtasks = client.page.locator('task.hasNoSubTasks .task-title'); - const count = await subtasks.count(); - const order: string[] = []; - for (let i = 0; i < count; i++) { - const text = await subtasks.nth(i).innerText(); - order.push(text.trim()); - } - return order; - }; - - const subtaskOrderA = await getSubtaskOrder(clientA); - const subtaskOrderB = await getSubtaskOrder(clientB); - - console.log('[Subtask Order Test] Client A subtask order:', subtaskOrderA); - console.log('[Subtask Order Test] Client B subtask order:', subtaskOrderB); - - // Verify both have 3 subtasks - expect(subtaskOrderA.length).toBe(3); - expect(subtaskOrderB.length).toBe(3); - - // Orders should match - expect(subtaskOrderA).toEqual(subtaskOrderB); - - // Verify all subtasks are present - expect(subtaskOrderA.some((t) => t.includes('Sub1'))).toBe(true); - expect(subtaskOrderA.some((t) => t.includes('Sub2'))).toBe(true); - expect(subtaskOrderA.some((t) => t.includes('Sub3'))).toBe(true); - - console.log('[Subtask Order Test] Subtask orders match'); - } finally { - if (clientA) await closeClient(clientA); - if (clientB) await closeClient(clientB); + // Expand parent to see subtasks + const expandBtn = parentTask.locator('.expand-btn'); + if (await expandBtn.isVisible()) { + await expandBtn.click(); } - }, - ); + + // ============ PHASE 2: Sync to Server ============ + await clientA.sync.syncAndWait(); + console.log('[Subtask Order Test] Client A synced'); + + // ============ PHASE 3: Client B Downloads ============ + clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); + await clientB.sync.setupSuperSync(syncConfig); + await clientB.sync.syncAndWait(); + console.log('[Subtask Order Test] Client B synced'); + + // Expand parent on Client B + const parentTaskB = clientB.page.locator(`task:has-text("${parentName}")`).first(); + const expandBtnB = parentTaskB.locator('.expand-btn'); + if (await expandBtnB.isVisible()) { + await expandBtnB.click(); + } + + // Verify Client B has all subtasks + await waitForTask(clientB.page, sub1Name); + await waitForTask(clientB.page, sub2Name); + await waitForTask(clientB.page, sub3Name); + console.log('[Subtask Order Test] Client B has all subtasks'); + + // ============ PHASE 4: Verify Subtask Order ============ + // Get subtask order using innerText to avoid duplicate text issues + const getSubtaskOrder = async (client: SimulatedE2EClient): Promise => { + const subtasks = client.page.locator('task.hasNoSubTasks .task-title'); + const count = await subtasks.count(); + const order: string[] = []; + for (let i = 0; i < count; i++) { + const text = await subtasks.nth(i).innerText(); + order.push(text.trim()); + } + return order; + }; + + const subtaskOrderA = await getSubtaskOrder(clientA); + const subtaskOrderB = await getSubtaskOrder(clientB); + + console.log('[Subtask Order Test] Client A subtask order:', subtaskOrderA); + console.log('[Subtask Order Test] Client B subtask order:', subtaskOrderB); + + // Verify both have 3 subtasks + expect(subtaskOrderA.length).toBe(3); + expect(subtaskOrderB.length).toBe(3); + + // Orders should match + expect(subtaskOrderA).toEqual(subtaskOrderB); + + // Verify all subtasks are present + expect(subtaskOrderA.some((t) => t.includes('Sub1'))).toBe(true); + expect(subtaskOrderA.some((t) => t.includes('Sub2'))).toBe(true); + expect(subtaskOrderA.some((t) => t.includes('Sub3'))).toBe(true); + + console.log('[Subtask Order Test] Subtask orders match'); + } finally { + if (clientA) await closeClient(clientA); + if (clientB) await closeClient(clientB); + } + }); /** * Test: Concurrent reordering resolves via LWW @@ -250,90 +230,90 @@ base.describe('@supersync Task Ordering Sync', () => { * 4. Both sync - LWW should resolve * 5. Verify consistent state */ - base( - 'Concurrent reordering resolves consistently', - async ({ browser, baseURL }, testInfo) => { - const testRunId = generateTestRunId(testInfo.workerIndex); - const uniqueId = Date.now(); - let clientA: SimulatedE2EClient | null = null; - let clientB: SimulatedE2EClient | null = null; + test('Concurrent reordering resolves consistently', async ({ + browser, + baseURL, + testRunId, + }) => { + const uniqueId = Date.now(); + let clientA: SimulatedE2EClient | null = null; + let clientB: SimulatedE2EClient | null = null; - try { - const user = await createTestUser(testRunId); - const syncConfig = getSuperSyncConfig(user); + try { + const user = await createTestUser(testRunId); + const syncConfig = getSuperSyncConfig(user); - // ============ PHASE 1: Setup Both Clients with Same Tasks ============ - clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); - await clientA.sync.setupSuperSync(syncConfig); + // ============ PHASE 1: Setup Both Clients with Same Tasks ============ + clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); + await clientA.sync.setupSuperSync(syncConfig); - const task1Name = `Task1-${uniqueId}`; - const task2Name = `Task2-${uniqueId}`; - const task3Name = `Task3-${uniqueId}`; + const task1Name = `Task1-${uniqueId}`; + const task2Name = `Task2-${uniqueId}`; + const task3Name = `Task3-${uniqueId}`; - await clientA.workView.addTask(task1Name); - await clientA.workView.addTask(task2Name); - await clientA.workView.addTask(task3Name); + await clientA.workView.addTask(task1Name); + await clientA.workView.addTask(task2Name); + await clientA.workView.addTask(task3Name); - await clientA.sync.syncAndWait(); + await clientA.sync.syncAndWait(); - clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); - await clientB.sync.setupSuperSync(syncConfig); - await clientB.sync.syncAndWait(); + clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); + await clientB.sync.setupSuperSync(syncConfig); + await clientB.sync.syncAndWait(); - // Verify both have all tasks - await waitForTask(clientA.page, task1Name); - await waitForTask(clientB.page, task1Name); - console.log('[Concurrent Order Test] Both clients have all tasks'); + // Verify both have all tasks + await waitForTask(clientA.page, task1Name); + await waitForTask(clientB.page, task1Name); + console.log('[Concurrent Order Test] Both clients have all tasks'); - // ============ PHASE 2: Concurrent Reordering ============ - // Client A moves Task1 to top - const task1A = clientA.page.locator(`task:has-text("${task1Name}")`); - await task1A.click(); - await clientA.page.keyboard.press('Control+Shift+ArrowUp'); - await clientA.page.waitForTimeout(300); - console.log('[Concurrent Order Test] Client A moved Task1 to top'); + // ============ PHASE 2: Concurrent Reordering ============ + // Client A moves Task1 to top + const task1A = clientA.page.locator(`task:has-text("${task1Name}")`); + await task1A.click(); + await clientA.page.keyboard.press('Control+Shift+ArrowUp'); + await clientA.page.waitForTimeout(300); + console.log('[Concurrent Order Test] Client A moved Task1 to top'); - // Client B moves Task3 to top (concurrent, no sync yet) - const task3B = clientB.page.locator(`task:has-text("${task3Name}")`); - await task3B.click(); - await clientB.page.keyboard.press('Control+Shift+ArrowUp'); - await clientB.page.waitForTimeout(300); - console.log('[Concurrent Order Test] Client B moved Task3 to top'); + // Client B moves Task3 to top (concurrent, no sync yet) + const task3B = clientB.page.locator(`task:has-text("${task3Name}")`); + await task3B.click(); + await clientB.page.keyboard.press('Control+Shift+ArrowUp'); + await clientB.page.waitForTimeout(300); + console.log('[Concurrent Order Test] Client B moved Task3 to top'); - // ============ PHASE 3: Sync - LWW Resolution ============ - await clientA.sync.syncAndWait(); - await clientB.sync.syncAndWait(); - await clientA.sync.syncAndWait(); // Final sync to converge - console.log('[Concurrent Order Test] All clients synced'); + // ============ PHASE 3: Sync - LWW Resolution ============ + await clientA.sync.syncAndWait(); + await clientB.sync.syncAndWait(); + await clientA.sync.syncAndWait(); // Final sync to converge + console.log('[Concurrent Order Test] All clients synced'); - // ============ PHASE 4: Verify Consistent State ============ - const getOrder = async (client: SimulatedE2EClient): Promise => { - const tasks = client.page.locator('task'); - const count = await tasks.count(); - const order: string[] = []; - for (let i = 0; i < count; i++) { - const title = await tasks.nth(i).locator('.task-title').textContent(); - order.push(title?.trim() || ''); - } - return order; - }; + // ============ PHASE 4: Verify Consistent State ============ + const getOrder = async (client: SimulatedE2EClient): Promise => { + const tasks = client.page.locator('task'); + const count = await tasks.count(); + const order: string[] = []; + for (let i = 0; i < count; i++) { + const title = await tasks.nth(i).locator('.task-title').textContent(); + order.push(title?.trim() || ''); + } + return order; + }; - const orderA = await getOrder(clientA); - const orderB = await getOrder(clientB); + const orderA = await getOrder(clientA); + const orderB = await getOrder(clientB); - console.log('[Concurrent Order Test] Client A final order:', orderA); - console.log('[Concurrent Order Test] Client B final order:', orderB); + console.log('[Concurrent Order Test] Client A final order:', orderA); + console.log('[Concurrent Order Test] Client B final order:', orderB); - // Both clients should have the same order (eventual consistency) - expect(orderA.length).toBe(3); - expect(orderB.length).toBe(3); - expect(orderA).toEqual(orderB); + // Both clients should have the same order (eventual consistency) + expect(orderA.length).toBe(3); + expect(orderB.length).toBe(3); + expect(orderA).toEqual(orderB); - console.log('[Concurrent Order Test] Both clients have consistent order'); - } finally { - if (clientA) await closeClient(clientA); - if (clientB) await closeClient(clientB); - } - }, - ); + console.log('[Concurrent Order Test] Both clients have consistent order'); + } finally { + if (clientA) await closeClient(clientA); + if (clientB) await closeClient(clientB); + } + }); }); diff --git a/e2e/tests/sync/supersync-time-tracking-advanced.spec.ts b/e2e/tests/sync/supersync-time-tracking-advanced.spec.ts index 358508e5b..50edc894a 100644 --- a/e2e/tests/sync/supersync-time-tracking-advanced.spec.ts +++ b/e2e/tests/sync/supersync-time-tracking-advanced.spec.ts @@ -1,13 +1,17 @@ -import { test as base, expect } from '@playwright/test'; +import { test, expect } from '../../fixtures/supersync.fixture'; import { createTestUser, getSuperSyncConfig, createSimulatedClient, closeClient, waitForTask, - isServerHealthy, + startTimeTracking, + stopTimeTracking, + getTaskElement, + markTaskDone, type SimulatedE2EClient, } from '../../utils/supersync-helpers'; +import { expectTaskVisible } from '../../utils/supersync-assertions'; import { waitForAppReady } from '../../utils/waits'; /** @@ -19,24 +23,7 @@ import { waitForAppReady } from '../../utils/waits'; * - Large time values precision */ -let testCounter = 0; -const generateTestRunId = (workerIndex: number): string => { - return `timetrack-${Date.now()}-${workerIndex}-${testCounter++}`; -}; - -base.describe('@supersync Time Tracking Advanced Sync', () => { - let serverHealthy: boolean | null = null; - - base.beforeEach(async ({}, testInfo) => { - if (serverHealthy === null) { - serverHealthy = await isServerHealthy(); - if (!serverHealthy) { - console.warn('SuperSync server not healthy - skipping tests'); - } - } - testInfo.skip(!serverHealthy, 'SuperSync server not running'); - }); - +test.describe('@supersync Time Tracking Advanced Sync', () => { /** * Test: Time tracking data persists after archive * @@ -49,117 +36,110 @@ base.describe('@supersync Time Tracking Advanced Sync', () => { * 6. Client A syncs * 7. Both verify time in worklog */ - base( - 'Time tracking data persists after archive', - async ({ browser, baseURL }, testInfo) => { - const testRunId = generateTestRunId(testInfo.workerIndex); - const uniqueId = Date.now(); - let clientA: SimulatedE2EClient | null = null; - let clientB: SimulatedE2EClient | null = null; + test('Time tracking data persists after archive', async ({ + browser, + baseURL, + testRunId, + }) => { + const uniqueId = Date.now(); + let clientA: SimulatedE2EClient | null = null; + let clientB: SimulatedE2EClient | null = null; - try { - const user = await createTestUser(testRunId); - const syncConfig = getSuperSyncConfig(user); + try { + const user = await createTestUser(testRunId); + const syncConfig = getSuperSyncConfig(user); - // ============ PHASE 1: Client A Creates and Tracks Task ============ - clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); - await clientA.sync.setupSuperSync(syncConfig); + // ============ PHASE 1: Client A Creates and Tracks Task ============ + clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); + await clientA.sync.setupSuperSync(syncConfig); - const taskName = `ArchiveTime-${uniqueId}`; - await clientA.workView.addTask(taskName); - console.log('[Archive Time Test] Client A created task'); + const taskName = `ArchiveTime-${uniqueId}`; + await clientA.workView.addTask(taskName); + console.log('[Archive Time Test] Client A created task'); - // Start time tracking - const taskLocatorA = clientA.page.locator(`task:has-text("${taskName}")`); - await taskLocatorA.hover(); - const startBtn = taskLocatorA.locator('.start-task-btn'); - await startBtn.waitFor({ state: 'visible', timeout: 5000 }); - await startBtn.click(); - console.log('[Archive Time Test] Started time tracking'); + // Start time tracking + await startTimeTracking(clientA, taskName); + console.log('[Archive Time Test] Started time tracking'); - // Wait for time to accumulate - await clientA.page.waitForTimeout(5000); + // Wait for time to accumulate + await clientA.page.waitForTimeout(5000); - // Stop tracking - await taskLocatorA.hover(); - const pauseBtn = taskLocatorA.locator('button:has(mat-icon:has-text("pause"))'); - await pauseBtn.waitFor({ state: 'visible', timeout: 5000 }); - await pauseBtn.click(); - console.log('[Archive Time Test] Stopped time tracking'); + // Stop tracking + await stopTimeTracking(clientA, taskName); + console.log('[Archive Time Test] Stopped time tracking'); - // Capture tracked time - const timeVal = taskLocatorA.locator('.time-wrapper .time-val').first(); - await expect(timeVal).toBeVisible({ timeout: 5000 }); - const trackedTime = await timeVal.textContent(); - console.log(`[Archive Time Test] Tracked time: ${trackedTime}`); + // Capture tracked time + const taskLocatorA = getTaskElement(clientA, taskName); + const timeVal = taskLocatorA.locator('.time-wrapper .time-val').first(); + await expect(timeVal).toBeVisible({ timeout: 5000 }); + const trackedTime = await timeVal.textContent(); + console.log(`[Archive Time Test] Tracked time: ${trackedTime}`); - // Mark as done - await taskLocatorA.hover(); - await taskLocatorA.locator('.task-done-btn').click(); - console.log('[Archive Time Test] Marked task done'); + // Mark as done + await markTaskDone(clientA, taskName); + console.log('[Archive Time Test] Marked task done'); - // ============ PHASE 2: Sync to Server ============ - await clientA.sync.syncAndWait(); - console.log('[Archive Time Test] Client A synced'); + // ============ PHASE 2: Sync to Server ============ + await clientA.sync.syncAndWait(); + console.log('[Archive Time Test] Client A synced'); - // ============ PHASE 3: Client B Archives ============ - clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); - await clientB.sync.setupSuperSync(syncConfig); - await clientB.sync.syncAndWait(); - console.log('[Archive Time Test] Client B synced'); + // ============ PHASE 3: Client B Archives ============ + clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); + await clientB.sync.setupSuperSync(syncConfig); + await clientB.sync.syncAndWait(); + console.log('[Archive Time Test] Client B synced'); - // Verify task exists on B - await waitForTask(clientB.page, taskName); + // Verify task exists on B + await waitForTask(clientB.page, taskName); - // Archive via Finish Day - const finishDayBtn = clientB.page.locator('.e2e-finish-day'); - await finishDayBtn.waitFor({ state: 'visible', timeout: 10000 }); - await finishDayBtn.click(); - console.log('[Archive Time Test] Client B clicked Finish Day'); + // Archive via Finish Day + const finishDayBtn = clientB.page.locator('.e2e-finish-day'); + await finishDayBtn.waitFor({ state: 'visible', timeout: 10000 }); + await finishDayBtn.click(); + console.log('[Archive Time Test] Client B clicked Finish Day'); - await clientB.page.waitForURL(/daily-summary/, { timeout: 10000 }); - await clientB.page.waitForLoadState('networkidle'); + await clientB.page.waitForURL(/daily-summary/, { timeout: 10000 }); + await clientB.page.waitForLoadState('networkidle'); - // Click save to archive - const saveBtn = clientB.page.locator( - 'daily-summary button[mat-flat-button]:has(mat-icon:has-text("wb_sunny"))', - ); - await saveBtn.waitFor({ state: 'visible', timeout: 10000 }); - await saveBtn.click(); - console.log('[Archive Time Test] Client B archived'); + // Click save to archive + const saveBtn = clientB.page.locator( + 'daily-summary button[mat-flat-button]:has(mat-icon:has-text("wb_sunny"))', + ); + await saveBtn.waitFor({ state: 'visible', timeout: 10000 }); + await saveBtn.click(); + console.log('[Archive Time Test] Client B archived'); - await clientB.page.waitForURL(/tag\/TODAY/, { timeout: 10000 }); + await clientB.page.waitForURL(/tag\/TODAY/, { timeout: 10000 }); - // ============ PHASE 4: Sync Archive ============ - await clientB.sync.syncAndWait(); - await clientA.sync.syncAndWait(); - console.log('[Archive Time Test] Both synced archive'); + // ============ PHASE 4: Sync Archive ============ + await clientB.sync.syncAndWait(); + await clientA.sync.syncAndWait(); + console.log('[Archive Time Test] Both synced archive'); - // ============ PHASE 5: Verify Time in Worklog ============ - // Navigate to worklog on Client A - await clientA.page.goto('/#/tag/TODAY/worklog'); - await clientA.page.waitForLoadState('networkidle'); - await clientA.page.waitForSelector('worklog', { timeout: 10000 }); + // ============ PHASE 5: Verify Time in Worklog ============ + // Navigate to worklog on Client A + await clientA.page.goto('/#/tag/TODAY/worklog'); + await clientA.page.waitForLoadState('networkidle'); + await clientA.page.waitForSelector('worklog', { timeout: 10000 }); - // Expand week to see tasks - const weekRow = clientA.page.locator('.week-row').first(); - if (await weekRow.isVisible()) { - await weekRow.click(); - await clientA.page.waitForTimeout(500); - } - - // Verify task with time appears in worklog - const taskInWorklog = clientA.page.locator( - `.task-summary-table .task-title:has-text("${taskName}")`, - ); - await expect(taskInWorklog).toBeVisible({ timeout: 10000 }); - console.log('[Archive Time Test] Task visible in worklog with time data'); - } finally { - if (clientA) await closeClient(clientA); - if (clientB) await closeClient(clientB); + // Expand week to see tasks + const weekRow = clientA.page.locator('.week-row').first(); + if (await weekRow.isVisible()) { + await weekRow.click(); + await clientA.page.waitForTimeout(500); } - }, - ); + + // Verify task with time appears in worklog + const taskInWorklog = clientA.page.locator( + `.task-summary-table .task-title:has-text("${taskName}")`, + ); + await expect(taskInWorklog).toBeVisible({ timeout: 10000 }); + console.log('[Archive Time Test] Task visible in worklog with time data'); + } finally { + if (clientA) await closeClient(clientA); + if (clientB) await closeClient(clientB); + } + }); /** * Test: Large time values sync with precision @@ -170,57 +150,55 @@ base.describe('@supersync Time Tracking Advanced Sync', () => { * 3. Client B syncs * 4. Verify time estimate is exactly 8h on Client B */ - base( - 'Task with large time estimate syncs correctly', - async ({ browser, baseURL }, testInfo) => { - const testRunId = generateTestRunId(testInfo.workerIndex); - const uniqueId = Date.now(); - let clientA: SimulatedE2EClient | null = null; - let clientB: SimulatedE2EClient | null = null; + test('Task with large time estimate syncs correctly', async ({ + browser, + baseURL, + testRunId, + }) => { + const uniqueId = Date.now(); + let clientA: SimulatedE2EClient | null = null; + let clientB: SimulatedE2EClient | null = null; - try { - const user = await createTestUser(testRunId); - const syncConfig = getSuperSyncConfig(user); + try { + const user = await createTestUser(testRunId); + const syncConfig = getSuperSyncConfig(user); - // ============ PHASE 1: Client A Creates Task with Large Estimate ============ - clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); - await clientA.sync.setupSuperSync(syncConfig); + // ============ PHASE 1: Client A Creates Task with Large Estimate ============ + clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); + await clientA.sync.setupSuperSync(syncConfig); - const taskName = `LargeTime-${uniqueId}`; - // Use t:8h for 8 hour estimate - await clientA.workView.addTask(`${taskName} t:8h`); - console.log('[Large Time Test] Client A created task with 8h estimate'); + const taskName = `LargeTime-${uniqueId}`; + // Use t:8h for 8 hour estimate + await clientA.workView.addTask(`${taskName} t:8h`); + console.log('[Large Time Test] Client A created task with 8h estimate'); - await waitForTask(clientA.page, taskName); + await waitForTask(clientA.page, taskName); - // Task exists on Client A (estimate is stored but may not be visible in compact view) - const taskLocatorA = clientA.page.locator(`task:has-text("${taskName}")`); - await expect(taskLocatorA).toBeVisible({ timeout: 5000 }); - console.log('[Large Time Test] Task visible on Client A'); + // Task exists on Client A (estimate is stored but may not be visible in compact view) + await expectTaskVisible(clientA, taskName); + console.log('[Large Time Test] Task visible on Client A'); - // ============ PHASE 2: Sync ============ - await clientA.sync.syncAndWait(); + // ============ PHASE 2: Sync ============ + await clientA.sync.syncAndWait(); - clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); - await clientB.sync.setupSuperSync(syncConfig); - await clientB.sync.syncAndWait(); + clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); + await clientB.sync.setupSuperSync(syncConfig); + await clientB.sync.syncAndWait(); - // ============ PHASE 3: Verify on Client B ============ - await waitForTask(clientB.page, taskName); + // ============ PHASE 3: Verify on Client B ============ + await waitForTask(clientB.page, taskName); - const taskLocatorB = clientB.page.locator(`task:has-text("${taskName}")`); - await expect(taskLocatorB).toBeVisible({ timeout: 5000 }); - console.log('[Large Time Test] Task visible on Client B'); + await expectTaskVisible(clientB, taskName); + console.log('[Large Time Test] Task visible on Client B'); - // Note: Time estimate is stored as task data, UI display varies - // The key test is that the task synced successfully - console.log('[Large Time Test] Task with time estimate synced successfully'); - } finally { - if (clientA) await closeClient(clientA); - if (clientB) await closeClient(clientB); - } - }, - ); + // Note: Time estimate is stored as task data, UI display varies + // The key test is that the task synced successfully + console.log('[Large Time Test] Task with time estimate synced successfully'); + } finally { + if (clientA) await closeClient(clientA); + if (clientB) await closeClient(clientB); + } + }); /** * Test: Concurrent time tracking resolves via LWW @@ -231,99 +209,85 @@ base.describe('@supersync Time Tracking Advanced Sync', () => { * 3. Client B tracks 5 seconds concurrently (started before A sync), stops, syncs * 4. Verify final time is consistent (LWW - later sync wins) */ - base( - 'Concurrent time tracking resolves consistently', - async ({ browser, baseURL }, testInfo) => { - const testRunId = generateTestRunId(testInfo.workerIndex); - const uniqueId = Date.now(); - let clientA: SimulatedE2EClient | null = null; - let clientB: SimulatedE2EClient | null = null; + test('Concurrent time tracking resolves consistently', async ({ + browser, + baseURL, + testRunId, + }) => { + const uniqueId = Date.now(); + let clientA: SimulatedE2EClient | null = null; + let clientB: SimulatedE2EClient | null = null; - try { - const user = await createTestUser(testRunId); - const syncConfig = getSuperSyncConfig(user); + try { + const user = await createTestUser(testRunId); + const syncConfig = getSuperSyncConfig(user); - // ============ PHASE 1: Setup Both Clients ============ - clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); - await clientA.sync.setupSuperSync(syncConfig); + // ============ PHASE 1: Setup Both Clients ============ + clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); + await clientA.sync.setupSuperSync(syncConfig); - const taskName = `ConcurrentTime-${uniqueId}`; - await clientA.workView.addTask(taskName); - await clientA.sync.syncAndWait(); + const taskName = `ConcurrentTime-${uniqueId}`; + await clientA.workView.addTask(taskName); + await clientA.sync.syncAndWait(); - clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); - await clientB.sync.setupSuperSync(syncConfig); - await clientB.sync.syncAndWait(); + clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); + await clientB.sync.setupSuperSync(syncConfig); + await clientB.sync.syncAndWait(); - await waitForTask(clientA.page, taskName); - await waitForTask(clientB.page, taskName); - console.log('[Concurrent Time Test] Both clients have task'); + await waitForTask(clientA.page, taskName); + await waitForTask(clientB.page, taskName); + console.log('[Concurrent Time Test] Both clients have task'); - // ============ PHASE 2: Client A Tracks Time ============ - const taskLocatorA = clientA.page.locator(`task:has-text("${taskName}")`); - await taskLocatorA.hover(); - await taskLocatorA.locator('.start-task-btn').click(); - console.log('[Concurrent Time Test] Client A started tracking'); + // ============ PHASE 2: Client A Tracks Time ============ + await startTimeTracking(clientA, taskName); + console.log('[Concurrent Time Test] Client A started tracking'); - await clientA.page.waitForTimeout(3000); + await clientA.page.waitForTimeout(3000); - await taskLocatorA.hover(); - const pauseBtnA = taskLocatorA.locator('button:has(mat-icon:has-text("pause"))'); - await pauseBtnA.click(); - console.log('[Concurrent Time Test] Client A stopped after 3s'); + await stopTimeTracking(clientA, taskName); + console.log('[Concurrent Time Test] Client A stopped after 3s'); - // ============ PHASE 3: Client B Tracks Time (Concurrent) ============ - const taskLocatorB = clientB.page.locator(`task:has-text("${taskName}")`); - await taskLocatorB.hover(); - await taskLocatorB.locator('.start-task-btn').click(); - console.log('[Concurrent Time Test] Client B started tracking'); + // ============ PHASE 3: Client B Tracks Time (Concurrent) ============ + await startTimeTracking(clientB, taskName); + console.log('[Concurrent Time Test] Client B started tracking'); - await clientB.page.waitForTimeout(5000); + await clientB.page.waitForTimeout(5000); - await taskLocatorB.hover(); - const pauseBtnB = taskLocatorB.locator('button:has(mat-icon:has-text("pause"))'); - await pauseBtnB.click(); - console.log('[Concurrent Time Test] Client B stopped after 5s'); + await stopTimeTracking(clientB, taskName); + console.log('[Concurrent Time Test] Client B stopped after 5s'); - // ============ PHASE 4: Sync Both ============ - await clientA.sync.syncAndWait(); - await clientB.sync.syncAndWait(); - await clientA.sync.syncAndWait(); // Converge - console.log('[Concurrent Time Test] All synced'); + // ============ PHASE 4: Sync Both ============ + await clientA.sync.syncAndWait(); + await clientB.sync.syncAndWait(); + await clientA.sync.syncAndWait(); // Converge + console.log('[Concurrent Time Test] All synced'); - // ============ PHASE 5: Verify Consistent State ============ - // Reload to ensure UI reflects final state - await clientA.page.reload(); - await waitForAppReady(clientA.page); - await waitForTask(clientA.page, taskName); + // ============ PHASE 5: Verify Consistent State ============ + // Reload to ensure UI reflects final state + await clientA.page.reload(); + await waitForAppReady(clientA.page); + await waitForTask(clientA.page, taskName); - await clientB.page.reload(); - await waitForAppReady(clientB.page); - await waitForTask(clientB.page, taskName); + await clientB.page.reload(); + await waitForAppReady(clientB.page); + await waitForTask(clientB.page, taskName); - const taskA = clientA.page.locator(`task:has-text("${taskName}")`); - const taskB = clientB.page.locator(`task:has-text("${taskName}")`); + const taskA = getTaskElement(clientA, taskName); + const taskB = getTaskElement(clientB, taskName); - const timeA = await taskA - .locator('.time-wrapper .time-val') - .first() - .textContent(); - const timeB = await taskB - .locator('.time-wrapper .time-val') - .first() - .textContent(); + const timeA = await taskA.locator('.time-wrapper .time-val').first().textContent(); + const timeB = await taskB.locator('.time-wrapper .time-val').first().textContent(); - console.log(`[Concurrent Time Test] Client A final time: ${timeA}`); - console.log(`[Concurrent Time Test] Client B final time: ${timeB}`); + console.log(`[Concurrent Time Test] Client A final time: ${timeA}`); + console.log(`[Concurrent Time Test] Client B final time: ${timeB}`); - // Times should match (LWW resolution) - expect(timeA?.trim()).toBe(timeB?.trim()); + // Times should match (LWW resolution) + expect(timeA?.trim()).toBe(timeB?.trim()); - console.log('[Concurrent Time Test] Time tracking resolved consistently'); - } finally { - if (clientA) await closeClient(clientA); - if (clientB) await closeClient(clientB); - } - }, - ); + console.log('[Concurrent Time Test] Time tracking resolved consistently'); + } finally { + if (clientA) await closeClient(clientA); + if (clientB) await closeClient(clientB); + } + }); }); diff --git a/e2e/tests/sync/supersync-worklog.spec.ts b/e2e/tests/sync/supersync-worklog.spec.ts index cb8ecfc49..19e993d08 100644 --- a/e2e/tests/sync/supersync-worklog.spec.ts +++ b/e2e/tests/sync/supersync-worklog.spec.ts @@ -1,10 +1,10 @@ -import { test as base, expect } from '@playwright/test'; +import { test, expect } from '../../fixtures/supersync.fixture'; import { createTestUser, getSuperSyncConfig, createSimulatedClient, closeClient, - isServerHealthy, + markTaskDone, type SimulatedE2EClient, } from '../../utils/supersync-helpers'; @@ -17,24 +17,7 @@ import { * - Multiple archives across clients */ -let testCounter = 0; -const generateTestRunId = (workerIndex: number): string => { - return `worklog-${Date.now()}-${workerIndex}-${testCounter++}`; -}; - -base.describe('@supersync Worklog Sync', () => { - let serverHealthy: boolean | null = null; - - base.beforeEach(async ({}, testInfo) => { - if (serverHealthy === null) { - serverHealthy = await isServerHealthy(); - if (!serverHealthy) { - console.warn('SuperSync server not healthy - skipping tests'); - } - } - testInfo.skip(!serverHealthy, 'SuperSync server not running'); - }); - +test.describe('@supersync Worklog Sync', () => { /** * Test: Archived tasks appear in worklog on synced client * @@ -45,96 +28,91 @@ base.describe('@supersync Worklog Sync', () => { * 4. Client B syncs * 5. Client B verifies tasks in worklog */ - base( - 'Archived tasks appear in worklog on synced client', - async ({ browser, baseURL }, testInfo) => { - const testRunId = generateTestRunId(testInfo.workerIndex); - const uniqueId = Date.now(); - let clientA: SimulatedE2EClient | null = null; - let clientB: SimulatedE2EClient | null = null; + test('Archived tasks appear in worklog on synced client', async ({ + browser, + baseURL, + testRunId, + }) => { + const uniqueId = Date.now(); + let clientA: SimulatedE2EClient | null = null; + let clientB: SimulatedE2EClient | null = null; - try { - const user = await createTestUser(testRunId); - const syncConfig = getSuperSyncConfig(user); + try { + const user = await createTestUser(testRunId); + const syncConfig = getSuperSyncConfig(user); - // ============ PHASE 1: Client A Creates and Completes Tasks ============ - clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); - await clientA.sync.setupSuperSync(syncConfig); + // ============ PHASE 1: Client A Creates and Completes Tasks ============ + clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); + await clientA.sync.setupSuperSync(syncConfig); - const task1Name = `Worklog1-${uniqueId}`; - const task2Name = `Worklog2-${uniqueId}`; + const task1Name = `Worklog1-${uniqueId}`; + const task2Name = `Worklog2-${uniqueId}`; - await clientA.workView.addTask(task1Name); - await clientA.workView.addTask(task2Name); - console.log('[Worklog Test] Client A created 2 tasks'); + await clientA.workView.addTask(task1Name); + await clientA.workView.addTask(task2Name); + console.log('[Worklog Test] Client A created 2 tasks'); - // Mark both as done - const task1 = clientA.page.locator(`task:has-text("${task1Name}")`); - await task1.hover(); - await task1.locator('.task-done-btn').click(); + // Mark both as done + await markTaskDone(clientA, task1Name); + await markTaskDone(clientA, task2Name); + console.log('[Worklog Test] Client A marked tasks done'); - const task2 = clientA.page.locator(`task:has-text("${task2Name}")`); - await task2.hover(); - await task2.locator('.task-done-btn').click(); - console.log('[Worklog Test] Client A marked tasks done'); + // ============ PHASE 2: Client A Archives ============ + const finishDayBtn = clientA.page.locator('.e2e-finish-day'); + await finishDayBtn.waitFor({ state: 'visible', timeout: 10000 }); + await finishDayBtn.click(); - // ============ PHASE 2: Client A Archives ============ - const finishDayBtn = clientA.page.locator('.e2e-finish-day'); - await finishDayBtn.waitFor({ state: 'visible', timeout: 10000 }); - await finishDayBtn.click(); + await clientA.page.waitForURL(/daily-summary/, { timeout: 10000 }); + await clientA.page.waitForLoadState('networkidle'); - await clientA.page.waitForURL(/daily-summary/, { timeout: 10000 }); - await clientA.page.waitForLoadState('networkidle'); + const saveBtn = clientA.page.locator( + 'daily-summary button[mat-flat-button]:has(mat-icon:has-text("wb_sunny"))', + ); + await saveBtn.waitFor({ state: 'visible', timeout: 10000 }); + await saveBtn.click(); - const saveBtn = clientA.page.locator( - 'daily-summary button[mat-flat-button]:has(mat-icon:has-text("wb_sunny"))', - ); - await saveBtn.waitFor({ state: 'visible', timeout: 10000 }); - await saveBtn.click(); + await clientA.page.waitForURL(/tag\/TODAY/, { timeout: 10000 }); + console.log('[Worklog Test] Client A archived tasks'); - await clientA.page.waitForURL(/tag\/TODAY/, { timeout: 10000 }); - console.log('[Worklog Test] Client A archived tasks'); + // ============ PHASE 3: Sync ============ + await clientA.sync.syncAndWait(); + console.log('[Worklog Test] Client A synced'); - // ============ PHASE 3: Sync ============ - await clientA.sync.syncAndWait(); - console.log('[Worklog Test] Client A synced'); + // ============ PHASE 4: Client B Downloads and Checks Worklog ============ + clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); + await clientB.sync.setupSuperSync(syncConfig); + await clientB.sync.syncAndWait(); + console.log('[Worklog Test] Client B synced'); - // ============ PHASE 4: Client B Downloads and Checks Worklog ============ - clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); - await clientB.sync.setupSuperSync(syncConfig); - await clientB.sync.syncAndWait(); - console.log('[Worklog Test] Client B synced'); + // Navigate to worklog + await clientB.page.goto('/#/tag/TODAY/worklog'); + await clientB.page.waitForLoadState('networkidle'); + await clientB.page.waitForSelector('worklog', { timeout: 10000 }); + console.log('[Worklog Test] Client B navigated to worklog'); - // Navigate to worklog - await clientB.page.goto('/#/tag/TODAY/worklog'); - await clientB.page.waitForLoadState('networkidle'); - await clientB.page.waitForSelector('worklog', { timeout: 10000 }); - console.log('[Worklog Test] Client B navigated to worklog'); - - // Expand week to see tasks - const weekRow = clientB.page.locator('.week-row').first(); - if (await weekRow.isVisible()) { - await weekRow.click(); - await clientB.page.waitForTimeout(500); - } - - // Verify both tasks appear in worklog - const task1InWorklog = clientB.page.locator( - `.task-summary-table .task-title:has-text("${task1Name}")`, - ); - const task2InWorklog = clientB.page.locator( - `.task-summary-table .task-title:has-text("${task2Name}")`, - ); - - await expect(task1InWorklog).toBeVisible({ timeout: 10000 }); - await expect(task2InWorklog).toBeVisible({ timeout: 10000 }); - console.log('[Worklog Test] Both tasks visible in worklog on Client B'); - } finally { - if (clientA) await closeClient(clientA); - if (clientB) await closeClient(clientB); + // Expand week to see tasks + const weekRow = clientB.page.locator('.week-row').first(); + if (await weekRow.isVisible()) { + await weekRow.click(); + await clientB.page.waitForTimeout(500); } - }, - ); + + // Verify both tasks appear in worklog + const task1InWorklog = clientB.page.locator( + `.task-summary-table .task-title:has-text("${task1Name}")`, + ); + const task2InWorklog = clientB.page.locator( + `.task-summary-table .task-title:has-text("${task2Name}")`, + ); + + await expect(task1InWorklog).toBeVisible({ timeout: 10000 }); + await expect(task2InWorklog).toBeVisible({ timeout: 10000 }); + console.log('[Worklog Test] Both tasks visible in worklog on Client B'); + } finally { + if (clientA) await closeClient(clientA); + if (clientB) await closeClient(clientB); + } + }); /** * Test: Worklog shows correct time data after sync @@ -144,86 +122,85 @@ base.describe('@supersync Worklog Sync', () => { * 2. Client A archives * 3. Client B syncs and checks worklog for time data */ - base( - 'Worklog shows correct time data after sync', - async ({ browser, baseURL }, testInfo) => { - const testRunId = generateTestRunId(testInfo.workerIndex); - const uniqueId = Date.now(); - let clientA: SimulatedE2EClient | null = null; - let clientB: SimulatedE2EClient | null = null; + test('Worklog shows correct time data after sync', async ({ + browser, + baseURL, + testRunId, + }) => { + const uniqueId = Date.now(); + let clientA: SimulatedE2EClient | null = null; + let clientB: SimulatedE2EClient | null = null; - try { - const user = await createTestUser(testRunId); - const syncConfig = getSuperSyncConfig(user); + try { + const user = await createTestUser(testRunId); + const syncConfig = getSuperSyncConfig(user); - // ============ PHASE 1: Client A Creates, Tracks, Completes ============ - clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); - await clientA.sync.setupSuperSync(syncConfig); + // ============ PHASE 1: Client A Creates, Tracks, Completes ============ + clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); + await clientA.sync.setupSuperSync(syncConfig); - const taskName = `TimedWorklog-${uniqueId}`; - await clientA.workView.addTask(taskName); + const taskName = `TimedWorklog-${uniqueId}`; + await clientA.workView.addTask(taskName); - // Track time - const task = clientA.page.locator(`task:has-text("${taskName}")`); - await task.hover(); - await task.locator('.start-task-btn').click(); - await clientA.page.waitForTimeout(3000); - await task.hover(); - const pauseBtn = task.locator('button:has(mat-icon:has-text("pause"))'); - await pauseBtn.click(); - console.log('[Timed Worklog Test] Tracked 3 seconds'); + // Track time using helper functions (need to add imports) + const task = clientA.page.locator(`task:has-text("${taskName}")`); + await task.hover(); + await task.locator('.start-task-btn').click(); + await clientA.page.waitForTimeout(3000); + await task.hover(); + const pauseBtn = task.locator('button:has(mat-icon:has-text("pause"))'); + await pauseBtn.click(); + console.log('[Timed Worklog Test] Tracked 3 seconds'); - // Complete task - await task.hover(); - await task.locator('.task-done-btn').click(); + // Complete task + await markTaskDone(clientA, taskName); - // ============ PHASE 2: Archive ============ - const finishDayBtn = clientA.page.locator('.e2e-finish-day'); - await finishDayBtn.waitFor({ state: 'visible', timeout: 10000 }); - await finishDayBtn.click(); + // ============ PHASE 2: Archive ============ + const finishDayBtn = clientA.page.locator('.e2e-finish-day'); + await finishDayBtn.waitFor({ state: 'visible', timeout: 10000 }); + await finishDayBtn.click(); - await clientA.page.waitForURL(/daily-summary/, { timeout: 10000 }); + await clientA.page.waitForURL(/daily-summary/, { timeout: 10000 }); - const saveBtn = clientA.page.locator( - 'daily-summary button[mat-flat-button]:has(mat-icon:has-text("wb_sunny"))', - ); - await saveBtn.waitFor({ state: 'visible', timeout: 10000 }); - await saveBtn.click(); + const saveBtn = clientA.page.locator( + 'daily-summary button[mat-flat-button]:has(mat-icon:has-text("wb_sunny"))', + ); + await saveBtn.waitFor({ state: 'visible', timeout: 10000 }); + await saveBtn.click(); - await clientA.page.waitForURL(/tag\/TODAY/, { timeout: 10000 }); + await clientA.page.waitForURL(/tag\/TODAY/, { timeout: 10000 }); - // ============ PHASE 3: Sync ============ - await clientA.sync.syncAndWait(); + // ============ PHASE 3: Sync ============ + await clientA.sync.syncAndWait(); - // ============ PHASE 4: Client B Checks Worklog ============ - clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); - await clientB.sync.setupSuperSync(syncConfig); - await clientB.sync.syncAndWait(); + // ============ PHASE 4: Client B Checks Worklog ============ + clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); + await clientB.sync.setupSuperSync(syncConfig); + await clientB.sync.syncAndWait(); - await clientB.page.goto('/#/tag/TODAY/worklog'); - await clientB.page.waitForLoadState('networkidle'); - await clientB.page.waitForSelector('worklog', { timeout: 10000 }); + await clientB.page.goto('/#/tag/TODAY/worklog'); + await clientB.page.waitForLoadState('networkidle'); + await clientB.page.waitForSelector('worklog', { timeout: 10000 }); - // Expand week - const weekRow = clientB.page.locator('.week-row').first(); - if (await weekRow.isVisible()) { - await weekRow.click(); - await clientB.page.waitForTimeout(500); - } - - // Verify task appears in worklog - const taskInWorklog = clientB.page.locator(`text=${taskName}`); - await expect(taskInWorklog).toBeVisible({ timeout: 10000 }); - - // The task with tracked time should appear in the worklog - // Time data is stored but exact UI format varies - console.log('[Timed Worklog Test] Task with time data visible in worklog'); - } finally { - if (clientA) await closeClient(clientA); - if (clientB) await closeClient(clientB); + // Expand week + const weekRow = clientB.page.locator('.week-row').first(); + if (await weekRow.isVisible()) { + await weekRow.click(); + await clientB.page.waitForTimeout(500); } - }, - ); + + // Verify task appears in worklog + const taskInWorklog = clientB.page.locator(`text=${taskName}`); + await expect(taskInWorklog).toBeVisible({ timeout: 10000 }); + + // The task with tracked time should appear in the worklog + // Time data is stored but exact UI format varies + console.log('[Timed Worklog Test] Task with time data visible in worklog'); + } finally { + if (clientA) await closeClient(clientA); + if (clientB) await closeClient(clientB); + } + }); /** * Test: Multiple archive operations sync correctly @@ -236,120 +213,116 @@ base.describe('@supersync Worklog Sync', () => { * 5. Both sync * 6. Verify all archived tasks in worklog on both */ - base( - 'Multiple archive operations sync correctly', - async ({ browser, baseURL }, testInfo) => { - const testRunId = generateTestRunId(testInfo.workerIndex); - const uniqueId = Date.now(); - let clientA: SimulatedE2EClient | null = null; - let clientB: SimulatedE2EClient | null = null; + test('Multiple archive operations sync correctly', async ({ + browser, + baseURL, + testRunId, + }) => { + const uniqueId = Date.now(); + let clientA: SimulatedE2EClient | null = null; + let clientB: SimulatedE2EClient | null = null; - try { - const user = await createTestUser(testRunId); - const syncConfig = getSuperSyncConfig(user); + try { + const user = await createTestUser(testRunId); + const syncConfig = getSuperSyncConfig(user); - // ============ PHASE 1: Client A Archives First Batch ============ - clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); - await clientA.sync.setupSuperSync(syncConfig); + // ============ PHASE 1: Client A Archives First Batch ============ + clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); + await clientA.sync.setupSuperSync(syncConfig); - const taskA1 = `BatchA1-${uniqueId}`; - await clientA.workView.addTask(taskA1); + const taskA1 = `BatchA1-${uniqueId}`; + await clientA.workView.addTask(taskA1); - const task1 = clientA.page.locator(`task:has-text("${taskA1}")`); - await task1.hover(); - await task1.locator('.task-done-btn').click(); + await markTaskDone(clientA, taskA1); - // Archive - const finishDayBtnA = clientA.page.locator('.e2e-finish-day'); - await finishDayBtnA.click(); - await clientA.page.waitForURL(/daily-summary/, { timeout: 10000 }); + // Archive + const finishDayBtnA = clientA.page.locator('.e2e-finish-day'); + await finishDayBtnA.click(); + await clientA.page.waitForURL(/daily-summary/, { timeout: 10000 }); - const saveBtnA = clientA.page.locator( - 'daily-summary button[mat-flat-button]:has(mat-icon:has-text("wb_sunny"))', - ); - await saveBtnA.click(); - await clientA.page.waitForURL(/tag\/TODAY/, { timeout: 10000 }); - console.log('[Multi Archive Test] Client A archived first batch'); + const saveBtnA = clientA.page.locator( + 'daily-summary button[mat-flat-button]:has(mat-icon:has-text("wb_sunny"))', + ); + await saveBtnA.click(); + await clientA.page.waitForURL(/tag\/TODAY/, { timeout: 10000 }); + console.log('[Multi Archive Test] Client A archived first batch'); - await clientA.sync.syncAndWait(); + await clientA.sync.syncAndWait(); - // ============ PHASE 2: Client B Archives Second Batch ============ - clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); - await clientB.sync.setupSuperSync(syncConfig); - await clientB.sync.syncAndWait(); + // ============ PHASE 2: Client B Archives Second Batch ============ + clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); + await clientB.sync.setupSuperSync(syncConfig); + await clientB.sync.syncAndWait(); - const taskB1 = `BatchB1-${uniqueId}`; - await clientB.workView.addTask(taskB1); + const taskB1 = `BatchB1-${uniqueId}`; + await clientB.workView.addTask(taskB1); - const task2 = clientB.page.locator(`task:has-text("${taskB1}")`); - await task2.hover(); - await task2.locator('.task-done-btn').click(); + await markTaskDone(clientB, taskB1); - // Archive - const finishDayBtnB = clientB.page.locator('.e2e-finish-day'); - await finishDayBtnB.click(); - await clientB.page.waitForURL(/daily-summary/, { timeout: 10000 }); + // Archive + const finishDayBtnB = clientB.page.locator('.e2e-finish-day'); + await finishDayBtnB.click(); + await clientB.page.waitForURL(/daily-summary/, { timeout: 10000 }); - const saveBtnB = clientB.page.locator( - 'daily-summary button[mat-flat-button]:has(mat-icon:has-text("wb_sunny"))', - ); - await saveBtnB.click(); - await clientB.page.waitForURL(/tag\/TODAY/, { timeout: 10000 }); - console.log('[Multi Archive Test] Client B archived second batch'); + const saveBtnB = clientB.page.locator( + 'daily-summary button[mat-flat-button]:has(mat-icon:has-text("wb_sunny"))', + ); + await saveBtnB.click(); + await clientB.page.waitForURL(/tag\/TODAY/, { timeout: 10000 }); + console.log('[Multi Archive Test] Client B archived second batch'); - // ============ PHASE 3: Sync Both ============ - await clientB.sync.syncAndWait(); - await clientA.sync.syncAndWait(); - console.log('[Multi Archive Test] Both synced'); + // ============ PHASE 3: Sync Both ============ + await clientB.sync.syncAndWait(); + await clientA.sync.syncAndWait(); + console.log('[Multi Archive Test] Both synced'); - // ============ PHASE 4: Verify All Archives in Worklog ============ - // Check Client A worklog - await clientA.page.goto('/#/tag/TODAY/worklog'); - await clientA.page.waitForLoadState('networkidle'); - await clientA.page.waitForSelector('worklog', { timeout: 10000 }); + // ============ PHASE 4: Verify All Archives in Worklog ============ + // Check Client A worklog + await clientA.page.goto('/#/tag/TODAY/worklog'); + await clientA.page.waitForLoadState('networkidle'); + await clientA.page.waitForSelector('worklog', { timeout: 10000 }); - const weekRowA = clientA.page.locator('.week-row').first(); - if (await weekRowA.isVisible()) { - await weekRowA.click(); - await clientA.page.waitForTimeout(500); - } - - const taskA1InWorklog = clientA.page.locator( - `.task-summary-table .task-title:has-text("${taskA1}")`, - ); - const taskB1InWorklogA = clientA.page.locator( - `.task-summary-table .task-title:has-text("${taskB1}")`, - ); - - await expect(taskA1InWorklog).toBeVisible({ timeout: 10000 }); - await expect(taskB1InWorklogA).toBeVisible({ timeout: 10000 }); - console.log('[Multi Archive Test] Client A has both archives in worklog'); - - // Check Client B worklog - await clientB.page.goto('/#/tag/TODAY/worklog'); - await clientB.page.waitForLoadState('networkidle'); - await clientB.page.waitForSelector('worklog', { timeout: 10000 }); - - const weekRowB = clientB.page.locator('.week-row').first(); - if (await weekRowB.isVisible()) { - await weekRowB.click(); - await clientB.page.waitForTimeout(500); - } - - const taskA1InWorklogB = clientB.page.locator( - `.task-summary-table .task-title:has-text("${taskA1}")`, - ); - const taskB1InWorklog = clientB.page.locator( - `.task-summary-table .task-title:has-text("${taskB1}")`, - ); - - await expect(taskA1InWorklogB).toBeVisible({ timeout: 10000 }); - await expect(taskB1InWorklog).toBeVisible({ timeout: 10000 }); - console.log('[Multi Archive Test] Client B has both archives in worklog'); - } finally { - if (clientA) await closeClient(clientA); - if (clientB) await closeClient(clientB); + const weekRowA = clientA.page.locator('.week-row').first(); + if (await weekRowA.isVisible()) { + await weekRowA.click(); + await clientA.page.waitForTimeout(500); } - }, - ); + + const taskA1InWorklog = clientA.page.locator( + `.task-summary-table .task-title:has-text("${taskA1}")`, + ); + const taskB1InWorklogA = clientA.page.locator( + `.task-summary-table .task-title:has-text("${taskB1}")`, + ); + + await expect(taskA1InWorklog).toBeVisible({ timeout: 10000 }); + await expect(taskB1InWorklogA).toBeVisible({ timeout: 10000 }); + console.log('[Multi Archive Test] Client A has both archives in worklog'); + + // Check Client B worklog + await clientB.page.goto('/#/tag/TODAY/worklog'); + await clientB.page.waitForLoadState('networkidle'); + await clientB.page.waitForSelector('worklog', { timeout: 10000 }); + + const weekRowB = clientB.page.locator('.week-row').first(); + if (await weekRowB.isVisible()) { + await weekRowB.click(); + await clientB.page.waitForTimeout(500); + } + + const taskA1InWorklogB = clientB.page.locator( + `.task-summary-table .task-title:has-text("${taskA1}")`, + ); + const taskB1InWorklog = clientB.page.locator( + `.task-summary-table .task-title:has-text("${taskB1}")`, + ); + + await expect(taskA1InWorklogB).toBeVisible({ timeout: 10000 }); + await expect(taskB1InWorklog).toBeVisible({ timeout: 10000 }); + console.log('[Multi Archive Test] Client B has both archives in worklog'); + } finally { + if (clientA) await closeClient(clientA); + if (clientB) await closeClient(clientB); + } + }); }); diff --git a/e2e/tests/sync/supersync.spec.ts b/e2e/tests/sync/supersync.spec.ts index 822dea288..8801e8d13 100644 --- a/e2e/tests/sync/supersync.spec.ts +++ b/e2e/tests/sync/supersync.spec.ts @@ -1,13 +1,34 @@ -import { test as base, expect } from '@playwright/test'; +import { test, expect } from '../../fixtures/supersync.fixture'; import { createTestUser, getSuperSyncConfig, createSimulatedClient, closeClient, waitForTask, - isServerHealthy, + getTaskElement, + getParentTaskElement, + markTaskDone, + markSubtaskDone, + expandTask, + deleteTask, + renameTask, + startTimeTracking, + stopTimeTracking, + getTaskCount, + hasTaskOnClient, type SimulatedE2EClient, } from '../../utils/supersync-helpers'; +import { + expectTaskVisible, + expectTaskNotVisible, + expectTaskDone, + expectTaskOnAllClients, + expectSubtaskVisible, + expectSubtaskDone, + expectSubtaskNotDone, + expectEqualTaskCount, + expectTaskCount, +} from '../../utils/supersync-assertions'; import { waitForAppReady } from '../../utils/waits'; /** @@ -24,31 +45,7 @@ import { waitForAppReady } from '../../utils/waits'; * Run with: npm run e2e:supersync */ -/** - * Generate a unique test run ID for data isolation. - */ -const generateTestRunId = (workerIndex: number): string => { - return `${Date.now()}-${workerIndex}`; -}; - -base.describe('@supersync SuperSync E2E', () => { - // Check server health once and cache the result - let serverHealthy: boolean | null = null; - - base.beforeEach(async ({}, testInfo) => { - // Only check server health once per worker - if (serverHealthy === null) { - serverHealthy = await isServerHealthy(); - if (!serverHealthy) { - console.warn( - 'SuperSync server not healthy at http://localhost:1901 - skipping tests', - ); - } - } - // Skip the test if server is not healthy - testInfo.skip(!serverHealthy, 'SuperSync server not running'); - }); - +test.describe('@supersync SuperSync E2E', () => { /** * Scenario 2.1: Client A Creates, Client B Downloads * @@ -66,55 +63,51 @@ base.describe('@supersync SuperSync E2E', () => { * - Client B: has Task 1 (received via sync) * - Server: has 1 operation */ - base( - '2.1 Client A creates task, Client B downloads it', - async ({ browser, baseURL }, testInfo) => { - const testRunId = generateTestRunId(testInfo.workerIndex); - let clientA: SimulatedE2EClient | null = null; - let clientB: SimulatedE2EClient | null = null; + test('2.1 Client A creates task, Client B downloads it', async ({ + browser, + baseURL, + testRunId, + }) => { + let clientA: SimulatedE2EClient | null = null; + let clientB: SimulatedE2EClient | null = null; - try { - // Create shared test user (both clients use same account) - const user = await createTestUser(testRunId); - const syncConfig = getSuperSyncConfig(user); + try { + // Create shared test user (both clients use same account) + const user = await createTestUser(testRunId); + const syncConfig = getSuperSyncConfig(user); - // Set up Client A - clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); - await clientA.sync.setupSuperSync(syncConfig); + // Set up Client A + clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); + await clientA.sync.setupSuperSync(syncConfig); - // Step 1: Client A creates a task - const taskName = `Task-${testRunId}-from-A`; - await clientA.workView.addTask(taskName); + // Step 1: Client A creates a task + const taskName = `Task-${testRunId}-from-A`; + await clientA.workView.addTask(taskName); - // Step 2: Client A syncs (upload) - await clientA.sync.syncAndWait(); + // Step 2: Client A syncs (upload) + await clientA.sync.syncAndWait(); - // Verify Client A still has the task - await waitForTask(clientA.page, taskName); + // Verify Client A still has the task + await waitForTask(clientA.page, taskName); - // Set up Client B (fresh context = isolated IndexedDB) - clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); - await clientB.sync.setupSuperSync(syncConfig); + // Set up Client B (fresh context = isolated IndexedDB) + clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); + await clientB.sync.setupSuperSync(syncConfig); - // Step 3: Client B syncs (download) - await clientB.sync.syncAndWait(); + // Step 3: Client B syncs (download) + await clientB.sync.syncAndWait(); - // Verify Client B has the task from Client A - await waitForTask(clientB.page, taskName); + // Verify Client B has the task from Client A + await waitForTask(clientB.page, taskName); - // Final assertions - const taskLocatorA = clientA.page.locator(`task:has-text("${taskName}")`); - const taskLocatorB = clientB.page.locator(`task:has-text("${taskName}")`); - - await expect(taskLocatorA).toBeVisible(); - await expect(taskLocatorB).toBeVisible(); - } finally { - // Cleanup - if (clientA) await closeClient(clientA); - if (clientB) await closeClient(clientB); - } - }, - ); + // Final assertions + await expectTaskOnAllClients([clientA, clientB], taskName); + } finally { + // Cleanup + if (clientA) await closeClient(clientA); + if (clientB) await closeClient(clientB); + } + }); /** * Scenario 2.2: Both Clients Create Different Tasks @@ -128,68 +121,66 @@ base.describe('@supersync SuperSync E2E', () => { * * Expected: Both clients have both tasks */ - base( - '2.2 Both clients create different tasks', - async ({ browser, baseURL }, testInfo) => { - const testRunId = generateTestRunId(testInfo.workerIndex); - let clientA: SimulatedE2EClient | null = null; - let clientB: SimulatedE2EClient | null = null; + test('2.2 Both clients create different tasks', async ({ + browser, + baseURL, + testRunId, + }) => { + let clientA: SimulatedE2EClient | null = null; + let clientB: SimulatedE2EClient | null = null; - try { - const user = await createTestUser(testRunId); - const syncConfig = getSuperSyncConfig(user); + 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); + // 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); + clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); + await clientB.sync.setupSuperSync(syncConfig); - // Step 1: Client A creates Task A - const taskA = `TaskA-${testRunId}`; - await clientA.workView.addTask(taskA); - // Wait for task to be fully created in store - await waitForTask(clientA.page, taskA); + // Step 1: Client A creates Task A + const taskA = `TaskA-${testRunId}`; + await clientA.workView.addTask(taskA); + // Wait for task to be fully created in store + await waitForTask(clientA.page, taskA); - // Step 2: Client A syncs (upload Task A) - await clientA.sync.syncAndWait(); - console.log('[Test] Client A synced Task A'); + // Step 2: Client A syncs (upload Task A) + await clientA.sync.syncAndWait(); + console.log('[Test] Client A synced Task A'); - // Step 3: Client B creates Task B - const taskB = `TaskB-${testRunId}`; - await clientB.workView.addTask(taskB); - // Wait for task to be fully created in store - await waitForTask(clientB.page, taskB); + // Step 3: Client B creates Task B + const taskB = `TaskB-${testRunId}`; + await clientB.workView.addTask(taskB); + // Wait for task to be fully created in store + await waitForTask(clientB.page, taskB); - // Step 4: Client B syncs (upload Task B, download Task A) - await clientB.sync.syncAndWait(); - console.log('[Test] Client B synced (uploaded Task B, downloaded Task A)'); + // Step 4: Client B syncs (upload Task B, download Task A) + await clientB.sync.syncAndWait(); + console.log('[Test] Client B synced (uploaded Task B, downloaded Task A)'); - // Step 5: Client A syncs (download Task B) - await clientA.sync.syncAndWait(); - console.log('[Test] Client A synced (downloaded Task B)'); + // Step 5: Client A syncs (download Task B) + await clientA.sync.syncAndWait(); + console.log('[Test] Client A synced (downloaded Task B)'); - // Wait for UI to settle after sync - await clientA.page.waitForTimeout(500); - await clientB.page.waitForTimeout(500); + // Wait for UI to settle after sync + await clientA.page.waitForTimeout(500); + await clientB.page.waitForTimeout(500); - // Verify both clients have both tasks - await waitForTask(clientA.page, taskA); - await waitForTask(clientA.page, taskB); - await waitForTask(clientB.page, taskA); - await waitForTask(clientB.page, taskB); + // Verify both clients have both tasks + await waitForTask(clientA.page, taskA); + await waitForTask(clientA.page, taskB); + await waitForTask(clientB.page, taskA); + await waitForTask(clientB.page, taskB); - await expect(clientA.page.locator(`task:has-text("${taskA}")`)).toBeVisible(); - await expect(clientA.page.locator(`task:has-text("${taskB}")`)).toBeVisible(); - await expect(clientB.page.locator(`task:has-text("${taskA}")`)).toBeVisible(); - await expect(clientB.page.locator(`task:has-text("${taskB}")`)).toBeVisible(); - } finally { - if (clientA) await closeClient(clientA); - if (clientB) await closeClient(clientB); - } - }, - ); + await expectTaskOnAllClients([clientA, clientB], taskA); + await expectTaskOnAllClients([clientA, clientB], taskB); + } finally { + if (clientA) await closeClient(clientA); + if (clientB) await closeClient(clientB); + } + }); /** * Scenario 1.3: Update Task and Sync @@ -204,56 +195,51 @@ base.describe('@supersync SuperSync E2E', () => { * * Expected: Both clients see task as done */ - base( - '1.3 Update propagates between clients', - async ({ browser, baseURL }, testInfo) => { - const testRunId = generateTestRunId(testInfo.workerIndex); - let clientA: SimulatedE2EClient | null = null; - let clientB: SimulatedE2EClient | null = null; + test('1.3 Update propagates between clients', async ({ + browser, + baseURL, + testRunId, + }) => { + let clientA: SimulatedE2EClient | null = null; + let clientB: SimulatedE2EClient | null = null; - try { - const user = await createTestUser(testRunId); - const syncConfig = getSuperSyncConfig(user); + try { + const user = await createTestUser(testRunId); + const syncConfig = getSuperSyncConfig(user); - // Set up Client A and create task - clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); - await clientA.sync.setupSuperSync(syncConfig); + // Set up Client A and create task + clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); + await clientA.sync.setupSuperSync(syncConfig); - const taskName = `Task-${testRunId}-update`; - await clientA.workView.addTask(taskName); - await clientA.sync.syncAndWait(); + const taskName = `Task-${testRunId}-update`; + await clientA.workView.addTask(taskName); + await clientA.sync.syncAndWait(); - // Set up Client B and sync to get the task - clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); - await clientB.sync.setupSuperSync(syncConfig); - await clientB.sync.syncAndWait(); + // Set up Client B and sync to get the task + clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); + await clientB.sync.setupSuperSync(syncConfig); + await clientB.sync.syncAndWait(); - // Verify Client B has the task - await waitForTask(clientB.page, taskName); + // Verify Client B has the task + await waitForTask(clientB.page, taskName); - // Client A marks task as done (hover to reveal done button) - const taskLocatorA = clientA.page.locator(`task:has-text("${taskName}")`); - await taskLocatorA.hover(); - await taskLocatorA.locator('.task-done-btn').click(); + // Client A marks task as done + await markTaskDone(clientA, taskName); - // Client A syncs the update - await clientA.sync.syncAndWait(); + // Client A syncs the update + await clientA.sync.syncAndWait(); - // Client B syncs to receive the update - await clientB.sync.syncAndWait(); + // Client B syncs to receive the update + await clientB.sync.syncAndWait(); - // Verify both show task as done (task with .isDone class exists) - // Use .isDone selector to avoid matching animating/duplicated elements - const doneTaskA = clientA.page.locator(`task.isDone:has-text("${taskName}")`); - const doneTaskB = clientB.page.locator(`task.isDone:has-text("${taskName}")`); - await expect(doneTaskA).toBeVisible({ timeout: 10000 }); - await expect(doneTaskB).toBeVisible({ timeout: 10000 }); - } finally { - if (clientA) await closeClient(clientA); - if (clientB) await closeClient(clientB); - } - }, - ); + // Verify both show task as done + await expectTaskDone(clientA, taskName); + await expectTaskDone(clientB, taskName); + } finally { + if (clientA) await closeClient(clientA); + if (clientB) await closeClient(clientB); + } + }); /** * Scenario 1.4: Delete Task and Sync @@ -266,71 +252,55 @@ base.describe('@supersync SuperSync E2E', () => { * * Expected: Task removed from both clients */ - base( - '1.4 Delete propagates between clients', - async ({ browser, baseURL }, testInfo) => { - const testRunId = generateTestRunId(testInfo.workerIndex); - let clientA: SimulatedE2EClient | null = null; - let clientB: SimulatedE2EClient | null = null; + test('1.4 Delete propagates between clients', async ({ + browser, + baseURL, + testRunId, + }) => { + let clientA: SimulatedE2EClient | null = null; + let clientB: SimulatedE2EClient | null = null; - try { - const user = await createTestUser(testRunId); - const syncConfig = getSuperSyncConfig(user); + try { + const user = await createTestUser(testRunId); + const syncConfig = getSuperSyncConfig(user); - // Set up Client A and create task - clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); - await clientA.sync.setupSuperSync(syncConfig); + // Set up Client A and create task + clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); + await clientA.sync.setupSuperSync(syncConfig); - const taskName = `Task-${testRunId}-delete`; - await clientA.workView.addTask(taskName); - await clientA.sync.syncAndWait(); + const taskName = `Task-${testRunId}-delete`; + await clientA.workView.addTask(taskName); + await clientA.sync.syncAndWait(); - // Set up Client B and sync - clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); - await clientB.sync.setupSuperSync(syncConfig); - await clientB.sync.syncAndWait(); + // Set up Client B and sync + clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); + await clientB.sync.setupSuperSync(syncConfig); + await clientB.sync.syncAndWait(); - // Verify Client B has the task - await waitForTask(clientB.page, taskName); + // Verify Client B has the task + await waitForTask(clientB.page, taskName); - // Client A deletes the task using keyboard shortcut (Backspace) - const taskLocatorA = clientA.page.locator(`task:has-text("${taskName}")`); - await taskLocatorA.click(); // Focus the task - await clientA.page.keyboard.press('Backspace'); + // Client A deletes the task + await deleteTask(clientA, taskName); - // Confirm deletion if dialog appears - const confirmBtn = clientA.page.locator( - 'mat-dialog-actions button:has-text("Delete")', - ); - if (await confirmBtn.isVisible({ timeout: 2000 }).catch(() => false)) { - await confirmBtn.click(); - } - // Wait for task to be removed - await clientA.page.waitForTimeout(500); + // Client A syncs the deletion + await clientA.sync.syncAndWait(); - // Client A syncs the deletion - await clientA.sync.syncAndWait(); + // Client B syncs to receive the deletion + await clientB.sync.syncAndWait(); - // Client B syncs to receive the deletion - await clientB.sync.syncAndWait(); + // Wait for DOM to settle after sync + await clientB.page.waitForLoadState('domcontentloaded'); + await clientB.page.waitForTimeout(300); - // Wait for DOM to settle after sync - await clientB.page.waitForLoadState('domcontentloaded'); - await clientB.page.waitForTimeout(300); - - // Verify task is removed from both clients - await expect( - clientA.page.locator(`task:has-text("${taskName}")`), - ).not.toBeVisible({ timeout: 10000 }); - await expect( - clientB.page.locator(`task:has-text("${taskName}")`), - ).not.toBeVisible({ timeout: 10000 }); - } finally { - if (clientA) await closeClient(clientA); - if (clientB) await closeClient(clientB); - } - }, - ); + // Verify task is removed from both clients + await expectTaskNotVisible(clientA, taskName); + await expectTaskNotVisible(clientB, taskName); + } finally { + if (clientA) await closeClient(clientA); + if (clientB) await closeClient(clientB); + } + }); /** * Scenario 3.1: Concurrent Edits on Same Task @@ -348,67 +318,59 @@ base.describe('@supersync SuperSync E2E', () => { * * Expected: Conflict detected or auto-merged, final state consistent */ - base( - '3.1 Concurrent edits handled gracefully', - async ({ browser, baseURL }, testInfo) => { - const testRunId = generateTestRunId(testInfo.workerIndex); - let clientA: SimulatedE2EClient | null = null; - let clientB: SimulatedE2EClient | null = null; + test('3.1 Concurrent edits handled gracefully', async ({ + browser, + baseURL, + testRunId, + }) => { + let clientA: SimulatedE2EClient | null = null; + let clientB: SimulatedE2EClient | null = null; - try { - const user = await createTestUser(testRunId); - const syncConfig = getSuperSyncConfig(user); + 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); + // Set up both clients + clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); + await clientA.sync.setupSuperSync(syncConfig); - const taskName = `Task-${testRunId}-conflict`; - await clientA.workView.addTask(taskName); - await clientA.sync.syncAndWait(); + const taskName = `Task-${testRunId}-conflict`; + await clientA.workView.addTask(taskName); + await clientA.sync.syncAndWait(); - clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); - await clientB.sync.setupSuperSync(syncConfig); - await clientB.sync.syncAndWait(); + clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); + await clientB.sync.setupSuperSync(syncConfig); + await clientB.sync.syncAndWait(); - // Both clients now have the task - await waitForTask(clientA.page, taskName); - await waitForTask(clientB.page, taskName); + // Both clients now have the task + await waitForTask(clientA.page, taskName); + await waitForTask(clientB.page, taskName); - // Client A marks done (creates local op) - // Hover to reveal controls, then click done button - const taskLocatorA = clientA.page.locator(`task:has-text("${taskName}")`); - await taskLocatorA.hover(); - await taskLocatorA.locator('.task-done-btn').click(); + // Client A marks done (creates local op) + await markTaskDone(clientA, taskName); - // Client B marks done too (concurrent edit) - const taskLocatorB = clientB.page.locator(`task:has-text("${taskName}")`); - await taskLocatorB.hover(); - await taskLocatorB.locator('.task-done-btn').click(); + // Client B marks done too (concurrent edit) + await markTaskDone(clientB, taskName); - // Client A syncs first - await clientA.sync.syncAndWait(); + // Client A syncs first + await clientA.sync.syncAndWait(); - // Client B syncs (may detect concurrent edit) - await clientB.sync.syncAndWait(); + // Client B syncs (may detect concurrent edit) + await clientB.sync.syncAndWait(); - // Client A syncs again to converge - await clientA.sync.syncAndWait(); + // Client A syncs again to converge + await clientA.sync.syncAndWait(); - // Verify both clients have consistent state - const countA = await clientA.page.locator('task').count(); - const countB = await clientB.page.locator('task').count(); - expect(countA).toBe(countB); + // Verify both clients have consistent state + await expectEqualTaskCount([clientA, clientB]); - // Task should exist on both - await expect(taskLocatorA).toBeVisible(); - await expect(taskLocatorB).toBeVisible(); - } finally { - if (clientA) await closeClient(clientA); - if (clientB) await closeClient(clientB); - } - }, - ); + // Task should exist on both + await expectTaskOnAllClients([clientA, clientB], taskName); + } finally { + if (clientA) await closeClient(clientA); + if (clientB) await closeClient(clientB); + } + }); /** * Scenario 2.3: Client A Creates Parent, Client B Creates Subtask @@ -423,76 +385,63 @@ base.describe('@supersync SuperSync E2E', () => { * * Expected: Both clients have parent with subtask */ - base( - '2.3 Client A creates parent, Client B creates subtask', - async ({ browser, baseURL }, testInfo) => { - const testRunId = generateTestRunId(testInfo.workerIndex); - let clientA: SimulatedE2EClient | null = null; - let clientB: SimulatedE2EClient | null = null; + test('2.3 Client A creates parent, Client B creates subtask', async ({ + browser, + baseURL, + testRunId, + }) => { + let clientA: SimulatedE2EClient | null = null; + let clientB: SimulatedE2EClient | null = null; - try { - const user = await createTestUser(testRunId); - const syncConfig = getSuperSyncConfig(user); + try { + const user = await createTestUser(testRunId); + const syncConfig = getSuperSyncConfig(user); - // Set up Client A and create parent task - clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); - await clientA.sync.setupSuperSync(syncConfig); + // Set up Client A and create parent task + clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); + await clientA.sync.setupSuperSync(syncConfig); - const parentTaskName = `Parent-${testRunId}`; - await clientA.workView.addTask(parentTaskName); - await clientA.sync.syncAndWait(); + const parentTaskName = `Parent-${testRunId}`; + await clientA.workView.addTask(parentTaskName); + await clientA.sync.syncAndWait(); - // Set up Client B and sync to get the parent task - clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); - await clientB.sync.setupSuperSync(syncConfig); - await clientB.sync.syncAndWait(); + // Set up Client B and sync to get the parent task + clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); + await clientB.sync.setupSuperSync(syncConfig); + await clientB.sync.syncAndWait(); - // Verify Client B has the parent task - await waitForTask(clientB.page, parentTaskName); + // Verify Client B has the parent task + await waitForTask(clientB.page, parentTaskName); - // Client B creates a subtask under the parent - // Use a name that won't match the parent (no testRunId overlap) - const subtaskName = `ChildOfParent-${Date.now()}`; - const parentTaskB = clientB.page.locator(`task:has-text("${parentTaskName}")`); - await clientB.workView.addSubTask(parentTaskB, subtaskName); + // Client B creates a subtask under the parent + // Use a name that won't match the parent (no testRunId overlap) + const subtaskName = `ChildOfParent-${Date.now()}`; + const parentTaskB = getTaskElement(clientB, parentTaskName); + await clientB.workView.addSubTask(parentTaskB, subtaskName); - // Client B syncs (uploads subtask) - await clientB.sync.syncAndWait(); + // Client B syncs (uploads subtask) + await clientB.sync.syncAndWait(); - // Client A syncs (downloads subtask) - await clientA.sync.syncAndWait(); + // Client A syncs (downloads subtask) + await clientA.sync.syncAndWait(); - // Verify both clients have parent and subtask - // First expand the parent task to see subtasks - const parentTaskA = clientA.page.locator(`task:has-text("${parentTaskName}")`); - const expandBtnA = parentTaskA.locator('.expand-btn'); - if (await expandBtnA.isVisible()) { - await expandBtnA.click(); - } + // Verify both clients have parent and subtask + // First expand the parent task to see subtasks + await expandTask(clientA, parentTaskName); + await expandTask(clientB, parentTaskName); - const expandBtnB = parentTaskB.locator('.expand-btn'); - if (await expandBtnB.isVisible()) { - await expandBtnB.click(); - } + // Wait for subtasks to be visible + await waitForTask(clientA.page, subtaskName); + await waitForTask(clientB.page, subtaskName); - // Wait for subtasks to be visible - await waitForTask(clientA.page, subtaskName); - await waitForTask(clientB.page, subtaskName); - - // Verify subtask exists on both - // Use .hasNoSubTasks to target only the subtask, not its parent (which also "has text" of nested subtask) - await expect( - clientA.page.locator(`task.hasNoSubTasks:has-text("${subtaskName}")`), - ).toBeVisible(); - await expect( - clientB.page.locator(`task.hasNoSubTasks:has-text("${subtaskName}")`), - ).toBeVisible(); - } finally { - if (clientA) await closeClient(clientA); - if (clientB) await closeClient(clientB); - } - }, - ); + // Verify subtask exists on both + await expectSubtaskVisible(clientA, subtaskName); + await expectSubtaskVisible(clientB, subtaskName); + } finally { + if (clientA) await closeClient(clientA); + if (clientB) await closeClient(clientB); + } + }); /** * Scenario 4.1: Complex Chain of Actions @@ -520,197 +469,149 @@ base.describe('@supersync SuperSync E2E', () => { * - "Implementation" (not done) * - "Testing" (not done) */ - base( - '4.1 Complex chain of actions syncs correctly', - async ({ browser, baseURL }, testInfo) => { - const testRunId = generateTestRunId(testInfo.workerIndex); - const uniqueId = Date.now(); - let clientA: SimulatedE2EClient | null = null; - let clientB: SimulatedE2EClient | null = null; + test('4.1 Complex chain of actions syncs correctly', async ({ + browser, + baseURL, + testRunId, + }) => { + const uniqueId = Date.now(); + let clientA: SimulatedE2EClient | null = null; + let clientB: SimulatedE2EClient | null = null; - try { - const user = await createTestUser(testRunId); - const syncConfig = getSuperSyncConfig(user); + try { + const user = await createTestUser(testRunId); + const syncConfig = getSuperSyncConfig(user); - // ============ PHASE 1: Initial Setup ============ - // Set up both clients - clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); - await clientA.sync.setupSuperSync(syncConfig); + // ============ PHASE 1: Initial Setup ============ + // 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); + clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); + await clientB.sync.setupSuperSync(syncConfig); - // ============ PHASE 2: Client A Creates Initial Task ============ - const initialTaskName = `ProjectX-${uniqueId}`; - await clientA.workView.addTask(initialTaskName); - console.log(`[Chain Test] Client A created task: ${initialTaskName}`); + // ============ PHASE 2: Client A Creates Initial Task ============ + const initialTaskName = `ProjectX-${uniqueId}`; + await clientA.workView.addTask(initialTaskName); + console.log(`[Chain Test] Client A created task: ${initialTaskName}`); - // Sync: A → Server - await clientA.sync.syncAndWait(); + // Sync: A → Server + await clientA.sync.syncAndWait(); - // Sync: Server → B - await clientB.sync.syncAndWait(); + // Sync: Server → B + await clientB.sync.syncAndWait(); - // Verify B has the task - await waitForTask(clientB.page, initialTaskName); - console.log('[Chain Test] Client B received initial task'); + // Verify B has the task + await waitForTask(clientB.page, initialTaskName); + console.log('[Chain Test] Client B received initial task'); - // ============ PHASE 3: Client B Renames and Adds Subtask ============ - const renamedTaskName = `ProjectX-Planning-${uniqueId}`; - const taskLocatorB = clientB.page.locator(`task:has-text("${initialTaskName}")`); + // ============ PHASE 3: Client B Renames and Adds Subtask ============ + const renamedTaskName = `ProjectX-Planning-${uniqueId}`; + await renameTask(clientB, initialTaskName, renamedTaskName); + console.log(`[Chain Test] Client B renamed task to: ${renamedTaskName}`); - // Rename the task (click title, edit, blur) - await taskLocatorB.locator('task-title').click(); - await clientB.page.waitForSelector('task textarea', { state: 'visible' }); - await clientB.page.locator('task textarea').fill(renamedTaskName); - await clientB.page.keyboard.press('Tab'); - await clientB.page.waitForTimeout(300); - console.log(`[Chain Test] Client B renamed task to: ${renamedTaskName}`); + // Add first subtask "Research" + const subtask1Name = `Research-${uniqueId}`; + const renamedTaskLocatorB = getTaskElement(clientB, renamedTaskName); + await clientB.workView.addSubTask(renamedTaskLocatorB, subtask1Name); + console.log(`[Chain Test] Client B added subtask: ${subtask1Name}`); - // Add first subtask "Research" - const subtask1Name = `Research-${uniqueId}`; - const renamedTaskLocatorB = clientB.page.locator( - `task:has-text("${renamedTaskName}")`, - ); - await clientB.workView.addSubTask(renamedTaskLocatorB, subtask1Name); - console.log(`[Chain Test] Client B added subtask: ${subtask1Name}`); + // Sync: B → Server + await clientB.sync.syncAndWait(); - // Sync: B → Server - await clientB.sync.syncAndWait(); + // Sync: Server → A + await clientA.sync.syncAndWait(); - // Sync: Server → A - await clientA.sync.syncAndWait(); + // Wait for DOM to settle + await clientA.page.waitForLoadState('domcontentloaded'); - // Wait for DOM to settle - await clientA.page.waitForLoadState('domcontentloaded'); + // Verify A has the renamed task and subtask + await waitForTask(clientA.page, renamedTaskName); + await expandTask(clientA, renamedTaskName); + await waitForTask(clientA.page, subtask1Name); + console.log('[Chain Test] Client A received rename and subtask'); - // Verify A has the renamed task and subtask - await waitForTask(clientA.page, renamedTaskName); - const parentTaskA = clientA.page.locator(`task:has-text("${renamedTaskName}")`); - const expandBtnA = parentTaskA.locator('.expand-btn'); - if (await expandBtnA.isVisible()) { - await expandBtnA.click(); - } - await waitForTask(clientA.page, subtask1Name); - console.log('[Chain Test] Client A received rename and subtask'); + const parentTaskA = getTaskElement(clientA, renamedTaskName); - // ============ PHASE 4: Client A Marks Subtask Done and Adds Another ============ - // Mark Research subtask as done - const subtask1LocatorA = clientA.page.locator( - `task.hasNoSubTasks:has-text("${subtask1Name}")`, - ); - await subtask1LocatorA.hover(); - await subtask1LocatorA.locator('.task-done-btn').click(); - console.log(`[Chain Test] Client A marked ${subtask1Name} as done`); + // ============ PHASE 4: Client A Marks Subtask Done and Adds Another ============ + // Mark Research subtask as done + await markSubtaskDone(clientA, subtask1Name); + console.log(`[Chain Test] Client A marked ${subtask1Name} as done`); - // Add second subtask "Implementation" - const subtask2Name = `Implementation-${uniqueId}`; - await clientA.workView.addSubTask(parentTaskA, subtask2Name); - console.log(`[Chain Test] Client A added subtask: ${subtask2Name}`); + // Add second subtask "Implementation" + const subtask2Name = `Implementation-${uniqueId}`; + await clientA.workView.addSubTask(parentTaskA, subtask2Name); + console.log(`[Chain Test] Client A added subtask: ${subtask2Name}`); - // Sync: A → Server - await clientA.sync.syncAndWait(); + // Sync: A → Server + await clientA.sync.syncAndWait(); - // Sync: Server → B - await clientB.sync.syncAndWait(); + // Sync: Server → B + await clientB.sync.syncAndWait(); - // Wait for DOM to settle - await clientB.page.waitForLoadState('domcontentloaded'); + // Wait for DOM to settle + await clientB.page.waitForLoadState('domcontentloaded'); - // Verify B has the updates - const expandBtnB = renamedTaskLocatorB.locator('.expand-btn'); - if (await expandBtnB.isVisible()) { - await expandBtnB.click(); - } - await waitForTask(clientB.page, subtask2Name); - console.log('[Chain Test] Client B received done status and new subtask'); + // Verify B has the updates + await expandTask(clientB, renamedTaskName); + await waitForTask(clientB.page, subtask2Name); + console.log('[Chain Test] Client B received done status and new subtask'); - // ============ PHASE 5: Client B Adds Subtask and Marks It Done ============ - // Add third subtask "Testing" - const subtask3Name = `Testing-${uniqueId}`; - await clientB.workView.addSubTask(renamedTaskLocatorB, subtask3Name); - console.log(`[Chain Test] Client B added subtask: ${subtask3Name}`); + // ============ PHASE 5: Client B Adds Subtask and Marks It Done ============ + // Add third subtask "Testing" + const subtask3Name = `Testing-${uniqueId}`; + await clientB.workView.addSubTask( + getTaskElement(clientB, renamedTaskName), + subtask3Name, + ); + console.log(`[Chain Test] Client B added subtask: ${subtask3Name}`); - // Mark the Testing subtask as done (not the parent - parent tasks with subtasks - // don't show done button in hover controls) - const subtask3LocatorB = clientB.page.locator( - `task.hasNoSubTasks:has-text("${subtask3Name}")`, - ); - await subtask3LocatorB.hover(); - await subtask3LocatorB.locator('.task-done-btn').click(); - console.log(`[Chain Test] Client B marked ${subtask3Name} as done`); + // Mark the Testing subtask as done + await markSubtaskDone(clientB, subtask3Name); + console.log(`[Chain Test] Client B marked ${subtask3Name} as done`); - // Sync: B → Server - await clientB.sync.syncAndWait(); + // Sync: B → Server + await clientB.sync.syncAndWait(); - // Sync: Server → A - await clientA.sync.syncAndWait(); + // Sync: Server → A + await clientA.sync.syncAndWait(); - // ============ PHASE 6: Final Verification ============ - console.log('[Chain Test] Verifying final state...'); + // ============ PHASE 6: Final Verification ============ + console.log('[Chain Test] Verifying final state...'); - // Both clients should have the parent task (not done - only subtasks were marked done) - const finalParentA = clientA.page.locator( - `task:not(.hasNoSubTasks):has-text("${renamedTaskName}")`, - ); - const finalParentB = clientB.page.locator( - `task:not(.hasNoSubTasks):has-text("${renamedTaskName}")`, - ); - await expect(finalParentA).toBeVisible({ timeout: 10000 }); - await expect(finalParentB).toBeVisible({ timeout: 10000 }); + // Both clients should have the parent task + const finalParentA = getParentTaskElement(clientA, renamedTaskName); + const finalParentB = getParentTaskElement(clientB, renamedTaskName); + await expect(finalParentA).toBeVisible({ timeout: 10000 }); + await expect(finalParentB).toBeVisible({ timeout: 10000 }); - // Expand parents to see subtasks - const finalExpandA = finalParentA.locator('.expand-btn'); - if (await finalExpandA.isVisible()) { - await finalExpandA.click(); - } - const finalExpandB = finalParentB.locator('.expand-btn'); - if (await finalExpandB.isVisible()) { - await finalExpandB.click(); - } + // Expand parents to see subtasks + await expandTask(clientA, renamedTaskName); + await expandTask(clientB, renamedTaskName); - // Verify all three subtasks exist on both clients - // Research (done - marked by Client A in Phase 4) - await expect( - clientA.page.locator(`task.hasNoSubTasks.isDone:has-text("${subtask1Name}")`), - ).toBeVisible({ timeout: 5000 }); - await expect( - clientB.page.locator(`task.hasNoSubTasks.isDone:has-text("${subtask1Name}")`), - ).toBeVisible({ timeout: 5000 }); + // Verify all three subtasks exist on both clients + // Research (done - marked by Client A in Phase 4) + await expectSubtaskDone(clientA, subtask1Name); + await expectSubtaskDone(clientB, subtask1Name); - // Implementation (not done) - await expect( - clientA.page.locator( - `task.hasNoSubTasks:not(.isDone):has-text("${subtask2Name}")`, - ), - ).toBeVisible({ timeout: 5000 }); - await expect( - clientB.page.locator( - `task.hasNoSubTasks:not(.isDone):has-text("${subtask2Name}")`, - ), - ).toBeVisible({ timeout: 5000 }); + // Implementation (not done) + await expectSubtaskNotDone(clientA, subtask2Name); + await expectSubtaskNotDone(clientB, subtask2Name); - // Testing (done - marked by Client B in Phase 5) - await expect( - clientA.page.locator(`task.hasNoSubTasks.isDone:has-text("${subtask3Name}")`), - ).toBeVisible({ timeout: 5000 }); - await expect( - clientB.page.locator(`task.hasNoSubTasks.isDone:has-text("${subtask3Name}")`), - ).toBeVisible({ timeout: 5000 }); + // Testing (done - marked by Client B in Phase 5) + await expectSubtaskDone(clientA, subtask3Name); + await expectSubtaskDone(clientB, subtask3Name); - // Count total tasks should match - const totalTasksA = await clientA.page.locator('task').count(); - const totalTasksB = await clientB.page.locator('task').count(); - expect(totalTasksA).toBe(totalTasksB); - expect(totalTasksA).toBe(4); // 1 parent + 3 subtasks + // Count total tasks should match + await expectEqualTaskCount([clientA, clientB]); + await expectTaskCount(clientA, 4); // 1 parent + 3 subtasks - console.log('[Chain Test] ✓ All verifications passed!'); - } finally { - if (clientA) await closeClient(clientA); - if (clientB) await closeClient(clientB); - } - }, - ); + console.log('[Chain Test] ✓ All verifications passed!'); + } finally { + if (clientA) await closeClient(clientA); + if (clientB) await closeClient(clientB); + } + }); /** * Scenario 5.1: Task Archiving and Worklog Sync @@ -732,156 +633,152 @@ base.describe('@supersync SuperSync E2E', () => { * - Both clients show archived tasks in worklog * - Task titles visible in worklog entries */ - base( - '5.1 Archived tasks appear in worklog on both clients', - async ({ browser, baseURL }, testInfo) => { - const testRunId = generateTestRunId(testInfo.workerIndex); - const uniqueId = Date.now(); - let clientA: SimulatedE2EClient | null = null; - let clientB: SimulatedE2EClient | null = null; + test('5.1 Archived tasks appear in worklog on both clients', async ({ + browser, + baseURL, + testRunId, + }) => { + const uniqueId = Date.now(); + let clientA: SimulatedE2EClient | null = null; + let clientB: SimulatedE2EClient | null = null; - try { - // Create shared test user - const user = await createTestUser(testRunId); - const syncConfig = getSuperSyncConfig(user); + try { + // Create shared test user + const user = await createTestUser(testRunId); + const syncConfig = getSuperSyncConfig(user); - // ============ PHASE 1: Client A Creates and Completes Tasks ============ - clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); - await clientA.sync.setupSuperSync(syncConfig); + // ============ PHASE 1: Client A Creates and Completes Tasks ============ + clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); + await clientA.sync.setupSuperSync(syncConfig); - // Create two tasks - const task1Name = `Archive-Task1-${uniqueId}`; - const task2Name = `Archive-Task2-${uniqueId}`; + // Create two tasks + const task1Name = `Archive-Task1-${uniqueId}`; + const task2Name = `Archive-Task2-${uniqueId}`; - await clientA.workView.addTask(task1Name); - console.log(`[Archive Test] Client A created task: ${task1Name}`); + await clientA.workView.addTask(task1Name); + console.log(`[Archive Test] Client A created task: ${task1Name}`); - await clientA.workView.addTask(task2Name); - console.log(`[Archive Test] Client A created task: ${task2Name}`); + await clientA.workView.addTask(task2Name); + console.log(`[Archive Test] Client A created task: ${task2Name}`); - // Mark both tasks as done - const task1Locator = clientA.page.locator(`task:has-text("${task1Name}")`); - await task1Locator.hover(); - await task1Locator.locator('.task-done-btn').click(); - console.log(`[Archive Test] Client A marked ${task1Name} as done`); + // Mark both tasks as done + await markTaskDone(clientA, task1Name); + console.log(`[Archive Test] Client A marked ${task1Name} as done`); - const task2Locator = clientA.page.locator(`task:has-text("${task2Name}")`); - await task2Locator.hover(); - await task2Locator.locator('.task-done-btn').click(); - console.log(`[Archive Test] Client A marked ${task2Name} as done`); + await markTaskDone(clientA, task2Name); + console.log(`[Archive Test] Client A marked ${task2Name} as done`); - // ============ PHASE 2: Sync Tasks to Server ============ - await clientA.sync.syncAndWait(); - console.log('[Archive Test] Client A synced tasks'); + // ============ PHASE 2: Sync Tasks to Server ============ + await clientA.sync.syncAndWait(); + console.log('[Archive Test] Client A synced tasks'); - // ============ PHASE 3: Client B Downloads Tasks ============ - clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); - await clientB.sync.setupSuperSync(syncConfig); - await clientB.sync.syncAndWait(); - console.log('[Archive Test] Client B synced (downloaded tasks)'); + // ============ PHASE 3: Client B Downloads Tasks ============ + clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); + await clientB.sync.setupSuperSync(syncConfig); + await clientB.sync.syncAndWait(); + console.log('[Archive Test] Client B synced (downloaded tasks)'); - // Verify Client B has the tasks - await waitForTask(clientB.page, task1Name); - await waitForTask(clientB.page, task2Name); - console.log('[Archive Test] Client B received both tasks'); + // Verify Client B has the tasks + await waitForTask(clientB.page, task1Name); + await waitForTask(clientB.page, task2Name); + console.log('[Archive Test] Client B received both tasks'); - // ============ PHASE 4: Client B Archives Tasks via Finish Day ============ - // Click "Finish Day" button to go to daily summary - const finishDayBtn = clientB.page.locator('.e2e-finish-day'); - await finishDayBtn.waitFor({ state: 'visible', timeout: 10000 }); - await finishDayBtn.click(); - console.log('[Archive Test] Client B clicked Finish Day'); + // ============ PHASE 4: Client B Archives Tasks via Finish Day ============ + // Click "Finish Day" button to go to daily summary + const finishDayBtn = clientB.page.locator('.e2e-finish-day'); + await finishDayBtn.waitFor({ state: 'visible', timeout: 10000 }); + await finishDayBtn.click(); + console.log('[Archive Test] Client B clicked Finish Day'); - // Wait for daily summary page - await clientB.page.waitForURL(/daily-summary/, { timeout: 10000 }); - await clientB.page.waitForLoadState('networkidle'); - console.log('[Archive Test] Client B on daily summary page'); + // Wait for daily summary page + await clientB.page.waitForURL(/daily-summary/, { timeout: 10000 }); + await clientB.page.waitForLoadState('networkidle'); + console.log('[Archive Test] Client B on daily summary page'); - // Click the "Save and go home" button to archive tasks - const saveAndGoHomeBtn = clientB.page.locator( - 'daily-summary button[mat-flat-button]:has(mat-icon:has-text("wb_sunny"))', - ); - await saveAndGoHomeBtn.waitFor({ state: 'visible', timeout: 10000 }); - await saveAndGoHomeBtn.click(); - console.log('[Archive Test] Client B clicked Save and go home (archiving)'); + // Click the "Save and go home" button to archive tasks + const saveAndGoHomeBtn = clientB.page.locator( + 'daily-summary button[mat-flat-button]:has(mat-icon:has-text("wb_sunny"))', + ); + await saveAndGoHomeBtn.waitFor({ state: 'visible', timeout: 10000 }); + await saveAndGoHomeBtn.click(); + console.log('[Archive Test] Client B clicked Save and go home (archiving)'); - // Wait for navigation back to work view - await clientB.page.waitForURL(/tag\/TODAY/, { timeout: 10000 }); - await clientB.page.waitForLoadState('networkidle'); - console.log('[Archive Test] Client B back on work view after archiving'); + // Wait for navigation back to work view + await clientB.page.waitForURL(/tag\/TODAY/, { timeout: 10000 }); + await clientB.page.waitForLoadState('networkidle'); + console.log('[Archive Test] Client B back on work view after archiving'); - // ============ PHASE 5: Sync Archive Operation ============ - await clientB.sync.syncAndWait(); - console.log('[Archive Test] Client B synced (uploaded archive)'); + // ============ PHASE 5: Sync Archive Operation ============ + await clientB.sync.syncAndWait(); + console.log('[Archive Test] Client B synced (uploaded archive)'); - // Client A syncs to receive archive - await clientA.sync.syncAndWait(); - console.log('[Archive Test] Client A synced (downloaded archive)'); + // Client A syncs to receive archive + await clientA.sync.syncAndWait(); + console.log('[Archive Test] Client A synced (downloaded archive)'); - // ============ PHASE 6: Verify Worklog on Both Clients ============ - // Navigate Client A to worklog - await clientA.page.goto('/#/tag/TODAY/worklog'); - await clientA.page.waitForLoadState('networkidle'); - await clientA.page.waitForSelector('worklog', { timeout: 10000 }); - console.log('[Archive Test] Client A navigated to worklog'); + // ============ PHASE 6: Verify Worklog on Both Clients ============ + // Navigate Client A to worklog + await clientA.page.goto('/#/tag/TODAY/worklog'); + await clientA.page.waitForLoadState('networkidle'); + await clientA.page.waitForSelector('worklog', { timeout: 10000 }); + console.log('[Archive Test] Client A navigated to worklog'); - // Navigate Client B to worklog - await clientB.page.goto('/#/tag/TODAY/worklog'); - await clientB.page.waitForLoadState('networkidle'); - await clientB.page.waitForSelector('worklog', { timeout: 10000 }); - console.log('[Archive Test] Client B navigated to worklog'); + // Navigate Client B to worklog + await clientB.page.goto('/#/tag/TODAY/worklog'); + await clientB.page.waitForLoadState('networkidle'); + await clientB.page.waitForSelector('worklog', { timeout: 10000 }); + console.log('[Archive Test] Client B navigated to worklog'); - // Expand the current day's worklog to see tasks - // Click on the week row to expand it - const expandWorklogA = async (): Promise => { - const weekRow = clientA.page.locator('.week-row').first(); - if (await weekRow.isVisible()) { - await weekRow.click(); - await clientA.page.waitForTimeout(500); - } - }; + // Expand the current day's worklog to see tasks + // Click on the week row to expand it + const expandWorklogA = async (): Promise => { + const weekRow = clientA.page.locator('.week-row').first(); + if (await weekRow.isVisible()) { + await weekRow.click(); + await clientA.page.waitForTimeout(500); + } + }; - const expandWorklogB = async (): Promise => { - const weekRow = clientB.page.locator('.week-row').first(); - if (await weekRow.isVisible()) { - await weekRow.click(); - await clientB.page.waitForTimeout(500); - } - }; + const expandWorklogB = async (): Promise => { + const weekRow = clientB.page.locator('.week-row').first(); + if (await weekRow.isVisible()) { + await weekRow.click(); + await clientB.page.waitForTimeout(500); + } + }; - await expandWorklogA(); - await expandWorklogB(); + await expandWorklogA(); + await expandWorklogB(); - // Verify tasks appear in worklog on both clients - // Tasks are shown in .task-title within the worklog table - const task1InWorklogA = clientA.page.locator( - `.task-summary-table .task-title:has-text("${task1Name}")`, - ); - const task2InWorklogA = clientA.page.locator( - `.task-summary-table .task-title:has-text("${task2Name}")`, - ); - const task1InWorklogB = clientB.page.locator( - `.task-summary-table .task-title:has-text("${task1Name}")`, - ); - const task2InWorklogB = clientB.page.locator( - `.task-summary-table .task-title:has-text("${task2Name}")`, - ); + // Verify tasks appear in worklog on both clients + // Tasks are shown in .task-title within the worklog table + const task1InWorklogA = clientA.page.locator( + `.task-summary-table .task-title:has-text("${task1Name}")`, + ); + const task2InWorklogA = clientA.page.locator( + `.task-summary-table .task-title:has-text("${task2Name}")`, + ); + const task1InWorklogB = clientB.page.locator( + `.task-summary-table .task-title:has-text("${task1Name}")`, + ); + const task2InWorklogB = clientB.page.locator( + `.task-summary-table .task-title:has-text("${task2Name}")`, + ); - await expect(task1InWorklogA).toBeVisible({ timeout: 10000 }); - await expect(task2InWorklogA).toBeVisible({ timeout: 10000 }); - console.log('[Archive Test] Client A worklog has both tasks'); + await expect(task1InWorklogA).toBeVisible({ timeout: 10000 }); + await expect(task2InWorklogA).toBeVisible({ timeout: 10000 }); + console.log('[Archive Test] Client A worklog has both tasks'); - await expect(task1InWorklogB).toBeVisible({ timeout: 10000 }); - await expect(task2InWorklogB).toBeVisible({ timeout: 10000 }); - console.log('[Archive Test] Client B worklog has both tasks'); + await expect(task1InWorklogB).toBeVisible({ timeout: 10000 }); + await expect(task2InWorklogB).toBeVisible({ timeout: 10000 }); + console.log('[Archive Test] Client B worklog has both tasks'); - console.log('[Archive Test] ✓ All verifications passed!'); - } finally { - if (clientA) await closeClient(clientA); - if (clientB) await closeClient(clientB); - } - }, - ); + console.log('[Archive Test] ✓ All verifications passed!'); + } finally { + if (clientA) await closeClient(clientA); + if (clientB) await closeClient(clientB); + } + }); /** * Scenario 6.1: Time Tracking Sync @@ -903,119 +800,108 @@ base.describe('@supersync SuperSync E2E', () => { * - Client B shows timeSpent > 0 for the task * - Both clients have matching time values */ - base( - '6.1 Time tracking syncs between clients', - async ({ browser, baseURL }, testInfo) => { - const testRunId = generateTestRunId(testInfo.workerIndex); - const uniqueId = Date.now(); - let clientA: SimulatedE2EClient | null = null; - let clientB: SimulatedE2EClient | null = null; + test('6.1 Time tracking syncs between clients', async ({ + browser, + baseURL, + testRunId, + }) => { + const uniqueId = Date.now(); + let clientA: SimulatedE2EClient | null = null; + let clientB: SimulatedE2EClient | null = null; + try { + // Create shared test user + const user = await createTestUser(testRunId); + const syncConfig = getSuperSyncConfig(user); + + // ============ PHASE 1: Client A Creates Task ============ + clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); + await clientA.sync.setupSuperSync(syncConfig); + + const taskName = `TimeTrack-${uniqueId}`; + await clientA.workView.addTask(taskName); + console.log(`[TimeTrack Test] Client A created task: ${taskName}`); + + // ============ PHASE 2: Start Time Tracking ============ + await startTimeTracking(clientA, taskName); + console.log('[TimeTrack Test] Client A started time tracking'); + + // Verify tracking started + const taskLocatorA = getTaskElement(clientA, taskName); + const playIndicator = taskLocatorA.locator('.play-icon-indicator'); + await expect(playIndicator).toBeVisible({ timeout: 5000 }); + console.log('[TimeTrack Test] Time tracking active'); + + // ============ PHASE 3: Accumulate Time ============ + // Wait for time to accumulate (5 seconds to get visible time display) + console.log('[TimeTrack Test] Waiting 5 seconds for time to accumulate...'); + await clientA.page.waitForTimeout(5000); + + // ============ PHASE 4: Stop Time Tracking ============ + await stopTimeTracking(clientA, taskName); + console.log('[TimeTrack Test] Client A stopped time tracking'); + + // Wait for tracking to stop + await expect(playIndicator).not.toBeVisible({ timeout: 5000 }); + + // Verify time was recorded on Client A + // Time is displayed in .time-wrapper .time-val + const timeValA = taskLocatorA.locator('.time-wrapper .time-val').first(); + await expect(timeValA).toBeVisible({ timeout: 5000 }); + const timeTextA = await timeValA.textContent(); + console.log(`[TimeTrack Test] Client A recorded time: ${timeTextA}`); + + // ============ PHASE 5: Sync to Server ============ + await clientA.sync.syncAndWait(); + console.log('[TimeTrack Test] Client A synced'); + + // ============ PHASE 6: Client B Downloads ============ + clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); + await clientB.sync.setupSuperSync(syncConfig); + // Add delay to ensure any auto-sync from setup has time to start/finish + // or to avoid race conditions with "Sync already in progress" + await clientB.page.waitForTimeout(2000); + await clientB.sync.syncAndWait(); + console.log('[TimeTrack Test] Client B synced'); + + // Reload to ensure UI reflects DB state (in case of sync UI glitch) + await clientB.page.reload(); + await waitForAppReady(clientB.page); + + // ============ PHASE 7: Verify Time on Client B ============ + // Wait for task to appear try { - // Create shared test user - const user = await createTestUser(testRunId); - const syncConfig = getSuperSyncConfig(user); - - // ============ PHASE 1: Client A Creates Task ============ - clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); - await clientA.sync.setupSuperSync(syncConfig); - - const taskName = `TimeTrack-${uniqueId}`; - await clientA.workView.addTask(taskName); - console.log(`[TimeTrack Test] Client A created task: ${taskName}`); - - // ============ PHASE 2: Start Time Tracking ============ - const taskLocatorA = clientA.page.locator(`task:has-text("${taskName}")`); - await taskLocatorA.hover(); - - // Click the play button to start tracking - const startBtn = taskLocatorA.locator('.start-task-btn'); - await startBtn.waitFor({ state: 'visible', timeout: 5000 }); - await startBtn.click(); - console.log('[TimeTrack Test] Client A started time tracking'); - - // Verify tracking started - play icon indicator should appear - const playIndicator = taskLocatorA.locator('.play-icon-indicator'); - await expect(playIndicator).toBeVisible({ timeout: 5000 }); - console.log('[TimeTrack Test] Time tracking active'); - - // ============ PHASE 3: Accumulate Time ============ - // Wait for time to accumulate (5 seconds to get visible time display) - console.log('[TimeTrack Test] Waiting 5 seconds for time to accumulate...'); - await clientA.page.waitForTimeout(5000); - - // ============ PHASE 4: Stop Time Tracking ============ - await taskLocatorA.hover(); - - // Click the pause button to stop tracking - // The pause button appears when task is current (being tracked) - const pauseBtn = taskLocatorA.locator('button:has(mat-icon:has-text("pause"))'); - await pauseBtn.waitFor({ state: 'visible', timeout: 5000 }); - await pauseBtn.click(); - console.log('[TimeTrack Test] Client A stopped time tracking'); - - // Wait for tracking to stop - await expect(playIndicator).not.toBeVisible({ timeout: 5000 }); - - // Verify time was recorded on Client A - // Time is displayed in .time-wrapper .time-val - const timeValA = taskLocatorA.locator('.time-wrapper .time-val').first(); - await expect(timeValA).toBeVisible({ timeout: 5000 }); - const timeTextA = await timeValA.textContent(); - console.log(`[TimeTrack Test] Client A recorded time: ${timeTextA}`); - - // ============ PHASE 5: Sync to Server ============ - await clientA.sync.syncAndWait(); - console.log('[TimeTrack Test] Client A synced'); - - // ============ PHASE 6: Client B Downloads ============ - clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); - await clientB.sync.setupSuperSync(syncConfig); - // Add delay to ensure any auto-sync from setup has time to start/finish - // or to avoid race conditions with "Sync already in progress" - await clientB.page.waitForTimeout(2000); - await clientB.sync.syncAndWait(); - console.log('[TimeTrack Test] Client B synced'); - - // Reload to ensure UI reflects DB state (in case of sync UI glitch) - await clientB.page.reload(); - await waitForAppReady(clientB.page); - - // ============ PHASE 7: Verify Time on Client B ============ - // Wait for task to appear - try { - await waitForTask(clientB.page, taskName); - } catch (e) { - const tasks = await clientB.page.locator('task .task-title').allTextContents(); - console.log('[TimeTrack Test] Client B tasks found:', tasks); - throw e; - } - - const taskLocatorB = clientB.page.locator(`task:has-text("${taskName}")`); - await expect(taskLocatorB).toBeVisible({ timeout: 10000 }); - - // Verify time is displayed on Client B - const timeValB = taskLocatorB.locator('.time-wrapper .time-val').first(); - await expect(timeValB).toBeVisible({ timeout: 10000 }); - const timeTextB = await timeValB.textContent(); - console.log(`[TimeTrack Test] Client B shows time: ${timeTextB}`); - - // Verify time is non-zero (should show something like "0h 0m 3s" or similar) - // The time text should not be empty and should not be "0s" or equivalent - expect(timeTextB).toBeTruthy(); - expect(timeTextB?.trim()).not.toBe(''); - - // Both clients should show the same time - expect(timeTextB?.trim()).toBe(timeTextA?.trim()); - console.log('[TimeTrack Test] Time values match on both clients'); - - console.log('[TimeTrack Test] ✓ All verifications passed!'); - } finally { - if (clientA) await closeClient(clientA); - if (clientB) await closeClient(clientB); + await waitForTask(clientB.page, taskName); + } catch (e) { + const tasks = await clientB.page.locator('task .task-title').allTextContents(); + console.log('[TimeTrack Test] Client B tasks found:', tasks); + throw e; } - }, - ); + + await expectTaskVisible(clientB, taskName); + const taskLocatorB = getTaskElement(clientB, taskName); + + // Verify time is displayed on Client B + const timeValB = taskLocatorB.locator('.time-wrapper .time-val').first(); + await expect(timeValB).toBeVisible({ timeout: 10000 }); + const timeTextB = await timeValB.textContent(); + console.log(`[TimeTrack Test] Client B shows time: ${timeTextB}`); + + // Verify time is non-zero (should show something like "0h 0m 3s" or similar) + // The time text should not be empty and should not be "0s" or equivalent + expect(timeTextB).toBeTruthy(); + expect(timeTextB?.trim()).not.toBe(''); + + // Both clients should show the same time + expect(timeTextB?.trim()).toBe(timeTextA?.trim()); + console.log('[TimeTrack Test] Time values match on both clients'); + + console.log('[TimeTrack Test] ✓ All verifications passed!'); + } finally { + if (clientA) await closeClient(clientA); + if (clientB) await closeClient(clientB); + } + }); /** * Scenario 7.1: Three-Client Eventual Consistency @@ -1041,186 +927,159 @@ base.describe('@supersync SuperSync E2E', () => { * - No data loss from concurrent operations * - System achieves eventual consistency */ - base( - '7.1 Three clients achieve eventual consistency', - async ({ browser, baseURL }, testInfo) => { - const testRunId = generateTestRunId(testInfo.workerIndex); - const uniqueId = Date.now(); - let clientA: SimulatedE2EClient | null = null; - let clientB: SimulatedE2EClient | null = null; - let clientC: SimulatedE2EClient | null = null; + test('7.1 Three clients achieve eventual consistency', async ({ + browser, + baseURL, + testRunId, + }) => { + const uniqueId = Date.now(); + let clientA: SimulatedE2EClient | null = null; + let clientB: SimulatedE2EClient | null = null; + let clientC: SimulatedE2EClient | null = null; - try { - // Create shared test user - const user = await createTestUser(testRunId); - const syncConfig = getSuperSyncConfig(user); + try { + // Create shared test user + const user = await createTestUser(testRunId); + const syncConfig = getSuperSyncConfig(user); - // Task names - const task1Name = `Task1-${uniqueId}`; - const task1Renamed = `Task1-Renamed-${uniqueId}`; - const task2Name = `Task2-${uniqueId}`; - const task3Name = `Task3-${uniqueId}`; + // Task names + const task1Name = `Task1-${uniqueId}`; + const task1Renamed = `Task1-Renamed-${uniqueId}`; + const task2Name = `Task2-${uniqueId}`; + const task3Name = `Task3-${uniqueId}`; - // ============ PHASE 1: Client A Creates Task-1 ============ - console.log('[3-Client Test] Phase 1: Client A creates Task-1'); - clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); - await clientA.sync.setupSuperSync(syncConfig); + // ============ PHASE 1: Client A Creates Task-1 ============ + console.log('[3-Client Test] Phase 1: Client A creates Task-1'); + clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); + await clientA.sync.setupSuperSync(syncConfig); - await clientA.workView.addTask(task1Name); - console.log(`[3-Client Test] Client A created: ${task1Name}`); + await clientA.workView.addTask(task1Name); + console.log(`[3-Client Test] Client A created: ${task1Name}`); - await clientA.sync.syncAndWait(); - console.log('[3-Client Test] Client A synced'); + await clientA.sync.syncAndWait(); + console.log('[3-Client Test] Client A synced'); - // ============ PHASE 2: Client B Gets Task-1, Creates Task-2 ============ - console.log('[3-Client Test] Phase 2: Client B syncs and creates Task-2'); - clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); - await clientB.sync.setupSuperSync(syncConfig); - await clientB.sync.syncAndWait(); + // ============ PHASE 2: Client B Gets Task-1, Creates Task-2 ============ + console.log('[3-Client Test] Phase 2: Client B syncs and creates Task-2'); + clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); + await clientB.sync.setupSuperSync(syncConfig); + await clientB.sync.syncAndWait(); - // Verify B has Task-1 - await waitForTask(clientB.page, task1Name); - console.log('[3-Client Test] Client B received Task-1'); + // Verify B has Task-1 + await waitForTask(clientB.page, task1Name); + console.log('[3-Client Test] Client B received Task-1'); - // B creates Task-2 - await clientB.workView.addTask(task2Name); - console.log(`[3-Client Test] Client B created: ${task2Name}`); + // B creates Task-2 + await clientB.workView.addTask(task2Name); + console.log(`[3-Client Test] Client B created: ${task2Name}`); - // ============ PHASE 3: Client C Gets Task-1, Creates Task-3, Renames Task-1 ============ - console.log( - '[3-Client Test] Phase 3: Client C syncs, creates Task-3, renames Task-1', - ); - clientC = await createSimulatedClient(browser, baseURL!, 'C', testRunId); - await clientC.sync.setupSuperSync(syncConfig); - await clientC.sync.syncAndWait(); + // ============ PHASE 3: Client C Gets Task-1, Creates Task-3, Renames Task-1 ============ + console.log( + '[3-Client Test] Phase 3: Client C syncs, creates Task-3, renames Task-1', + ); + clientC = await createSimulatedClient(browser, baseURL!, 'C', testRunId); + await clientC.sync.setupSuperSync(syncConfig); + await clientC.sync.syncAndWait(); - // Verify C has Task-1 (but NOT Task-2 yet - B hasn't synced) - await waitForTask(clientC.page, task1Name); - console.log('[3-Client Test] Client C received Task-1'); + // Verify C has Task-1 (but NOT Task-2 yet - B hasn't synced) + await waitForTask(clientC.page, task1Name); + console.log('[3-Client Test] Client C received Task-1'); - // C creates Task-3 - await clientC.workView.addTask(task3Name); - console.log(`[3-Client Test] Client C created: ${task3Name}`); + // C creates Task-3 + await clientC.workView.addTask(task3Name); + console.log(`[3-Client Test] Client C created: ${task3Name}`); - // C renames Task-1 - const task1LocatorC = clientC.page.locator(`task:has-text("${task1Name}")`); - await task1LocatorC.dblclick(); // Double-click to edit - const editInput = clientC.page.locator( - 'input.mat-mdc-input-element:focus, textarea:focus', - ); - await editInput.waitFor({ state: 'visible', timeout: 5000 }); - await editInput.fill(task1Renamed); - await clientC.page.keyboard.press('Enter'); - await clientC.page.waitForTimeout(500); - console.log(`[3-Client Test] Client C renamed Task-1 to: ${task1Renamed}`); + // C renames Task-1 + await renameTask(clientC, task1Name, task1Renamed); + console.log(`[3-Client Test] Client C renamed Task-1 to: ${task1Renamed}`); - // ============ PHASE 4: Client B Syncs (Uploads Task-2) ============ - console.log('[3-Client Test] Phase 4: Client B syncs (uploads Task-2)'); - await clientB.sync.syncAndWait(); - console.log('[3-Client Test] Client B synced'); + // ============ PHASE 4: Client B Syncs (Uploads Task-2) ============ + console.log('[3-Client Test] Phase 4: Client B syncs (uploads Task-2)'); + await clientB.sync.syncAndWait(); + console.log('[3-Client Test] Client B synced'); - // ============ PHASE 5: Client C Syncs (Uploads Task-3 + Rename, Downloads Task-2) ============ - console.log('[3-Client Test] Phase 5: Client C syncs'); - await clientC.sync.syncAndWait(); + // ============ PHASE 5: Client C Syncs (Uploads Task-3 + Rename, Downloads Task-2) ============ + console.log('[3-Client Test] Phase 5: Client C syncs'); + await clientC.sync.syncAndWait(); - // Wait for DOM to settle after sync - await clientC.page.waitForLoadState('domcontentloaded'); + // Wait for DOM to settle after sync + await clientC.page.waitForLoadState('domcontentloaded'); - // Verify C now has Task-2 - await waitForTask(clientC.page, task2Name); - console.log('[3-Client Test] Client C received Task-2'); + // Verify C now has Task-2 + await waitForTask(clientC.page, task2Name); + console.log('[3-Client Test] Client C received Task-2'); - // ============ PHASE 6: Client A Syncs (Downloads Task-2, Task-3, Rename) ============ - console.log('[3-Client Test] Phase 6: Client A syncs'); - await clientA.sync.syncAndWait(); + // ============ PHASE 6: Client A Syncs (Downloads Task-2, Task-3, Rename) ============ + console.log('[3-Client Test] Phase 6: Client A syncs'); + await clientA.sync.syncAndWait(); - // Wait for DOM to settle after sync - await clientA.page.waitForLoadState('domcontentloaded'); + // Wait for DOM to settle after sync + await clientA.page.waitForLoadState('domcontentloaded'); - // Verify A has all tasks with correct state - await waitForTask(clientA.page, task1Renamed); - await waitForTask(clientA.page, task2Name); - await waitForTask(clientA.page, task3Name); - console.log('[3-Client Test] Client A received all updates'); + // Verify A has all tasks with correct state + await waitForTask(clientA.page, task1Renamed); + await waitForTask(clientA.page, task2Name); + await waitForTask(clientA.page, task3Name); + console.log('[3-Client Test] Client A received all updates'); - // ============ PHASE 7: Client B Syncs (Downloads Task-3, Rename) ============ - console.log('[3-Client Test] Phase 7: Client B syncs'); - await clientB.sync.syncAndWait(); + // ============ PHASE 7: Client B Syncs (Downloads Task-3, Rename) ============ + console.log('[3-Client Test] Phase 7: Client B syncs'); + await clientB.sync.syncAndWait(); - // Wait for DOM to settle after sync - await clientB.page.waitForLoadState('domcontentloaded'); - await clientB.page.waitForTimeout(500); + // Wait for DOM to settle after sync + await clientB.page.waitForLoadState('domcontentloaded'); + await clientB.page.waitForTimeout(500); - // Verify B has all tasks with correct state - await waitForTask(clientB.page, task1Renamed); - await waitForTask(clientB.page, task3Name); - console.log('[3-Client Test] Client B received all updates'); + // Verify B has all tasks with correct state + await waitForTask(clientB.page, task1Renamed); + await waitForTask(clientB.page, task3Name); + console.log('[3-Client Test] Client B received all updates'); - // ============ PHASE 8: Final Verification - All Clients Identical ============ - console.log('[3-Client Test] Phase 8: Verifying eventual consistency'); + // ============ PHASE 8: Final Verification - All Clients Identical ============ + console.log('[3-Client Test] Phase 8: Verifying eventual consistency'); - // Helper to count tasks on a client - const getTaskCount = async (client: SimulatedE2EClient): Promise => { - return client.page.locator('task').count(); - }; + const countA = await getTaskCount(clientA); + const countB = await getTaskCount(clientB); + const countC = await getTaskCount(clientC); - // Helper to check if a task with given text exists - const hasTaskWithText = async ( - client: SimulatedE2EClient, - text: string, - ): Promise => { - const count = await client.page.locator(`task:has-text("${text}")`).count(); - return count > 0; - }; + console.log(`[3-Client Test] Client A task count: ${countA}`); + console.log(`[3-Client Test] Client B task count: ${countB}`); + console.log(`[3-Client Test] Client C task count: ${countC}`); - const countA = await getTaskCount(clientA); - const countB = await getTaskCount(clientB); - const countC = await getTaskCount(clientC); + // All clients should have exactly 3 tasks + await expectTaskCount(clientA, 3); + await expectTaskCount(clientB, 3); + await expectTaskCount(clientC, 3); - console.log(`[3-Client Test] Client A task count: ${countA}`); - console.log(`[3-Client Test] Client B task count: ${countB}`); - console.log(`[3-Client Test] Client C task count: ${countC}`); + // Verify renamed Task-1 exists on all clients + expect(await hasTaskOnClient(clientA, task1Renamed)).toBe(true); + expect(await hasTaskOnClient(clientB, task1Renamed)).toBe(true); + expect(await hasTaskOnClient(clientC, task1Renamed)).toBe(true); + console.log('[3-Client Test] Task-1 (renamed) exists on all clients'); - // All clients should have exactly 3 tasks - expect(countA).toBe(3); - expect(countB).toBe(3); - expect(countC).toBe(3); + // Verify Task-2 exists on all clients + expect(await hasTaskOnClient(clientA, task2Name)).toBe(true); + expect(await hasTaskOnClient(clientB, task2Name)).toBe(true); + expect(await hasTaskOnClient(clientC, task2Name)).toBe(true); + console.log('[3-Client Test] Task-2 exists on all clients'); - // Verify renamed Task-1 exists on all clients (using partial match for the unique part) - // Note: Task names may have client prefixes, so we match on the unique identifier - const task1UniqueId = `Task1-Renamed-${uniqueId}`; - expect(await hasTaskWithText(clientA, task1UniqueId)).toBe(true); - expect(await hasTaskWithText(clientB, task1UniqueId)).toBe(true); - expect(await hasTaskWithText(clientC, task1UniqueId)).toBe(true); - console.log('[3-Client Test] Task-1 (renamed) exists on all clients'); + // Verify Task-3 exists on all clients + expect(await hasTaskOnClient(clientA, task3Name)).toBe(true); + expect(await hasTaskOnClient(clientB, task3Name)).toBe(true); + expect(await hasTaskOnClient(clientC, task3Name)).toBe(true); + console.log('[3-Client Test] Task-3 exists on all clients'); - // Verify Task-2 exists on all clients - const task2UniqueId = `Task2-${uniqueId}`; - expect(await hasTaskWithText(clientA, task2UniqueId)).toBe(true); - expect(await hasTaskWithText(clientB, task2UniqueId)).toBe(true); - expect(await hasTaskWithText(clientC, task2UniqueId)).toBe(true); - console.log('[3-Client Test] Task-2 exists on all clients'); + // Verify original Task-1 name no longer exists (it was renamed) + // Skip checking on C since C did the rename and might still have the old element in DOM + expect(await hasTaskOnClient(clientA, task1Name)).toBe(false); + expect(await hasTaskOnClient(clientB, task1Name)).toBe(false); - // Verify Task-3 exists on all clients - const task3UniqueId = `Task3-${uniqueId}`; - expect(await hasTaskWithText(clientA, task3UniqueId)).toBe(true); - expect(await hasTaskWithText(clientB, task3UniqueId)).toBe(true); - expect(await hasTaskWithText(clientC, task3UniqueId)).toBe(true); - console.log('[3-Client Test] Task-3 exists on all clients'); - - // Verify original Task-1 name no longer exists (it was renamed) - const task1OriginalId = `Task1-${uniqueId}`; - // Skip checking on C since C did the rename and might still have the old element in DOM - expect(await hasTaskWithText(clientA, task1OriginalId)).toBe(false); - expect(await hasTaskWithText(clientB, task1OriginalId)).toBe(false); - - console.log('[3-Client Test] ✓ All three clients have identical state!'); - console.log('[3-Client Test] ✓ Eventual consistency achieved!'); - } finally { - if (clientA) await closeClient(clientA); - if (clientB) await closeClient(clientB); - if (clientC) await closeClient(clientC); - } - }, - ); + console.log('[3-Client Test] ✓ All three clients have identical state!'); + console.log('[3-Client Test] ✓ Eventual consistency achieved!'); + } finally { + if (clientA) await closeClient(clientA); + if (clientB) await closeClient(clientB); + if (clientC) await closeClient(clientC); + } + }); }); diff --git a/e2e/tests/task-detail/task-detail.spec.ts b/e2e/tests/task-detail/task-detail.spec.ts index 95070fa67..c1cea447a 100644 --- a/e2e/tests/task-detail/task-detail.spec.ts +++ b/e2e/tests/task-detail/task-detail.spec.ts @@ -42,6 +42,8 @@ test.describe('Task detail', () => { // Flipping the meridiem should guarantee a change timeInputText = timeInputText!.replace(/([AP])/, (_, c) => (c === 'A' ? 'P' : 'A')); await timeInput.fill(timeInputText); + // Blur to ensure Angular registers the change before saving + await timeInput.blur(); await page.getByRole('button', { name: 'Save' }).click(); await expect(createdInfo).not.toHaveText(createdInfoText!); @@ -75,6 +77,8 @@ test.describe('Task detail', () => { // Flipping the meridiem should guarantee a change timeInputText = timeInputText!.replace(/([AP])/, (_, c) => (c === 'A' ? 'P' : 'A')); await timeInput.fill(timeInputText); + // Blur to ensure Angular registers the change before saving + await timeInput.blur(); await page.getByRole('button', { name: 'Save' }).click(); await expect(completedInfo).not.toHaveText(completedInfoText!); diff --git a/e2e/utils/supersync-assertions.ts b/e2e/utils/supersync-assertions.ts new file mode 100644 index 000000000..196216212 --- /dev/null +++ b/e2e/utils/supersync-assertions.ts @@ -0,0 +1,303 @@ +import { expect } from '@playwright/test'; +import { + getTaskElement, + getDoneTaskElement, + getSubtaskElement, + getDoneSubtaskElement, + getUndoneSubtaskElement, + getTaskCount, + getTaskTitles, + hasTaskOnClient, + type SimulatedE2EClient, +} from './supersync-helpers'; + +/** + * Assertion helpers for SuperSync E2E tests. + * + * These provide reusable, readable assertions for common sync test patterns. + */ + +// ============================================================================ +// Task Visibility Assertions +// ============================================================================ + +/** + * Assert a task is visible on a client. + * + * @param client - The simulated E2E client + * @param taskName - The task name to check + * @param timeout - Optional timeout in ms (default: 10000) + */ +export const expectTaskVisible = async ( + client: SimulatedE2EClient, + taskName: string, + timeout = 10000, +): Promise => { + const task = getTaskElement(client, taskName); + await expect(task).toBeVisible({ timeout }); +}; + +/** + * Assert a task is NOT visible on a client. + * + * @param client - The simulated E2E client + * @param taskName - The task name to check + * @param timeout - Optional timeout in ms (default: 10000) + */ +export const expectTaskNotVisible = async ( + client: SimulatedE2EClient, + taskName: string, + timeout = 10000, +): Promise => { + const task = getTaskElement(client, taskName); + await expect(task).not.toBeVisible({ timeout }); +}; + +/** + * Assert a task is done (visible with done state). + * + * @param client - The simulated E2E client + * @param taskName - The task name to check + * @param timeout - Optional timeout in ms (default: 10000) + */ +export const expectTaskDone = async ( + client: SimulatedE2EClient, + taskName: string, + timeout = 10000, +): Promise => { + const task = getDoneTaskElement(client, taskName); + await expect(task).toBeVisible({ timeout }); +}; + +/** + * Assert a subtask is visible. + * + * @param client - The simulated E2E client + * @param taskName - The subtask name to check + * @param timeout - Optional timeout in ms (default: 10000) + */ +export const expectSubtaskVisible = async ( + client: SimulatedE2EClient, + taskName: string, + timeout = 10000, +): Promise => { + const task = getSubtaskElement(client, taskName); + await expect(task).toBeVisible({ timeout }); +}; + +/** + * Assert a subtask is done. + * + * @param client - The simulated E2E client + * @param taskName - The subtask name to check + * @param timeout - Optional timeout in ms (default: 5000) + */ +export const expectSubtaskDone = async ( + client: SimulatedE2EClient, + taskName: string, + timeout = 5000, +): Promise => { + const task = getDoneSubtaskElement(client, taskName); + await expect(task).toBeVisible({ timeout }); +}; + +/** + * Assert a subtask is NOT done. + * + * @param client - The simulated E2E client + * @param taskName - The subtask name to check + * @param timeout - Optional timeout in ms (default: 5000) + */ +export const expectSubtaskNotDone = async ( + client: SimulatedE2EClient, + taskName: string, + timeout = 5000, +): Promise => { + const task = getUndoneSubtaskElement(client, taskName); + await expect(task).toBeVisible({ timeout }); +}; + +// ============================================================================ +// Multi-Client Assertions +// ============================================================================ + +/** + * Assert a task exists on all provided clients. + * + * @param clients - Array of simulated E2E clients + * @param taskName - The task name to check + * @param timeout - Optional timeout in ms (default: 10000) + */ +export const expectTaskOnAllClients = async ( + clients: SimulatedE2EClient[], + taskName: string, + timeout = 10000, +): Promise => { + await Promise.all( + clients.map((client) => expectTaskVisible(client, taskName, timeout)), + ); +}; + +/** + * Assert a task does NOT exist on any of the provided clients. + * + * @param clients - Array of simulated E2E clients + * @param taskName - The task name to check + * @param timeout - Optional timeout in ms (default: 10000) + */ +export const expectTaskNotOnAnyClient = async ( + clients: SimulatedE2EClient[], + taskName: string, + timeout = 10000, +): Promise => { + await Promise.all( + clients.map((client) => expectTaskNotVisible(client, taskName, timeout)), + ); +}; + +/** + * Assert a task is done on all provided clients. + * + * @param clients - Array of simulated E2E clients + * @param taskName - The task name to check + * @param timeout - Optional timeout in ms (default: 10000) + */ +export const expectTaskDoneOnAllClients = async ( + clients: SimulatedE2EClient[], + taskName: string, + timeout = 10000, +): Promise => { + await Promise.all(clients.map((client) => expectTaskDone(client, taskName, timeout))); +}; + +/** + * Assert task count is equal across all clients. + * + * @param clients - Array of simulated E2E clients + */ +export const expectEqualTaskCount = async ( + clients: SimulatedE2EClient[], +): Promise => { + const counts = await Promise.all(clients.map((client) => getTaskCount(client))); + const firstCount = counts[0]; + for (let i = 1; i < counts.length; i++) { + expect(counts[i]).toBe(firstCount); + } +}; + +/** + * Assert task count is a specific value on a client. + * + * @param client - The simulated E2E client + * @param expectedCount - The expected task count + */ +export const expectTaskCount = ( + client: SimulatedE2EClient, + expectedCount: number, +): Promise => { + return getTaskCount(client).then((count) => { + expect(count).toBe(expectedCount); + }); +}; + +/** + * Assert task order matches across clients. + * + * @param clientA - First client + * @param clientB - Second client + */ +export const expectTaskOrderMatches = async ( + clientA: SimulatedE2EClient, + clientB: SimulatedE2EClient, +): Promise => { + const orderA = await getTaskTitles(clientA); + const orderB = await getTaskTitles(clientB); + expect(orderA).toEqual(orderB); +}; + +/** + * Assert all clients have the same task order. + * + * @param clients - Array of simulated E2E clients + */ +export const expectSameTaskOrder = async ( + clients: SimulatedE2EClient[], +): Promise => { + if (clients.length < 2) return; + + const orders = await Promise.all(clients.map((client) => getTaskTitles(client))); + const firstOrder = orders[0]; + for (let i = 1; i < orders.length; i++) { + expect(orders[i]).toEqual(firstOrder); + } +}; + +/** + * Assert a task exists on a client (boolean check). + * + * @param client - The simulated E2E client + * @param taskName - The task name to check + * @param exists - Whether the task should exist (default: true) + */ +export const expectTaskExists = async ( + client: SimulatedE2EClient, + taskName: string, + exists = true, +): Promise => { + const hasIt = await hasTaskOnClient(client, taskName); + expect(hasIt).toBe(exists); +}; + +// ============================================================================ +// Consistency Assertions +// ============================================================================ + +/** + * Assert all clients have consistent state (same task count and tasks visible). + * + * @param clients - Array of simulated E2E clients + * @param taskNames - Array of task names that should exist on all clients + */ +export const expectConsistentState = async ( + clients: SimulatedE2EClient[], + taskNames: string[], +): Promise => { + await expectEqualTaskCount(clients); + for (const taskName of taskNames) { + await expectTaskOnAllClients(clients, taskName); + } +}; + +/** + * Assert time tracking indicator is visible on a task. + * + * @param client - The simulated E2E client + * @param taskName - The task name + * @param timeout - Optional timeout in ms (default: 5000) + */ +export const expectTimeTrackingActive = async ( + client: SimulatedE2EClient, + taskName: string, + timeout = 5000, +): Promise => { + const task = getTaskElement(client, taskName); + const indicator = task.locator('.play-icon-indicator'); + await expect(indicator).toBeVisible({ timeout }); +}; + +/** + * Assert time tracking indicator is NOT visible on a task. + * + * @param client - The simulated E2E client + * @param taskName - The task name + * @param timeout - Optional timeout in ms (default: 5000) + */ +export const expectTimeTrackingInactive = async ( + client: SimulatedE2EClient, + taskName: string, + timeout = 5000, +): Promise => { + const task = getTaskElement(client, taskName); + const indicator = task.locator('.play-icon-indicator'); + await expect(indicator).not.toBeVisible({ timeout }); +}; diff --git a/e2e/utils/supersync-helpers.ts b/e2e/utils/supersync-helpers.ts index 0b3f1e91c..7596d2dcf 100644 --- a/e2e/utils/supersync-helpers.ts +++ b/e2e/utils/supersync-helpers.ts @@ -1,4 +1,9 @@ -import { type Browser, type BrowserContext, type Page } from '@playwright/test'; +import { + type Browser, + type BrowserContext, + type Locator, + type Page, +} from '@playwright/test'; import { SuperSyncPage, type SuperSyncConfig } from '../pages/supersync.page'; import { WorkViewPage } from '../pages/work-view.page'; import { waitForAppReady } from './waits'; @@ -311,3 +316,326 @@ export const hasTask = async (page: Page, taskName: string): Promise => const count = await page.locator(`task:has-text("${escapedName}")`).count(); return count > 0; }; + +// ============================================================================ +// Task Element Helpers +// ============================================================================ + +/** + * Escape special characters in a string for use in CSS :has-text() selector. + */ +const escapeForSelector = (text: string): string => { + return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +}; + +/** + * Get a task element locator by task name. + * This replaces the common pattern: `client.page.locator(\`task:has-text("${taskName}")\`)` + * + * @param client - The simulated E2E client + * @param taskName - The task name to search for + * @returns Locator for the task element + */ +export const getTaskElement = (client: SimulatedE2EClient, taskName: string): Locator => { + const escapedName = escapeForSelector(taskName); + return client.page.locator(`task:has-text("${escapedName}")`); +}; + +/** + * Get a task element locator from a page by task name. + * Use this when you have a page but not a client. + * + * @param page - The Playwright page + * @param taskName - The task name to search for + * @returns Locator for the task element + */ +export const getTaskElementFromPage = (page: Page, taskName: string): Locator => { + const escapedName = escapeForSelector(taskName); + return page.locator(`task:has-text("${escapedName}")`); +}; + +/** + * Get a subtask element (task without subtasks) by name. + * Useful for targeting subtasks without matching their parent. + * + * @param client - The simulated E2E client + * @param taskName - The subtask name to search for + * @returns Locator for the subtask element + */ +export const getSubtaskElement = ( + client: SimulatedE2EClient, + taskName: string, +): Locator => { + const escapedName = escapeForSelector(taskName); + return client.page.locator(`task.hasNoSubTasks:has-text("${escapedName}")`); +}; + +/** + * Get a done task element by name. + * + * @param client - The simulated E2E client + * @param taskName - The task name to search for + * @returns Locator for the done task element + */ +export const getDoneTaskElement = ( + client: SimulatedE2EClient, + taskName: string, +): Locator => { + const escapedName = escapeForSelector(taskName); + return client.page.locator(`task.isDone:has-text("${escapedName}")`); +}; + +/** + * Get a done subtask element by name. + * + * @param client - The simulated E2E client + * @param taskName - The subtask name to search for + * @returns Locator for the done subtask element + */ +export const getDoneSubtaskElement = ( + client: SimulatedE2EClient, + taskName: string, +): Locator => { + const escapedName = escapeForSelector(taskName); + return client.page.locator(`task.hasNoSubTasks.isDone:has-text("${escapedName}")`); +}; + +/** + * Get an undone subtask element by name. + * + * @param client - The simulated E2E client + * @param taskName - The subtask name to search for + * @returns Locator for the undone subtask element + */ +export const getUndoneSubtaskElement = ( + client: SimulatedE2EClient, + taskName: string, +): Locator => { + const escapedName = escapeForSelector(taskName); + return client.page.locator( + `task.hasNoSubTasks:not(.isDone):has-text("${escapedName}")`, + ); +}; + +/** + * Get a parent task element (task with subtasks) by name. + * + * @param client - The simulated E2E client + * @param taskName - The task name to search for + * @returns Locator for the parent task element + */ +export const getParentTaskElement = ( + client: SimulatedE2EClient, + taskName: string, +): Locator => { + const escapedName = escapeForSelector(taskName); + return client.page.locator(`task:not(.hasNoSubTasks):has-text("${escapedName}")`); +}; + +// ============================================================================ +// Task Action Helpers +// ============================================================================ + +/** + * Mark a task as done by hovering and clicking the done button. + * + * @param client - The simulated E2E client + * @param taskName - The task name to mark as done + */ +export const markTaskDone = async ( + client: SimulatedE2EClient, + taskName: string, +): Promise => { + const task = getTaskElement(client, taskName); + await task.hover(); + await task.locator('.task-done-btn').click(); +}; + +/** + * Mark a subtask as done by hovering and clicking the done button. + * Uses getSubtaskElement to avoid matching parent tasks. + * + * @param client - The simulated E2E client + * @param subtaskName - The subtask name to mark as done + */ +export const markSubtaskDone = async ( + client: SimulatedE2EClient, + subtaskName: string, +): Promise => { + const subtask = getSubtaskElement(client, subtaskName); + await subtask.hover(); + await subtask.locator('.task-done-btn').click(); +}; + +/** + * Expand a parent task to show its subtasks. + * + * @param client - The simulated E2E client + * @param taskName - The parent task name + */ +export const expandTask = async ( + client: SimulatedE2EClient, + taskName: string, +): Promise => { + const task = getTaskElement(client, taskName); + const expandBtn = task.locator('.expand-btn'); + if (await expandBtn.isVisible()) { + await expandBtn.click(); + } +}; + +/** + * Delete a task via keyboard shortcut (Backspace) and confirm if dialog appears. + * + * @param client - The simulated E2E client + * @param taskName - The task name to delete + */ +export const deleteTask = async ( + client: SimulatedE2EClient, + taskName: string, +): Promise => { + const task = getTaskElement(client, taskName); + await task.click(); + await client.page.keyboard.press('Backspace'); + + // Confirm deletion if dialog appears + const confirmBtn = client.page.locator('mat-dialog-actions button:has-text("Delete")'); + if (await confirmBtn.isVisible({ timeout: 2000 }).catch(() => false)) { + await confirmBtn.click(); + } + await client.page.waitForTimeout(500); +}; + +/** + * Rename a task by double-clicking and filling the input. + * + * @param client - The simulated E2E client + * @param oldName - The current task name + * @param newName - The new task name + */ +export const renameTask = async ( + client: SimulatedE2EClient, + oldName: string, + newName: string, +): Promise => { + const task = getTaskElement(client, oldName); + await task.locator('task-title').click(); + await client.page.waitForSelector('task textarea', { state: 'visible' }); + await client.page.locator('task textarea').fill(newName); + await client.page.keyboard.press('Tab'); + await client.page.waitForTimeout(300); +}; + +/** + * Start time tracking on a task. + * + * @param client - The simulated E2E client + * @param taskName - The task name to start tracking + */ +export const startTimeTracking = async ( + client: SimulatedE2EClient, + taskName: string, +): Promise => { + const task = getTaskElement(client, taskName); + await task.hover(); + const startBtn = task.locator('.start-task-btn'); + await startBtn.waitFor({ state: 'visible', timeout: 5000 }); + await startBtn.click(); +}; + +/** + * Stop time tracking on a task. + * + * @param client - The simulated E2E client + * @param taskName - The task name to stop tracking + */ +export const stopTimeTracking = async ( + client: SimulatedE2EClient, + taskName: string, +): Promise => { + const task = getTaskElement(client, taskName); + await task.hover(); + const pauseBtn = task.locator('button:has(mat-icon:has-text("pause"))'); + await pauseBtn.waitFor({ state: 'visible', timeout: 5000 }); + await pauseBtn.click(); +}; + +// ============================================================================ +// Task Query Helpers +// ============================================================================ + +/** + * Get the task count on a client. + * + * @param client - The simulated E2E client + * @returns The number of tasks + */ +export const getTaskCount = async (client: SimulatedE2EClient): Promise => { + return client.page.locator('task').count(); +}; + +/** + * Get all task titles as an array. + * Useful for comparing task order between clients. + * + * @param client - The simulated E2E client + * @returns Array of task titles in order + */ +export const getTaskTitles = async (client: SimulatedE2EClient): Promise => { + const tasks = client.page.locator('task .task-title'); + const count = await tasks.count(); + const titles: string[] = []; + for (let i = 0; i < count; i++) { + const text = await tasks.nth(i).innerText(); + titles.push(text.trim()); + } + return titles; +}; + +/** + * Get the tracked time display text for a task. + * + * @param client - The simulated E2E client + * @param taskName - The task name + * @returns The time display text or null if not visible + */ +export const getTaskTimeDisplay = async ( + client: SimulatedE2EClient, + taskName: string, +): Promise => { + const task = getTaskElement(client, taskName); + const timeVal = task.locator('.time-wrapper .time-val').first(); + if (await timeVal.isVisible()) { + return timeVal.textContent(); + } + return null; +}; + +/** + * Check if a task has the given text (exists on client). + * + * @param client - The simulated E2E client + * @param taskName - The task name to check + * @returns true if the task exists + */ +export const hasTaskOnClient = async ( + client: SimulatedE2EClient, + taskName: string, +): Promise => { + return hasTask(client.page, taskName); +}; + +// ============================================================================ +// Test Setup Helpers +// ============================================================================ + +/** + * Generate a unique test run ID for data isolation. + * This replaces the common `generateTestRunId` function in test files. + * + * @param workerIndex - The Playwright worker index from testInfo + * @returns A unique test run ID string + */ +export const generateTestRunId = (workerIndex: number): string => { + return `${Date.now()}-${workerIndex}`; +};