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 { basename, join, resolve } from 'node:path'; export type LaunchElectronOptions = { skipSetup?: boolean; }; type ElectronTestResources = { apps: Set; defaultSkipSetup: boolean; homeDir: string; rootDir: string; userDataDir: string; }; type ElectronFixtures = { electronApp: ElectronApplication; electronTestResources: ElectronTestResources; launchElectronApp: (options?: LaunchElectronOptions) => Promise; page: Page; }; const repoRoot = resolve(process.cwd()); const electronEntry = join(repoRoot, 'dist-electron/main/index.js'); const PASSTHROUGH_ENVIRONMENT_KEYS = [ 'PATH', 'Path', 'PATHEXT', 'SystemRoot', 'SYSTEMROOT', 'WINDIR', 'COMSPEC', 'TEMP', 'TMP', 'TMPDIR', 'LANG', 'LC_ALL', 'DISPLAY', 'XAUTHORITY', 'WAYLAND_DISPLAY', 'XDG_RUNTIME_DIR', 'DBUS_SESSION_BUS_ADDRESS', 'CI', ] as const; 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; } server.close((error) => { if (error) { reject(error); return; } resolvePort(address.port); }); }); }); } function buildElectronEnvironment( resources: ElectronTestResources, skipSetup: boolean, hostApiPort: number, ): Record { const env: Record = {}; for (const key of PASSTHROUGH_ENVIRONMENT_KEYS) { const value = process.env[key]; if (value !== undefined) { env[key] = value; } } Object.assign(env, { HOME: resources.homeDir, USERPROFILE: resources.homeDir, APPDATA: join(resources.homeDir, 'AppData', 'Roaming'), LOCALAPPDATA: join(resources.homeDir, 'AppData', 'Local'), XDG_CONFIG_HOME: join(resources.homeDir, '.config'), NIANCODE_E2E: '1', NIANCODE_USER_DATA_DIR: resources.userDataDir, NIANCODE_PORT_NIANCODE_HOST_API: String(hostApiPort), }); delete env.NIANCODE_E2E_SKIP_SETUP; if (skipSetup) { env.NIANCODE_E2E_SKIP_SETUP = '1'; } if (process.platform === 'linux') { env.ELECTRON_DISABLE_SANDBOX = '1'; } return env; } async function launchMakeloreElectron( resources: ElectronTestResources, skipSetup: boolean, ): Promise { const hostApiPort = await allocatePort(); const app = await electron.launch({ executablePath: electronBinaryPath as unknown as string, args: [electronEntry], env: buildElectronEnvironment(resources, skipSetup, hostApiPort), timeout: 90_000, }); resources.apps.add(app); app.once('close', () => { resources.apps.delete(app); }); return app; } export 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.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'); } export 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(); }), ]); closed = closeResult.status === 'fulfilled'; })(), new Promise((resolveTimeout) => setTimeout(resolveTimeout, timeoutMs)), ]); if (closed) { return; } const playwrightClosed = await Promise.race([ app.close().then(() => true).catch(() => false), new Promise((resolveTimeout) => { setTimeout(() => resolveTimeout(false), timeoutMs); }), ]); if (playwrightClosed) { return; } const childProcess = app.process(); try { childProcess.kill('SIGKILL'); } catch { // The process may already have exited during teardown. } if (childProcess.exitCode === null) { await Promise.race([ new Promise((resolveExit) => { childProcess.once('exit', () => resolveExit()); }), new Promise((resolveTimeout) => { setTimeout(resolveTimeout, timeoutMs); }), ]); } } export const test = base.extend({ electronTestResources: async ({ browserName: _browserName }, provideResources, testInfo) => { const rootDir = await mkdtemp(join(tmpdir(), 'makelore-e2e-')); const homeDir = join(rootDir, 'home'); const userDataDir = join(rootDir, 'user-data'); await Promise.all([ mkdir(join(homeDir, '.config'), { recursive: true }), mkdir(join(homeDir, 'AppData', 'Local'), { recursive: true }), mkdir(join(homeDir, 'AppData', 'Roaming'), { recursive: true }), mkdir(userDataDir, { recursive: true }), ]); const resources: ElectronTestResources = { apps: new Set(), defaultSkipSetup: ![ 'app-smoke.spec.ts', 'language-russian.spec.ts', ].includes(basename(testInfo.file)), homeDir, rootDir, userDataDir, }; try { await provideResources(resources); } finally { await Promise.allSettled([...resources.apps].map((app) => closeElectronApp(app))); await rm(rootDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 200, }); } }, launchElectronApp: async ({ electronTestResources }, provideLauncher) => { await provideLauncher(async (options = {}) => { const skipSetup = options.skipSetup ?? electronTestResources.defaultSkipSetup; return await launchMakeloreElectron(electronTestResources, skipSetup); }); }, electronApp: async ({ launchElectronApp }, provideElectronApp) => { await provideElectronApp(await launchElectronApp()); }, page: async ({ electronApp }, providePage) => { await providePage(await getStableWindow(electronApp)); }, }); export async function completeSetup(page: Page): Promise { const setupPage = page.getByTestId('setup-page'); if (await setupPage.isVisible()) { await page.getByTestId('setup-skip-button').click(); } await expect(page.getByTestId('main-layout')).toBeVisible(); } export { expect };