import { test, expect, _electron as electron } from '@playwright/test'; import type { ElectronApplication, Page } from '@playwright/test'; import { spawn, type ChildProcess } from 'node:child_process'; import { createServer } from 'node:net'; import { get as httpGet } from 'node:http'; import path from 'node:path'; import { ACCOUNTS, JMAP_URL } from './helpers/config'; import { sendMail } from './helpers/smtp'; import { JmapClient } from './helpers/jmap'; import { expectFolderUnread } from './helpers/app'; /** * Electron desktop shell against the real Stalwart fixture, end to end. * * Unlike e2e/electron-smoke.spec.ts (which calls window.vnc.showNotification * directly to prove the IPC bridge itself is wired), this launches the real * Electron shell, logs in as a real account against this same integration * stack's Stalwart, injects a message over SMTP exactly like * 02-mail-sync.spec.ts does for the browser-based suite, and asserts a * native notification fires as a side effect of the REAL push pipeline: * * SMTP delivery -> Stalwart -> JMAP StateChange push (lib/jmap/client.ts) * -> stores/email-store.ts's handleStateChange -> handleNewEmailNotification * -> app/(main)/[locale]/page.tsx's effect -> lib/electron-bridge.ts's * showElectronNotification() -> the contextBridge/IPC bridge * (electron/preload.ts) -> electron/main.ts's ipcMain.handle, which is * what actually shows the OS notification (and increments the * __notificationCallCount test hook this test polls). * * Nothing here is mocked - real SMTP socket, real Stalwart, real Electron * process, real IPC. * * WHY A DEV SERVER, NOT THE STANDALONE BUILD: electron/main.ts normally boots * the production "standalone" artifact (Phase 1 step 1), whose CSP * (proxy.ts) only allows TLS connections in production (`https:`/`wss:`). * This fixture's Stalwart is deliberately plain HTTP - the same reason * integration/webmail.Dockerfile runs the browser-suite's webmail in dev * mode instead of building it. This test makes the identical trade-off: * electron/main.ts's ELECTRON_LOAD_URL escape hatch (test-only, never used * by real users or any packaging/CI path) points the shell at a `next dev` * server this test spawns itself, instead of the standalone build. That * still exercises the real preload/IPC bridge, the real JMAP client * (identical source either way), and the real notification handler - the * only thing NOT covered here is the standalone-server-boot mechanism * itself, which e2e/electron-smoke.spec.ts already covers separately. * * NOTE on "the real WebSocket path": confirmed against the actual * `stalwartlabs/stalwart:v0.16` image this fixture runs (same as the * sandbox server this feature was built against) that its /jmap/ws endpoint * requires the same HTTP Authorization header as every other JMAP endpoint * on the WebSocket UPGRADE request itself - and confirmed separately that * the browser WebSocket API has no way to attach a custom header to that * handshake (a WHATWG spec restriction, not a CSP or Electron quirk - CSP * was a real, now-fixed blocker for reaching the network at all, see the * commit that added `wss:` to proxy.ts's production connect-src, but is not * why THIS specific handshake fails). So the WS attempt below will reach * the network correctly but still fail authentication against Stalwart * every time, and the client's circuit breaker (wsPermanentlyDisabled, * after 5 quick attempts) falls back to SSE within a few seconds. That * fallback is what actually delivers the push exercised below - a real, * working push path, just not literally the WebSocket one. Asserting the WS * handshake itself succeeds would be asserting something that cannot be * true against this server from a browser context; the assertion here is * on the thing that IS true end to end: a real delivery reaches the native * notification bridge no matter which transport carried the StateChange. */ const alice = ACCOUNTS.alice; const projectRoot = path.resolve(__dirname, '../..'); function getFreePort(): Promise { return new Promise((resolve, reject) => { const server = createServer(); server.unref(); server.on('error', reject); server.listen(0, '127.0.0.1', () => { const address = server.address(); if (address && typeof address === 'object') { const { port } = address; server.close(() => resolve(port)); } else { server.close(() => reject(new Error('Could not allocate a free localhost port'))); } }); }); } function waitForServerReady(url: string, timeoutMs: number): Promise { const deadline = Date.now() + timeoutMs; return new Promise((resolve, reject) => { const attempt = () => { const req = httpGet(url, (res) => { res.resume(); resolve(); }); req.on('error', () => { if (Date.now() > deadline) { reject(new Error(`Dev server never became reachable at ${url}`)); return; } setTimeout(attempt, 300); }); }; attempt(); }); } async function getNotificationCallCount(app: ElectronApplication): Promise { return app.evaluate(({ app: electronApp }) => { const counters = electronApp as unknown as { __notificationCallCount?: number }; return counters.__notificationCallCount ?? 0; }); } test.describe('Electron desktop shell - real push notification', () => { test('a real SMTP delivery triggers the native notification bridge', async () => { const jmap = await JmapClient.connect(alice.email, alice.password); await jmap.reset(); const devPort = await getFreePort(); const devUrl = `http://127.0.0.1:${devPort}`; // `next dev` (not the standalone build - see the header comment above // for why) with JMAP_SERVER_URL pointed at this fixture's real Stalwart. const devServer: ChildProcess = spawn('npx', ['next', 'dev', '--turbopack', '-p', String(devPort)], { cwd: projectRoot, env: { ...process.env, JMAP_SERVER_URL: JMAP_URL, // Must be >= 32 chars (lib/impersonation/master-config.ts) - anything // shorter logs a "Failed to store Stalwart auth context" error on // every request. Not a real secret either way. SESSION_SECRET: 'integration-not-a-real-secret-32-chars!!', NODE_ENV: 'development', }, stdio: 'pipe', }); devServer.stderr?.on('data', (chunk) => process.stderr.write(`[next dev] ${chunk}`)); let electronApp: ElectronApplication | undefined; try { // next dev's cold compile of the login route can take a while the // first time - generous timeout, matches this suite's overall 90s // test timeout with headroom for what comes after. await waitForServerReady(devUrl, 60000); electronApp = await electron.launch({ args: [projectRoot], env: { ...process.env, ELECTRON_LOAD_URL: devUrl, }, }); const appWindow: Page = await electronApp.firstWindow(); await appWindow.waitForLoadState('domcontentloaded'); // Diagnosing a failure locally: temporarily add // appWindow.on('console', (msg) => console.log(msg.type(), msg.text())); // appWindow.on('request', (req) => { if (/jmap/i.test(req.url())) console.log(req.method(), req.url()); }); // right here - that's what surfaced the WS-then-SSE-fallback sequence // this test now relies on, and would surface the same for whatever // trips the retry below. // Real login through the actual form - same selectors // integration/tests/helpers/app.ts's submitCredentials() uses. Not // reusing that helper directly because it also calls page.goto('/'), // which would navigate this window away from the dev server // electron/main.ts already loaded it against. await appWindow.locator('#username').waitFor({ state: 'visible', timeout: 30000 }); await appWindow.fill('#username', alice.email); await appWindow.fill('#password', alice.password); await appWindow.click('button[type="submit"]'); await appWindow.locator('[data-testid="account-switcher"]').first().waitFor({ state: 'visible', timeout: 30000 }); // The account switcher rendering only means the sidebar chrome is up, // not that the Inbox has actually loaded/been auto-selected yet - the // "new mail" notification only fires when handleStateChange's refresh // finds an actively-SELECTED inbox (stores/email-store.ts's // refreshCurrentMailbox() early-returns with no selectedMailbox). // Same wait 02-mail-sync.spec.ts's very first test uses right after // login, before its own first delivery, for exactly this reason. await expectFolderUnread(appWindow, { role: 'inbox' }, 0); // Baseline before triggering delivery, so this assertion is robust // even if a stray notification fired during login/setup. const before = await getNotificationCallCount(electronApp); const subject = `IT electron-push ${Date.now()}`; await sendMail({ from: alice.email, authPass: alice.password, to: alice.email, subject, body: 'hi from the electron integration test', }); await expect .poll(() => getNotificationCallCount(electronApp!), { timeout: 60000, message: 'native notification bridge never fired after a real SMTP delivery', }) .toBeGreaterThan(before); } finally { await electronApp?.close(); devServer.kill(); } }); });