diff --git a/e2e/tests/sync/supersync-cascade-delete.spec.ts b/e2e/tests/sync/supersync-cascade-delete.spec.ts index d59611b3ad..56fb13a561 100644 --- a/e2e/tests/sync/supersync-cascade-delete.spec.ts +++ b/e2e/tests/sync/supersync-cascade-delete.spec.ts @@ -38,27 +38,29 @@ const navigateToProject = async ( }; /** - * Delete a project via its settings. + * Delete a project via the context menu on the sidebar nav-item. */ -const deleteProjectViaSettings = async ( +const deleteProjectViaContextMenu = async ( page: import('@playwright/test').Page, projectName: string, ): Promise => { - // Navigate to the project first - await navigateToProject(page, projectName); + // Find the project nav-item in the sidebar + const projectNavItem = page.locator(`nav-item:has-text("${projectName}")`).first(); + await projectNavItem.waitFor({ state: 'visible', timeout: 10000 }); - // Open project settings via the route - await page.goto('/#/project-settings'); - await page.waitForLoadState('networkidle'); - await page.waitForTimeout(500); + // Hover to reveal the more_vert button (hidden by default, shown on hover) + await projectNavItem.hover(); + const moreBtn = projectNavItem.locator('.additional-btn'); + await moreBtn.waitFor({ state: 'visible', timeout: 5000 }); + await moreBtn.click(); - // Find and click Delete Project button - const deleteBtn = page.locator('button:has-text("Delete Project")').first(); - await deleteBtn.waitFor({ state: 'visible', timeout: 10000 }); - await deleteBtn.click(); + // Click "Delete project" in context menu + const deleteMenuItem = page.locator('button[mat-menu-item]:has-text("Delete project")'); + await deleteMenuItem.waitFor({ state: 'visible', timeout: 5000 }); + await deleteMenuItem.click(); - // Confirm deletion dialog - const confirmBtn = page.locator('mat-dialog-actions button:has-text("Delete")').first(); + // Confirm deletion dialog (button text is "Ok") + const confirmBtn = page.locator('dialog-confirm button[e2e="confirmBtn"]'); await confirmBtn.waitFor({ state: 'visible', timeout: 5000 }); await confirmBtn.click(); @@ -141,7 +143,7 @@ test.describe('@supersync Cross-Entity Cascade Delete', () => { // ============ PHASE 3: Client A deletes project (without syncing B's tasks first) ============ console.log('[CascadeDelete] Phase 3: Client A deletes project'); - await deleteProjectViaSettings(clientA.page, projectName); + await deleteProjectViaContextMenu(clientA.page, projectName); console.log('[CascadeDelete] Client A deleted project'); // Client A syncs (uploads deletion) diff --git a/e2e/tests/sync/supersync-multi-tab.spec.ts b/e2e/tests/sync/supersync-multi-tab.spec.ts index 5a59613ca8..a8c531a89a 100644 --- a/e2e/tests/sync/supersync-multi-tab.spec.ts +++ b/e2e/tests/sync/supersync-multi-tab.spec.ts @@ -17,9 +17,10 @@ import { waitForAppReady } from '../../utils/waits'; * Tests that two browser tabs sharing the same IndexedDB (same BrowserContext) * don't corrupt data when using SuperSync. * - * NOTE: This test is inherently fragile. Two Angular instances sharing IndexedDB - * may surface real bugs (e.g., transaction conflicts, stale reads). The test uses - * reload-based verification to ensure state consistency. + * NOTE: The app prevents two tabs from running simultaneously via BroadcastChannel. + * This test works around that by navigating the inactive tab to about:blank before + * opening the next tab, then reloading it later. This simulates the real-world + * scenario of closing and reopening a tab. * * Prerequisites: * - super-sync-server running on localhost:1901 with TEST_MODE=true @@ -36,10 +37,10 @@ test.describe('@supersync Multi-Tab Same Account', () => { * 1. Create a SHARED browser context (same IndexedDB) * 2. Open Tab 1, set up SuperSync, create task * 3. Tab 1 syncs - * 4. Open Tab 2 in SAME context - * 5. Tab 2 reloads → should see Tab 1's task (from shared IndexedDB) - * 6. Tab 2 creates a task - * 7. Tab 1 reloads → should see both tasks + * 4. Navigate Tab 1 away (avoid multi-instance blocker) + * 5. Open Tab 2 → should see Tab 1's task (from shared IndexedDB) + * 6. Tab 2 creates a task, syncs + * 7. Navigate Tab 2 away, reload Tab 1 → should see both tasks * 8. External Client C (separate context) syncs → should see consistent state */ test('Two tabs sharing IndexedDB converge with external client', async ({ @@ -62,6 +63,8 @@ test.describe('@supersync Multi-Tab Same Account', () => { sharedContext = await browser.newContext({ storageState: undefined, viewport: { width: 1920, height: 1080 }, + // Set PLAYWRIGHT user agent to skip the Shepherd tour dialog + userAgent: 'PLAYWRIGHT MULTI-TAB-TEST', }); const tab1 = await sharedContext.newPage(); @@ -93,6 +96,10 @@ test.describe('@supersync Multi-Tab Same Account', () => { // ============ PHASE 3: Open Tab 2 in same context ============ console.log('[MultiTab] Phase 3: Open Tab 2 in same context'); + // Navigate Tab 1 away to avoid multi-instance blocker + // (app uses BroadcastChannel to prevent two tabs from running simultaneously) + await tab1.goto('about:blank'); + const tab2 = await sharedContext.newPage(); tab2.on('console', (msg) => { if (msg.type() === 'error') { @@ -125,7 +132,10 @@ test.describe('@supersync Multi-Tab Same Account', () => { // ============ PHASE 5: Tab 1 reloads and verifies ============ console.log('[MultiTab] Phase 5: Tab 1 reloads'); - await tab1.reload(); + // Navigate Tab 2 away before reloading Tab 1 + await tab2.goto('about:blank'); + + await tab1.goto(appUrl); await waitForAppReady(tab1); await tab1.waitForTimeout(2000); @@ -137,6 +147,9 @@ test.describe('@supersync Multi-Tab Same Account', () => { // ============ PHASE 6: External Client C verifies consistent state ============ console.log('[MultiTab] Phase 6: External Client C verifies'); + // Navigate Tab 1 away before creating external client + await tab1.goto('about:blank'); + clientC = await createSimulatedClient(browser, appUrl, 'C', testRunId); await clientC.sync.setupSuperSync(syncConfig); await clientC.sync.syncAndWait(); diff --git a/e2e/tests/sync/supersync-token-expiry.spec.ts b/e2e/tests/sync/supersync-token-expiry.spec.ts index 0c11d33b70..949d3afa68 100644 --- a/e2e/tests/sync/supersync-token-expiry.spec.ts +++ b/e2e/tests/sync/supersync-token-expiry.spec.ts @@ -14,6 +14,9 @@ import { * Tests graceful recovery when auth token returns 401 during sync. * Uses Playwright route interception to simulate expired token responses. * + * After a 401, the app clears stored credentials and shows a "Configure" snackbar. + * Recovery requires re-entering credentials via the sync config dialog. + * * Prerequisites: * - super-sync-server running on localhost:1901 with TEST_MODE=true * - Frontend running on localhost:4242 @@ -23,13 +26,13 @@ import { test.describe('@supersync Token Expiry Recovery', () => { /** - * Scenario: Upload gets 401, retry succeeds after route is restored + * Scenario: Upload gets 401, re-authentication and retry succeeds * * Flow: * 1. Client A creates task, sets up sync * 2. Intercept upload to return 401 once - * 3. First sync fails with auth error - * 4. Remove interception, retry sync + * 3. First sync fails with auth error (app clears credentials) + * 4. Remove interception, re-enter credentials via setupSuperSync * 5. Client B verifies task synced correctly */ test('recovers from 401 on upload and retries successfully', async ({ @@ -57,7 +60,7 @@ test.describe('@supersync Token Expiry Recovery', () => { await waitForTask(clientA.page, taskName); // Intercept upload to return 401 once - await clientA.page.route('**/api/sync/ops/**', async (route) => { + await clientA.page.route('**/api/sync/ops*', async (route) => { if (state.return401 && route.request().method() === 'POST') { state.return401 = false; console.log('[TokenExpiry] Simulating 401 Unauthorized on upload'); @@ -83,10 +86,17 @@ test.describe('@supersync Token Expiry Recovery', () => { expect(state.return401).toBe(false); // Remove interception - await clientA.page.unroute('**/api/sync/ops/**'); + await clientA.page.unroute('**/api/sync/ops*'); await clientA.page.waitForTimeout(500); - // Retry sync - should succeed + // After 401, the app clears credentials and may show a config dialog. + // Close any open dialogs before re-entering credentials. + await clientA.sync.ensureOverlaysClosed(); + + // Re-enter credentials (app cleared them on auth failure) + await clientA.sync.setupSuperSync(syncConfig); + + // Sync again - should succeed now await clientA.sync.syncAndWait(); console.log('[TokenExpiry] Retry sync succeeded'); @@ -105,7 +115,7 @@ test.describe('@supersync Token Expiry Recovery', () => { console.log('[TokenExpiry] ✓ Upload 401 recovery test PASSED'); } finally { if (clientA) { - await clientA.page.unroute('**/api/sync/ops/**').catch(() => {}); + await clientA.page.unroute('**/api/sync/ops*').catch(() => {}); } if (clientA) await closeClient(clientA); if (clientB) await closeClient(clientB); @@ -113,13 +123,14 @@ test.describe('@supersync Token Expiry Recovery', () => { }); /** - * Scenario: Download gets 401, retry succeeds + * Scenario: Download gets 401, re-authentication and retry succeeds * * Flow: * 1. Client A creates and syncs task * 2. Client B sets up, intercept download to return 401 once - * 3. First download fails - * 4. Retry succeeds, Client B gets the task + * 3. First download fails (app clears credentials) + * 4. Remove interception, re-enter credentials + * 5. Retry succeeds, Client B gets the task */ test('recovers from 401 on download and retries successfully', async ({ browser, @@ -151,7 +162,7 @@ test.describe('@supersync Token Expiry Recovery', () => { await clientB.sync.setupSuperSync(syncConfig); // Intercept download to return 401 once - await clientB.page.route('**/api/sync/ops/**', async (route) => { + await clientB.page.route('**/api/sync/ops*', async (route) => { if (state.return401 && route.request().method() === 'GET') { state.return401 = false; console.log('[TokenExpiry] Simulating 401 Unauthorized on download'); @@ -177,10 +188,17 @@ test.describe('@supersync Token Expiry Recovery', () => { expect(state.return401).toBe(false); // Remove interception - await clientB.page.unroute('**/api/sync/ops/**'); + await clientB.page.unroute('**/api/sync/ops*'); await clientB.page.waitForTimeout(500); - // Retry - should succeed + // After 401, the app clears credentials and may show a config dialog. + // Close any open dialogs before re-entering credentials. + await clientB.sync.ensureOverlaysClosed(); + + // Re-enter credentials (app cleared them on auth failure) + await clientB.sync.setupSuperSync(syncConfig); + + // Sync again - should succeed await clientB.sync.syncAndWait(); // Verify task received @@ -191,7 +209,7 @@ test.describe('@supersync Token Expiry Recovery', () => { console.log('[TokenExpiry] ✓ Download 401 recovery test PASSED'); } finally { if (clientB) { - await clientB.page.unroute('**/api/sync/ops/**').catch(() => {}); + await clientB.page.unroute('**/api/sync/ops*').catch(() => {}); } if (clientA) await closeClient(clientA); if (clientB) await closeClient(clientB);