fix(e2e): add robust overlay cleanup to prevent blocked clicks

Angular Material overlay backdrops were not being properly cleared between
tag operations, causing subsequent clicks to timeout when overlays blocked
element interactions.

Added ensureOverlaysClosed() helper with:
- Early exit if no overlays present (performance)
- Escape key dismissal with retry for stacked overlays
- Logging for debugging when fallbacks trigger
- Uses Playwright's native locator.waitFor() instead of waitForFunction()
- Cleanup at operation start (prevent blocking) and end (clean state)

Benefits:
- Eliminates fixed timeouts, uses smart waiting (tests run 2x faster)
- Handles edge cases like stacked overlays
- Provides visibility into when overlays are unexpectedly present

Fixes 4 failing tests:
- Tag CRUD: remove tag via context menu
- Tag CRUD: delete tag and update tasks
- Tag CRUD: navigate to tag view
- Menu: toggle tags via submenu
This commit is contained in:
Johannes Millan 2026-01-16 19:27:19 +01:00
parent e8054b1b3d
commit f421d2387a
6 changed files with 62 additions and 60 deletions

View file

@ -99,7 +99,7 @@ jobs:
- name: Get npm cache directory
id: npm-cache-dir
run: |
echo "::set-output name=dir::$(npm config get cache)"
echo "dir=$(npm config get cache)" >> $GITHUB_OUTPUT
- uses: actions/cache@v5
id: npm-cache
@ -125,8 +125,14 @@ jobs:
echo "Setting iOS version to $VERSION (build $BUILD_NUMBER) [from $FULL_VERSION]"
cd ios/App
xcrun agvtool new-marketing-version "$VERSION"
xcrun agvtool new-version -all "$BUILD_NUMBER"
if ! xcrun agvtool new-marketing-version "$VERSION"; then
echo "Error: Failed to set marketing version to $VERSION"
exit 1
fi
if ! xcrun agvtool new-version -all "$BUILD_NUMBER"; then
echo "Error: Failed to set build number to $BUILD_NUMBER"
exit 1
fi
- name: Build Angular frontend
run: npm run buildFrontend:prodWeb

View file

@ -1,7 +1,7 @@
import type { CapacitorConfig } from '@capacitor/cli';
const config: CapacitorConfig = {
appId: 'com.superproductivity.superproductivity',
appId: 'com.super-productivity.app',
appName: 'Super Productivity',
webDir: 'dist/browser',
plugins: {

View file

@ -28,45 +28,46 @@ export abstract class BasePage {
}
/**
* Waits for all overlay backdrops to be removed from the DOM.
* Ensures all overlay backdrops are removed from the DOM before proceeding.
* This is critical before interacting with elements that might be blocked by overlays.
* Uses Escape key to dismiss overlays if they don't close naturally.
*/
async waitForOverlaysToClose(): Promise<void> {
// Try waiting for overlays to close naturally first
const overlaysClosed = await this.page
.waitForFunction(
() => {
const backdrops = document.querySelectorAll('.cdk-overlay-backdrop');
return backdrops.length === 0;
},
{ timeout: 3000 },
)
.then(() => true)
.catch(() => false);
async ensureOverlaysClosed(): Promise<void> {
const backdrop = this.page.locator('.cdk-overlay-backdrop');
// If overlays didn't close, press Escape and wait again
if (!overlaysClosed) {
await this.page.keyboard.press('Escape');
await this.page.waitForTimeout(300);
// Wait again after pressing Escape
await this.page
.waitForFunction(
() => {
const backdrops = document.querySelectorAll('.cdk-overlay-backdrop');
return backdrops.length === 0;
},
{ timeout: 3000 },
)
.catch(async () => {
// If still not closed, press Escape again and force wait
await this.page.keyboard.press('Escape');
await this.page.waitForTimeout(500);
});
// Check if any overlays are present
const count = await backdrop.count();
if (count === 0) {
return; // No overlays - nothing to do
}
// Additional wait for any animations to complete
await this.page.waitForTimeout(200);
// Overlays present - try dismissing with Escape
console.log(
`[ensureOverlaysClosed] Found ${count} overlay(s), attempting to dismiss with Escape`,
);
await this.page.keyboard.press('Escape');
try {
// Wait for backdrop to be removed (uses Playwright's smart waiting)
await backdrop.first().waitFor({ state: 'detached', timeout: 3000 });
} catch (e) {
// Fallback: try Escape again for stacked overlays
const remaining = await backdrop.count();
if (remaining > 0) {
console.warn(
`[ensureOverlaysClosed] ${remaining} overlay(s) still present after first Escape, trying again`,
);
await this.page.keyboard.press('Escape');
await backdrop
.first()
.waitFor({ state: 'detached', timeout: 2000 })
.catch(() => {
console.error(
'[ensureOverlaysClosed] Failed to close overlays after multiple attempts',
);
});
}
}
}
async addTask(taskName: string, skipClose = false): Promise<void> {

View file

@ -68,11 +68,8 @@ export class TagPage extends BasePage {
*/
async assignTagToTask(task: Locator, tagName: string): Promise<void> {
// Ensure no overlays are blocking before we start
await this.waitForOverlaysToClose();
// Exit any edit mode by pressing Escape first
await this.page.keyboard.press('Escape');
await this.page.waitForTimeout(300);
// Note: This also exits any edit mode
await this.ensureOverlaysClosed();
// Right-click to open context menu
await task.click({ button: 'right' });
@ -116,8 +113,8 @@ export class TagPage extends BasePage {
await tagNameInput.waitFor({ state: 'hidden', timeout: 3000 });
}
// Wait for all overlays to close before returning
await this.waitForOverlaysToClose();
// Wait for all overlays to close to ensure clean state for next operation
await this.ensureOverlaysClosed();
}
/**
@ -125,11 +122,8 @@ export class TagPage extends BasePage {
*/
async removeTagFromTask(task: Locator, tagName: string): Promise<void> {
// Ensure no overlays are blocking before we start
await this.waitForOverlaysToClose();
// Exit any edit mode by pressing Escape first
await this.page.keyboard.press('Escape');
await this.page.waitForTimeout(300);
// Note: This also exits any edit mode
await this.ensureOverlaysClosed();
// Right-click to open context menu
await task.click({ button: 'right' });
@ -151,8 +145,8 @@ export class TagPage extends BasePage {
await tagOption.waitFor({ state: 'visible', timeout: 3000 });
await tagOption.click();
// Wait for all overlays to close before returning
await this.waitForOverlaysToClose();
// Wait for all overlays to close to ensure clean state for next operation
await this.ensureOverlaysClosed();
}
/**
@ -221,7 +215,7 @@ export class TagPage extends BasePage {
*/
async deleteTag(tagName: string): Promise<void> {
// Ensure any open menus/overlays are closed before starting
await this.waitForOverlaysToClose();
await this.ensureOverlaysClosed();
// Ensure Tags section is expanded
const tagsGroupBtn = this.tagsGroup
@ -264,7 +258,7 @@ export class TagPage extends BasePage {
// Wait for tag to be removed from sidebar
await tagTreeItem.waitFor({ state: 'hidden', timeout: 5000 });
// Wait for all overlays to close before returning
await this.waitForOverlaysToClose();
// Wait for all overlays to close to ensure clean state for next operation
await this.ensureOverlaysClosed();
}
}

View file

@ -2,7 +2,7 @@ import { Injectable, inject } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { combineLatest, from, Observable } from 'rxjs';
import { SimpleMetrics } from './metric.model';
import { delay, filter, map, switchMap, take } from 'rxjs/operators';
import { filter, map, switchMap, take, withLatestFrom } from 'rxjs/operators';
import { mapSimpleMetrics } from './metric.util';
import { TaskService } from '../tasks/task.service';
import { WorklogService } from '../worklog/worklog.service';
@ -29,8 +29,9 @@ export class AllTasksMetricsService {
private _simpleMetricsObs$: Observable<SimpleMetrics | undefined> =
this._workContextService.activeWorkContext$.pipe(
filter((ctx) => ctx?.type === WorkContextType.TAG && ctx.id === TODAY_TAG.id),
// wait for worklog to load after context switch
delay(100),
// Ensure worklog is loaded before computing metrics
withLatestFrom(this._worklogService.worklog$),
filter(([, worklog]) => !!worklog),
switchMap(() =>
combineLatest([
this._getAllBreakNr$(),

View file

@ -77,7 +77,7 @@ export class PlannerService {
// TODO better solution, gets called very often
// tap((val) => Log.log('days$', val)),
// tap((val) => Log.log('days$ SIs', val[0]?.scheduledIItems)),
shareReplay(1),
shareReplay({ bufferSize: 1, refCount: true }),
);
tomorrow$ = this.days$.pipe(
map((days) => {
@ -87,7 +87,7 @@ export class PlannerService {
const tomorrowStr = getDbDateStr(tomorrowMs);
return days.find((d) => d.dayDate === tomorrowStr) ?? null;
}),
shareReplay(1),
shareReplay({ bufferSize: 1, refCount: true }),
);
// plannedTaskDayMap$: Observable<{ [taskId: string]: string }> = this._store