mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
Closes the "e2e never runs in CI" hole. Adds per-PR e2e smoke gate, nightly full-suite workflows, parallel vitest forks (per-fork DBs), Playwright parallel/serial/visual projects against production builds, metadata-generated test suites (drift guards, hostile inputs, format matrix, pairwise settings, property-based fuzz), Stryker mutation testing, Schemathesis API fuzz, coverage ratchet, and fixes for three session-poisoning bugs that caused 200+ serial-bucket failures. Bug fix included: favicon/split/bulk-rename could hang clients forever when ZIP streaming failed after reply.hijack().
922 lines
37 KiB
TypeScript
922 lines
37 KiB
TypeScript
import { changePasswordViaApi, expect, login, openSettings, putSettings, test } from "./helpers";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Settings Dialog -- General, System Settings, About tabs
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test.describe("GUI Settings - Dialog Navigation", () => {
|
|
test("opens settings dialog from sidebar", async ({ loggedInPage: page }) => {
|
|
await openSettings(page);
|
|
|
|
// Dialog sidebar should render with the Settings heading
|
|
await expect(page.locator("h2").filter({ hasText: "Settings" })).toBeVisible();
|
|
});
|
|
|
|
test("dialog has correct dimensions (85dvh, max-w-3xl)", async ({ loggedInPage: page }) => {
|
|
await openSettings(page);
|
|
|
|
const dialog = page.locator(".relative.bg-background.border.border-border.rounded-xl");
|
|
await expect(dialog).toBeVisible();
|
|
|
|
// Verify the dialog uses the expected sizing classes
|
|
await expect(dialog).toHaveClass(/max-w-3xl/);
|
|
await expect(dialog).toHaveClass(/h-\[85dvh\]/);
|
|
});
|
|
|
|
test("dialog sidebar lists navigable section tabs", async ({ loggedInPage: page }) => {
|
|
await openSettings(page);
|
|
|
|
// All section tabs should be buttons in the dialog sidebar
|
|
for (const label of [
|
|
"General",
|
|
"System Settings",
|
|
"Security",
|
|
"People",
|
|
"Teams",
|
|
"API Keys",
|
|
"Tools",
|
|
"About",
|
|
]) {
|
|
await expect(page.getByRole("button", { name: new RegExp(label, "i") })).toBeVisible();
|
|
}
|
|
});
|
|
|
|
test("clicking a tab switches the content pane", async ({ loggedInPage: page }) => {
|
|
await openSettings(page);
|
|
|
|
// Navigate to About section and confirm its heading
|
|
await page.getByRole("button", { name: /about/i }).click();
|
|
await expect(page.locator("h3").filter({ hasText: "About" })).toBeVisible();
|
|
|
|
// Navigate back to General and confirm its heading
|
|
await page.getByRole("button", { name: /general/i }).click();
|
|
await expect(page.locator("h3").filter({ hasText: "General" })).toBeVisible();
|
|
});
|
|
|
|
test("active tab is visually highlighted", async ({ loggedInPage: page }) => {
|
|
await openSettings(page);
|
|
|
|
// The General tab should be active by default and have the primary color class
|
|
const generalBtn = page.getByRole("button", { name: /general/i });
|
|
await expect(generalBtn).toHaveClass(/bg-primary/);
|
|
|
|
// Navigate to Security -- it should become highlighted and General should not
|
|
await page.getByRole("button", { name: /security/i }).click();
|
|
const securityBtn = page.getByRole("button", { name: /security/i });
|
|
await expect(securityBtn).toHaveClass(/bg-primary/);
|
|
await expect(generalBtn).not.toHaveClass(/bg-primary/);
|
|
});
|
|
|
|
test("re-opening dialog defaults to General tab", async ({ loggedInPage: page }) => {
|
|
await openSettings(page);
|
|
|
|
// Navigate to About
|
|
await page.getByRole("button", { name: /about/i }).click();
|
|
await expect(page.locator("h3").filter({ hasText: "About" })).toBeVisible();
|
|
|
|
// Close dialog
|
|
await page.keyboard.press("Escape");
|
|
await expect(page.locator("h2").filter({ hasText: "Settings" })).not.toBeVisible();
|
|
|
|
// Re-open -- should be back on General (component state resets)
|
|
await openSettings(page);
|
|
await expect(page.locator("h3").filter({ hasText: "General" })).toBeVisible();
|
|
});
|
|
|
|
test("closes dialog via the X button", async ({ loggedInPage: page }) => {
|
|
await openSettings(page);
|
|
|
|
// The close button renders the X icon inside the dialog
|
|
const closeBtn = page.locator("button").filter({ has: page.locator("svg.lucide-x") });
|
|
await closeBtn.click();
|
|
|
|
// Dialog content should disappear
|
|
await expect(page.locator("h2").filter({ hasText: "Settings" })).not.toBeVisible();
|
|
});
|
|
|
|
test("closes dialog via Escape key", async ({ loggedInPage: page }) => {
|
|
await openSettings(page);
|
|
|
|
await page.keyboard.press("Escape");
|
|
|
|
await expect(page.locator("h2").filter({ hasText: "Settings" })).not.toBeVisible();
|
|
});
|
|
|
|
test("closes dialog by clicking the backdrop", async ({ loggedInPage: page }) => {
|
|
await openSettings(page);
|
|
|
|
// Click the backdrop overlay (the semi-transparent div behind the dialog)
|
|
await page.locator(".bg-black\\/50").click({ position: { x: 10, y: 10 } });
|
|
|
|
await expect(page.locator("h2").filter({ hasText: "Settings" })).not.toBeVisible();
|
|
});
|
|
|
|
test("settings icon is visible in the sidebar", async ({ loggedInPage: page }) => {
|
|
// The sidebar should have a Settings entry
|
|
const sidebar = page.locator("aside");
|
|
await expect(sidebar.getByText("Settings")).toBeVisible();
|
|
});
|
|
});
|
|
|
|
test.describe("GUI Settings - General Tab", () => {
|
|
test("displays username and role badge", async ({ loggedInPage: page }) => {
|
|
await openSettings(page);
|
|
|
|
// General section is the default; username and role should be visible
|
|
await expect(page.getByText("admin").first()).toBeVisible();
|
|
// The role is displayed as a capitalize text below the username
|
|
await expect(page.getByText(/admin/i).first()).toBeVisible();
|
|
});
|
|
|
|
test("displays avatar with initial letter", async ({ loggedInPage: page }) => {
|
|
await openSettings(page);
|
|
|
|
// Avatar circle shows the first letter of the username (uppercase A for admin)
|
|
const avatar = page.locator(".w-10.h-10.rounded-full");
|
|
await expect(avatar).toBeVisible();
|
|
await expect(avatar).toContainText("A");
|
|
});
|
|
|
|
test("shows user preferences description text", async ({ loggedInPage: page }) => {
|
|
await openSettings(page);
|
|
|
|
await expect(page.getByText("User preferences and display settings.")).toBeVisible();
|
|
});
|
|
|
|
test("shows Default Tool View dropdown with options", async ({ loggedInPage: page }) => {
|
|
await openSettings(page);
|
|
|
|
await expect(page.getByText("Default Tool View")).toBeVisible();
|
|
const select = page.locator("select").first();
|
|
await expect(select).toBeVisible();
|
|
|
|
// Verify the two options
|
|
await expect(select.locator("option[value='sidebar']")).toHaveText("Sidebar");
|
|
await expect(select.locator("option[value='fullscreen']")).toHaveText("Fullscreen Grid");
|
|
});
|
|
|
|
test("changing Default Tool View and saving persists the value", async ({
|
|
loggedInPage: page,
|
|
}) => {
|
|
await openSettings(page);
|
|
|
|
const select = page.locator("select").first();
|
|
const originalValue = await select.inputValue();
|
|
|
|
// Switch to the other option
|
|
const newValue = originalValue === "sidebar" ? "fullscreen" : "sidebar";
|
|
await select.selectOption(newValue);
|
|
|
|
await page.getByRole("button", { name: /save settings/i }).click();
|
|
await expect(page.getByText("Settings saved.")).toBeVisible({ timeout: 5_000 });
|
|
|
|
// Close and reopen to verify persistence
|
|
await page.keyboard.press("Escape");
|
|
await openSettings(page);
|
|
|
|
const updatedValue = await page.locator("select").first().inputValue();
|
|
expect(updatedValue).toBe(newValue);
|
|
|
|
// Restore original value
|
|
await page.locator("select").first().selectOption(originalValue);
|
|
await page.getByRole("button", { name: /save settings/i }).click();
|
|
await expect(page.getByText("Settings saved.")).toBeVisible({ timeout: 5_000 });
|
|
});
|
|
|
|
test("setting Fullscreen Grid and saving redirects home to /fullscreen", async ({
|
|
loggedInPage: page,
|
|
}) => {
|
|
await openSettings(page);
|
|
|
|
const select = page.locator("select").first();
|
|
const originalValue = await select.inputValue();
|
|
|
|
// Set to fullscreen
|
|
await select.selectOption("fullscreen");
|
|
await page.getByRole("button", { name: /save settings/i }).click();
|
|
await expect(page.getByText("Settings saved.")).toBeVisible({ timeout: 5_000 });
|
|
|
|
// Close settings and navigate home
|
|
await page.keyboard.press("Escape");
|
|
await page.goto("/");
|
|
|
|
// Should redirect to /fullscreen
|
|
await page.waitForURL(/\/fullscreen/, { timeout: 10_000 });
|
|
expect(page.url()).toContain("/fullscreen");
|
|
|
|
// Restore via API: the fullscreen layout has no reachable settings entry,
|
|
// and an unrestored value poisons every later test on the shared server.
|
|
const restore = await putSettings(page, { defaultToolView: originalValue });
|
|
expect(restore.ok).toBeTruthy();
|
|
});
|
|
|
|
test("shows App Version string", async ({ loggedInPage: page }) => {
|
|
await openSettings(page);
|
|
|
|
await expect(page.getByText("App Version")).toBeVisible();
|
|
// Version is in a monospace span matching semver pattern
|
|
await expect(page.locator(".font-mono").filter({ hasText: /^\d+\.\d+\.\d+/ })).toBeVisible();
|
|
});
|
|
|
|
test("has a Save Settings button", async ({ loggedInPage: page }) => {
|
|
await openSettings(page);
|
|
|
|
await expect(page.getByRole("button", { name: /save settings/i })).toBeVisible();
|
|
});
|
|
|
|
test("logout button redirects to /login", async ({ browser }) => {
|
|
// Isolated session: logging out revokes the token server-side, and the
|
|
// shared storageState token must survive for every later test in the run.
|
|
const context = await browser.newContext({ storageState: undefined });
|
|
const page = await context.newPage();
|
|
await login(page);
|
|
await openSettings(page);
|
|
|
|
const logoutBtn = page.getByRole("button", { name: /log out/i });
|
|
await expect(logoutBtn).toBeVisible();
|
|
|
|
await logoutBtn.click();
|
|
await page.waitForURL("/login", { timeout: 10_000 });
|
|
|
|
// Should be on the login page
|
|
expect(page.url()).toContain("/login");
|
|
await context.close();
|
|
});
|
|
});
|
|
|
|
test.describe("GUI Settings - System Settings Tab", () => {
|
|
test("shows section heading and description", async ({ loggedInPage: page }) => {
|
|
await openSettings(page);
|
|
await page.getByRole("button", { name: /system settings/i }).click();
|
|
|
|
await expect(page.locator("h3").filter({ hasText: "System Settings" })).toBeVisible();
|
|
await expect(page.getByText("Server-side configuration and limits.")).toBeVisible();
|
|
});
|
|
|
|
test("shows File Upload Limit input", async ({ loggedInPage: page }) => {
|
|
await openSettings(page);
|
|
await page.getByRole("button", { name: /system settings/i }).click();
|
|
|
|
await expect(page.getByText("File Upload Limit (MB)")).toBeVisible();
|
|
await expect(page.getByText("Maximum file size per upload")).toBeVisible();
|
|
await expect(page.locator("input[type='number']").first()).toBeVisible();
|
|
});
|
|
|
|
test("shows Default Theme dropdown", async ({ loggedInPage: page }) => {
|
|
await openSettings(page);
|
|
await page.getByRole("button", { name: /system settings/i }).click();
|
|
|
|
await expect(page.getByText("Default Theme")).toBeVisible();
|
|
const themeSelect = page
|
|
.locator("select")
|
|
.filter({ has: page.locator("option[value='dark']") });
|
|
await expect(themeSelect).toBeVisible();
|
|
|
|
// Verify theme options
|
|
await expect(themeSelect.locator("option[value='light']")).toHaveText("Light");
|
|
await expect(themeSelect.locator("option[value='dark']")).toHaveText("Dark");
|
|
await expect(themeSelect.locator("option[value='system']")).toHaveText("System");
|
|
});
|
|
|
|
test("shows Language dropdown", async ({ loggedInPage: page }) => {
|
|
await openSettings(page);
|
|
await page.getByRole("button", { name: /system settings/i }).click();
|
|
|
|
await expect(page.getByText("Language", { exact: true })).toBeVisible();
|
|
await expect(page.getByText("Language for the interface")).toBeVisible();
|
|
const langSelect = page.locator("select").filter({ has: page.locator("option[value='en']") });
|
|
await expect(langSelect).toBeVisible();
|
|
await expect(langSelect.locator("option[value='en']")).toHaveText("English");
|
|
});
|
|
|
|
test("shows Login Attempt Limit input", async ({ loggedInPage: page }) => {
|
|
await openSettings(page);
|
|
await page.getByRole("button", { name: /system settings/i }).click();
|
|
|
|
await expect(page.getByText("Login Attempt Limit")).toBeVisible();
|
|
await expect(
|
|
page.getByText("Max failed login attempts per minute before lockout"),
|
|
).toBeVisible();
|
|
});
|
|
|
|
test("shows File Management section with Max File Age and Startup Cleanup", async ({
|
|
loggedInPage: page,
|
|
}) => {
|
|
await openSettings(page);
|
|
await page.getByRole("button", { name: /system settings/i }).click();
|
|
|
|
await expect(page.getByText("File Management")).toBeVisible();
|
|
await expect(page.getByText("Max File Age (hours)")).toBeVisible();
|
|
await expect(page.getByText("Startup Cleanup")).toBeVisible();
|
|
await expect(
|
|
page.getByText("Clean up old temporary files when the server starts"),
|
|
).toBeVisible();
|
|
});
|
|
|
|
test("Startup Cleanup toggle can be toggled", async ({ loggedInPage: page }) => {
|
|
await openSettings(page);
|
|
await page.getByRole("button", { name: /system settings/i }).click();
|
|
|
|
// Wait for settings to load
|
|
await expect(page.getByText("Startup Cleanup")).toBeVisible();
|
|
|
|
// The toggle is a button.rounded-full near "Startup Cleanup"
|
|
const toggleRow = page.locator("div").filter({ hasText: "Startup Cleanup" }).last();
|
|
const toggle = toggleRow.locator("button.rounded-full");
|
|
await expect(toggle).toBeVisible();
|
|
|
|
// Click to toggle
|
|
await toggle.click();
|
|
// Toggle state should change (the class alternates between bg-primary and bg-muted)
|
|
// Just verify the toggle is still clickable (no crash)
|
|
await toggle.click();
|
|
});
|
|
|
|
test("Save Settings button persists changes", async ({ loggedInPage: page }) => {
|
|
await openSettings(page);
|
|
await page.getByRole("button", { name: /system settings/i }).click();
|
|
|
|
// Wait for section to load
|
|
await expect(page.getByText("File Upload Limit (MB)")).toBeVisible();
|
|
|
|
// Click save
|
|
await page.getByRole("button", { name: /save settings/i }).click();
|
|
|
|
// Should show a success message
|
|
await expect(page.getByText("Settings saved.")).toBeVisible({ timeout: 5_000 });
|
|
});
|
|
|
|
test("changed File Upload Limit persists after dialog re-open", async ({
|
|
loggedInPage: page,
|
|
}) => {
|
|
await openSettings(page);
|
|
await page.getByRole("button", { name: /system settings/i }).click();
|
|
await expect(page.getByText("File Upload Limit (MB)")).toBeVisible();
|
|
|
|
const uploadInput = page.locator("input[type='number']").first();
|
|
const originalValue = await uploadInput.inputValue();
|
|
|
|
// Change to a different value
|
|
const testValue = originalValue === "100" ? "200" : "100";
|
|
await uploadInput.fill(testValue);
|
|
|
|
await page.getByRole("button", { name: /save settings/i }).click();
|
|
await expect(page.getByText("Settings saved.")).toBeVisible({ timeout: 5_000 });
|
|
|
|
// Close and re-open
|
|
await page.keyboard.press("Escape");
|
|
await openSettings(page);
|
|
await page.getByRole("button", { name: /system settings/i }).click();
|
|
await expect(page.getByText("File Upload Limit (MB)")).toBeVisible();
|
|
|
|
const persistedValue = await page.locator("input[type='number']").first().inputValue();
|
|
expect(persistedValue).toBe(testValue);
|
|
|
|
// Restore original value
|
|
await page.locator("input[type='number']").first().fill(originalValue);
|
|
await page.getByRole("button", { name: /save settings/i }).click();
|
|
await expect(page.getByText("Settings saved.")).toBeVisible({ timeout: 5_000 });
|
|
});
|
|
|
|
test("changed Default Theme persists after dialog re-open", async ({ loggedInPage: page }) => {
|
|
await openSettings(page);
|
|
await page.getByRole("button", { name: /system settings/i }).click();
|
|
await expect(page.getByText("Default Theme")).toBeVisible();
|
|
|
|
const themeSelect = page
|
|
.locator("select")
|
|
.filter({ has: page.locator("option[value='dark']") });
|
|
const originalValue = await themeSelect.inputValue();
|
|
|
|
// Switch theme
|
|
const newValue = originalValue === "dark" ? "light" : "dark";
|
|
await themeSelect.selectOption(newValue);
|
|
|
|
await page.getByRole("button", { name: /save settings/i }).click();
|
|
await expect(page.getByText("Settings saved.")).toBeVisible({ timeout: 5_000 });
|
|
|
|
// Close and re-open
|
|
await page.keyboard.press("Escape");
|
|
await openSettings(page);
|
|
await page.getByRole("button", { name: /system settings/i }).click();
|
|
await expect(page.getByText("Default Theme")).toBeVisible();
|
|
|
|
const themeSelect2 = page
|
|
.locator("select")
|
|
.filter({ has: page.locator("option[value='dark']") });
|
|
const persisted = await themeSelect2.inputValue();
|
|
expect(persisted).toBe(newValue);
|
|
|
|
// Restore original
|
|
await themeSelect2.selectOption(originalValue);
|
|
await page.getByRole("button", { name: /save settings/i }).click();
|
|
await expect(page.getByText("Settings saved.")).toBeVisible({ timeout: 5_000 });
|
|
});
|
|
});
|
|
|
|
test.describe("GUI Settings - About Tab", () => {
|
|
test("displays app description and version", async ({ loggedInPage: page }) => {
|
|
await openSettings(page);
|
|
await page.getByRole("button", { name: /about/i }).click();
|
|
|
|
await expect(page.locator("h3").filter({ hasText: "About" })).toBeVisible();
|
|
// SnapOtter branding
|
|
await expect(page.getByText("SnapOtter").first()).toBeVisible();
|
|
// Description text
|
|
await expect(page.getByText(/self-hosted.*privacy/i).first()).toBeVisible();
|
|
// Version label and value
|
|
await expect(page.getByText("Version:")).toBeVisible();
|
|
});
|
|
|
|
test("shows GitHub and documentation links", async ({ loggedInPage: page }) => {
|
|
await openSettings(page);
|
|
await page.getByRole("button", { name: /about/i }).click();
|
|
|
|
await expect(page.getByRole("link", { name: /github repository/i })).toBeVisible();
|
|
await expect(page.getByRole("link", { name: /documentation/i })).toBeVisible();
|
|
});
|
|
|
|
test("shows API Reference (Swagger) link", async ({ loggedInPage: page }) => {
|
|
await openSettings(page);
|
|
await page.getByRole("button", { name: /about/i }).click();
|
|
|
|
await expect(page.getByRole("link", { name: /api reference/i })).toBeVisible();
|
|
});
|
|
|
|
test("version displays a semver string", async ({ loggedInPage: page }) => {
|
|
await openSettings(page);
|
|
await page.getByRole("button", { name: /about/i }).click();
|
|
|
|
// Version text should match semver format (e.g., 1.2.3)
|
|
const versionEl = page.locator(".font-mono").filter({ hasText: /^\d+\.\d+\.\d+/ });
|
|
await expect(versionEl).toBeVisible();
|
|
});
|
|
|
|
test("about section has the Links heading", async ({ loggedInPage: page }) => {
|
|
await openSettings(page);
|
|
await page.getByRole("button", { name: /about/i }).click();
|
|
|
|
await expect(page.getByText("Links")).toBeVisible();
|
|
});
|
|
});
|
|
|
|
test.describe("GUI Settings - AI Features Tab", () => {
|
|
test("displays AI Features heading and description", async ({ loggedInPage: page }) => {
|
|
await openSettings(page);
|
|
await page.getByRole("button", { name: /ai features/i }).click();
|
|
|
|
await expect(page.locator("h3").filter({ hasText: "AI Features" })).toBeVisible();
|
|
await expect(page.getByText("Manage AI model bundles")).toBeVisible();
|
|
});
|
|
|
|
test("shows Install All button", async ({ loggedInPage: page }) => {
|
|
await openSettings(page);
|
|
await page.getByRole("button", { name: /ai features/i }).click();
|
|
|
|
await expect(page.getByRole("button", { name: /install all/i })).toBeVisible();
|
|
});
|
|
|
|
test("lists feature bundles with install status", async ({ loggedInPage: page }) => {
|
|
await openSettings(page);
|
|
await page.getByRole("button", { name: /ai features/i }).click();
|
|
|
|
// Each bundle card is rendered within a bordered div
|
|
const bundleCards = page.locator(".rounded-lg.border.border-border.p-4");
|
|
|
|
// At least one bundle should be present
|
|
const count = await bundleCards.count();
|
|
expect(count).toBeGreaterThan(0);
|
|
|
|
// Each card should have a name and a status indicator (Installed or Not installed or Install button)
|
|
const firstCard = bundleCards.first();
|
|
const hasStatus =
|
|
(await firstCard
|
|
.getByText("Installed")
|
|
.isVisible()
|
|
.catch(() => false)) ||
|
|
(await firstCard
|
|
.getByText("Not installed")
|
|
.isVisible()
|
|
.catch(() => false)) ||
|
|
(await firstCard
|
|
.getByRole("button", { name: /install/i })
|
|
.isVisible()
|
|
.catch(() => false));
|
|
expect(hasStatus).toBe(true);
|
|
});
|
|
|
|
test("bundles show estimated size", async ({ loggedInPage: page }) => {
|
|
await openSettings(page);
|
|
await page.getByRole("button", { name: /ai features/i }).click();
|
|
|
|
// Each bundle should display an estimated size like (~XXX MB)
|
|
await expect(page.getByText(/~\d+/).first()).toBeVisible();
|
|
});
|
|
});
|
|
|
|
test.describe("GUI Settings - Tools Tab (deep)", () => {
|
|
test("all tools are listed with enable/disable toggles", async ({ loggedInPage: page }) => {
|
|
await openSettings(page);
|
|
await page.getByRole("button", { name: /tools/i }).click();
|
|
|
|
await expect(page.locator("h3").filter({ hasText: "Tools" }).first()).toBeVisible();
|
|
// Wait for tools to finish loading (disabled counter appears when loaded)
|
|
await expect(page.getByText(/\d+ tools? disabled/)).toBeVisible({ timeout: 5_000 });
|
|
|
|
// Each tool row has an enable/disable toggle (w-11 h-6 rounded-full)
|
|
const toolToggles = page.locator("button.w-11.h-6");
|
|
const count = await toolToggles.count();
|
|
// Should have many tools (SnapOtter has 48)
|
|
expect(count).toBeGreaterThan(10);
|
|
});
|
|
|
|
test("toggling a tool changes the disabled counter", async ({ loggedInPage: page }) => {
|
|
await openSettings(page);
|
|
await page.getByRole("button", { name: /tools/i }).click();
|
|
|
|
// Read initial disabled count
|
|
const counterText = page.getByText(/\d+ tools? disabled/);
|
|
await expect(counterText).toBeVisible({ timeout: 5_000 });
|
|
const initialText = await counterText.textContent();
|
|
const initialCount = parseInt(initialText?.match(/(\d+)/)?.[1] || "0", 10);
|
|
|
|
// Click the first tool toggle to change its state
|
|
const firstToggle = page.locator("button.w-11.h-6").first();
|
|
await firstToggle.click();
|
|
|
|
// The counter should change by 1 (either +1 or -1)
|
|
const updatedText = await counterText.textContent();
|
|
const updatedCount = parseInt(updatedText?.match(/(\d+)/)?.[1] || "0", 10);
|
|
expect(Math.abs(updatedCount - initialCount)).toBe(1);
|
|
|
|
// Revert the toggle to not affect other tests
|
|
await firstToggle.click();
|
|
});
|
|
|
|
test("search filters tools in settings dialog", async ({ loggedInPage: page }) => {
|
|
await openSettings(page);
|
|
await page.getByRole("button", { name: /tools/i }).click();
|
|
|
|
// Wait for the tools to load
|
|
await expect(page.getByText(/\d+ tools? disabled/)).toBeVisible({ timeout: 5_000 });
|
|
|
|
// The Settings dialog content area has the search input
|
|
const dialogContent = page.locator(".flex-1.overflow-y-auto");
|
|
const searchInput = dialogContent.getByPlaceholder("Search tools...");
|
|
await expect(searchInput).toBeVisible();
|
|
|
|
// Search for a tool name that likely exists
|
|
await searchInput.fill("Resize");
|
|
|
|
// Should show a filtered subset; the "Resize" tool should be visible
|
|
await expect(dialogContent.getByText("Resize").first()).toBeVisible();
|
|
|
|
// Search for something that does not exist
|
|
await searchInput.fill("zzzznonexistenttool");
|
|
await expect(dialogContent.getByText("No tools match your search.")).toBeVisible();
|
|
|
|
// Clear search restores all tools
|
|
await searchInput.fill("");
|
|
const toolToggles = page.locator("button.w-11.h-6");
|
|
const count = await toolToggles.count();
|
|
expect(count).toBeGreaterThan(10);
|
|
});
|
|
});
|
|
|
|
test.describe("GUI Settings - Product Analytics Tab (deep)", () => {
|
|
test("displays description about anonymous data and image privacy", async ({
|
|
loggedInPage: page,
|
|
}) => {
|
|
await openSettings(page);
|
|
await page.getByRole("button", { name: /product analytics/i }).click();
|
|
|
|
await expect(page.getByText(/share anonymous usage data/i)).toBeVisible();
|
|
await expect(page.getByText(/images never leave your network/i)).toBeVisible();
|
|
});
|
|
|
|
test("shows either consent toggle or admin-disabled message", async ({ loggedInPage: page }) => {
|
|
await openSettings(page);
|
|
await page.getByRole("button", { name: /product analytics/i }).click();
|
|
|
|
// Either the toggle button is present, or the admin-disabled message is shown
|
|
const toggle = page.locator("button.rounded-full");
|
|
const disabledMsg = page.getByText(/has been disabled by the server administrator/i);
|
|
|
|
const toggleVisible = await toggle.isVisible().catch(() => false);
|
|
const disabledVisible = await disabledMsg.isVisible().catch(() => false);
|
|
|
|
// One of the two states must be true
|
|
expect(toggleVisible || disabledVisible).toBe(true);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Audit Log Tab (12.8) -- admin-only
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test.describe("GUI Settings - Audit Log Tab", () => {
|
|
test("displays Audit Log heading and filter dropdown", async ({ loggedInPage: page }) => {
|
|
await openSettings(page);
|
|
await page.getByRole("button", { name: /audit log/i }).click();
|
|
|
|
await expect(page.locator("h3").filter({ hasText: "Audit Log" })).toBeVisible();
|
|
// Filter dropdown with "All actions" default
|
|
const filterSelect = page.locator("select").filter({ has: page.locator("option[value='']") });
|
|
await expect(filterSelect).toBeVisible();
|
|
await expect(filterSelect.locator("option[value='']")).toHaveText("All actions");
|
|
});
|
|
|
|
test("audit table shows Time, User, Action, Target columns", async ({ loggedInPage: page }) => {
|
|
await openSettings(page);
|
|
await page.getByRole("button", { name: /audit log/i }).click();
|
|
|
|
// Wait for table to render
|
|
await expect(page.locator("table thead")).toBeVisible({ timeout: 10_000 });
|
|
|
|
await expect(page.locator("table thead th").filter({ hasText: "Time" })).toBeVisible();
|
|
await expect(page.locator("table thead th").filter({ hasText: "User" })).toBeVisible();
|
|
await expect(page.locator("table thead th").filter({ hasText: "Action" })).toBeVisible();
|
|
await expect(page.locator("table thead th").filter({ hasText: "Target" })).toBeVisible();
|
|
});
|
|
|
|
test("LOGIN_SUCCESS entries are present after admin login", async ({ loggedInPage: page }) => {
|
|
await openSettings(page);
|
|
await page.getByRole("button", { name: /audit log/i }).click();
|
|
|
|
// Wait for table to load
|
|
await expect(page.locator("table tbody tr").first()).toBeVisible({ timeout: 10_000 });
|
|
|
|
// Filter by LOGIN_SUCCESS
|
|
const filterSelect = page.locator("select").first();
|
|
await filterSelect.selectOption("LOGIN_SUCCESS");
|
|
|
|
// Should display at least one LOGIN_SUCCESS row
|
|
await expect(page.locator("table tbody tr").first()).toBeVisible({ timeout: 10_000 });
|
|
const tableText = await page.locator("table tbody").textContent();
|
|
expect(tableText).toContain("LOGIN_SUCCESS");
|
|
});
|
|
|
|
test("filter dropdown includes all expected audit action types", async ({
|
|
loggedInPage: page,
|
|
}) => {
|
|
await openSettings(page);
|
|
await page.getByRole("button", { name: /audit log/i }).click();
|
|
|
|
const filterSelect = page.locator("select").first();
|
|
await expect(filterSelect).toBeVisible();
|
|
|
|
// Verify key action options exist
|
|
for (const action of [
|
|
"LOGIN_SUCCESS",
|
|
"LOGIN_FAILED",
|
|
"USER_CREATED",
|
|
"USER_DELETED",
|
|
"PASSWORD_CHANGED",
|
|
"API_KEY_CREATED",
|
|
"SETTINGS_UPDATED",
|
|
]) {
|
|
await expect(filterSelect.locator(`option[value='${action}']`)).toBeAttached();
|
|
}
|
|
});
|
|
|
|
test("clicking a row expands details", async ({ loggedInPage: page }) => {
|
|
await openSettings(page);
|
|
await page.getByRole("button", { name: /audit log/i }).click();
|
|
|
|
// Wait for at least one row
|
|
await expect(page.locator("table tbody tr").first()).toBeVisible({ timeout: 10_000 });
|
|
|
|
// Click the first row to expand
|
|
await page.locator("table tbody tr").first().click();
|
|
|
|
// If the entry has details, a <pre> block with JSON appears
|
|
// (not all entries have details, so we just verify the click does not crash)
|
|
await page.waitForTimeout(300);
|
|
});
|
|
|
|
test("empty audit log shows 'No audit log entries' message", async ({ loggedInPage: page }) => {
|
|
await openSettings(page);
|
|
await page.getByRole("button", { name: /audit log/i }).click();
|
|
|
|
// Filter by an action that may not have entries (ROLE_DELETED)
|
|
const filterSelect = page.locator("select").first();
|
|
await filterSelect.selectOption("ROLE_DELETED");
|
|
|
|
// Wait for the table to update
|
|
await page.waitForTimeout(1_000);
|
|
|
|
// Either we see entries or the empty state
|
|
const emptyMsg = page.getByText("No audit log entries.");
|
|
const hasRows = await page.locator("table tbody tr").count();
|
|
const emptyVisible = await emptyMsg.isVisible().catch(() => false);
|
|
|
|
// One must be true -- either rows exist or the empty message shows
|
|
expect(hasRows > 0 || emptyVisible).toBe(true);
|
|
});
|
|
|
|
test("SETTINGS_UPDATED entry appears after saving system settings", async ({
|
|
loggedInPage: page,
|
|
}) => {
|
|
// Save system settings to generate a SETTINGS_UPDATED audit entry
|
|
await openSettings(page);
|
|
await page.getByRole("button", { name: /system settings/i }).click();
|
|
await expect(page.getByText("File Upload Limit (MB)")).toBeVisible();
|
|
await page.getByRole("button", { name: /save settings/i }).click();
|
|
await expect(page.getByText("Settings saved.")).toBeVisible({ timeout: 5_000 });
|
|
|
|
// Navigate to audit log and filter by SETTINGS_UPDATED
|
|
await page.getByRole("button", { name: /audit log/i }).click();
|
|
await expect(page.locator("table thead")).toBeVisible({ timeout: 10_000 });
|
|
|
|
const filterSelect = page.locator("select").first();
|
|
await filterSelect.selectOption("SETTINGS_UPDATED");
|
|
|
|
// Should display at least one SETTINGS_UPDATED row
|
|
await expect(page.locator("table tbody tr").first()).toBeVisible({ timeout: 10_000 });
|
|
const tableText = await page.locator("table tbody").textContent();
|
|
expect(tableText).toContain("SETTINGS_UPDATED");
|
|
});
|
|
|
|
test("PASSWORD_CHANGED entry appears after changing password", async ({ loggedInPage: page }) => {
|
|
// Change password (admin -> admin) to generate audit entry
|
|
await openSettings(page);
|
|
await page.getByRole("button", { name: /security/i }).click();
|
|
|
|
// Client policy requires 8+ chars, so a temporary password is used and
|
|
// reverted via API right after (the current session token survives).
|
|
await page.getByPlaceholder("Current Password").fill("admin");
|
|
await page.getByPlaceholder("New Password").first().fill("Testpass123");
|
|
await page.getByPlaceholder("Confirm New Password").fill("Testpass123");
|
|
await page.getByRole("button", { name: /change password/i }).click();
|
|
await expect(page.getByText("Password changed successfully")).toBeVisible({ timeout: 5_000 });
|
|
|
|
const revert = await changePasswordViaApi(page, "Testpass123", "admin");
|
|
expect(revert.ok).toBeTruthy();
|
|
|
|
// Navigate to audit log and filter by PASSWORD_CHANGED
|
|
await page.getByRole("button", { name: /audit log/i }).click();
|
|
await expect(page.locator("table thead")).toBeVisible({ timeout: 10_000 });
|
|
|
|
const filterSelect = page.locator("select").first();
|
|
await filterSelect.selectOption("PASSWORD_CHANGED");
|
|
|
|
await expect(page.locator("table tbody tr").first()).toBeVisible({ timeout: 10_000 });
|
|
const tableText = await page.locator("table tbody").textContent();
|
|
expect(tableText).toContain("PASSWORD_CHANGED");
|
|
});
|
|
|
|
test("switching audit log filter back to All actions shows unfiltered entries", async ({
|
|
loggedInPage: page,
|
|
}) => {
|
|
await openSettings(page);
|
|
await page.getByRole("button", { name: /audit log/i }).click();
|
|
|
|
await expect(page.locator("table tbody tr").first()).toBeVisible({ timeout: 10_000 });
|
|
const allCount = await page.locator("table tbody tr").count();
|
|
|
|
// Filter to LOGIN_SUCCESS
|
|
const filterSelect = page.locator("select").first();
|
|
await filterSelect.selectOption("LOGIN_SUCCESS");
|
|
await page.waitForTimeout(500);
|
|
const filteredCount = await page.locator("table tbody tr").count();
|
|
|
|
// Switch back to All actions
|
|
await filterSelect.selectOption("");
|
|
await page.waitForTimeout(500);
|
|
const restoredCount = await page.locator("table tbody tr").count();
|
|
|
|
// All actions should show at least as many rows as the filtered view
|
|
expect(restoredCount).toBeGreaterThanOrEqual(filteredCount);
|
|
// Restored count should match the original (or be close, if entries were added)
|
|
expect(restoredCount).toBeGreaterThanOrEqual(allCount);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// System Settings -- additional persistence and interaction tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test.describe("GUI Settings - System Settings (extended)", () => {
|
|
test("changed Language persists after dialog re-open", async ({ loggedInPage: page }) => {
|
|
await openSettings(page);
|
|
await page.getByRole("button", { name: /system settings/i }).click();
|
|
await expect(page.getByText("Language", { exact: true })).toBeVisible();
|
|
|
|
const langSelect = page.locator("select").filter({ has: page.locator("option[value='en']") });
|
|
const originalValue = await langSelect.inputValue();
|
|
|
|
// Switch to French
|
|
const newValue = originalValue === "en" ? "fr" : "en";
|
|
await langSelect.selectOption(newValue);
|
|
|
|
await page.getByRole("button", { name: /save settings/i }).click();
|
|
// Wait for save confirmation (text may be in the new locale)
|
|
await page.waitForTimeout(2_000);
|
|
|
|
// Close and re-open
|
|
await page.keyboard.press("Escape");
|
|
await openSettings(page);
|
|
|
|
// Navigate to system settings -- the tab text may have changed locale
|
|
// Use the second nav button (System Settings is index 1)
|
|
const navButtons = page.locator(".w-48 button");
|
|
await navButtons.nth(1).click();
|
|
|
|
// Verify the language select persisted the new value
|
|
const langSelect2 = page.locator("select").filter({ has: page.locator("option[value='en']") });
|
|
const persisted = await langSelect2.inputValue();
|
|
|
|
// Restore via API before asserting: a failed assertion must not leave the
|
|
// server in a non-English locale for every later test.
|
|
const restore = await putSettings(page, { defaultLocale: originalValue });
|
|
expect(restore.ok).toBeTruthy();
|
|
expect(persisted).toBe(newValue);
|
|
});
|
|
|
|
test("changed Login Attempt Limit persists after dialog re-open", async ({
|
|
loggedInPage: page,
|
|
}) => {
|
|
await openSettings(page);
|
|
await page.getByRole("button", { name: /system settings/i }).click();
|
|
await expect(page.getByText("Login Attempt Limit")).toBeVisible();
|
|
|
|
// The login attempt limit input is the second number input
|
|
const numberInputs = page.locator("input[type='number']");
|
|
const loginInput = numberInputs.nth(1);
|
|
const originalValue = await loginInput.inputValue();
|
|
|
|
const testValue = originalValue === "5" ? "10" : "5";
|
|
await loginInput.fill(testValue);
|
|
|
|
await page.getByRole("button", { name: /save settings/i }).click();
|
|
await expect(page.getByText("Settings saved.")).toBeVisible({ timeout: 5_000 });
|
|
|
|
// Close and re-open
|
|
await page.keyboard.press("Escape");
|
|
await openSettings(page);
|
|
await page.getByRole("button", { name: /system settings/i }).click();
|
|
await expect(page.getByText("Login Attempt Limit")).toBeVisible();
|
|
|
|
const persistedValue = await page.locator("input[type='number']").nth(1).inputValue();
|
|
expect(persistedValue).toBe(testValue);
|
|
|
|
// Restore original value
|
|
await page.locator("input[type='number']").nth(1).fill(originalValue);
|
|
await page.getByRole("button", { name: /save settings/i }).click();
|
|
await expect(page.getByText("Settings saved.")).toBeVisible({ timeout: 5_000 });
|
|
});
|
|
|
|
test("changed Max File Age persists after dialog re-open", async ({ loggedInPage: page }) => {
|
|
await openSettings(page);
|
|
await page.getByRole("button", { name: /system settings/i }).click();
|
|
await expect(page.getByText("Max File Age (hours)")).toBeVisible();
|
|
|
|
// Max file age is the third number input
|
|
const numberInputs = page.locator("input[type='number']");
|
|
const ageInput = numberInputs.nth(2);
|
|
const originalValue = await ageInput.inputValue();
|
|
|
|
const testValue = originalValue === "24" ? "48" : "24";
|
|
await ageInput.fill(testValue);
|
|
|
|
await page.getByRole("button", { name: /save settings/i }).click();
|
|
await expect(page.getByText("Settings saved.")).toBeVisible({ timeout: 5_000 });
|
|
|
|
// Close and re-open
|
|
await page.keyboard.press("Escape");
|
|
await openSettings(page);
|
|
await page.getByRole("button", { name: /system settings/i }).click();
|
|
await expect(page.getByText("Max File Age (hours)")).toBeVisible();
|
|
|
|
const persistedValue = await page.locator("input[type='number']").nth(2).inputValue();
|
|
expect(persistedValue).toBe(testValue);
|
|
|
|
// Restore original value
|
|
await page.locator("input[type='number']").nth(2).fill(originalValue);
|
|
await page.getByRole("button", { name: /save settings/i }).click();
|
|
await expect(page.getByText("Settings saved.")).toBeVisible({ timeout: 5_000 });
|
|
});
|
|
|
|
test("system settings save failure shows error message on invalid data", async ({
|
|
loggedInPage: page,
|
|
}) => {
|
|
await openSettings(page);
|
|
await page.getByRole("button", { name: /system settings/i }).click();
|
|
await expect(page.getByText("File Upload Limit (MB)")).toBeVisible();
|
|
|
|
// Set an extremely large value to trigger potential validation
|
|
const uploadInput = page.locator("input[type='number']").first();
|
|
const originalValue = await uploadInput.inputValue();
|
|
await uploadInput.fill("0");
|
|
|
|
// Save -- value of 0 might be accepted or rejected depending on backend validation
|
|
await page.getByRole("button", { name: /save settings/i }).click();
|
|
|
|
// Wait for response and restore regardless
|
|
await page.waitForTimeout(2_000);
|
|
await uploadInput.fill(originalValue);
|
|
await page.getByRole("button", { name: /save settings/i }).click();
|
|
await page.waitForTimeout(1_000);
|
|
});
|
|
});
|