super-productivity/e2e/tests/sync/webdav-sync-error-handling.spec.ts
Johannes Millan 77f83c5687
test(e2e): harden failure signals and provider gates (#7753)
* test: harden e2e failure signals

Fail otherwise-passing E2E tests on browser runtime errors, keep Playwright retries disabled, preserve Docker E2E exit codes, and make plugin/WebDAV setup failures hard failures instead of logged or skipped conditions.

* test: harden provider e2e runners

Make WebDAV and SuperSync runner scripts require provider readiness, preserve cleanup and argument forwarding, and fail manual sync clients on uncaught page errors.

* ci: require providers for scheduled e2e

Set required-provider flags in scheduled WebDAV and SuperSync jobs, and remove the duplicate provider runner scripts while keeping local npm aliases inline.

* test: catch e2e teardown pageerrors and tighten fixture

- closeClient now asserts runtime errors AFTER context.close() so
  pageerrors emitted during teardown (Angular destroy hooks, late RxJS
  errors) are captured instead of dropped. Matches the pattern in
  guardContextCloseWithRuntimeErrorCheck.
- test.fixture.ts isolatedContext now spreads Playwright's merged
  contextOptions instead of destructuring 23 fields by hand. Future
  option additions propagate automatically; the page fixture uses the
  shared attachPageErrorCollector and only fails on pageerror (not
  console.error, which is too noisy). Guards against a configured 0
  timeout being treated as undefined.
- plugin-loading.spec.ts second test now hard-asserts that the plugin
  menu entry reappears after re-enable, matching the first test instead
  of silently logging when not visible.

* test(sync): stabilize ImmediateUploadService spec

Two complementary fixes for flaky failures observed under full-suite
random-order runs where the upload pipeline silently never fires:

- Pin navigator.onLine = true in beforeEach (restored in afterEach).
  isOnline() inside _canUpload reads navigator.onLine directly. The
  keyboard-layout spec replaces the whole navigator and the is-online
  spec spies on it; if order or restoration ever drifts, every "should
  fire upload" test fails trivially while the "should NOT" tests pass.
- Replace tick(2100) with tick(2000); flush(). The await chain inside
  withSession() (provider.isReady, withSession entry, uploadPendingOps,
  optional LWW re-upload) requires more microtask drain than tick's
  fixed-time window reliably provides under load. flush() drains the
  pipeline regardless.

* test(e2e): guard skipOnboarding init script against data: frames

The new page-error collector started failing plugin specs because
addInitScript runs in every frame — including the empty data:text/html
iframe that plugin-index swaps in on destroy — and localStorage access
in a data: URL throws SecurityError. Wrap the four setItem calls in
try/catch so the helper noops in storage-less frames.
2026-05-23 20:33:04 +02:00

247 lines
7.7 KiB
TypeScript

import { test, expect } from '../../fixtures/webdav.fixture';
import { SyncPage } from '../../pages/sync.page';
import { WorkViewPage } from '../../pages/work-view.page';
import {
WEBDAV_CONFIG_TEMPLATE,
createUniqueSyncFolder,
createWebDavFolder,
setupClient,
waitForSync,
simulateNetworkFailure,
restoreNetwork,
} from '../../utils/sync-helpers';
import { waitForStatePersistence } from '../../utils/waits';
test.describe('@webdav WebDAV Sync Error Handling', () => {
// Run sync tests serially to avoid WebDAV server contention
test.describe.configure({ mode: 'serial' });
test('should handle server unavailable during sync', async ({
browser,
baseURL,
request,
}) => {
test.slow();
const SYNC_FOLDER_NAME = createUniqueSyncFolder('error-network');
await createWebDavFolder(request, SYNC_FOLDER_NAME);
const WEBDAV_CONFIG = {
...WEBDAV_CONFIG_TEMPLATE,
syncFolderPath: `/${SYNC_FOLDER_NAME}`,
};
const url = baseURL || 'http://localhost:4242';
// --- Client A ---
const { context: contextA, page: pageA } = await setupClient(browser, url);
const syncPageA = new SyncPage(pageA);
const workViewPageA = new WorkViewPage(pageA);
await workViewPageA.waitForTaskList();
// Configure Sync on Client A
await syncPageA.setupWebdavSync(WEBDAV_CONFIG);
await expect(syncPageA.syncBtn).toBeVisible();
// First, verify sync works normally
await syncPageA.triggerSync();
await waitForSync(pageA, syncPageA);
// Create a task
const taskName = `Network Test Task ${Date.now()}`;
await workViewPageA.addTask(taskName);
await waitForStatePersistence(pageA);
// Simulate network failure
await simulateNetworkFailure(pageA);
// Trigger sync - should fail
await syncPageA.triggerSync();
// Wait for error indication (snackbar or sync icon change)
// The sync should fail gracefully
const startTime = Date.now();
let errorFound = false;
while (Date.now() - startTime < 15000 && !errorFound) {
// Check for error snackbar
const snackBars = pageA.locator('.mat-mdc-snack-bar-container');
const count = await snackBars.count();
for (let i = 0; i < count; ++i) {
const text = await snackBars.nth(i).innerText();
if (
text.toLowerCase().includes('error') ||
text.toLowerCase().includes('fail') ||
text.toLowerCase().includes('network')
) {
errorFound = true;
break;
}
}
// Check for error icon on sync button
const errorIcon = syncPageA.syncBtn.locator(
'mat-icon.error, mat-icon:text("error"), mat-icon:text("sync_problem")',
);
if (await errorIcon.isVisible({ timeout: 500 }).catch(() => false)) {
errorFound = true;
}
if (!errorFound) {
await pageA.waitForTimeout(500);
}
}
expect(errorFound).toBe(true);
// App should not crash - verify we can still interact
const taskLocator = pageA.locator('task', { hasText: taskName });
await expect(taskLocator).toBeVisible();
// Restore network
await restoreNetwork(pageA);
// Wait a moment for route to be fully removed
await pageA.waitForTimeout(1000);
// Dismiss any visible error snackbars before retrying
const snackBarDismiss = pageA.locator(
'.mat-mdc-snack-bar-container button, .mat-mdc-snack-bar-action',
);
if (await snackBarDismiss.isVisible({ timeout: 1000 }).catch(() => false)) {
await snackBarDismiss.click().catch(() => {});
await pageA.waitForTimeout(500);
}
// Dismiss any open dialogs that might be blocking
const dialogBackdrop = pageA.locator('.cdk-overlay-backdrop');
if (await dialogBackdrop.isVisible({ timeout: 500 }).catch(() => false)) {
// Press Escape to close any open dialogs
await pageA.keyboard.press('Escape');
await pageA.waitForTimeout(500);
}
// Sync should work again
await syncPageA.triggerSync();
await expect(syncPageA.syncCheckIcon).toBeVisible({ timeout: 30000 });
// Cleanup
await contextA.close();
});
test('should handle authentication failure', async ({ browser, baseURL, request }) => {
test.slow();
const SYNC_FOLDER_NAME = createUniqueSyncFolder('error-auth');
await createWebDavFolder(request, SYNC_FOLDER_NAME);
const url = baseURL || 'http://localhost:4242';
// --- Client A with wrong password ---
const { context: contextA, page: pageA } = await setupClient(browser, url);
const syncPageA = new SyncPage(pageA);
const workViewPageA = new WorkViewPage(pageA);
await workViewPageA.waitForTaskList();
// Configure Sync with wrong password
const WRONG_CONFIG = {
baseUrl: WEBDAV_CONFIG_TEMPLATE.baseUrl,
username: 'admin',
password: 'wrongpassword',
syncFolderPath: `/${SYNC_FOLDER_NAME}`,
};
await syncPageA.setupWebdavSync(WRONG_CONFIG);
await expect(syncPageA.syncBtn).toBeVisible();
// Trigger sync - should fail with auth error
await syncPageA.triggerSync();
// Wait for error indication
const startTime = Date.now();
let authErrorFound = false;
while (Date.now() - startTime < 15000 && !authErrorFound) {
// Check for error snackbar
const snackBars = pageA.locator('.mat-mdc-snack-bar-container');
const count = await snackBars.count();
for (let i = 0; i < count; ++i) {
const text = await snackBars
.nth(i)
.innerText()
.catch(() => '');
const textLower = text.toLowerCase();
if (
textLower.includes('401') ||
textLower.includes('auth') ||
textLower.includes('unauthorized') ||
textLower.includes('error') ||
textLower.includes('fail')
) {
authErrorFound = true;
break;
}
}
if (!authErrorFound) {
await pageA.waitForTimeout(500);
}
}
expect(authErrorFound).toBe(true);
// App should not crash
const taskList = pageA.locator('task-list');
await expect(taskList).toBeVisible();
// Cleanup
await contextA.close();
});
test('should handle double sync trigger gracefully', async ({
browser,
baseURL,
request,
}) => {
test.slow();
const SYNC_FOLDER_NAME = createUniqueSyncFolder('error-double');
await createWebDavFolder(request, SYNC_FOLDER_NAME);
const WEBDAV_CONFIG = {
...WEBDAV_CONFIG_TEMPLATE,
syncFolderPath: `/${SYNC_FOLDER_NAME}`,
};
const url = baseURL || 'http://localhost:4242';
// --- Client A ---
const { context: contextA, page: pageA } = await setupClient(browser, url);
const syncPageA = new SyncPage(pageA);
const workViewPageA = new WorkViewPage(pageA);
await workViewPageA.waitForTaskList();
// Configure Sync on Client A
await syncPageA.setupWebdavSync(WEBDAV_CONFIG);
await expect(syncPageA.syncBtn).toBeVisible();
// Create some tasks
const taskName = `Double Sync Task ${Date.now()}`;
await workViewPageA.addTask(taskName);
await waitForStatePersistence(pageA);
// Trigger sync twice rapidly (simulating double-click)
await syncPageA.syncBtn.click();
await pageA.waitForTimeout(100);
await syncPageA.syncBtn.click();
// Wait for sync to complete
await waitForSync(pageA, syncPageA);
// App should not crash and task should still be visible
const taskLocator = pageA.locator('task', { hasText: taskName });
await expect(taskLocator).toBeVisible();
// Verify sync button is in normal state (not stuck)
await expect(syncPageA.syncBtn).toBeEnabled();
// Try another sync to confirm everything works
await syncPageA.triggerSync();
await waitForSync(pageA, syncPageA);
// Cleanup
await contextA.close();
});
});