From a2837157180e24b3dcd9896838608898fa111758 Mon Sep 17 00:00:00 2001 From: Javier Ribal del Rio Date: Wed, 21 Oct 2026 10:16:41 +0200 Subject: [PATCH 1/6] feat(electron-app): add menus --- e2e/tests/ui/menus.test.ts | 97 ++++++++++++++ electron-app/main.js | 75 +++++++---- .../src/app/__tests__/modeSelector.test.js | 90 +++++++++++++ .../src/app/__tests__/modeSwitching.test.js | 126 ++++++++++++++++++ electron-app/src/app/index.js | 7 +- electron-app/src/app/initialization.js | 7 +- electron-app/src/app/modeSelector.js | 79 +++++------ electron-app/src/menu/README.md | 17 ++- electron-app/src/menu/__tests__/menu.test.js | 115 ++++++++++++++++ electron-app/src/menu/menu.js | 55 ++++++-- .../src/windows/__tests__/mainWindow.test.js | 37 +++++ electron-app/src/windows/aboutWindow.js | 3 +- electron-app/src/windows/logWindow.js | 3 + electron-app/src/windows/mainWindow.js | 10 +- 14 files changed, 626 insertions(+), 95 deletions(-) create mode 100644 e2e/tests/ui/menus.test.ts create mode 100644 electron-app/src/app/__tests__/modeSelector.test.js create mode 100644 electron-app/src/app/__tests__/modeSwitching.test.js create mode 100644 electron-app/src/menu/__tests__/menu.test.js create mode 100644 electron-app/src/windows/__tests__/mainWindow.test.js diff --git a/e2e/tests/ui/menus.test.ts b/e2e/tests/ui/menus.test.ts new file mode 100644 index 000000000..e669e9ef4 --- /dev/null +++ b/e2e/tests/ui/menus.test.ts @@ -0,0 +1,97 @@ +import type { Page } from "@playwright/test"; +import { expect, test } from "../../fixtures/electron"; + +async function openAdjFromRenderer(page: Page) { + await page.evaluate(() => { + const { electronAPI } = window as unknown as { + electronAPI: { switchView: (view: string) => Promise }; + }; + void electronAPI.switchView("adj-view"); + }); + await expect(page).toHaveURL(/\/adj-view\/index\.html$/); +} + +test("Control Station menu switches modes and returns through the selector", async ({ + app, +}) => { + test.setTimeout(60_000); + + const labels = await app.evaluate(({ Menu }) => { + const menu = Menu.getApplicationMenu()!; + return { + title: menu.items[0].label, + items: menu.items[0] + .submenu!.items.filter((item) => item.type !== "separator") + .map((item) => item.label), + }; + }); + expect(labels.title).toBe("Control Station"); + expect(labels.items[0]).toBe("Reload"); + expect(labels.items.slice(-2)).toEqual(["Return to Selector", "Quit"]); + test.skip( + !labels.items.includes("Logging View") || + !labels.items.includes("ADJ Viewer"), + "Requires Logging and ADJ in the installed build", + ); + + await app.evaluate(({ BrowserWindow, Menu }) => { + const menu = Menu.getApplicationMenu()!; + const logs = BrowserWindow.getAllWindows().find((window) => + window.webContents.getURL().includes("/log-viewer/"), + )!; + const item = menu.items[0].submenu!.items.find( + (item) => item.label === "Logging View", + )!; + item.click(item, logs, {} as never); + }); + await expect + .poll(() => + app + .windows() + .filter((window) => !window.isClosed()) + .map((window) => window.url()), + ) + .toEqual([expect.stringContaining("/logging-view/index.html")]); + + // Exercise renderer navigation: this fails if the old main window cleared + // the replacement's reference when it closed. + const logging = app + .windows() + .find((window) => window.url().includes("/logging-view/index.html"))!; + await logging.waitForLoadState("domcontentloaded"); + await openAdjFromRenderer(logging); + + await app.evaluate(({ BrowserWindow, Menu }) => { + const window = BrowserWindow.getAllWindows()[0]; + const item = Menu.getApplicationMenu()!.items[0].submenu!.items.find( + (item) => item.label === "Return to Selector", + )!; + item.click(item, window, {} as never); + }); + await expect + .poll(() => + app + .windows() + .some((window) => window.url().includes("/mode-selector/index.html")), + ) + .toBe(true); + const selector = app + .windows() + .find((window) => window.url().includes("/mode-selector/index.html"))!; + await selector + .getByRole("button", { name: "Logging View", exact: true }) + .click(); + await expect + .poll(() => + app + .windows() + .filter((window) => !window.isClosed()) + .map((window) => window.url()), + ) + .toEqual([expect.stringContaining("/logging-view/index.html")]); + const replacement = app + .windows() + .find((window) => window.url().includes("/logging-view/index.html"))!; + await replacement.waitForLoadState("domcontentloaded"); + await openAdjFromRenderer(replacement); +}); diff --git a/electron-app/main.js b/electron-app/main.js index 6ce31fd17..d57accbfb 100644 --- a/electron-app/main.js +++ b/electron-app/main.js @@ -1,7 +1,7 @@ /** * @module main * @description Main entry point for the Electron application. - * + * * Orchestrates application lifecycle and initialization through modular components: * - initialization: Config, IPC, and process cleanup * - modeSelector: Mode selection UI and main window creation @@ -13,6 +13,7 @@ import { app, BrowserWindow, screen } from "electron"; import { handleSelectorFallback, initializeApp, + openMode, setTransitionMode, setupLifecycleHandlers, setupUpdater, @@ -21,14 +22,15 @@ import { import { stopBackend } from "./src/processes/backend.js"; import { stopBlcuProgramming } from "./src/processes/blcuProgramming.js"; import { logger } from "./src/utils/logger.js"; +import { getCurrentView, getMainWindow } from "./src/windows/mainWindow.js"; /** * Initializes the application when Electron is ready. * Orchestrates startup sequence: * 1. Initialize config and IPC - * 2. Show mode selector - * 3. Setup auto-updater - * 4. Setup lifecycle handlers + * 2. Register mode switching and lifecycle handlers + * 3. Show mode selector + * 4. Setup auto-updater */ app.whenReady().then(async () => { try { @@ -36,25 +38,46 @@ app.whenReady().then(async () => { await initializeApp(); // Get screen dimensions for window creation - const { width: screenWidth, height: screenHeight } = screen.getPrimaryDisplay().workAreaSize; + const { width: screenWidth, height: screenHeight } = + screen.getPrimaryDisplay().workAreaSize; - // Register return-to-selector handler before starting the app. - app.on("return-to-selector", async () => { + // Keep startup and mode transitions exclusive, including shortcuts in other windows. + let isChangingMode = true; + async function changeMode(mode = null) { + if (isChangingMode) return; + if (mode && getCurrentView() === `${mode}-view`) { + const mainWindow = getMainWindow(); + mainWindow?.show(); + mainWindow?.focus(); + return; + } + + isChangingMode = true; + setTransitionMode(true); + let existingWindows = []; try { - // Set transition mode to prevent window-all-closed from quitting - setTransitionMode(true); - - logger.electron.info("Returning to selector mode..."); + logger.electron.info( + mode + ? `Switching to ${mode} mode...` + : "Returning to selector mode...", + ); await Promise.all([stopBackend(), stopBlcuProgramming()]); - + // Give ports time to be released (increased to 3s for TCP TIME_WAIT state) await new Promise((resolve) => setTimeout(resolve, 3000)); - const existingWindows = BrowserWindow.getAllWindows().filter((window) => !window.isDestroyed()); + existingWindows = BrowserWindow.getAllWindows().filter( + (window) => !window.isDestroyed(), + ); existingWindows.forEach((window) => window.hide()); - const { width: selectorWidth, height: selectorHeight } = screen.getPrimaryDisplay().workAreaSize; - await showModeSelector(selectorWidth, selectorHeight); + const { width: selectorWidth, height: selectorHeight } = + screen.getPrimaryDisplay().workAreaSize; + if (mode) { + await openMode(selectorWidth, selectorHeight, mode); + } else { + await showModeSelector(selectorWidth, selectorHeight); + } existingWindows.forEach((window) => { if (!window.isDestroyed()) { @@ -62,14 +85,21 @@ app.whenReady().then(async () => { window.close(); } }); - - // Exit transition mode once selector is shown - setTransitionMode(false); } catch (error) { - logger.electron.error("Failed to return to selector:", error); + logger.electron.error("Failed to change mode:", error); + existingWindows.forEach((window) => { + if (!window.isDestroyed()) window.show(); + }); + } finally { + isChangingMode = false; setTransitionMode(false); } - }); + } + app.on("return-to-selector", () => changeMode()); + app.on("switch-mode", (mode) => changeMode(mode)); + + // Register shutdown cleanup before the selector can start any services. + setupLifecycleHandlers(); // Show mode selector and get user choice try { @@ -77,13 +107,12 @@ app.whenReady().then(async () => { } catch (error) { logger.electron.error("Mode selector failed:", error); await handleSelectorFallback(screenWidth, screenHeight); + } finally { + isChangingMode = false; } // 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/__tests__/modeSelector.test.js b/electron-app/src/app/__tests__/modeSelector.test.js new file mode 100644 index 000000000..3dd340db0 --- /dev/null +++ b/electron-app/src/app/__tests__/modeSelector.test.js @@ -0,0 +1,90 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { openMode } from "../modeSelector.js"; +import { startBackend } from "../../processes/backend.js"; +import { startBlcuProgramming } from "../../processes/blcuProgramming.js"; +import { createLogWindow, createWindow } from "../../windows/index.js"; +import { loadView } from "../../windows/mainWindow.js"; + +vi.mock("electron", () => ({ + app: {}, + BrowserWindow: vi.fn(), + ipcMain: {}, + screen: {}, +})); +vi.mock("../../processes/backend.js", () => ({ + startBackend: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("../../processes/blcuProgramming.js", () => ({ + startBlcuProgramming: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("../../utils/logger.js", () => ({ + logger: { electron: { header: vi.fn(), error: vi.fn() } }, +})); +vi.mock("../../utils/paths.js", () => ({ getAppPath: () => "/app" })); +vi.mock("../../menu/menu.js", () => ({ setWindowMenu: vi.fn() })); +vi.mock("../../windows/index.js", () => ({ + createWindow: vi.fn(), + createLogWindow: vi.fn(), +})); +vi.mock("../../windows/mainWindow.js", () => ({ loadView: vi.fn() })); + +beforeEach(() => { + vi.resetAllMocks(); + createWindow.mockReturnValue({ + maximize: vi.fn(), + show: vi.fn(), + focus: vi.fn(), + }); + createLogWindow.mockReturnValue({ show: vi.fn() }); +}); + +describe("mode service startup", () => { + it.each(["testing", "competition"])( + "waits for the backend before loading %s", + async (mode) => { + let ready; + startBackend.mockImplementationOnce( + () => + new Promise((resolve) => { + ready = resolve; + }), + ); + const opening = openMode(1280, 800, mode); + expect(startBackend).toHaveBeenCalledWith( + createLogWindow.mock.results[0].value, + ); + expect(loadView).not.toHaveBeenCalled(); + ready(); + await opening; + expect(loadView).toHaveBeenCalledWith(`${mode}-view`); + expect(startBlcuProgramming).not.toHaveBeenCalled(); + }, + ); + + it.each(["logging", "adj"])( + "opens %s without starting services", + async (mode) => { + await openMode(1280, 800, mode); + expect(loadView).toHaveBeenCalledWith(`${mode}-view`); + expect(startBackend).not.toHaveBeenCalled(); + expect(startBlcuProgramming).not.toHaveBeenCalled(); + expect(createLogWindow).not.toHaveBeenCalled(); + }, + ); + + it("starts the programming service before opening Flashing View", async () => { + vi.useFakeTimers(); + try { + const opening = openMode(1280, 800, "flashing"); + await vi.advanceTimersByTimeAsync(4999); + expect(startBlcuProgramming).toHaveBeenCalledOnce(); + expect(loadView).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1); + await opening; + expect(loadView).toHaveBeenCalledWith("flashing-view"); + expect(startBackend).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/electron-app/src/app/__tests__/modeSwitching.test.js b/electron-app/src/app/__tests__/modeSwitching.test.js new file mode 100644 index 000000000..b67015618 --- /dev/null +++ b/electron-app/src/app/__tests__/modeSwitching.test.js @@ -0,0 +1,126 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("electron", async () => { + const { EventEmitter } = await import("node:events"); + const app = new EventEmitter(); + app.whenReady = () => Promise.resolve(); + app.quit = vi.fn(); + return { + app, + BrowserWindow: { getAllWindows: vi.fn(() => []) }, + screen: { + getPrimaryDisplay: () => ({ workAreaSize: { width: 1280, height: 800 } }), + }, + }; +}); +vi.mock("../index.js", () => ({ + initializeApp: vi.fn().mockResolvedValue(undefined), + handleSelectorFallback: vi.fn().mockResolvedValue(undefined), + openMode: vi.fn().mockResolvedValue(undefined), + showModeSelector: vi.fn().mockResolvedValue(undefined), + setTransitionMode: vi.fn(), + setupLifecycleHandlers: vi.fn(), + setupUpdater: vi.fn(), +})); +vi.mock("../../processes/backend.js", () => ({ + stopBackend: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("../../processes/blcuProgramming.js", () => ({ + stopBlcuProgramming: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("../../utils/logger.js", () => ({ + logger: { electron: { info: vi.fn(), error: vi.fn() } }, +})); +vi.mock("../../windows/mainWindow.js", () => ({ + getCurrentView: () => "testing-view", + getMainWindow: vi.fn(), +})); + +const flush = async () => { + for (let i = 0; i < 20; i++) await Promise.resolve(); +}; + +beforeEach(async () => { + vi.resetModules(); + vi.resetAllMocks(); + vi.useFakeTimers(); + const { app } = await import("electron"); + app.removeAllListeners(); +}); +afterEach(() => { + vi.useRealTimers(); + vi.clearAllMocks(); +}); + +async function start() { + const electron = await import("electron"); + const modes = await import("../index.js"); + const backend = await import("../../processes/backend.js"); + await import("../../../main.js"); + await flush(); + return { ...electron, ...modes, ...backend }; +} + +describe("menu mode transitions", () => { + it("opens a requested mode and closes previous windows after startup", async () => { + const h = await start(); + const previous = { + hide: vi.fn(), + close: vi.fn(), + removeAllListeners: vi.fn(), + isDestroyed: () => false, + }; + h.BrowserWindow.getAllWindows.mockReturnValue([previous]); + let finish; + h.openMode.mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + h.app.emit("switch-mode", "logging"); + await vi.advanceTimersByTimeAsync(3000); + expect(h.openMode).toHaveBeenCalledWith(1280, 800, "logging"); + expect(previous.hide).toHaveBeenCalledOnce(); + expect(previous.close).not.toHaveBeenCalled(); + finish(); + await flush(); + expect(previous.removeAllListeners).toHaveBeenCalledWith("close"); + expect(previous.close).toHaveBeenCalledOnce(); + expect(h.setTransitionMode).toHaveBeenLastCalledWith(false); + }); + + it("ignores repeated switch and return requests until the transition finishes", async () => { + const h = await start(); + h.app.emit("return-to-selector"); + h.app.emit("switch-mode", "logging"); + h.app.emit("return-to-selector"); + await vi.advanceTimersByTimeAsync(3000); + expect(h.stopBackend).toHaveBeenCalledOnce(); + expect(h.showModeSelector).toHaveBeenCalledTimes(2); + expect(h.openMode).not.toHaveBeenCalled(); + }); + + it("ignores navigation while the startup selector is active", async () => { + const modes = await import("../index.js"); + modes.showModeSelector.mockImplementationOnce(() => new Promise(() => {})); + const h = await start(); + h.app.emit("return-to-selector"); + h.app.emit("switch-mode", "logging"); + await vi.advanceTimersByTimeAsync(3000); + expect(h.stopBackend).not.toHaveBeenCalled(); + expect(h.showModeSelector).toHaveBeenCalledOnce(); + }); + + it("focuses the current mode instead of restarting its services", async () => { + const h = await start(); + const { getMainWindow } = await import("../../windows/mainWindow.js"); + const window = { show: vi.fn(), focus: vi.fn() }; + getMainWindow.mockReturnValue(window); + h.app.emit("switch-mode", "testing"); + await flush(); + expect(window.focus).toHaveBeenCalledOnce(); + expect(h.stopBackend).not.toHaveBeenCalled(); + expect(h.openMode).not.toHaveBeenCalled(); + }); +}); diff --git a/electron-app/src/app/index.js b/electron-app/src/app/index.js index 5ae193df7..92e851794 100644 --- a/electron-app/src/app/index.js +++ b/electron-app/src/app/index.js @@ -6,6 +6,9 @@ export { cleanupLeftoverBackendProcesses } from "./cleanup.js"; export { initializeApp } from "./initialization.js"; export { setupLifecycleHandlers, setTransitionMode } from "./lifecycle.js"; -export { handleSelectorFallback, showModeSelector } from "./modeSelector.js"; +export { + handleSelectorFallback, + openMode, + showModeSelector, +} from "./modeSelector.js"; export { setupUpdater } from "./updater.js"; - diff --git a/electron-app/src/app/initialization.js b/electron-app/src/app/initialization.js index 433c7fddc..1939d3477 100644 --- a/electron-app/src/app/initialization.js +++ b/electron-app/src/app/initialization.js @@ -3,7 +3,7 @@ * @description Application initialization: config setup and cleanup. */ -import { Menu } from "electron"; +import { app, Menu } from "electron"; import { getConfigManager } from "../config/configInstance.js"; import { setupIpcHandlers } from "../ipc/handlers.js"; import { createMenu } from "../menu/menu.js"; @@ -12,14 +12,15 @@ import { cleanupLeftoverBackendProcesses } from "./cleanup.js"; /** * Initializes the application: - * - Sets the application menu shared by all windows + * - Sets the default application menu (windows can override it) * - Sets up IPC handlers * - Initializes configuration * - Cleans up leftover processes * @returns {Promise} */ async function initializeApp() { - // Application-wide menu so shortcuts (F11 fullscreen, F12 DevTools, ...) work in every window + // macOS uses the application name for the leftmost menu's label. + if (process.platform === "darwin") app.setName("Control Station"); Menu.setApplicationMenu(createMenu()); // Setup IPC handlers for renderer process communication diff --git a/electron-app/src/app/modeSelector.js b/electron-app/src/app/modeSelector.js index 43a5186ec..e58fbab5a 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 { setWindowMenu } from "../menu/menu.js"; const VALID_MODES = { testing: "testing-view", @@ -31,7 +32,6 @@ const VALID_MODES = { */ async function showModeSelector(screenWidth, screenHeight) { return new Promise(async (resolve, reject) => { - let mainWindow = null; let modeSelected = false; const { x, y, width, height } = screen.getPrimaryDisplay().bounds; @@ -60,6 +60,8 @@ async function showModeSelector(screenWidth, screenHeight) { title: "Select Mode", }); + setWindowMenu(selectorWindow, { isSelector: true }); + selectorWindow.on("closed", () => { ipcMain.removeListener("mode-selected", onModeSelected); // Closing the selector cancels startup, including return-to-selector flows @@ -67,13 +69,20 @@ async function showModeSelector(screenWidth, screenHeight) { if (!modeSelected) app.quit(); }); - const selectorPath = path.join(getAppPath(), "renderer", "mode-selector", "index.html"); + const selectorPath = path.join( + getAppPath(), + "renderer", + "mode-selector", + "index.html", + ); if (!fs.existsSync(selectorPath)) { - logger.electron.warning("Mode selector UI not found, using default testing-view"); + logger.electron.warning( + "Mode selector UI not found, using default testing-view", + ); modeSelected = true; + resolve(handleSelectorFallback(screenWidth, screenHeight)); selectorWindow.close(); - resolve({ mode: "default", view: VALID_MODES.default, mainWindow: null }); return; } @@ -90,8 +99,8 @@ async function showModeSelector(screenWidth, screenHeight) { } catch (err) { logger.electron.error("Failed to load selector UI:", err); modeSelected = true; + resolve(handleSelectorFallback(screenWidth, screenHeight)); selectorWindow.close(); - resolve({ mode: "default", view: VALID_MODES.default, mainWindow: null }); return; } @@ -101,29 +110,7 @@ async function showModeSelector(screenWidth, screenHeight) { modeSelected = true; ipcMain.removeListener("mode-selected", onModeSelected); try { - const view = VALID_MODES[mode] || VALID_MODES.default; - - // Create the main window without loading the view yet. - mainWindow = createWindow(screenWidth, screenHeight, null); - try { - mainWindow.maximize(); - } catch (e) {} - logger.electron.header("Main application window created"); - - // Start services and only then load the selected view. - if (view === "testing-view" || view === "flashing-view" || view === "competition-view") { - await startServices(screenWidth, screenHeight, view); - } - - loadView(view); - - // Show and focus main window - try { - mainWindow.show(); - mainWindow.focus(); - } catch (e) {} - - resolve({ mode, view, mainWindow }); + resolve(await openMode(screenWidth, screenHeight, mode)); } catch (error) { logger.electron.error("Error handling mode selection:", error); reject(error); @@ -136,6 +123,21 @@ async function showModeSelector(screenWidth, screenHeight) { }); } +/** Open a mode using the same service startup sequence as the selector. */ +async function openMode(screenWidth, screenHeight, mode) { + const view = VALID_MODES[mode] || VALID_MODES.default; + const mainWindow = createWindow(screenWidth, screenHeight, null); + mainWindow.maximize(); + logger.electron.header("Main application window created"); + + await startServices(screenWidth, screenHeight, view); + loadView(view); + mainWindow.show(); + mainWindow.focus(); + + return { mode, view, mainWindow }; +} + /** * Starts services based on the selected view. * - testing-view: Backend only @@ -182,24 +184,7 @@ async function startServices(screenWidth, screenHeight, view) { * @returns {Promise<{mode: string, view: string, mainWindow: BrowserWindow}>} */ async function handleSelectorFallback(screenWidth, screenHeight) { - const view = VALID_MODES.default; - const mainWindow = createWindow(screenWidth, screenHeight, view); - - try { - mainWindow.maximize(); - } catch (e) {} - - logger.electron.header("Main application window created"); - - try { - mainWindow.show(); - } catch (e) {} - - // Start services by default (testing view) - await startServices(screenWidth, screenHeight, view); - - return { mode: "default", view, mainWindow }; + return openMode(screenWidth, screenHeight, "default"); } -export { handleSelectorFallback, showModeSelector }; - +export { handleSelectorFallback, openMode, showModeSelector }; diff --git a/electron-app/src/menu/README.md b/electron-app/src/menu/README.md index 9b124b27a..72ead00d3 100644 --- a/electron-app/src/menu/README.md +++ b/electron-app/src/menu/README.md @@ -4,7 +4,7 @@ Application menu system for the Electron application. Provides the native menu b ## Overview -Creates the native application menu bar shared by all windows. Menu actions operate on the currently focused window, so shortcuts like fullscreen and DevTools work in the main window, the mode selector, and the log window alike. +Creates native menus for the main window, backend logs, and About window. The mode selector keeps its original File menu. Menu actions such as reload, fullscreen, and DevTools operate on the window that invoked them. ## Files @@ -12,11 +12,14 @@ Creates the native application menu bar shared by all windows. Menu actions oper ## Menu Structure -### File Menu +### Control Station Menu - **Reload** (`CmdOrCtrl+R`) - Reloads the focused window +- **Testing View**, **Competition View**, **Flashing View**, **Logging View**, **ADJ Viewer** - Switches directly to the chosen mode, stopping the previous services and starting those required by the destination. Only views included in the build are listed. - **Return to Selector** (`CmdOrCtrl+Shift+S`) - Stops services and returns to the mode selector -- **Exit** (`CmdOrCtrl+Q`) - Quits the application +- **Quit** (`CmdOrCtrl+Q`) - Quits the application + +The selector retains **File > Reload / Return to Selector / Exit**, with Return to Selector disabled to prevent opening another selector. ### Tools Menu @@ -32,9 +35,13 @@ Creates the native application menu bar shared by all windows. Menu actions oper ## Functions -### `createMenu()` +### `createMenu({ isSelector = false } = {})` + +Builds the Control Station menu, or the original File menu when `isSelector` is true. The default menu is installed during application initialization. + +### `setWindowMenu(window, options)` -Builds and returns the application menu. Set it globally with `Menu.setApplicationMenu(createMenu())` so it applies to every window. +Assigns a window's menu on Windows/Linux. On macOS, updates the application menu when that window gains focus. ## Dependencies diff --git a/electron-app/src/menu/__tests__/menu.test.js b/electron-app/src/menu/__tests__/menu.test.js new file mode 100644 index 000000000..09c776549 --- /dev/null +++ b/electron-app/src/menu/__tests__/menu.test.js @@ -0,0 +1,115 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { app, BrowserWindow, Menu } from "electron"; +import fs from "fs"; +import { createMenu, setWindowMenu } from "../menu.js"; + +vi.mock("electron", () => ({ + app: { emit: vi.fn(), quit: vi.fn() }, + BrowserWindow: { getFocusedWindow: vi.fn() }, + Menu: { + buildFromTemplate: vi.fn((template) => ({ items: template })), + setApplicationMenu: vi.fn(), + }, +})); +vi.mock("fs", () => ({ default: { existsSync: vi.fn() } })); +vi.mock("../../utils/paths.js", () => ({ getAppPath: () => "/app" })); +vi.mock("../../windows/mainWindow.js", () => ({ + getCurrentView: () => "logging-view", +})); +vi.mock("../../windows/aboutWindow.js", () => ({ showAboutWindow: vi.fn() })); + +beforeEach(() => { + vi.clearAllMocks(); + fs.existsSync.mockReturnValue(true); +}); +afterEach(() => vi.unstubAllGlobals()); + +describe("Control Station menu", () => { + it("orders reload, available views, return to selector and quit", () => { + const menu = createMenu(); + expect(menu.items[0].label).toBe("Control Station"); + expect( + menu.items[0].submenu + .filter((item) => item.label) + .map((item) => item.label), + ).toEqual([ + "Reload", + "Testing View", + "Competition View", + "Flashing View", + "Logging View", + "ADJ Viewer", + "Return to Selector", + "Quit", + ]); + }); + + it("only offers views whose entry point is installed", () => { + fs.existsSync.mockImplementation( + (file) => file === "/app/renderer/logging-view/index.html", + ); + const items = createMenu().items[0].submenu; + expect( + items + .filter((item) => item.label?.endsWith("View")) + .map((item) => item.label), + ).toEqual(["Logging View"]); + expect(items.some((item) => item.label === "ADJ Viewer")).toBe(false); + }); + + it("requests a mode change for each destination", () => { + const items = createMenu().items[0].submenu; + for (const [label, mode] of [ + ["Testing View", "testing"], + ["Competition View", "competition"], + ["Flashing View", "flashing"], + ["Logging View", "logging"], + ["ADJ Viewer", "adj"], + ]) { + items.find((item) => item.label === label).click(); + expect(app.emit).toHaveBeenLastCalledWith("switch-mode", mode); + } + }); + + it("reloads the invoking window rather than whichever window is focused", () => { + const window = { reload: vi.fn() }; + createMenu().items[0].submenu[0].click(null, window); + expect(window.reload).toHaveBeenCalledOnce(); + expect(BrowserWindow.getFocusedWindow).not.toHaveBeenCalled(); + }); + + it("preserves the selector menu without mode switching or nested selectors", () => { + const menu = createMenu({ isSelector: true }); + expect(menu.items[0].label).toBe("File"); + expect( + menu.items[0].submenu + .filter((item) => item.label) + .map((item) => item.label), + ).toEqual(["Reload", "Return to Selector", "Exit"]); + expect( + menu.items[0].submenu.find((item) => item.label === "Return to Selector") + .enabled, + ).toBe(false); + }); + + it("assigns menus per window on Windows and Linux", () => { + vi.stubGlobal("process", { ...process, platform: "linux" }); + const window = { setMenu: vi.fn() }; + setWindowMenu(window); + expect(window.setMenu.mock.calls[0][0].items[0].label).toBe( + "Control Station", + ); + expect(Menu.setApplicationMenu).not.toHaveBeenCalled(); + }); + + it("updates the macOS application menu when a window gains focus", () => { + vi.stubGlobal("process", { ...process, platform: "darwin" }); + const window = { on: vi.fn(), isFocused: () => false }; + setWindowMenu(window, { isSelector: true }); + expect(window.on).toHaveBeenCalledWith("focus", expect.any(Function)); + window.on.mock.calls[0][1](); + expect(Menu.setApplicationMenu.mock.calls[0][0].items[0].label).toBe( + "File", + ); + }); +}); diff --git a/electron-app/src/menu/menu.js b/electron-app/src/menu/menu.js index 000ad85f6..41c3c1dc0 100644 --- a/electron-app/src/menu/menu.js +++ b/electron-app/src/menu/menu.js @@ -1,27 +1,47 @@ /** * @module menu * @description Application menu creation and management for the Electron application. - * Defines menu structure with File, Tools, and Help sections with keyboard shortcuts and actions. + * Defines window menus and shortcuts for navigating the Control Station. */ import { BrowserWindow, Menu, app } from "electron"; +import fs from "fs"; +import path from "path"; +import { getAppPath } from "../utils/paths.js"; import { showAboutWindow } from "../windows/aboutWindow.js"; import { getCurrentView } from "../windows/mainWindow.js"; /** - * Creates the application menu with File, Tools, and Help sections. - * Includes menu items for reloading, exiting, toggling fullscreen/DevTools, and app information. - * Menu actions operate on the currently focused window, so the menu can be - * shared by all windows via Menu.setApplicationMenu. - * View switching is no longer available since the mode is selected at startup. + * Creates the Control Station menu, or the original File menu for the selector. + * Only views included in the installed build are offered as destinations. * @returns {Menu} The built application menu. * @example * Menu.setApplicationMenu(createMenu()); */ -function createMenu() { +function createMenu({ isSelector = false } = {}) { + const views = [ + { mode: "testing", label: "Testing View" }, + { mode: "competition", label: "Competition View" }, + { mode: "flashing", label: "Flashing View" }, + { mode: "logging", label: "Logging View" }, + { mode: "adj", label: "ADJ Viewer" }, + ]; + const viewItems = isSelector + ? [] + : views + .filter(({ mode }) => + fs.existsSync( + path.join(getAppPath(), "renderer", `${mode}-view`, "index.html"), + ), + ) + .map(({ mode, label }) => ({ + label, + click: () => app.emit("switch-mode", mode), + })); + const template = [ { - label: "File", + label: isSelector ? "File" : "Control Station", submenu: [ { label: "Reload", @@ -32,14 +52,18 @@ function createMenu() { } }, }, + ...(viewItems.length + ? [{ type: "separator" }, ...viewItems, { type: "separator" }] + : []), { label: "Return to Selector", accelerator: "CmdOrCtrl+Shift+S", + enabled: !isSelector, click: () => app.emit("return-to-selector"), }, { type: "separator" }, { - label: "Exit", + label: isSelector ? "Exit" : "Quit", accelerator: "CmdOrCtrl+Q", click: () => app.quit(), }, @@ -92,4 +116,15 @@ function createMenu() { return menu; } -export { createMenu }; +/** Assign a menu to a window; macOS uses the focused window's application menu. */ +function setWindowMenu(window, options) { + const menu = createMenu(options); + if (process.platform === "darwin") { + window.on("focus", () => Menu.setApplicationMenu(menu)); + if (window.isFocused()) Menu.setApplicationMenu(menu); + } else { + window.setMenu(menu); + } +} + +export { createMenu, setWindowMenu }; diff --git a/electron-app/src/windows/__tests__/mainWindow.test.js b/electron-app/src/windows/__tests__/mainWindow.test.js new file mode 100644 index 000000000..fbb2018d6 --- /dev/null +++ b/electron-app/src/windows/__tests__/mainWindow.test.js @@ -0,0 +1,37 @@ +import { EventEmitter } from "node:events"; +import { beforeEach, expect, it, vi } from "vitest"; +import { createWindow, getMainWindow, loadView } from "../mainWindow.js"; + +vi.mock("electron", () => ({ + app: { isPackaged: true, quit: vi.fn() }, + dialog: { showErrorBox: vi.fn() }, + Menu: { buildFromTemplate: vi.fn() }, + BrowserWindow: class extends EventEmitter { + constructor() { + super(); + this.webContents = new EventEmitter(); + this.loadFile = vi.fn().mockResolvedValue(undefined); + this.setTitle = vi.fn(); + this.isDestroyed = () => false; + } + }, +})); +vi.mock("fs", () => ({ default: { existsSync: () => true } })); +vi.mock("../../utils/paths.js", () => ({ getAppPath: () => "/app" })); +vi.mock("../../menu/menu.js", () => ({ setWindowMenu: vi.fn() })); +vi.mock("../aboutWindow.js", () => ({ showAboutWindow: vi.fn() })); + +beforeEach(() => vi.clearAllMocks()); + +it("keeps the replacement window usable when the previous window closes", () => { + const previous = createWindow(1280, 800, null); + const replacement = createWindow(1280, 800, null); + previous.emit("closed"); + expect(getMainWindow()).toBe(replacement); + loadView("adj-view"); + expect(replacement.loadFile).toHaveBeenCalledWith( + "/app/renderer/adj-view/index.html", + ); + replacement.emit("closed"); + expect(getMainWindow()).toBeNull(); +}); diff --git a/electron-app/src/windows/aboutWindow.js b/electron-app/src/windows/aboutWindow.js index 044ec45b5..c25a2e9a1 100644 --- a/electron-app/src/windows/aboutWindow.js +++ b/electron-app/src/windows/aboutWindow.js @@ -9,6 +9,7 @@ import { BrowserWindow } from "electron"; import path from "path"; import { getAppPath } from "../utils/paths.js"; import { logger } from "../utils/logger.js"; +import { setWindowMenu } from "../menu/menu.js"; let aboutWindow = null; @@ -41,7 +42,7 @@ function showAboutWindow(parentWindow) { }, }); - aboutWindow.setMenuBarVisibility(false); + setWindowMenu(aboutWindow); const aboutPath = path.join(getAppPath(), "renderer", "about", "index.html"); aboutWindow.loadFile(aboutPath).catch((err) => { diff --git a/electron-app/src/windows/logWindow.js b/electron-app/src/windows/logWindow.js index 4c1c0e27e..4a90c041c 100644 --- a/electron-app/src/windows/logWindow.js +++ b/electron-app/src/windows/logWindow.js @@ -2,6 +2,7 @@ import { BrowserWindow } from "electron"; import path from "path"; import { getAppPath } from "../utils/paths.js"; +import { setWindowMenu } from "../menu/menu.js"; // Get the application root path const appPath = getAppPath(); @@ -20,6 +21,8 @@ export const createLogWindow = (screenWidth, screenHeight) => { }, }); + setWindowMenu(logWindow); + const logFilePath = path.join(appPath, "src", "log-viewer", "index.html"); logWindow.loadFile(logFilePath); diff --git a/electron-app/src/windows/mainWindow.js b/electron-app/src/windows/mainWindow.js index ca46432c7..7469757d1 100644 --- a/electron-app/src/windows/mainWindow.js +++ b/electron-app/src/windows/mainWindow.js @@ -9,6 +9,7 @@ import fs from "fs"; import path from "path"; import { getAppPath } from "../utils/paths.js"; import { showAboutWindow } from "./aboutWindow.js"; +import { setWindowMenu } from "../menu/menu.js"; // Get the application root path const appPath = getAppPath(); @@ -47,6 +48,9 @@ function createWindow(screenWidth, screenHeight, initialView) { backgroundColor: "#1a1a1a", }); + const window = mainWindow; + setWindowMenu(window); + // Right-click anywhere in the view to reach About — only offered in // logging-view, since the About window credits its author specifically // (not the Control Station as a whole). @@ -83,7 +87,7 @@ function createWindow(screenWidth, screenHeight, initialView) { // Clear window reference when closed mainWindow.on("closed", () => { - mainWindow = null; + if (mainWindow === window) mainWindow = null; }); return mainWindow; @@ -124,9 +128,7 @@ function loadView(view, opts = {}) { "logging-view": "Logging View", "adj-view": "ADJ Viewer", }; - mainWindow.setTitle( - `Hyperloop Control Station - ${titles[view] ?? view}`, - ); + mainWindow.setTitle(`Hyperloop Control Station - ${titles[view] ?? view}`); } else { // Log error and show dialog if view not found console.error(`View not found: ${viewPath}`); From 8dbc97c83ab1960bec8f700d520b602d45ae183d Mon Sep 17 00:00:00 2001 From: Javier Ribal del Rio Date: Wed, 21 Oct 2026 10:34:03 +0200 Subject: [PATCH 2/6] feat(electrop-app): more menus --- e2e/tests/ui/menus.test.ts | 119 ++++++++++++++ electron-app/renderer/about/index.html | 148 ++++++++++-------- .../src/app/__tests__/modeSelector.test.js | 4 +- .../src/app/__tests__/updater.test.js | 105 +++++++++++++ electron-app/src/app/modeSelector.js | 2 +- electron-app/src/app/updater.js | 96 +++++++++++- electron-app/src/menu/README.md | 20 ++- electron-app/src/menu/__tests__/menu.test.js | 104 +++++++++++- electron-app/src/menu/menu.js | 114 +++++++++++--- electron-app/src/processes/README.md | 2 +- electron-app/src/processes/backend.js | 51 ++---- .../src/windows/__tests__/logWindow.test.js | 93 +++++++++++ electron-app/src/windows/aboutWindow.js | 8 +- electron-app/src/windows/logWindow.js | 55 ++++++- electron-app/src/windows/mainWindow.js | 4 +- 15 files changed, 773 insertions(+), 152 deletions(-) create mode 100644 electron-app/src/app/__tests__/updater.test.js create mode 100644 electron-app/src/windows/__tests__/logWindow.test.js diff --git a/e2e/tests/ui/menus.test.ts b/e2e/tests/ui/menus.test.ts index e669e9ef4..d87e9e086 100644 --- a/e2e/tests/ui/menus.test.ts +++ b/e2e/tests/ui/menus.test.ts @@ -1,6 +1,125 @@ import type { Page } from "@playwright/test"; import { expect, test } from "../../fixtures/electron"; +for (const view of ["Testing View", "Competition View"]) { + test(`Window reopens backend logs with history in ${view}`, async ({ + app, + }) => { + test.setTimeout(60_000); + if (view === "Competition View") { + const available = await app.evaluate(({ Menu }) => + Menu.getApplicationMenu()!.items[0].submenu!.items.some( + (item) => item.label === "Competition View", + ), + ); + test.skip(!available, "Competition View is not installed"); + await app.evaluate(({ BrowserWindow, Menu }) => { + const item = Menu.getApplicationMenu()!.items[0].submenu!.items.find( + (item) => item.label === "Competition View", + )!; + item.click(item, BrowserWindow.getAllWindows()[0], {} as never); + }); + await expect + .poll( + () => + app + .windows() + .some((window) => + window.url().includes("/competition-view/index.html"), + ), + { timeout: 15_000 }, + ) + .toBe(true); + await expect + .poll(() => + app + .windows() + .some((window) => + window.url().includes("/testing-view/index.html"), + ), + ) + .toBe(false); + } + + const logs = app + .windows() + .find((window) => window.url().includes("/log-viewer/index.html"))!; + await expect(logs.locator("#log-container")).not.toBeEmpty(); + const firstLine = await logs + .locator("#log-container > div") + .first() + .innerText(); + await app.evaluate(({ BrowserWindow }) => { + BrowserWindow.getAllWindows() + .find((window) => + window.webContents.getURL().includes("/log-viewer/index.html"), + )! + .close(); + }); + const opened = app.waitForEvent("window"); + await app.evaluate(({ BrowserWindow, Menu }) => { + const menu = Menu.getApplicationMenu()!; + menu.items + .find((item) => item.label === "Window")! + .submenu!.emit("menu-will-show"); + const item = menu.getMenuItemById("show-backend-logs")!; + if (!item.enabled) throw new Error("Backend logs should be available"); + item.click(item, BrowserWindow.getAllWindows()[0], {} as never); + }); + const reopened = await opened; + await expect(reopened).toHaveTitle("Backend Logs"); + await expect(reopened.locator("#log-container")).toContainText(firstLine); + expect( + await app.evaluate( + ({ BrowserWindow }) => + BrowserWindow.getAllWindows().filter((window) => + window.webContents.getURL().includes("/log-viewer/index.html"), + ).length, + ), + ).toBe(1); + }); +} + +test("English menus and About are available in Testing View", async ({ + app, +}) => { + const menus = await app.evaluate(({ Menu }) => { + const menu = Menu.getApplicationMenu()!; + return { + labels: menu.items.map((item) => item.label), + edit: menu.items + .find((item) => item.label === "Edit")! + .submenu!.items.map((item) => item.role) + .filter(Boolean), + help: menu.items + .find((item) => item.label === "Help")! + .submenu!.items.map((item) => item.label), + }; + }); + expect(menus.labels).toEqual([ + "Control Station", + "Edit", + "View", + "Window", + "Help", + ]); + expect(menus.edit).toEqual(["undo", "redo", "cut", "copy", "paste"]); + expect(menus.help).toEqual(["About Control Station", "Check for Updates..."]); + const opened = app.waitForEvent("window"); + await app.evaluate(({ BrowserWindow, Menu }) => { + const item = Menu.getApplicationMenu()!.items.find( + (item) => item.label === "Help", + )!.submenu!.items[0]; + const main = BrowserWindow.getAllWindows().find((window) => + window.webContents.getURL().includes("/testing-view/"), + )!; + item.click(item, main, {} as never); + }); + const about = await opened; + await expect(about).toHaveTitle("About Control Station"); + await expect(about.locator("#app-version")).toContainText("Version"); +}); + async function openAdjFromRenderer(page: Page) { await page.evaluate(() => { const { electronAPI } = window as unknown as { diff --git a/electron-app/renderer/about/index.html b/electron-app/renderer/about/index.html index c758bd0bf..9038c9620 100644 --- a/electron-app/renderer/about/index.html +++ b/electron-app/renderer/about/index.html @@ -1,79 +1,93 @@ + + + + About Control Station + - - - -
- - -

Hyperloop Control Station — Logging View

-

Javier Ribal del Río — H11

-

© HyperloopUPV - Software

-
- + .copy { + margin: 0; + font-size: 0.72rem; + opacity: 0.55; + } + + + +
+ + +

Hyperloop Control Station

+

+

Hyperloop UPV · Software

+

Logging View: Javier Ribal del Río — H11

+

© HyperloopUPV - Software

+
+ + diff --git a/electron-app/src/app/__tests__/modeSelector.test.js b/electron-app/src/app/__tests__/modeSelector.test.js index 3dd340db0..526c9d884 100644 --- a/electron-app/src/app/__tests__/modeSelector.test.js +++ b/electron-app/src/app/__tests__/modeSelector.test.js @@ -50,9 +50,7 @@ describe("mode service startup", () => { }), ); const opening = openMode(1280, 800, mode); - expect(startBackend).toHaveBeenCalledWith( - createLogWindow.mock.results[0].value, - ); + expect(startBackend).toHaveBeenCalledOnce(); expect(loadView).not.toHaveBeenCalled(); ready(); await opening; diff --git a/electron-app/src/app/__tests__/updater.test.js b/electron-app/src/app/__tests__/updater.test.js new file mode 100644 index 000000000..d3ae9a1d0 --- /dev/null +++ b/electron-app/src/app/__tests__/updater.test.js @@ -0,0 +1,105 @@ +import { beforeEach, expect, it, vi } from "vitest"; +import { app, dialog } from "electron"; +import pkg from "electron-updater"; + +vi.mock("electron", () => ({ + app: { isPackaged: true, getVersion: () => "1.2.3" }, + dialog: { showMessageBox: vi.fn().mockResolvedValue({ response: 1 }) }, +})); +vi.mock("electron-updater", () => ({ + default: { + autoUpdater: { + on: vi.fn(), + checkForUpdates: vi.fn(), + quitAndInstall: vi.fn(), + }, + }, +})); +vi.mock("../../utils/logger.js", () => ({ + logger: { + electron: { + info: vi.fn(), + error: vi.fn(), + warning: vi.fn(), + debug: vi.fn(), + }, + }, +})); + +beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + app.isPackaged = true; + pkg.autoUpdater.checkForUpdates.mockReset(); +}); + +it("reports that the installed version is up to date", async () => { + pkg.autoUpdater.checkForUpdates.mockResolvedValue({ + isUpdateAvailable: false, + }); + const { checkForUpdates } = await import("../updater.js"); + await checkForUpdates(); + expect(dialog.showMessageBox).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Control Station is up to date.", + detail: "Current version: 1.2.3", + }), + ); +}); + +it("reports an available download without restarting automatically", async () => { + pkg.autoUpdater.checkForUpdates.mockResolvedValue({ + isUpdateAvailable: true, + updateInfo: { version: "2.0.0" }, + }); + const { checkForUpdates } = await import("../updater.js"); + await checkForUpdates(); + expect(dialog.showMessageBox).toHaveBeenCalledWith( + expect.objectContaining({ message: "Version 2.0.0 is available." }), + ); + expect(pkg.autoUpdater.quitAndInstall).not.toHaveBeenCalled(); +}); + +it("shows a failed check instead of silently logging it", async () => { + pkg.autoUpdater.checkForUpdates.mockRejectedValue(new Error("Offline")); + const { checkForUpdates } = await import("../updater.js"); + await checkForUpdates(); + expect(dialog.showMessageBox).toHaveBeenCalledWith( + expect.objectContaining({ type: "error", detail: "Offline" }), + ); +}); + +it("does not query release servers in a development build", async () => { + app.isPackaged = false; + const { checkForUpdates, setupUpdater } = await import("../updater.js"); + setupUpdater(); + await checkForUpdates(); + expect(pkg.autoUpdater.checkForUpdates).not.toHaveBeenCalled(); + expect(dialog.showMessageBox).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Updates are available in installed builds of Control Station.", + }), + ); +}); + +it("coalesces repeated manual checks and registers updater handlers once", async () => { + let finish; + pkg.autoUpdater.checkForUpdates.mockImplementation( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + const { checkForUpdates } = await import("../updater.js"); + const first = checkForUpdates(); + const second = checkForUpdates(); + expect(pkg.autoUpdater.checkForUpdates).toHaveBeenCalledOnce(); + finish({ isUpdateAvailable: false }); + await Promise.all([first, second]); + expect(dialog.showMessageBox).toHaveBeenCalledOnce(); + expect( + pkg.autoUpdater.on.mock.calls.filter( + ([event]) => event === "update-downloaded", + ), + ).toHaveLength(1); +}); diff --git a/electron-app/src/app/modeSelector.js b/electron-app/src/app/modeSelector.js index e58fbab5a..a6f0d27d4 100644 --- a/electron-app/src/app/modeSelector.js +++ b/electron-app/src/app/modeSelector.js @@ -154,7 +154,7 @@ async function startServices(screenWidth, screenHeight, view) { logWindow.show(); // Show the log window try { - await startBackend(logWindow); + await startBackend(); logger.electron.header("Backend process spawned"); } catch (err) { logger.electron.error("Failed to start backend:", err); diff --git a/electron-app/src/app/updater.js b/electron-app/src/app/updater.js index b41ed1b88..a285546c6 100644 --- a/electron-app/src/app/updater.js +++ b/electron-app/src/app/updater.js @@ -8,15 +8,16 @@ import pkg from "electron-updater"; import { logger } from "../utils/logger.js"; const { autoUpdater } = pkg; +let updaterInitialized = false; +let manualCheck = null; /** * Initializes the auto-updater with appropriate logging and event handlers. * @returns {void} */ -function setupUpdater() { - if (!app.isPackaged) { - autoUpdater.forceDevUpdateConfig = true; - } +function initializeUpdater() { + if (updaterInitialized) return; + updaterInitialized = true; // Configure auto-updater logging autoUpdater.logger = { @@ -25,6 +26,9 @@ function setupUpdater() { warn: (message) => logger.electron.warning(message), debug: (message) => logger.electron.debug(message), }; + autoUpdater.on("error", (error) => { + logger.electron.error("Updater error:", error.message); + }); // Handle update downloaded event autoUpdater.on("update-downloaded", (info) => { @@ -39,13 +43,91 @@ function setupUpdater() { if (result.response === 0) { autoUpdater.quitAndInstall(); } - }); + }) + .catch((error) => logger.electron.error("Update dialog failed:", error)); }); +} - // Check for updates +function setupUpdater() { + initializeUpdater(); + if (!app.isPackaged) return; autoUpdater.checkForUpdates().catch((error) => { logger.electron.error("Update check failed:", error.message); }); } -export { setupUpdater }; +/** Manual checks always report their result; installing still requires Restart. */ +async function checkForUpdates(parentWindow) { + if (manualCheck) return manualCheck; + initializeUpdater(); + + const showResult = (options) => { + const dialogOptions = { + title: "Check for Updates", + buttons: ["OK"], + ...options, + }; + return parentWindow && !parentWindow.isDestroyed() + ? dialog.showMessageBox(parentWindow, dialogOptions) + : dialog.showMessageBox(dialogOptions); + }; + + manualCheck = (async () => { + if (!app.isPackaged) { + await showResult({ + type: "info", + message: + "Updates are available in installed builds of Control Station.", + detail: `Current version: ${app.getVersion()}`, + }); + return; + } + + try { + const result = await autoUpdater.checkForUpdates(); + // Auto-downloads are started by electron-updater; handle their rejection too. + result?.downloadPromise?.catch((error) => { + logger.electron.error("Update download failed:", error.message); + return showResult({ + type: "error", + message: "Could not download the update.", + detail: error.message, + }); + }); + if (!result) { + await showResult({ + type: "info", + message: "Automatic updates are not available for this installation.", + }); + } else if (result.isUpdateAvailable) { + await showResult({ + type: "info", + message: `Version ${result.updateInfo.version} is available.`, + detail: + "The update is downloading. You will be asked before restarting to install it.", + }); + } else { + await showResult({ + type: "info", + message: "Control Station is up to date.", + detail: `Current version: ${app.getVersion()}`, + }); + } + } catch (error) { + logger.electron.error("Update check failed:", error.message); + await showResult({ + type: "error", + message: "Could not check for updates.", + detail: error.message, + }); + } + })(); + + try { + await manualCheck; + } finally { + manualCheck = null; + } +} + +export { checkForUpdates, setupUpdater }; diff --git a/electron-app/src/menu/README.md b/electron-app/src/menu/README.md index 72ead00d3..228c598a7 100644 --- a/electron-app/src/menu/README.md +++ b/electron-app/src/menu/README.md @@ -21,17 +21,31 @@ Creates native menus for the main window, backend logs, and About window. The mo The selector retains **File > Reload / Return to Selector / Exit**, with Return to Selector disabled to prevent opening another selector. -### Tools Menu +### Edit Menu +- **Undo**, **Redo**, **Cut**, **Copy**, **Paste** - Native editing commands and their platform shortcuts. Select All is intentionally omitted. + +### View Menu + +- **Reload** - Reloads the invoking window (also available in Control Station) - **Zoom In** (`CmdOrCtrl+Plus`) - Increases zoom of the focused window - **Zoom Out** (`CmdOrCtrl+-`) - Decreases zoom of the focused window - **Reset Zoom** (`CmdOrCtrl+0`) - Restores the focused window to 100% zoom - **Toggle Full Screen** (`F11`) - Toggles fullscreen on the focused window -- **Toggle DevTools** (`F12`) - Opens/closes Chrome DevTools for the focused window +- **Development > Toggle DevTools** (`F12`) - Opens/closes Chrome DevTools for the focused window + +### Window Menu + +- **Show Main Window** - Restores and focuses the active mode's main window +- **Show Backend Logs** - Reopens or focuses backend logs in Testing and Competition. Keeps the latest 2000 messages, including output received while the window was closed. Reopening does not restart the backend. +- **Minimize** - Minimizes the focused window ### Help Menu -- **About** - Displays application information dialog +- **About Control Station** - Shows application information, the installed version, and Logging View attribution from every mode +- **Check for Updates...** - Checks for a newer release and reports the result. Updates download automatically; installation requires choosing Restart. Development builds show an informational message without contacting release servers. + +The selector keeps its existing Tools and Help menus; the additional menus above apply to the other windows. ## Functions diff --git a/electron-app/src/menu/__tests__/menu.test.js b/electron-app/src/menu/__tests__/menu.test.js index 09c776549..fb42025f1 100644 --- a/electron-app/src/menu/__tests__/menu.test.js +++ b/electron-app/src/menu/__tests__/menu.test.js @@ -2,29 +2,129 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { app, BrowserWindow, Menu } from "electron"; import fs from "fs"; import { createMenu, setWindowMenu } from "../menu.js"; +import { getCurrentView, getMainWindow } from "../../windows/mainWindow.js"; +import { showLogWindow } from "../../windows/logWindow.js"; +import { checkForUpdates } from "../../app/updater.js"; +import { showAboutWindow } from "../../windows/aboutWindow.js"; vi.mock("electron", () => ({ app: { emit: vi.fn(), quit: vi.fn() }, BrowserWindow: { getFocusedWindow: vi.fn() }, Menu: { - buildFromTemplate: vi.fn((template) => ({ items: template })), + buildFromTemplate: vi.fn((template) => { + for (const item of template) { + if (item.submenu) item.submenu.on = vi.fn(); + } + return { + items: template, + getMenuItemById: (id) => + template + .flatMap((item) => item.submenu ?? []) + .find((item) => item.id === id), + }; + }), setApplicationMenu: vi.fn(), }, })); vi.mock("fs", () => ({ default: { existsSync: vi.fn() } })); vi.mock("../../utils/paths.js", () => ({ getAppPath: () => "/app" })); vi.mock("../../windows/mainWindow.js", () => ({ - getCurrentView: () => "logging-view", + getCurrentView: vi.fn(), + getMainWindow: vi.fn(), })); vi.mock("../../windows/aboutWindow.js", () => ({ showAboutWindow: vi.fn() })); +vi.mock("../../windows/logWindow.js", () => ({ showLogWindow: vi.fn() })); +vi.mock("../../app/updater.js", () => ({ checkForUpdates: vi.fn() })); beforeEach(() => { vi.clearAllMocks(); fs.existsSync.mockReturnValue(true); + getCurrentView.mockReturnValue("logging-view"); }); afterEach(() => vi.unstubAllGlobals()); describe("Control Station menu", () => { + it("provides the English menu bar and editing commands without Select All", () => { + const menu = createMenu(); + expect(menu.items.map((item) => item.label)).toEqual([ + "Control Station", + "Edit", + "View", + "Window", + "Help", + ]); + expect( + menu.items + .find((item) => item.label === "Edit") + .submenu.filter((item) => item.role) + .map((item) => item.role), + ).toEqual(["undo", "redo", "cut", "copy", "paste"]); + expect( + menu.items + .find((item) => item.label === "Help") + .submenu.map((item) => item.label), + ).toEqual(["About Control Station", "Check for Updates..."]); + }); + + it.each(["testing-view", "competition-view"])( + "reopens backend logs in %s", + (view) => { + getCurrentView.mockReturnValue(view); + const item = createMenu().getMenuItemById("show-backend-logs"); + expect(item.enabled).toBe(true); + item.click(); + expect(showLogWindow).toHaveBeenCalledOnce(); + }, + ); + + it.each(["logging-view", "adj-view", "flashing-view"])( + "does not open backend logs in %s", + (view) => { + getCurrentView.mockReturnValue(view); + const item = createMenu().getMenuItemById("show-backend-logs"); + expect(item.enabled).toBe(false); + item.click(); + expect(showLogWindow).not.toHaveBeenCalled(); + }, + ); + + it("refreshes log availability when Window opens after a mode change", () => { + const menu = createMenu(); + getCurrentView.mockReturnValue("competition-view"); + const submenu = menu.items.find((item) => item.label === "Window").submenu; + submenu.on.mock.calls[0][1](); + expect(menu.getMenuItemById("show-backend-logs").enabled).toBe(true); + }); + + it("restores and focuses the main window", () => { + const window = { + isDestroyed: () => false, + isMinimized: () => true, + restore: vi.fn(), + show: vi.fn(), + focus: vi.fn(), + }; + getMainWindow.mockReturnValue(window); + createMenu() + .items.find((item) => item.label === "Window") + .submenu[0].click(); + expect(window.restore).toHaveBeenCalledOnce(); + expect(window.show).toHaveBeenCalledOnce(); + expect(window.focus).toHaveBeenCalledOnce(); + }); + + it("opens application information and update checks from any view", () => { + getCurrentView.mockReturnValue("competition-view"); + const window = {}; + const items = createMenu().items.find( + (item) => item.label === "Help", + ).submenu; + items[0].click(null, window); + expect(showAboutWindow).toHaveBeenCalledWith(window); + items[1].click(null, window); + expect(checkForUpdates).toHaveBeenCalledWith(window); + }); + it("orders reload, available views, return to selector and quit", () => { const menu = createMenu(); expect(menu.items[0].label).toBe("Control Station"); diff --git a/electron-app/src/menu/menu.js b/electron-app/src/menu/menu.js index 41c3c1dc0..0957621bb 100644 --- a/electron-app/src/menu/menu.js +++ b/electron-app/src/menu/menu.js @@ -9,7 +9,9 @@ import fs from "fs"; import path from "path"; import { getAppPath } from "../utils/paths.js"; import { showAboutWindow } from "../windows/aboutWindow.js"; -import { getCurrentView } from "../windows/mainWindow.js"; +import { getCurrentView, getMainWindow } from "../windows/mainWindow.js"; +import { showLogWindow } from "../windows/logWindow.js"; +import { checkForUpdates } from "../app/updater.js"; /** * Creates the Control Station menu, or the original File menu for the selector. @@ -69,9 +71,27 @@ function createMenu({ isSelector = false } = {}) { }, ], }, + ...(!isSelector + ? [ + { + label: "Edit", + submenu: [ + { role: "undo", label: "Undo" }, + { role: "redo", label: "Redo" }, + { type: "separator" }, + { role: "cut", label: "Cut" }, + { role: "copy", label: "Copy" }, + { role: "paste", label: "Paste" }, + ], + }, + ] + : []), { - label: "Tools", + label: isSelector ? "Tools" : "View", submenu: [ + ...(!isSelector + ? [{ role: "reload", label: "Reload" }, { type: "separator" }] + : []), { role: "zoomIn", label: "Zoom In" }, { role: "zoomOut", label: "Zoom Out" }, { role: "resetZoom", label: "Reset Zoom" }, @@ -85,37 +105,95 @@ function createMenu({ isSelector = false } = {}) { } }, }, - { - label: "Toggle DevTools", - accelerator: "F12", - click: (_, browserWindow) => { - if (browserWindow) { - browserWindow.webContents.toggleDevTools(); - } - }, - }, + ...(isSelector + ? [ + { + role: "toggleDevTools", + label: "Toggle DevTools", + accelerator: "F12", + }, + ] + : [ + { + label: "Development", + submenu: [ + { + role: "toggleDevTools", + label: "Toggle DevTools", + accelerator: "F12", + }, + ], + }, + ]), ], }, + ...(!isSelector + ? [ + { + label: "Window", + submenu: [ + { + label: "Show Main Window", + click: () => { + const window = getMainWindow(); + if (!window || window.isDestroyed()) return; + if (window.isMinimized()) window.restore(); + window.show(); + window.focus(); + }, + }, + { + id: "show-backend-logs", + label: "Show Backend Logs", + enabled: hasBackendView(), + click: () => { + if (hasBackendView()) showLogWindow(); + }, + }, + { type: "separator" }, + { role: "minimize", label: "Minimize" }, + ], + }, + ] + : []), { label: "Help", submenu: [ { - label: "About", - // Only meaningful in logging-view — the About window credits its - // author specifically, not the Control Station as a whole. - click: () => { - if (getCurrentView() !== "logging-view") return; - showAboutWindow(BrowserWindow.getFocusedWindow()); - }, + label: isSelector ? "About" : "About Control Station", + click: (_, window) => + showAboutWindow(window ?? BrowserWindow.getFocusedWindow()), }, + ...(!isSelector + ? [ + { + label: "Check for Updates...", + click: (_, window) => + checkForUpdates(window ?? BrowserWindow.getFocusedWindow()), + }, + ] + : []), ], }, ]; const menu = Menu.buildFromTemplate(template); + if (!isSelector) { + // Menus outlive view changes, so refresh availability whenever Window opens. + const windowMenu = menu.items.find( + (item) => item.label === "Window", + ).submenu; + windowMenu.on("menu-will-show", () => { + menu.getMenuItemById("show-backend-logs").enabled = hasBackendView(); + }); + } return menu; } +function hasBackendView() { + return ["testing-view", "competition-view"].includes(getCurrentView()); +} + /** Assign a menu to a window; macOS uses the focused window's application menu. */ function setWindowMenu(window, options) { const menu = createMenu(options); diff --git a/electron-app/src/processes/README.md b/electron-app/src/processes/README.md index 8232422ab..d4a9acdb2 100644 --- a/electron-app/src/processes/README.md +++ b/electron-app/src/processes/README.md @@ -19,7 +19,7 @@ Manages the Go backend binary that handles data ingestion and pod communication. ### Functions -- `startBackend(logWindow)` - Spawns the backend binary, piping stdout/stderr to the log window +- `startBackend()` - Spawns the backend binary, buffering stdout/stderr for the log window so it can be closed and reopened without restarting the process - `stopBackend()` - Stops the backend process gracefully (SIGTERM) - `restartBackend()` - Stops and restarts the backend process diff --git a/electron-app/src/processes/backend.js b/electron-app/src/processes/backend.js index c314a4637..2bc3c8d13 100644 --- a/electron-app/src/processes/backend.js +++ b/electron-app/src/processes/backend.js @@ -16,6 +16,7 @@ import { getUserConfigPath, } from "../utils/paths.js"; import { formatBackendError, getHint } from "./backendError.js"; +import { appendBackendLog, clearLogWindow } from "../windows/logWindow.js"; // Create ANSI to HTML converter const convert = new AnsiToHtml(); @@ -26,37 +27,16 @@ const appPath = getAppPath(); // Store the backend process instance let backendProcess = null; -// Common log window instance for all backend processes -let storedLogWindow = null; - // Store error messages accumulated from the current process run let lastBackendError = null; -/** - * Clears the stored log window reference and closes it - */ -function clearLogWindow() { - if (storedLogWindow && !storedLogWindow.isDestroyed()) { - try { - storedLogWindow.close(); - } catch (e) {} - } - storedLogWindow = null; -} - /** * Starts the backend process by spawning the backend binary with the user configuration. * @returns {void} * @example * startBackend(); */ -async function startBackend(logWindow = null) { - if (logWindow) { - storedLogWindow = logWindow; - } - - const currentLogWindow = logWindow || storedLogWindow; - +async function startBackend() { return new Promise((resolve, reject) => { let resolved = false; @@ -93,15 +73,7 @@ async function startBackend(logWindow = null) { const text = data.toString().trim(); logger.backend.info(text); - // Send log message to log window - if ( - currentLogWindow && - !currentLogWindow.isDestroyed() && - currentLogWindow.webContents - ) { - const htmlData = convert.toHtml(text); - currentLogWindow.webContents.send("log", htmlData); - } + appendBackendLog(convert.toHtml(text)); // Resolve as soon as the HTTP server confirms it is listening. // Matches: "INF ... > http server listening localAddr=..." @@ -119,11 +91,7 @@ async function startBackend(logWindow = null) { logger.backend.error(errorMsg); lastBackendError = errorMsg; - // Send error message to log window - if (currentLogWindow && !currentLogWindow.isDestroyed()) { - const htmlError = convert.toHtml(errorMsg); - currentLogWindow.webContents.send("log", htmlError); - } + appendBackendLog(convert.toHtml(errorMsg)); }); // Handle spawn errors @@ -165,7 +133,8 @@ async function startBackend(logWindow = null) { } if (code === null || code === 0) { - let errorMessage = "Backend process closed before initialization completed"; + let errorMessage = + "Backend process closed before initialization completed"; if (lastBackendError) { const stripped = lastBackendError.replace(/\x1b\[[0-9;]*m/g, ""); errorMessage += `\n\n${stripped}`; @@ -267,4 +236,10 @@ function getBackendWorkingDir() { : path.dirname(getUserConfigPath()); } -export { clearLogWindow, getBackendWorkingDir, restartBackend, startBackend, stopBackend }; +export { + clearLogWindow, + getBackendWorkingDir, + restartBackend, + startBackend, + stopBackend, +}; diff --git a/electron-app/src/windows/__tests__/logWindow.test.js b/electron-app/src/windows/__tests__/logWindow.test.js new file mode 100644 index 000000000..401d28665 --- /dev/null +++ b/electron-app/src/windows/__tests__/logWindow.test.js @@ -0,0 +1,93 @@ +import { EventEmitter } from "node:events"; +import { beforeEach, expect, it, vi } from "vitest"; +import { BrowserWindow } from "electron"; +import { + appendBackendLog, + clearLogWindow, + createLogWindow, + showLogWindow, +} from "../logWindow.js"; + +vi.mock("electron", () => ({ + screen: { + getPrimaryDisplay: () => ({ workAreaSize: { width: 1280, height: 800 } }), + }, + BrowserWindow: vi.fn(function () { + return new (class extends EventEmitter { + constructor() { + super(); + this.webContents = new EventEmitter(); + this.webContents.send = vi.fn(); + this.loadFile = vi.fn(); + this.destroyed = false; + this.isMinimized = () => true; + this.restore = vi.fn(); + this.show = vi.fn(); + this.focus = vi.fn(); + } + isDestroyed() { + return this.destroyed; + } + close() { + this.destroyed = true; + this.emit("closed"); + } + })(); + }), +})); +vi.mock("../../utils/paths.js", () => ({ getAppPath: () => "/app" })); +vi.mock("../../menu/menu.js", () => ({ setWindowMenu: vi.fn() })); + +beforeEach(() => { + clearLogWindow(); + vi.clearAllMocks(); +}); + +it("replays recent history and delivers new messages to the reopened window", () => { + const previous = createLogWindow(1280, 800); + appendBackendLog("startup"); + previous.webContents.emit("did-finish-load"); + expect(previous.webContents.send).toHaveBeenCalledWith("log", "startup"); + previous.close(); + appendBackendLog("while closed"); + const replacement = createLogWindow(1280, 800); + expect(replacement.webContents.send).not.toHaveBeenCalled(); + replacement.webContents.emit("did-finish-load"); + appendBackendLog("live"); + expect(replacement.webContents.send.mock.calls).toEqual([ + ["log", "startup"], + ["log", "while closed"], + ["log", "live"], + ]); +}); + +it("focuses an existing log window instead of opening duplicates", () => { + const window = createLogWindow(1280, 800); + showLogWindow(); + showLogWindow(); + expect(BrowserWindow).toHaveBeenCalledOnce(); + expect(window.restore).toHaveBeenCalledTimes(2); + expect(window.focus).toHaveBeenCalledTimes(2); +}); + +it("caps history at 2000 entries and clears it when the backend stops", () => { + for (let i = 0; i < 2002; i++) appendBackendLog(String(i)); + const window = createLogWindow(1280, 800); + window.webContents.emit("did-finish-load"); + expect(window.webContents.send).toHaveBeenCalledTimes(2000); + expect(window.webContents.send).toHaveBeenNthCalledWith(1, "log", "2"); + clearLogWindow(); + const next = createLogWindow(1280, 800); + next.webContents.emit("did-finish-load"); + expect(next.webContents.send).not.toHaveBeenCalled(); +}); + +it("buffers output during reload and replays it after the listener is ready", () => { + const window = createLogWindow(1280, 800); + window.webContents.emit("did-finish-load"); + window.webContents.emit("did-start-loading"); + appendBackendLog("during reload"); + expect(window.webContents.send).not.toHaveBeenCalled(); + window.webContents.emit("did-finish-load"); + expect(window.webContents.send).toHaveBeenCalledWith("log", "during reload"); +}); diff --git a/electron-app/src/windows/aboutWindow.js b/electron-app/src/windows/aboutWindow.js index c25a2e9a1..687d43b9e 100644 --- a/electron-app/src/windows/aboutWindow.js +++ b/electron-app/src/windows/aboutWindow.js @@ -1,6 +1,6 @@ /** * @module windows/aboutWindow - * @description Small "About" window — team logo, SW logo, and author credit. + * @description Application information, installed version, and Logging View credit. * Reachable via the main window's right-click context menu and the * Help > About application menu item. */ @@ -26,14 +26,14 @@ function showAboutWindow(parentWindow) { } aboutWindow = new BrowserWindow({ - width: 380, - height: 440, + width: 480, + height: 480, resizable: false, minimizable: false, maximizable: false, modal: !!parentWindow, parent: parentWindow ?? undefined, - title: "About Logging View", + title: "About Control Station", backgroundColor: "#111111", webPreferences: { preload: path.join(getAppPath(), "preload.js"), diff --git a/electron-app/src/windows/logWindow.js b/electron-app/src/windows/logWindow.js index 4a90c041c..9758de26a 100644 --- a/electron-app/src/windows/logWindow.js +++ b/electron-app/src/windows/logWindow.js @@ -1,4 +1,4 @@ -import { BrowserWindow } from "electron"; +import { BrowserWindow, screen } from "electron"; import path from "path"; import { getAppPath } from "../utils/paths.js"; @@ -6,9 +6,15 @@ import { setWindowMenu } from "../menu/menu.js"; // Get the application root path const appPath = getAppPath(); +const MAX_LOG_ENTRIES = 2000; +let logWindow = null; +let logWindowReady = false; +let logHistory = []; export const createLogWindow = (screenWidth, screenHeight) => { - const logWindow = new BrowserWindow({ + if (logWindow && !logWindow.isDestroyed()) return logWindow; + + const window = new BrowserWindow({ x: Math.floor(screenWidth * 0.65), y: 0, width: Math.floor(screenWidth * 0.35), @@ -20,11 +26,50 @@ export const createLogWindow = (screenWidth, screenHeight) => { nodeIntegration: false, }, }); + logWindow = window; + logWindowReady = false; - setWindowMenu(logWindow); + setWindowMenu(window); + window.webContents.on("did-start-loading", () => { + if (logWindow === window) logWindowReady = false; + }); + window.webContents.on("did-finish-load", () => { + if (logWindow !== window) return; + logWindowReady = true; + for (const entry of logHistory) window.webContents.send("log", entry); + }); + window.on("closed", () => { + if (logWindow === window) { + logWindow = null; + logWindowReady = false; + } + }); const logFilePath = path.join(appPath, "src", "log-viewer", "index.html"); - logWindow.loadFile(logFilePath); + window.loadFile(logFilePath); - return logWindow; + return window; }; + +export function showLogWindow() { + const { width, height } = screen.getPrimaryDisplay().workAreaSize; + const window = createLogWindow(width, height); + if (window.isMinimized()) window.restore(); + window.show(); + window.focus(); +} + +/** Buffer recent output even while the log window is closed or reloading. */ +export function appendBackendLog(entry) { + logHistory.push(entry); + if (logHistory.length > MAX_LOG_ENTRIES) logHistory.shift(); + if (logWindowReady && logWindow && !logWindow.isDestroyed()) { + logWindow.webContents.send("log", entry); + } +} + +/** End the current logging session when its backend is stopped. */ +export function clearLogWindow() { + logHistory = []; + if (logWindow && !logWindow.isDestroyed()) logWindow.close(); +} diff --git a/electron-app/src/windows/mainWindow.js b/electron-app/src/windows/mainWindow.js index 7469757d1..8884f8579 100644 --- a/electron-app/src/windows/mainWindow.js +++ b/electron-app/src/windows/mainWindow.js @@ -51,9 +51,7 @@ function createWindow(screenWidth, screenHeight, initialView) { const window = mainWindow; setWindowMenu(window); - // Right-click anywhere in the view to reach About — only offered in - // logging-view, since the About window credits its author specifically - // (not the Control Station as a whole). + // Keep Logging View's existing right-click shortcut to application information. mainWindow.webContents.on("context-menu", (_event, _params) => { if (currentView !== "logging-view") return; const contextMenu = Menu.buildFromTemplate([ From e0d5b8fcc4b4f5c6f7c6c2567d0a2560f83b82a0 Mon Sep 17 00:00:00 2001 From: Javier Ribal del Rio Date: Wed, 21 Oct 2026 10:55:44 +0200 Subject: [PATCH 3/6] feat(electron-app): create menu tools --- e2e/tests/ui/menus.test.ts | 69 ++++++++++++- electron-app/src/menu/README.md | 10 ++ electron-app/src/menu/__tests__/menu.test.js | 43 +++++++- .../src/menu/__tests__/toolActions.test.js | 98 +++++++++++++++++++ electron-app/src/menu/menu.js | 30 ++++++ electron-app/src/menu/toolActions.js | 78 +++++++++++++++ .../src/utils/__tests__/backendPaths.test.js | 71 ++++++++++++++ electron-app/src/utils/paths.js | 38 +++++++ 8 files changed, 433 insertions(+), 4 deletions(-) create mode 100644 electron-app/src/menu/__tests__/toolActions.test.js create mode 100644 electron-app/src/menu/toolActions.js create mode 100644 electron-app/src/utils/__tests__/backendPaths.test.js diff --git a/e2e/tests/ui/menus.test.ts b/e2e/tests/ui/menus.test.ts index d87e9e086..3fa84ca6b 100644 --- a/e2e/tests/ui/menus.test.ts +++ b/e2e/tests/ui/menus.test.ts @@ -93,18 +93,24 @@ test("English menus and About are available in Testing View", async ({ .filter(Boolean), help: menu.items .find((item) => item.label === "Help")! - .submenu!.items.map((item) => item.label), + .submenu!.items.filter((item) => item.type !== "separator") + .map((item) => item.label), }; }); expect(menus.labels).toEqual([ "Control Station", "Edit", "View", + "Tools", "Window", "Help", ]); expect(menus.edit).toEqual(["undo", "redo", "cut", "copy", "paste"]); - expect(menus.help).toEqual(["About Control Station", "Check for Updates..."]); + expect(menus.help).toEqual([ + "About Control Station", + "Check for Updates...", + "Report an Issue...", + ]); const opened = app.waitForEvent("window"); await app.evaluate(({ BrowserWindow, Menu }) => { const item = Menu.getApplicationMenu()!.items.find( @@ -130,6 +136,65 @@ async function openAdjFromRenderer(page: Page) { await expect(page).toHaveURL(/\/adj-view\/index\.html$/); } +test("Tools opens app locations and project pages through the OS", async ({ + app, +}) => { + const opened = await app.evaluate(async ({ BrowserWindow, Menu, shell }) => { + const paths: string[] = []; + const urls: string[] = []; + const originalOpenPath = shell.openPath; + const originalOpenExternal = shell.openExternal; + shell.openPath = async (target) => { + paths.push(target); + return ""; + }; + shell.openExternal = async (url) => { + urls.push(url); + }; + try { + const menu = Menu.getApplicationMenu()!; + const tools = menu.items.find((item) => item.label === "Tools")!.submenu!; + const window = BrowserWindow.getAllWindows()[0]; + for (const label of [ + "Open ADJ Repository", + "Open App Cache Folder", + "Open Settings Folder", + "Open Logger Folder", + "Open config.toml", + "Open ADJ Folder", + ]) { + const item = tools.items.find((item) => item.label === label)!; + await item.click(item, window, {} as never); + } + const issue = menu.items + .find((item) => item.label === "Help")! + .submenu!.items.find((item) => item.label === "Report an Issue...")!; + await issue.click(issue, window, {} as never); + await new Promise((resolve) => setImmediate(resolve)); + return { + paths, + urls, + tools: tools.items + .filter((item) => item.type !== "separator") + .map((item) => item.label), + }; + } finally { + shell.openPath = originalOpenPath; + shell.openExternal = originalOpenExternal; + } + }); + expect(opened.paths).toHaveLength(5); + expect(opened.paths.some((target) => target.endsWith("config.toml"))).toBe( + true, + ); + expect(opened.paths.some((target) => target.endsWith("logger"))).toBe(true); + expect(opened.tools).not.toContain("Open Logger Settings"); + expect(opened.urls).toEqual([ + "https://github.com/Hyperloop-UPV/adj", + "https://github.com/Hyperloop-UPV/software/issues/new/choose", + ]); +}); + test("Control Station menu switches modes and returns through the selector", async ({ app, }) => { diff --git a/electron-app/src/menu/README.md b/electron-app/src/menu/README.md index 228c598a7..a1331f4ce 100644 --- a/electron-app/src/menu/README.md +++ b/electron-app/src/menu/README.md @@ -34,6 +34,15 @@ The selector retains **File > Reload / Return to Selector / Exit**, with Return - **Toggle Full Screen** (`F11`) - Toggles fullscreen on the focused window - **Development > Toggle DevTools** (`F12`) - Opens/closes Chrome DevTools for the focused window +### Tools Menu + +- **Open ADJ Repository** - Opens the project's ADJ repository in the browser +- **Open App Cache Folder** - Opens Go's OS-specific cache folder for `hyperloop-control-station` (honors `XDG_CACHE_HOME` on Linux) +- **Open Settings Folder** - Opens the directory containing the active `config.toml` +- **Open Logger Folder** - Opens `/logger`, resolving relative or empty paths against the Go backend's working directory; creates the directory on first use if needed +- **Open config.toml** - Opens the active configuration file in its default application +- **Open ADJ Folder** - Opens the Go backend's existing `adj` folder under the application cache. The backend does not support a separate configured local ADJ path. + ### Window Menu - **Show Main Window** - Restores and focuses the active mode's main window @@ -44,6 +53,7 @@ The selector retains **File > Reload / Return to Selector / Exit**, with Return - **About Control Station** - Shows application information, the installed version, and Logging View attribution from every mode - **Check for Updates...** - Checks for a newer release and reports the result. Updates download automatically; installation requires choosing Restart. Development builds show an informational message without contacting release servers. +- **Report an Issue...** - Opens the software repository's new-issue form; no issue is submitted automatically The selector keeps its existing Tools and Help menus; the additional menus above apply to the other windows. diff --git a/electron-app/src/menu/__tests__/menu.test.js b/electron-app/src/menu/__tests__/menu.test.js index fb42025f1..e5e30fd58 100644 --- a/electron-app/src/menu/__tests__/menu.test.js +++ b/electron-app/src/menu/__tests__/menu.test.js @@ -6,6 +6,7 @@ import { getCurrentView, getMainWindow } from "../../windows/mainWindow.js"; import { showLogWindow } from "../../windows/logWindow.js"; import { checkForUpdates } from "../../app/updater.js"; import { showAboutWindow } from "../../windows/aboutWindow.js"; +import { openAppLocation, openProjectLink } from "../toolActions.js"; vi.mock("electron", () => ({ app: { emit: vi.fn(), quit: vi.fn() }, @@ -35,6 +36,10 @@ vi.mock("../../windows/mainWindow.js", () => ({ vi.mock("../../windows/aboutWindow.js", () => ({ showAboutWindow: vi.fn() })); vi.mock("../../windows/logWindow.js", () => ({ showLogWindow: vi.fn() })); vi.mock("../../app/updater.js", () => ({ checkForUpdates: vi.fn() })); +vi.mock("../toolActions.js", () => ({ + openAppLocation: vi.fn(), + openProjectLink: vi.fn(), +})); beforeEach(() => { vi.clearAllMocks(); @@ -50,6 +55,7 @@ describe("Control Station menu", () => { "Control Station", "Edit", "View", + "Tools", "Window", "Help", ]); @@ -62,8 +68,41 @@ describe("Control Station menu", () => { expect( menu.items .find((item) => item.label === "Help") - .submenu.map((item) => item.label), - ).toEqual(["About Control Station", "Check for Updates..."]); + .submenu.filter((item) => item.label) + .map((item) => item.label), + ).toEqual([ + "About Control Station", + "Check for Updates...", + "Report an Issue...", + ]); + }); + + it("opens application locations from Tools and issue creation from Help", () => { + const menu = createMenu(); + const window = {}; + const tools = menu.items.find((item) => item.label === "Tools").submenu; + tools + .find((item) => item.label === "Open ADJ Repository") + .click(null, window); + expect(openProjectLink).toHaveBeenCalledWith("adj", window); + for (const [label, location] of [ + ["Open App Cache Folder", "cache"], + ["Open Settings Folder", "settings"], + ["Open Logger Folder", "logger"], + ["Open config.toml", "config"], + ["Open ADJ Folder", "adj"], + ]) { + tools.find((item) => item.label === label).click(null, window); + expect(openAppLocation).toHaveBeenLastCalledWith(location, window); + } + menu.items + .find((item) => item.label === "Help") + .submenu.find((item) => item.label === "Report an Issue...") + .click(null, window); + expect(openProjectLink).toHaveBeenLastCalledWith("issue", window); + expect(tools.some((item) => item.label === "Open Logger Settings")).toBe( + false, + ); }); it.each(["testing-view", "competition-view"])( diff --git a/electron-app/src/menu/__tests__/toolActions.test.js b/electron-app/src/menu/__tests__/toolActions.test.js new file mode 100644 index 000000000..9d251b10c --- /dev/null +++ b/electron-app/src/menu/__tests__/toolActions.test.js @@ -0,0 +1,98 @@ +import { beforeEach, expect, it, vi } from "vitest"; +import { shell, dialog } from "electron"; +import fs from "fs"; +import { readConfig } from "../../config/configInstance.js"; +import { openAppLocation, openProjectLink } from "../toolActions.js"; + +vi.mock("electron", () => ({ + shell: { openPath: vi.fn(), openExternal: vi.fn() }, + dialog: { showMessageBox: vi.fn().mockResolvedValue({ response: 0 }) }, +})); +vi.mock("fs", () => ({ default: { existsSync: vi.fn(), mkdirSync: vi.fn() } })); +vi.mock("../../config/configInstance.js", () => ({ readConfig: vi.fn() })); +vi.mock("../../utils/paths.js", () => ({ + getBackendCachePath: () => "/system/cache/hyperloop-control-station", + getBackendWorkingDir: () => "/work/backend/cmd", + getUserConfigPath: () => "/user/configs/config.toml", +})); +vi.mock("../../utils/logger.js", () => ({ + logger: { electron: { error: vi.fn() } }, +})); + +beforeEach(() => { + vi.clearAllMocks(); + fs.existsSync.mockReturnValue(true); + shell.openPath.mockResolvedValue(""); + shell.openExternal.mockResolvedValue(undefined); + readConfig.mockResolvedValue({}); +}); + +it.each([ + ["cache", "/system/cache/hyperloop-control-station"], + ["adj", "/system/cache/hyperloop-control-station/adj"], + ["settings", "/user/configs"], + ["config", "/user/configs/config.toml"], +])("opens %s in its OS default application", async (location, target) => { + await openAppLocation(location); + expect(shell.openPath).toHaveBeenCalledWith(target); + expect(shell.openExternal).not.toHaveBeenCalled(); +}); + +it.each([undefined, "", "."])( + "uses Go's default logger path when logging_path is %s", + async (loggingPath) => { + readConfig.mockResolvedValue({ logging: { logging_path: loggingPath } }); + await openAppLocation("logger"); + expect(shell.openPath).toHaveBeenCalledWith("/work/backend/cmd/logger"); + }, +); + +it.each([ + ["../logs", "/work/backend/logs/logger"], + ["/custom/data", "/custom/data/logger"], +])( + "respects a configured logging path of %s", + async (loggingPath, expected) => { + readConfig.mockResolvedValue({ logging: { logging_path: loggingPath } }); + await openAppLocation("logger"); + expect(shell.openPath).toHaveBeenCalledWith(expected); + }, +); + +it("creates the logger directory on first use instead of opening the wrong parent", async () => { + fs.existsSync.mockReturnValueOnce(false).mockReturnValue(true); + await openAppLocation("logger"); + expect(fs.mkdirSync).toHaveBeenCalledWith("/work/backend/cmd/logger", { + recursive: true, + }); + expect(shell.openPath).toHaveBeenCalledWith("/work/backend/cmd/logger"); +}); + +it("opens the cache ADJ folder supported by the existing Go backend", async () => { + await openAppLocation("adj"); + expect(shell.openPath).toHaveBeenCalledWith( + "/system/cache/hyperloop-control-station/adj", + ); + expect(readConfig).not.toHaveBeenCalled(); +}); + +it("reports openPath error strings", async () => { + shell.openPath.mockResolvedValue( + "No application is associated with this file", + ); + await openAppLocation("config"); + expect(dialog.showMessageBox).toHaveBeenCalledWith( + expect.objectContaining({ + detail: expect.stringContaining("No application is associated"), + }), + ); +}); + +it("opens the ADJ repository and issue form without submitting an issue", async () => { + await openProjectLink("adj"); + await openProjectLink("issue"); + expect(shell.openExternal.mock.calls).toEqual([ + ["https://github.com/Hyperloop-UPV/adj"], + ["https://github.com/Hyperloop-UPV/software/issues/new/choose"], + ]); +}); diff --git a/electron-app/src/menu/menu.js b/electron-app/src/menu/menu.js index 0957621bb..4fa04ac19 100644 --- a/electron-app/src/menu/menu.js +++ b/electron-app/src/menu/menu.js @@ -12,6 +12,7 @@ import { showAboutWindow } from "../windows/aboutWindow.js"; import { getCurrentView, getMainWindow } from "../windows/mainWindow.js"; import { showLogWindow } from "../windows/logWindow.js"; import { checkForUpdates } from "../app/updater.js"; +import { openAppLocation, openProjectLink } from "./toolActions.js"; /** * Creates the Control Station menu, or the original File menu for the selector. @@ -127,6 +128,30 @@ function createMenu({ isSelector = false } = {}) { ]), ], }, + ...(!isSelector + ? [ + { + label: "Tools", + submenu: [ + { + label: "Open ADJ Repository", + click: (_, window) => openProjectLink("adj", window), + }, + { type: "separator" }, + ...[ + { label: "Open App Cache Folder", location: "cache" }, + { label: "Open Settings Folder", location: "settings" }, + { label: "Open Logger Folder", location: "logger" }, + { label: "Open config.toml", location: "config" }, + { label: "Open ADJ Folder", location: "adj" }, + ].map(({ label, location }) => ({ + label, + click: (_, window) => openAppLocation(location, window), + })), + ], + }, + ] + : []), ...(!isSelector ? [ { @@ -171,6 +196,11 @@ function createMenu({ isSelector = false } = {}) { click: (_, window) => checkForUpdates(window ?? BrowserWindow.getFocusedWindow()), }, + { type: "separator" }, + { + label: "Report an Issue...", + click: (_, window) => openProjectLink("issue", window), + }, ] : []), ], diff --git a/electron-app/src/menu/toolActions.js b/electron-app/src/menu/toolActions.js new file mode 100644 index 000000000..650d83cd3 --- /dev/null +++ b/electron-app/src/menu/toolActions.js @@ -0,0 +1,78 @@ +import { dialog, shell } from "electron"; +import fs from "fs"; +import path from "path"; +import { readConfig } from "../config/configInstance.js"; +import { + getBackendCachePath, + getBackendWorkingDir, + getUserConfigPath, +} from "../utils/paths.js"; +import { logger } from "../utils/logger.js"; + +const LINKS = { + adj: "https://github.com/Hyperloop-UPV/adj", + issue: "https://github.com/Hyperloop-UPV/software/issues/new/choose", +}; + +async function resolveLocation(location) { + if (location === "cache") return getBackendCachePath(); + if (location === "settings") return path.dirname(getUserConfigPath()); + if (location === "config") return getUserConfigPath(); + + if (location === "adj") return path.join(getBackendCachePath(), "adj"); + + if (location === "logger") { + const config = await readConfig(); + const loggerPath = path.resolve( + getBackendWorkingDir(), + config.logging?.logging_path ?? ".", + "logger", + ); + return loggerPath; + } + throw new Error(`Unknown application location: ${location}`); +} + +async function reportOpenError(error, parentWindow) { + logger.electron.error("Could not open application resource:", error.message); + const options = { + type: "error", + title: "Could Not Open", + message: "Could not open the requested file, folder, or page.", + detail: error.message, + buttons: ["OK"], + }; + if (parentWindow && !parentWindow.isDestroyed()) { + await dialog.showMessageBox(parentWindow, options); + } else { + await dialog.showMessageBox(options); + } +} + +/** Open files with the OS default application, and folders in its file manager. */ +async function openAppLocation(location, parentWindow) { + try { + const target = await resolveLocation(location); + if (location === "logger" && !fs.existsSync(target)) { + fs.mkdirSync(target, { recursive: true }); + } + if (!fs.existsSync(target)) + throw new Error(`This location does not exist yet:\n${target}`); + const error = await shell.openPath(target); + if (error) throw new Error(`${error}\n${target}`); + } catch (error) { + await reportOpenError(error, parentWindow); + } +} + +async function openProjectLink(link, parentWindow) { + try { + const url = LINKS[link]; + if (!url) throw new Error(`Unknown project link: ${link}`); + await shell.openExternal(url); + } catch (error) { + await reportOpenError(error, parentWindow); + } +} + +export { openAppLocation, openProjectLink }; diff --git a/electron-app/src/utils/__tests__/backendPaths.test.js b/electron-app/src/utils/__tests__/backendPaths.test.js new file mode 100644 index 000000000..04be267d1 --- /dev/null +++ b/electron-app/src/utils/__tests__/backendPaths.test.js @@ -0,0 +1,71 @@ +import path from "node:path"; +import { beforeEach, expect, it, vi } from "vitest"; +import { app } from "electron"; +import { + getBackendCachePath, + getBackendWorkingDir, + getAppPath, +} from "../paths.js"; + +vi.mock("electron", () => ({ + app: { + isPackaged: false, + getPath: vi.fn(() => "/user/data"), + }, +})); +beforeEach(() => { + app.isPackaged = false; +}); + +it.each([ + [ + "linux", + { HOME: "/users/me" }, + "/users/me/.cache/hyperloop-control-station", + ], + [ + "linux", + { HOME: "/users/me", XDG_CACHE_HOME: "/custom/cache" }, + "/custom/cache/hyperloop-control-station", + ], + [ + "linux", + { HOME: "/users/me", XDG_CACHE_HOME: "" }, + "/users/me/.cache/hyperloop-control-station", + ], + [ + "darwin", + { HOME: "/Users/me", XDG_CACHE_HOME: "/ignored" }, + "/Users/me/Library/Caches/hyperloop-control-station", + ], + [ + "win32", + { LocalAppData: "C:\\Users\\me\\AppData\\Local" }, + "C:\\Users\\me\\AppData\\Local\\hyperloop-control-station", + ], +])("matches Go's cache location on %s with %j", (platform, env, expected) => { + expect(getBackendCachePath({ platform, env })).toBe(expected); +}); + +it("rejects a relative XDG cache path like Go's os.UserCacheDir", () => { + expect(() => + getBackendCachePath({ + platform: "linux", + env: { XDG_CACHE_HOME: "cache" }, + }), + ).toThrow("absolute XDG_CACHE_HOME"); +}); + +it("reports a missing Go cache environment rather than guessing an Electron directory", () => { + expect(() => getBackendCachePath({ platform: "linux", env: {} })).toThrow( + "not configured", + ); +}); + +it("uses the same working directory for packaged and development backend paths", () => { + expect(getBackendWorkingDir()).toBe( + path.resolve(getAppPath(), "..", "backend", "cmd"), + ); + app.isPackaged = true; + expect(getBackendWorkingDir()).toBe("/user/data/configs"); +}); diff --git a/electron-app/src/utils/paths.js b/electron-app/src/utils/paths.js index e6ba2b5ca..b017776c6 100644 --- a/electron-app/src/utils/paths.js +++ b/electron-app/src/utils/paths.js @@ -90,6 +90,42 @@ function getUserConfigPath() { return path.join(configsDir, "config.toml"); } +/** Working directory passed to the Go backend; relative logging paths use it. */ +function getBackendWorkingDir() { + return !app.isPackaged + ? path.join(getAppPath(), "..", "backend", "cmd") + : path.dirname(getUserConfigPath()); +} + +/** Match os.UserCacheDir and filepath.Abs in backend/pkg/adj/adj.go. */ +function getBackendCachePath({ + platform = process.platform, + env = process.env, +} = {}) { + const platformPath = platform === "win32" ? path.win32 : path.posix; + let cacheDir; + if (platform === "win32") { + cacheDir = env.LocalAppData || env.LOCALAPPDATA; + } else if (platform === "darwin") { + if (env.HOME) cacheDir = platformPath.join(env.HOME, "Library", "Caches"); + } else { + if (env.XDG_CACHE_HOME && !platformPath.isAbsolute(env.XDG_CACHE_HOME)) { + throw new Error( + "The Go backend requires an absolute XDG_CACHE_HOME path.", + ); + } + cacheDir = + env.XDG_CACHE_HOME || (env.HOME && platformPath.join(env.HOME, ".cache")); + } + if (!cacheDir) + throw new Error("The Go backend's user cache directory is not configured."); + return platformPath.resolve( + getBackendWorkingDir(), + cacheDir, + "hyperloop-control-station", + ); +} + /** * Gets the path to the user app version file. * @returns {string} The absolute path to the user's version.toml file. @@ -130,6 +166,8 @@ function getTemplatePath() { export { getAppPath, getBinaryPath, + getBackendCachePath, + getBackendWorkingDir, getTemplatePath, getUserConfigPath, getVersionFilePath, From 07254c0aa3f2a2de607e04cefbbf92f64a7d0c61 Mon Sep 17 00:00:00 2001 From: Javier Ribal del Rio Date: Fri, 23 Oct 2026 08:51:48 +0200 Subject: [PATCH 4/6] feat(electron-app): enhance about --- e2e/tests/ui/about.test.ts | 124 +++++++++ e2e/tests/ui/backend-menu.test.ts | 159 +++++++++++ electron-app/build.mjs | 9 +- electron-app/package.json | 24 +- electron-app/renderer/about/about.css | 160 ++++++++++++ electron-app/renderer/about/about.js | 73 ++++++ .../renderer/about/avatars/114338434.jpg | Bin 0 -> 1992 bytes .../renderer/about/avatars/114561048.jpg | Bin 0 -> 1885 bytes .../renderer/about/avatars/130096040.png | Bin 0 -> 21590 bytes .../renderer/about/avatars/152517350.jpg | Bin 0 -> 3026 bytes .../renderer/about/avatars/152909605.jpg | Bin 0 -> 1954 bytes .../renderer/about/avatars/183129863.png | Bin 0 -> 5065 bytes .../renderer/about/avatars/228559793.png | Bin 0 -> 5065 bytes .../renderer/about/avatars/47422871.png | Bin 0 -> 12159 bytes .../renderer/about/avatars/58850783.jpg | Bin 0 -> 1710 bytes .../renderer/about/avatars/60261013.jpg | Bin 0 -> 2414 bytes .../renderer/about/avatars/60979852.png | Bin 0 -> 12100 bytes .../renderer/about/avatars/70332045.png | Bin 0 -> 1075 bytes .../renderer/about/avatars/71978109.jpg | Bin 0 -> 2243 bytes .../renderer/about/avatars/74974283.jpg | Bin 0 -> 1198 bytes .../renderer/about/avatars/76229993.png | Bin 0 -> 9949 bytes .../renderer/about/avatars/80858832.png | Bin 0 -> 13060 bytes .../renderer/about/avatars/99354212.png | Bin 0 -> 5065 bytes electron-app/renderer/about/index.html | 123 +++------ electron-app/src/about/README.md | 19 ++ .../src/about/__tests__/githubCredits.test.js | 246 ++++++++++++++++++ electron-app/src/about/credits.snapshot.json | 169 ++++++++++++ electron-app/src/about/generateCredits.js | 188 +++++++++++++ electron-app/src/about/githubCredits.js | 22 ++ .../src/windows/__tests__/aboutWindow.test.js | 127 +++++++++ electron-app/src/windows/aboutPreload.cjs | 7 + electron-app/src/windows/aboutWindow.js | 64 ++++- 32 files changed, 1417 insertions(+), 97 deletions(-) create mode 100644 e2e/tests/ui/about.test.ts create mode 100644 e2e/tests/ui/backend-menu.test.ts create mode 100644 electron-app/renderer/about/about.css create mode 100644 electron-app/renderer/about/about.js create mode 100644 electron-app/renderer/about/avatars/114338434.jpg create mode 100644 electron-app/renderer/about/avatars/114561048.jpg create mode 100644 electron-app/renderer/about/avatars/130096040.png create mode 100644 electron-app/renderer/about/avatars/152517350.jpg create mode 100644 electron-app/renderer/about/avatars/152909605.jpg create mode 100644 electron-app/renderer/about/avatars/183129863.png create mode 100644 electron-app/renderer/about/avatars/228559793.png create mode 100644 electron-app/renderer/about/avatars/47422871.png create mode 100644 electron-app/renderer/about/avatars/58850783.jpg create mode 100644 electron-app/renderer/about/avatars/60261013.jpg create mode 100644 electron-app/renderer/about/avatars/60979852.png create mode 100644 electron-app/renderer/about/avatars/70332045.png create mode 100644 electron-app/renderer/about/avatars/71978109.jpg create mode 100644 electron-app/renderer/about/avatars/74974283.jpg create mode 100644 electron-app/renderer/about/avatars/76229993.png create mode 100644 electron-app/renderer/about/avatars/80858832.png create mode 100644 electron-app/renderer/about/avatars/99354212.png create mode 100644 electron-app/src/about/README.md create mode 100644 electron-app/src/about/__tests__/githubCredits.test.js create mode 100644 electron-app/src/about/credits.snapshot.json create mode 100644 electron-app/src/about/generateCredits.js create mode 100644 electron-app/src/about/githubCredits.js create mode 100644 electron-app/src/windows/__tests__/aboutWindow.test.js create mode 100644 electron-app/src/windows/aboutPreload.cjs diff --git a/e2e/tests/ui/about.test.ts b/e2e/tests/ui/about.test.ts new file mode 100644 index 000000000..8010986a6 --- /dev/null +++ b/e2e/tests/ui/about.test.ts @@ -0,0 +1,124 @@ +import type { ElectronApplication } from "@playwright/test"; +import { expect, test } from "../../fixtures/electron"; + +async function openAbout(app: ElectronApplication) { + const opened = app.waitForEvent("window"); + await app.evaluate(({ BrowserWindow, Menu }) => { + const item = Menu.getApplicationMenu()!.items.find( + (item) => item.label === "Help", + )!.submenu!.items[0]; + const parent = BrowserWindow.getAllWindows().find((window) => + window.webContents.getURL().includes("/testing-view/"), + )!; + item.click(item, parent, {} as never); + }); + const about = await opened; + await expect(about.locator("#contributors li").first()).toBeVisible(); + return about; +} + +test("About shows both original logos and descending credits entirely offline", async ({ + app, +}) => { + const remoteRequests: string[] = []; + await app.context().route(/^https?:/, (route) => { + // Other open views still use their backend; only About must be network-free. + if (route.request().frame().url().includes("/about/")) { + remoteRequests.push(route.request().url()); + return route.abort(); + } + return route.continue(); + }); + await app.evaluate(() => { + const state = globalThis as typeof globalThis & { aboutFetchCalls: number }; + state.aboutFetchCalls = 0; + globalThis.fetch = async () => { + state.aboutFetchCalls++; + throw new Error("No runtime network allowed"); + }; + }); + const about = await openAbout(app); + await expect(about.getByRole("heading", { level: 1 })).toHaveText( + "Control Station", + ); + await expect(about.locator("#app-version")).toContainText("Version"); + await expect( + about.locator("#refresh, #sort, #search, #commit-list, .hero"), + ).toHaveCount(0); + const assets = await about + .locator("header img, .avatar img") + .evaluateAll((images) => + images.map((element) => { + const image = element as HTMLImageElement; + return { + source: image.src, + loaded: image.complete && image.naturalWidth > 0, + }; + }), + ); + expect(assets.length).toBeGreaterThan(2); + expect( + assets.every((asset) => asset.source.startsWith("file:") && asset.loaded), + ).toBe(true); + const counts = await about.locator(".count").allTextContents(); + const numbers = counts.map((value) => Number(value.replace(/[^0-9]/g, ""))); + expect(numbers).toEqual([...numbers].sort((a, b) => b - a)); + expect(remoteRequests).toEqual([]); + expect( + await app.evaluate( + () => + (globalThis as typeof globalThis & { aboutFetchCalls: number }) + .aboutFetchCalls, + ), + ).toBe(0); + await about.screenshot({ + path: test.info().outputPath("about.png"), + fullPage: true, + }); + await about.reload(); + await expect(about.locator("#contributors li")).toHaveCount(numbers.length); + expect( + await app.evaluate( + () => + (globalThis as typeof globalThis & { aboutFetchCalls: number }) + .aboutFetchCalls, + ), + ).toBe(0); +}); + +test("About opens a member's GitHub profile and stays compact", async ({ + app, +}) => { + const about = await openAbout(app); + await app.evaluate(({ shell }) => { + const state = globalThis as typeof globalThis & { aboutLinks: string[] }; + state.aboutLinks = []; + shell.openExternal = async (url) => { + state.aboutLinks.push(url); + }; + }); + const member = about.locator("button.contributor").first(); + const name = await member.locator(".name").innerText(); + await member.focus(); + await member.press("Enter"); + expect( + await app.evaluate( + () => + (globalThis as typeof globalThis & { aboutLinks: string[] }).aboutLinks, + ), + ).toEqual([`https://github.com/${name}`]); + await app.evaluate(({ BrowserWindow }) => { + BrowserWindow.getAllWindows() + .find((window) => window.getTitle() === "About Control Station")! + .setSize(440, 620); + }); + expect( + await about.evaluate( + () => document.documentElement.scrollWidth <= innerWidth, + ), + ).toBe(true); + await about.keyboard.press("Escape").catch((error) => { + if (!about.isClosed()) throw error; + }); + await expect.poll(() => about.isClosed()).toBe(true); +}); diff --git a/e2e/tests/ui/backend-menu.test.ts b/e2e/tests/ui/backend-menu.test.ts new file mode 100644 index 000000000..4132c9161 --- /dev/null +++ b/e2e/tests/ui/backend-menu.test.ts @@ -0,0 +1,159 @@ +import type { ElectronApplication } from "@playwright/test"; +import { expect, test } from "../../fixtures/electron"; + +async function controlState(app: ElectronApplication) { + return app.evaluate(({ Menu }) => { + const menu = Menu.getApplicationMenu()!; + return { + visible: menu.getMenuItemById("backend-menu")!.visible, + stop: menu.getMenuItemById("backend-stop")!.enabled, + restart: menu.getMenuItemById("backend-restart")!.enabled, + continue: menu.getMenuItemById("backend-continue")!.enabled, + }; + }); +} + +async function invoke(app: ElectronApplication, id: string) { + await app.evaluate(({ BrowserWindow, Menu }, id) => { + const item = Menu.getApplicationMenu()!.getMenuItemById(id)!; + if (!item.enabled) throw new Error(`${id} is disabled`); + item.click(item, BrowserWindow.getAllWindows()[0], {} as never); + }, id); +} + +async function pressRestartShortcut(app: ElectronApplication, mode: string) { + // Exercise the application shortcut through Electron's native keyboard input. + await app.evaluate(({ BrowserWindow }, mode) => { + const window = BrowserWindow.getAllWindows().find((window) => + window.webContents.getURL().includes(`/${mode}-view/index.html`), + )!; + window.focus(); + const modifiers: ("control" | "meta" | "shift")[] = [ + process.platform === "darwin" ? "meta" : "control", + "shift", + ]; + window.webContents.sendInputEvent({ + type: "keyDown", + keyCode: "R", + modifiers, + }); + window.webContents.sendInputEvent({ + type: "keyUp", + keyCode: "R", + modifiers, + }); + }, mode); +} + +test("Logging View has no Backend menu or restart binding", async ({ app }) => { + const available = await app.evaluate(({ Menu }) => + Menu.getApplicationMenu()!.items[0].submenu!.items.some( + (item) => item.label === "Logging View", + ), + ); + test.skip(!available, "Logging View is not installed"); + await app.evaluate(({ BrowserWindow, Menu }) => { + const item = Menu.getApplicationMenu()!.items[0].submenu!.items.find( + (item) => item.label === "Logging View", + )!; + item.click(item, BrowserWindow.getAllWindows()[0], {} as never); + }); + await expect + .poll(() => + app + .windows() + .some((window) => window.url().includes("/logging-view/index.html")), + ) + .toBe(true); + await expect + .poll(() => controlState(app)) + .toEqual({ visible: false, stop: false, restart: false, continue: false }); +}); + +for (const mode of ["competition", "testing"]) { + test(`Backend Stop, Continue and Ctrl+Shift+R preserve ${mode} view`, async ({ + app, + }) => { + test.setTimeout(90_000); + const label = mode === "competition" ? "Competition View" : "Testing View"; + const available = await app.evaluate( + ({ Menu }, label) => + Menu.getApplicationMenu()!.items[0].submenu!.items.some( + (item) => item.label === label, + ), + label, + ); + test.skip(!available, `${label} is not installed`); + await app.evaluate(({ BrowserWindow, Menu }, label) => { + const item = Menu.getApplicationMenu()!.items[0].submenu!.items.find( + (item) => item.label === label, + )!; + item.click(item, BrowserWindow.getAllWindows()[0], {} as never); + }, label); + await expect + .poll( + () => + app + .windows() + .some((window) => + window.url().includes(`/${mode}-view/index.html`), + ), + { timeout: 20_000 }, + ) + .toBe(true); + if (mode !== "testing") + await expect + .poll(() => + app + .windows() + .some((window) => + window.url().includes("/testing-view/index.html"), + ), + ) + .toBe(false); + const main = app + .windows() + .find((window) => window.url().includes(`/${mode}-view/index.html`))!; + + if ((await controlState(app)).stop) await invoke(app, "backend-stop"); + const stopped = { + visible: true, + stop: false, + restart: true, + continue: true, + }; + const running = { + visible: true, + stop: true, + restart: true, + continue: false, + }; + await expect.poll(() => controlState(app)).toEqual(stopped); + await invoke(app, "backend-continue"); + await expect + .poll(() => controlState(app), { timeout: 15_000 }) + .toEqual(running); + await expect(main).toHaveURL( + new RegExp(`/${mode}-view/index\\.html(?:#.*)?$`), + ); + + await invoke(app, "backend-stop"); + await expect.poll(() => controlState(app)).toEqual(stopped); + await pressRestartShortcut(app, mode); + await expect + .poll(() => controlState(app), { timeout: 15_000 }) + .toEqual(running); + const previousLogs = app + .windows() + .find((window) => window.url().includes("/log-viewer/index.html"))!; + await pressRestartShortcut(app, mode); + await expect.poll(() => previousLogs.isClosed()).toBe(true); + await expect + .poll(() => controlState(app), { timeout: 15_000 }) + .toEqual(running); + expect(main.isClosed()).toBe(false); + await expect(main).toHaveURL( + new RegExp(`/${mode}-view/index\\.html(?:#.*)?$`), + ); + }); +} diff --git a/electron-app/build.mjs b/electron-app/build.mjs index 3a34361db..6a5483254 100644 --- a/electron-app/build.mjs +++ b/electron-app/build.mjs @@ -9,6 +9,7 @@ import { cpSync, existsSync, mkdirSync, rmSync } from "fs"; import { dirname, join } from "path"; import { fileURLToPath } from "url"; import { logger } from "./src/utils/logger.js"; +import { generateAboutCredits } from "./src/about/generateCredits.js"; // --- Configuration --- @@ -272,7 +273,9 @@ const buildPython = (name, config, requestedPlatforms) => { `--paths "${config.path}"`, addDataFlags, `"${config.entry}"`, - ].filter(Boolean).join(" "), + ] + .filter(Boolean) + .join(" "), config.path, ); }; @@ -353,6 +356,10 @@ _ __ / _ /_/ /__ /_/ / __/ / _ / / /_/ / /_/ /_ /_/ / / /_/ / _ _ logger.header("Hyperloop Control Station Build"); (async () => { + if (targetsToBuild.some((key) => CONFIG[key].type === "frontend")) { + logger.info("Generating About contributors and avatars..."); + await generateAboutCredits(); + } let frontendBuilt = false; let allSuccess = true; diff --git a/electron-app/package.json b/electron-app/package.json index c60c8bdfe..2549992da 100644 --- a/electron-app/package.json +++ b/electron-app/package.json @@ -9,11 +9,23 @@ "email": "partners@hyperloopupv.com" }, "contributors": [ - { "name": "Maxim Grivennyy", "email": "max766667@gmail.com" }, - { "name": "Javier Ribal del Río", "email": "javierribaldelrio@gmail.com" }, - { "name": "Lola Castello" }, - { "name": "Alejandro González" }, - { "name": "Vasyl Klymenko" } + { + "name": "Maxim Grivennyy", + "email": "max766667@gmail.com" + }, + { + "name": "Javier Ribal del Río", + "email": "javierribaldelrio@gmail.com" + }, + { + "name": "Lola Castello" + }, + { + "name": "Alejandro González" + }, + { + "name": "Vasyl Klymenko" + } ], "license": "MIT", "repository": { @@ -28,6 +40,7 @@ "dist:mac": "electron-builder --mac", "dist:linux": "electron-builder --linux", "test": "vitest run", + "build:credits": "node src/about/generateCredits.js", "build-icons": "electron-icon-builder --input=./icon.png --output=./ --flatten", "build": "node build.mjs", "build:win": "node build.mjs --win", @@ -65,6 +78,7 @@ "vitest": "^4.0.18" }, "build": { + "beforePack": "./src/about/generateCredits.js", "appId": "com.hyperloop.controlstation", "publish": { "provider": "github", diff --git a/electron-app/renderer/about/about.css b/electron-app/renderer/about/about.css new file mode 100644 index 000000000..2a595cb8f --- /dev/null +++ b/electron-app/renderer/about/about.css @@ -0,0 +1,160 @@ +:root { + color-scheme: dark; + font-family: + "Segoe UI", + system-ui, + -apple-system, + sans-serif; + color: #edeef0; + background: #161a2d; +} +* { + box-sizing: border-box; +} +body { + margin: 0; +} +main { + max-width: 760px; + margin: 0 auto; + padding: 30px 28px 22px; +} +header { + text-align: center; +} +.team-logo { + display: block; + width: 220px; + height: auto; + filter: invert(1); + margin: 0 auto 12px; +} +.software-logo { + display: block; + width: 56px; + height: 56px; + object-fit: contain; + margin: 0 auto 14px; + filter: invert(1); + mix-blend-mode: screen; +} +h1 { + font-size: 24px; + margin: 0 0 7px; + font-weight: 600; +} +header p { + margin: 0; + font-size: 12px; + color: #b0b7cf; +} +header .team { + margin-top: 8px; + color: #d5d9e6; +} +section { + margin-top: 28px; + border-top: 1px solid #ffffff18; + padding-top: 20px; +} +h2 { + margin: 0 0 15px; + font-size: 14px; + font-weight: 500; + text-align: center; +} +ul { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; + list-style: none; + margin: 0; + padding: 0; +} +li { + min-width: 0; +} +.contributor { + width: 100%; + min-width: 0; + display: flex; + align-items: center; + gap: 10px; + padding: 11px 9px; + border: 1px solid #ffffff12; + border-radius: 6px; + background: #20274c55; + text-align: left; + font: inherit; + color: inherit; +} +button.contributor { + cursor: pointer; +} +button.contributor:hover { + border-color: #ff7f24; + background: #20274c; +} +:focus-visible { + outline: 2px solid #ff7f24; + outline-offset: 3px; +} +.avatar { + width: 38px; + height: 38px; + flex-shrink: 0; + display: grid; + place-items: center; + border-radius: 50%; + background: #354061; + overflow: hidden; + font-size: 12px; +} +.avatar img { + width: 100%; + height: 100%; + object-fit: cover; +} +.details { + min-width: 0; +} +.name { + display: block; + font-size: 11px; + font-weight: 500; + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; +} +.count { + display: block; + margin-top: 5px; + font-size: 10px; + color: #ffac6f; +} +footer { + margin-top: 24px; + text-align: center; +} +footer p { + font-size: 10px; + line-height: 1.7; + color: #a9b1c9; + margin: 5px 0; +} +[role="alert"] { + color: #ffb9a5; + text-align: center; + font-size: 12px; +} +[hidden] { + display: none !important; +} +@media (max-width: 580px) { + main { + padding: 24px 18px; + } + ul { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} diff --git a/electron-app/renderer/about/about.js b/electron-app/renderer/about/about.js new file mode 100644 index 000000000..f3487016a --- /dev/null +++ b/electron-app/renderer/about/about.js @@ -0,0 +1,73 @@ +const $ = (id) => document.getElementById(id); + +function contributorItem(person) { + const item = document.createElement("li"); + // Authors without an associated GitHub account have no profile to open. + const card = document.createElement(person.login ? "button" : "div"); + card.className = "contributor"; + card.title = person.login + ? `Open ${person.name} on GitHub` + : `${person.name} (unlinked author)`; + if (person.login) { + card.setAttribute("aria-label", `Open ${person.name} on GitHub`); + card.addEventListener("click", async () => { + $("link-error").hidden = true; + try { + await window.aboutAPI.openProfile(person.id); + } catch { + $("link-error").hidden = false; + } + }); + } + const avatar = document.createElement("span"); + avatar.className = "avatar"; + const initials = person.name + .replace(/[^\p{L}\p{N}]/gu, "") + .slice(0, 2) + .toUpperCase(); + avatar.textContent = initials; + if ( + person.avatarUrl && + /^\.\/avatars\/[a-zA-Z0-9-]+\.(png|jpg|webp)$/.test(person.avatarUrl) + ) { + const image = document.createElement("img"); + image.src = person.avatarUrl; + image.alt = ""; + image.addEventListener("error", () => { + avatar.textContent = initials; + }); + avatar.replaceChildren(image); + } + const details = document.createElement("span"); + details.className = "details"; + const name = document.createElement("span"); + name.className = "name"; + name.textContent = person.name; + const count = document.createElement("span"); + count.className = "count"; + count.textContent = `${new Intl.NumberFormat("en-GB").format(person.contributions)} ${person.contributions === 1 ? "commit" : "commits"}`; + details.append(name, count); + card.append(avatar, details); + item.append(card); + return item; +} + +window.aboutAPI + .getCredits() + .then((data) => { + $("app-version").textContent = `Version ${data.version}`; + const contributors = [...data.contributors].sort( + (a, b) => + b.contributions - a.contributions || a.name.localeCompare(b.name), + ); + $("contributors").replaceChildren(...contributors.map(contributorItem)); + $("credits-date").textContent = + `GitHub credits · ${new Date(data.fetchedAt).toLocaleDateString("en-GB")} · Merges excluded`; + }) + .catch(() => { + $("load-error").hidden = false; + }); + +document.addEventListener("keydown", (event) => { + if (event.key === "Escape") window.close(); +}); diff --git a/electron-app/renderer/about/avatars/114338434.jpg b/electron-app/renderer/about/avatars/114338434.jpg new file mode 100644 index 0000000000000000000000000000000000000000..c2861fdee7f34e44bfadc5bb5c13a943848a019e GIT binary patch literal 1992 zcmbW!c{tRI8VB&->@${O7&W7jow4LvW@0RrUC0!QXfsKf5;H`yL?MahWQ)#?8q9JA z9c7tLC3|QQBQdCikmbm}yUscHInVv`-ur!?_x&F;-!AOS$a;RrYki9jGw zD5QiGR$59@QVK68FN0N5RNkqiNFb8le6L{lAoLn9K2q^wP`GBdU`Fd-R( zP$-m?q!dnC8fUCZP&NLavsnXRkiaS66a=IOfH5El2DI4#VgmpO2KqC=zXJk8pfETB ziIR|%1;8K(1Pq11U{L7RWz1FwKrt{mRU>P-{2@Pt8Ut$_f9D}m-KMNo!M%N2!-U4X zhLYH}9fw!c)Y2yE=#t30P0c9gws!Uoj!yfXJv0|G~E4Fm&) zsT#rMtPdgl81iby@kp%Aorh($D0LI}X$2ayU1FOCX+ZPGAEZA;{~hSs|0Vhx^tWeI z3`jvhTMvU^fW5%9Ig$NSjeEN@5l1U@QYvdg@D&QUK27^S%O5ZRJRz2O*D4-GlLalo zJ6@k5`8oioyuIkGpLrQ&Y>xQpTbJ`1w&AAQi=B*vUHw(!xu$H>RCB}nf@#MWo}K!Z ziQ6$?zfjg9b;-k{>kWbQn+cD^NiTlmOk~Hyuo6CS?0^%@>>%R7+V0yO2i(3c4Eeum zO)L79(E@mJ>89D|x$A{*GPuF2I3GrJb}dt zT{^rT>XI$jroYAahq_xgjhWMqk!Q_55eR(1*DIYmR8F zq9f#{Q($K%5=Jb2Qn^8$9d}3kwu7J6SMoK%)$^E<*bWK`L3VMkLw#n zj>UOHHpoL)0ty&KblFeh>K@t!mhD80pyyIm=RbllqTuxLpPDHuDT923;`9D@D`W~^ zSbc-ci@aANu7&sqNgSKD@Xx1znYXCOqZ+8Wo{J3rUHK$OuhKX0RG8|~(?_7IUq0dm z=c?}6HC|NT>ESd+_@y@c02cv`aU3}=d*ppn@%_{{?jB`M_ViF`l88zYIRPGtD%fcv z0M3+P@@W7xqefw3r}PpPpQN`Q>C#+dEQZ8Y|J)Wjxi*%jmlryaFMVPu<4981C%h|s zpLVX-a9U`KHisd8QTGnV>DX1>hL_FhR4Z$dyiHm0@(uD+O1zoU^oh7&pl*LqkpE!P zyQ6m#@N-a^sV_B=gXGtb>gAPdS}^Zp34T$z{DGopm(V%MF%xLtZg+aDd}U{5;NxCF z9eiC_!%CIrmX&h0RY%FTH_9Dr-g7J?VW+T>%JsI&dWJe<{BH7T-=bC(cc;^LyOB3- zupi2GX^t+5rC~h>CQfId`6arpsS zG!V;L=MLgD(+%Gry>2qZ$V=I4l3!PylIoWtb0L=e?JBZ7;PHFSmsuf;rTCa|gMI4} zigU@hTS;xMV4ZDQccSCWlyr$DvgH}A4+{fonnz)-6mY)DGGk~1(v={UO0b3q(aqjz-+RG>w51_QdjMK9?+=GvQHxPBgvyaedA{@3>l2y zjQxK5C98tj(Yr%QI<+D4BlF^3w~AsVw!0dGCQrzPA5y-Dn;K;_E8oQNFSZUkPrZ7-2QRD? z&H1{>tL}p8;mrnBXpbCAddYP>TJerRDGFh(G%SGlywC4OKlIA#qJp>UcOSQ-OX`+i zZrd}Po}G>U^@oeCsNh2BevU=1W@=SGBHpuWqd#MvnYcjF%GKhchm8cg`xa_S`n^kf zTKO##X{I597j1wXy{I5}Fntt#T(}}Lb6)cB2Y=zMRu<-(1>svkkXw|mT?!>7;n*qA zhJp+R>Qmp69o)XB!{<=m1mmOlX;5r<-nSm4xF~J~Tys^0p2QurcpX)Sx=qQ-fvoLf zJ)(99KGSdU`)bv6(jfd=D?_W;iVyfkwx{A*)~CwVp6+>Lqjh>2e=6D31JmET+)0K@ HZT9>N4AOQY literal 0 HcmV?d00001 diff --git a/electron-app/renderer/about/avatars/114561048.jpg b/electron-app/renderer/about/avatars/114561048.jpg new file mode 100644 index 0000000000000000000000000000000000000000..ec329866aabdcb46875dff9c42869bc3f34125b4 GIT binary patch literal 1885 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 QtpET307*qoM6N<$g3uqDYXATM literal 0 HcmV?d00001 diff --git a/electron-app/renderer/about/avatars/152517350.jpg b/electron-app/renderer/about/avatars/152517350.jpg new file mode 100644 index 0000000000000000000000000000000000000000..cd1adcd0fe8fce1769972f10fb9c49a20287b07b GIT binary patch literal 3026 zcmbW!cRU-476YQ(Bqp|&JFIfZ3K#BNK#;cy-yd4!y#lDHIH5=c!=O-D-y zp{IvP@^SJ>{?EB=1~5_qtN>OZAPhjp2m~<#FWZ6H0058z_*Ve`4v-8)PC*H#qNbr` z0FVJeATn|g1qC_zug!p8GXOaw1rwiy3gxwj)?kKdB*j|~isj7>~!?d+d8I666dKKJtW@qOVJ z9P&Ce>`iz?Tzmp1F)2AEH5;3g`!O%Sps=jGqOuBKT~piI*51+C)!oxOIyOErNtl`@ zE-kODuB~r;+5EbiSv*29Kgr_08Lq$T`a`Q?H4v(imqEX_hH@F~#Y;&#?J+ zAq_nlyd|;FYg-2~^ltqrpHITwnC^ZvWm#cw*!iq!2l{OEh|<6kH>ha$!1t%9%F3H1 z!x#J(!D;26yS5&goTTqsns1WWU<^}IVWLfeLK&?|Rc|Il8bfqG&SkrrafEV@Ktncb zW4q2+nt6@}in7cC?nJw-4tcCYxd{)cI^67@fL0m;RFDKr}~Gyi<2cK?f5Advq_A4kNvwJO_pe9M7_pY zgBh{Y*2LmYNRNYpkhdK#%}2emfU#K%~F|fv7u5k(qC9bmFHN23!$r* zfLfQpz!P;m{ppMR9fldRiXE!okVxlY23Wx(n^#%$uk=0zF`ZTgT*W(5*uFLPGb@k#*>=@gOPjPA6@}C zJtFc7O62mzU9OHQA>4Cx;3KnhI${d}ACr7uwq<_&a7Cp=9LtUM4)*??>>O(yY0FhW z!Y4{v<~GgjKxZ5ctrisJINvEOrjPgC;7Bgny_hJjT-}v<+Ai7*BQ3l`LJjTx^ z)=p?A0a+Y>YxBJwK2Bi z%&;|w3Aow6Qzs0I#-MmpPAy_C6ty%tjTWsC2)12={ zjJXj--dvx(7t5ix@^GYi(uPo@vw3u~er<(r0bC7dBJS=g9!Hho=f+zo{6&{qI@euE zRzmD^t$G+%WWe71r0Upm%mF7eqQVgsrx|IS@f(m6ee|{JHHXQ3k1I#HYES*nwr7NS zUzS83{1bl`hV`ECR11WczTvgn3R;8^npf8R=DE|G>W{TojxkaRrqwxNa}7+jE*<^xQ9 zwQ9Wd^RN7ja-2Ga#%)ET`vapnvK9%l$OvuE_uiwnQNgb5VVP$(7%xxA1WA)5M=!l2 z)bRRgrc)_OvlIJ?lTdyaRi~;>qn=O8Psex>;Bu=uvDU8?ixv)5JguSB_WA*m^LC!7P)XwFRaz4xr%CMBlKQ$g&&vtN-W?P@L3qs(pgx*IT7uUFy_K zJC89PsT&oChufIRy1aFQM8z?%BLK=7rUf3%&f5na(&d4PPnlO{qv`KQcN5-TJBN1Y zQ?p@aPoEmWx8Rld*riuYTYb2mjoGvF$JCzI9+>D$z+jhw$-$4pmYeRj{4=Kb#LTo< zoaYh3Nx&?>F3HD^loK+;g+VXaFE+OxOBV@T5UQ%HMYoN}AcPc|*uWti6655U8tI;Y4+=^6F`K94 zDy_ORs&l__g5#~D%(i~i;OTL0_-KbU0d9wv5fjf#JQy-2n}PiJ7_E|V+i!0S4Ef=L z)*BbBtcbo+$1JRzX~+^DIytNg!G%!pb8d`n8Dhs>PuD_u`sBGdh6&f)Hf*9MKMr*4 zRR?A#-tJx@e^S2Fu20k;Z?waeVfI%s5&OzDIsPH$rxAN3j!vn=V(J_SC3O?B$@7kG5w1cM|uXlIZsW}0!Dn<@H5B8qaUTE4heGn>8J z;dfRVMcd#^<}f*Z82#2aVmXo65+9ag5WURuhRfG|Z2)+a#uc;b8;Xh?TB zp>@M}GMwd>b-Fy=BX4Dh@ZoZ)ikBRJi8X%Ui#Ha zk1>nhu>|8zEpAT>U;V)@O%j3z2^x$SE4a??#~JaVp49>`$DZ{<{2#$KNbSfM3+;`c{yK|eIGWIfBiI4_XDB;y>zvU1B~DG z3qXioE1us-8K5s(u!Y&4K0FdZ-)^e71k|0x^HBXUO$CMkbehln9-&+LLcyF@=6%S| zGW2(ULqD}d4|!_Go7P6rA_gb8P#$_;<1VZ&#=h~iS17?UZGoYl2b#Js8!MKU)Q?xY z*yd+F`Q(+45~WW$`e5G~g=Q5Sk4JM9a+fXviQCEvjk!Xk!1XdqhrL@pOT1qm!yPqx ytTvD(k1WF`!!H3hDFb}Q(L`B?;@#6x#M*p+O4_XwWTD*8I`;a$_r5%r)Bgf@OP4Wrz7%L+sCo3Z*EiJF8L6j$`DM(8z>#3+6)g+NfazwI`zLueeHc1O4 zARvGeLP?;}5?Xj^yw?95em#Ie0(5{50TBQ&1_Z%?_|2dv004r4{tWQ%fWQza430nw z2nvY+U=RcXhC*O4D0KfaY`+7b7?>zt^9Q(?tt*1yi`8N!Jw_^+l{bjn^{gmrGyEb1 z1aSu?B&7~15tUUAlXP_T^vMS1$1VP8Nui!N{o@(?pBx;W+}zK5T=4X|$n*~g3`CHfomw}(Fh zpdg_Ahe0ra3BXgcV`Ptdso<1Ud@YpOQexPu652t_F#DiR?+AI+osAg;hrOW*%Ums? z?`UpSq-^u-rb?178d+Q0#tz^*bgJ*DY9H+$Hx*iBIj$b>Ii?UHX@g`ZSP=4P&2d=9 z!`Q?3Ma>xTrwiE*q_H?9JvEH_P&ystE!e>+j7SI;k=ugG5Q48rLuVhQ-d57OlF*rn{<-2Bt*k(ox1U ze!zN5(C)WSfj7kB{H=cDCO4Qu^rde|$F5m2-Z>^x$x0Qur9_1j&wFgE`e->>+RhKQ zaIx0Ckrok?8lU>8`M9qsI(hNWkCAfvLrO!osVlpx>N5_@&iZc>6E^uTz{W=gU3Ibk)fP%|Bcdfyj(DiZUrmL{Mj6y5c6I} zweJc+boQW6NcL$fNDgyPkqvx*^?umVsq%bvrerFNn~Z?S@Ep6*uiPj!MXupnKG1aV z`=Op8Fe8i0Y|hwGR1{7zu8|F!@6;fqja;}LJVrjX=Fc5AqF2>Fiz; zp)6ooX>hf7eDnoP;@AMSGZZv*fMev_7Fwb0-_A&gcQwb<8-ixP>%5)sJCqlp+Fh8e z3nIdXvU9(Mca&Tc-KzX-=_*eSoR&0-?h`+GsxLdlaFbGSUVicZ60h4-a@?0SmT{IR z9AiH(`-^m^JiTLz9>`4$Ki6CJO3)-%{iQg>6+3J%%PBM|+bYB-{`1Mj| z2)TP8X2o)Xcoql^pab;G`upOk`}E3f;y7u&8U$-w zRanVqy*W@h(-7o;6YrW()#5fkCKub-Yt30{{R3Ns^%l0001@P)t-s%F4?A z{{GF)&Gz>8&d$!}=H~3|?BnC(&(F``;Na@&>hSRJ(b3V^*x35|`snEB-{0TV)YR_o z?&Rd;^78WA+uPF8(%jtK_4W1F*Vp^|`}Fkm`1ttH(9q1x%+=M^`T6g5{PXkk*4EbX@$u{H>*C_#_xJbN z+1ZWTJHh|}65>fjK~#8N?7)W&gD?;PK+o|uy`}f;zZzl+By^XK1^@s60000000000 z000000002!s^&k=m*M=*zY_5{ZTg6|HvAL|j%fTNz3YJ8ldJNg4G$C5^2r2rPBTx%3j# zwT%E$Mj=D5!2Y3f&la#jQ(B5H6 ziW_yIxfUbkxf|%rERhyw9NKD(NfRR*nwq<$jbQ~k;x*C)Si4m*a8=f8y~LMvPxsG2FSRjA$8F9{e6y!JPEbsa?it|+Z=E5T z9On*xs?$ALC?!jG74SEc*^uN=z13sLV02Fb3MtTC%);OC`JP^{-{ZKYME9@?lu==H zR~xrAa*ST8eZd`dy36;V2+ybc&bY^}x$-vHuo&GnwV?!aOb>K%pP7A8Ow9%!+0%U) z0}9}6dSD7b#+Z&1Jhr9>mQX;A9yuWZtH>x(nG1p#=y0IEp@-%Oq}!zA3=15AaF-tG zKynjC50$0s^T28bf@;$vepmHL15p)$@xqV-@;^q$Wv-abuJgZSx5eD*I2sS>k$nTf zZRw$m1DP%9v1NoEIowRC*xFng7RMc?SDEK)%Zy8ZGps{IhaSs8VrL4V6-jTKkL9*L zupq{q%(Kl8M*p=kA*4(J)YTr4#K%?st%TFv&*~Eg;qgX&ws-0Osym2s=`q)Xq>M2I z;X#zQr(edo!h|CVe6m#2|l$eFjD4Y?84xA4XwpC@? z;FqC?hAaQnp-17&p^GyK>!`79w}LkKo+Q8QU{NSl6Z+^f62vSdX+fcMpp!L;DoB59 zPPBwpoT=3NVPy)#EkiFosrUa|GH!mWu2NE}e#^w^1 zIp@>;@8{O`c}a_s8qnEDzCp<+VO3~tO+top-dtTlYZvM2$?7Do4y_p^W`npD(;?~E zgX)=#_k>&yy`4zNNXDgX8=5;yww!H{xEVBek*o#QCOtU+$(F2YAxq*0&>fSj+sKl{ z+0fmGT-6n062>@mSCXp3sYBwX(B6r}IMQtj?V-J`RMWd%TJRFZ@l8j%;;|F7SC{G% z;+DkKpuM?N7u=dr^dL+x8<(UjOvG^Dj`od|1bNQvGkSVhyeJPj!)TbH|4 zx_9?ONwx+Kr8J};_KDXIq=$Y564Xb0G895T4e5uE#Bar^O<23*F_^59t8iElHwNKSp8npu0ASLVPHce0mrB2kD-qB6K%Ye@rSt zbDY!nvHX>2Yq&E`NeQ;l@!nzrSvxClTxjs3&rf}A2;Y)J$m<0;w2%RlR*e2md zY6*l^t6msY1@ zu~^IgqgMRKs(EaWGUMuy{>4mas=Un2vgdlQ(y$$T;#T)?nb-5JY_0fz+w3}DpYIM` z699O5u&E@mZmW)(wfy|@^iUyUrIjy~|+IwV|oixXMUS z1u?TL^>uDPWhBT;cUKeYO3O2hLYk#V=fAm#*ObC?9q8yNVOiahLR+M7yM#Unyr$5` z9Qw)2csagD;mx6+jEo1>9)&a<w$}wv-sN*6_kAxMpGe%ac}DzZg$9Q+DHQuIiMv1_4Mt&n zAOYohZNw;~sR3=YDU_l-JBs>OIrk`>jWo4~`d5@0Qwpb?cs;y0zpT@&LDsMJH|aTj z2O8K>NI6;Vr|Ultx2hD8*!=P*Nt^Dc4iGE}h}(vy~Q zEV;$`btu89(bMKglHpQx+6~CBGNvm<3QJZLAvQB0H!DXGILUP8C2m;fkeAc)DGCQ9 zi&rUHV`*dJ^(jT-14+wL)W#bwWkh3&NFGTmQ}m{J6LPUyRf-C;k+>4Yz%4c)53lG_ zbXG{-j^fd-8<2rf-&3RpNFJXCs`OVP^U70&d4zb@m8)hP~1 z#PHN{@G9n}=~CnhxRPhr!R_LUT{|}Ce77Va?y_znd5v?MQ4D+sFHsBD)Xf_HD)RGI zBq+1p8Ay&-XbveRYg|b*3$OD3&M@`>aOjh;{%r0ccIKtEtWtcKg%@cCTD&=*$*>MS z@fM>H{Lj)tj(FIYd~-@ME8wG(rsj`00Y|~S!`_;CvE8`iv{A`(VDLGAvyDhWU&x)P{Z_?A+HlES#Q?eRTmyMRJ z@6TNa{6tUMay-W=Qj(gIfAFL_$8X&X5fwSWSM&^4!1K_qImPDysjR|| zqZjwZMF|F@xXd6Ivl`~EEf;ApvX$ZsNXf72lK*!79#@4A8g3nUAFuro-kD-jfpnUp zKl$$WT*7El0L(!=CKQjEx(zRn_-%7Nde{u<5qFE2a98`6-PKNTj>buY69N?4Hs~xM?eA=~57x&q7fsj0biK)IF>I)(EvIq2O zgnyak$)IwWlpTD6RXK3nVo$RIAuS6@P47C8*h+l;PdPoMLmQHt(_#;qYxI3(A^ zPcB7?RUo}BJ+`=W4KDld?!_4o>_tPm@rU{pr3K{2OQBDdt@ZNM!zZh?I$iL9MIk~O z;iqaWmx`62Jd8M=*SiuV+&q(K#C^P2>!M-1ob1Nhx zhLB1Qpm|DEt$C4$WF#2U(E;>NiD-1bmlJR7Bo3+MKxyWl0-LTD6%*g^?(SS1xik_E zDX}rB$)PysNJ57FWpSI&FV6W^_Ou*L8GXq_7LtwcGW3iShw^xnA|M`;nNUbI=zyPI z5|WJ{W-)|xh9+9thQxZ%#)jz`l3#&FDt`1K87~iM+=W)Wv3?=7O=!kECKghcK6!D6 zbmGtspT!c=2|svAgv1umQr(Rqq}*#lsve-J$yk?=_7=2dEcFPf#-X!3ODd#BY#|MI z(A;61P)LWkLTcO4o{^(4J|qc=wK(3v+$V8UIYP=z@FwiiD?{?|-JXJ~Id~s+{}mzC zo(ySR!CSFQafful580KM#+%v~}I%HxA=@5NLD+_On z7ltZ?lo{Zy88a4BNS&xcsunojows}{q~@)V=Fx=Yt{tR+!TyPmma&G!PBy-zb{RQC zT6J-i>Sg2$srfz5RHrXhNG#{!r_^pww^i}O{g4XJgv5$v{9S6YVswKaV@OB(({OF! zwp6+12R)hPAw!zry9NDMrtaXrlvD2q>yluE3~7MxOrL&Zs|tchO-^oru3olqNUd92 z5e544akdat%Gk8&Pxj9!Z-unG)#xXGv;Ah1i2T#wf9>7dO2a@924G7x5iQgMT1`!= zQA}$*P^|C&YJz&V89`}7FTVXQ_i|WfmxX^6;E%@V>vZl?n%A~WH@avh)?+v_r(U&F zo#zu7Ue4lC>GR^lC*FYe=hNb-t$Q{`J3nd4Di))%EN`;eY8P*D000000000000000 f000000D$!g&?1*xFp3|#00000NkvXXu0mjf>(Bb5 literal 0 HcmV?d00001 diff --git a/electron-app/renderer/about/avatars/228559793.png b/electron-app/renderer/about/avatars/228559793.png new file mode 100644 index 0000000000000000000000000000000000000000..0b976420b016ccccd7ecb157c07a68fdeecec9c1 GIT binary patch literal 5065 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 literal 0 HcmV?d00001 diff --git a/electron-app/renderer/about/avatars/47422871.png b/electron-app/renderer/about/avatars/47422871.png new file mode 100644 index 0000000000000000000000000000000000000000..156493877eca5e984de8aae6c1176ae5afb3c75c GIT binary patch literal 12159 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 Bl34%% literal 0 HcmV?d00001 diff --git a/electron-app/renderer/about/avatars/58850783.jpg b/electron-app/renderer/about/avatars/58850783.jpg new file mode 100644 index 0000000000000000000000000000000000000000..e74963c1abc8d31fd92d7da08e8f6507ee37bde2 GIT binary patch literal 1710 zcmbW!X;6~~8VB(A&6_)+nh=mcfDj@v0o)Lxkbuh}M*so2!c~jqQczq13fM8DP_3Av zC0y1l20`u;FqBk~S`SD?3zQ>>29O2hR0ISRUB@hKcRKrVcmFfb^X311`OTwyq8kCu z0%#->g+!oHC=>>R#^OnOcpMH-ChBh?nHrm$nHp0lRQgT^m1bi}q3m#A+OX}oT&_8T zx5ts=ywje`0Wla19)~y5(=*~&P%JqAb98qB0vb379E3m`03(190n~MZ6#xJtz~2M> zJ0J{#BakRG28-JYz(5Fs!65_!4&S^KZ*~BjfFN4fxgqs~K1b0+Bu=)p9Bt{Yyk`*H zzf8A}P0PVx4YwJQjkjAdb}(7oUAr9|dHlT|o?hMpAK#GBFkyH^WK>*yLgJyrNy+KQ zPh@0fo&4g&o(EAL?}?Sq#MV$=GuhIH=4_LbjAzl;7mP|p7) z`V;i0N2dkw5V-j;hyb_%OV|+Kp#r&P;HRPO29g2NOhZvM=^9p@RF$k39d@&Sao<~$ z+?Vwnf6?1AeVg(t&R>U@=)e8;2lN{?)hW1TK-Cd_N;qid75yD=(&R?NS%=wyTmDY! zC@Tfc^N5&y<-Nx{A!88LG~s%Y)^cBUP{CHPt&1mR!WwHeSK)tVPBQ4L=?p>FlX13Q z$r%O&5;#4+_0`AWhK}kSK`4qmCDH*e!Vz^z#E9J+iKD50VBZ$s_2?dpogdOqWU@!4 z9@(L54}*leVm^r)jkp1l`>}} zzZPo@njAO$YI?8>nRq*bwQ-q{%y=hRyRT~IP_5N$MSw70L3I#ROM|8UwAu<7iy6>0 zro4KsG^~KNdtVNd?OnFee8AuKarxgY8;(kZbfegNuFuzztv>5L$D9=3*glevlx6Lj zzjG*BygZRB=0uqB-Bz1OU;S_g`^-AxBk{&R&GMT0DJAK*&tIE! zaVz`zrl7I5{d$3KZ&#DVUA4QH*`AcErO7;f1fFb5noL+dA_$iu)1I-q4PUUQob~5F z{bFI8dAyvN@>meV!tv{+8?q(mu&rW-5q!z$*S_JR7vY3TPcY$B99bv_*@_I1r&y^j&;jqS7KyJWgeS&`Oq!ats|CO(p_BV0zdS@XSgssEF|*jT%LZ5n1L`qmw#siGQhr8x zmt1Wp@@pwP!>cQS@|5ZT$@NzfSJB7qt$0Lp-b-HG(q}1+9hBP0B8u_aX@d60%z%4o zO{>_|vBYP=?zN24$=0e&hxBGl+Tr-<`n^WjYhvrlzNYD%1#8ilq#FF5PaJ7&a~0yd ze5qu-FGc28&NHWrx759KX_^i<8Q;s(YOluN?E?c?T*}J4F!o~nE4Ri%S!ZtY{-o-$ zzW7re+I-_!hd7f_rX&3I5r3iMFr-TG68(DHc4(%*cu!JsoLlK3g-Mrba#j0f-X4+A zq>8FE)ufJjH@oVk^DpIG+!+Er$s2ZaVTqIL5~%uxi}H2i*@U*r#3|)3=D7RO-9;r! zc?Ux)p4#Tq55q1YQ3id^k&tu2X>6Sc_>1xTzKW90M*-1Amfz4b>MXmDg5SlCP%e#X nLOjAzrVAH5&$VIODE+z&M6BUO* zAQBSdlG1Yfq@|>!VX_DJ%c&~E)l`*LR1RwzY8}?lN2sW1o9gHvGeV(Ia4mByGo+=V zF$xKkkdTm;l2+QcPYJ26qK^EZv)2xQiUR@xfgqp;Km-Z|L4kX0U>N`a6b1enz`p|& z0f9xuAmS2|QU?GcKoCd-3=$OugTG%Ue(wNasHm*Ek&W0v_aKNylpHcCr&JtaTmMYn zWAKBf@kRVi2}uP-C7AM&qgvWJx~StOre@|Ac4&JC$CFN{&U&8n^7ird!(Ivw3B4Q^ z9vwsYIhGg~pL{DNH7)&@j9f|{HUCaQVNqH6eR{=%hm}H&n_JsEpLT%&&|jSI`!CRcc%a`sB498G4Ee(Y z6p8&lAShT=-AGK<#vKwAbx;GDBra#0Q(FH_0%7d&LH;6sP*On?HGSmcAEZA;{~hS& z|0Vhx^tWek3?K~xet#GU3a|#OfoZQ5@v>DSxyC~1Sj~m7Z42z-dpJF^YJBKWm6JA< zCM;?9J~T3Fv~T7aFfOCK>)OuMCu#gf1A3Z!OVNt2syZzAQ@Xx`4|e>p_m6ce{h0C6 zE`d@`t|R?inm5g%T1lv2s^mUQ`BFcYqIi+Jy&XnM)NL&-zOe+`c=lDt;3D&J^JV%E za+TQzT~TCL*E&)>Y0IFwcoFTRcfHqw*Zn9Z-PrMm{JBt2Y3U1t49{^5ig$~X*Rd&! zrbX}@HM!U&;wr`O!0J%@N2@W4Y;lb-S7u~;49BZ7^oI1PSc}d``9t7=} zt*}ber4|~X;vbm~O#P%hX;&o**MWh_#NAiiTW)=mxSowf=Z(+2T$ry-L_?Fy4c1yf z$yn0U`fTo)nu{moNZxKnzjrdY<@PoUlqp25umKMzjW8G3)d<3y@n($JKWYlM zURuxYxM>nmcM6JW^|8_UJ*(#xk|TP{LgClf_tWwGrup|8D)k|&n{{Y(M(?O)M`iQt zLHBVq?vyF-PB|p7y(1%MH18TwjaO9kjaGAD(((O^^W=9@NuOKfhe!AdxafM+miDB` zTQX_3 zO^@^~XM=3<2Ds#awS@9739si>KE+EM2Swkq|nOJldt&K2Sx zm*gae28KravE7I!Tmi+U*sw1P(SWzjbzMDwH0AP#9E6Be6uIyLDlgVh5kE5egpew7 zp;VsJ+eGP)Qi`msZS-e`8T{0bH?LK|`BQI;kCcnptQC1SO%Q5Jy7EkbbcZB)j9PJe zZR7JImD4rMuM_%LX2zE8#y0_1hgv2NV;uEy+Jak|)_~*h zulBy$bueIUaQQ6p0S=t$@GA{J`tptOm(}X@RQrfU1mimI$YdV}>53q=S1*zF3#7iv z^v{vFC#g?QJ@#p5b(LXVvF_#%q0fjJ2(`wTNpOk}X$rBs(2|gDdSd$7Mr}5ma^||d zCB9;W9X1btFO4S|XLmFdB3q+<+F(nAVV$s>JiF9fCc_;TYiKrm+~Fz&uuye;^F9RS86KWzx(XxDY*Ypo7IRti^_>cr zU50|LL?{GLSg7&rI+c{mpaOa8{^o(Js(M;JO!Ca<=sBu=z|-tahEAIc#y&5Ce3_XL z`uxZDOyW6&mI3Hd=r}EVU^tga(;G6{5e6iG_&rK)m`~GIzXZ79xa;+T7O`Q~zG0o> z@Kxs>NX8*t5gdcv5xTLX1V8*jYhAIPoxF(M2)PY^ExCw(JR=&{U1#6cV<*kR2y8K}tRALX@{b0^n|=*I+a*^64oBW;s5!}8_K+~@_nBpQE}=v<$q z(_VeEp5x~odPu`-G3nito*AOIDw1DXKX?G7{#d=N+B=QVzU}q3l9ZOtwtWFV??Ur zw)N*`)jn0Om7fUI$49r9@g+{*MH7l-7{Sw?r>_JE68!&JN5de~vR>_6v-wu+dVIn+ zT%jmWjZoZomh(zGI=hY@_mpi8{c`Aj3+JxfXT`5Wh1T8PC8ObYOpy2Ugv>Di0o zj7rX!UBOHxv6s`f@-f6@*ZW=or(rs+%`E0Xt7mP=AtN;K2 literal 0 HcmV?d00001 diff --git a/electron-app/renderer/about/avatars/60979852.png b/electron-app/renderer/about/avatars/60979852.png new file mode 100644 index 0000000000000000000000000000000000000000..32e45659c5a7cf92c9eb157c4e97cfc2b37c3298 GIT binary patch literal 12100 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 literal 0 HcmV?d00001 diff --git a/electron-app/renderer/about/avatars/74974283.jpg b/electron-app/renderer/about/avatars/74974283.jpg new file mode 100644 index 0000000000000000000000000000000000000000..1490e555d5146a85f9fab26ce76df03a616673de GIT binary patch literal 1198 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 literal 0 HcmV?d00001 diff --git a/electron-app/renderer/about/index.html b/electron-app/renderer/about/index.html index 9038c9620..149728c9b 100644 --- a/electron-app/renderer/about/index.html +++ b/electron-app/renderer/about/index.html @@ -1,93 +1,48 @@ - + + About Control Station - + + - -
- - -

Hyperloop Control Station

-

-

Hyperloop UPV · Software

-

Logging View: Javier Ribal del Río — H11

-

© HyperloopUPV - Software

-
- +
+
+ + +

Control Station

+

Version —

+

Hyperloop UPV · Software

+
+
+

Contributors

+
    + + +
    +
    +

    Logging View: Javier Ribal del Río — H11

    +

    +

    © Hyperloop UPV · Software · MIT License

    +
    +
    diff --git a/electron-app/src/about/README.md b/electron-app/src/about/README.md new file mode 100644 index 000000000..d3159efc2 --- /dev/null +++ b/electron-app/src/about/README.md @@ -0,0 +1,19 @@ +# About + +A small About window with the original Hyperloop UPV and Software logos, Control Station, its version, and contributors sorted by descending commit count. Clicking a linked contributor opens their GitHub profile in the default browser. Unlinked authors remain credited without an invented profile; bots are excluded from the member list. + +## Build-time generation + +`generateCredits.js` fetches every page of the public GitHub commits API for the default branch. Commits with two or more parents (including octopus merges) are excluded. Root commits and empty non-merge commits count. Authors are grouped by GitHub account ID, or by author email for unlinked accounts; email addresses are only temporary grouping keys and are never stored. A SHA set prevents duplicate counts, and pagination is pinned to the first page's head SHA so new pushes cannot shift the history mid-build. + +The `contributions` field is the resulting non-merge count, not GitHub's merge-inclusive contributors total. Avatars are downloaded into `renderer/about/avatars/`. Rebuilding an unchanged head reuses the existing counts and photos. The full initial traversal may require a GitHub token to stay within API limits. + +It runs from `build.mjs` for frontend/Electron builds and from electron-builder's `beforePack` hook, covering `pack`, all `dist` commands and `.github/workflows/release.yaml`. CI's existing `GH_TOKEN` is used if available. For a manual refresh: `pnpm --dir electron-app build:credits`. + +The generated `credits.snapshot.json` records `counting: "non-merge"` and the head SHA. It and the images ship in the existing `src/**/*` / `renderer/**/*` packaging globs. Offline builds keep the previous non-merge snapshot; missing photos fall back to saved photos or initials. Failed pagination never replaces the complete snapshot with a partial list, and legacy merge-inclusive snapshots are not accepted as a fallback. + +## Runtime + +Opening About only reads the bundled JSON and images. There is no GitHub fetch, background refresh, user cache, search, activity feed or synchronization control. The renderer's CSP blocks all network access. Its sandboxed preload exposes only credits and a sender-checked, contributor-ID-based profile opener. Go is untouched. + +Tests: `pnpm --dir electron-app test` and `pnpm --dir e2e test:fast:ui --grep 'About shows|About opens|English menus'`. diff --git a/electron-app/src/about/__tests__/githubCredits.test.js b/electron-app/src/about/__tests__/githubCredits.test.js new file mode 100644 index 000000000..53375be85 --- /dev/null +++ b/electron-app/src/about/__tests__/githubCredits.test.js @@ -0,0 +1,246 @@ +import { mkdtemp, readFile, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { expect, it, vi } from "vitest"; +import { getAboutCredits } from "../githubCredits.js"; +import { generateAboutCredits } from "../generateCredits.js"; + +const sha = (id) => id.toString(16).padStart(40, "0"); +function commit(id, member = 1, parents = 1) { + return { + sha: sha(id), + parents: Array.from({ length: parents }, (_, index) => ({ + sha: sha(id + index + 1000), + })), + author: { id: member, login: `member-${member}`, type: "User" }, + commit: { + message: "A change", + author: { + name: `Member ${member}`, + email: `member-${member}@example.com`, + }, + }, + }; +} +const response = (data) => + new Response(JSON.stringify(data), { + headers: { "content-type": "application/json" }, + }); +const photoResponse = () => + new Response(new Uint8Array([137, 80, 78, 71]), { + headers: { "content-type": "image/png" }, + }); +async function options(fetchImpl) { + const directory = await mkdtemp(path.join(os.tmpdir(), "about-build-")); + return { + fetchImpl, + snapshotPath: path.join(directory, "credits.json"), + avatarDirectory: path.join(directory, "avatars"), + token: "test-token", + warn: vi.fn(), + }; +} +const apiFor = (commits) => + vi.fn(async (url) => + url.includes("api.github.com") ? response(commits) : photoResponse(), + ); + +it("reads sorted bundled credits without any runtime fetch", () => { + const fetchSpy = vi.spyOn(globalThis, "fetch"); + try { + const credits = getAboutCredits(); + expect(credits.contributors.length).toBeGreaterThan(0); + expect(credits.contributors.some((person) => person.type === "Bot")).toBe( + false, + ); + const counts = credits.contributors.map((person) => person.contributions); + expect(counts).toEqual([...counts].sort((a, b) => b - a)); + expect(fetchSpy).not.toHaveBeenCalled(); + } finally { + fetchSpy.mockRestore(); + } +}); + +it("ships a snapshot explicitly generated without merges", async () => { + const snapshot = JSON.parse( + await readFile( + new URL("../credits.snapshot.json", import.meta.url), + "utf8", + ), + ); + expect(snapshot.counting).toBe("non-merge"); + expect(snapshot.schemaVersion).toBe(3); + expect(snapshot.headSha).toMatch(/^[a-f0-9]{40}$/); +}); + +it("counts non-merge commits, not the merge-inclusive contributors total", async () => { + const root = commit(1, 2, 0); + const normal = commit(2, 2); + normal.commit.message = "Merge is part of this ordinary commit's title"; + const merge = commit(3, 2, 2); + const octopus = commit(4, 2, 3); + const fetchImpl = apiFor([root, normal, merge, octopus, commit(5, 1)]); + const config = await options(fetchImpl); + const data = await generateAboutCredits(config); + expect(data.counting).toBe("non-merge"); + expect(data.headSha).toBe(root.sha); + expect( + data.contributors.map((person) => [person.login, person.contributions]), + ).toEqual([ + ["member-2", 2], + ["member-1", 1], + ]); + expect(data.contributors[0].avatarUrl).toBe("./avatars/2.png"); + expect(await readFile(path.join(config.avatarDirectory, "2.png"))).toEqual( + Buffer.from([137, 80, 78, 71]), + ); + expect(JSON.parse(await readFile(config.snapshotPath, "utf8"))).toEqual(data); + expect(JSON.stringify(data)).not.toContain("@example.com"); + expect(JSON.stringify(data)).not.toContain("test-token"); + expect( + fetchImpl.mock.calls.find(([url]) => url.includes("api.github.com"))[1] + .headers.Authorization, + ).toBe("Bearer test-token"); + expect( + fetchImpl.mock.calls.find(([url]) => + url.includes("avatars.githubusercontent.com"), + )[1].headers, + ).toBeUndefined(); +}); + +it("groups account aliases, keeps distinct unlinked authors and excludes bots", async () => { + const alias = commit(2); + alias.commit.author.name = "Another name"; + alias.commit.author.email = "another-address@example.com"; + const unlinked = commit(3); + unlinked.author = null; + unlinked.commit.author = { name: "Early author", email: "early@example.com" }; + const unlinkedAgain = structuredClone(unlinked); + unlinkedAgain.sha = sha(4); + const otherUnlinked = structuredClone(unlinked); + otherUnlinked.sha = sha(5); + otherUnlinked.commit.author.email = "different@example.com"; + const bot = commit(6, 3); + bot.author.type = "Bot"; + const data = await generateAboutCredits( + await options( + apiFor([commit(1), alias, unlinked, unlinkedAgain, otherUnlinked, bot]), + ), + ); + expect(data.contributors.map((person) => person.contributions)).toEqual([ + 2, 2, 1, + ]); + expect( + data.contributors.filter((person) => person.login === null), + ).toHaveLength(2); + expect( + data.contributors.find((person) => person.login === "member-1") + .contributions, + ).toBe(2); + expect(JSON.stringify(data)).not.toContain("@example.com"); +}); + +it("paginates the complete history pinned to a SHA and counts each commit once", async () => { + const firstPage = Array.from({ length: 100 }, (_, index) => + commit(index + 1), + ); + const fetchImpl = vi.fn(async (url) => { + if (url.includes("avatars.githubusercontent.com")) return photoResponse(); + return response( + url.endsWith("page=1") ? firstPage : [firstPage[99], commit(101)], + ); + }); + const data = await generateAboutCredits(await options(fetchImpl)); + expect(data.contributors[0].contributions).toBe(101); + expect(fetchImpl).toHaveBeenCalledWith( + expect.stringContaining(`/commits?sha=${sha(1)}&per_page=100&page=2`), + expect.anything(), + ); +}); + +it("reuses a previously verified non-merge snapshot when the history head is unchanged", async () => { + const fetchImpl = apiFor([commit(1)]); + const config = await options(fetchImpl); + const data = await generateAboutCredits(config); + fetchImpl.mockClear(); + expect(await generateAboutCredits(config)).toEqual(data); + expect(fetchImpl).toHaveBeenCalledTimes(1); +}); + +it("keeps the complete non-merge snapshot on offline or partial failures", async () => { + const config = await options( + vi.fn(async () => { + throw new Error("Offline"); + }), + ); + const previous = { + counting: "non-merge", + headSha: sha(500), + contributors: [ + { id: "1", login: "member-1", name: "member-1", contributions: 1 }, + ], + fetchedAt: "2026-10-21T11:00:00Z", + }; + await writeFile(config.snapshotPath, JSON.stringify(previous)); + expect(await generateAboutCredits(config)).toEqual(previous); + config.fetchImpl.mockImplementation(async (url) => + url.endsWith("page=1") + ? response(Array.from({ length: 100 }, (_, index) => commit(index + 1))) + : new Response("Rate limited", { status: 403 }), + ); + expect(await generateAboutCredits(config)).toEqual(previous); + expect(JSON.parse(await readFile(config.snapshotPath, "utf8"))).toEqual( + previous, + ); +}); + +it("never silently falls back to legacy merge-inclusive counts", async () => { + const config = await options( + vi.fn(async () => { + throw new Error("Offline"); + }), + ); + await writeFile( + config.snapshotPath, + JSON.stringify({ contributors: [{ id: "1", contributions: 986 }] }), + ); + await expect(generateAboutCredits(config)).rejects.toThrow("Offline"); +}); + +it("keeps a saved photo on download failure and uses initials for new authors", async () => { + const fetchImpl = apiFor([commit(1)]); + const config = await options(fetchImpl); + await generateAboutCredits(config); + fetchImpl.mockImplementation(async (url) => + url.includes("api.github.com") + ? response([commit(2), commit(3, 2)]) + : new Response("Offline", { status: 503 }), + ); + const data = await generateAboutCredits(config); + expect(data.contributors.find((person) => person.id === "1").avatarUrl).toBe( + "./avatars/1.png", + ); + expect( + data.contributors.find((person) => person.id === "2").avatarUrl, + ).toBeNull(); +}); + +it("fails a first build without data instead of silently generating empty credits", async () => { + const config = await options(apiFor([])); + await expect(generateAboutCredits(config)).rejects.toThrow("no contributors"); +}); + +it("wires generation into electron-builder and the local Electron build", async () => { + const packageJson = JSON.parse( + await readFile(new URL("../../../package.json", import.meta.url), "utf8"), + ); + expect(packageJson.build.beforePack).toBe("./src/about/generateCredits.js"); + expect(packageJson.scripts["build:credits"]).toBe( + "node src/about/generateCredits.js", + ); + const localBuild = await readFile( + new URL("../../../build.mjs", import.meta.url), + "utf8", + ); + expect(localBuild).toContain("await generateAboutCredits()"); +}); diff --git a/electron-app/src/about/credits.snapshot.json b/electron-app/src/about/credits.snapshot.json new file mode 100644 index 000000000..b947797ff --- /dev/null +++ b/electron-app/src/about/credits.snapshot.json @@ -0,0 +1,169 @@ +{ + "schemaVersion": 3, + "repository": "Hyperloop-UPV/software", + "counting": "non-merge", + "headSha": "7f6deb7fc751c782a05175f9d25f162145122999", + "fetchedAt": "2026-10-22T21:19:51.503Z", + "contributors": [ + { + "id": "114561048", + "login": "jmaralo", + "name": "jmaralo", + "contributions": 774, + "avatarUrl": "./avatars/114561048.jpg", + "type": "User" + }, + { + "id": "114338434", + "login": "smorsua", + "name": "smorsua", + "contributions": 588, + "avatarUrl": "./avatars/114338434.jpg", + "type": "User" + }, + { + "id": "47422871", + "login": "msanlli", + "name": "msanlli", + "contributions": 557, + "avatarUrl": "./avatars/47422871.png", + "type": "User" + }, + { + "id": "74974283", + "login": "maximka76667", + "name": "maximka76667", + "contributions": 537, + "avatarUrl": "./avatars/74974283.jpg", + "type": "User" + }, + { + "id": "70332045", + "login": "JavierRibaldelRio", + "name": "JavierRibaldelRio", + "contributions": 311, + "avatarUrl": "./avatars/70332045.png", + "type": "User" + }, + { + "id": "130096040", + "login": "JFisica", + "name": "JFisica", + "contributions": 188, + "avatarUrl": "./avatars/130096040.png", + "type": "User" + }, + { + "id": "60979852", + "login": "lipezaballa", + "name": "lipezaballa", + "contributions": 169, + "avatarUrl": "./avatars/60979852.png", + "type": "User" + }, + { + "id": "76229993", + "login": "andresdlt03", + "name": "andresdlt03", + "contributions": 164, + "avatarUrl": "./avatars/76229993.png", + "type": "User" + }, + { + "id": "71978109", + "login": "l-castel", + "name": "l-castel", + "contributions": 55, + "avatarUrl": "./avatars/71978109.jpg", + "type": "User" + }, + { + "id": "183129863", + "login": "JESUSJH18", + "name": "JESUSJH18", + "contributions": 44, + "avatarUrl": "./avatars/183129863.png", + "type": "User" + }, + { + "id": "228559793", + "login": "vasyl-ks", + "name": "vasyl-ks", + "contributions": 25, + "avatarUrl": "./avatars/228559793.png", + "type": "User" + }, + { + "id": "99354212", + "login": "ariadnatp", + "name": "ariadnatp", + "contributions": 22, + "avatarUrl": "./avatars/99354212.png", + "type": "User" + }, + { + "id": "58850783", + "login": "g0nz4I0", + "name": "g0nz4I0", + "contributions": 10, + "avatarUrl": "./avatars/58850783.jpg", + "type": "User" + }, + { + "id": "anonymous-14", + "login": null, + "name": "Nomad", + "contributions": 8, + "avatarUrl": null, + "type": "Anonymous" + }, + { + "id": "anonymous-15", + "login": null, + "name": "[Marc Sanchis]", + "contributions": 6, + "avatarUrl": null, + "type": "Anonymous" + }, + { + "id": "60261013", + "login": "Losina24", + "name": "Losina24", + "contributions": 5, + "avatarUrl": "./avatars/60261013.jpg", + "type": "User" + }, + { + "id": "80858832", + "login": "Humanoidear", + "name": "Humanoidear", + "contributions": 4, + "avatarUrl": "./avatars/80858832.png", + "type": "User" + }, + { + "id": "152909605", + "login": "davidpascual05", + "name": "davidpascual05", + "contributions": 1, + "avatarUrl": "./avatars/152909605.jpg", + "type": "User" + }, + { + "id": "152517350", + "login": "Saulsantos25", + "name": "Saulsantos25", + "contributions": 1, + "avatarUrl": "./avatars/152517350.jpg", + "type": "User" + }, + { + "id": "anonymous-19", + "login": null, + "name": "sms", + "contributions": 1, + "avatarUrl": null, + "type": "Anonymous" + } + ] +} diff --git a/electron-app/src/about/generateCredits.js b/electron-app/src/about/generateCredits.js new file mode 100644 index 000000000..44602bf3d --- /dev/null +++ b/electron-app/src/about/generateCredits.js @@ -0,0 +1,188 @@ +import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const ROOT = fileURLToPath(new URL("../../", import.meta.url)); +const REPOSITORY = "Hyperloop-UPV/software"; +const SNAPSHOT = path.join(ROOT, "src/about/credits.snapshot.json"); +const AVATARS = path.join(ROOT, "renderer/about/avatars"); + +/** Runs during Electron build/packaging, never from the running application. */ +export async function generateAboutCredits({ + fetchImpl = globalThis.fetch, + snapshotPath = SNAPSHOT, + avatarDirectory = AVATARS, + token = process.env.GH_TOKEN || process.env.GITHUB_TOKEN, + now = Date.now, + warn = console.warn, +} = {}) { + let previous; + try { + previous = JSON.parse(await readFile(snapshotPath, "utf8")); + } catch { + /* First build. */ + } + const headers = { + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + "User-Agent": "Hyperloop-UPV-Control-Station", + }; + if (token) headers.Authorization = `Bearer ${token}`; + try { + const authors = new Map(); + const seen = new Set(); + let headSha; + for (let page = 1; ; page++) { + const response = await fetchImpl( + `https://api.github.com/repos/${REPOSITORY}/commits?${headSha ? `sha=${headSha}&` : ""}per_page=100&page=${page}`, + { + headers, + signal: AbortSignal.timeout(15_000), + }, + ); + if (!response.ok) + throw new Error(`GitHub commits: HTTP ${response.status}`); + const commits = await response.json(); + if (!Array.isArray(commits)) + throw new Error("Invalid GitHub commit response"); + if (page === 1 && commits.length) { + headSha = commits[0].sha; + if (!/^[a-f0-9]{40}$/.test(headSha)) + throw new Error("Invalid history head"); + // A second build of the same revision can reuse its already verified counts/photos. + if (previous?.counting === "non-merge" && previous.headSha === headSha) + return previous; + } + for (const commit of commits) { + if ( + !/^[a-f0-9]{40}$/.test(commit.sha) || + !Array.isArray(commit.parents) + ) + throw new Error("Invalid GitHub commit"); + if (seen.has(commit.sha)) continue; + seen.add(commit.sha); + // Check ancestry, not the message: ordinary commits can be named "merge". + // Root commits (zero parents) and empty non-merge commits still count. + if (commit.parents.length > 1) continue; + const person = commit.author || {}; + if (person.type === "Bot") continue; + const login = typeof person.login === "string" ? person.login : null; + if ( + login && + (!/^[a-zA-Z0-9][a-zA-Z0-9-]{0,38}$/.test(login) || + !Number.isSafeInteger(person.id)) + ) + throw new Error("Invalid GitHub contributor identity"); + // Emails are used only as transient identity keys, never written to the snapshot. + const name = login || commit.commit?.author?.name || "Unlinked author"; + const key = login + ? `user:${person.id}` + : `anonymous:${commit.commit?.author?.email || name}`; + if (authors.has(key)) { + authors.get(key).contributions++; + continue; + } + authors.set(key, { + id: login ? String(person.id) : `anonymous-${authors.size}`, + login, + name, + contributions: 1, + avatarUrl: null, + type: login ? "User" : "Anonymous", + }); + } + if (commits.length < 100) break; + if (page === 1000) throw new Error("Too many commit pages"); + } + const contributors = [...authors.values()]; + if (!contributors.length) + throw new Error("GitHub returned no contributors"); + contributors.sort( + (a, b) => + b.contributions - a.contributions || a.name.localeCompare(b.name), + ); + await mkdir(avatarDirectory, { recursive: true }); + // Four downloads at a time, without sending the GitHub API token to the avatar host. + let cursor = 0; + await Promise.all( + Array.from({ length: 4 }, async () => { + while (cursor < contributors.length) { + const person = contributors[cursor++]; + if (!person.login) continue; + try { + const response = await fetchImpl( + `https://avatars.githubusercontent.com/u/${person.id}?s=96`, + { signal: AbortSignal.timeout(10_000) }, + ); + if (!response.ok) throw new Error("Avatar download failed"); + const type = response.headers.get("content-type")?.split(";")[0]; + const extension = { + "image/png": "png", + "image/jpeg": "jpg", + "image/webp": "webp", + }[type]; + if (!extension) throw new Error("Unsupported avatar image"); + const image = Buffer.from(await response.arrayBuffer()); + if (!image.length || image.length > 256_000) + throw new Error("Invalid avatar size"); + const filename = `${person.id}.${extension}`; + await writeFile(path.join(avatarDirectory, filename), image); + person.avatarUrl = `./avatars/${filename}`; + } catch { + const saved = previous?.contributors?.find( + (item) => item.id === person.id, + )?.avatarUrl; + if (/^\.\/avatars\/\d+\.(png|jpg|webp)$/.test(saved)) { + try { + await readFile( + path.join(avatarDirectory, path.basename(saved)), + ); + person.avatarUrl = saved; + } catch { + /* Initials are the offline fallback. */ + } + } + warn( + `About: could not update the avatar for ${person.name}; keeping the local fallback.`, + ); + } + } + }), + ); + const snapshot = { + schemaVersion: 3, + repository: REPOSITORY, + counting: "non-merge", + headSha, + fetchedAt: new Date(now()).toISOString(), + contributors, + }; + const temporary = `${snapshotPath}.tmp`; + await writeFile( + temporary, + `${JSON.stringify(snapshot, null, 2)}\n`, + "utf8", + ); + await rename(temporary, snapshotPath); + return snapshot; + } catch (error) { + // Offline builds retain the complete bundled credits instead of generating a partial list. + if (!previous?.contributors?.length || previous.counting !== "non-merge") + throw error; + warn(`About: ${error.message}. Using the bundled contributor snapshot.`); + return previous; + } +} + +// electron-builder's beforePack hook, including the existing release workflow. +let buildPromise; +export default function beforePack() { + return (buildPromise ??= generateAboutCredits()); +} + +if ( + process.argv[1] && + pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url +) { + await generateAboutCredits(); +} diff --git a/electron-app/src/about/githubCredits.js b/electron-app/src/about/githubCredits.js new file mode 100644 index 000000000..dfccce926 --- /dev/null +++ b/electron-app/src/about/githubCredits.js @@ -0,0 +1,22 @@ +import { readFileSync } from "node:fs"; + +export const REPOSITORY = "Hyperloop-UPV/software"; + +/** Build-time snapshot only. Opening About never accesses GitHub or a user cache. */ +export function getAboutCredits() { + const data = JSON.parse( + readFileSync(new URL("./credits.snapshot.json", import.meta.url), "utf8"), + ); + if (data.counting !== "non-merge") { + throw new Error("About credits must be generated without merge commits"); + } + return { + fetchedAt: data.fetchedAt, + contributors: data.contributors + .filter((person) => person.type !== "Bot") + .sort( + (a, b) => + b.contributions - a.contributions || a.name.localeCompare(b.name), + ), + }; +} diff --git a/electron-app/src/windows/__tests__/aboutWindow.test.js b/electron-app/src/windows/__tests__/aboutWindow.test.js new file mode 100644 index 000000000..e2850b221 --- /dev/null +++ b/electron-app/src/windows/__tests__/aboutWindow.test.js @@ -0,0 +1,127 @@ +import { EventEmitter } from "node:events"; +import { beforeEach, expect, it, vi } from "vitest"; +import { BrowserWindow, ipcMain, shell } from "electron"; +import { showAboutWindow } from "../aboutWindow.js"; + +const mocks = vi.hoisted(() => ({ + handlers: new Map(), + getAboutCredits: vi.fn(), +})); +vi.mock("electron", () => ({ + app: { getVersion: () => "1.2.3" }, + screen: { + getPrimaryDisplay: () => ({ workAreaSize: { width: 1920, height: 1080 } }), + }, + ipcMain: { + handle: vi.fn((name, callback) => mocks.handlers.set(name, callback)), + }, + shell: { openExternal: vi.fn() }, + BrowserWindow: vi.fn(function () { + const window = new EventEmitter(); + window.webContents = new EventEmitter(); + window.webContents.mainFrame = { + url: "file:///app/renderer/about/index.html", + }; + window.webContents.setWindowOpenHandler = vi.fn(); + window.loadFile = vi.fn(async () => {}); + window.isDestroyed = () => false; + window.isMinimized = () => false; + window.focus = vi.fn(); + return window; + }), +})); +vi.mock("../../about/githubCredits.js", () => ({ + getAboutCredits: mocks.getAboutCredits, +})); +vi.mock("../../utils/paths.js", () => ({ getAppPath: () => "/app" })); +vi.mock("../../menu/menu.js", () => ({ setWindowMenu: vi.fn() })); + +beforeEach(() => { + BrowserWindow.mock.results.at(-1)?.value.emit("closed"); + vi.clearAllMocks(); + mocks.getAboutCredits.mockReturnValue({ + contributors: [ + { id: "1", login: "teammate" }, + { id: "2", login: null }, + ], + }); +}); +function sender(window) { + return { + sender: window.webContents, + senderFrame: window.webContents.mainFrame, + }; +} +function open() { + showAboutWindow(); + return BrowserWindow.mock.results.at(-1).value; +} + +it("opens a compact, sandboxed About with bundled credits and the version", async () => { + const window = open(); + expect(BrowserWindow.mock.calls[0][0]).toMatchObject({ + width: 700, + height: 800, + resizable: true, + webPreferences: { + nodeIntegration: false, + contextIsolation: true, + sandbox: true, + preload: "/app/src/windows/aboutPreload.cjs", + }, + }); + expect( + (await mocks.handlers.get("about:credits")(sender(window))).version, + ).toBe("1.2.3"); + expect(mocks.getAboutCredits).toHaveBeenCalledWith(); + expect(ipcMain.handle).toHaveBeenCalledTimes(2); +}); + +it("reuses its window and creates a fresh one after closing", () => { + const window = open(); + showAboutWindow(); + expect(BrowserWindow).toHaveBeenCalledOnce(); + expect(window.focus).toHaveBeenCalledOnce(); + window.emit("closed"); + showAboutWindow(); + expect(BrowserWindow).toHaveBeenCalledTimes(2); +}); + +it("rejects IPC from other windows and subframes", async () => { + const window = open(); + const callback = mocks.handlers.get("about:credits"); + await expect( + callback({ sender: {}, senderFrame: window.webContents.mainFrame }), + ).rejects.toThrow("restricted"); + await expect( + callback({ sender: window.webContents, senderFrame: {} }), + ).rejects.toThrow("restricted"); + window.webContents.mainFrame.url = "https://example.com"; + await expect(callback(sender(window))).rejects.toThrow("restricted"); +}); + +it("opens the contributor's profile, not history or an arbitrary URL", async () => { + const window = open(); + const callback = (id) => + mocks.handlers.get("about:open-profile")(sender(window), id); + await callback("1"); + expect(shell.openExternal).toHaveBeenCalledWith( + "https://github.com/teammate", + ); + await expect(callback("2")).rejects.toThrow("no GitHub profile"); + await expect(callback("https://example.com")).rejects.toThrow( + "no GitHub profile", + ); + await expect(callback({})).rejects.toThrow("Invalid contributor"); + expect(shell.openExternal).toHaveBeenCalledOnce(); +}); + +it("blocks popups and in-app navigation", () => { + const window = open(); + expect(window.webContents.setWindowOpenHandler.mock.calls[0][0]()).toEqual({ + action: "deny", + }); + const event = { preventDefault: vi.fn() }; + window.webContents.emit("will-navigate", event); + expect(event.preventDefault).toHaveBeenCalledOnce(); +}); diff --git a/electron-app/src/windows/aboutPreload.cjs b/electron-app/src/windows/aboutPreload.cjs new file mode 100644 index 000000000..bcebc32bd --- /dev/null +++ b/electron-app/src/windows/aboutPreload.cjs @@ -0,0 +1,7 @@ +const { contextBridge, ipcRenderer } = require("electron"); + +// About gets a minimal bridge, not configuration, filesystem or backend access. +contextBridge.exposeInMainWorld("aboutAPI", { + getCredits: () => ipcRenderer.invoke("about:credits"), + openProfile: (id) => ipcRenderer.invoke("about:open-profile", id), +}); diff --git a/electron-app/src/windows/aboutWindow.js b/electron-app/src/windows/aboutWindow.js index 687d43b9e..f21e5cd42 100644 --- a/electron-app/src/windows/aboutWindow.js +++ b/electron-app/src/windows/aboutWindow.js @@ -5,13 +5,52 @@ * Help > About application menu item. */ -import { BrowserWindow } from "electron"; +import { app, BrowserWindow, ipcMain, screen, shell } from "electron"; import path from "path"; +import { pathToFileURL } from "node:url"; import { getAppPath } from "../utils/paths.js"; import { logger } from "../utils/logger.js"; import { setWindowMenu } from "../menu/menu.js"; +import { getAboutCredits } from "../about/githubCredits.js"; let aboutWindow = null; +let ipcInitialized = false; + +function setupAboutIpc() { + if (ipcInitialized) return; + const assertSender = (event) => { + const url = pathToFileURL( + path.join(getAppPath(), "renderer", "about", "index.html"), + ).href; + if ( + !aboutWindow || + event.sender !== aboutWindow.webContents || + event.senderFrame !== aboutWindow.webContents.mainFrame || + event.senderFrame.url !== url + ) { + throw new Error("About IPC is restricted to the About window"); + } + }; + ipcMain.handle("about:credits", async (event) => { + assertSender(event); + return { + ...getAboutCredits(), + version: app.getVersion(), + }; + }); + ipcMain.handle("about:open-profile", async (event, id) => { + assertSender(event); + if (typeof id !== "string" || id.length > 100) + throw new Error("Invalid contributor"); + const person = getAboutCredits().contributors.find( + (person) => person.id === id, + ); + if (!person?.login || !/^[a-zA-Z0-9][a-zA-Z0-9-]{0,38}$/.test(person.login)) + throw new Error("Contributor has no GitHub profile"); + await shell.openExternal(`https://github.com/${person.login}`); + }); + ipcInitialized = true; +} /** * Shows the About window, creating it if needed. Reuses a single instance — @@ -21,30 +60,41 @@ let aboutWindow = null; */ function showAboutWindow(parentWindow) { if (aboutWindow && !aboutWindow.isDestroyed()) { + if (aboutWindow.isMinimized()) aboutWindow.restore(); aboutWindow.focus(); return; } + setupAboutIpc(); + const { width, height } = screen.getPrimaryDisplay().workAreaSize; aboutWindow = new BrowserWindow({ - width: 480, - height: 480, - resizable: false, + width: Math.min(700, width - 48), + height: Math.min(800, height - 64), + minWidth: Math.min(420, width - 48), + minHeight: Math.min(540, height - 64), + resizable: true, minimizable: false, - maximizable: false, + maximizable: true, modal: !!parentWindow, parent: parentWindow ?? undefined, title: "About Control Station", - backgroundColor: "#111111", + backgroundColor: "#161a2d", webPreferences: { - preload: path.join(getAppPath(), "preload.js"), + preload: path.join(getAppPath(), "src", "windows", "aboutPreload.cjs"), contextIsolation: true, nodeIntegration: false, + sandbox: true, }, }); setWindowMenu(aboutWindow); const aboutPath = path.join(getAppPath(), "renderer", "about", "index.html"); + // All outbound links go through the explicit IPC allowlist in the system browser. + aboutWindow.webContents.setWindowOpenHandler(() => ({ action: "deny" })); + aboutWindow.webContents.on("will-navigate", (event) => + event.preventDefault(), + ); aboutWindow.loadFile(aboutPath).catch((err) => { logger.electron.error("Failed to load About window:", err); }); From e3cc45c5ab545399d80eccf05fcdd5294389da27 Mon Sep 17 00:00:00 2001 From: Javier Ribal del Rio Date: Fri, 23 Oct 2026 09:12:52 +0200 Subject: [PATCH 5/6] feat(electron-app): add open new window menu --- e2e/tests/ui/new-testing-window.test.ts | 193 ++++++++++++++++++ electron-app/main.js | 16 ++ .../src/app/__tests__/modeSwitching.test.js | 82 +++++++- electron-app/src/menu/__tests__/menu.test.js | 99 +++++++++ electron-app/src/menu/menu.js | 45 ++++ electron-app/src/windows/README.md | 3 + .../__tests__/additionalViewWindow.test.js | 159 +++++++++++++++ .../src/windows/additionalViewWindow.js | 75 +++++++ 8 files changed, 671 insertions(+), 1 deletion(-) create mode 100644 e2e/tests/ui/new-testing-window.test.ts create mode 100644 electron-app/src/windows/__tests__/additionalViewWindow.test.js create mode 100644 electron-app/src/windows/additionalViewWindow.js diff --git a/e2e/tests/ui/new-testing-window.test.ts b/e2e/tests/ui/new-testing-window.test.ts new file mode 100644 index 000000000..8f6377442 --- /dev/null +++ b/e2e/tests/ui/new-testing-window.test.ts @@ -0,0 +1,193 @@ +import type { ElectronApplication } from "@playwright/test"; +import { expect, test } from "../../fixtures/electron"; + +async function invokeNewWindow(app: ElectronApplication, view = "testing") { + const opened = app.waitForEvent("window"); + await app.evaluate(({ BrowserWindow, Menu }, view) => { + const menu = Menu.getApplicationMenu()!; + menu.items + .find((item) => item.label === "Window")! + .submenu!.emit("menu-will-show"); + const item = menu.getMenuItemById(`new-${view}-window`)!; + if (!item.visible || !item.enabled) + throw new Error(`New ${view} window is not available`); + item.click(item, BrowserWindow.getFocusedWindow()!, {} as never); + }, view); + const window = await opened; + await expect(window).toHaveURL(new RegExp(`/${view}-view/index\\.html$`)); + if (view === "testing") + await expect(window.getByTestId("mode-badge")).toHaveAttribute( + "data-mode", + "active", + { timeout: 15_000 }, + ); + return window; +} + +for (const mode of ["testing", "competition"]) { + test(`New Testing Window opens additional instances from ${mode} without replacing the main window`, async ({ + app, + }) => { + test.setTimeout(60_000); + if (mode === "competition") { + const available = await app.evaluate(({ Menu }) => + Menu.getApplicationMenu()!.items[0].submenu!.items.some( + (item) => item.label === "Competition View", + ), + ); + test.skip(!available, "Competition View is not packaged"); + await app.evaluate(({ BrowserWindow, Menu }) => { + const item = Menu.getApplicationMenu()!.items[0].submenu!.items.find( + (item) => item.label === "Competition View", + )!; + item.click(item, BrowserWindow.getAllWindows()[0], {} as never); + }); + await expect + .poll( + () => + app + .windows() + .some((window) => + window.url().includes("/competition-view/index.html"), + ), + { timeout: 15_000 }, + ) + .toBe(true); + } + const main = app + .windows() + .find((window) => window.url().includes(`/${mode}-view/index.html`))!; + const mainHandle = await app.browserWindow(main); + const mainId = await mainHandle.evaluate((window) => window.id); + const logs = app + .windows() + .find((window) => window.url().includes("/log-viewer/index.html"))!; + await main.evaluate(() => { + document.body.dataset.windowInstance = "original"; + }); + + const first = await invokeNewWindow(app); + const second = await invokeNewWindow(app); + const competition = + mode === "competition" ? await invokeNewWindow(app, "competition") : null; + expect(first).not.toBe(second); + expect(main.isClosed()).toBe(false); + expect(logs.isClosed()).toBe(false); + if (competition) { + expect(competition.url()).toContain("/competition-view/index.html"); + expect( + await competition.evaluate(() => document.body.dataset.windowInstance), + ).toBeUndefined(); + await competition.close(); + expect(main.isClosed()).toBe(false); + expect(second.isClosed()).toBe(false); + expect(logs.isClosed()).toBe(false); + } + expect(main.url()).toContain(`/${mode}-view/index.html`); + expect( + await first.evaluate(() => document.body.dataset.windowInstance), + ).toBeUndefined(); + expect( + await main.evaluate(() => document.body.dataset.windowInstance), + ).toBe("original"); + + // The global main-window pointer still targets the original window. + const focusTarget = await app.evaluate( + ({ BrowserWindow, Menu }, mainId) => { + const main = BrowserWindow.fromId(mainId)!; + const originalFocus = main.focus; + let focusTarget: number | undefined; + main.focus = () => { + focusTarget = main.id; + originalFocus.call(main); + }; + const item = Menu.getApplicationMenu()! + .items.find((item) => item.label === "Window")! + .submenu!.items.find((item) => item.label === "Show Main Window")!; + try { + item.click(item, undefined, {} as never); + } finally { + main.focus = originalFocus; + } + return focusTarget; + }, + mainId, + ); + // Check the target, not OS focus-stealing prevention on the test desktop. + expect(focusTarget).toBe(mainId); + await first.close(); + expect(main.isClosed()).toBe(false); + expect(second.isClosed()).toBe(false); + expect(logs.isClosed()).toBe(false); + + // The existing transition keeps old windows hidden until a new mode is chosen. + await app.evaluate(({ app }) => { + app.emit("return-to-selector"); + }); + await expect + .poll( + () => + main.isClosed() || + app + .windows() + .some((window) => + window.url().includes("/mode-selector/index.html"), + ), + { timeout: 15_000 }, + ) + .toBe(true); + if (!main.isClosed()) { + const selector = app + .windows() + .find((window) => window.url().includes("/mode-selector/index.html"))!; + await selector + .getByRole("button", { name: "Testing View", exact: true }) + .click(); + } + await expect + .poll(() => main.isClosed() && second.isClosed(), { timeout: 15_000 }) + .toBe(true); + }); +} + +test("New Testing Window is hidden in Logging View", async ({ app }) => { + const available = await app.evaluate(({ Menu }) => + Menu.getApplicationMenu()!.items[0].submenu!.items.some( + (item) => item.label === "Logging View", + ), + ); + test.skip(!available, "Logging View is not packaged"); + await app.evaluate(({ BrowserWindow, Menu }) => { + const item = Menu.getApplicationMenu()!.items[0].submenu!.items.find( + (item) => item.label === "Logging View", + )!; + item.click(item, BrowserWindow.getAllWindows()[0], {} as never); + }); + await expect + .poll( + () => + app + .windows() + .some((window) => window.url().includes("/logging-view/index.html")), + { timeout: 15_000 }, + ) + .toBe(true); + const state = await app.evaluate(({ Menu }) => { + const menu = Menu.getApplicationMenu()!; + menu.items + .find((item) => item.label === "Window")! + .submenu!.emit("menu-will-show"); + const item = menu.getMenuItemById("new-testing-window")!; + const competition = menu.getMenuItemById("new-competition-window")!; + return { + visible: item.visible, + enabled: item.enabled, + competitionVisible: competition.visible, + }; + }); + expect(state).toEqual({ + visible: false, + enabled: false, + competitionVisible: false, + }); +}); diff --git a/electron-app/main.js b/electron-app/main.js index d57accbfb..a68367934 100644 --- a/electron-app/main.js +++ b/electron-app/main.js @@ -23,6 +23,10 @@ import { stopBackend } from "./src/processes/backend.js"; import { stopBlcuProgramming } from "./src/processes/blcuProgramming.js"; import { logger } from "./src/utils/logger.js"; import { getCurrentView, getMainWindow } from "./src/windows/mainWindow.js"; +import { + createCompetitionWindow, + createTestingWindow, +} from "./src/windows/additionalViewWindow.js"; /** * Initializes the application when Electron is ready. @@ -97,6 +101,18 @@ app.whenReady().then(async () => { } app.on("return-to-selector", () => changeMode()); app.on("switch-mode", (mode) => changeMode(mode)); + app.on("new-testing-window", () => { + if ( + isChangingMode || + !["testing-view", "competition-view"].includes(getCurrentView()) + ) + return; + void createTestingWindow(getMainWindow()); + }); + app.on("new-competition-window", () => { + if (isChangingMode || getCurrentView() !== "competition-view") return; + void createCompetitionWindow(getMainWindow()); + }); // Register shutdown cleanup before the selector can start any services. setupLifecycleHandlers(); diff --git a/electron-app/src/app/__tests__/modeSwitching.test.js b/electron-app/src/app/__tests__/modeSwitching.test.js index b67015618..c51d51e7a 100644 --- a/electron-app/src/app/__tests__/modeSwitching.test.js +++ b/electron-app/src/app/__tests__/modeSwitching.test.js @@ -32,9 +32,13 @@ vi.mock("../../utils/logger.js", () => ({ logger: { electron: { info: vi.fn(), error: vi.fn() } }, })); vi.mock("../../windows/mainWindow.js", () => ({ - getCurrentView: () => "testing-view", + getCurrentView: vi.fn(() => "testing-view"), getMainWindow: vi.fn(), })); +vi.mock("../../windows/additionalViewWindow.js", () => ({ + createTestingWindow: vi.fn(), + createCompetitionWindow: vi.fn(), +})); const flush = async () => { for (let i = 0; i < 20; i++) await Promise.resolve(); @@ -46,6 +50,8 @@ beforeEach(async () => { vi.useFakeTimers(); const { app } = await import("electron"); app.removeAllListeners(); + const { getCurrentView } = await import("../../windows/mainWindow.js"); + getCurrentView.mockReturnValue("testing-view"); }); afterEach(() => { vi.useRealTimers(); @@ -123,4 +129,78 @@ describe("menu mode transitions", () => { expect(h.stopBackend).not.toHaveBeenCalled(); expect(h.openMode).not.toHaveBeenCalled(); }); + + it.each(["testing-view", "competition-view"])( + "opens secondary Testing windows in %s without restarting services", + async (view) => { + const h = await start(); + const { getCurrentView, getMainWindow } = + await import("../../windows/mainWindow.js"); + const { createTestingWindow } = + await import("../../windows/additionalViewWindow.js"); + const mainWindow = {}; + getCurrentView.mockReturnValue(view); + getMainWindow.mockReturnValue(mainWindow); + h.app.emit("new-testing-window"); + h.app.emit("new-testing-window"); + expect(createTestingWindow).toHaveBeenCalledTimes(2); + expect(createTestingWindow).toHaveBeenLastCalledWith(mainWindow); + expect(h.stopBackend).not.toHaveBeenCalled(); + expect(h.openMode).not.toHaveBeenCalled(); + }, + ); + + it("does not create extra windows during mode transitions or in Logging mode", async () => { + const h = await start(); + const { getCurrentView } = await import("../../windows/mainWindow.js"); + const { createTestingWindow } = + await import("../../windows/additionalViewWindow.js"); + getCurrentView.mockReturnValue("logging-view"); + h.app.emit("new-testing-window"); + expect(createTestingWindow).not.toHaveBeenCalled(); + getCurrentView.mockReturnValue("testing-view"); + h.app.emit("return-to-selector"); + h.app.emit("new-testing-window"); + expect(createTestingWindow).not.toHaveBeenCalled(); + }); + + it("does not create extra windows before startup finishes", async () => { + const modes = await import("../index.js"); + modes.showModeSelector.mockImplementationOnce(() => new Promise(() => {})); + const h = await start(); + const { createTestingWindow } = + await import("../../windows/additionalViewWindow.js"); + h.app.emit("new-testing-window"); + expect(createTestingWindow).not.toHaveBeenCalled(); + }); + + it("opens additional Competition windows while preserving the Testing option", async () => { + const h = await start(); + const { getCurrentView, getMainWindow } = + await import("../../windows/mainWindow.js"); + const { createCompetitionWindow, createTestingWindow } = + await import("../../windows/additionalViewWindow.js"); + getCurrentView.mockReturnValue("competition-view"); + const mainWindow = {}; + getMainWindow.mockReturnValue(mainWindow); + h.app.emit("new-competition-window"); + h.app.emit("new-testing-window"); + expect(createCompetitionWindow).toHaveBeenCalledWith(mainWindow); + expect(createTestingWindow).toHaveBeenCalledWith(mainWindow); + expect(h.stopBackend).not.toHaveBeenCalled(); + expect(h.openMode).not.toHaveBeenCalled(); + }); + + it("rejects new Competition windows outside Competition and during a transition", async () => { + const h = await start(); + const { getCurrentView } = await import("../../windows/mainWindow.js"); + const { createCompetitionWindow } = + await import("../../windows/additionalViewWindow.js"); + h.app.emit("new-competition-window"); + expect(createCompetitionWindow).not.toHaveBeenCalled(); + getCurrentView.mockReturnValue("competition-view"); + h.app.emit("return-to-selector"); + h.app.emit("new-competition-window"); + expect(createCompetitionWindow).not.toHaveBeenCalled(); + }); }); diff --git a/electron-app/src/menu/__tests__/menu.test.js b/electron-app/src/menu/__tests__/menu.test.js index e5e30fd58..996113691 100644 --- a/electron-app/src/menu/__tests__/menu.test.js +++ b/electron-app/src/menu/__tests__/menu.test.js @@ -133,6 +133,105 @@ describe("Control Station menu", () => { const submenu = menu.items.find((item) => item.label === "Window").submenu; submenu.on.mock.calls[0][1](); expect(menu.getMenuItemById("show-backend-logs").enabled).toBe(true); + expect(menu.getMenuItemById("new-testing-window").visible).toBe(true); + }); + + it.each(["testing-view", "competition-view"])( + "offers an additional Testing window in %s", + (view) => { + getCurrentView.mockReturnValue(view); + const item = createMenu().getMenuItemById("new-testing-window"); + expect(item.label).toBe("New Testing Window"); + expect(item.visible).toBe(true); + expect(item.enabled).toBe(true); + item.click(); + expect(app.emit).toHaveBeenCalledWith("new-testing-window"); + }, + ); + + it.each(["logging-view", "adj-view", "flashing-view"])( + "hides additional Testing windows in %s", + (view) => { + getCurrentView.mockReturnValue(view); + const item = createMenu().getMenuItemById("new-testing-window"); + expect(item.visible).toBe(false); + expect(item.enabled).toBe(false); + item.click(); + expect(app.emit).not.toHaveBeenCalled(); + }, + ); + + it("does not offer Testing windows when that view is not packaged", () => { + getCurrentView.mockReturnValue("competition-view"); + fs.existsSync.mockReturnValue(false); + const item = createMenu().getMenuItemById("new-testing-window"); + expect(item.visible).toBe(false); + item.click(); + expect(app.emit).not.toHaveBeenCalled(); + }); + + it("offers both Competition and Testing windows in Competition mode", () => { + getCurrentView.mockReturnValue("competition-view"); + const menu = createMenu(); + const competition = menu.getMenuItemById("new-competition-window"); + expect(competition.visible).toBe(true); + expect(competition.enabled).toBe(true); + expect(competition.label).toBe("New Competition Window"); + expect(menu.getMenuItemById("new-testing-window").visible).toBe(true); + competition.click(); + expect(app.emit).toHaveBeenCalledWith("new-competition-window"); + }); + + it.each(["testing-view", "logging-view", "adj-view", "flashing-view"])( + "hides new Competition windows in %s", + (view) => { + getCurrentView.mockReturnValue(view); + const item = createMenu().getMenuItemById("new-competition-window"); + expect(item.visible).toBe(false); + expect(item.enabled).toBe(false); + item.click(); + expect(app.emit).not.toHaveBeenCalled(); + }, + ); + + it("keeps Testing available if Competition is not packaged", () => { + getCurrentView.mockReturnValue("competition-view"); + fs.existsSync.mockImplementation( + (file) => file !== "/app/renderer/competition-view/index.html", + ); + const menu = createMenu(); + const item = menu.getMenuItemById("new-competition-window"); + expect(item.visible).toBe(false); + item.click(); + expect(app.emit).not.toHaveBeenCalled(); + expect(menu.getMenuItemById("new-testing-window").visible).toBe(true); + }); + + it("refreshes the new Competition option after changing mode", () => { + getCurrentView.mockReturnValue("testing-view"); + const menu = createMenu(); + const refresh = menu.items.find((item) => item.label === "Window").submenu + .on.mock.calls[0][1]; + getCurrentView.mockReturnValue("competition-view"); + refresh(); + expect(menu.getMenuItemById("new-competition-window").visible).toBe(true); + getCurrentView.mockReturnValue("logging-view"); + refresh(); + expect(menu.getMenuItemById("new-competition-window").visible).toBe(false); + }); + + it("hides a stale new-window item after changing to a non-backend mode", () => { + getCurrentView.mockReturnValue("testing-view"); + const menu = createMenu(); + getCurrentView.mockReturnValue("logging-view"); + menu.items + .find((item) => item.label === "Window") + .submenu.on.mock.calls[0][1](); + const item = menu.getMenuItemById("new-testing-window"); + expect(item.visible).toBe(false); + expect(item.enabled).toBe(false); + item.click(); + expect(app.emit).not.toHaveBeenCalled(); }); it("restores and focuses the main window", () => { diff --git a/electron-app/src/menu/menu.js b/electron-app/src/menu/menu.js index 4fa04ac19..e55133bd5 100644 --- a/electron-app/src/menu/menu.js +++ b/electron-app/src/menu/menu.js @@ -175,6 +175,25 @@ function createMenu({ isSelector = false } = {}) { if (hasBackendView()) showLogWindow(); }, }, + { + id: "new-competition-window", + label: "New Competition Window", + visible: canOpenCompetitionWindow(), + enabled: canOpenCompetitionWindow(), + click: () => { + if (canOpenCompetitionWindow()) + app.emit("new-competition-window"); + }, + }, + { + id: "new-testing-window", + label: "New Testing Window", + visible: canOpenTestingWindow(), + enabled: canOpenTestingWindow(), + click: () => { + if (canOpenTestingWindow()) app.emit("new-testing-window"); + }, + }, { type: "separator" }, { role: "minimize", label: "Minimize" }, ], @@ -215,6 +234,14 @@ function createMenu({ isSelector = false } = {}) { ).submenu; windowMenu.on("menu-will-show", () => { menu.getMenuItemById("show-backend-logs").enabled = hasBackendView(); + const newWindow = menu.getMenuItemById("new-testing-window"); + newWindow.visible = canOpenTestingWindow(); + newWindow.enabled = newWindow.visible; + const newCompetitionWindow = menu.getMenuItemById( + "new-competition-window", + ); + newCompetitionWindow.visible = canOpenCompetitionWindow(); + newCompetitionWindow.enabled = newCompetitionWindow.visible; }); } return menu; @@ -224,6 +251,24 @@ function hasBackendView() { return ["testing-view", "competition-view"].includes(getCurrentView()); } +function canOpenTestingWindow() { + return ( + hasBackendView() && + fs.existsSync( + path.join(getAppPath(), "renderer", "testing-view", "index.html"), + ) + ); +} + +function canOpenCompetitionWindow() { + return ( + getCurrentView() === "competition-view" && + fs.existsSync( + path.join(getAppPath(), "renderer", "competition-view", "index.html"), + ) + ); +} + /** Assign a menu to a window; macOS uses the focused window's application menu. */ function setWindowMenu(window, options) { const menu = createMenu(options); diff --git a/electron-app/src/windows/README.md b/electron-app/src/windows/README.md index 7698de6aa..8991eba9d 100644 --- a/electron-app/src/windows/README.md +++ b/electron-app/src/windows/README.md @@ -10,6 +10,9 @@ Manages the primary and logs Electron `BrowserWindow` instances and provides fun - `logWindow.js` - Backend logs and messages - `mainWindow.js` - Main window creation and management +- `additionalViewWindow.js` - Additional Testing and Competition windows sharing the existing backend + +In Testing and Competition modes, **Window > New Testing Window** opens an additional Testing renderer. Competition mode also offers **Window > New Competition Window** to open another Competition renderer. Both use the existing backend without replacing the main window or restarting services. Each entry is hidden if its renderer is not packaged. Secondary windows close independently; mode changes close them along with the other previous windows. ## Window Configuration diff --git a/electron-app/src/windows/__tests__/additionalViewWindow.test.js b/electron-app/src/windows/__tests__/additionalViewWindow.test.js new file mode 100644 index 000000000..67fb51889 --- /dev/null +++ b/electron-app/src/windows/__tests__/additionalViewWindow.test.js @@ -0,0 +1,159 @@ +import { EventEmitter } from "node:events"; +import { app, BrowserWindow, dialog, screen } from "electron"; +import fs from "node:fs"; +import { beforeEach, expect, it, vi } from "vitest"; +import { + createCompetitionWindow, + createTestingWindow, +} from "../additionalViewWindow.js"; +import { createWindow, getCurrentView, getMainWindow } from "../mainWindow.js"; +import { setWindowMenu } from "../../menu/menu.js"; + +vi.mock("electron", () => ({ + app: { isPackaged: true, quit: vi.fn() }, + Menu: { buildFromTemplate: vi.fn() }, + dialog: { showErrorBox: vi.fn() }, + screen: { + getPrimaryDisplay: () => ({ + workAreaSize: { width: 1600, height: 1000 }, + workArea: { x: 0, y: 0 }, + }), + getDisplayMatching: vi.fn(() => ({ + workAreaSize: { width: 1600, height: 1000 }, + workArea: { x: 1600, y: 0 }, + })), + }, + BrowserWindow: vi.fn(function () { + const window = new EventEmitter(); + window.webContents = new EventEmitter(); + window.loadFile = vi.fn().mockResolvedValue(undefined); + window.setTitle = vi.fn(); + window.show = vi.fn(); + window.focus = vi.fn(); + window.isDestroyed = () => false; + window.getBounds = () => ({ x: 1700, y: 0, width: 1280, height: 800 }); + window.close = vi.fn(() => { + window.emit("close"); + window.emit("closed"); + }); + return window; + }), +})); +vi.mock("node:fs", () => ({ default: { existsSync: vi.fn() } })); +vi.mock("fs", () => ({ default: { existsSync: vi.fn(() => true) } })); +vi.mock("../../utils/paths.js", () => ({ getAppPath: () => "/app" })); +vi.mock("../../menu/menu.js", () => ({ setWindowMenu: vi.fn() })); +vi.mock("../aboutWindow.js", () => ({ showAboutWindow: vi.fn() })); +vi.mock("../../utils/logger.js", () => ({ + logger: { electron: { error: vi.fn() } }, +})); + +beforeEach(() => { + getMainWindow()?.emit("closed"); + vi.clearAllMocks(); + fs.existsSync.mockReturnValue(true); +}); + +it("opens the packaged Testing renderer without replacing the main view", async () => { + const main = createWindow(1600, 1000, "competition-view"); + const extra = await createTestingWindow(main); + expect(extra).not.toBe(main); + expect(getMainWindow()).toBe(main); + expect(getCurrentView()).toBe("competition-view"); + expect(extra.loadFile).toHaveBeenCalledWith( + "/app/renderer/testing-view/index.html", + ); + expect(setWindowMenu).toHaveBeenCalledWith(extra); + expect(extra.show).toHaveBeenCalledOnce(); + expect(extra.focus).toHaveBeenCalledOnce(); + expect(screen.getDisplayMatching).toHaveBeenCalledWith(main.getBounds()); + expect(BrowserWindow.mock.calls[1][0]).toMatchObject({ + x: 1760, + y: 100, + width: 1280, + height: 800, + webPreferences: { + preload: "/app/preload.js", + contextIsolation: true, + nodeIntegration: false, + backgroundThrottling: false, + }, + }); +}); + +it("allows several extra windows and closing one does not quit the app", async () => { + const main = createWindow(1600, 1000, "testing-view"); + const first = await createTestingWindow(); + const second = await createTestingWindow(); + expect(first).not.toBe(second); + first.close(); + expect(app.quit).not.toHaveBeenCalled(); + expect(getMainWindow()).toBe(main); + expect(second.close).not.toHaveBeenCalled(); + second.close(); +}); + +it("opens another Competition renderer without replacing the main window or Testing windows", async () => { + const main = createWindow(1600, 1000, "competition-view"); + const testing = await createTestingWindow(main); + const competition = await createCompetitionWindow(main); + expect(competition).not.toBe(testing); + expect(competition.loadFile).toHaveBeenCalledWith( + "/app/renderer/competition-view/index.html", + ); + expect(BrowserWindow.mock.calls[2][0].title).toBe( + "Hyperloop Control Station - Competition View", + ); + expect(getMainWindow()).toBe(main); + expect(getCurrentView()).toBe("competition-view"); + competition.close(); + expect(testing.close).not.toHaveBeenCalled(); + expect(app.quit).not.toHaveBeenCalled(); +}); + +it("does not open Competition when its renderer is not packaged", async () => { + fs.existsSync.mockReturnValue(false); + expect(await createCompetitionWindow()).toBeNull(); + expect(BrowserWindow).not.toHaveBeenCalled(); +}); + +it("does not create an empty window if Testing View is not packaged", async () => { + fs.existsSync.mockReturnValue(false); + expect(await createTestingWindow()).toBeNull(); + expect(BrowserWindow).not.toHaveBeenCalled(); +}); + +it("closes only the failed extra window if its page cannot load", async () => { + const main = createWindow(1600, 1000, null); + BrowserWindow.mockImplementationOnce(function () { + const window = new EventEmitter(); + window.loadFile = vi + .fn() + .mockRejectedValue(new Error("Unable to load view")); + window.isDestroyed = () => false; + window.close = vi.fn(() => window.emit("closed")); + return window; + }); + expect(await createTestingWindow()).toBeNull(); + expect(app.quit).not.toHaveBeenCalled(); + expect(getMainWindow()).toBe(main); + expect(dialog.showErrorBox).toHaveBeenCalledWith( + "Could Not Open Testing View", + "Unable to load view", + ); +}); + +it("does not report an error when the extra window closes while loading", async () => { + BrowserWindow.mockImplementationOnce(function () { + const window = new EventEmitter(); + window.isDestroyed = () => true; + window.loadFile = vi.fn(async () => { + window.emit("closed"); + throw new Error("Navigation aborted because the window closed"); + }); + return window; + }); + expect(await createTestingWindow()).toBeNull(); + expect(dialog.showErrorBox).not.toHaveBeenCalled(); + expect(app.quit).not.toHaveBeenCalled(); +}); diff --git a/electron-app/src/windows/additionalViewWindow.js b/electron-app/src/windows/additionalViewWindow.js new file mode 100644 index 000000000..d6bc323ec --- /dev/null +++ b/electron-app/src/windows/additionalViewWindow.js @@ -0,0 +1,75 @@ +import { BrowserWindow, dialog, screen } from "electron"; +import fs from "node:fs"; +import path from "node:path"; +import { setWindowMenu } from "../menu/menu.js"; +import { getAppPath } from "../utils/paths.js"; +import { logger } from "../utils/logger.js"; + +// Keep secondary windows alive without replacing the application's main window. +const additionalWindows = new Set(); + +/** Open another Testing renderer connected to the existing backend. */ +export function createTestingWindow(sourceWindow) { + return createAdditionalWindow("testing-view", "Testing View", sourceWindow); +} + +/** Open another Competition renderer connected to the existing backend. */ +export function createCompetitionWindow(sourceWindow) { + return createAdditionalWindow( + "competition-view", + "Competition View", + sourceWindow, + ); +} + +async function createAdditionalWindow(view, title, sourceWindow) { + const appPath = getAppPath(); + const entry = path.join(appPath, "renderer", view, "index.html"); + if (!fs.existsSync(entry)) return null; + + const display = + sourceWindow && !sourceWindow.isDestroyed() + ? screen.getDisplayMatching(sourceWindow.getBounds()) + : screen.getPrimaryDisplay(); + const width = Math.min(1280, display.workAreaSize.width); + const height = Math.min(800, display.workAreaSize.height); + const window = new BrowserWindow({ + width, + height, + x: + display.workArea.x + Math.floor((display.workAreaSize.width - width) / 2), + y: + display.workArea.y + + Math.floor((display.workAreaSize.height - height) / 2), + minWidth: Math.min(800, display.workAreaSize.width), + minHeight: Math.min(600, display.workAreaSize.height), + title: `Hyperloop Control Station - ${title}`, + backgroundColor: "#1a1a1a", + show: false, + webPreferences: { + preload: path.join(appPath, "preload.js"), + contextIsolation: true, + nodeIntegration: false, + backgroundThrottling: false, + }, + }); + additionalWindows.add(window); + setWindowMenu(window); + // Closing a secondary window must not quit Control Station or stop its services. + window.on("closed", () => additionalWindows.delete(window)); + + try { + await window.loadFile(entry); + if (window.isDestroyed()) return null; + window.show(); + window.focus(); + return window; + } catch (error) { + // Closing the window or switching mode while it loads is not a load failure. + if (window.isDestroyed()) return null; + logger.electron.error(`Failed to load additional ${title} window:`, error); + window.close(); + dialog.showErrorBox(`Could Not Open ${title}`, error.message); + return null; + } +} From 8592dbf0c3fddd89acec9fdcf642df4fd8ccb33a Mon Sep 17 00:00:00 2001 From: Javier Ribal del Rio Date: Fri, 23 Oct 2026 09:40:02 +0200 Subject: [PATCH 6/6] fix(electron-app): e2e tests --- .github/workflows/e2e-tests.yaml | 28 ++--- e2e/README.md | 60 ++++++---- e2e/fixtures/electron-main.cjs | 14 +++ e2e/fixtures/electron.ts | 54 ++++++--- e2e/package.json | 5 +- e2e/scripts/build-renderers.mjs | 34 ++++++ e2e/tests/integration/backend.test.ts | 146 +++++++++++++++++++++++ e2e/tests/integration/profile.test.ts | 25 ++++ e2e/tests/ui/backend-menu.test.ts | 159 -------------------------- e2e/tests/ui/filter-dialog.test.ts | 55 ++++++--- 10 files changed, 354 insertions(+), 226 deletions(-) create mode 100644 e2e/fixtures/electron-main.cjs create mode 100644 e2e/scripts/build-renderers.mjs create mode 100644 e2e/tests/integration/backend.test.ts create mode 100644 e2e/tests/integration/profile.test.ts delete mode 100644 e2e/tests/ui/backend-menu.test.ts diff --git a/.github/workflows/e2e-tests.yaml b/.github/workflows/e2e-tests.yaml index e42a4a7f3..426052f50 100644 --- a/.github/workflows/e2e-tests.yaml +++ b/.github/workflows/e2e-tests.yaml @@ -12,6 +12,11 @@ on: - "e2e/**" paths: - "frontend/testing-view/**" + - "frontend/competition-view/**" + - "frontend/logging-view/**" + - "frontend/adj-view/**" + - "frontend/frontend-kit/**" + - "backend/**" - "electron-app/**" - "e2e/**" - "pnpm-lock.yaml" @@ -38,7 +43,7 @@ jobs: node-version: "20" - name: Install dependencies - run: pnpm install --frozen-lockfile --filter "testing-view..." --filter "e2e..." --filter "hyperloop-control-station..." + run: pnpm install --frozen-lockfile --filter "testing-view..." --filter "competition-view..." --filter "logging-view..." --filter "adj-view..." --filter "e2e..." --filter "hyperloop-control-station..." - name: Build backend binary run: | @@ -48,14 +53,6 @@ jobs: - name: Patch backend config run: sed -i 's/validate = true/validate = false/' backend/cmd/dev-config.toml - - name: Debug - test backend binary - run: | - ./electron-app/binaries/backend-windows-amd64.exe --config backend/cmd/dev-config.toml 2>&1 & - PID=$! - sleep 5 - kill $PID 2>/dev/null || true - continue-on-error: true - - name: Install Electron binary run: node node_modules/electron/install.js working-directory: e2e @@ -66,11 +63,10 @@ jobs: ls -la "$(node -e "process.stdout.write(require('electron'))")" working-directory: e2e - - name: Build testing-view (e2e mode) - run: | - pnpm --filter testing-view build:e2e - mkdir -p electron-app/renderer/testing-view - cp -r frontend/testing-view/dist/. electron-app/renderer/testing-view/ + - name: Build E2E renderers + run: pnpm --dir e2e build - - name: Run UI tests - run: pnpm --filter e2e exec playwright test tests/ui/ + - name: Run UI and integration tests + run: pnpm --dir e2e test:fast + env: + E2E_REQUIRED_VIEWS: testing,competition,logging,adj diff --git a/e2e/README.md b/e2e/README.md index 32a913afc..b5decd204 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -8,16 +8,16 @@ End-to-end tests for the whole app. Tests run against the real Electron applicat Tests are split into two Playwright projects: -| Project | Directory | Description | -| :------------ | :--------------- | :------------------------------------------------------------ | -| `ui` | `tests/ui/` | UI tests — launch the Electron app and interact with the UI | -| `integration` | `tests/integration/` | Integration tests (reserved for future use) | +| Project | Directory | Description | +| :------------ | :------------------- | :-------------------------------------------------------------------------------------- | +| `ui` | `tests/ui/` | UI tests — launch the Electron app and interact with the UI | +| `integration` | `tests/integration/` | Real renderer/main IPC, Go HTTP catalogs, WebSocket subscriptions and profile isolation | --- ## UI Tests -These tests launch the full Electron app and drive it through Playwright. They cover app startup, window titles, mode badge state, chart interactions, and filter dialog behaviour. +These tests launch the full Electron app and cover startup, menus, additional Testing/Competition windows, About, charts and filters. Filter tests assert checkbox state after selecting/clearing, and exercise Escape and overlay clicks separately. Tests for the removed Backend menu controls have been retired; those controls are not restored by this suite. --- @@ -25,38 +25,54 @@ These tests launch the full Electron app and drive it through Playwright. They c `fixtures/electron.ts` provides three custom Playwright fixtures: -| Fixture | Description | -| :--------- | :------------------------------------------------------------------ | -| `app` | Launches Electron, selects Testing View, and waits for the Testing View and Backend Logs windows | -| `page` | The main Control Station window — waits for the app to leave loading state | -| `logPage` | The Backend Logs window | +| Fixture | Description | +| :-------- | :----------------------------------------------------------------------------------------------- | +| `app` | Launches Electron, selects Testing View, and waits for the Testing View and Backend Logs windows | +| `page` | The main Testing window — waits for the real backend's `active` mode | +| `logPage` | The Backend Logs window | The fixture clicks **Testing View** on the startup mode selector. Builds containing only Testing View skip the selector automatically, which the fixture also supports. Windows are located by their loaded URL, not their creation order. Electron is closed even if startup or a test fails. +Each test gets a fresh temporary `userData` directory through the test-only +`fixtures/electron-main.cjs` bootstrap. It is removed after Electron closes, +including failures. Preferences and `localStorage` do not leak between cases or +into a developer's profile; persistence within one test remains testable. +The normal Electron entry point and Go sources are unchanged. Tests still use the +development configuration and existing backend/ADJ installation; they never send +vehicle commands. The WebSocket integration test only subscribes to connection state. + --- ## Scripts -| Script | Description | -| :--------------------- | :---------------------------------------------------------------- | -| `pnpm test` | Build all dependencies then run all tests | -| `pnpm test:ui` | Build all dependencies then run only `tests/ui` | -| `pnpm test:integration`| Build the electron app then run only `tests/integration` | -| `pnpm test:fast` | Run all tests without rebuilding (assumes already built) | -| `pnpm test:fast:ui` | Run `tests/ui` without rebuilding | -| `pnpm report` | Open the last Playwright HTML report | - -> **Note:** `pnpm test` and `pnpm test:ui` always build the `testing-view` (e2e mode) and the electron app before running. Use the `:fast` variants when iterating to skip the build step. +| Script | Description | +| :--------------------------- | :-------------------------------------------------------------------- | +| `pnpm build` | Build/copy Testing (e2e mode), Competition, Logging and ADJ renderers | +| `pnpm test` | Build those renderers then run UI and integration tests | +| `pnpm test:ui` | Build those renderers then run only `tests/ui` | +| `pnpm test:integration` | Build those renderers then run the integration project | +| `pnpm test:fast` | Run all tests without rebuilding (assumes already built) | +| `pnpm test:fast:ui` | Run `tests/ui` without rebuilding | +| `pnpm test:fast:integration` | Run integration tests without rebuilding | +| `pnpm report` | Open the last Playwright HTML report | + +The renderer build does not compile Go or package Electron. It copies each +frontend once, preserving Testing's E2E build rather than rebuilding it in normal mode. +Use `:fast` variants when iterating against already-built renderers. --- ## Requirements -- The `hyperloop-control-station` electron app must be built (handled automatically by `pnpm test`). +- A platform-appropriate, prebuilt backend binary must exist in `electron-app/binaries/`. CI builds it before running the suite. - `testing-view` must be built in e2e mode (`build:e2e`), also handled automatically. - Workers are set to `1` — Electron tests must run serially since only one app instance can run at a time. -> **Note:** The app runs in its normal production mode during tests — there is no special test environment or mock mode. +CI builds all four tested views and runs both projects. `E2E_REQUIRED_VIEWS=testing,competition,logging,adj` makes missing CI build artifacts a failure instead of silently skipping cross-view tests. Fast local runs may still skip optional-view cases when testing a reduced build. + +Renderers use the real backend, not mock mode. `NODE_ENV=test` only suppresses +automatic DevTools in Electron. Run the suite without another Control Station +backend using the same fixed ports. diff --git a/e2e/fixtures/electron-main.cjs b/e2e/fixtures/electron-main.cjs new file mode 100644 index 000000000..81d111f2b --- /dev/null +++ b/e2e/fixtures/electron-main.cjs @@ -0,0 +1,14 @@ +// Test-only bootstrap: production startup and Go remain unchanged. +const { app } = require("electron"); +const path = require("node:path"); + +const profile = process.env.CONTROL_STATION_E2E_PROFILE; +if (!profile || !path.isAbsolute(profile)) { + throw new Error("E2E requires an absolute temporary profile path"); +} +app.setPath("userData", profile); + +import("../../electron-app/main.js").catch((error) => { + console.error("E2E application startup failed:", error); + app.exit(1); +}); diff --git a/e2e/fixtures/electron.ts b/e2e/fixtures/electron.ts index 3c8a6a08a..81b1990da 100644 --- a/e2e/fixtures/electron.ts +++ b/e2e/fixtures/electron.ts @@ -6,6 +6,8 @@ import { type Page, } from "@playwright/test"; import path from "path"; +import os from "node:os"; +import { access, mkdtemp, rm } from "node:fs/promises"; import { fileURLToPath } from "url"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -33,14 +35,35 @@ type ElectronFixtures = { export const test = base.extend({ app: async ({}, use) => { - const app = await electron.launch({ - args: ["--no-sandbox", path.join(ELECTRON_APP_PATH, "main.js")], - cwd: ELECTRON_APP_PATH, - env: { - ...process.env, - NODE_ENV: "test", - }, - }); + for (const mode of (process.env.E2E_REQUIRED_VIEWS ?? "testing").split( + ",", + )) { + await access( + path.join( + ELECTRON_APP_PATH, + "renderer", + `${mode.trim()}-view`, + "index.html", + ), + ); + } + const profile = await mkdtemp( + path.join(os.tmpdir(), "control-station-e2e-"), + ); + const app = await electron + .launch({ + args: ["--no-sandbox", path.join(__dirname, "electron-main.cjs")], + cwd: ELECTRON_APP_PATH, + env: { + ...process.env, + NODE_ENV: "test", + CONTROL_STATION_E2E_PROFILE: profile, + }, + }) + .catch(async (error) => { + await rm(profile, { recursive: true, force: true, maxRetries: 5 }); + throw error; + }); try { // A build containing only Testing View selects it automatically. Otherwise @@ -75,7 +98,11 @@ export const test = base.extend({ await waitForWindow(app, "/log-viewer/index.html"); await use(app); } finally { - await app.close(); + try { + await app.close(); + } finally { + await rm(profile, { recursive: true, force: true, maxRetries: 5 }); + } } }, @@ -86,13 +113,14 @@ export const test = base.extend({ await use(page); }, - // Wait for Testing View to leave its loading state before yielding. + // Interactive tests require the real backend, not a terminal error/mock state. page: async ({ app }, use) => { const page = await waitForWindow(app, "/testing-view/index.html"); await page.waitForLoadState("domcontentloaded"); - await page.waitForSelector( - '[data-testid="mode-badge"]:not([data-mode="loading"])', - { timeout: 15000 }, + await expect(page.getByTestId("mode-badge")).toHaveAttribute( + "data-mode", + "active", + { timeout: 15_000 }, ); await use(page); }, diff --git a/e2e/package.json b/e2e/package.json index 0d7986fa6..52f849dda 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -4,12 +4,13 @@ "private": true, "type": "module", "scripts": { - "build": "pnpm --filter testing-view build:e2e && pnpm --filter hyperloop-control-station build:testing", + "build": "node scripts/build-renderers.mjs", "test": "pnpm run build && playwright test", "test:ui": "pnpm run build && playwright test tests/ui", - "test:integration": "pnpm --filter hyperloop-control-station build:testing && playwright test tests/integration", + "test:integration": "pnpm run build && playwright test --project=integration", "test:fast": "playwright test", "test:fast:ui": "playwright test tests/ui", + "test:fast:integration": "playwright test --project=integration", "report": "playwright show-report" }, "devDependencies": { diff --git a/e2e/scripts/build-renderers.mjs b/e2e/scripts/build-renderers.mjs new file mode 100644 index 000000000..ed13e14b5 --- /dev/null +++ b/e2e/scripts/build-renderers.mjs @@ -0,0 +1,34 @@ +import { execFileSync } from "node:child_process"; +import { cpSync, existsSync, mkdirSync, rmSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = fileURLToPath(new URL("../../", import.meta.url)); +const pnpmCli = process.env.npm_execpath; +if (!pnpmCli) throw new Error("Run this build through pnpm --dir e2e build"); + +for (const view of ["testing", "competition", "logging", "adj"]) { + // Preserve Testing's E2E build; do not rebuild it in normal mode afterwards. + execFileSync( + process.execPath, + [ + pnpmCli, + "--filter", + `${view}-view`, + view === "testing" ? "build:e2e" : "build", + ], + { cwd: root, stdio: "inherit" }, + ); + const source = path.join(root, "frontend", `${view}-view`, "dist"); + if (!existsSync(path.join(source, "index.html"))) + throw new Error(`Missing build for ${view}`); + const destination = path.join( + root, + "electron-app", + "renderer", + `${view}-view`, + ); + rmSync(destination, { recursive: true, force: true }); + mkdirSync(destination, { recursive: true }); + cpSync(source, destination, { recursive: true }); +} diff --git a/e2e/tests/integration/backend.test.ts b/e2e/tests/integration/backend.test.ts new file mode 100644 index 000000000..8613d0cb7 --- /dev/null +++ b/e2e/tests/integration/backend.test.ts @@ -0,0 +1,146 @@ +import { expect, test } from "../../fixtures/electron"; + +type TestConfig = { + vehicle: { boards: string[] }; + server: Record< + string, + { + address: string; + endpoints: { pod_data: string; order_data: string; connections: string }; + } + >; +}; +type Bridge = { + getConfig(): Promise; + getCurrentView(): Promise; + getAppVersion(): Promise; +}; + +test("renderer IPC reads the current view, version and actual configuration", async ({ + page, +}) => { + const result = await page.evaluate(async () => { + const api = (window as unknown as { electronAPI: Bridge }).electronAPI; + return { + config: await api.getConfig(), + view: await api.getCurrentView(), + version: await api.getAppVersion(), + }; + }); + expect(result.view).toBe("testing-view"); + expect(result.version).toMatch(/^\d+\.\d+\.\d+/); + expect(result.config.vehicle.boards.length).toBeGreaterThan(0); + expect(result.config.server["control-station"].address).toBeTruthy(); +}); + +test("the real backend serves telemetry and command catalogs to the renderer", async ({ + page, +}) => { + const result = await page.evaluate(async () => { + const config = await ( + window as unknown as { electronAPI: Bridge } + ).electronAPI.getConfig(); + const server = config.server["control-station"]; + const base = `http://${server.address}/backend`; + const endpoints = [server.endpoints.pod_data, server.endpoints.order_data]; + const catalogs = await Promise.all( + endpoints.map(async (endpoint) => { + const response = await fetch(`${base}${endpoint}`); + if (!response.ok) + throw new Error(`Catalog ${endpoint}: HTTP ${response.status}`); + return response.json() as Promise<{ + boards: { name: string; packets?: unknown[]; orders?: unknown[] }[]; + }>; + }), + ); + return { + configured: config.vehicle.boards, + telemetry: catalogs[0].boards, + commands: catalogs[1].boards, + }; + }); + expect(result.telemetry.length).toBeGreaterThan(0); + expect(result.commands.length).toBeGreaterThan(0); + // Catalogs describe the full ADJ, not only the boards enabled in vehicle.boards. + for (const board of [...result.telemetry, ...result.commands]) { + expect(typeof board.name).toBe("string"); + expect(board.name.length).toBeGreaterThan(0); + } + for (const configured of result.configured) { + expect(result.telemetry.map((board) => board.name)).toContain(configured); + expect(result.commands.map((board) => board.name)).toContain(configured); + } + expect( + result.telemetry.some((board) => (board.packets?.length ?? 0) > 0), + ).toBe(true); + expect(result.commands.some((board) => (board.orders?.length ?? 0) > 0)).toBe( + true, + ); + await expect(page.getByTestId("mode-badge")).toHaveAttribute( + "data-mode", + "active", + ); +}); + +test("the renderer can subscribe to connection state over a real backend WebSocket", async ({ + page, +}) => { + // Read-only subscription: no vehicle commands, network mocks or backend changes. + const message = await page.evaluate(async () => { + const config = await ( + window as unknown as { electronAPI: Bridge } + ).electronAPI.getConfig(); + const server = config.server["control-station"]; + return new Promise<{ + topic: string; + payload: Record; + }>((resolve, reject) => { + const socket = new WebSocket( + `ws://${server.address}${server.endpoints.connections}`, + ); + const timer = setTimeout( + () => finish(new Error("Timed out waiting for connection state")), + 8000, + ); + let finished = false; + function finish( + error?: Error, + data?: { + topic: string; + payload: Record; + }, + ) { + if (finished) return; + finished = true; + clearTimeout(timer); + socket.close(); + if (error) reject(error); + else resolve(data!); + } + socket.onopen = () => + socket.send( + JSON.stringify({ + topic: "connection/update", + payload: { subscribe: true }, + }), + ); + socket.onmessage = (event) => { + try { + finish(undefined, JSON.parse(event.data)); + } catch { + finish(new Error("Invalid backend WebSocket JSON")); + } + }; + socket.onerror = () => finish(new Error("Backend WebSocket failed")); + socket.onclose = () => + finish(new Error("Backend WebSocket closed before a response")); + }); + }); + expect(message.topic).toBe("connection/update"); + expect(message.payload).not.toBeNull(); + expect(Array.isArray(message.payload)).toBe(false); + for (const connection of Object.values(message.payload)) { + expect(typeof connection.name).toBe("string"); + expect(typeof connection.isConnected).toBe("boolean"); + } +}); diff --git a/e2e/tests/integration/profile.test.ts b/e2e/tests/integration/profile.test.ts new file mode 100644 index 000000000..404832bbc --- /dev/null +++ b/e2e/tests/integration/profile.test.ts @@ -0,0 +1,25 @@ +import { expect, test } from "../../fixtures/electron"; + +// Every case writes the same key. Without per-test profiles the second case fails. +for (const run of [1, 2]) { + test(`Electron uses an isolated profile for integration case ${run}`, async ({ + app, + page, + }) => { + const profile = await app.evaluate(({ app }) => ({ + actual: app.getPath("userData"), + expected: process.env.CONTROL_STATION_E2E_PROFILE, + })); + expect(profile.actual).toBe(profile.expected); + expect( + await page.evaluate(() => localStorage.getItem("e2e-profile-sentinel")), + ).toBeNull(); + await page.evaluate(() => + localStorage.setItem("e2e-profile-sentinel", "persisted-in-this-test"), + ); + await page.reload(); + expect( + await page.evaluate(() => localStorage.getItem("e2e-profile-sentinel")), + ).toBe("persisted-in-this-test"); + }); +} diff --git a/e2e/tests/ui/backend-menu.test.ts b/e2e/tests/ui/backend-menu.test.ts deleted file mode 100644 index 4132c9161..000000000 --- a/e2e/tests/ui/backend-menu.test.ts +++ /dev/null @@ -1,159 +0,0 @@ -import type { ElectronApplication } from "@playwright/test"; -import { expect, test } from "../../fixtures/electron"; - -async function controlState(app: ElectronApplication) { - return app.evaluate(({ Menu }) => { - const menu = Menu.getApplicationMenu()!; - return { - visible: menu.getMenuItemById("backend-menu")!.visible, - stop: menu.getMenuItemById("backend-stop")!.enabled, - restart: menu.getMenuItemById("backend-restart")!.enabled, - continue: menu.getMenuItemById("backend-continue")!.enabled, - }; - }); -} - -async function invoke(app: ElectronApplication, id: string) { - await app.evaluate(({ BrowserWindow, Menu }, id) => { - const item = Menu.getApplicationMenu()!.getMenuItemById(id)!; - if (!item.enabled) throw new Error(`${id} is disabled`); - item.click(item, BrowserWindow.getAllWindows()[0], {} as never); - }, id); -} - -async function pressRestartShortcut(app: ElectronApplication, mode: string) { - // Exercise the application shortcut through Electron's native keyboard input. - await app.evaluate(({ BrowserWindow }, mode) => { - const window = BrowserWindow.getAllWindows().find((window) => - window.webContents.getURL().includes(`/${mode}-view/index.html`), - )!; - window.focus(); - const modifiers: ("control" | "meta" | "shift")[] = [ - process.platform === "darwin" ? "meta" : "control", - "shift", - ]; - window.webContents.sendInputEvent({ - type: "keyDown", - keyCode: "R", - modifiers, - }); - window.webContents.sendInputEvent({ - type: "keyUp", - keyCode: "R", - modifiers, - }); - }, mode); -} - -test("Logging View has no Backend menu or restart binding", async ({ app }) => { - const available = await app.evaluate(({ Menu }) => - Menu.getApplicationMenu()!.items[0].submenu!.items.some( - (item) => item.label === "Logging View", - ), - ); - test.skip(!available, "Logging View is not installed"); - await app.evaluate(({ BrowserWindow, Menu }) => { - const item = Menu.getApplicationMenu()!.items[0].submenu!.items.find( - (item) => item.label === "Logging View", - )!; - item.click(item, BrowserWindow.getAllWindows()[0], {} as never); - }); - await expect - .poll(() => - app - .windows() - .some((window) => window.url().includes("/logging-view/index.html")), - ) - .toBe(true); - await expect - .poll(() => controlState(app)) - .toEqual({ visible: false, stop: false, restart: false, continue: false }); -}); - -for (const mode of ["competition", "testing"]) { - test(`Backend Stop, Continue and Ctrl+Shift+R preserve ${mode} view`, async ({ - app, - }) => { - test.setTimeout(90_000); - const label = mode === "competition" ? "Competition View" : "Testing View"; - const available = await app.evaluate( - ({ Menu }, label) => - Menu.getApplicationMenu()!.items[0].submenu!.items.some( - (item) => item.label === label, - ), - label, - ); - test.skip(!available, `${label} is not installed`); - await app.evaluate(({ BrowserWindow, Menu }, label) => { - const item = Menu.getApplicationMenu()!.items[0].submenu!.items.find( - (item) => item.label === label, - )!; - item.click(item, BrowserWindow.getAllWindows()[0], {} as never); - }, label); - await expect - .poll( - () => - app - .windows() - .some((window) => - window.url().includes(`/${mode}-view/index.html`), - ), - { timeout: 20_000 }, - ) - .toBe(true); - if (mode !== "testing") - await expect - .poll(() => - app - .windows() - .some((window) => - window.url().includes("/testing-view/index.html"), - ), - ) - .toBe(false); - const main = app - .windows() - .find((window) => window.url().includes(`/${mode}-view/index.html`))!; - - if ((await controlState(app)).stop) await invoke(app, "backend-stop"); - const stopped = { - visible: true, - stop: false, - restart: true, - continue: true, - }; - const running = { - visible: true, - stop: true, - restart: true, - continue: false, - }; - await expect.poll(() => controlState(app)).toEqual(stopped); - await invoke(app, "backend-continue"); - await expect - .poll(() => controlState(app), { timeout: 15_000 }) - .toEqual(running); - await expect(main).toHaveURL( - new RegExp(`/${mode}-view/index\\.html(?:#.*)?$`), - ); - - await invoke(app, "backend-stop"); - await expect.poll(() => controlState(app)).toEqual(stopped); - await pressRestartShortcut(app, mode); - await expect - .poll(() => controlState(app), { timeout: 15_000 }) - .toEqual(running); - const previousLogs = app - .windows() - .find((window) => window.url().includes("/log-viewer/index.html"))!; - await pressRestartShortcut(app, mode); - await expect.poll(() => previousLogs.isClosed()).toBe(true); - await expect - .poll(() => controlState(app), { timeout: 15_000 }) - .toEqual(running); - expect(main.isClosed()).toBe(false); - await expect(main).toHaveURL( - new RegExp(`/${mode}-view/index\\.html(?:#.*)?$`), - ); - }); -} diff --git a/e2e/tests/ui/filter-dialog.test.ts b/e2e/tests/ui/filter-dialog.test.ts index b68b41327..9256e31db 100644 --- a/e2e/tests/ui/filter-dialog.test.ts +++ b/e2e/tests/ui/filter-dialog.test.ts @@ -18,28 +18,55 @@ test("commands filter dialog opens with correct title", async ({ page }) => { await expect(dialog.getByRole("heading")).toHaveText("Filter commands"); }); -test("filter dialog select all and clear all buttons work", async ({ - page, -}) => { +for (const scope of ["telemetry", "commands"]) { + test(`${scope} select all and clear all change the selected filters`, async ({ + page, + }) => { + await page.getByTestId(`filter-button-${scope}`).click(); + const dialog = page.getByTestId("filter-dialog"); + const checkboxes = dialog.getByRole("checkbox"); + await expect.poll(() => checkboxes.count()).toBeGreaterThan(0); + const count = await checkboxes.count(); + await dialog.getByTestId("filter-select-all").click(); + await expect( + dialog.locator('[role="checkbox"][aria-checked="true"]'), + ).toHaveCount(count); + await dialog.getByTestId("filter-clear-all").click(); + await expect( + dialog.locator('[role="checkbox"][aria-checked="false"]'), + ).toHaveCount(count); + await expect( + dialog.locator('[role="checkbox"][aria-checked="true"]'), + ).toHaveCount(0); + await dialog.getByTestId("filter-select-all").click(); + await expect( + dialog.locator('[role="checkbox"][aria-checked="true"]'), + ).toHaveCount(count); + await page.keyboard.press("Escape"); + await page.getByTestId(`filter-button-${scope}`).click(); + await expect( + dialog.locator('[role="checkbox"][aria-checked="true"]'), + ).toHaveCount(count); + }); +} + +test("filter dialog closes on Escape", async ({ page }) => { await page.getByTestId("filter-button-telemetry").click(); const dialog = page.getByTestId("filter-dialog"); await expect(dialog).toBeVisible(); - await dialog.getByTestId("filter-clear-all").click(); - await dialog.getByTestId("filter-select-all").click(); - - // Dialog should still be open after interactions - await expect(dialog).toBeVisible(); + // Press Escape to close + await page.keyboard.press("Escape"); + await expect(dialog).not.toBeVisible(); }); -test("filter dialog closes on overlay click", async ({ page }) => { +test("filter dialog closes on an actual overlay click", async ({ page }) => { await page.getByTestId("filter-button-telemetry").click(); - const dialog = page.getByTestId("filter-dialog"); await expect(dialog).toBeVisible(); - - // Press Escape to close - await page.keyboard.press("Escape"); - await expect(dialog).not.toBeVisible(); + await page + .locator('[data-slot="dialog-overlay"]') + .click({ position: { x: 5, y: 5 } }); + await expect(dialog).toBeHidden(); });