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/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/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(); }); diff --git a/e2e/tests/ui/menus.test.ts b/e2e/tests/ui/menus.test.ts new file mode 100644 index 000000000..3fa84ca6b --- /dev/null +++ b/e2e/tests/ui/menus.test.ts @@ -0,0 +1,281 @@ +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.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...", + "Report an Issue...", + ]); + 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 { + electronAPI: { switchView: (view: string) => Promise }; + }; + void electronAPI.switchView("adj-view"); + }); + 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, +}) => { + 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/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/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/main.js b/electron-app/main.js index 6ce31fd17..a68367934 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,19 @@ 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"; +import { + createCompetitionWindow, + createTestingWindow, +} from "./src/windows/additionalViewWindow.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 +42,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; + + // 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; + } - // Register return-to-selector handler before starting the app. - app.on("return-to-selector", async () => { + 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,28 +89,46 @@ 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)); + 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(); + // Show mode selector and get user choice try { await showModeSelector(screenWidth, screenHeight); } 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/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 000000000..c2861fdee Binary files /dev/null and b/electron-app/renderer/about/avatars/114338434.jpg differ diff --git a/electron-app/renderer/about/avatars/114561048.jpg b/electron-app/renderer/about/avatars/114561048.jpg new file mode 100644 index 000000000..ec329866a Binary files /dev/null and b/electron-app/renderer/about/avatars/114561048.jpg differ diff --git a/electron-app/renderer/about/avatars/130096040.png b/electron-app/renderer/about/avatars/130096040.png new file mode 100644 index 000000000..9e358e5c7 Binary files /dev/null and b/electron-app/renderer/about/avatars/130096040.png differ diff --git a/electron-app/renderer/about/avatars/152517350.jpg b/electron-app/renderer/about/avatars/152517350.jpg new file mode 100644 index 000000000..cd1adcd0f Binary files /dev/null and b/electron-app/renderer/about/avatars/152517350.jpg differ diff --git a/electron-app/renderer/about/avatars/152909605.jpg b/electron-app/renderer/about/avatars/152909605.jpg new file mode 100644 index 000000000..140e75b26 Binary files /dev/null and b/electron-app/renderer/about/avatars/152909605.jpg differ diff --git a/electron-app/renderer/about/avatars/183129863.png b/electron-app/renderer/about/avatars/183129863.png new file mode 100644 index 000000000..0b976420b Binary files /dev/null and b/electron-app/renderer/about/avatars/183129863.png differ diff --git a/electron-app/renderer/about/avatars/228559793.png b/electron-app/renderer/about/avatars/228559793.png new file mode 100644 index 000000000..0b976420b Binary files /dev/null and b/electron-app/renderer/about/avatars/228559793.png differ diff --git a/electron-app/renderer/about/avatars/47422871.png b/electron-app/renderer/about/avatars/47422871.png new file mode 100644 index 000000000..156493877 Binary files /dev/null and b/electron-app/renderer/about/avatars/47422871.png differ diff --git a/electron-app/renderer/about/avatars/58850783.jpg b/electron-app/renderer/about/avatars/58850783.jpg new file mode 100644 index 000000000..e74963c1a Binary files /dev/null and b/electron-app/renderer/about/avatars/58850783.jpg differ diff --git a/electron-app/renderer/about/avatars/60261013.jpg b/electron-app/renderer/about/avatars/60261013.jpg new file mode 100644 index 000000000..e9cf34628 Binary files /dev/null and b/electron-app/renderer/about/avatars/60261013.jpg differ diff --git a/electron-app/renderer/about/avatars/60979852.png b/electron-app/renderer/about/avatars/60979852.png new file mode 100644 index 000000000..32e45659c Binary files /dev/null and b/electron-app/renderer/about/avatars/60979852.png differ diff --git a/electron-app/renderer/about/avatars/70332045.png b/electron-app/renderer/about/avatars/70332045.png new file mode 100644 index 000000000..09e85d903 Binary files /dev/null and b/electron-app/renderer/about/avatars/70332045.png differ diff --git a/electron-app/renderer/about/avatars/71978109.jpg b/electron-app/renderer/about/avatars/71978109.jpg new file mode 100644 index 000000000..8fba3d143 Binary files /dev/null and b/electron-app/renderer/about/avatars/71978109.jpg differ diff --git a/electron-app/renderer/about/avatars/74974283.jpg b/electron-app/renderer/about/avatars/74974283.jpg new file mode 100644 index 000000000..1490e555d Binary files /dev/null and b/electron-app/renderer/about/avatars/74974283.jpg differ diff --git a/electron-app/renderer/about/avatars/76229993.png b/electron-app/renderer/about/avatars/76229993.png new file mode 100644 index 000000000..9057abbcf Binary files /dev/null and b/electron-app/renderer/about/avatars/76229993.png differ diff --git a/electron-app/renderer/about/avatars/80858832.png b/electron-app/renderer/about/avatars/80858832.png new file mode 100644 index 000000000..d320a75c7 Binary files /dev/null and b/electron-app/renderer/about/avatars/80858832.png differ diff --git a/electron-app/renderer/about/avatars/99354212.png b/electron-app/renderer/about/avatars/99354212.png new file mode 100644 index 000000000..0b976420b Binary files /dev/null and b/electron-app/renderer/about/avatars/99354212.png differ diff --git a/electron-app/renderer/about/index.html b/electron-app/renderer/about/index.html index c758bd0bf..149728c9b 100644 --- a/electron-app/renderer/about/index.html +++ b/electron-app/renderer/about/index.html @@ -1,79 +1,48 @@ - - - - - - About Logging View - - - - -
- - -

Hyperloop Control Station — Logging View

-

Javier Ribal del Río — H11

-

© HyperloopUPV - Software

-
- - + + + + + + About Control Station + + + + +
+
+ + +

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/app/__tests__/modeSelector.test.js b/electron-app/src/app/__tests__/modeSelector.test.js new file mode 100644 index 000000000..526c9d884 --- /dev/null +++ b/electron-app/src/app/__tests__/modeSelector.test.js @@ -0,0 +1,88 @@ +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).toHaveBeenCalledOnce(); + 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..c51d51e7a --- /dev/null +++ b/electron-app/src/app/__tests__/modeSwitching.test.js @@ -0,0 +1,206 @@ +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: 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(); +}; + +beforeEach(async () => { + vi.resetModules(); + vi.resetAllMocks(); + vi.useFakeTimers(); + const { app } = await import("electron"); + app.removeAllListeners(); + const { getCurrentView } = await import("../../windows/mainWindow.js"); + getCurrentView.mockReturnValue("testing-view"); +}); +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(); + }); + + 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/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/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..a6f0d27d4 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 @@ -152,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); @@ -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/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 9b124b27a..a1331f4ce 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,29 +12,60 @@ 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 -### Tools Menu +The selector retains **File > Reload / Return to Selector / Exit**, with Return to Selector disabled to prevent opening another selector. + +### 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 + +### 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 +- **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. +- **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. ## 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..996113691 --- /dev/null +++ b/electron-app/src/menu/__tests__/menu.test.js @@ -0,0 +1,353 @@ +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"; +import { openAppLocation, openProjectLink } from "../toolActions.js"; + +vi.mock("electron", () => ({ + app: { emit: vi.fn(), quit: vi.fn() }, + BrowserWindow: { getFocusedWindow: vi.fn() }, + Menu: { + 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: 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() })); +vi.mock("../toolActions.js", () => ({ + openAppLocation: vi.fn(), + openProjectLink: 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", + "Tools", + "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.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"])( + "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); + 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", () => { + 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"); + 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/__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 000ad85f6..e55133bd5 100644 --- a/electron-app/src/menu/menu.js +++ b/electron-app/src/menu/menu.js @@ -1,27 +1,50 @@ /** * @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"; +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 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,22 +55,44 @@ 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(), }, ], }, + ...(!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" }, @@ -61,35 +106,178 @@ function createMenu() { } }, }, - { - 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: "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 + ? [ + { + 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(); + }, + }, + { + 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" }, + ], + }, + ] + : []), { 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()), + }, + { type: "separator" }, + { + label: "Report an Issue...", + click: (_, window) => openProjectLink("issue", window), + }, + ] + : []), ], }, ]; 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(); + 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; } -export { createMenu }; +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); + 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/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/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/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, 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__/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/__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/__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/__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/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 044ec45b5..f21e5cd42 100644 --- a/electron-app/src/windows/aboutWindow.js +++ b/electron-app/src/windows/aboutWindow.js @@ -1,16 +1,56 @@ /** * @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. */ -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 — @@ -20,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: 380, - height: 440, - 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 Logging View", - backgroundColor: "#111111", + title: "About Control Station", + backgroundColor: "#161a2d", webPreferences: { - preload: path.join(getAppPath(), "preload.js"), + preload: path.join(getAppPath(), "src", "windows", "aboutPreload.cjs"), contextIsolation: true, nodeIntegration: false, + sandbox: true, }, }); - aboutWindow.setMenuBarVisibility(false); + 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); }); 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; + } +} diff --git a/electron-app/src/windows/logWindow.js b/electron-app/src/windows/logWindow.js index 4c1c0e27e..9758de26a 100644 --- a/electron-app/src/windows/logWindow.js +++ b/electron-app/src/windows/logWindow.js @@ -1,13 +1,20 @@ -import { BrowserWindow } from "electron"; +import { BrowserWindow, screen } 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(); +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), @@ -19,9 +26,50 @@ export const createLogWindow = (screenWidth, screenHeight) => { nodeIntegration: false, }, }); + logWindow = window; + logWindowReady = false; + + 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 ca46432c7..8884f8579 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,9 +48,10 @@ function createWindow(screenWidth, screenHeight, initialView) { backgroundColor: "#1a1a1a", }); - // 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). + const window = mainWindow; + setWindowMenu(window); + + // 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([ @@ -83,7 +85,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 +126,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}`);