import { test, expect, _electron as electron } from '@playwright/test'; import type { ElectronApplication, Page } from '@playwright/test'; import path from 'node:path'; // Regression gate for the Electron desktop shell (electron/main.ts + // electron/preload.ts). Launches the real skeleton - the same standalone // Next.js server artifact the Dockerfile produces, booted as a child // process by main.ts, with a real BrowserWindow on top - and asserts the // login screen renders with zero uncaught page errors. Every later step in // the Electron rollout (notification bridge, realtime sync, packaging) must // keep this green; run it before touching anything else. // // Requires `npm run build:standalone && npm run build:electron` to have run // first (see package.json's `electron:dev`/`test:electron` scripts, which // this suite assumes but does not itself trigger, matching how // playwright.config.ts's browser suite assumes `npm run build` for its own // prod-mode runs). const projectRoot = path.resolve(__dirname, '..'); test.describe('Electron desktop shell', () => { let electronApp: ElectronApplication; // Named `appWindow`, not `window` - the latter would shadow the DOM // global inside every `appWindow.evaluate(() => window...)` callback // below, silently breaking their typing (evaluate() callbacks run in the // browser context, where `window` must resolve to the DOM global). let appWindow: Page; const pageErrors: Error[] = []; test.beforeAll(async () => { electronApp = await electron.launch({ args: [projectRoot], env: { ...process.env, // Bypass the first-run setup wizard (lib/setup/state.ts's // "bootstrap" state, which 302s everything to /setup) without // needing a reachable JMAP server just to prove the login screen // renders - any non-empty JMAP_SERVER_URL is enough to reach // "env-managed" state and serve the normal app shell. JMAP_SERVER_URL: 'https://emailcore.src-advisory.com', SESSION_SECRET: 'electron-smoke-test-not-for-production', NODE_ENV: 'production', }, }); appWindow = await electronApp.firstWindow(); appWindow.on('pageerror', (error) => { pageErrors.push(error); }); await appWindow.waitForLoadState('domcontentloaded'); }); test.afterAll(async () => { await electronApp?.close(); }); test('boots the standalone server and renders the login screen', async () => { // Same selectors as e2e/login.spec.ts's browser-based check - the // shell should render the identical login form, not a different view. await expect(appWindow.locator('input[type="text"]')).toBeVisible({ timeout: 20000 }); await expect(appWindow.locator('input[type="password"]')).toBeVisible(); }); test('exposes the contextBridge API to the renderer', async () => { const isElectron = await appWindow.evaluate(() => window.vnc?.isElectron); expect(isElectron).toBe(true); }); test('produces zero uncaught page errors', () => { expect(pageErrors).toEqual([]); }); test('the native notification bridge round-trips through IPC', async () => { // Not asserting a real OS toast appears - that isn't observable in CI // (headless runners/CI accounts routinely have no notification // permission, and Notification.isSupported() can legitimately be // false). What matters is that window.vnc.showNotification (exposed by // electron/preload.ts's contextBridge) actually reaches the main // process's ipcMain.handle("vnc:show-notification", ...) and resolves - // proving the renderer -> preload -> main -> Electron Notification API // plumbing is wired, not just that `window.vnc` exists. const result = await appWindow.evaluate(async () => { return window.vnc?.showNotification('Electron smoke test', { body: 'IPC round-trip check', }); }); expect(result).toBeDefined(); expect(typeof result?.shown).toBe('boolean'); }); });