From 8ad81efdc3a50fc582ce15c051c6d7edd5f486f9 Mon Sep 17 00:00:00 2001 From: KingKDot Date: Wed, 15 Jul 2026 12:12:13 -0500 Subject: [PATCH] AI made playwright tests + add them to the github. --- .github/workflows/tests.yml | 28 +++- .gitignore | 4 +- Overlord-Server/bun.lock | 9 ++ Overlord-Server/bunfig.toml | 2 + Overlord-Server/package.json | 4 + Overlord-Server/playwright.config.ts | 23 +++ Overlord-Server/public/assets/main.css | 10 +- Overlord-Server/public/assets/nav.js | 8 +- Overlord-Server/scripts/run-tests-isolated.ts | 26 ++++ Overlord-Server/src/paths.ts | 32 ++++- Overlord-Server/test/e2e/access.spec.ts | 116 +++++++++++++++ Overlord-Server/test/e2e/credentials.ts | 22 +++ Overlord-Server/test/e2e/global-setup.ts | 136 ++++++++++++++++++ Overlord-Server/test/e2e/helpers.ts | 43 ++++++ Overlord-Server/test/e2e/smoke.spec.ts | 81 +++++++++++ Overlord-Server/test/preload.ts | 19 +++ 16 files changed, 554 insertions(+), 9 deletions(-) create mode 100644 Overlord-Server/bunfig.toml create mode 100644 Overlord-Server/playwright.config.ts create mode 100644 Overlord-Server/scripts/run-tests-isolated.ts create mode 100644 Overlord-Server/test/e2e/access.spec.ts create mode 100644 Overlord-Server/test/e2e/credentials.ts create mode 100644 Overlord-Server/test/e2e/global-setup.ts create mode 100644 Overlord-Server/test/e2e/helpers.ts create mode 100644 Overlord-Server/test/e2e/smoke.spec.ts create mode 100644 Overlord-Server/test/preload.ts diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index deec3ef..a09356b 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -28,6 +28,7 @@ jobs: filters: | server: - 'Overlord-Server/**' + - '.github/workflows/tests.yml' client: - 'Overlord-Client/**' - 'go.work' @@ -57,7 +58,32 @@ jobs: run: bun install --frozen-lockfile - name: Run server tests - run: bun test ./src + run: bun run test + + server-e2e-tests: + needs: changes + if: needs.changes.outputs.server == 'true' || github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + defaults: + run: + working-directory: Overlord-Server + steps: + - name: Checkout repository + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: 1.3.14 + + - name: Install server dependencies + run: bun install --frozen-lockfile + + - name: Install Chromium and system dependencies + run: bunx playwright install --with-deps chromium + + - name: Run browser tests + run: bun run test:e2e client-tests: needs: changes diff --git a/.gitignore b/.gitignore index 7b572ee..42757a1 100644 --- a/.gitignore +++ b/.gitignore @@ -75,6 +75,8 @@ plugins/extension/* # Runtime data Overlord-Server/data/ +Overlord-Server/test-results/ +Overlord-Server/playwright-report/ # Release packaging output release/ @@ -520,4 +522,4 @@ plugins/plink/* plugins/plink-static/* Overlord-Client/third_party/amf -Overlord-Client/third_party/onevpl \ No newline at end of file +Overlord-Client/third_party/onevpl diff --git a/Overlord-Server/bun.lock b/Overlord-Server/bun.lock index 3acf910..c947973 100644 --- a/Overlord-Server/bun.lock +++ b/Overlord-Server/bun.lock @@ -26,6 +26,7 @@ "@fontsource/inter": "^5.2.8", "@fontsource/jetbrains-mono": "^5.2.8", "@fortawesome/fontawesome-free": "6.5.2", + "@playwright/test": "^1.61.1", "@tailwindcss/cli": "^4.1.18", "@types/bun": "^1.3.9", "@types/geoip-lite": "latest", @@ -128,6 +129,8 @@ "@parcel/watcher-win32-x64": ["@parcel/watcher-win32-x64@2.5.1", "", { "os": "win32", "cpu": "x64" }, "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA=="], + "@playwright/test": ["@playwright/test@1.61.1", "", { "dependencies": { "playwright": "1.61.1" }, "bin": { "playwright": "cli.js" } }, "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig=="], + "@so-ric/colorspace": ["@so-ric/colorspace@1.1.6", "", { "dependencies": { "color": "^5.0.2", "text-hex": "1.0.x" } }, "sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw=="], "@solana/buffer-layout": ["@solana/buffer-layout@4.0.1", "", { "dependencies": { "buffer": "~6.0.3" } }, "sha512-E1ImOIAD1tBZFRdjeM4/pzTiTApC0AOBGwyAMS4fwIodCWArzJ3DWdoh8cKxeFM2fElkxBh2Aqts1BPC373rHA=="], @@ -322,6 +325,8 @@ "fraction.js": ["fraction.js@5.3.4", "", {}, "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ=="], + "fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], + "fuse.js": ["fuse.js@7.4.2", "", {}, "sha512-LVbzjD4WA6UP5B1UnP8wuaXJiLnqMdM/E4fiJXTJ5haJ5b/MBNsK29h2fm6swEoQaVQjvYFWKLE2RanyZIoRVQ=="], "geoip-lite": ["geoip-lite@2.0.3", "", { "dependencies": { "chalk": "4.1 - 4.1.2", "iconv-lite": "0.4.13 - 0.6.3", "ip-address": "^10.2.0", "lazy": "1.0.11", "yauzl": "^3.3.1" } }, "sha512-I0v/6sITK5FhEXHmatGSPBMply2nHN/2D743LIx4KkksttZt/E505lDaV6qUwt5RM08MbZW+/4E0KxusLVN39w=="], @@ -464,6 +469,10 @@ "picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], + "playwright": ["playwright@1.61.1", "", { "dependencies": { "playwright-core": "1.61.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ=="], + + "playwright-core": ["playwright-core@1.61.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg=="], + "pngjs": ["pngjs@5.0.0", "", {}, "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw=="], "postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="], diff --git a/Overlord-Server/bunfig.toml b/Overlord-Server/bunfig.toml new file mode 100644 index 0000000..786a377 --- /dev/null +++ b/Overlord-Server/bunfig.toml @@ -0,0 +1,2 @@ +[test] +preload = ["./test/preload.ts"] diff --git a/Overlord-Server/package.json b/Overlord-Server/package.json index fc4aa6e..308ee3f 100644 --- a/Overlord-Server/package.json +++ b/Overlord-Server/package.json @@ -4,6 +4,9 @@ "private": true, "type": "module", "scripts": { + "test": "bun run scripts/run-tests-isolated.ts", + "test:e2e": "playwright test", + "test:e2e:install": "playwright install chromium", "dev": "bun --watch src/index.ts", "start": "bun run build && bun dist/index.js", "build": "bun run build:css && bun run vendor && bun run build:bundle", @@ -40,6 +43,7 @@ "@fontsource/inter": "^5.2.8", "@fontsource/jetbrains-mono": "^5.2.8", "@fortawesome/fontawesome-free": "6.5.2", + "@playwright/test": "^1.61.1", "@tailwindcss/cli": "^4.1.18", "@types/bun": "^1.3.9", "@types/geoip-lite": "latest", diff --git a/Overlord-Server/playwright.config.ts b/Overlord-Server/playwright.config.ts new file mode 100644 index 0000000..4ce3ce7 --- /dev/null +++ b/Overlord-Server/playwright.config.ts @@ -0,0 +1,23 @@ +import { defineConfig } from "@playwright/test"; + +const port = 5193; +const baseURL = `http://127.0.0.1:${port}`; + +export default defineConfig({ + testDir: "./test/e2e", + globalSetup: "./test/e2e/global-setup.ts", + fullyParallel: false, + workers: 1, + timeout: 30_000, + expect: { timeout: 7_500 }, + forbidOnly: Boolean(process.env.CI), + retries: process.env.CI ? 1 : 0, + reporter: process.env.CI ? "github" : "list", + use: { + baseURL, + browserName: "chromium", + ignoreHTTPSErrors: true, + trace: "retain-on-failure", + screenshot: "only-on-failure", + }, +}); diff --git a/Overlord-Server/public/assets/main.css b/Overlord-Server/public/assets/main.css index e63a347..3ea6769 100644 --- a/Overlord-Server/public/assets/main.css +++ b/Overlord-Server/public/assets/main.css @@ -268,10 +268,6 @@ html { scrollbar-gutter: stable; } -@view-transition { - navigation: auto; -} - body { margin: 0; min-height: 100vh; @@ -4342,6 +4338,9 @@ body.sb-collapsed .sb-collapse-btn i { position: relative; transition: background 0.14s, color 0.14s; } +.sb-link.hidden { + display: none; +} .sb-link:hover { background: rgba(255, 255, 255, 0.06); color: #e2e8f0; @@ -4826,6 +4825,9 @@ body.topbar-compact #role-badge { white-space: nowrap; transition: background 0.15s, color 0.15s, border-color 0.15s; } +.nav-dd-group-btn.hidden { + display: none; +} .nav-dd-group-btn:hover { background: rgba(51, 65, 85, 0.7); color: #e2e8f0; diff --git a/Overlord-Server/public/assets/nav.js b/Overlord-Server/public/assets/nav.js index 8264ebd..def6cb1 100644 --- a/Overlord-Server/public/assets/nav.js +++ b/Overlord-Server/public/assets/nav.js @@ -190,16 +190,20 @@ if (host) { } applyAdaptiveNavLayout(); + return user; } catch (err) { console.error("Failed to load user:", err); + return null; } } + let currentUserPromise = Promise.resolve(null); if (refs.usernameDisplay && refs.roleBadge) { - loadCurrentUser(); + currentUserPromise = loadCurrentUser(); } - import("./chat-widget.js").then((chatWidget) => { + Promise.all([currentUserPromise, import("./chat-widget.js")]).then(([user, chatWidget]) => { + if (!user?.permissions?.includes("chat:write")) return; runWithoutPageTracking(() => { chatWidget.init(); diff --git a/Overlord-Server/scripts/run-tests-isolated.ts b/Overlord-Server/scripts/run-tests-isolated.ts new file mode 100644 index 0000000..cf9f4ed --- /dev/null +++ b/Overlord-Server/scripts/run-tests-isolated.ts @@ -0,0 +1,26 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const dataDir = mkdtempSync(join(tmpdir(), "overlord-bun-test-")); +writeFileSync(join(dataDir, "config.json"), "{}\n", "utf8"); + +const child = Bun.spawn(["bun", "test", "src", ...process.argv.slice(2)], { + cwd: import.meta.dirname + "/..", + env: { + ...process.env, + NODE_ENV: "test", + DATA_DIR: dataDir, + OVERLORD_TEST_DATA_DIR: "1", + }, + stdin: "inherit", + stdout: "inherit", + stderr: "inherit", +}); + +const exitCode = await child.exited; + +// Cleanup happens in the parent after Bun Test and every SQLite connection it +// owns have exited, which avoids Windows EBUSY errors from open database files. +rmSync(dataDir, { recursive: true, force: true }); +process.exit(exitCode); diff --git a/Overlord-Server/src/paths.ts b/Overlord-Server/src/paths.ts index 09fa1f0..3cce1a3 100644 --- a/Overlord-Server/src/paths.ts +++ b/Overlord-Server/src/paths.ts @@ -1,12 +1,42 @@ import path from "path"; import { existsSync, mkdirSync } from "fs"; +import { tmpdir } from "os"; + +function assertSafeTestDataDir(dir: string): void { + if (String(process.env.NODE_ENV || "").toLowerCase() !== "test") return; + + if (process.env.OVERLORD_TEST_DATA_DIR !== "1") { + throw new Error( + "Refusing to run tests without OVERLORD_TEST_DATA_DIR=1. Use `bun test` so test/preload.ts can create an isolated data directory.", + ); + } + + const resolvedDir = path.resolve(dir); + const resolvedTemp = path.resolve(tmpdir()); + const tempPrefix = `${resolvedTemp}${path.sep}`; + const normalize = (value: string) => + process.platform === "win32" ? value.toLowerCase() : value; + + if (!normalize(resolvedDir).startsWith(normalize(tempPrefix))) { + throw new Error( + `Refusing to use non-temporary DATA_DIR while NODE_ENV=test: ${resolvedDir}`, + ); + } +} export function resolveDataDir(): string { const envDir = process.env.DATA_DIR; if (envDir && envDir.trim()) { + assertSafeTestDataDir(envDir); return envDir; } + if (String(process.env.NODE_ENV || "").toLowerCase() === "test") { + throw new Error( + "Refusing to use the default application data directory while NODE_ENV=test. Run tests through `bun test`.", + ); + } + if (process.platform === "win32" && process.env.APPDATA) { return path.join(process.env.APPDATA, "Overlord"); } @@ -20,4 +50,4 @@ export function ensureDataDir(): string { mkdirSync(dir, { recursive: true }); } return dir; -} \ No newline at end of file +} diff --git a/Overlord-Server/test/e2e/access.spec.ts b/Overlord-Server/test/e2e/access.spec.ts new file mode 100644 index 0000000..53215f8 --- /dev/null +++ b/Overlord-Server/test/e2e/access.spec.ts @@ -0,0 +1,116 @@ +import { expect, test } from "@playwright/test"; +import { OPERATOR, VIEWER } from "./credentials"; +import { collectBrowserIssues, login } from "./helpers"; + +test.describe("authentication and authorization", () => { + test("rejects invalid credentials without creating a session", async ({ page }) => { + await page.goto("/login.html"); + await page.locator("#user").fill("not-a-real-user"); + await page.locator("#pass").fill("not-the-password"); + await page.locator("#login-form button[type='submit']").click(); + + await expect(page.locator("#error")).toContainText("Invalid credentials"); + await expect(page).toHaveURL(/\/login\.html$/); + const me = await page.request.get("/api/auth/me"); + expect(me.status()).toBe(401); + }); + + test("serves login instead of protected content when unauthenticated", async ({ page }) => { + const response = await page.goto("/settings"); + expect(response?.status()).toBe(200); + await expect(page.locator("#login-form")).toBeVisible(); + await expect(page.getByRole("heading", { name: "Settings" })).toHaveCount(0); + }); + + test("logout revokes the browser session", async ({ page }) => { + await login(page); + page.once("dialog", (dialog) => dialog.accept()); + await page.locator("#user-actions-btn").click(); + await Promise.all([ + page.waitForURL((url) => url.pathname === "/"), + page.locator("#logout-btn").click(), + ]); + await expect(page.locator("#login-form")).toBeVisible(); + + const me = await page.request.get("/api/auth/me"); + expect(me.status()).toBe(401); + await page.goto("/users"); + await expect(page.locator("#login-form")).toBeVisible(); + }); + + test("viewer navigation and direct routes enforce read-only access", async ({ page }) => { + await login(page, VIEWER); + await expect(page.locator("#role-badge")).toContainText("Viewer"); + + for (const id of [ + "users-link", + "build-link", + "plugins-link", + "scripts-link", + "file-share-link", + "notifications-link", + "enrollment-link", + ]) { + await expect(page.locator(`#${id}`)).toBeHidden(); + } + + const denied = await page.goto("/users"); + expect(denied?.status()).toBe(403); + await expect(page.locator("body")).toContainText("Admin access required"); + + const allowed = await page.goto("/metrics"); + expect(allowed?.status()).toBe(200); + await expect(page.getByRole("heading", { name: "Metrics Dashboard" })).toBeVisible(); + }); + + test("operator can use operational pages but not admin pages", async ({ page }) => { + await login(page, OPERATOR); + await expect(page.locator("#role-badge")).toContainText("Operator"); + await expect(page.locator("#build-link")).not.toHaveClass(/hidden/); + await expect(page.locator("#users-link")).toBeHidden(); + + const build = await page.goto("/build"); + expect(build?.status()).toBe(200); + await expect(page.locator("#build-form")).toBeVisible(); + + const users = await page.goto("/users"); + expect(users?.status()).toBe(403); + }); +}); + +test("admin page sweep loads without browser errors", async ({ page }) => { + // This test validates each page's static/HTTP initialization. Opening two + // long-lived sockets for every rapid hard navigation would only exercise the + // server's WebSocket rate limiter; socket lifecycle is covered separately. + await page.routeWebSocket(/.*/, (socket) => socket.close()); + const issues = collectBrowserIssues(page); + await login(page); + + const routes = [ + ["/", "#grid"], + ["/metrics", "h1"], + ["/graph", "h1"], + ["/settings", "#prefs-form"], + ["/logs", "#page-info"], + ["/notifications", "#notification-table"], + ["/users", "#users-table-body"], + ["/build", "#build-form"], + ["/sol-publish", "#rpc-endpoint-list"], + ["/plugins", "#plugin-list"], + ["/scripts", "#saved-scripts-list"], + ["/deploy", "#client-list"], + ["/socks5-manager", "body"], + ["/file-share", "#upload-form"], + ["/purgatory", "#enrollment-table"], + ] as const; + + for (const [path, selector] of routes) { + const response = await page.goto(path); + expect(response?.status(), `${path} should return 200`).toBe(200); + await expect(page.locator("#top-nav"), `${path} should mount navigation`).toBeVisible(); + await expect(page.locator(selector).first(), `${path} should render ${selector}`).toBeVisible(); + await page.waitForTimeout(250); + } + + expect(issues).toEqual([]); +}); diff --git a/Overlord-Server/test/e2e/credentials.ts b/Overlord-Server/test/e2e/credentials.ts new file mode 100644 index 0000000..7bf1835 --- /dev/null +++ b/Overlord-Server/test/e2e/credentials.ts @@ -0,0 +1,22 @@ +export const ADMIN = { + username: "e2e-admin", + password: "E2ePassword!2026", +} as const; + +export const ONBOARDING_USER = { + username: "e2e-onboarding", + password: "E2eOnboarding!2026", + role: "viewer", +} as const; + +export const OPERATOR = { + username: "e2e-operator", + password: "E2eOperator!2026", + role: "operator", +} as const; + +export const VIEWER = { + username: "e2e-viewer", + password: "E2eViewer!2026", + role: "viewer", +} as const; diff --git a/Overlord-Server/test/e2e/global-setup.ts b/Overlord-Server/test/e2e/global-setup.ts new file mode 100644 index 0000000..a58e453 --- /dev/null +++ b/Overlord-Server/test/e2e/global-setup.ts @@ -0,0 +1,136 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { spawn, type ChildProcess } from "node:child_process"; +import type { FullConfig } from "@playwright/test"; +import { ADMIN, ONBOARDING_USER, OPERATOR, VIEWER } from "./credentials"; + +const PORT = 5193; +const BASE_URL = `http://127.0.0.1:${PORT}`; + +async function loginCookie(username: string, password: string): Promise { + const response = await fetch(`${BASE_URL}/api/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ user: username, pass: password }), + }); + if (!response.ok) { + throw new Error(`E2E seed login failed for ${username}: ${response.status}`); + } + const cookie = response.headers.get("set-cookie")?.split(";", 1)[0]; + if (!cookie) throw new Error(`E2E seed login returned no cookie for ${username}`); + return cookie; +} + +async function authenticatedRequest( + path: string, + cookie: string, + init: RequestInit = {}, +): Promise { + return fetch(`${BASE_URL}${path}`, { + ...init, + headers: { + "Content-Type": "application/json", + Cookie: cookie, + ...init.headers, + }, + }); +} + +async function seedBrowserUsers(): Promise { + const adminCookie = await loginCookie(ADMIN.username, ADMIN.password); + const onboardingResponse = await authenticatedRequest( + "/api/auth/onboarding/complete", + adminCookie, + { method: "POST" }, + ); + if (!onboardingResponse.ok) { + throw new Error(`Could not complete admin onboarding: ${onboardingResponse.status}`); + } + + for (const account of [ONBOARDING_USER, OPERATOR, VIEWER]) { + const response = await authenticatedRequest("/api/users", adminCookie, { + method: "POST", + body: JSON.stringify(account), + }); + if (!response.ok) { + throw new Error(`Could not seed ${account.username}: ${response.status}`); + } + } + + for (const account of [OPERATOR, VIEWER]) { + const cookie = await loginCookie(account.username, account.password); + const response = await authenticatedRequest( + "/api/auth/onboarding/complete", + cookie, + { method: "POST" }, + ); + if (!response.ok) { + throw new Error(`Could not complete onboarding for ${account.username}: ${response.status}`); + } + } +} + +async function waitForServer(child: ChildProcess): Promise { + const deadline = Date.now() + 60_000; + while (Date.now() < deadline) { + if (child.exitCode !== null) { + throw new Error(`E2E server exited before startup (code ${child.exitCode})`); + } + try { + const response = await fetch(`${BASE_URL}/login.html`); + if (response.ok) return; + } catch {} + await new Promise((resolveWait) => setTimeout(resolveWait, 200)); + } + throw new Error("Timed out waiting for the isolated E2E server"); +} + +async function stopServer(child: ChildProcess): Promise { + if (child.exitCode !== null) return; + child.kill("SIGTERM"); + await Promise.race([ + new Promise((resolveExit) => child.once("exit", () => resolveExit())), + new Promise((resolveTimeout) => setTimeout(resolveTimeout, 5_000)), + ]); + if (child.exitCode === null) child.kill("SIGKILL"); +} + +export default async function globalSetup(_config: FullConfig) { + const projectRoot = resolve(import.meta.dirname, "../.."); + const dataDir = mkdtempSync(join(tmpdir(), "overlord-e2e-")); + writeFileSync(join(dataDir, "config.json"), "{}\n", "utf8"); + + const child = spawn("bun", ["run", "src/index.ts"], { + cwd: projectRoot, + windowsHide: true, + stdio: "ignore", + env: { + ...process.env, + NODE_ENV: "test", + DATA_DIR: dataDir, + OVERLORD_TEST_DATA_DIR: "1", + HOST: "127.0.0.1", + PORT: String(PORT), + OVERLORD_TLS_OFFLOAD: "true", + OVERLORD_USER: "e2e-admin", + OVERLORD_PASS: "E2ePassword!2026", + JWT_SECRET: "e2e-jwt-secret-that-is-only-used-for-browser-tests", + OVERLORD_AGENT_TOKEN: "e2e-agent-token-that-is-only-used-for-browser-tests", + }, + }); + + try { + await waitForServer(child); + await seedBrowserUsers(); + } catch (error) { + await stopServer(child); + rmSync(dataDir, { recursive: true, force: true }); + throw error; + } + + return async () => { + await stopServer(child); + rmSync(dataDir, { recursive: true, force: true }); + }; +} diff --git a/Overlord-Server/test/e2e/helpers.ts b/Overlord-Server/test/e2e/helpers.ts new file mode 100644 index 0000000..a6146e3 --- /dev/null +++ b/Overlord-Server/test/e2e/helpers.ts @@ -0,0 +1,43 @@ +import { expect, type Page } from "@playwright/test"; +import { ADMIN } from "./credentials"; + +export async function login( + page: Page, + credentials: { username: string; password: string } = ADMIN, +): Promise { + await page.goto("/login.html"); + await page.locator("#user").fill(credentials.username); + await page.locator("#pass").fill(credentials.password); + await Promise.all([ + page.waitForURL((url) => url.pathname === "/"), + page.locator("#login-form button[type='submit']").click(), + ]); + await expect(page.locator("#top-nav")).toBeVisible(); +} + +export async function navigateFromMenu(page: Page, linkId: string): Promise { + const link = page.locator(`#${linkId}`); + const wrapper = link.locator("xpath=ancestor::*[contains(@class, 'nav-dd-wrapper')][1]"); + await wrapper.locator(":scope > .nav-dd-group-btn").click(); + await expect(link).toBeVisible(); + await link.click(); +} + +export function collectBrowserIssues(page: Page): string[] { + const issues: string[] = []; + page.on("pageerror", (error) => issues.push(`pageerror: ${error.message}`)); + page.on("console", (message) => { + const text = message.text(); + if (message.type() === "error") { + issues.push(`console error: ${text}`); + return; + } + if ( + message.type() === "warning" && + /duplicate definition|preload|password field|autocomplete/i.test(text) + ) { + issues.push(`console warning: ${text}`); + } + }); + return issues; +} diff --git a/Overlord-Server/test/e2e/smoke.spec.ts b/Overlord-Server/test/e2e/smoke.spec.ts new file mode 100644 index 0000000..eb14e25 --- /dev/null +++ b/Overlord-Server/test/e2e/smoke.spec.ts @@ -0,0 +1,81 @@ +import { expect, test } from "@playwright/test"; +import { ONBOARDING_USER } from "./credentials"; +import { collectBrowserIssues, login, navigateFromMenu } from "./helpers"; + +test.describe("authenticated browser smoke tests", () => { + test("onboarding completion survives a hard reload", async ({ page }) => { + const issues = collectBrowserIssues(page); + await login(page, ONBOARDING_USER); + + const continueButton = page.locator(".account-onboarding__continue"); + await expect(continueButton).toBeVisible(); + // The dashboard behind the modal keeps resizing GridStack during startup, + // so dispatch directly to the already-visible modal control. + await continueButton.click({ force: true }); + await expect(continueButton).toContainText("report bugs"); + await continueButton.click({ force: true }); + await expect(page.locator(".account-onboarding")).toHaveCount(0); + + await page.reload(); + await expect(page.locator("#top-nav")).toBeVisible(); + await expect(page.locator(".account-onboarding")).toHaveCount(0); + expect(issues).toEqual([]); + }); + + test("repeated Turbo visits do not duplicate widgets or throw", async ({ page }) => { + const issues = collectBrowserIssues(page); + await login(page); + + for (let visit = 0; visit < 3; visit += 1) { + await navigateFromMenu(page, "notifications-link"); + await expect(page).toHaveURL(/\/notifications$/); + await expect(page.locator("#notification-table.tabulator")).toBeVisible(); + + await navigateFromMenu(page, "plugins-link"); + await expect(page).toHaveURL(/\/plugins$/); + await expect(page.locator("#plugin-list")).toBeVisible(); + } + + expect(issues).toEqual([]); + }); + + test("dashboard metrics and plugin signing layout respond to narrow content", async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await login(page); + await expect(page.locator("#dashboard-stats")).toBeVisible(); + + await page.setViewportSize({ width: 800, height: 900 }); + await expect(page.locator("#dashboard-stats")).toBeHidden(); + + await page.goto("/plugins"); + await expect(page.locator("#trusted-keys-section")).toBeVisible(); + await page.setViewportSize({ width: 375, height: 812 }); + + const layoutFits = await page.evaluate(() => + document.documentElement.scrollWidth <= document.documentElement.clientWidth + 1, + ); + expect(layoutFits).toBe(true); + + for (const selector of [".plugin-panel", "#trusted-key-form"]) { + const box = await page.locator(selector).last().boundingBox(); + expect(box).not.toBeNull(); + expect(box!.x).toBeGreaterThanOrEqual(0); + expect(box!.x + box!.width).toBeLessThanOrEqual(376); + } + }); + + test("password inputs expose browser-safe form semantics", async ({ page }) => { + const issues = collectBrowserIssues(page); + await login(page); + + await page.goto("/file-share"); + await expect(page.locator("#upload-password")).toHaveAttribute("autocomplete", "new-password"); + await expect(page.locator("#upload-password").locator("xpath=ancestor::form")).toHaveCount(1); + await expect(page.locator("#edit-password")).toHaveAttribute("autocomplete", "new-password"); + await expect(page.locator("#edit-password").locator("xpath=ancestor::form")).toHaveCount(1); + + await page.goto("/users"); + await expect(page.locator("#password")).toHaveAttribute("autocomplete", "new-password"); + expect(issues).toEqual([]); + }); +}); diff --git a/Overlord-Server/test/preload.ts b/Overlord-Server/test/preload.ts new file mode 100644 index 0000000..1e968e1 --- /dev/null +++ b/Overlord-Server/test/preload.ts @@ -0,0 +1,19 @@ +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const suppliedDataDir = + process.env.OVERLORD_TEST_DATA_DIR === "1" && process.env.DATA_DIR?.trim() + ? process.env.DATA_DIR + : undefined; +const testDataDir = suppliedDataDir ?? mkdtempSync(join(tmpdir(), "overlord-bun-test-")); + +// These must be set before any application module imports config or opens the +// singleton SQLite connection. +process.env.NODE_ENV = "test"; +process.env.DATA_DIR = testDataDir; +process.env.OVERLORD_TEST_DATA_DIR = "1"; + +// A persistent config in DATA_DIR prevents the loader from falling back to the +// repository's legacy config.json, which may contain real deployment settings. +writeFileSync(join(testDataDir, "config.json"), "{}\n", "utf8");