super-productivity/e2e/pages/supersync.page.ts
Johannes Millan ab0371fac6 fix(e2e): improve supersync test stability and build compatibility
E2E test improvements:
- Increase timeouts for sync button and add task bar visibility checks
- Add retry logic for sync button wait in setupSuperSync
- Handle dialog close race conditions in save button click
- Fix simple counter test to work with collapsible sections and inline forms

Build fixes:
- Add es2022 lib/target and baseUrl to electron tsconfig
- Include window-ea.d.ts for proper type resolution
- Add @ts-ignore for import.meta.url in reminder service for Electron build
2025-12-19 09:59:44 +01:00

393 lines
15 KiB
TypeScript

import { type Page, type Locator, expect } from '@playwright/test';
import { BasePage } from './base.page';
export interface SuperSyncConfig {
baseUrl: string;
accessToken: string;
isEncryptionEnabled?: boolean;
password?: string;
}
/**
* Page object for SuperSync configuration and sync operations.
* Used for E2E tests that verify multi-client sync via the super-sync-server.
*/
export class SuperSyncPage extends BasePage {
readonly syncBtn: Locator;
readonly providerSelect: Locator;
readonly baseUrlInput: Locator;
readonly accessTokenInput: Locator;
readonly encryptionCheckbox: Locator;
readonly encryptionPasswordInput: Locator;
readonly saveBtn: Locator;
readonly syncSpinner: Locator;
readonly syncCheckIcon: Locator;
readonly syncErrorIcon: Locator;
/** Fresh client confirmation dialog - appears when a new client first syncs */
readonly freshClientDialog: Locator;
readonly freshClientConfirmBtn: Locator;
/** Conflict resolution dialog - appears when local and remote have conflicting changes */
readonly conflictDialog: Locator;
readonly conflictUseRemoteBtn: Locator;
readonly conflictApplyBtn: Locator;
constructor(page: Page) {
super(page);
this.syncBtn = page.locator('button.sync-btn');
this.providerSelect = page.locator('formly-field-mat-select mat-select');
this.baseUrlInput = page.locator('.e2e-baseUrl input');
this.accessTokenInput = page.locator('.e2e-accessToken textarea');
this.encryptionCheckbox = page.locator(
'.e2e-isEncryptionEnabled input[type="checkbox"]',
);
this.encryptionPasswordInput = page.locator('.e2e-encryptKey input[type="password"]');
this.saveBtn = page.locator('mat-dialog-actions button[mat-stroked-button]');
this.syncSpinner = page.locator('.sync-btn mat-icon.spin');
this.syncCheckIcon = page.locator('.sync-btn mat-icon.sync-state-ico');
// Error state shows sync_problem icon (no special class, just the icon name)
this.syncErrorIcon = page.locator('.sync-btn mat-icon:has-text("sync_problem")');
// Fresh client confirmation dialog elements
this.freshClientDialog = page.locator('dialog-confirm');
this.freshClientConfirmBtn = page.locator(
'dialog-confirm button[mat-stroked-button]',
);
// Conflict resolution dialog elements
this.conflictDialog = page.locator('dialog-conflict-resolution');
this.conflictUseRemoteBtn = page.locator(
'dialog-conflict-resolution button:has-text("Use All Remote")',
);
this.conflictApplyBtn = page.locator(
'dialog-conflict-resolution button:has-text("Apply")',
);
}
/**
* Configure SuperSync with server URL and access token.
* Uses right-click to open settings dialog (works even when sync is already configured).
*/
async setupSuperSync(config: SuperSyncConfig): Promise<void> {
// Wait for sync button to be ready first
// The sync button depends on globalConfig being loaded (isSyncIconEnabled),
// which can take time after initial app load. Use longer timeout and retry.
const syncBtnTimeout = 30000;
try {
await this.syncBtn.waitFor({ state: 'visible', timeout: syncBtnTimeout });
} catch {
// If sync button not visible, the app might not be fully loaded
// Wait a bit more and try once more
console.log('[SuperSyncPage] Sync button not found initially, waiting longer...');
await this.page.waitForTimeout(2000);
await this.syncBtn.waitFor({ state: 'visible', timeout: syncBtnTimeout });
}
// Use right-click to always open sync settings dialog
// (left-click triggers sync if already configured)
await this.syncBtn.click({ button: 'right' });
// Wait for dialog to be fully loaded
await this.providerSelect.waitFor({ state: 'visible', timeout: 10000 });
// Additional wait for the element to be stable/interactive
await this.page.waitForTimeout(300);
// Retry loop for opening the dropdown
let dropdownOpen = false;
const superSyncOption = this.page
.locator('mat-option')
.filter({ hasText: 'SuperSync' });
for (let i = 0; i < 5; i++) {
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
await this.page.keyboard.press('Escape');
await this.page.waitForTimeout(300);
}
} catch (e) {
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) {
// 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();
// Wait for the dropdown overlay to close
await this.page.locator('.mat-mdc-select-panel').waitFor({ state: 'detached' });
// Fill configuration
await this.baseUrlInput.waitFor({ state: 'visible' });
await this.baseUrlInput.fill(config.baseUrl);
await this.accessTokenInput.fill(config.accessToken);
// Handle Encryption
if (config.isEncryptionEnabled) {
// Check if already checked (mat-checkbox structure)
// We check the native input checked state
const isChecked = await this.encryptionCheckbox.isChecked();
if (!isChecked) {
// Click the custom checkbox touch target, force true to bypass internal input interception
await this.page
.locator('.e2e-isEncryptionEnabled .mat-mdc-checkbox-touch-target')
.click({ force: true });
// Verify it got checked
await expect(this.encryptionCheckbox).toBeChecked({ timeout: 2000 });
}
if (config.password) {
await this.encryptionPasswordInput.waitFor({ state: 'visible' });
await this.encryptionPasswordInput.fill(config.password);
await this.encryptionPasswordInput.blur();
}
} else {
// Ensure it is unchecked if config says disabled
const isChecked = await this.encryptionCheckbox.isChecked();
if (isChecked) {
await this.page
.locator('.e2e-isEncryptionEnabled .mat-mdc-checkbox-touch-target')
.click({ force: true });
await expect(this.encryptionCheckbox).not.toBeChecked({ timeout: 2000 });
}
}
// Save - use a robust click that handles element detachment during dialog close
// The dialog may close and navigation may start before click completes
try {
// Wait for button to be stable before clicking
await this.saveBtn.waitFor({ state: 'visible', timeout: 5000 });
await this.page.waitForTimeout(100); // Brief settle
// Click and don't wait for navigation to complete - just initiate the action
await Promise.race([
this.saveBtn.click({ timeout: 5000 }),
// If dialog closes quickly, the click may fail - that's OK if dialog is gone
this.page
.locator('mat-dialog-container')
.waitFor({ state: 'detached', timeout: 5000 }),
]);
} catch (e) {
// If click failed but dialog is already closed, that's fine
const dialogStillOpen = await this.page
.locator('mat-dialog-container')
.isVisible()
.catch(() => false);
if (dialogStillOpen) {
// Dialog still open - the click actually failed
throw e;
}
// Dialog closed - click worked or was unnecessary
console.log('[SuperSyncPage] Dialog closed (click may have been interrupted)');
}
// Wait for dialog to fully close
await this.page
.locator('mat-dialog-container')
.waitFor({ state: 'detached', timeout: 5000 })
.catch(() => {});
// Check if sync starts automatically (it should if enabled)
try {
await this.syncSpinner.waitFor({ state: 'visible', timeout: 5000 });
console.log(
'[SuperSyncPage] Initial sync started automatically, waiting for completion...',
);
await this.waitForSyncComplete();
} catch (e) {
// No auto-sync, that's fine
}
}
/**
* Trigger a sync operation by clicking the sync button.
*/
async triggerSync(): Promise<void> {
// Wait a bit to ensure any previous internal state is cleared
await this.page.waitForTimeout(1000);
// Check if sync is already running to avoid "Sync already in progress" errors
// If it is, wait for it to finish so we can trigger a fresh sync that includes our latest changes
if (await this.syncSpinner.isVisible()) {
console.log(
'[SuperSyncPage] Sync already in progress, waiting for it to finish...',
);
await this.syncSpinner.waitFor({ state: 'hidden', timeout: 30000 }).catch(() => {
console.log(
'[SuperSyncPage] Warning: Timed out waiting for previous sync to finish',
);
});
// Add a small buffer after spinner disappears
await this.page.waitForTimeout(500);
}
// Use force:true to bypass any tooltip overlays that might be in the way
await this.syncBtn.click({ force: true });
// Wait for sync to start or complete immediately
await Promise.race([
this.syncSpinner.waitFor({ state: 'visible', timeout: 5000 }).catch(() => {}),
this.syncCheckIcon.waitFor({ state: 'visible', timeout: 5000 }).catch(() => {}),
]);
}
/**
* Wait for sync to complete (spinner gone, no error).
* Automatically handles sync dialogs:
* - Fresh client confirmation dialog
* - Conflict resolution dialog (uses remote by default)
*/
async waitForSyncComplete(timeout = 30000): Promise<void> {
const startTime = Date.now();
let stableCount = 0; // Count consecutive checks where sync appears complete
// Poll for completion while handling dialogs
while (Date.now() - startTime < timeout) {
// 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);
stableCount = 0;
continue;
}
// Check if conflict resolution dialog appeared
if (await this.conflictDialog.isVisible()) {
console.log('[SuperSyncPage] Conflict dialog detected, using remote...');
await this.conflictUseRemoteBtn.click();
// Wait for selection to be applied and Apply to be enabled
await this.page.waitForTimeout(500);
// Wait for Apply button to be enabled (with retry)
// Increase retries to allow for processing time (50 * 200ms = 10s)
for (let i = 0; i < 50; i++) {
// If dialog closed unexpectedly, break loop
if (!(await this.conflictDialog.isVisible())) {
break;
}
// Check if enabled with short timeout to avoid long waits if element missing
const isEnabled = await this.conflictApplyBtn
.isEnabled({ timeout: 1000 })
.catch(() => false);
if (isEnabled) {
console.log('[SuperSyncPage] Clicking Apply to apply resolution...');
await this.conflictApplyBtn.click();
break;
}
await this.page.waitForTimeout(200);
}
// Wait for dialog to close
await this.conflictDialog
.waitFor({ state: 'hidden', timeout: 5000 })
.catch(() => {});
await this.page.waitForTimeout(500);
stableCount = 0;
continue;
}
// Check if sync is complete
const isSpinning = await this.syncSpinner.isVisible();
if (!isSpinning) {
// Check for error state first
const hasError = await this.syncErrorIcon.isVisible();
if (hasError) {
// Check for error snackbar - only treat as error if it contains actual error keywords
const errorSnackbar = this.page.locator(
'simple-snack-bar, .mat-mdc-snack-bar-container',
);
const snackbarText = await errorSnackbar.textContent().catch(() => '');
const snackbarLower = (snackbarText || '').toLowerCase();
// Only throw if this looks like a real sync error, not an informational message
// Informational messages include: "Deleted task X Undo", "addCreated task X", etc.
// Rate limit errors (429) are transient - the app retries automatically
const isRateLimitError =
snackbarLower.includes('rate limit') ||
snackbarLower.includes('429') ||
snackbarLower.includes('retry in');
const isRealError =
(snackbarLower.includes('error') ||
snackbarLower.includes('failed') ||
snackbarLower.includes('problem') ||
snackbarLower.includes('could not') ||
snackbarLower.includes('unable to')) &&
!isRateLimitError;
if (isRealError) {
throw new Error(`Sync failed: ${snackbarText?.trim() || 'Server error'}`);
}
// If rate limited, wait for the retry (app handles this automatically)
if (isRateLimitError) {
console.log('[SuperSyncPage] Rate limited, waiting for automatic retry...');
stableCount = 0;
await this.page.waitForTimeout(1000);
continue;
}
// Not a real error, just an informational snackbar - continue checking
}
// Sync finished - check icon may appear briefly or not at all
const checkVisible = await this.syncCheckIcon.isVisible();
if (checkVisible) {
return; // Sync complete with check icon
}
// No spinner, no error - sync likely complete
// Wait for stable state (3 consecutive checks) to confirm
stableCount++;
if (stableCount >= 3) {
console.log('[SuperSyncPage] Sync complete (no spinner, no error)');
return;
}
await this.page.waitForTimeout(300);
continue;
}
// Still spinning - reset stable count
stableCount = 0;
await this.page.waitForTimeout(200);
}
throw new Error(`Sync did not complete within ${timeout}ms`);
}
/**
* Check if sync resulted in an error.
*/
async hasSyncError(): Promise<boolean> {
return this.syncErrorIcon.isVisible();
}
/**
* Perform a full sync and wait for completion.
* Includes a settling delay to let UI update after sync.
*/
async syncAndWait(): Promise<void> {
await this.triggerSync();
await this.waitForSyncComplete();
// Allow UI to settle after sync - reduces flakiness
await this.page.waitForTimeout(300);
}
}