import { Page } from '@playwright/test'; /** * Helper functions for plugin testing with robust loading verification */ /** * Wait for plugin assets to be available via HTTP */ export const waitForPluginAssets = async ( page: Page, maxRetries: number = 12, retryDelay: number = 1000, overallTimeoutMs?: number, ): Promise => { // Hard cap for total waiting time to avoid exceeding test timeout const capMs = overallTimeoutMs ?? (process.env.CI ? 25000 : 15000); const start = Date.now(); // In CI, keep retries conservative to stay under cap if (process.env.CI) { maxRetries = Math.min(Math.max(maxRetries, 10), 15); retryDelay = Math.max(retryDelay, 1500); } const baseUrl = page.url().split('#')[0]; const testUrl = `${baseUrl}assets/bundled-plugins/api-test-plugin/manifest.json`; // Basic readiness check; keep short to not eat into cap try { await page.waitForSelector('app-root', { state: 'visible', timeout: 8000 }); } catch { return false; } for (let i = 0; i < maxRetries; i++) { try { const response = await page.request.get(testUrl); if (response.ok()) { await response.json(); return true; } else { // Debug: Check if basic assets work if (response.status() === 404 && i === 3) { const iconUrl = `${baseUrl}assets/icons/sp.svg`; try { await page.request.get(iconUrl); } catch (e) { const message = e instanceof Error ? e.message : String(e); console.error(`[Plugin Test] Basic asset test failed:`, message); } } } } catch (error) { const message = error instanceof Error ? error.message : String(error); console.error(`[Plugin Test] Attempt ${i + 1}/${maxRetries}: Error - ${message}`); } // Respect overall cap to avoid test-level timeout const elapsed = Date.now() - start; if (elapsed + retryDelay > capMs) { break; } if (i < maxRetries - 1) { // Wait before retry - network request, so timeout is appropriate here await page.waitForTimeout(retryDelay); } } console.error('[Plugin Test] Failed to load plugin assets after all retries'); // In CI, this might be expected if assets aren't built properly if (process.env.CI) { console.warn('[Plugin Test] Plugin assets unavailable; skipping in CI'); } return false; }; /** * Wait for plugin system to be initialized - now navigates to settings and ensures plugin section is available */ export const waitForPluginManagementInit = async ( page: Page, // 30s timeout needed: plugin management init includes navigation to settings, // tab loading, and plugin card rendering which is slow in CI environments timeout: number = 30000, ): Promise => { try { // Navigate to settings page if not already there const currentUrl = page.url(); if (!currentUrl.includes('#/config')) { await page.goto('/#/config'); } // Wait for settings page to load await page.waitForSelector('.page-settings', { state: 'visible', timeout: 10000 }); // Navigate to the Plugins tab const pluginsTab = page.locator( 'mat-tab-header .mat-mdc-tab:has(mat-icon:has-text("extension"))', ); await pluginsTab.waitFor({ state: 'visible', timeout: 10000 }); await pluginsTab.click(); // Expand plugin section if collapsed and scroll it into view await page.waitForSelector('.plugin-section', { state: 'visible', timeout: 5000 }); await page.evaluate(() => { const pluginSection = document.querySelector('.plugin-section'); if (pluginSection) { pluginSection.scrollIntoView({ behavior: 'instant', block: 'center' }); } const collapsible = document.querySelector('.plugin-section collapsible'); if (collapsible && !collapsible.classList.contains('isExpanded')) { const header = collapsible.querySelector('.collapsible-header'); if (header) { (header as HTMLElement).click(); } } }); // Wait for plugin management component with plugin cards await page.waitForSelector('plugin-management', { state: 'attached', timeout: 10000, }); const result = await page.waitForFunction( () => { const cards = document.querySelectorAll('plugin-management mat-card'); return cards.length > 0; }, { timeout: 10000 }, ); // Scroll plugin-management into view await page.evaluate(() => { const pluginMgmt = document.querySelector('plugin-management'); if (pluginMgmt) { pluginMgmt.scrollIntoView({ behavior: 'instant', block: 'center' }); } }); return !!result; } catch (error) { const message = error instanceof Error ? error.message : String(error); console.error('[Plugin Test] Plugin management init failed:', message); return false; } }; /** * Enable a plugin with robust verification */ export const enablePluginWithVerification = async ( page: Page, pluginName: string, timeout: number = 8000, // Reduced from 10s to 8s // Reduced from 15s to 10s ): Promise => { const startTime = Date.now(); // First, verify the plugin card exists const pluginCardResult = await page .waitForFunction( (name) => { const cards = Array.from(document.querySelectorAll('plugin-management mat-card')); const targetCard = cards.find((card) => { const title = card.querySelector('mat-card-title')?.textContent || ''; return title.includes(name); }); if (targetCard) { return true; } return false; }, pluginName, { timeout: timeout / 2 }, ) .catch(() => null); if (!pluginCardResult) { console.error(`[Plugin Test] Plugin card not found for: ${pluginName}`); return false; } // Enable the plugin const enableResult = await page.evaluate((name) => { const cards = Array.from(document.querySelectorAll('plugin-management mat-card')); const targetCard = cards.find((card) => { const title = card.querySelector('mat-card-title')?.textContent || ''; return title.includes(name); }); if (!targetCard) { return { success: false, error: 'Card not found' }; } const toggle = targetCard.querySelector( 'mat-slide-toggle button[role="switch"]', ) as HTMLButtonElement; if (!toggle) { return { success: false, error: 'Toggle not found' }; } const wasEnabled = toggle.getAttribute('aria-checked') === 'true'; if (!wasEnabled) { toggle.click(); } return { success: true, wasEnabled }; }, pluginName); if (!enableResult.success) { console.error(`[Plugin Test] Failed to enable plugin: ${enableResult.error}`); return false; } // Wait for the toggle state to update await page.waitForFunction( (name) => { const cards = Array.from(document.querySelectorAll('plugin-management mat-card')); const targetCard = cards.find((card) => { const title = card.querySelector('mat-card-title')?.textContent || ''; return title.includes(name); }); const toggle = targetCard?.querySelector( 'mat-slide-toggle button[role="switch"]', ) as HTMLButtonElement; return toggle?.getAttribute('aria-checked') === 'true'; }, pluginName, { timeout: timeout - (Date.now() - startTime) }, ); return true; }; /** * Wait for plugin to be fully loaded and visible in menu */ export const waitForPluginInMenu = async ( page: Page, pluginName: string, timeout: number = 15000, // Reduced from 20s to 15s ): Promise => { try { // Navigate to main view to see the menu await page.goto('/#/tag/TODAY/tasks'); // Wait for magic-side-nav to exist await page.waitForSelector('magic-side-nav', { state: 'attached', timeout: timeout / 2, }); // Wait for the specific plugin button in the magic-side-nav const result = await page.waitForFunction( (name) => { const sideNav = document.querySelector('magic-side-nav'); if (!sideNav) { return false; } const buttons = Array.from(sideNav.querySelectorAll('nav-item button')); const found = buttons.some((btn) => { const text = btn.textContent?.trim() || ''; return text.includes(name); }); return found; }, pluginName, { timeout }, ); return !!result; } catch (error) { const message = error instanceof Error ? error.message : String(error); console.error(`[Plugin Test] Plugin ${pluginName} not found in menu:`, message); return false; } }; /** * Debug helper to log current plugin state */ export const logPluginState = async (page: Page): Promise => { await page.evaluate(() => { const cards = Array.from(document.querySelectorAll('plugin-management mat-card')); const plugins = cards.map((card) => { const title = card.querySelector('mat-card-title')?.textContent?.trim() || 'Unknown'; const toggle = card.querySelector( 'mat-slide-toggle button[role="switch"]', ) as HTMLButtonElement; const enabled = toggle?.getAttribute('aria-checked') === 'true'; return { title, enabled }; }); const menuButtons = Array.from( document.querySelectorAll('magic-side-nav nav-item button'), ).map((btn) => btn.textContent?.trim() || ''); return { pluginCards: plugins, menuEntries: menuButtons, hasPluginManagement: !!document.querySelector('plugin-management'), hasMagicSideNav: !!document.querySelector('magic-side-nav'), }; }); // Plugin state debugging removed to reduce test output }; /** * Get timeout multiplier for CI environment */ export const getCITimeoutMultiplier = (): number => { return process.env.CI ? 2 : 1; }; /** * Disable a plugin with robust verification */ export const disablePluginWithVerification = async ( page: Page, pluginName: string, timeout: number = 10000, ): Promise => { const startTime = Date.now(); // First, verify the plugin card exists and is enabled const pluginCardResult = await page .waitForFunction( (name) => { const cards = Array.from(document.querySelectorAll('plugin-management mat-card')); const targetCard = cards.find((card) => { const title = card.querySelector('mat-card-title')?.textContent || ''; return title.includes(name); }); return !!targetCard; }, pluginName, { timeout: timeout / 2 }, ) .catch(() => null); if (!pluginCardResult) { console.error(`[Plugin Test] Plugin card not found for: ${pluginName}`); return false; } // Check current state and click to disable if needed const disableResult = await page.evaluate((name) => { const cards = Array.from(document.querySelectorAll('plugin-management mat-card')); const targetCard = cards.find((card) => { const title = card.querySelector('mat-card-title')?.textContent || ''; return title.includes(name); }); if (!targetCard) { return { success: false, error: 'Card not found', wasEnabled: false, clicked: false, }; } const toggle = targetCard.querySelector( 'mat-slide-toggle button[role="switch"]', ) as HTMLButtonElement; if (!toggle) { return { success: false, error: 'Toggle not found', wasEnabled: false, clicked: false, }; } const wasEnabled = toggle.getAttribute('aria-checked') === 'true'; if (wasEnabled) { toggle.click(); return { success: true, wasEnabled, clicked: true }; } // Already disabled return { success: true, wasEnabled: false, clicked: false }; }, pluginName); if (!disableResult.success) { console.error(`[Plugin Test] Failed to disable plugin: ${disableResult.error}`); return false; } // If already disabled, no need to wait if (!disableResult.clicked) { return true; } // Wait for the toggle state to update to disabled const remainingTimeout = Math.max(5000, timeout - (Date.now() - startTime)); try { await page.waitForFunction( (name) => { const cards = Array.from(document.querySelectorAll('plugin-management mat-card')); const targetCard = cards.find((card) => { const title = card.querySelector('mat-card-title')?.textContent || ''; return title.includes(name); }); const toggle = targetCard?.querySelector( 'mat-slide-toggle button[role="switch"]', ) as HTMLButtonElement; return toggle?.getAttribute('aria-checked') === 'false'; }, pluginName, { timeout: remainingTimeout }, ); return true; } catch (error) { const message = error instanceof Error ? error.message : String(error); console.error(`[Plugin Test] Timeout waiting for plugin to disable: ${message}`); return false; } }; /** * Robust element clicking with multiple selector fallbacks */ export const robustClick = async ( page: Page, selectors: string[], timeout: number = 8000, // Reduced from 10s to 8s ): Promise => { for (const selector of selectors) { try { const element = page.locator(selector).first(); await element.waitFor({ state: 'visible', timeout: timeout / selectors.length }); await element.click(); return true; } catch (error) { const message = error instanceof Error ? error.message : String(error); console.log(`Selector ${selector} failed: ${message}`); } } console.error(`All selectors failed: ${selectors.join(', ')}`); return false; }; /** * Wait for element with multiple selector fallbacks */ export const robustWaitFor = async ( page: Page, selectors: string[], timeout: number = 8000, // Reduced from 10s to 8s ): Promise => { const promises = selectors.map((selector) => page .locator(selector) .first() .waitFor({ state: 'visible', timeout, }) .then(() => selector) .catch(() => null), ); try { const result = await Promise.race(promises.filter(Boolean)); return !!result; } catch { return false; } };