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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 12 additions & 16 deletions .github/workflows/e2e-tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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: |
Expand All @@ -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
Expand All @@ -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
60 changes: 38 additions & 22 deletions e2e/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,55 +8,71 @@ 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.

---

## Fixtures

`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.
14 changes: 14 additions & 0 deletions e2e/fixtures/electron-main.cjs
Original file line number Diff line number Diff line change
@@ -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);
});
54 changes: 41 additions & 13 deletions e2e/fixtures/electron.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down Expand Up @@ -33,14 +35,35 @@ type ElectronFixtures = {

export const test = base.extend<ElectronFixtures>({
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
Expand Down Expand Up @@ -75,7 +98,11 @@ export const test = base.extend<ElectronFixtures>({
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 });
}
}
},

Expand All @@ -86,13 +113,14 @@ export const test = base.extend<ElectronFixtures>({
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);
},
Expand Down
5 changes: 3 additions & 2 deletions e2e/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
34 changes: 34 additions & 0 deletions e2e/scripts/build-renderers.mjs
Original file line number Diff line number Diff line change
@@ -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 });
}
Loading
Loading