mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-17 16:37:43 +00:00
* 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 by0e9218bd68Commit0e9218bd68silently 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 by0e9218bd68Commit0e9218bd68silently 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 -a4fe03272iOS keyboard accessory bar (global-theme + dialog-fullscreen-markdown) -309670db3ShortSyntaxEffects undefined guard -667a7986fDropbox PKCE auth comment/behavior * fix(electron): restore electron + e2e work undone by0e9218bd68Commit0e9218bd68silently 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: -e8a3e156ebfix(electron): Linux autostart IDB backing-store recovery (re-adds electron/clear-stale-idb-locks.ts + start-app.ts wiring) -5ce78a5b63fix(electron): macOS Cmd+Q / Dock > Quit hang (setIsQuiting + before-quit delegate to close-handler) -46e0fa2d01fix(sync): FILE_SYNC_LIST_FILES IPC contract (electronAPI.d.ts + local-file-sync + preload + ipc-events) -ea1ef16307fix(android): session-only SAF permissions on OEM devices -8865dc0a50test(e2e): supersync parallel-worker stampede guard (SUPERSYNC_SERVER_HEALTHY env-var fallback + goto retry loop) -af7c7687e2test(e2e): block WS-triggered downloads in non-WS specs -265b44db5dtest(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 fromd64014d086(later thanc558bcab5e) while restoring the goto retry loop from8865dc0a50. - electron/start-app.ts: unioned imports (setIsQuiting + clearStaleLevelDbLocks from theirs, fs from ours). * fix(sync): restore sync-core work undone by0e9218bd68Commit0e9218bd68silently 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: -e8a3e156ebfix(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 (1f5184f6e7re-added LegacySyncFormatDetectedError) - file-based-sync-adapter.service.ts + spec (1f5184f6e7+d9158d6adb) - file-based-sync.types.ts (1f5184f6e7) - operation-log.const.ts (32dbc95ed9bumped IDB_OPEN_RETRIES to 5) - dialog-sync-initial-cfg.component.ts (f89fe1ebc3)
1108 lines
36 KiB
TypeScript
1108 lines
36 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';
|
||
|
||
/**
|
||
* 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,
|
||
});
|
||
}
|
||
};
|