test(e2e): fix reliability issues in supersync E2E tests

- Replace page.reload() with close-and-reopen pattern after backup
  imports to prevent hangs with active sync connections
- Add proper sync-import-conflict dialog handling via Promise.race
- Fix browser context leak in provider-switch test
- Replace baseURL! non-null assertions with fallback pattern
- Replace fixed timeouts with element-based waits in cascade-delete
- Extract duplicate dialog-dismissal loop into dismissBlockingDialogs
  helper in waits.ts
- Add missing WorkViewPage/SuperSyncPage imports in same-client test
- Replace any type with proper VectorClockEntry interface
This commit is contained in:
Johannes Millan 2026-02-13 12:47:14 +01:00
parent 41987c94ae
commit 93cbcd7e44
7 changed files with 194 additions and 98 deletions

View file

@ -33,8 +33,10 @@ const navigateToProject = async (
const projectNavItem = page.locator(`nav-item:has-text("${projectName}")`).first();
await projectNavItem.waitFor({ state: 'visible', timeout: 10000 });
await projectNavItem.click();
await page.waitForLoadState('networkidle');
await page.waitForTimeout(500);
await page
.locator('.route-wrapper')
.first()
.waitFor({ state: 'visible', timeout: 10000 });
};
/**
@ -60,7 +62,8 @@ const deleteProjectViaContextMenu = async (
await confirmBtn.waitFor({ state: 'visible', timeout: 5000 });
await confirmBtn.click();
await page.waitForTimeout(1000);
// Wait for confirmation dialog to close
await page.locator('dialog-confirm').waitFor({ state: 'hidden', timeout: 5000 });
};
test.describe('@supersync Cross-Entity Cascade Delete', () => {

View file

@ -8,6 +8,9 @@ import {
type SimulatedE2EClient,
} from '../../utils/supersync-helpers';
import { ImportPage } from '../../pages/import.page';
import { SuperSyncPage } from '../../pages/supersync.page';
import { WorkViewPage } from '../../pages/work-view.page';
import { waitForAppReady } from '../../utils/waits';
/**
* SuperSync Import Clean Slate E2E Tests
@ -58,6 +61,7 @@ test.describe('@supersync @cleanslate Import Clean Slate Semantics', () => {
testRunId,
}) => {
const uniqueId = Date.now();
const appUrl = baseURL || 'http://localhost:4242';
let clientA: SimulatedE2EClient | null = null;
let clientB: SimulatedE2EClient | null = null;
@ -68,10 +72,10 @@ test.describe('@supersync @cleanslate Import Clean Slate Semantics', () => {
// ============ PHASE 1: Setup Both Clients ============
console.log('[Clean Slate] Phase 1: Setting up both clients');
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
clientA = await createSimulatedClient(browser, appUrl, 'A', testRunId);
await clientA.sync.setupSuperSync(syncConfig);
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
clientB = await createSimulatedClient(browser, appUrl, 'B', testRunId);
await clientB.sync.setupSuperSync(syncConfig);
// ============ PHASE 2: Create Pre-Import Tasks on Both Clients ============
@ -111,17 +115,36 @@ test.describe('@supersync @cleanslate Import Clean Slate Semantics', () => {
await importPage.importBackupFile(backupPath);
console.log('[Clean Slate] Client A imported backup');
// Reload page after import to ensure UI reflects the imported state
// (bulk state updates from BACKUP_IMPORT may not trigger component re-renders)
// Use goto instead of reload - more reliable with service workers
await clientA.page.goto(clientA.page.url(), {
waitUntil: 'domcontentloaded',
timeout: 30000,
});
await clientA.page.waitForLoadState('networkidle');
// Close and re-open page to pick up imported data with fresh Angular services.
// Using page.reload() can hang when active sync connections prevent navigation.
await clientA.page.close();
clientA.page = await clientA.context.newPage();
await clientA.page.goto('/');
clientA.workView = new WorkViewPage(clientA.page, `A-${testRunId}`);
clientA.sync = new SuperSyncPage(clientA.page);
await waitForAppReady(clientA.page, { ensureRoute: false });
// Re-enable sync after import (import overwrites globalConfig)
await clientA.sync.setupSuperSync(syncConfig);
// Configure sync WITHOUT waiting for initial sync
// (initial sync will show sync-import-conflict dialog since we have a local BackupImport)
await clientA.sync.setupSuperSync({ ...syncConfig, waitForInitialSync: false });
// Wait for either sync import conflict dialog OR sync completion
const syncImportDialog = clientA.sync.syncImportConflictDialog;
const syncResult = await Promise.race([
syncImportDialog
.waitFor({ state: 'visible', timeout: 30000 })
.then(() => 'dialog' as const),
clientA.sync.syncCheckIcon
.waitFor({ state: 'visible', timeout: 30000 })
.then(() => 'complete' as const),
]);
if (syncResult === 'dialog') {
// Choose "Use My Data" to preserve the import
await clientA.sync.syncImportUseLocalBtn.click();
await syncImportDialog.waitFor({ state: 'hidden', timeout: 5000 });
await clientA.sync.syncCheckIcon.waitFor({ state: 'visible', timeout: 30000 });
}
// Wait for imported task to be visible
await waitForTask(clientA.page, 'E2E Import Test - Active Task With Subtask');
@ -234,6 +257,7 @@ test.describe('@supersync @cleanslate Import Clean Slate Semantics', () => {
testRunId,
}) => {
const uniqueId = Date.now();
const appUrl = baseURL || 'http://localhost:4242';
let clientA: SimulatedE2EClient | null = null;
let clientB: SimulatedE2EClient | null = null;
@ -244,7 +268,7 @@ test.describe('@supersync @cleanslate Import Clean Slate Semantics', () => {
// ============ 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);
clientB = await createSimulatedClient(browser, appUrl, 'B', testRunId);
await clientB.sync.setupSuperSync(syncConfig);
// Client B creates and syncs a task
@ -260,7 +284,7 @@ test.describe('@supersync @cleanslate Import Clean Slate Semantics', () => {
// ============ 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);
clientA = await createSimulatedClient(browser, appUrl, 'A', testRunId);
await clientA.sync.setupSuperSync(syncConfig);
// DO NOT sync before import - A doesn't know about B's task
@ -273,16 +297,33 @@ test.describe('@supersync @cleanslate Import Clean Slate Semantics', () => {
await importPage.importBackupFile(backupPath);
console.log('[Late Joiner] Client A imported backup');
// Reload page after import to ensure UI reflects the imported state
// Use goto instead of reload - more reliable with service workers
await clientA.page.goto(clientA.page.url(), {
waitUntil: 'domcontentloaded',
timeout: 30000,
});
await clientA.page.waitForLoadState('networkidle');
// Close and re-open page to pick up imported data with fresh Angular services.
await clientA.page.close();
clientA.page = await clientA.context.newPage();
await clientA.page.goto('/');
clientA.workView = new WorkViewPage(clientA.page, `A-${testRunId}`);
clientA.sync = new SuperSyncPage(clientA.page);
await waitForAppReady(clientA.page, { ensureRoute: false });
// Re-enable sync after import
await clientA.sync.setupSuperSync(syncConfig);
// Configure sync WITHOUT waiting for initial sync
await clientA.sync.setupSuperSync({ ...syncConfig, waitForInitialSync: false });
// Wait for either sync import conflict dialog OR sync completion
const syncImportDialog2 = clientA.sync.syncImportConflictDialog;
const syncResult2 = await Promise.race([
syncImportDialog2
.waitFor({ state: 'visible', timeout: 30000 })
.then(() => 'dialog' as const),
clientA.sync.syncCheckIcon
.waitFor({ state: 'visible', timeout: 30000 })
.then(() => 'complete' as const),
]);
if (syncResult2 === 'dialog') {
await clientA.sync.syncImportUseLocalBtn.click();
await syncImportDialog2.waitFor({ state: 'hidden', timeout: 5000 });
await clientA.sync.syncCheckIcon.waitFor({ state: 'visible', timeout: 30000 });
}
// ============ PHASE 3: Both Clients Sync ============
console.log('[Late Joiner] Phase 3: Both clients sync');
@ -361,6 +402,7 @@ test.describe('@supersync @cleanslate Import Clean Slate Semantics', () => {
testRunId,
}) => {
const uniqueId = Date.now();
const appUrl = baseURL || 'http://localhost:4242';
let clientA: SimulatedE2EClient | null = null;
let clientB: SimulatedE2EClient | null = null;
@ -373,11 +415,11 @@ test.describe('@supersync @cleanslate Import Clean Slate Semantics', () => {
'[Pending Invalidation] Phase 1: Setting up both clients with initial sync',
);
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
clientA = await createSimulatedClient(browser, appUrl, 'A', testRunId);
await clientA.sync.setupSuperSync(syncConfig);
await clientA.sync.syncAndWait(); // Initial sync to establish vector clock
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
clientB = await createSimulatedClient(browser, appUrl, 'B', testRunId);
await clientB.sync.setupSuperSync(syncConfig);
await clientB.sync.syncAndWait(); // Initial sync to establish vector clock
@ -411,31 +453,32 @@ test.describe('@supersync @cleanslate Import Clean Slate Semantics', () => {
await importPage.importBackupFile(backupPath);
console.log('[Pending Invalidation] Client A imported backup');
// Reload page after import to ensure UI reflects the imported state
// Use goto instead of reload - more reliable with service workers
await clientA.page.goto(clientA.page.url(), {
waitUntil: 'domcontentloaded',
timeout: 30000,
});
await clientA.page.waitForLoadState('networkidle');
// Close and re-open page to pick up imported data with fresh Angular services.
await clientA.page.close();
clientA.page = await clientA.context.newPage();
await clientA.page.goto('/');
clientA.workView = new WorkViewPage(clientA.page, `A-${testRunId}`);
clientA.sync = new SuperSyncPage(clientA.page);
await waitForAppReady(clientA.page, { ensureRoute: false });
// Re-enable sync after import (import overwrites globalConfig)
// Use waitForInitialSync: false because a conflict dialog may appear
await clientA.sync.setupSuperSync({
...syncConfig,
waitForInitialSync: false,
});
// Configure sync WITHOUT waiting for initial sync
await clientA.sync.setupSuperSync({ ...syncConfig, waitForInitialSync: false });
// Handle sync conflict dialog if it appears (server has changes from initial sync)
const conflictDialog = clientA.page.locator(
'dialog-handle-import-conflict, mat-dialog-container:has-text("Sync Conflict")',
);
if (await conflictDialog.isVisible({ timeout: 3000 }).catch(() => false)) {
console.log('[Pending Invalidation] Handling sync conflict dialog');
// Choose "Use My Data" to keep imported data (clean slate)
const useMyDataBtn = conflictDialog.locator('button:has-text("Use My Data")');
await useMyDataBtn.click();
await conflictDialog.waitFor({ state: 'hidden', timeout: 10000 });
// Wait for either sync import conflict dialog OR sync completion
const syncImportDialog3 = clientA.sync.syncImportConflictDialog;
const syncResult3 = await Promise.race([
syncImportDialog3
.waitFor({ state: 'visible', timeout: 30000 })
.then(() => 'dialog' as const),
clientA.sync.syncCheckIcon
.waitFor({ state: 'visible', timeout: 30000 })
.then(() => 'complete' as const),
]);
if (syncResult3 === 'dialog') {
await clientA.sync.syncImportUseLocalBtn.click();
await syncImportDialog3.waitFor({ state: 'hidden', timeout: 5000 });
await clientA.sync.syncCheckIcon.waitFor({ state: 'visible', timeout: 30000 });
}
// Wait for imported task to be visible

View file

@ -62,6 +62,7 @@ test.describe('@supersync @pruning Other client post-import ops sync correctly',
testRunId,
}) => {
const uniqueId = `${testRunId}-${Date.now()}-${Math.random().toString(36).substring(2, 8)}`;
const appUrl = baseURL || 'http://localhost:4242';
let clientA: SimulatedE2EClient | null = null;
let clientB: SimulatedE2EClient | null = null;
@ -72,10 +73,10 @@ test.describe('@supersync @pruning Other client post-import ops sync correctly',
// ============ PHASE 1: Setup Both Clients ============
console.log('[Other-Client Import] Phase 1: Setting up both clients');
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
clientA = await createSimulatedClient(browser, appUrl, 'A', testRunId);
await clientA.sync.setupSuperSync(syncConfig);
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
clientB = await createSimulatedClient(browser, appUrl, 'B', testRunId);
await clientB.sync.setupSuperSync(syncConfig);
// Initial sync to establish vector clocks
@ -164,6 +165,11 @@ test.describe('@supersync @pruning Other client post-import ops sync correctly',
);
await clientB.page.evaluate(async () => {
interface VectorClockEntry {
clock: Record<string, number>;
lastUpdate: number;
}
const DB_NAME = 'SUP_OPS';
const VECTOR_CLOCK_STORE = 'vector_clock';
const SINGLETON_KEY = 'current';
@ -176,13 +182,16 @@ test.describe('@supersync @pruning Other client post-import ops sync correctly',
});
// Read current vector clock entry
const currentEntry = await new Promise<any>((resolve, reject) => {
const tx = db.transaction(VECTOR_CLOCK_STORE, 'readonly');
const store = tx.objectStore(VECTOR_CLOCK_STORE);
const request = store.get(SINGLETON_KEY);
request.onsuccess = (): void => resolve(request.result);
request.onerror = (): void => reject(request.error);
});
const currentEntry = await new Promise<VectorClockEntry | undefined>(
(resolve, reject) => {
const tx = db.transaction(VECTOR_CLOCK_STORE, 'readonly');
const store = tx.objectStore(VECTOR_CLOCK_STORE);
const request = store.get(SINGLETON_KEY);
request.onsuccess = (): void =>
resolve(request.result as VectorClockEntry | undefined);
request.onerror = (): void => reject(request.error);
},
);
const existingClock = currentEntry?.clock ?? {};
console.log(

View file

@ -8,6 +8,8 @@ import {
type SimulatedE2EClient,
} from '../../utils/supersync-helpers';
import { ImportPage } from '../../pages/import.page';
import { SuperSyncPage } from '../../pages/supersync.page';
import { WorkViewPage } from '../../pages/work-view.page';
import { expectTaskOnAllClients } from '../../utils/supersync-assertions';
import { waitForAppReady } from '../../utils/waits';
@ -55,6 +57,7 @@ test.describe('@supersync @pruning Import client post-import ops sync correctly'
testRunId,
}) => {
const uniqueId = `${testRunId}-${Date.now()}-${Math.random().toString(36).substring(2, 8)}`;
const appUrl = baseURL || 'http://localhost:4242';
let clientA: SimulatedE2EClient | null = null;
let clientB: SimulatedE2EClient | null = null;
@ -65,10 +68,10 @@ test.describe('@supersync @pruning Import client post-import ops sync correctly'
// ============ PHASE 1: Setup Both Clients ============
console.log('[Same-Client Import] Phase 1: Setting up both clients');
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
clientA = await createSimulatedClient(browser, appUrl, 'A', testRunId);
await clientA.sync.setupSuperSync(syncConfig);
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
clientB = await createSimulatedClient(browser, appUrl, 'B', testRunId);
await clientB.sync.setupSuperSync(syncConfig);
// Initial sync to establish vector clocks
@ -86,8 +89,13 @@ test.describe('@supersync @pruning Import client post-import ops sync correctly'
await importPage.importBackupFile(backupPath);
console.log('[Same-Client Import] Client A imported backup');
// Reload and re-enable sync
await clientA.page.reload({ timeout: 60000 });
// Close and re-open page to pick up imported data with fresh Angular services.
// Using page.reload() can hang when active sync connections prevent navigation.
await clientA.page.close();
clientA.page = await clientA.context.newPage();
await clientA.page.goto('/');
clientA.workView = new WorkViewPage(clientA.page, `A-${testRunId}`);
clientA.sync = new SuperSyncPage(clientA.page);
await waitForAppReady(clientA.page, { ensureRoute: false });
// Configure sync WITHOUT waiting for initial sync
@ -209,6 +217,7 @@ test.describe('@supersync @pruning Import client post-import ops sync correctly'
testRunId,
}) => {
const uniqueId = `${testRunId}-${Date.now()}-${Math.random().toString(36).substring(2, 8)}`;
const appUrl = baseURL || 'http://localhost:4242';
let clientA: SimulatedE2EClient | null = null;
let clientB: SimulatedE2EClient | null = null;
@ -217,10 +226,10 @@ test.describe('@supersync @pruning Import client post-import ops sync correctly'
const syncConfig = getSuperSyncConfig(user);
// Setup
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
clientA = await createSimulatedClient(browser, appUrl, 'A', testRunId);
await clientA.sync.setupSuperSync(syncConfig);
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
clientB = await createSimulatedClient(browser, appUrl, 'B', testRunId);
await clientB.sync.setupSuperSync(syncConfig);
await clientA.sync.syncAndWait();
@ -232,7 +241,12 @@ test.describe('@supersync @pruning Import client post-import ops sync correctly'
const backupPath = ImportPage.getFixturePath('test-backup.json');
await importPage.importBackupFile(backupPath);
await clientA.page.reload({ timeout: 60000 });
// Close and re-open page to pick up imported data with fresh Angular services.
await clientA.page.close();
clientA.page = await clientA.context.newPage();
await clientA.page.goto('/');
clientA.workView = new WorkViewPage(clientA.page, `A-${testRunId}`);
clientA.sync = new SuperSyncPage(clientA.page);
await waitForAppReady(clientA.page, { ensureRoute: false });
// Configure sync WITHOUT waiting for initial sync (same dialog handling as test 1)

View file

@ -8,6 +8,9 @@ import {
type SimulatedE2EClient,
} from '../../utils/supersync-helpers';
import { ImportPage } from '../../pages/import.page';
import { SuperSyncPage } from '../../pages/supersync.page';
import { WorkViewPage } from '../../pages/work-view.page';
import { waitForAppReady } from '../../utils/waits';
/**
* SuperSync lastSeq Preservation Regression Tests
@ -89,15 +92,36 @@ test.describe('@supersync lastSeq Preservation After Import', () => {
await importPage.importBackupFile(backupPath);
console.log('[lastSeq] Client A imported backup');
// Reload page after import
await clientA.page.goto(clientA.page.url(), {
waitUntil: 'domcontentloaded',
timeout: 30000,
});
await clientA.page.waitForLoadState('networkidle');
// Close and re-open page to pick up imported data with fresh Angular services.
// Using page.reload() can hang when active sync connections prevent navigation.
await clientA.page.close();
clientA.page = await clientA.context.newPage();
await clientA.page.goto('/');
clientA.workView = new WorkViewPage(clientA.page, `A-${testRunId}`);
clientA.sync = new SuperSyncPage(clientA.page);
await waitForAppReady(clientA.page, { ensureRoute: false });
// Re-enable sync after import (import overwrites globalConfig)
await clientA.sync.setupSuperSync(syncConfig);
// Configure sync WITHOUT waiting for initial sync
// (initial sync will show sync-import-conflict dialog since we have a local BackupImport)
await clientA.sync.setupSuperSync({ ...syncConfig, waitForInitialSync: false });
// Wait for either sync import conflict dialog OR sync completion
const syncImportDialog = clientA.sync.syncImportConflictDialog;
const syncResult = await Promise.race([
syncImportDialog
.waitFor({ state: 'visible', timeout: 30000 })
.then(() => 'dialog' as const),
clientA.sync.syncCheckIcon
.waitFor({ state: 'visible', timeout: 30000 })
.then(() => 'complete' as const),
]);
if (syncResult === 'dialog') {
// Choose "Use My Data" to preserve the import
await clientA.sync.syncImportUseLocalBtn.click();
await syncImportDialog.waitFor({ state: 'hidden', timeout: 5000 });
await clientA.sync.syncCheckIcon.waitFor({ state: 'visible', timeout: 30000 });
}
// Wait for imported task to be visible
await waitForTask(clientA.page, 'E2E Import Test - Active Task With Subtask');

View file

@ -96,8 +96,12 @@ test.describe('@supersync Provider Switch (WebDAV ↔ SuperSync)', () => {
// Create WebDAV sync folder
const syncFolderName = generateSyncFolderName(`e2e-provswitch-${testRunId}`);
const request = await (await browser.newContext()).request;
await createSyncFolder(request, syncFolderName);
const webdavRequestContext = await browser.newContext();
try {
await createSyncFolder(webdavRequestContext.request, syncFolderName);
} finally {
await webdavRequestContext.close();
}
// Disable SuperSync and set up WebDAV
const syncPageA = new SyncPage(clientA.page);

View file

@ -17,6 +17,24 @@ type WaitForAppReadyOptions = {
routeRegex?: RegExp;
};
/**
* Dismiss up to `maxAttempts` blocking confirmation dialogs.
* Some app flows show chained dialogs (e.g., pre-migration + data-repair)
* that must be dismissed before the app shell becomes interactive.
*/
const dismissBlockingDialogs = async (page: Page, maxAttempts = 3): Promise<void> => {
for (let i = 0; i < maxAttempts; i++) {
try {
const dialogConfirmBtn = page.locator('dialog-confirm button[e2e="confirmBtn"]');
await dialogConfirmBtn.waitFor({ state: 'visible', timeout: 2000 });
await dialogConfirmBtn.click();
await page.waitForTimeout(500);
} catch {
break;
}
}
};
/**
* Simplified wait that relies on Playwright's auto-waiting.
* Previously used Angular testability API to check Zone.js stability.
@ -54,17 +72,7 @@ export const waitForAppReady = async (
// Handle any blocking dialogs (pre-migration, confirmation, etc.)
// These dialogs block app until dismissed
for (let i = 0; i < 3; i++) {
try {
const dialogConfirmBtn = page.locator('dialog-confirm button[e2e="confirmBtn"]');
await dialogConfirmBtn.waitFor({ state: 'visible', timeout: 2000 });
await dialogConfirmBtn.click();
await page.waitForTimeout(500);
} catch {
// No dialog visible, break out
break;
}
}
await dismissBlockingDialogs(page);
// Wait for the loading screen to disappear (if visible).
// The app shows `.loading-full-page-wrapper` while syncing/importing data.
@ -112,16 +120,7 @@ export const waitForAppReady = async (
);
await page.reload();
await page.waitForLoadState('domcontentloaded');
for (let i = 0; i < 3; i++) {
try {
const btn = page.locator('dialog-confirm button[e2e="confirmBtn"]');
await btn.waitFor({ state: 'visible', timeout: 2000 });
await btn.click();
await page.waitForTimeout(500);
} catch {
break;
}
}
await dismissBlockingDialogs(page);
const rl = page.locator('.loading-full-page-wrapper');
if (await rl.isVisible().catch(() => false)) {
await rl.waitFor({ state: 'hidden', timeout: 30000 });