Files
SRCmail/e2e/electron-smoke.spec.ts
T
Bernd Rodler b8f668d25a feat(electron): native notification bridge over contextBridge/IPC
Phase 1 step 3 of VNCprodbuild. electron/preload.ts's contextBridge now
exposes window.vnc.showNotification(title, options), routed via
ipcRenderer.invoke("vnc:show-notification") to a new ipcMain.handle in
electron/main.ts that calls Electron's own Notification API. This is the
desktop shell's native notification path - it sits alongside, not in place
of, the browser/PWA's service-worker push path (public/sw.js's push/
notificationclick handlers + lib/web-push.ts), which is untouched.

lib/electron-bridge.ts gives the renderer a `isElectronShell()` +
`showElectronNotification()` wrapper so app code can detect the shell and
use the native path instead of/alongside SW push - not wired to any real
mail-delivery trigger yet, that's Phase 1 steps 4-6 (JMAP realtime
capability investigation, the background/foreground strategy decision, and
implementing it).

Extended e2e/electron-smoke.spec.ts to prove the IPC plumbing actually
fires end-to-end: calls window.vnc.showNotification from the renderer and
asserts the round-trip resolves (not that a real OS toast appears - not
observable in CI). Verified locally: the call resolves {"shown":true} on
this machine, confirming it genuinely reaches Electron's Notification API
and back, not just that window.vnc exists.

Also fixes a real bug caught by this step's typecheck: the smoke test's
Playwright Page variable was named `window`, shadowing the DOM global
inside every evaluate() callback and silently breaking their types. Renamed
to `appWindow`.

All 4 smoke-test assertions green: npm run build:electron && npm run
test:electron.
2026-08-04 12:48:30 +02:00

91 lines
3.9 KiB
TypeScript

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://stalwart.sandbox.vnc.de',
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');
});
});