import fs from 'node:fs/promises'; import { createRequire } from 'node:module'; import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { test as base, expect, _electron as electron, type ElectronApplication, type Page } from '@playwright/test'; const require = createRequire(import.meta.url); const electronExecutable = require('electron') as string; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); const mainEntry = path.join(root, 'dist/main/index.cjs'); const fixturePng = path.join(root, 'e2e/fixtures/sample.png'); const SAMPLE_PNG_B64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=='; export type ElectronFixtures = { electronApp: ElectronApplication; editorWindow: Page; userDataDir: string; sampleImagePath: string; }; async function ensureBuilt() { try { await fs.access(mainEntry); const editorHtml = await fs.readFile(path.join(root, 'dist/renderer/editor.html'), 'utf8'); // Production Vite build uses relative asset URLs (required for Electron file://). if (editorHtml.includes('src="/assets/') || editorHtml.includes("src='/assets/")) { throw new Error('dev-base'); } } catch (err) { const reason = err instanceof Error && err.message === 'dev-base' ? 'dev-base' : 'missing'; throw new Error( reason === 'dev-base' ? 'dist/renderer was built with Vite base "/". Run "npm run build" (production) before e2e.' : `Missing ${mainEntry}. Run "npm run build" (or "npm run test:e2e:build") before e2e.`, ); } } export const test = base.extend({ userDataDir: async ({}, use) => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'dnd-e2e-')); await use(dir); await fs.rm(dir, { recursive: true, force: true }).catch(() => undefined); }, sampleImagePath: async ({}, use) => { await fs.mkdir(path.dirname(fixturePng), { recursive: true }); await fs.writeFile(fixturePng, Buffer.from(SAMPLE_PNG_B64, 'base64')); await use(fixturePng); }, electronApp: async ({ userDataDir }, use) => { await ensureBuilt(); const app = await electron.launch({ executablePath: electronExecutable, // Launch the package root so app.getAppPath() is the repo (not dist/main). args: ['.', `--user-data-dir=${userDataDir}`], cwd: root, env: { ...process.env, NODE_ENV: 'production', DND_SKIP_LICENSE: '1', DND_SKIP_BOOT: '1', ELECTRON_DISABLE_SECURITY_WARNINGS: 'true', }, }); await use(app); try { await app.evaluate(({ app: electronApp }) => { electronApp.exit(0); }); } catch { /* ignore */ } try { await Promise.race([ app.close(), new Promise((resolve) => { setTimeout(resolve, 3_000); }), ]); } catch { /* ignore */ } }, editorWindow: async ({ electronApp }, use) => { const page = await electronApp.firstWindow(); await page.waitForLoadState('domcontentloaded'); await expect(page.getByTestId('players-header-btn')).toBeVisible({ timeout: 60_000 }); await use(page); }, }); export { expect }; /** Stub native open-dialog to return a fixed image path. */ export async function stubOpenImageDialog(app: ElectronApplication, filePath: string) { await app.evaluate(async ({ dialog }, chosen) => { dialog.showOpenDialog = async () => ({ canceled: false, filePaths: [chosen] }); }, filePath); } export async function invokeInRenderer( page: Page, channel: string, payload: unknown = {}, ): Promise { return page.evaluate( async ({ channel: ch, payload: pl }) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any return (window as any).dnd.invoke(ch, pl); }, { channel, payload }, ); }