mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-18 08:56:41 +00:00
* 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.
1286 lines
41 KiB
TypeScript
1286 lines
41 KiB
TypeScript
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,
|
||
});
|
||
}
|
||
};
|