super-productivity/e2e/utils/supersync-helpers.ts
Johannes Millan 01e30b9c7e
Feat/to me it looks like there are lots of 60dd04 (#7280)
* fix(issue): prevent crash from orphan issueProviderId (#7135)

The Jira image-headers effect in task-detail-panel subscribed to
selectIssueProviderById without an error handler, so a task with an
issueProviderId pointing at a deleted provider (e.g. after sync
convergence where taskIdsToUnlink didn't cover all local tasks)
propagated the selector throw to Zone.js as a crash dialog. Wrap the
inner selector observable in catchError that logs and falls back to
of(null); the downstream jiraCfg?.isEnabled guard handles the fallback.

Also drop IssueLog.log(issueProviderKey, issueProvider) from the
throwing variant of the selector: providers may carry credentials
(host, token, apiKey) and IssueLog history is exportable.

* fix(focus-mode): sync tray countdown with in-app timer during breaks

Tray title was rebuilt from a cached currentFocusSessionTime that only
refreshed when CURRENT_TASK_UPDATED fired. addTimeSpent is gated on an
active current task, so during focus-mode breaks or task-less focus
sessions the cache froze while the in-app timer kept ticking.

Add the tick action to taskChangeElectron$ so the cache refreshes every
second whenever the focus timer is running.

Fixes #7278

* fix(ci): restore GitHub Actions SHA pins undone by 0e9218bd68

Commit 0e9218bd68 silently reverted PR #7212 (github-actions-minor group
bump) along with its stated sync/client-id work. This restores the 15
workflow files to their pre-revert state.

Actions restored to newer pinned SHAs:
- actions/upload-artifact v7.0.0 -> v7.0.1
- step-security/harden-runner v2.16.1 -> v2.17.0
- softprops/action-gh-release v2.6.1 -> v3.0.0
- signpath/github-action-submit-signing-request v2.0 -> v2.1
- anthropics/claude-code-action v1.0.89 -> v1.0.93
- docker/build-push-action v7.0.0 -> v7.1.0
- easingthemes/ssh-deploy v5.1.1 -> v6.0.3

* fix: restore i18n, UI, and docs work undone by 0e9218bd68

Commit 0e9218bd68 silently reverted the following work alongside its stated
sync/client-id changes. Files where later master commits (fec7b25f23, etc.)
already re-applied the reverted work are intentionally left untouched.

Restored:
- #7232 docs/long-term-plans/location-based-reminders.md (513 lines)
- #7199 Romanian i18n phase 3 (ro.json + ro-md.json, ~1168 lines)
- #7049 Polish translation improvements
- #7143 planner component styling (4 scss files)
- #7211 add-task-bar preserve time estimate when typing title
- #7208 task.reducer roll-up estimates for added subtasks
- #6767 focus-mode pomodoro reset button
- #7205 plugin-dev github-issue-provider TOKEN description
- #7231 mobile-bottom-nav FAB fix
- a4fe03272 iOS keyboard accessory bar (global-theme + dialog-fullscreen-markdown)
- 309670db3 ShortSyntaxEffects undefined guard
- 667a7986f Dropbox PKCE auth comment/behavior

* fix(electron): restore electron + e2e work undone by 0e9218bd68

Commit 0e9218bd68 silently reverted the following electron/e2e work.
Files already re-fixed by later master commits are left as-is:
- e2e/tests/sync/supersync-archive-conflict.spec.ts (de33234976 + 191d129ff3)

Restored:
- e8a3e156eb fix(electron): Linux autostart IDB backing-store recovery
  (re-adds electron/clear-stale-idb-locks.ts + start-app.ts wiring)
- 5ce78a5b63 fix(electron): macOS Cmd+Q / Dock > Quit hang
  (setIsQuiting + before-quit delegate to close-handler)
- 46e0fa2d01 fix(sync): FILE_SYNC_LIST_FILES IPC contract
  (electronAPI.d.ts + local-file-sync + preload + ipc-events)
- ea1ef16307 fix(android): session-only SAF permissions on OEM devices
- 8865dc0a50 test(e2e): supersync parallel-worker stampede guard
  (SUPERSYNC_SERVER_HEALTHY env-var fallback + goto retry loop)
- af7c7687e2 test(e2e): block WS-triggered downloads in non-WS specs
- 265b44db5d test(e2e): premature waitForURL on daily-summary
  (this is literally the fix the bad commit's message claimed to add)

Conflict resolutions:
- e2e/utils/supersync-helpers.ts: kept the refined getDoneTaskElement
  checks from d64014d086 (later than c558bcab5e) while restoring the
  goto retry loop from 8865dc0a50.
- electron/start-app.ts: unioned imports (setIsQuiting +
  clearStaleLevelDbLocks from theirs, fs from ours).

* fix(sync): restore sync-core work undone by 0e9218bd68

Commit 0e9218bd68 silently reverted parts of several sync fixes. Most
sync-core reverts have already been re-addressed differently on master
by later commits (1f5184f6e7, 05cd875dd6, 09f5ced2c9, 7df43358ab,
d9158d6adb, 32dbc95ed9, 8c3b08e016, f89fe1ebc3) — those files are
intentionally left untouched to avoid reverting master's newer work.

This PR restores only the pieces that are genuinely still missing:

- e8a3e156eb fix(electron): IDB backing-store autoreload (in-app piece)
  operation-log-hydrator.service.ts + .spec.ts (the electron/clear-
  stale-idb-locks.ts piece was restored in the prior commit)

Plus three low-risk documentation/cleanup restorations:
- operation-sync.util.ts — add "Nextcloud" to isFileBasedProvider JSDoc
- dropbox.ts — restore improved _getRedirectUri JSDoc (667a7986fb)
- dialog-get-and-enter-auth-code.component.ts — restore isNativePlatform
  comment explaining why manual code entry flow is used (667a7986fb)
- file-adapter.interface.ts — remove stray "// NEW" comment (46e0fa2d01)

Intentionally NOT restored (master's newer work covers or supersedes):
- sync-trigger.service.ts / sync.effects.ts (05cd875dd6)
- sync-wrapper.service.ts (1f5184f6e7)
- sync-errors.ts (1f5184f6e7 re-added LegacySyncFormatDetectedError)
- file-based-sync-adapter.service.ts + spec (1f5184f6e7 + d9158d6adb)
- file-based-sync.types.ts (1f5184f6e7)
- operation-log.const.ts (32dbc95ed9 bumped IDB_OPEN_RETRIES to 5)
- dialog-sync-initial-cfg.component.ts (f89fe1ebc3)
2026-04-20 12:04:38 +02:00

1108 lines
36 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {
type Browser,
type BrowserContext,
type Locator,
type Page,
expect,
} from '@playwright/test';
import { SuperSyncPage, type SuperSyncConfig } from '../pages/supersync.page';
import { WorkViewPage } from '../pages/work-view.page';
import { waitForAppReady } from './waits';
import {
HEALTH_CHECK_TIMEOUT,
API_REQUEST_TIMEOUT,
UI_VISIBLE_TIMEOUT,
UI_VISIBLE_TIMEOUT_LONG,
UI_SETTLE_SMALL,
UI_SETTLE_MEDIUM,
UI_SETTLE_STANDARD,
UI_SETTLE_EXTENDED,
RETRY_BASE_DELAY,
TASK_POLL_INTERVAL,
TASK_WAIT_TIMEOUT,
UI_VISIBLE_TIMEOUT_SHORT,
} from './e2e-constants';
/**
* SuperSync server URL for E2E tests.
* Server must be running with TEST_MODE=true.
* Defaults to port 1901 for e2e tests (dev server uses 1900).
*/
export const SUPERSYNC_BASE_URL =
process.env.SUPERSYNC_E2E_URL || 'http://localhost:1901';
/**
* Test user credentials returned from the server.
*/
export interface TestUser {
email: string;
token: string;
userId: number;
}
/**
* A simulated client for E2E sync tests.
* Wraps a browser context, page, and page objects.
*/
export interface SimulatedE2EClient {
context: BrowserContext;
page: Page;
workView: WorkViewPage;
sync: SuperSyncPage;
clientName: string;
}
/**
* Create a test user on the SuperSync server.
* Requires server to be running with TEST_MODE=true.
*
* @param testId - Unique test identifier for user email
* @returns Test user with email and JWT token
*/
export const createTestUser = async (
testId: string,
maxRetries = 3,
): Promise<TestUser> => {
const email = `test-${testId}@e2e.local`;
const password = 'TestPassword123!';
const headers = new Headers();
headers.set('Content-Type', 'application/json');
for (let attempt = 0; attempt < maxRetries; attempt++) {
const response = await fetch(`${SUPERSYNC_BASE_URL}/api/test/create-user`, {
method: 'POST',
headers,
body: JSON.stringify({ email, password }),
});
// Handle rate limiting with exponential backoff
if (response.status === 429 && attempt < maxRetries - 1) {
const delay = RETRY_BASE_DELAY * Math.pow(2, attempt);
console.log(`[createTestUser] Rate limited (429), retrying in ${delay}ms...`);
await new Promise((r) => setTimeout(r, delay));
continue;
}
if (!response.ok) {
const text = await response.text();
throw new Error(`Failed to create test user: ${response.status} - ${text}`);
}
const data = await response.json();
return {
email,
token: data.token,
userId: data.userId,
};
}
throw new Error(`Max retries (${maxRetries}) exceeded for createTestUser`);
};
/**
* Clean up all test data on the server.
* Call this in test teardown if needed.
*/
export const cleanupTestData = async (): Promise<void> => {
const response = await fetch(`${SUPERSYNC_BASE_URL}/api/test/cleanup`, {
method: 'POST',
});
if (!response.ok) {
console.warn(`Cleanup failed: ${response.status}`);
}
};
/**
* Delete a specific test user account on the SuperSync server.
* Used to test account deletion and re-registration scenarios.
*
* @param userId - The user ID to delete
*/
export const deleteTestUser = async (userId: number): Promise<void> => {
const response = await fetch(`${SUPERSYNC_BASE_URL}/api/test/user/${userId}`, {
method: 'DELETE',
});
if (!response.ok && response.status !== 404) {
const text = await response.text();
throw new Error(`Failed to delete test user: ${response.status} - ${text}`);
}
};
/**
* Check if the SuperSync server is running, healthy, AND has test mode enabled.
* Tests require TEST_MODE=true on the server for the /api/test/* endpoints.
*/
export const isServerHealthy = async (): Promise<boolean> => {
try {
// First check basic health
const healthResponse = await fetch(`${SUPERSYNC_BASE_URL}/health`, {
method: 'GET',
signal: AbortSignal.timeout(HEALTH_CHECK_TIMEOUT),
});
if (!healthResponse.ok) {
return false;
}
// Then verify test mode is enabled by trying to create a dummy user
// This is the only reliable way to check if test endpoints exist
const headers = new Headers();
headers.set('Content-Type', 'application/json');
const testModeResponse = await fetch(`${SUPERSYNC_BASE_URL}/api/test/create-user`, {
method: 'POST',
headers,
body: JSON.stringify({
email: `health-check-${Date.now()}@test.local`,
password: 'HealthCheck123!',
}),
signal: AbortSignal.timeout(API_REQUEST_TIMEOUT),
});
// If test mode is disabled, the route won't exist (404)
// If test mode is enabled, we'll get 201 (created) or 409 (conflict) or similar
if (testModeResponse.status === 404) {
console.warn('SuperSync server is running but TEST_MODE is not enabled');
return false;
}
return true;
} catch {
return false;
}
};
/**
* Get SuperSync configuration for a test user.
*/
export const getSuperSyncConfig = (user: TestUser): SuperSyncConfig => {
return {
baseUrl: SUPERSYNC_BASE_URL,
accessToken: user.token,
isEncryptionEnabled: true,
password: 'e2e-default-encryption-pw',
};
};
/**
* Create a simulated E2E client with its own isolated browser context.
*
* Each client has:
* - Separate browser context (isolated IndexedDB, localStorage)
* - Unique clientId generated by the app on first load
* - WorkViewPage for task operations
* - SuperSyncPage for sync operations
*
* @param browser - Playwright browser instance
* @param baseURL - App base URL (e.g., http://localhost:4242)
* @param clientName - Human-readable name for debugging (e.g., "A", "B")
* @param testPrefix - Test prefix for task naming
*/
export const createSimulatedClient = async (
browser: Browser,
baseURL: string,
clientName: string,
testPrefix: string,
): Promise<SimulatedE2EClient> => {
// Use provided baseURL or fall back to localhost:4242 (Playwright fixture may be undefined)
const effectiveBaseURL = baseURL || 'http://localhost:4242';
const context = await browser.newContext({
storageState: undefined, // Clean slate - no shared state
userAgent: `PLAYWRIGHT SYNC-CLIENT-${clientName}`,
baseURL: effectiveBaseURL,
viewport: { width: 1920, height: 1080 },
});
const page = await context.newPage();
// Skip onboarding, hints, and example tasks before the app boots.
// This runs before any page JavaScript, so Angular sees the flags immediately.
await page.addInitScript(() => {
localStorage.setItem('SUP_ONBOARDING_PRESET_DONE', 'true');
localStorage.setItem('SUP_ONBOARDING_HINTS_DONE', 'true');
localStorage.setItem('SUP_IS_SHOW_TOUR', 'true');
localStorage.setItem('SUP_EXAMPLE_TASKS_CREATED', 'true');
});
// Set up error logging
page.on('pageerror', (error) => {
console.error(`[Client ${clientName}] Page error:`, error.message);
});
page.on('console', (msg) => {
if (msg.type() === 'error') {
console.error(`[Client ${clientName}] Console error:`, msg.text());
} else if (process.env.E2E_VERBOSE) {
console.log(`[Client ${clientName}] Console ${msg.type()}:`, msg.text());
}
});
// Navigate to app with retry for transient ERR_CONNECTION_REFUSED.
// Under parallel load (many workers × 2-3 browser contexts each), the Angular
// dev server can temporarily refuse connections. Retrying recovers from this
// without failing the test outright.
let lastGotoError: Error | null = null;
for (let attempt = 0; attempt < 3; attempt++) {
try {
await page.goto('/');
lastGotoError = null;
break;
} catch (e) {
lastGotoError = e as Error;
const isConnectionRefused = lastGotoError.message.includes(
'ERR_CONNECTION_REFUSED',
);
if (attempt < 2 && isConnectionRefused) {
const delay = 1000 * (attempt + 1);
console.log(
`[Client ${clientName}] page.goto('/') failed (attempt ${attempt + 1}/3): ERR_CONNECTION_REFUSED — retrying in ${delay}ms`,
);
await new Promise((r) => setTimeout(r, delay));
} else {
break; // Non-connection error or last attempt — let it throw below
}
}
}
if (lastGotoError) throw lastGotoError;
await waitForAppReady(page);
const workView = new WorkViewPage(page, `${clientName}-${testPrefix}`);
const sync = new SuperSyncPage(page);
return {
context,
page,
workView,
sync,
clientName,
};
};
/**
* Close a simulated client and clean up resources.
* Safely handles already-closed contexts.
*/
export const closeClient = async (client: SimulatedE2EClient): Promise<void> => {
try {
// Check if page is still open before trying to close context
if (!client.page.isClosed()) {
// Add timeout wrapper to prevent cleanup from blocking test completion.
// context.close() can hang waiting for trace files to be written.
const closePromise = client.context.close();
let timeoutId: ReturnType<typeof setTimeout>;
const timeoutPromise = new Promise<void>((_, reject) => {
timeoutId = setTimeout(() => reject(new Error('Cleanup timeout')), 5000);
});
// Prevent unhandled rejection if closePromise rejects after timeout wins the race.
// Without this, the abandoned promise rejection causes Playwright to mark the test
// as failed with "error was not a part of any test", masking the real test error.
closePromise.catch(() => {});
try {
await Promise.race([closePromise, timeoutPromise]);
} finally {
clearTimeout(timeoutId!);
}
}
} catch (error) {
// Always ignore cleanup errors - they should never mask the actual test error.
// Common scenarios:
// - Test timeout: Playwright force-closes contexts, cleanup gets "Protocol error"
// - ENOENT: Trace file finalization fails for manually-created contexts
// - Context already closed: Race between test timeout and cleanup
// - Cleanup timeout: context.close() hung waiting for trace artifacts
console.warn(
`[closeClient] Cleanup error (ignored): ${error instanceof Error ? error.message : String(error)}`,
);
}
};
/**
* Wait for a task with given name to appear on the page.
* Uses longer timeout by default for sync operations which can be slow.
* Includes retry logic for better reliability after sync operations.
*/
export const waitForTask = async (
page: Page,
taskName: string,
timeout = TASK_WAIT_TIMEOUT,
): Promise<void> => {
const escapedName = taskName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const startTime = Date.now();
// Retry loop to handle DOM update delays
while (Date.now() - startTime < timeout) {
// Check if page is still open before each iteration
if (page.isClosed()) {
throw new Error(`Page was closed while waiting for task "${taskName}"`);
}
try {
await page.waitForSelector(`task:has-text("${escapedName}")`, {
timeout: UI_VISIBLE_TIMEOUT,
state: 'visible',
});
return; // Success
} catch (error) {
// Check if page is closed before waiting
if (page.isClosed()) {
throw new Error(`Page was closed while waiting for task "${taskName}"`);
}
// Only retry on timeout errors; re-throw other errors (page crashes, etc.)
const isTimeoutError =
error instanceof Error &&
(error.message.includes('Timeout') || error.name === 'TimeoutError');
if (!isTimeoutError) {
throw error;
}
// Wait a bit and retry
await page.waitForTimeout(TASK_POLL_INTERVAL);
}
}
// Final attempt with full remaining timeout
if (page.isClosed()) {
throw new Error(`Page was closed while waiting for task "${taskName}"`);
}
const remaining = Math.max(timeout - (Date.now() - startTime), RETRY_BASE_DELAY);
await page.waitForSelector(`task:has-text("${escapedName}")`, {
timeout: remaining,
state: 'visible',
});
};
/**
* Count tasks matching a pattern on the page.
*/
export const countTasks = async (page: Page, pattern?: string): Promise<number> => {
if (pattern) {
const escapedPattern = pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
return page.locator(`task:has-text("${escapedPattern}")`).count();
}
return page.locator('task').count();
};
/**
* Check if a task exists on the page.
*/
export const hasTask = async (page: Page, taskName: string): Promise<boolean> => {
const escapedName = taskName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const count = await page.locator(`task:has-text("${escapedName}")`).count();
return count > 0;
};
// ============================================================================
// Task Element Helpers
// ============================================================================
/**
* Escape special characters in a string for use in CSS :has-text() selector.
*/
const escapeForSelector = (text: string): string => {
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
};
/**
* Get a task element locator by task name.
* This replaces the common pattern: `client.page.locator(\`task:has-text("${taskName}")\`)`
*
* @param client - The simulated E2E client
* @param taskName - The task name to search for
* @returns Locator for the task element
*/
export const getTaskElement = (client: SimulatedE2EClient, taskName: string): Locator => {
const escapedName = escapeForSelector(taskName);
return client.page.locator(`task:has-text("${escapedName}")`);
};
/**
* Get a task element locator from a page by task name.
* Use this when you have a page but not a client.
*
* @param page - The Playwright page
* @param taskName - The task name to search for
* @returns Locator for the task element
*/
export const getTaskElementFromPage = (page: Page, taskName: string): Locator => {
const escapedName = escapeForSelector(taskName);
return page.locator(`task:has-text("${escapedName}")`);
};
/**
* Get a subtask element (task without subtasks) by name.
* Useful for targeting subtasks without matching their parent.
*
* @param client - The simulated E2E client
* @param taskName - The subtask name to search for
* @returns Locator for the subtask element
*/
export const getSubtaskElement = (
client: SimulatedE2EClient,
taskName: string,
): Locator => {
const escapedName = escapeForSelector(taskName);
return client.page.locator(`task.hasNoSubTasks:has-text("${escapedName}")`);
};
/**
* Get a done task element by name.
*
* @param client - The simulated E2E client
* @param taskName - The task name to search for
* @returns Locator for the done task element
*/
export const getDoneTaskElement = (
client: SimulatedE2EClient,
taskName: string,
): Locator => {
const escapedName = escapeForSelector(taskName);
return client.page.locator(`task.isDone:has-text("${escapedName}")`);
};
/**
* Get a done subtask element by name.
*
* @param client - The simulated E2E client
* @param taskName - The subtask name to search for
* @returns Locator for the done subtask element
*/
export const getDoneSubtaskElement = (
client: SimulatedE2EClient,
taskName: string,
): Locator => {
const escapedName = escapeForSelector(taskName);
return client.page.locator(`task.hasNoSubTasks.isDone:has-text("${escapedName}")`);
};
/**
* Get an undone subtask element by name.
*
* @param client - The simulated E2E client
* @param taskName - The subtask name to search for
* @returns Locator for the undone subtask element
*/
export const getUndoneSubtaskElement = (
client: SimulatedE2EClient,
taskName: string,
): Locator => {
const escapedName = escapeForSelector(taskName);
return client.page.locator(
`task.hasNoSubTasks:not(.isDone):has-text("${escapedName}")`,
);
};
/**
* Get a parent task element (task with subtasks) by name.
*
* @param client - The simulated E2E client
* @param taskName - The task name to search for
* @returns Locator for the parent task element
*/
export const getParentTaskElement = (
client: SimulatedE2EClient,
taskName: string,
): Locator => {
const escapedName = escapeForSelector(taskName);
return client.page.locator(`task:not(.hasNoSubTasks):has-text("${escapedName}")`);
};
// ============================================================================
// Task Action Helpers
// ============================================================================
/**
* Mark a task as done by hovering and clicking the done button.
*
* Waits for the `.isDone` class to appear before returning. `TaskService.toggleDoneWithAnimation`
* schedules the actual `setDone` dispatch via `setTimeout(200ms)` for the mark-done animation,
* so without this wait a following sync can start before the updateTask op is captured — the
* late op then lands during the sync's upload, leaving `hasPendingOps=true` and the check icon
* never appears.
*
* @param client - The simulated E2E client
* @param taskName - The task name to mark as done
*/
export const markTaskDone = async (
client: SimulatedE2EClient,
taskName: string,
): Promise<void> => {
const task = getTaskElement(client, taskName);
await task.hover();
await task.locator('done-toggle').click();
await expect(getDoneTaskElement(client, taskName).first()).toBeVisible({
timeout: UI_VISIBLE_TIMEOUT,
});
};
/**
* Mark a subtask as done by hovering and clicking the done button.
* Uses getSubtaskElement to avoid matching parent tasks.
*
* Waits for `.isDone` for the same reason as `markTaskDone` — the 200ms animation delay in
* `toggleDoneWithAnimation` would otherwise race the following sync and leave pending ops.
*
* @param client - The simulated E2E client
* @param subtaskName - The subtask name to mark as done
*/
export const markSubtaskDone = async (
client: SimulatedE2EClient,
subtaskName: string,
): Promise<void> => {
const subtask = getSubtaskElement(client, subtaskName);
await subtask.hover();
await subtask.locator('done-toggle').click();
await expect(getDoneSubtaskElement(client, subtaskName).first()).toBeVisible({
timeout: UI_VISIBLE_TIMEOUT,
});
};
/**
* Expand a parent task to show its subtasks.
*
* @param client - The simulated E2E client
* @param taskName - The parent task name
*/
export const expandTask = async (
client: SimulatedE2EClient,
taskName: string,
): Promise<void> => {
const task = getTaskElement(client, taskName);
const expandBtn = task.locator('.expand-btn');
if (await expandBtn.isVisible()) {
await expandBtn.click();
}
};
/**
* Delete a task via keyboard shortcut (Backspace) and confirm if dialog appears.
*
* @param client - The simulated E2E client
* @param taskName - The task name to delete
*/
export const deleteTask = async (
client: SimulatedE2EClient,
taskName: string,
): Promise<void> => {
const task = getTaskElement(client, taskName);
// Focus the task element directly 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.
await task.focus();
await client.page.keyboard.press('Backspace');
// Confirm deletion if dialog appears
const confirmBtn = client.page.locator('mat-dialog-actions button:has-text("Delete")');
const dialogAppeared = await confirmBtn
.waitFor({ state: 'visible', timeout: UI_VISIBLE_TIMEOUT_SHORT })
.then(() => true)
.catch(() => false);
if (dialogAppeared) {
await confirmBtn.click();
// Wait for dialog to close (not just click the button)
// This ensures the close animation completes before continuing
await client.page
.locator('mat-dialog-container')
.waitFor({ state: 'hidden', timeout: UI_VISIBLE_TIMEOUT });
}
// Wait for UI to settle after deletion
await client.page.waitForTimeout(UI_SETTLE_STANDARD);
};
/**
* Rename a task by double-clicking and filling the input.
*
* @param client - The simulated E2E client
* @param oldName - The current task name
* @param newName - The new task name
*/
export const renameTask = async (
client: SimulatedE2EClient,
oldName: string,
newName: string,
): Promise<void> => {
const task = getTaskElement(client, oldName);
// Wait for the task element to be stable before interacting — prevents
// "element detached from DOM" errors when Angular re-renders the task list
await task.waitFor({ state: 'attached', timeout: 5000 });
// Wait for Angular enter animations to complete. On slower/loaded CI machines the
// task element may still carry the `ng-animating` class when first visible,
// causing clicks to be swallowed and `task-title` to be temporarily absent.
await task
.evaluate((el) =>
el.classList.contains('ng-animating')
? new Promise<void>((resolve) => {
const observer = new MutationObserver(() => {
if (!el.classList.contains('ng-animating')) {
observer.disconnect();
resolve();
}
});
observer.observe(el, { attributes: true, attributeFilter: ['class'] });
setTimeout(() => {
observer.disconnect();
resolve();
}, 2000);
})
: undefined,
)
.catch(() => {});
// Enter edit mode by dispatching a click event directly on the task-title element.
// Using dispatchEvent avoids pointer-events:none and Playwright actionability issues.
// Retry up to 3 times: Angular may still re-render the component briefly after
// the animation class is removed, causing the click to not open edit mode.
const taskTitle = task.locator('task-title').first();
const textarea = client.page.locator('task-title textarea').first();
for (let attempt = 0; attempt < 3; attempt++) {
await taskTitle.dispatchEvent('click');
try {
await textarea.waitFor({ state: 'visible', timeout: 3000 });
break;
} catch {
if (attempt === 2) {
throw new Error(
`renameTask: textarea did not appear after 3 click attempts on "${oldName}"`,
);
}
await client.page.waitForTimeout(500);
}
}
// Type directly into the textarea via evaluate to avoid focus/detach races
await textarea.evaluate((el: HTMLTextAreaElement, name: string) => {
el.focus();
el.value = name;
el.dispatchEvent(new Event('input', { bubbles: true }));
}, newName);
// Blur to commit the change
await textarea.evaluate((el: HTMLTextAreaElement) => {
el.dispatchEvent(new Event('blur', { bubbles: true }));
});
await client.page.waitForTimeout(UI_SETTLE_MEDIUM);
};
/**
* Start time tracking on a task.
*
* @param client - The simulated E2E client
* @param taskName - The task name to start tracking
*/
export const startTimeTracking = async (
client: SimulatedE2EClient,
taskName: string,
): Promise<void> => {
const task = getTaskElement(client, taskName);
await task.hover();
const startBtn = task.locator('.start-task-btn');
await startBtn.waitFor({ state: 'visible', timeout: UI_VISIBLE_TIMEOUT });
await startBtn.click();
};
/**
* Stop time tracking on a task.
*
* @param client - The simulated E2E client
* @param taskName - The task name to stop tracking
*/
export const stopTimeTracking = async (
client: SimulatedE2EClient,
taskName: string,
): Promise<void> => {
const task = getTaskElement(client, taskName);
await task.hover();
const pauseBtn = task.locator('button:has(mat-icon:has-text("pause"))');
await pauseBtn.waitFor({ state: 'visible', timeout: UI_VISIBLE_TIMEOUT });
await pauseBtn.click();
};
// ============================================================================
// Task Query Helpers
// ============================================================================
/**
* Get the task count on a client.
*
* @param client - The simulated E2E client
* @returns The number of tasks
*/
export const getTaskCount = async (client: SimulatedE2EClient): Promise<number> => {
return client.page.locator('task').count();
};
/**
* Get all task titles as an array.
* Useful for comparing task order between clients.
*
* @param client - The simulated E2E client
* @returns Array of task titles in order
*/
export const getTaskTitles = async (client: SimulatedE2EClient): Promise<string[]> => {
const tasks = client.page.locator('task .task-title');
const count = await tasks.count();
const titles: string[] = [];
for (let i = 0; i < count; i++) {
const text = await tasks.nth(i).innerText();
titles.push(text.trim());
}
return titles;
};
/**
* Get the tracked time display text for a task.
*
* @param client - The simulated E2E client
* @param taskName - The task name
* @returns The time display text or null if not visible
*/
export const getTaskTimeDisplay = async (
client: SimulatedE2EClient,
taskName: string,
): Promise<string | null> => {
const task = getTaskElement(client, taskName);
const timeVal = task.locator('.time-wrapper .time-val').first();
if (await timeVal.isVisible()) {
return timeVal.textContent();
}
return null;
};
/**
* Check if a task has the given text (exists on client).
*
* @param client - The simulated E2E client
* @param taskName - The task name to check
* @returns true if the task exists
*/
export const hasTaskOnClient = async (
client: SimulatedE2EClient,
taskName: string,
): Promise<boolean> => {
return hasTask(client.page, taskName);
};
// ============================================================================
// Test Setup Helpers
// ============================================================================
/**
* Generate a unique test run ID for data isolation.
* This replaces the common `generateTestRunId` function in test files.
*
* @param workerIndex - The Playwright worker index from testInfo
* @returns A unique test run ID string
*/
export const generateTestRunId = (workerIndex: number): string => {
return `${Date.now()}-${workerIndex}`;
};
// ============================================================================
// UI HELPERS - For direct DOM interactions during tests
// ============================================================================
/**
* Reliably create a project through the UI.
* Handles sidebar state, hover-to-reveal buttons, and dialog interaction.
*
* @param page - The Playwright page
* @param projectName - The name for the new project
*/
export const createProjectReliably = async (
page: Page,
projectName: string,
): Promise<void> => {
await page.goto('/#/tag/TODAY/work');
await page.waitForLoadState('networkidle');
await page.waitForTimeout(UI_SETTLE_EXTENDED);
// Ensure sidebar is in full mode (visible labels)
const navSidenav = page.locator('.nav-sidenav');
if (await navSidenav.isVisible()) {
const isCompact = await navSidenav.evaluate((el) =>
el.classList.contains('compactMode'),
);
if (isCompact) {
const toggleBtn = navSidenav.locator('.mode-toggle');
if (await toggleBtn.isVisible()) {
await toggleBtn.click();
await page.waitForTimeout(UI_SETTLE_STANDARD);
}
}
}
// Find the Projects section wrapper
const projectsTree = page
.locator('nav-list-tree')
.filter({ hasText: 'Projects' })
.first();
await projectsTree.waitFor({ state: 'visible' });
// Hover the group header to make additional buttons visible and clickable
// (buttons have pointer-events: none by default, only enabled on hover)
const groupNavItem = projectsTree.locator('nav-item').first();
await groupNavItem.hover();
await page.waitForTimeout(UI_SETTLE_SMALL);
// Target the button element (not the mat-icon inside it)
const addBtn = projectsTree.locator(
'.additional-btns button[mat-icon-button]:has(mat-icon:text("add"))',
);
try {
await addBtn.waitFor({ state: 'visible', timeout: 5000 });
await addBtn.click();
} catch {
await addBtn.click({ force: true });
}
// Dialog
const nameInput = page.getByRole('textbox', { name: 'Project Name' });
await nameInput.waitFor({ state: 'visible', timeout: UI_VISIBLE_TIMEOUT_LONG });
await nameInput.fill(projectName);
const submitBtn = page.locator('dialog-create-project button[type=submit]').first();
await submitBtn.click();
// Wait for dialog to close
await nameInput.waitFor({ state: 'hidden', timeout: UI_VISIBLE_TIMEOUT });
// Wait for project to appear
await page.waitForTimeout(UI_SETTLE_EXTENDED);
};
// ============================================================================
// ARCHIVE & WORKLOG HELPERS - For archive-related E2E tests
// ============================================================================
/**
* Mark a task as done by pressing 'd' key.
* The task must be visible and focusable on the current page.
*
* @param client - The simulated E2E client
* @param taskName - The task name to mark as done
*/
export const markTaskDoneByKey = async (
client: SimulatedE2EClient,
taskName: string,
): Promise<void> => {
const escapedName = escapeForSelector(taskName);
const task = client.page
.locator(`task:not(.ng-animating):has-text("${escapedName}")`)
.first();
await task.waitFor({ state: 'visible', timeout: UI_VISIBLE_TIMEOUT });
await task.focus();
await client.page.waitForTimeout(UI_SETTLE_SMALL);
await task.press('d');
// Wait for task to show done state
await expect(task).toHaveClass(/isDone/, { timeout: UI_VISIBLE_TIMEOUT });
};
/**
* Archive done tasks via the Daily Summary flow.
* Navigates to Daily Summary, clicks "Save & Go Home" to archive all done tasks.
*
* @param client - The simulated E2E client
*/
export const archiveDoneTasks = async (client: SimulatedE2EClient): Promise<void> => {
// Click the "Finish Day" button to go to Daily Summary
const finishDayBtn = client.page.locator('.e2e-finish-day');
await finishDayBtn.waitFor({ state: 'visible', timeout: UI_VISIBLE_TIMEOUT });
await finishDayBtn.click();
// Wait for navigation to Daily Summary
await client.page.waitForURL(/daily-summary/);
// Click "Save & Go Home" button (has sun icon wb_sunny)
const saveAndGoHomeBtn = client.page.locator(
'daily-summary button[mat-flat-button]:has(mat-icon:has-text("wb_sunny"))',
);
await saveAndGoHomeBtn.waitFor({ state: 'visible', timeout: UI_VISIBLE_TIMEOUT });
await saveAndGoHomeBtn.click();
// Wait for navigation back to work view.
// Use negative lookahead: /(active\/tasks|tag\/TODAY)/ would match the
// current /daily-summary URL; adding (?!\/daily-summary) ensures we wait
// for a real navigation. Also handles tag/TODAY without /tasks suffix.
await client.page.waitForURL(/(active\/tasks|tag\/TODAY(?!\/daily-summary))/);
await client.page.waitForTimeout(UI_SETTLE_STANDARD);
};
/**
* Archive a specific task by marking it done and going through Daily Summary.
* Combines markTaskDoneByKey and archiveDoneTasks for convenience.
*
* @param client - The simulated E2E client
* @param taskName - The task name to archive
*/
export const archiveTask = async (
client: SimulatedE2EClient,
taskName: string,
): Promise<void> => {
await markTaskDoneByKey(client, taskName);
await archiveDoneTasks(client);
};
/**
* Navigate to worklog and check for a specific task in archived entries.
* Skips navigation if already on the worklog page to avoid re-render issues
* when checking multiple tasks in succession.
*
* @param client - The simulated E2E client
* @param taskName - The task name to search for in worklog
* @returns true if the task is found in worklog
*/
export const hasTaskInWorklog = async (
client: SimulatedE2EClient,
taskName: string,
): Promise<boolean> => {
// Only navigate if not already on worklog page
const currentUrl = client.page.url();
if (!currentUrl.includes('/worklog')) {
await client.page.goto('/#/tag/TODAY/worklog');
await client.page.waitForLoadState('networkidle');
await client.page.waitForTimeout(UI_SETTLE_EXTENDED);
}
// Expand week rows that aren't already expanded
const weekRows = client.page.locator('.week-row');
const weekCount = await weekRows.count();
for (let i = 0; i < Math.min(weekCount, 5); i++) {
const row = weekRows.nth(i);
if (await row.isVisible()) {
const isExpanded = await row.evaluate((el) => el.classList.contains('isExpanded'));
if (!isExpanded) {
await row.click().catch(() => {});
await client.page.waitForTimeout(UI_SETTLE_STANDARD);
}
}
}
// Wait for expanded task table, then search for the task
const escapedName = escapeForSelector(taskName);
const taskEntry = client.page.locator(
`.task-summary-table .task-title:has-text("${escapedName}")`,
);
return taskEntry
.first()
.waitFor({ state: 'visible', timeout: 10000 })
.then(() => true)
.catch(() => false);
};
/**
* Assert that a task appears in the worklog.
*
* @param client - The simulated E2E client
* @param taskName - The task name to verify in worklog
*/
export const expectTaskInWorklog = async (
client: SimulatedE2EClient,
taskName: string,
): Promise<void> => {
const found = await hasTaskInWorklog(client, taskName);
if (!found) {
throw new Error(`Expected task "${taskName}" to be in worklog, but it was not found`);
}
};
/**
* Assert that a task does NOT appear in the worklog.
*
* @param client - The simulated E2E client
* @param taskName - The task name that should NOT be in worklog
*/
export const expectTaskNotInWorklog = async (
client: SimulatedE2EClient,
taskName: string,
): Promise<void> => {
const found = await hasTaskInWorklog(client, taskName);
if (found) {
throw new Error(`Expected task "${taskName}" NOT to be in worklog, but it was found`);
}
};
/**
* Get the count of worklog entries (archived tasks).
*
* @param client - The simulated E2E client
* @returns The number of task entries in worklog
*/
export const getWorklogTaskCount = async (
client: SimulatedE2EClient,
): Promise<number> => {
// Navigate to worklog
await client.page.goto('/#/tag/TODAY/worklog');
await client.page.waitForLoadState('networkidle');
await client.page.waitForTimeout(UI_SETTLE_STANDARD);
// Expand week rows
const weekRows = client.page.locator('.week-row');
const weekCount = await weekRows.count();
for (let i = 0; i < Math.min(weekCount, 3); i++) {
const row = weekRows.nth(i);
if (await row.isVisible()) {
await row.click().catch(() => {});
await client.page.waitForTimeout(UI_SETTLE_SMALL);
}
}
// Count task entries
const taskEntries = await client.page
.locator('.task-summary-table .task-title, .worklog-task, worklog-task')
.count()
.catch(() => 0);
return taskEntries;
};
/**
* Navigate back to work view from worklog or other pages.
*
* @param client - The simulated E2E client
*/
export const navigateToWorkView = async (client: SimulatedE2EClient): Promise<void> => {
await client.page.goto('/#/tag/TODAY/tasks');
await client.page.waitForLoadState('networkidle');
await client.page.waitForTimeout(UI_SETTLE_STANDARD);
};
// ============================================================================
// ENCRYPTION DIALOG HELPERS
// ============================================================================
/**
* Handle the encryption warning dialog that appears when importing a backup
* with different encryption settings than the current app state.
*
* The dialog (dialog-import-encryption-warning) has a warn-colored confirm button.
* If the dialog does not appear within the timeout, this is a no-op.
*
* @param page - The Playwright page
* @param label - Optional label for console logging (e.g. "[importBackup]")
*/
export const handleEncryptionWarningDialog = async (
page: Page,
label = '[handleEncryptionWarningDialog]',
): Promise<void> => {
const encryptionWarning = page.locator('dialog-import-encryption-warning');
const warningAppeared = await encryptionWarning
.waitFor({ state: 'visible', timeout: UI_VISIBLE_TIMEOUT_SHORT })
.then(() => true)
.catch(() => false);
if (warningAppeared) {
console.log(`${label} Encryption warning dialog appeared - confirming import`);
const confirmBtn = encryptionWarning.locator('button[color="warn"]');
await confirmBtn.click();
await encryptionWarning.waitFor({
state: 'hidden',
timeout: UI_VISIBLE_TIMEOUT_LONG,
});
}
};