AI made playwright tests + add them to the github.
This commit is contained in:
@@ -28,6 +28,7 @@ jobs:
|
|||||||
filters: |
|
filters: |
|
||||||
server:
|
server:
|
||||||
- 'Overlord-Server/**'
|
- 'Overlord-Server/**'
|
||||||
|
- '.github/workflows/tests.yml'
|
||||||
client:
|
client:
|
||||||
- 'Overlord-Client/**'
|
- 'Overlord-Client/**'
|
||||||
- 'go.work'
|
- 'go.work'
|
||||||
@@ -57,7 +58,32 @@ jobs:
|
|||||||
run: bun install --frozen-lockfile
|
run: bun install --frozen-lockfile
|
||||||
|
|
||||||
- name: Run server tests
|
- 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:
|
client-tests:
|
||||||
needs: changes
|
needs: changes
|
||||||
|
|||||||
@@ -75,6 +75,8 @@ plugins/extension/*
|
|||||||
|
|
||||||
# Runtime data
|
# Runtime data
|
||||||
Overlord-Server/data/
|
Overlord-Server/data/
|
||||||
|
Overlord-Server/test-results/
|
||||||
|
Overlord-Server/playwright-report/
|
||||||
|
|
||||||
# Release packaging output
|
# Release packaging output
|
||||||
release/
|
release/
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
"@fontsource/inter": "^5.2.8",
|
"@fontsource/inter": "^5.2.8",
|
||||||
"@fontsource/jetbrains-mono": "^5.2.8",
|
"@fontsource/jetbrains-mono": "^5.2.8",
|
||||||
"@fortawesome/fontawesome-free": "6.5.2",
|
"@fortawesome/fontawesome-free": "6.5.2",
|
||||||
|
"@playwright/test": "^1.61.1",
|
||||||
"@tailwindcss/cli": "^4.1.18",
|
"@tailwindcss/cli": "^4.1.18",
|
||||||
"@types/bun": "^1.3.9",
|
"@types/bun": "^1.3.9",
|
||||||
"@types/geoip-lite": "latest",
|
"@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=="],
|
"@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=="],
|
"@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=="],
|
"@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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
[test]
|
||||||
|
preload = ["./test/preload.ts"]
|
||||||
@@ -4,6 +4,9 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"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",
|
"dev": "bun --watch src/index.ts",
|
||||||
"start": "bun run build && bun dist/index.js",
|
"start": "bun run build && bun dist/index.js",
|
||||||
"build": "bun run build:css && bun run vendor && bun run build:bundle",
|
"build": "bun run build:css && bun run vendor && bun run build:bundle",
|
||||||
@@ -40,6 +43,7 @@
|
|||||||
"@fontsource/inter": "^5.2.8",
|
"@fontsource/inter": "^5.2.8",
|
||||||
"@fontsource/jetbrains-mono": "^5.2.8",
|
"@fontsource/jetbrains-mono": "^5.2.8",
|
||||||
"@fortawesome/fontawesome-free": "6.5.2",
|
"@fortawesome/fontawesome-free": "6.5.2",
|
||||||
|
"@playwright/test": "^1.61.1",
|
||||||
"@tailwindcss/cli": "^4.1.18",
|
"@tailwindcss/cli": "^4.1.18",
|
||||||
"@types/bun": "^1.3.9",
|
"@types/bun": "^1.3.9",
|
||||||
"@types/geoip-lite": "latest",
|
"@types/geoip-lite": "latest",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -268,10 +268,6 @@ html {
|
|||||||
scrollbar-gutter: stable;
|
scrollbar-gutter: stable;
|
||||||
}
|
}
|
||||||
|
|
||||||
@view-transition {
|
|
||||||
navigation: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
@@ -4342,6 +4338,9 @@ body.sb-collapsed .sb-collapse-btn i {
|
|||||||
position: relative;
|
position: relative;
|
||||||
transition: background 0.14s, color 0.14s;
|
transition: background 0.14s, color 0.14s;
|
||||||
}
|
}
|
||||||
|
.sb-link.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
.sb-link:hover {
|
.sb-link:hover {
|
||||||
background: rgba(255, 255, 255, 0.06);
|
background: rgba(255, 255, 255, 0.06);
|
||||||
color: #e2e8f0;
|
color: #e2e8f0;
|
||||||
@@ -4826,6 +4825,9 @@ body.topbar-compact #role-badge {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||||
}
|
}
|
||||||
|
.nav-dd-group-btn.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
.nav-dd-group-btn:hover {
|
.nav-dd-group-btn:hover {
|
||||||
background: rgba(51, 65, 85, 0.7);
|
background: rgba(51, 65, 85, 0.7);
|
||||||
color: #e2e8f0;
|
color: #e2e8f0;
|
||||||
|
|||||||
@@ -190,16 +190,20 @@ if (host) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
applyAdaptiveNavLayout();
|
applyAdaptiveNavLayout();
|
||||||
|
return user;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to load user:", err);
|
console.error("Failed to load user:", err);
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let currentUserPromise = Promise.resolve(null);
|
||||||
if (refs.usernameDisplay && refs.roleBadge) {
|
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(() => {
|
runWithoutPageTracking(() => {
|
||||||
chatWidget.init();
|
chatWidget.init();
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -1,12 +1,42 @@
|
|||||||
import path from "path";
|
import path from "path";
|
||||||
import { existsSync, mkdirSync } from "fs";
|
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 {
|
export function resolveDataDir(): string {
|
||||||
const envDir = process.env.DATA_DIR;
|
const envDir = process.env.DATA_DIR;
|
||||||
if (envDir && envDir.trim()) {
|
if (envDir && envDir.trim()) {
|
||||||
|
assertSafeTestDataDir(envDir);
|
||||||
return 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) {
|
if (process.platform === "win32" && process.env.APPDATA) {
|
||||||
return path.join(process.env.APPDATA, "Overlord");
|
return path.join(process.env.APPDATA, "Overlord");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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([]);
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
@@ -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<string> {
|
||||||
|
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<Response> {
|
||||||
|
return fetch(`${BASE_URL}${path}`, {
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Cookie: cookie,
|
||||||
|
...init.headers,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedBrowserUsers(): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
if (child.exitCode !== null) return;
|
||||||
|
child.kill("SIGTERM");
|
||||||
|
await Promise.race([
|
||||||
|
new Promise<void>((resolveExit) => child.once("exit", () => resolveExit())),
|
||||||
|
new Promise<void>((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 });
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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<void> {
|
||||||
|
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<void> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
Reference in New Issue
Block a user