diff --git a/e2e/fixtures/electron.ts b/e2e/fixtures/electron.ts index 22ea3d4ab..e9c7f1d4d 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,81 @@ 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 + // 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(); }, - // 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. + // + // 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 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(); + 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, logs] = [...seen].filter((p) => p !== selectorWindow); + + 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/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) {} 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: