import electronBinaryPath from 'electron'; import { _electron as electron, expect, test as base, type ElectronApplication, type Page } from '@playwright/test'; import { mkdir, mkdtemp, rm } from 'node:fs/promises'; import { createServer } from 'node:net'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; type LaunchElectronOptions = { skipSetup?: boolean; }; type IpcMockConfig = { gatewayStatus?: Record; gatewayRpc?: Record; hostApi?: Record; }; type ElectronFixtures = { electronApp: ElectronApplication; page: Page; homeDir: string; userDataDir: string; launchElectronApp: (options?: LaunchElectronOptions) => Promise; }; const repoRoot = resolve(process.cwd()); const electronEntry = join(repoRoot, 'dist-electron/main/index.js'); async function allocatePort(): Promise { return await new Promise((resolvePort, reject) => { const server = createServer(); server.once('error', reject); server.listen(0, '127.0.0.1', () => { const address = server.address(); if (!address || typeof address === 'string') { server.close(() => reject(new Error('Failed to allocate an ephemeral port'))); return; } const { port } = address; server.close((error) => { if (error) { reject(error); return; } resolvePort(port); }); }); }); } async function getStableWindow(app: ElectronApplication): Promise { const deadline = Date.now() + 30_000; let page = await app.firstWindow(); while (Date.now() < deadline) { const openWindows = app.windows().filter((candidate) => !candidate.isClosed()); const currentWindow = openWindows.at(-1) ?? page; if (currentWindow && !currentWindow.isClosed()) { try { await currentWindow.waitForLoadState('domcontentloaded', { timeout: 2_000 }); return currentWindow; } catch (error) { if (!String(error).includes('has been closed')) { throw error; } } } try { page = await app.waitForEvent('window', { timeout: 2_000 }); } catch { // Keep polling until a stable window is available or the deadline expires. } } throw new Error('No stable Electron window became available'); } async function closeElectronApp(app: ElectronApplication, timeoutMs = 5_000): Promise { let closed = false; await Promise.race([ (async () => { const [closeResult] = await Promise.allSettled([ app.waitForEvent('close', { timeout: timeoutMs }), app.evaluate(({ app: electronApp }) => { electronApp.quit(); }), ]); if (closeResult.status === 'fulfilled') { closed = true; } })(), new Promise((resolve) => setTimeout(resolve, timeoutMs)), ]); if (closed) { return; } try { await app.close(); return; } catch { // Fall through to process kill if Playwright cannot close the app cleanly. } try { app.process().kill('SIGKILL'); } catch { // Ignore process kill failures during e2e teardown. } } async function launchClawXElectron( homeDir: string, userDataDir: string, options: LaunchElectronOptions = {}, ): Promise { const hostApiPort = await allocatePort(); const electronEnv = process.platform === 'linux' ? { ELECTRON_DISABLE_SANDBOX: '1' } : {}; return await electron.launch({ executablePath: electronBinaryPath, args: [electronEntry], env: { ...process.env, ...electronEnv, HOME: homeDir, USERPROFILE: homeDir, APPDATA: join(homeDir, 'AppData', 'Roaming'), LOCALAPPDATA: join(homeDir, 'AppData', 'Local'), XDG_CONFIG_HOME: join(homeDir, '.config'), CLAWX_E2E: '1', CLAWX_USER_DATA_DIR: userDataDir, ...(options.skipSetup ? { CLAWX_E2E_SKIP_SETUP: '1' } : {}), CLAWX_PORT_CLAWX_HOST_API: String(hostApiPort), }, timeout: 90_000, }); } export const test = base.extend({ homeDir: async ({ browserName: _browserName }, provideHomeDir) => { const homeDir = await mkdtemp(join(tmpdir(), 'clawx-e2e-home-')); await mkdir(join(homeDir, '.config'), { recursive: true }); await mkdir(join(homeDir, 'AppData', 'Local'), { recursive: true }); await mkdir(join(homeDir, 'AppData', 'Roaming'), { recursive: true }); try { await provideHomeDir(homeDir); } finally { await rm(homeDir, { recursive: true, force: true }); } }, userDataDir: async ({ browserName: _browserName }, provideUserDataDir) => { const userDataDir = await mkdtemp(join(tmpdir(), 'clawx-e2e-user-data-')); try { await provideUserDataDir(userDataDir); } finally { await rm(userDataDir, { recursive: true, force: true }); } }, launchElectronApp: async ({ homeDir, userDataDir }, provideLauncher) => { await provideLauncher(async (options?: LaunchElectronOptions) => await launchClawXElectron(homeDir, userDataDir, options)); }, electronApp: async ({ launchElectronApp }, provideElectronApp) => { const app = await launchElectronApp(); let appClosed = false; app.once('close', () => { appClosed = true; }); try { await provideElectronApp(app); } finally { if (!appClosed) { await closeElectronApp(app); } } }, page: async ({ electronApp }, providePage) => { const page = await getStableWindow(electronApp); await providePage(page); }, }); export async function completeSetup(page: Page): Promise { await expect(page.getByTestId('setup-page')).toBeVisible(); await page.getByTestId('setup-skip-button').click(); await expect(page.getByTestId('main-layout')).toBeVisible(); } export { closeElectronApp }; export { getStableWindow }; export { expect }; export async function installIpcMocks( app: ElectronApplication, config: IpcMockConfig, ): Promise { await app.evaluate( async ({ app: _app }, mockConfig) => { const { ipcMain } = process.mainModule!.require('electron') as typeof import('electron'); const stableStringify = (value: unknown): string => { if (value == null || typeof value !== 'object') return JSON.stringify(value); if (Array.isArray(value)) return `[${value.map((item) => stableStringify(item)).join(',')}]`; const entries = Object.entries(value as Record) .sort(([left], [right]) => left.localeCompare(right)) .map(([key, entryValue]) => `${JSON.stringify(key)}:${stableStringify(entryValue)}`); return `{${entries.join(',')}}`; }; if (mockConfig.gatewayRpc) { ipcMain.removeHandler('gateway:rpc'); ipcMain.handle('gateway:rpc', async (_event: unknown, method: string, payload: unknown) => { const key = stableStringify([method, payload ?? null]); if (key in mockConfig.gatewayRpc!) { return mockConfig.gatewayRpc![key]; } const fallbackKey = stableStringify([method, null]); if (fallbackKey in mockConfig.gatewayRpc!) { return mockConfig.gatewayRpc![fallbackKey]; } return { success: true, result: {} }; }); } if (mockConfig.hostApi) { ipcMain.removeHandler('hostapi:fetch'); ipcMain.handle('hostapi:fetch', async (_event: unknown, request: { path?: string; method?: string }) => { const key = stableStringify([request?.path ?? '', request?.method ?? 'GET']); if (key in mockConfig.hostApi!) { return mockConfig.hostApi![key]; } return { ok: true, data: { status: 200, ok: true, json: {} }, }; }); } if (mockConfig.gatewayStatus) { ipcMain.removeHandler('gateway:status'); ipcMain.handle('gateway:status', async () => mockConfig.gatewayStatus); } }, config, ); }