import { After, AfterStep, Before, setWorldConstructor, When } from '@cucumber/cucumber'; import fs from 'fs-extra'; import path from 'path'; import { _electron as electron } from 'playwright'; import type { ElectronApplication, Page } from 'playwright'; import { windowDimension, WindowNames } from '../../src/services/windows/WindowProperties'; import { MockOpenAIServer } from '../supports/mockOpenAI'; import { logsDirectory, makeSlugPath, screenshotsDirectory } from '../supports/paths'; import { getPackedAppPath } from '../supports/paths'; import { clearAISettings } from './agent'; import { clearTidgiMiniWindowSettings } from './tidgiMiniWindow'; // Helper function to check if window type is valid and return the corresponding WindowNames export function checkWindowName(windowType: string): WindowNames { // Exact match - windowType must be a valid WindowNames enum key if (windowType in WindowNames) { return (WindowNames as Record)[windowType]; } throw new Error(`Window type "${windowType}" is not a valid WindowNames. Check the WindowNames enum in WindowProperties.ts. Available: ${Object.keys(WindowNames).join(', ')}`); } // Helper function to get window dimensions and ensure they are valid export function checkWindowDimension(windowName: WindowNames): { width: number; height: number } { const targetDimensions = windowDimension[windowName]; if (!targetDimensions.width || !targetDimensions.height) { throw new Error(`Window "${windowName}" does not have valid dimensions defined in windowDimension`); } return targetDimensions as { width: number; height: number }; } export class ApplicationWorld { app: ElectronApplication | undefined; mainWindow: Page | undefined; // Keep for compatibility during transition currentWindow: Page | undefined; // New state-managed current window mockOpenAIServer: MockOpenAIServer | undefined; // Helper method to check if window is visible async isWindowVisible(page: Page): Promise { if (!this.app) return false; try { const browserWindow = await this.app.browserWindow(page); return await browserWindow.evaluate((win: Electron.BrowserWindow) => win.isVisible()); } catch { return false; } } // Helper method to wait for window with retry logic async waitForWindowCondition( windowType: string, condition: (window: Page | undefined, isVisible: boolean) => boolean, maxAttempts: number = 3, retryInterval: number = 250, ): Promise { if (!this.app) return false; for (let attempt = 0; attempt < maxAttempts; attempt++) { const targetWindow = await this.findWindowByType(windowType); const visible = targetWindow ? await this.isWindowVisible(targetWindow) : false; if (condition(targetWindow, visible)) { return true; } // Wait before retrying await new Promise(resolve => setTimeout(resolve, retryInterval)); } return false; } // Helper method to find window by type - strict WindowNames matching async findWindowByType(windowType: string): Promise { if (!this.app) return undefined; // Validate window type first const windowName = checkWindowName(windowType); const pages = this.app.windows(); if (windowName === WindowNames.main) { // Main window is the first/primary window, typically showing guide, agent, help, or wiki pages // It's the window that opens on app launch const allWindows = pages.filter(page => !page.isClosed()); if (allWindows.length > 0) { // Return the first window (main window is always the first one created) return allWindows[0]; } return undefined; } else if (windowName === WindowNames.tidgiMiniWindow) { // Special handling for tidgi mini window // First try to find by Electron window dimensions (more reliable than title) const windowDimensions = checkWindowDimension(windowName); try { const electronWindowInfo = await this.app.evaluate( async ({ BrowserWindow }, size: { width: number; height: number }) => { const allWindows = BrowserWindow.getAllWindows(); const tidgiMiniWindow = allWindows.find(win => { const bounds = win.getBounds(); return bounds.width === size.width && bounds.height === size.height; }); return tidgiMiniWindow ? { id: tidgiMiniWindow.id } : null; }, windowDimensions, ); if (electronWindowInfo) { // Found by dimensions, now match with Playwright page const allWindows = pages.filter(page => !page.isClosed()); for (const page of allWindows) { try { // Try to match by checking if this page belongs to the found electron window // For now, use title as fallback verification const title = await page.title(); if (title.includes('太记小窗') || title.includes('TidGi Mini Window') || title.includes('TidGiMiniWindow')) { return page; } } catch { continue; } } } } catch { // If Electron API fails, fallback to title matching } // Fallback: Match by window title const allWindows = pages.filter(page => !page.isClosed()); for (const page of allWindows) { try { const title = await page.title(); if (title.includes('太记小窗') || title.includes('TidGi Mini Window') || title.includes('TidGiMiniWindow')) { return page; } } catch { // Page might be closed or not ready, continue to next continue; } } return undefined; } else { // For regular windows (preferences, about, addWorkspace, etc.) return pages.find(page => { if (page.isClosed()) return false; const url = page.url() || ''; // Match exact route paths: /#/windowType or ending with /windowType return url.includes(`#/${windowType}`) || url.endsWith(`/${windowType}`); }); } } async getWindow(windowType: string = 'main'): Promise { if (!this.app) return undefined; // Special case for 'current' window if (windowType === 'current') { return this.currentWindow; } // Use the findWindowByType method with retry logic for (let attempt = 0; attempt < 3; attempt++) { try { const window = await this.findWindowByType(windowType); if (window) return window; } catch (error) { // If it's an invalid window type error, throw immediately if (error instanceof Error && error.message.includes('is not a valid WindowNames')) { throw error; } } // If window not found, wait and retry (except for the last attempt) if (attempt < 2) { await new Promise(resolve => setTimeout(resolve, 1000)); } } return undefined; } } setWorldConstructor(ApplicationWorld); // setDefaultTimeout(50000); Before(function(this: ApplicationWorld, { pickle }) { // Create necessary directories under userData-test/logs to match appPaths in dev/test if (!fs.existsSync(logsDirectory)) { fs.mkdirSync(logsDirectory, { recursive: true }); } // Create screenshots subdirectory in logs if (!fs.existsSync(screenshotsDirectory)) { fs.mkdirSync(screenshotsDirectory, { recursive: true }); } if (pickle.tags.some((tag) => tag.name === '@setup')) { clearAISettings(); } if (pickle.tags.some((tag) => tag.name === '@tidgiminiwindow')) { clearTidgiMiniWindowSettings(); } }); After(async function(this: ApplicationWorld, { pickle }) { if (this.app) { try { // Close all windows including tidgi mini window before closing the app, otherwise it might hang, and refused to exit until ctrl+C const allWindows = this.app.windows(); for (const window of allWindows) { try { if (!window.isClosed()) { await window.close(); } } catch (error) { console.error('Error closing window:', error); } } await this.app.close(); } catch (error) { console.error('Error during cleanup:', error); } this.app = undefined; this.mainWindow = undefined; this.currentWindow = undefined; } if (pickle.tags.some((tag) => tag.name === '@tidgiminiwindow')) { clearTidgiMiniWindowSettings(); } if (pickle.tags.some((tag) => tag.name === '@setup')) { clearAISettings(); } }); AfterStep(async function(this: ApplicationWorld, { pickle, pickleStep, result }) { // Only take screenshots in CI environment // if (!process.env.CI) return; try { // Prefer an existing currentWindow if it's still open let pageToUse: Page | undefined; if (this.currentWindow && !this.currentWindow.isClosed()) { pageToUse = this.currentWindow; } // If currentWindow is not available, try to re-acquire any open window from the app if ((!pageToUse || pageToUse.isClosed()) && this.app) { const openPages = this.app.windows().filter(p => !p.isClosed()); if (openPages.length > 0) { pageToUse = openPages[0]; this.currentWindow = pageToUse; } } const scenarioName = pickle.name; const cleanScenarioName = makeSlugPath(scenarioName); const stepText = pickleStep.text; const cleanStepText = makeSlugPath(stepText, 120); const stepStatus = result && typeof result.status === 'string' ? result.status : 'unknown-status'; const featureDirectory = path.resolve(screenshotsDirectory, cleanScenarioName); // Create directory asynchronously to avoid blocking the event loop in CI await fs.ensureDir(featureDirectory); // Sometimes window close and don't wait for use to take picture, or window haven't open in this step, never mind, just skip. /** * Typical steps like: * - I add test ai settings * - I cleanup test wiki so it could create a new one on start * - I clear test ai settings */ if (!pageToUse || pageToUse.isClosed()) { // console.warn(`Skipping screenshot: ${cleanStepText}`); return; } const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); const screenshotPath = path.resolve(featureDirectory, `${timestamp}-${cleanStepText}-${stepStatus}.jpg`); // Use conservative screenshot options for CI await pageToUse.screenshot({ path: screenshotPath, fullPage: true, type: 'jpeg', quality: 10 }); } catch (screenshotError) { console.warn('Failed to take screenshot:', screenshotError); } }); When('I launch the TidGi application', async function(this: ApplicationWorld) { // For E2E tests on dev mode, use the packaged test version with NODE_ENV environment variable baked in const packedAppPath = getPackedAppPath(); try { this.app = await electron.launch({ executablePath: packedAppPath, // Add debugging options to prevent app from closing and CI-specific args args: [ '--no-sandbox', '--disable-dev-shm-usage', '--disable-gpu', '--disable-software-rasterizer', '--disable-background-timer-throttling', '--disable-backgrounding-occluded-windows', '--disable-renderer-backgrounding', '--disable-features=TranslateUI', '--disable-ipc-flooding-protection', '--force-device-scale-factor=1', '--high-dpi-support=1', '--force-color-profile=srgb', '--disable-extensions', '--disable-plugins', '--disable-default-apps', '--virtual-time-budget=1000', '--run-all-compositor-stages-before-draw', '--disable-checker-imaging', // Linux CI specific arguments ...(process.env.CI && process.platform === 'linux' ? [ '--disable-background-mode', '--disable-features=VizDisplayCompositor', '--use-gl=swiftshader', '--disable-accelerated-2d-canvas', '--disable-accelerated-jpeg-decoding', '--disable-accelerated-mjpeg-decode', '--disable-accelerated-video-decode', ] : []), ], env: { ...process.env, NODE_ENV: 'test', E2E_TEST: 'true', // Ensure tests run in Chinese locale so i18n UI strings match expectations // set multiple variables for cross-platform coverage LANG: process.env.LANG || 'zh-Hans.UTF-8', LANGUAGE: process.env.LANGUAGE || 'zh-Hans:zh', LC_ALL: process.env.LC_ALL || 'zh-Hans.UTF-8', // Force display settings for CI ELECTRON_DISABLE_SECURITY_WARNINGS: 'true', ...(process.env.CI && { ELECTRON_ENABLE_LOGGING: 'true', ELECTRON_DISABLE_HARDWARE_ACCELERATION: 'true', }), }, timeout: 30000, // Increase timeout to 30 seconds for CI }); // Wait longer for window in CI environment const windowTimeout = process.env.CI ? 45000 : 10000; this.mainWindow = await this.app.firstWindow({ timeout: windowTimeout }); this.currentWindow = this.mainWindow; } catch (error) { throw new Error( `Failed to launch TidGi application: ${error as Error}. You should run \`pnpm run test:prepare-e2e\` before running the tests to ensure the app is built, and build with binaries like "dugite" and "tiddlywiki", see scripts/afterPack.js for more details.`, ); } });