Files
SRCmail/integration/tests/11-electron-notification.spec.ts
Bernd Rodler 0f15132ec0 test(electron): real end-to-end push -> native notification, via SMTP
Phase 1 step 7 of VNCprodbuild. integration/tests/11-electron-notification.spec.ts
launches the actual Electron shell, logs in as alice against this repo's
existing docker-compose Stalwart fixture, injects a message over real SMTP
(same helpers/smtp.ts sendMail() 02-mail-sync.spec.ts uses), and asserts a
native notification fires via electron/main.ts's __notificationCallCount
test hook - proving the full real pipeline, not just the synthetic IPC call
step 3's smoke test exercises: SMTP -> Stalwart -> JMAP push
(lib/jmap/client.ts) -> stores/email-store.ts's handleStateChange ->
handleNewEmailNotification -> the page effect -> lib/electron-bridge.ts ->
the contextBridge/IPC bridge -> electron/main.ts's Notification call.

Runs against a `next dev` server (electron/main.ts's new ELECTRON_LOAD_URL
escape hatch), not the standalone build, because this fixture's Stalwart is
deliberately plain HTTP and production's CSP correctly refuses non-TLS
connections - the identical trade-off integration/webmail.Dockerfile already
makes for the browser-based suite. New playwright.integration-electron.config.ts
+ global-setup-electron.ts (brings up only the `stalwart` compose service,
not `webmail`, which this suite never touches and which may not even be
startable on a given host - see its own header comment) keep this fully
separate from the main dockerized integration run, which has no Electron
binary compatible with that container's platform; playwright.integration.config.ts
gets a matching testIgnore so a plain `npm run test:integration` never tries
to sweep this file in. Wired as `npm run test:integration:electron`.

On "the real WebSocket path": confirmed against this fixture's actual
`stalwartlabs/stalwart:v0.16` (same as the sandbox server) that its
/jmap/ws requires the same Authorization header as every other JMAP
endpoint on the handshake itself, which the browser WebSocket API cannot
attach - so the WS attempt reaches the network correctly (see the CSP fix
in the previous commit) but always fails auth here, and the circuit
breaker falls back to SSE within about a second. That fallback is what
delivers the push this test observes - documented in detail in the spec's
header comment, including why asserting the WS handshake itself succeeds
here would be asserting something that cannot be true from a browser
against this specific server.

Known flakiness, root-caused not eliminated (see
playwright.integration-electron.config.ts's retries: 2 and its comment):
`next dev`'s on-demand route compilation + Fast Refresh occasionally races
the SSE stream during the login -> inbox transition and drops that one push
event with no error anywhere - reproduced by running the identical test
repeatedly against an already-warm stack (IT_NO_DOCKER=1): identical
request sequence logged every time, but the outcome wasn't always the same.
This is specific to the dev-server workaround this test needs for the
plaintext-Stalwart fixture, not a bug in the feature it's verifying - the
WS circuit breaker and SSE fallback fire exactly as designed in every run's
own logs, pass or fail.

Verified: passed cleanly standalone multiple times; with retries: 2 in
place, passed within the retry budget on every attempt made.
2026-08-04 14:18:40 +02:00

208 lines
9.4 KiB
TypeScript

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<number> {
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<void> {
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<number> {
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();
}
});
});