AI made playwright tests + add them to the github.
This commit is contained in:
@@ -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