2026-05-01 13:44:04 +08:00
|
|
|
import { expect, openSettings, test } from "./helpers";
|
2026-04-29 01:39:25 +08:00
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Settings Dialog -- General, System Settings, About tabs
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
test.describe("GUI Settings - Dialog Navigation", () => {
|
|
|
|
|
test("opens settings dialog from sidebar", async ({ loggedInPage: page }) => {
|
2026-05-01 13:44:04 +08:00
|
|
|
await openSettings(page);
|
2026-04-29 01:39:25 +08:00
|
|
|
|
|
|
|
|
// Dialog sidebar should render with the Settings heading
|
|
|
|
|
await expect(page.locator("h2").filter({ hasText: "Settings" })).toBeVisible();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("dialog sidebar lists navigable section tabs", async ({ loggedInPage: page }) => {
|
2026-05-01 13:44:04 +08:00
|
|
|
await openSettings(page);
|
2026-04-29 01:39:25 +08:00
|
|
|
|
|
|
|
|
// 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 }) => {
|
2026-05-01 13:44:04 +08:00
|
|
|
await openSettings(page);
|
2026-04-29 01:39:25 +08:00
|
|
|
|
|
|
|
|
// 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("closes dialog via the X button", async ({ loggedInPage: page }) => {
|
2026-05-01 13:44:04 +08:00
|
|
|
await openSettings(page);
|
2026-04-29 01:39:25 +08:00
|
|
|
|
|
|
|
|
// 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 }) => {
|
2026-05-01 13:44:04 +08:00
|
|
|
await openSettings(page);
|
2026-04-29 01:39:25 +08:00
|
|
|
|
|
|
|
|
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 }) => {
|
2026-05-01 13:44:04 +08:00
|
|
|
await openSettings(page);
|
2026-04-29 01:39:25 +08:00
|
|
|
|
|
|
|
|
// 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.describe("GUI Settings - General Tab", () => {
|
|
|
|
|
test("displays username and role badge", async ({ loggedInPage: page }) => {
|
2026-05-01 13:44:04 +08:00
|
|
|
await openSettings(page);
|
2026-04-29 01:39:25 +08:00
|
|
|
|
|
|
|
|
// 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("shows Default Tool View dropdown with options", async ({ loggedInPage: page }) => {
|
2026-05-01 13:44:04 +08:00
|
|
|
await openSettings(page);
|
2026-04-29 01:39:25 +08:00
|
|
|
|
|
|
|
|
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("shows App Version string", async ({ loggedInPage: page }) => {
|
2026-05-01 13:44:04 +08:00
|
|
|
await openSettings(page);
|
2026-04-29 01:39:25 +08:00
|
|
|
|
|
|
|
|
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 }) => {
|
2026-05-01 13:44:04 +08:00
|
|
|
await openSettings(page);
|
2026-04-29 01:39:25 +08:00
|
|
|
|
|
|
|
|
await expect(page.getByRole("button", { name: /save settings/i })).toBeVisible();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("logout button redirects to /login", async ({ loggedInPage: page }) => {
|
2026-05-01 13:44:04 +08:00
|
|
|
await openSettings(page);
|
2026-04-29 01:39:25 +08:00
|
|
|
|
|
|
|
|
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");
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test.describe("GUI Settings - System Settings Tab", () => {
|
|
|
|
|
test("shows File Upload Limit input", async ({ loggedInPage: page }) => {
|
2026-05-01 13:44:04 +08:00
|
|
|
await openSettings(page);
|
2026-04-29 01:39:25 +08:00
|
|
|
await page.getByRole("button", { name: /system settings/i }).click();
|
|
|
|
|
|
|
|
|
|
await expect(page.getByText("File Upload Limit (MB)")).toBeVisible();
|
|
|
|
|
await expect(page.locator("input[type='number']").first()).toBeVisible();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("shows Default Theme dropdown", async ({ loggedInPage: page }) => {
|
2026-05-01 13:44:04 +08:00
|
|
|
await openSettings(page);
|
2026-04-29 01:39:25 +08:00
|
|
|
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 Login Attempt Limit input", async ({ loggedInPage: page }) => {
|
2026-05-01 13:44:04 +08:00
|
|
|
await openSettings(page);
|
2026-04-29 01:39:25 +08:00
|
|
|
await page.getByRole("button", { name: /system settings/i }).click();
|
|
|
|
|
|
|
|
|
|
await expect(page.getByText("Login Attempt Limit")).toBeVisible();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("Save Settings button persists changes", async ({ loggedInPage: page }) => {
|
2026-05-01 13:44:04 +08:00
|
|
|
await openSettings(page);
|
2026-04-29 01:39:25 +08:00
|
|
|
await page.getByRole("button", { name: /system settings/i }).click();
|
|
|
|
|
|
|
|
|
|
// Wait for section to load
|
2026-05-07 19:41:30 +08:00
|
|
|
await expect(page.getByText("File Upload Limit (MB)")).toBeVisible();
|
2026-04-29 01:39:25 +08:00
|
|
|
|
|
|
|
|
// 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.describe("GUI Settings - About Tab", () => {
|
|
|
|
|
test("displays app description and version", async ({ loggedInPage: page }) => {
|
2026-05-01 13:44:04 +08:00
|
|
|
await openSettings(page);
|
2026-04-29 01:39:25 +08:00
|
|
|
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 }) => {
|
2026-05-01 13:44:04 +08:00
|
|
|
await openSettings(page);
|
2026-04-29 01:39:25 +08:00
|
|
|
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();
|
|
|
|
|
});
|
2026-05-01 05:28:00 +08:00
|
|
|
|
|
|
|
|
test("shows API Reference (Swagger) link", async ({ loggedInPage: page }) => {
|
2026-05-01 13:44:04 +08:00
|
|
|
await openSettings(page);
|
2026-05-01 05:28:00 +08:00
|
|
|
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 }) => {
|
2026-05-01 13:44:04 +08:00
|
|
|
await openSettings(page);
|
2026-05-01 05:28:00 +08:00
|
|
|
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 }) => {
|
2026-05-01 13:44:04 +08:00
|
|
|
await openSettings(page);
|
2026-05-01 05:28:00 +08:00
|
|
|
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 }) => {
|
2026-05-01 13:44:04 +08:00
|
|
|
await openSettings(page);
|
2026-05-01 05:28:00 +08:00
|
|
|
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 }) => {
|
2026-05-01 13:44:04 +08:00
|
|
|
await openSettings(page);
|
2026-05-01 05:28:00 +08:00
|
|
|
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 }) => {
|
2026-05-01 13:44:04 +08:00
|
|
|
await openSettings(page);
|
2026-05-01 05:28:00 +08:00
|
|
|
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 }) => {
|
2026-05-01 13:44:04 +08:00
|
|
|
await openSettings(page);
|
2026-05-01 05:28:00 +08:00
|
|
|
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 }) => {
|
2026-05-01 13:44:04 +08:00
|
|
|
await openSettings(page);
|
2026-05-01 05:28:00 +08:00
|
|
|
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();
|
2026-05-05 23:24:22 +08:00
|
|
|
// Should have many tools (SnapOtter has 48)
|
2026-05-01 05:28:00 +08:00
|
|
|
expect(count).toBeGreaterThan(10);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("toggling a tool changes the disabled counter", async ({ loggedInPage: page }) => {
|
2026-05-01 13:44:04 +08:00
|
|
|
await openSettings(page);
|
2026-05-01 05:28:00 +08:00
|
|
|
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 }) => {
|
2026-05-01 13:44:04 +08:00
|
|
|
await openSettings(page);
|
2026-05-01 05:28:00 +08:00
|
|
|
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,
|
|
|
|
|
}) => {
|
2026-05-01 13:44:04 +08:00
|
|
|
await openSettings(page);
|
2026-05-01 05:28:00 +08:00
|
|
|
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 machine/i)).toBeVisible();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("shows either consent toggle or admin-disabled message", async ({ loggedInPage: page }) => {
|
2026-05-01 13:44:04 +08:00
|
|
|
await openSettings(page);
|
2026-05-01 05:28:00 +08:00
|
|
|
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);
|
|
|
|
|
});
|
|
|
|
|
});
|