From 7164a5e44b54b9d85504d729861560a7c60ea4c8 Mon Sep 17 00:00:00 2001 From: Johannes Millan Date: Wed, 4 Mar 2026 15:30:37 +0100 Subject: [PATCH] test(e2e): fix encryption button timing and drag-handle strict mode violation Two root causes for failing E2E tests in CI: 1. Encryption button not visible after provider selection: The async provider change listener loads config from IndexedDB and updates the Formly model, but the 1000ms waitForTimeout was insufficient in CI. Replace with polling (200ms intervals, 10s timeout) that waits for either enable or disable encryption button to become visible. Also removes ~130 lines of leftover debug logging from disableEncryption(). 2. Drag-handle strict mode violation on parent tasks with subtasks: task.locator('.drag-handle') matched both the parent's drag handle and nested subtask drag handles. Add .first() to all drag-handle locator usages to target only the parent task's own handle. --- e2e/pages/supersync.page.ts | 214 +++++------------- e2e/pages/work-view.page.ts | 3 +- .../time-tracking-feature.spec.ts | 2 +- e2e/tests/sync/supersync-edge-cases.spec.ts | 2 +- .../task-delete-confirmation.spec.ts | 2 +- e2e/tests/task-dragdrop/task-dragdrop.spec.ts | 4 +- e2e/utils/supersync-helpers.ts | 3 +- 7 files changed, 64 insertions(+), 166 deletions(-) diff --git a/e2e/pages/supersync.page.ts b/e2e/pages/supersync.page.ts index b6d1bcccbe..972ba5a094 100644 --- a/e2e/pages/supersync.page.ts +++ b/e2e/pages/supersync.page.ts @@ -907,13 +907,12 @@ export class SuperSyncPage extends BasePage { // Without this, the form shows default/empty values instead of the actual current state await this.selectSuperSyncProviderWithRetry(); - // IMPORTANT: Wait for the provider change listener to complete loading the config - // The listener is async and needs time to load provider config and update the form - await this.page.waitForTimeout(1000); + // Wait for the provider change listener to load config and update the form model. + // The listener is async (loads from IndexedDB) so we poll for the encryption buttons + // instead of using an arbitrary timeout that may be insufficient in CI. + const encBtnResult = await this._waitForEncryptionButtonAfterProviderChange(); - // Check if already enabled (button is visible at top level for SuperSync, not inside Advanced) - const isEnabled = await this.disableEncryptionBtn.isVisible().catch(() => false); - if (isEnabled) { + if (encBtnResult === 'already-enabled') { // Already enabled - just close the dialog const configDialog = this.page.locator('mat-dialog-container').first(); const cancelBtn = configDialog.locator('button').filter({ hasText: /cancel/i }); @@ -922,25 +921,7 @@ export class SuperSyncPage extends BasePage { return; } - // Check if the enable encryption button is visible (for SuperSync it's at the top level) - let enableBtnVisible = await this.enableEncryptionBtn.isVisible().catch(() => false); - - if (!enableBtnVisible) { - // Try expanding "Advanced settings" collapsible - const advancedCollapsible = this.page.locator( - '.collapsible-header:has-text("Advanced")', - ); - const advancedExists = await advancedCollapsible.isVisible().catch(() => false); - if (advancedExists) { - await advancedCollapsible.click(); - enableBtnVisible = await this.enableEncryptionBtn - .waitFor({ state: 'visible', timeout: 3000 }) - .then(() => true) - .catch(() => false); - } - } - - if (!enableBtnVisible) { + if (encBtnResult !== 'enable-visible') { throw new Error( 'Enable encryption button not visible after checking top level and Advanced section', ); @@ -1008,6 +989,51 @@ export class SuperSyncPage extends BasePage { await this.ensureOverlaysClosed(); } + /** + * Poll for the encryption button state after provider selection. + * The provider change listener asynchronously loads config from IndexedDB + * and updates the Formly model, which controls button visibility. + * Returns 'enable-visible', 'already-enabled', or 'not-found'. + */ + private async _waitForEncryptionButtonAfterProviderChange(): Promise< + 'enable-visible' | 'already-enabled' | 'not-found' + > { + const POLL_TIMEOUT = 10000; + const POLL_INTERVAL = 200; + const deadline = Date.now() + POLL_TIMEOUT; + + while (Date.now() < deadline) { + // Check if disable button is visible (encryption already enabled) + if (await this.disableEncryptionBtn.isVisible().catch(() => false)) { + return 'already-enabled'; + } + + // Check if enable button is visible at top level (SuperSync placement) + if (await this.enableEncryptionBtn.isVisible().catch(() => false)) { + return 'enable-visible'; + } + + await this.page.waitForTimeout(POLL_INTERVAL); + } + + // Last resort: try expanding Advanced section + const advancedCollapsible = this.page.locator( + '.collapsible-header:has-text("Advanced")', + ); + if (await advancedCollapsible.isVisible().catch(() => false)) { + await advancedCollapsible.click(); + const found = await this.enableEncryptionBtn + .waitFor({ state: 'visible', timeout: 3000 }) + .then(() => true) + .catch(() => false); + if (found) { + return 'enable-visible'; + } + } + + return 'not-found'; + } + /** * Wait for the clean slate sync to complete after enabling/disabling encryption. */ @@ -1038,141 +1064,11 @@ export class SuperSyncPage extends BasePage { // Without this, the form shows default/empty values instead of the actual current state await this.selectSuperSyncProviderWithRetry(); - // IMPORTANT: Wait for the provider change listener to complete loading the config - // The listener is async and needs time to load provider config and update the form - await this.page.waitForTimeout(1000); + // Wait for the provider change listener to load config and update the form model. + // Poll for the disable encryption button which appears when isEncryptionEnabled is true. + const encBtnResult = await this._waitForEncryptionButtonAfterProviderChange(); - // Expand "Advanced settings" collapsible to access encryption fields - const advancedCollapsible = this.page.locator( - '.collapsible-header:has-text("Advanced")', - ); - - // Debug: Check if Advanced collapsible exists - const advancedExists = await advancedCollapsible.count(); - console.log(`[DisableEncryption] Advanced collapsible count: ${advancedExists}`); - - // Debug: Get all collapsible headers - const allCollapsibles = await this.page - .locator('.collapsible-header') - .allTextContents(); - console.log( - `[DisableEncryption] All collapsible headers: ${JSON.stringify(allCollapsibles)}`, - ); - - await advancedCollapsible.waitFor({ state: 'visible', timeout: 5000 }); - - // Check if already expanded - const isExpanded = await this.disableEncryptionBtn.isVisible().catch(() => false); - console.log(`[DisableEncryption] isExpanded before click: ${isExpanded}`); - if (!isExpanded) { - // Debug: Check if collapsible is clickable - const isClickable = await advancedCollapsible.isEnabled().catch(() => false); - console.log(`[DisableEncryption] Collapsible isEnabled: ${isClickable}`); - - // Scroll the collapsible into view - await advancedCollapsible.scrollIntoViewIfNeeded(); - await this.page.waitForTimeout(300); - - // Try clicking multiple times with different methods - console.log(`[DisableEncryption] Attempting click on collapsible...`); - - // Method 1: Regular click with noWaitAfter to prevent navigation blocking - await advancedCollapsible.click({ noWaitAfter: true }); - await this.page.waitForTimeout(500); - - // Check if expanded - let expandedAfterClick1 = - (await this.page.locator('formly-collapsible.isExpanded').count()) > 0; - console.log(`[DisableEncryption] After click 1: isExpanded=${expandedAfterClick1}`); - - if (!expandedAfterClick1) { - // Method 2: Click with force and noWaitAfter - await advancedCollapsible.click({ force: true, noWaitAfter: true }); - await this.page.waitForTimeout(500); - expandedAfterClick1 = - (await this.page.locator('formly-collapsible.isExpanded').count()) > 0; - console.log( - `[DisableEncryption] After click 2 (force): isExpanded=${expandedAfterClick1}`, - ); - } - - if (!expandedAfterClick1) { - // Method 3: Use JavaScript to click - await advancedCollapsible.evaluate((el) => (el as HTMLElement).click()); - await this.page.waitForTimeout(500); - expandedAfterClick1 = - (await this.page.locator('formly-collapsible.isExpanded').count()) > 0; - console.log( - `[DisableEncryption] After click 3 (JS): isExpanded=${expandedAfterClick1}`, - ); - } - - // Debug: Check collapsible state after click - const collapsibleText = await advancedCollapsible - .textContent() - .catch(() => 'unknown'); - console.log(`[DisableEncryption] Collapsible text after click: ${collapsibleText}`); - - // Debug: Check what buttons are visible in the Advanced section - const enableBtn = await this.enableEncryptionBtn.isVisible().catch(() => false); - const disableBtn = await this.disableEncryptionBtn.isVisible().catch(() => false); - const changeBtn = await this.page - .locator('button:has-text("Change Password")') - .isVisible() - .catch(() => false); - - // Debug: Check if the Advanced section content is visible at all - const advancedContent = await this.page - .locator('.collapsible-content') - .first() - .isVisible() - .catch(() => false); - const encryptKeyInput = await this.encryptionPasswordInput - .isVisible() - .catch(() => false); - - // Debug: Check the selected provider - const selectedProvider = await this.providerSelect - .textContent() - .catch(() => 'unknown'); - - // Debug: Check if there are any formly fields inside the collapsible - const formlyFieldsInCollapsible = await this.page - .locator('.collapsible-content formly-field') - .count(); - - // Debug: Check if there are any hidden formly fields - const hiddenFormlyFields = await this.page - .locator('.collapsible-content formly-field[hidden]') - .count(); - - // Debug: Check if the collapsible panel exists (expanded state) - const collapsiblePanel = await this.page.locator('.collapsible-panel').count(); - - // Debug: Check if formly-collapsible has isExpanded class - const isCollapsibleExpanded = await this.page - .locator('formly-collapsible.isExpanded') - .count(); - - console.log('[DisableEncryption] After expand', { - enableBtn, - disableBtn, - changeBtn, - advancedContent, - encryptKeyInput, - selectedProvider, - formlyFieldsInCollapsible, - hiddenFormlyFields, - collapsiblePanel, - isCollapsibleExpanded, - }); - - await this.disableEncryptionBtn.waitFor({ state: 'visible', timeout: 3000 }); - } - - // Check if already disabled - const isEnabled = await this.disableEncryptionBtn.isVisible().catch(() => false); - if (isEnabled) { + if (encBtnResult === 'already-enabled') { await this.disableEncryptionBtn.click(); // IMPORTANT: The "Disable Encryption?" confirmation dialog appears immediately diff --git a/e2e/pages/work-view.page.ts b/e2e/pages/work-view.page.ts index 95c6b7890a..7beff0863a 100644 --- a/e2e/pages/work-view.page.ts +++ b/e2e/pages/work-view.page.ts @@ -49,7 +49,8 @@ export class WorkViewPage extends BasePage { await task.waitFor({ state: 'visible' }); // Click the drag handle to focus the task (avoids opening edit mode on task-title) - const dragHandle = task.locator('.drag-handle'); + // Use .first() because parent tasks contain subtask elements which also have .drag-handle + const dragHandle = task.locator('.drag-handle').first(); await dragHandle.waitFor({ state: 'visible', timeout: 5000 }); await dragHandle.click(); await this.page.waitForTimeout(200); diff --git a/e2e/tests/app-features/time-tracking-feature.spec.ts b/e2e/tests/app-features/time-tracking-feature.spec.ts index 654f44729e..91b445b526 100644 --- a/e2e/tests/app-features/time-tracking-feature.spec.ts +++ b/e2e/tests/app-features/time-tracking-feature.spec.ts @@ -6,7 +6,7 @@ test.describe('App Features - Time Tracking', () => { // elements on main page const workViewPage = new WorkViewPage(page, testPrefix); const firstTask = page.locator('task').first(); - const firstTaskHandle = firstTask.locator('.drag-handle'); + const firstTaskHandle = firstTask.locator('.drag-handle').first(); const taskPlayButton = page .locator('task') .getByRole('button', { name: 'Start tracking time' }); diff --git a/e2e/tests/sync/supersync-edge-cases.spec.ts b/e2e/tests/sync/supersync-edge-cases.spec.ts index 4651c90176..92f97cceb8 100644 --- a/e2e/tests/sync/supersync-edge-cases.spec.ts +++ b/e2e/tests/sync/supersync-edge-cases.spec.ts @@ -645,7 +645,7 @@ test.describe('@supersync SuperSync Edge Cases', () => { const taskEl = clientA.page .locator(`task:not(.ng-animating):has-text("${taskName}")`) .first(); - await taskEl.locator('.drag-handle').click(); + await taskEl.locator('.drag-handle').first().click(); await clientA.page.keyboard.press('Backspace'); // 4. Handle the optional confirmation dialog, then click Undo. diff --git a/e2e/tests/task-basic/task-delete-confirmation.spec.ts b/e2e/tests/task-basic/task-delete-confirmation.spec.ts index 9cc712ac29..f4e082fa8d 100644 --- a/e2e/tests/task-basic/task-delete-confirmation.spec.ts +++ b/e2e/tests/task-basic/task-delete-confirmation.spec.ts @@ -89,7 +89,7 @@ test.describe('Task Delete Confirmation', () => { // Focus the task by clicking on drag-handle (not task-title which would open edit mode) const task = page.locator('task').first(); - const dragHandle = task.locator('.drag-handle'); + const dragHandle = task.locator('.drag-handle').first(); await dragHandle.waitFor({ state: 'visible', timeout: 5000 }); await dragHandle.click(); // Wait for focus to settle - the task component needs time to process the click diff --git a/e2e/tests/task-dragdrop/task-dragdrop.spec.ts b/e2e/tests/task-dragdrop/task-dragdrop.spec.ts index ddc2df0bf4..c598e33709 100644 --- a/e2e/tests/task-dragdrop/task-dragdrop.spec.ts +++ b/e2e/tests/task-dragdrop/task-dragdrop.spec.ts @@ -57,7 +57,7 @@ test.describe('Drag Task to change project and labels', () => { // find drag handle of task const firstTask = page.locator('task').first(); - const dragHandle = firstTask.locator('.drag-handle'); + const dragHandle = firstTask.locator('.drag-handle').first(); const tagList = firstTask.locator('tag-list'); // Drag and drop to first project - wait for tag to appear @@ -131,7 +131,7 @@ test.describe('Drag Task to change project and labels', () => { // find drag handle of task const firstTask = page.locator('task').first(); - const dragHandle = firstTask.locator('.drag-handle'); + const dragHandle = firstTask.locator('.drag-handle').first(); const tagList = firstTask.locator('tag-list'); // Drag and drop to first tag - wait for tag to appear diff --git a/e2e/utils/supersync-helpers.ts b/e2e/utils/supersync-helpers.ts index 76b7e40a8a..aff2ddedb2 100644 --- a/e2e/utils/supersync-helpers.ts +++ b/e2e/utils/supersync-helpers.ts @@ -539,7 +539,8 @@ export const deleteTask = async ( // Click the drag-handle to focus the task without entering title edit mode. // Clicking the task body can land on the task-title, opening the textarea editor, // which causes Backspace to delete text instead of triggering the delete shortcut. - const dragHandle = task.locator('.drag-handle'); + // Use .first() because parent tasks contain subtask elements which also have .drag-handle + const dragHandle = task.locator('.drag-handle').first(); await dragHandle.click(); await client.page.keyboard.press('Backspace');