super-productivity/e2e/utils/supersync-helpers.ts
Johannes Millan 7c03c84fad
feat(history): merge Quick History and Worklog into a unified History view (#8033)
* feat(history): merge Quick History and Worklog into one view

- single /history route; legacy worklog & quick-history redirect to it
- one History menu entry; navigate-to-task targets history
- show per-day work start-end as its own column in the day table
- show enabled habits/simple-counters (icon + tooltip) in the expanded day
- extract HistoryTaskRowComponent for archived task rows
- remove WorklogComponent and QuickHistoryComponent

* fix(history): restore quick history behavior

* refactor(history): collapse to a single unified view

- remove the Worklog/Quick History toggle; show one full all-time
  history tree (year -> month -> week -> day)
- drop the quick-history mode, significance filter and ?view= param
- extract shared HistoryDayMetaComponent; reuse HistoryTaskRowComponent
  in the Daily Summary "This Week" widget
- convert project colors + dateStr auto-expand to signals
- update e2e specs to the single-view selectors

* refactor(history): remove dead quick-history code and i18n keys

After merging Quick History into the unified History view, the
quick-history data pipeline had no consumers. Remove it:
- _quickHistoryData$, quickHistoryWeeks$, _loadQuickHistoryForWorkContext
- map-archive-to-worklog-weeks util (+ tz spec) and WorklogYearsWithWeeks
- orphaned F.QUICK_HISTORY, MH.QUICK_HISTORY, MH.WORKLOG translation keys

Keep WorklogWeekSimple (base of the still-used WorklogWeek).

* docs(history): reflect Quick History merge into unified History

The quick-history and worklog routes are now legacy aliases for the
single unified History view; drop the stale 'current-year mode/toggle'
framing. Concepts-note prose flagged for human review.

* feat(history): improve a11y and i18n of the unified history view

Accessibility:
- week table uses <thead> + <th scope=col>; icon-only work-times
  header gets a translated aria-label
- expandable day row: real <button> disclosure control carrying
  aria-expanded/aria-controls (keyboard + AT path) instead of a
  role=button <tr>; row keeps a pointer click
- archived-task title is now a <button> (was a non-focusable <span>)

i18n: translate previously hardcoded aria-labels (export/restore) and
the week-range tooltip; reuse VIEW_TASK_DETAILS/RESTORE_TASK_FROM_ARCHIVE,
add EXPORT_DATA + WEEK_RANGE_TOOLTIP keys.

Polish (carried in): convert worklogData$ to a toSignal signal (drop
AsyncPipe + dead animations), relocate the shared row to
features/worklog/worklog-task-row (WorklogTaskRowComponent) to break a
worklog<->history cycle, make template-unused services private, tighten
projectColor input type. SCSS focus rings use focus-ring tokens.

e2e: locate task titles via td.title button after the span->button change.
2026-06-05 18:10:56 +02:00

1286 lines
41 KiB
TypeScript
Raw Permalink 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';
import {
assertNoRuntimeBrowserErrors,
attachPageErrorCollector,
installDevErrorDialogHandler,
type RuntimeBrowserError,
} from './runtime-errors';
/**
* 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;
runtimeErrors: RuntimeBrowserError[];
}
/**
* 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,
options: { allowExampleTasks?: boolean } = {},
): Promise<SimulatedE2EClient> => {
const { allowExampleTasks = false } = options;
// 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();
const runtimeErrors = attachPageErrorCollector(page, `Client ${clientName}`);
installDevErrorDialogHandler(page, `Client ${clientName}`);
// Skip onboarding, hints, and example tasks before the app boots.
// This runs before any page JavaScript, so Angular sees the flags immediately.
// Tests of the example-task sync gate opt back in via { allowExampleTasks: true }
// so first-run onboarding tasks are actually created.
await page.addInitScript((allowExamples) => {
localStorage.setItem('SUP_ONBOARDING_PRESET_DONE', 'true');
localStorage.setItem('SUP_ONBOARDING_HINTS_DONE', 'true');
localStorage.setItem('SUP_IS_SHOW_TOUR', 'true');
if (!allowExamples) {
localStorage.setItem('SUP_EXAMPLE_TASKS_CREATED', 'true');
}
}, allowExampleTasks);
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,
runtimeErrors,
};
};
/**
* 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)}`,
);
}
// Assert AFTER close so pageerrors emitted during teardown (Angular destroy
// hooks, late RxJS errors) are still captured before we throw.
assertNoRuntimeBrowserErrors(client.runtimeErrors, `Client ${client.clientName}`);
};
/**
* 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();
await client.page.mouse.move(0, 0);
await task
.locator('task-hover-controls')
.waitFor({ state: 'detached', timeout: UI_VISIBLE_TIMEOUT_SHORT })
.catch(() => {});
};
// ============================================================================
// 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 present
*/
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.count()) > 0) {
return timeVal.textContent();
}
return null;
};
/**
* Wait for a task's tracked time text to be present.
*
* The task row intentionally hides `.time-wrapper` while hover controls are mounted,
* so time-tracking assertions should read the rendered text instead of requiring
* visual visibility.
*
* @param client - The simulated E2E client
* @param taskName - The task name
* @param timeout - Maximum time to wait for non-empty time text
* @returns The trimmed time display text
*/
export const waitForTaskTimeDisplay = async (
client: SimulatedE2EClient,
taskName: string,
timeout = UI_VISIBLE_TIMEOUT,
): Promise<string> => {
await expect
.poll(
async () => {
const text = await getTaskTimeDisplay(client, taskName);
return text?.trim() ?? '';
},
{
timeout,
intervals: [250, 500, 1000],
},
)
.not.toBe('');
return (await getTaskTimeDisplay(client, taskName))!.trim();
};
/**
* Get a task's persisted timeSpent from the app state cache.
*
* This avoids relying on the task row's time label: sub-minute values render as
* "-" in the UI, and the label can be hidden while hover controls are mounted.
*
* @param client - The simulated E2E client
* @param taskName - The task name
* @returns The persisted timeSpent value in milliseconds, or null if not found
*/
export const getTaskTimeSpentFromState = async (
client: SimulatedE2EClient,
taskName: string,
): Promise<number | null> =>
client.page.evaluate(async (name) => {
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null;
const getTimeSpentFromRootState = (state: Record<string, unknown>): number | null => {
const taskState = state.tasks ?? state.task;
if (!isRecord(taskState) || !isRecord(taskState.entities)) {
return null;
}
for (const task of Object.values(taskState.entities)) {
if (
isRecord(task) &&
typeof task.title === 'string' &&
task.title.includes(name)
) {
return typeof task.timeSpent === 'number' ? task.timeSpent : 0;
}
}
return null;
};
type StoreSubscription = { unsubscribe: () => void };
type StoreLike = {
subscribe: (next: (state: unknown) => void) => StoreSubscription;
};
const helpers = (
window as unknown as {
__e2eTestHelpers?: {
store?: StoreLike;
};
}
).__e2eTestHelpers;
if (helpers?.store) {
const liveState = await new Promise<Record<string, unknown> | null>((resolve) => {
let isDone = false;
const subscriptionRef: { current?: StoreSubscription } = {};
const finish = (state: unknown): void => {
if (isDone) {
return;
}
isDone = true;
window.setTimeout(() => subscriptionRef.current?.unsubscribe());
resolve(isRecord(state) ? state : null);
};
subscriptionRef.current = helpers.store.subscribe(finish);
window.setTimeout(() => finish(null), 1000);
});
if (liveState) {
const timeSpent = getTimeSpentFromRootState(liveState);
if (timeSpent !== null) {
return timeSpent;
}
}
}
const db = await new Promise<IDBDatabase>((resolve, reject) => {
const request = indexedDB.open('SUP_OPS');
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
const stateCacheEntry = await new Promise<
{ state?: Record<string, unknown> } | undefined
>((resolve, reject) => {
const tx = db.transaction('state_cache', 'readonly');
const store = tx.objectStore('state_cache');
const request = store.get('current');
request.onsuccess = () =>
resolve(
isRecord(request.result)
? (request.result as { state?: Record<string, unknown> })
: undefined,
);
request.onerror = () => reject(request.error);
});
db.close();
return stateCacheEntry?.state
? getTimeSpentFromRootState(stateCacheEntry.state)
: null;
}, taskName);
/**
* Wait for a task's persisted timeSpent to be greater than zero.
*
* @param client - The simulated E2E client
* @param taskName - The task name
* @param timeout - Maximum time to wait for timeSpent
* @returns The persisted timeSpent value in milliseconds
*/
export const waitForTaskTimeSpent = async (
client: SimulatedE2EClient,
taskName: string,
timeout = UI_VISIBLE_TIMEOUT,
): Promise<number> => {
await expect
.poll(async () => (await getTaskTimeSpentFromState(client, taskName)) ?? 0, {
timeout,
intervals: [250, 500, 1000],
})
.toBeGreaterThan(0);
return (await getTaskTimeSpentFromState(client, taskName))!;
};
/**
* 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('/history')) {
await client.page.goto('/#/tag/TODAY/history');
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/history');
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,
});
}
};