fix(electron): disable npmRebuild so packaging doesn't need Xcode CLT

electron-builder's default npmRebuild pass scans the entire node_modules
tree (not just what's actually packaged) for native addons and tries to
recompile them against Electron's ABI via node-gyp. It caught
@parcel/watcher - a transitive devDependency of some dev tool, never
shipped in this app - and hard-failed packaging on any machine without a
full Xcode Command Line Tools install ("gyp: No Xcode or CLT version
detected!"). GitHub's macOS runners happen to have Xcode, which is
presumably why CI never caught this.

The packaged app is plain esbuild-bundled JS with no native modules of
its own; the one native dependency in the repo (@signalapp/sqlcipher,
used by lib/mail-index/) ships prebuilt .node binaries for every
platform and is copied in wholesale by scripts/assemble-standalone.mjs,
never rebuilt by electron-builder. Verified by execution: packaging
failed with npmRebuild at its default (true), succeeded once set false,
and the resulting .dmg launches and runs correctly.

Also adds e2e/electron-live-sandbox.spec.ts - a live-connectivity check
against the real sandbox JMAP backend (stalwart.sandbox.vnc.de), proving
the packaged/launched app reaches it with no TLS/network errors and gets
a real structured auth-rejection on a deliberately fake credential.
Deliberately NOT wired into playwright.electron.config.ts's default
testMatch (electron-smoke.spec.ts only) - this depends on a live external
service and is a manual/opt-in verification tool, not part of the regular
regression suite.

Also carries the pre-existing lib/smime-ca/ejbca.ts no-control-regex
eslint fix from MR !1's branch (not yet merged to dev) so this commit's
own pre-commit hook passes - unrelated to electron work otherwise.
This commit is contained in:
Bernd Rodler
2026-08-05 14:29:17 +02:00
parent 12908ab706
commit 505e65f319
3 changed files with 134 additions and 1 deletions
+113
View File
@@ -0,0 +1,113 @@
import { test, expect, _electron as electron } from '@playwright/test';
import type { ElectronApplication, Page } from '@playwright/test';
import path from 'node:path';
// Live-sandbox verification run (not part of the regular regression suite).
//
// Unlike e2e/electron-smoke.spec.ts (which deliberately uses a fake
// JMAP_SERVER_URL just to skip the /setup wizard, and never expects a real
// server on the other end), this spec launches the exact same packaged
// artifact against the REAL sandbox JMAP backend at
// https://stalwart.sandbox.vnc.de and proves:
// 1. the login screen renders with no TLS/network errors reaching that host
// 2. submitting an obviously-fake, nonexistent test credential produces a
// structured "invalid credentials" style response from the real server
// (not a network failure) - proving the renderer -> Next API route ->
// real JMAP server round trip works end-to-end, without ever using or
// guessing a real account's credentials.
const projectRoot = path.resolve(__dirname, '..');
const SANDBOX_URL = 'https://stalwart.sandbox.vnc.de';
test.describe('Electron desktop shell - live sandbox connectivity', () => {
let electronApp: ElectronApplication;
let appWindow: Page;
const pageErrors: Error[] = [];
const networkFailures: string[] = [];
test.beforeAll(async () => {
electronApp = await electron.launch({
args: [projectRoot],
env: {
...process.env,
JMAP_SERVER_URL: SANDBOX_URL,
SESSION_SECRET: process.env.SESSION_SECRET || 'live-sandbox-verification-run',
NODE_ENV: 'production',
},
});
appWindow = await electronApp.firstWindow();
appWindow.on('pageerror', (error) => {
pageErrors.push(error);
});
appWindow.on('requestfailed', (request) => {
networkFailures.push(`${request.method()} ${request.url()} - ${request.failure()?.errorText}`);
});
await appWindow.waitForLoadState('domcontentloaded');
});
test.afterAll(async () => {
await electronApp?.close();
});
test('renders the real login screen (not SETUP REQUIRED) with no network/TLS errors', async () => {
const bodyText = await appWindow.locator('body').innerText();
expect(bodyText).not.toContain('SETUP REQUIRED');
expect(bodyText).not.toContain('Setup Required');
const emailInput = appWindow.locator('input[type="text"]').first();
const passwordInput = appWindow.locator('input[type="password"]').first();
await expect(emailInput).toBeVisible({ timeout: 20000 });
await expect(passwordInput).toBeVisible();
await appWindow.screenshot({
path: path.join(projectRoot, 'live-sandbox-login-screen.png'),
fullPage: true,
});
expect(pageErrors.map((e) => e.message).join('\n')).not.toMatch(/ERR_CERT|ERR_CONNECTION|ERR_NAME_NOT_RESOLVED|ECONNREFUSED/i);
expect(networkFailures.join('\n')).not.toMatch(/ERR_CERT|ERR_CONNECTION|ERR_NAME_NOT_RESOLVED|ECONNREFUSED/i);
});
test('submitting a nonexistent test credential reaches the real JMAP server and returns a structured auth error (no real account used/guessed)', async () => {
const emailInput = appWindow.locator('input[type="text"]').first();
const passwordInput = appWindow.locator('input[type="password"]').first();
// Deliberately fake, nonexistent address - not a real account, not a
// guess against one. This only proves the pipe to the real server works.
await emailInput.fill('electron-live-sandbox-verify-8f2c@invalid-test.example');
await passwordInput.fill('not-a-real-password-8f2c');
const allResponses: { url: string; status: number }[] = [];
appWindow.on('response', (res) => {
allResponses.push({ url: res.url(), status: res.status() });
});
await appWindow.locator('button[type="submit"]').first().click();
// The important assertion: the app renders a structured "invalid
// credentials" style error sourced from the real JMAP server's rejection
// (visible in whatever locale the app negotiated), not a network/TLS
// failure. A real connectivity break to stalwart.sandbox.vnc.de would
// instead surface as a generic network-error message or a stuck spinner.
const errorBanner = appWindow.getByText(/invalid|ungültig|incorrect|falsch|unauthorized/i).first();
await expect(errorBanner).toBeVisible({ timeout: 15000 });
const errorText = await errorBanner.innerText();
console.log('[live-sandbox] login error banner text:', errorText);
expect(errorText.length).toBeGreaterThan(0);
expect(errorText).not.toMatch(/network error|failed to fetch|ERR_CERT|ERR_CONNECTION|ECONNREFUSED/i);
await appWindow.screenshot({
path: path.join(projectRoot, 'live-sandbox-after-failed-login-attempt.png'),
fullPage: true,
});
console.log('[live-sandbox] ALL responses observed after click:', JSON.stringify(allResponses, null, 2));
const authResponses = allResponses.filter((r) => r.url.includes('/api/auth/'));
if (authResponses.length > 0) {
for (const r of authResponses) {
expect(r.status).toBeGreaterThanOrEqual(400);
expect(r.status).toBeLessThan(500);
}
}
});
});
+18
View File
@@ -15,6 +15,24 @@ module.exports = {
directories: { directories: {
output: "dist-electron-builds", output: "dist-electron-builds",
}, },
// The packaged app (`files` below) is plain esbuild-bundled JS - no native
// node modules of its own. The one native dependency anywhere in the repo,
// @signalapp/sqlcipher (used by lib/mail-index/**), ships its own prebuilt
// .node binaries for every platform/arch and is copied in wholesale by
// scripts/assemble-standalone.mjs as part of the extraResources standalone
// bundle below - it is never rebuilt by electron-builder.
//
// Without this, electron-builder's default @electron/rebuild pass scans
// the ENTIRE node_modules tree (not just what's actually packaged) for
// anything with a native binding and tries to recompile it from source
// against Electron's ABI via node-gyp. That caught @parcel/watcher - a
// transitive devDependency of some dev tool, never shipped in this app -
// and hard-failed the whole packaging step on any machine without a full
// Xcode Command Line Tools install (`gyp: No Xcode or CLT version
// detected!`), even though nothing that rebuild step touches is part of
// the artifact. Verified by execution: builds failed with npmRebuild at
// its default (true) and succeeded once set to false.
npmRebuild: false,
files: ["dist-electron/**/*", "package.json"], files: ["dist-electron/**/*", "package.json"],
extraResources: [ extraResources: [
{ {
+3 -1
View File
@@ -210,7 +210,9 @@ function escapeDn(value: string): string {
.replace(/([\\,+"<>;=])/g, '\\$1') .replace(/([\\,+"<>;=])/g, '\\$1')
.replace(/^([ #])/, '\\$1') .replace(/^([ #])/, '\\$1')
.replace(/ $/, '\\ ') .replace(/ $/, '\\ ')
// Control characters have no legitimate place in a DN. // Control characters have no legitimate place in a DN — the class below
// is intentional, not a typo.
// eslint-disable-next-line no-control-regex
.replace(/[\x00-\x1f\x7f]/g, ''); .replace(/[\x00-\x1f\x7f]/g, '');
} }