test(e2e): fix failing supersync E2E tests

- cascade-delete: use context menu for project deletion instead of
  non-existent settings page button
- multi-tab: set PLAYWRIGHT user agent on shared context and navigate
  inactive tabs to about:blank to avoid multi-instance blocker
- token-expiry: fix route pattern to match actual API endpoint and
  re-enter credentials after 401 clears them
This commit is contained in:
Johannes Millan 2026-02-12 18:02:14 +01:00
parent 612b7b1886
commit aea9c769c8
3 changed files with 70 additions and 37 deletions

View file

@ -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<void> => {
// 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)

View file

@ -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();

View file

@ -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);