Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 68 additions & 15 deletions e2e/fixtures/electron.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
Expand All @@ -27,29 +28,81 @@ export const test = base.extend<ElectronFixtures>({
},
});

// 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<Page>(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);
},
});

Expand Down
1 change: 1 addition & 0 deletions e2e/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
},
"devDependencies": {
"@playwright/test": "^1.50.0",
"playwright": "^1.50.0",
"electron": "^40.1.0"
}
}
11 changes: 8 additions & 3 deletions electron-app/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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();
Expand Down
9 changes: 9 additions & 0 deletions electron-app/src/app/modeSelector.js
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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) {}
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading