From 0b305f722cc9b7bbcf6ef56c20f1ce8244283516 Mon Sep 17 00:00:00 2001 From: Javier Ribal del Rio Date: Sun, 2 Aug 2026 22:30:20 +0200 Subject: [PATCH 1/4] fix(e2e): adapt Playwright fixture to mode-selector startup flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The app fixture assumed windows open automatically on launch (Backend Logs first, Control Station second), which stopped being true when "feat: selector mode" (143a00bf) added a blocking "Select Mode" window that requires an explicit IPC event before any other window is created. Drive that IPC event directly, and capture the resulting Page objects from waitForEvent instead of re-indexing app.windows() later, since window creation order flipped (Control Station now opens before Backend Logs) and the selector window lingers open for a while after selection. Also add playwright as an explicit e2e devDependency alongside @playwright/test, since CI's `pnpm --filter e2e exec playwright` step was failing with "Command playwright not found" — the CLI bin was only present transitively via @playwright/test. --- e2e/fixtures/electron.ts | 48 ++++++++++++++++++++++++++-------------- e2e/package.json | 1 + pnpm-lock.yaml | 3 +++ 3 files changed, 36 insertions(+), 16 deletions(-) diff --git a/e2e/fixtures/electron.ts b/e2e/fixtures/electron.ts index 22ea3d4ab..8ccac7c43 100644 --- a/e2e/fixtures/electron.ts +++ b/e2e/fixtures/electron.ts @@ -12,6 +12,7 @@ const ELECTRON_APP_PATH = path.resolve(__dirname, "../../electron-app"); type ElectronFixtures = { app: ElectronApplication; + windows: { main: Page; logs: Page }; page: Page; logPage: Page; }; @@ -27,29 +28,44 @@ export const test = base.extend({ }, }); - // Wait for both windows to open before yielding the app fixture, - // so logPage and logWindow fixtures can safely index into app.windows() - await app.firstWindow(); // Backend Logs — always first - await app.waitForEvent("window"); // Control Station — always second - await use(app); await app.close(); }, - // Backend logs window — always opens first - logPage: async ({ app }, use) => { - const page = app.windows()[0]; - await page.waitForLoadState("domcontentloaded"); - await use(page); + // Startup shows a "Select Mode" window first (see + // electron-app/src/app/modeSelector.js) that blocks on an IPC event until + // a mode is chosen. Send it directly instead of clicking a button, so this + // doesn't depend on how many view folders the build produced. + // + // createWindow() runs before createLogWindow() inside the "mode-selected" + // handler, so Control Station opens before Backend Logs. + windows: async ({ app }, use) => { + const selectorWindow = await app.firstWindow(); + await selectorWindow.waitForLoadState("domcontentloaded"); + await selectorWindow.evaluate(() => + (window as any).electronAPI.setInitialMode("testing"), + ); + + const main = await app.waitForEvent("window"); + const logs = await app.waitForEvent("window"); + + await main.waitForLoadState("domcontentloaded"); + await logs.waitForLoadState("domcontentloaded"); + + await use({ main, logs }); + }, + + logPage: async ({ windows }, use) => { + await use(windows.logs); }, - // Main control station window — always opens second // Waits for the app to reach "active" mode before yielding - page: async ({ app }, use) => { - const page = app.windows()[1]; - await page.waitForLoadState("domcontentloaded"); - await page.waitForSelector('[data-testid="mode-badge"]:not([data-mode="loading"])', { timeout: 15000 }); - await use(page); + page: async ({ windows }, use) => { + await windows.main.waitForSelector( + '[data-testid="mode-badge"]:not([data-mode="loading"])', + { timeout: 15000 }, + ); + await use(windows.main); }, }); diff --git a/e2e/package.json b/e2e/package.json index 0d7986fa6..a487373df 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -14,6 +14,7 @@ }, "devDependencies": { "@playwright/test": "^1.50.0", + "playwright": "^1.50.0", "electron": "^40.1.0" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 66a6e72f1..3bbf7e7d5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -39,6 +39,9 @@ importers: electron: specifier: ^40.1.0 version: 40.1.0 + playwright: + specifier: ^1.50.0 + version: 1.58.2 electron-app: dependencies: From 60db6f828abdd53219f69efefd635eecd0953b5d Mon Sep 17 00:00:00 2001 From: Javier Ribal del Rio Date: Sun, 2 Aug 2026 22:37:12 +0200 Subject: [PATCH 2/4] fix(e2e): tolerate selector auto-select race in fixture The e2e build only produces a testing-view folder, so getAvailableViews() returns exactly one view and the selector's own renderer auto-sends that mode as soon as it loads (renderer/mode-selector/index.html's `views.length === 1` shortcut). That can win the race against our explicit setInitialMode() call and close the selector window first, which was failing every CI run with "Target page ... has been closed" right at the selector interaction step. Make the explicit selector call best-effort (same outcome either way), and buffer window-open events instead of awaiting them sequentially, since window creation can likewise outrun sequential waitForEvent("window") calls once a mode is picked. --- e2e/fixtures/electron.ts | 41 +++++++++++++++++++++++++++++++++------- 1 file changed, 34 insertions(+), 7 deletions(-) diff --git a/e2e/fixtures/electron.ts b/e2e/fixtures/electron.ts index 8ccac7c43..9d99dcc91 100644 --- a/e2e/fixtures/electron.ts +++ b/e2e/fixtures/electron.ts @@ -37,17 +37,44 @@ export const test = base.extend({ // a mode is chosen. Send it directly instead of clicking a button, so this // doesn't depend on how many view folders the build produced. // + // If the build only contains one view (as the e2e "testing" build does), + // the selector's own renderer auto-sends that mode as soon as it loads + // (renderer/mode-selector/index.html), which can win the race against our + // explicit call and close the selector window before we reach it — so + // that call is best-effort. Windows are buffered from an event listener + // rather than awaited sequentially, since window creation can likewise + // outrun sequential `waitForEvent("window")` calls once a mode is picked. + // // createWindow() runs before createLogWindow() inside the "mode-selected" - // handler, so Control Station opens before Backend Logs. + // handler, so among the windows opened after the selector, the first is + // Control Station and the second is Backend Logs. windows: async ({ app }, use) => { + const seen = new Set(app.windows()); + app.on("window", (page) => seen.add(page)); + const selectorWindow = await app.firstWindow(); - await selectorWindow.waitForLoadState("domcontentloaded"); - await selectorWindow.evaluate(() => - (window as any).electronAPI.setInitialMode("testing"), - ); + seen.add(selectorWindow); + + try { + await selectorWindow.waitForLoadState("domcontentloaded"); + await selectorWindow.evaluate(() => + (window as any).electronAPI.setInitialMode("testing"), + ); + } catch { + // Selector already auto-selected and closed itself — same outcome. + } + + const start = Date.now(); + while ([...seen].filter((p) => p !== selectorWindow).length < 2) { + if (Date.now() - start > 15000) { + throw new Error( + "Timed out waiting for Control Station and Backend Logs windows to open", + ); + } + await new Promise((resolve) => setTimeout(resolve, 50)); + } - const main = await app.waitForEvent("window"); - const logs = await app.waitForEvent("window"); + const [main, logs] = [...seen].filter((p) => p !== selectorWindow); await main.waitForLoadState("domcontentloaded"); await logs.waitForLoadState("domcontentloaded"); From b3f928f55b139ce90009ba99f0ec7fd50eed99b0 Mon Sep 17 00:00:00 2001 From: Javier Ribal del Rio Date: Sun, 2 Aug 2026 22:40:14 +0200 Subject: [PATCH 3/4] debug(e2e): forward Electron process stdout/stderr into test output Playwright doesn't surface the launched Electron app's own console output or exit reason by default, which leaves "Target page, context or browser has been closed" failures with no clue why the process actually went away. Pipe stdout/stderr and log the exit code/signal so the next CI run shows what's actually happening inside the app. --- e2e/fixtures/electron.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/e2e/fixtures/electron.ts b/e2e/fixtures/electron.ts index 9d99dcc91..e9c7f1d4d 100644 --- a/e2e/fixtures/electron.ts +++ b/e2e/fixtures/electron.ts @@ -28,6 +28,16 @@ export const test = base.extend({ }, }); + // Surface the app's own logging (and any crash) in the test output — + // Playwright doesn't forward it by default, which otherwise leaves + // "Target page, context or browser has been closed" failures with no + // clue as to why the process actually went away. + app.process().stdout?.on("data", (d) => process.stdout.write(`[electron] ${d}`)); + app.process().stderr?.on("data", (d) => process.stderr.write(`[electron] ${d}`)); + app.process().on("exit", (code, signal) => + console.log(`[electron] process exited (code=${code}, signal=${signal})`), + ); + await use(app); await app.close(); }, From 4d789a2cd3ae08c1fa2993d69049ca703133ebce Mon Sep 17 00:00:00 2001 From: Javier Ribal del Rio Date: Sun, 2 Aug 2026 22:58:12 +0200 Subject: [PATCH 4/4] fix(electron-app): guard startup mode-selector handoff from quitting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The selector's own renderer auto-selects a mode and closes itself with zero user interaction whenever only one view is built (see renderer/mode-selector/index.html's `views.length === 1` shortcut) — true for the e2e "testing" build. That renderer-driven window.close() races ahead of the main process's handling of the "mode-selected" IPC event and can complete before createWindow() creates the replacement window. setupLifecycleHandlers() — the only place window-all-closed is handled — was only registered after showModeSelector() resolved, so during that race there was no listener at all: Electron's documented default (quit when all windows close and nothing is listening) fired silently, with exit code 0 and no error logged anywhere. This is what was causing every e2e test to fail with "Target page, context or browser has been closed" — confirmed via added stdout/stderr forwarding in the e2e fixture showing the process exit immediately after "Mode selector found", on a real CI Windows runner. Register lifecycle handlers before showing the selector, and reuse the existing isInTransition guard (the same one the "return-to-selector" flow already relies on for this exact class of problem) around the selector-to-main-window handoff, so a momentary zero-window state during mode selection no longer triggers an unwanted quit. --- electron-app/main.js | 11 ++++++++--- electron-app/src/app/modeSelector.js | 9 +++++++++ 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/electron-app/main.js b/electron-app/main.js index 6ce31fd17..1ca14f188 100644 --- a/electron-app/main.js +++ b/electron-app/main.js @@ -71,6 +71,14 @@ app.whenReady().then(async () => { } }); + // Setup application lifecycle handlers (window-all-closed, before-quit, activate, exceptions) + // before showing the selector — the selector's renderer can auto-select + // and close itself (when only one view is built) before the main + // process finishes creating the replacement window, and without a + // window-all-closed listener in place that transient zero-window state + // would make Electron quit by default. + setupLifecycleHandlers(); + // Show mode selector and get user choice try { await showModeSelector(screenWidth, screenHeight); @@ -81,9 +89,6 @@ app.whenReady().then(async () => { // Setup auto-updater setupUpdater(); - - // Setup application lifecycle handlers (window-all-closed, before-quit, activate, exceptions) - setupLifecycleHandlers(); } catch (error) { logger.electron.error("Failed to initialize application:", error); app.quit(); diff --git a/electron-app/src/app/modeSelector.js b/electron-app/src/app/modeSelector.js index 24c0a92c6..94ca2e7fe 100644 --- a/electron-app/src/app/modeSelector.js +++ b/electron-app/src/app/modeSelector.js @@ -12,6 +12,7 @@ import { logger } from "../utils/logger.js"; import { getAppPath } from "../utils/paths.js"; import { createLogWindow, createWindow } from "../windows/index.js"; import { loadView } from "../windows/mainWindow.js"; +import { setTransitionMode } from "./lifecycle.js"; const VALID_MODES = { testing: "testing-view", @@ -67,6 +68,13 @@ async function showModeSelector(screenWidth, screenHeight) { return; } + // The selector's own renderer can auto-select a mode and close itself + // with zero user interaction when only one view is built (see + // renderer/mode-selector/index.html). That close can complete before + // createWindow() below runs, leaving a moment with no windows open — + // guard window-all-closed against quitting during that handoff. + setTransitionMode(true); + // Listen for mode selection from renderer ipcMain.once("mode-selected", async (_event, mode) => { try { @@ -97,6 +105,7 @@ async function showModeSelector(screenWidth, screenHeight) { logger.electron.error("Error handling mode selection:", error); reject(error); } finally { + setTransitionMode(false); try { selectorWindow.close(); } catch (e) {}