From 224e4e6414af7ad9468001088f30c8451aa27572 Mon Sep 17 00:00:00 2001 From: Hamdi LAADHARI Date: Mon, 7 Sep 2026 15:56:23 +0200 Subject: [PATCH] test: boot the built app in CI so a blank window fails the build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #29 fixed a preload that threw on load, leaving window.electronAPI undefined and the renderer dead on its first property access — the app opened to a blank window. Every existing check passed on it: the import was valid TypeScript, the unit tests never boot Electron, and `npm run pack` builds the app without launching it. Adds `npm run test:smoke`, which boots the real dist/main.js and asserts the renderer actually came up: the preload loaded without error, every CHANNELS key is exposed on window.electronAPI, and #root has content. It reads the channel list from the compiled ipc-types, so a channel added to the contract without reaching the renderer fails here too. Verified against the regression itself — rebuilding the preload with plain tsc makes it exit 1 and name the cause: FAIL the preload script loaded — module not found: ./ipc-types FAIL contextBridge exposed window.electronAPI — got undefined FAIL React mounted and rendered — #root is empty Runs after the pack step, which has already produced dist/. Co-Authored-By: Claude Opus 5 --- .github/workflows/release.yml | 5 ++ package.json | 1 + scripts/smoke-test.js | 87 +++++++++++++++++++++++++++++++++++ 3 files changed, 93 insertions(+) create mode 100644 scripts/smoke-test.js diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5b98d83..72fe916 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -50,6 +50,11 @@ jobs: - name: Test build (compile + pack only) run: npm run pack + # Boots the packaged output in Electron. Everything above passes on an + # app that opens to a blank window, because nothing above launches it. + - name: Smoke test (boot the built app) + run: npm run test:smoke + build: name: Build & Publish macOS (Intel + Apple Silicon) runs-on: macos-latest diff --git a/package.json b/package.json index ad085c1..765885a 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "test": "vitest run", "test:watch": "vitest", "test:coverage": "vitest run --coverage", + "test:smoke": "electron scripts/smoke-test.js", "pack": "npm run build && electron-builder --dir", "dist": "npm run build && electron-builder --publish=never", "build:mac": "npm run build && electron-builder --mac", diff --git a/scripts/smoke-test.js b/scripts/smoke-test.js new file mode 100644 index 0000000..7caae91 --- /dev/null +++ b/scripts/smoke-test.js @@ -0,0 +1,87 @@ +// Boots the real built app in Electron and asserts the renderer actually +// came up. Exists because lint, typecheck, the unit tests and `npm run pack` +// all pass on an app that opens to a blank window: none of them launch it. +// +// The specific failure this guards against is a preload that throws on load. +// Electron sandboxes the renderer by default, and a sandboxed preload can +// only require Electron's own builtins — so a stray `require("./something")` +// in the preload silently leaves window.electronAPI undefined and the +// renderer dead on its first property access. +// +// Run with `npm run test:smoke`, after a build. +const { app, BrowserWindow } = require("electron"); +const path = require("node:path"); +const { CHANNELS } = require(path.join(__dirname, "..", "dist", "ipc-types.js")); + +const LOAD_TIMEOUT_MS = 30000; +const failures = []; +const preloadErrors = []; + +// Attach before the real main process creates its window, so a preload that +// fails during startup is still observed. +app.on("browser-window-created", (_event, win) => { + win.webContents.on("preload-error", (_e, preloadPath, error) => { + preloadErrors.push(`${preloadPath}: ${error.message}`); + }); +}); + +require(path.join(__dirname, "..", "dist", "main.js")); + +function check(description, condition, detail) { + if (condition) { + console.log(` ok ${description}`); + return; + } + console.log(` FAIL ${description}${detail ? ` — ${detail}` : ""}`); + failures.push(description); +} + +async function waitForRenderer(win) { + const deadline = Date.now() + LOAD_TIMEOUT_MS; + while (Date.now() < deadline) { + if (!win.webContents.isLoading()) return true; + await new Promise((resolve) => setTimeout(resolve, 250)); + } + return false; +} + +async function run() { + const win = BrowserWindow.getAllWindows()[0]; + if (!win) { + console.log(" FAIL the app created a window"); + return 1; + } + + const loaded = await waitForRenderer(win); + check("the renderer finished loading", loaded, `still loading after ${LOAD_TIMEOUT_MS}ms`); + + check("the preload script loaded", preloadErrors.length === 0, preloadErrors.join("; ")); + + const apiType = await win.webContents.executeJavaScript("typeof window.electronAPI"); + check("contextBridge exposed window.electronAPI", apiType === "object", `got ${apiType}`); + + if (apiType === "object") { + const exposed = await win.webContents.executeJavaScript("Object.keys(window.electronAPI)"); + const missing = Object.keys(CHANNELS).filter((name) => !exposed.includes(name)); + check("every IPC channel is exposed", missing.length === 0, `missing: ${missing.join(", ")}`); + } + + const rendered = await win.webContents.executeJavaScript( + "document.getElementById('root')?.textContent?.trim().length ?? 0" + ); + check("React mounted and rendered", rendered > 0, "#root is empty"); + + return failures.length === 0 ? 0 : 1; +} + +app.whenReady().then(async () => { + console.log("Smoke test: booting the built app"); + let code = 1; + try { + code = await run(); + } catch (error) { + console.log(` FAIL the smoke test threw — ${error.message}`); + } + console.log(code === 0 ? "Smoke test passed." : `Smoke test failed (${failures.length}).`); + app.exit(code); +});