diff --git a/.gitignore b/.gitignore index 59a2da23..c0db061f 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,7 @@ STALE_SERVICES.txt # -- git history on the real file is the rollback mechanism now, these are # just local working-copy scratch files. docker/*.bak-* + +# vitest/v8 coverage report output (npm run coverage:ui, packages/omnibioai-ui's +# npm run coverage) -- generated, not source. +coverage/ diff --git a/docker/nginx-router.conf b/docker/nginx-router.conf index 4955403f..7771884d 100644 --- a/docker/nginx-router.conf +++ b/docker/nginx-router.conf @@ -487,6 +487,16 @@ server { auth_request /internal/auth/verify; auth_request_set $auth_status $upstream_status; error_page 401 = @cc_unauthorized; + # auth_request above only gates the request -- it does not, on its + # own, change what header the actual proxied request below carries. + # Without this, a cookie-only iframe navigation (no Authorization + # header to begin with) passes the gate via $control_authorization's + # cookie fallback (see the map{} near the top of this file) but then + # reaches control-center with no Authorization header at all, and + # control-center's own require_permission() dependency 401s it + # independently ("Missing or malformed Authorization header") -- + # same pattern /_svc/toolserver below already gets right. + proxy_set_header Authorization $control_authorization; set $control_upstream control-center:7070; rewrite ^/_svc/control(/.*)$ $1 break; proxy_pass http://$control_upstream; diff --git a/packages/omnibioai-ui/src/components/Card/Card.test.tsx b/packages/omnibioai-ui/src/components/Card/Card.test.tsx index 76e61c12..2f96ddcf 100644 --- a/packages/omnibioai-ui/src/components/Card/Card.test.tsx +++ b/packages/omnibioai-ui/src/components/Card/Card.test.tsx @@ -25,4 +25,14 @@ describe('Card', () => { fireEvent.click(container.firstChild!); expect(fn).toHaveBeenCalledTimes(1); }); + it('applies inline padding style when provided', () => { + const { container } = render(body); + expect((container.firstChild as HTMLElement).style.padding).toBe('12px'); + }); + it('renders header with actions but no title', () => { + const { container } = render(Go}>body); + expect(container.querySelector('.omni-card__header')).not.toBeNull(); + expect(container.querySelector('.omni-card__title')).toBeNull(); + expect(screen.getByText('Go')).toBeInTheDocument(); + }); }); diff --git a/packages/omnibioai-ui/src/components/Table/Table.test.tsx b/packages/omnibioai-ui/src/components/Table/Table.test.tsx index 0402e9c3..2a31ddd5 100644 --- a/packages/omnibioai-ui/src/components/Table/Table.test.tsx +++ b/packages/omnibioai-ui/src/components/Table/Table.test.tsx @@ -42,3 +42,66 @@ describe('Table', () => { expect(screen.getByText('alpha').tagName).toBe('STRONG'); }); }); + +it('cycles sort direction, handles nulls, and resets after a third click', () => { + const columns = [ + { key: 'name' as const, label: 'Name', sortable: true }, + { key: 'value' as const, label: 'Value', sortable: true }, + { key: 'plain' as const, label: 'Plain', sortable: false }, + ]; + const rows = [ + { name: 'zeta', value: null as number | null, plain: 'x' }, + { name: 'alpha', value: 2, plain: 'y' }, + { name: 'beta', value: 1, plain: 'z' }, + ]; + render(); + const name = screen.getByText('Name'); + fireEvent.click(name); // asc + fireEvent.click(name); // desc + expect(screen.getAllByRole('cell')[0]).toHaveTextContent('zeta'); + fireEvent.click(name); // clear sort + expect(screen.getAllByRole('cell')[0]).toHaveTextContent('zeta'); + fireEvent.click(screen.getByText('Plain')); // no-op +}); + +it('compares nulls on both sides and equal values when sorting', () => { + const columns = [{ key: 'value' as const, label: 'Value', sortable: true }]; + const rows = [ + { value: null as number | null }, + { value: 5 }, + { value: 5 }, + { value: 3 }, + ]; + render(
); + fireEvent.click(screen.getByText('Value')); // ascending + const asc = screen.getAllByRole('cell').map(c => c.textContent); + expect(asc[asc.length - 1]).toBe('—'); // null sorts last ascending + fireEvent.click(screen.getByText('Value')); // descending + const desc = screen.getAllByRole('cell').map(c => c.textContent); + expect(desc[desc.length - 1]).toBe('—'); // null comparator return bypasses the direction flip +}); + +it('re-sorts ascending on the same column after a full reset cycle', () => { + const columns = [{ key: 'name' as const, label: 'Name', sortable: true }]; + const rows = [{ name: 'zeta' }, { name: 'alpha' }, { name: 'beta' }]; + render(
); + const name = screen.getByText('Name'); + fireEvent.click(name); // asc + fireEvent.click(name); // desc + fireEvent.click(name); // reset (null) + fireEvent.click(name); // asc again + expect(screen.getAllByRole('cell')[0]).toHaveTextContent('alpha'); +}); + +it('paginates, renders ellipses, and supports page navigation', () => { + const columns = [{ key: 'name' as const, label: 'Name', sortable: true }]; + const rows = Array.from({ length: 50 }, (_, i) => ({ name: `row-${i}` })); + render(
); + expect(screen.getByText('row-0')).toBeInTheDocument(); + expect(screen.getByText('…')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: '2' })); + expect(screen.getByText('row-2')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: '→' })); + fireEvent.click(screen.getByRole('button', { name: '←' })); + expect(screen.getByText('row-2')).toBeInTheDocument(); +}); diff --git a/packages/omnibioai-ui/src/components/Tabs/Tabs.test.tsx b/packages/omnibioai-ui/src/components/Tabs/Tabs.test.tsx index a4b93b4a..94905243 100644 --- a/packages/omnibioai-ui/src/components/Tabs/Tabs.test.tsx +++ b/packages/omnibioai-ui/src/components/Tabs/Tabs.test.tsx @@ -33,4 +33,12 @@ describe('Tabs', () => { render(); expect(screen.getByText('Content B')).toBeInTheDocument(); }); + it('renders an empty panel when there are no tabs', () => { + const { container } = render(); + expect(container.querySelector('.omni-tab-panel')?.textContent).toBe(''); + }); + it('renders an empty panel when defaultTab matches no tab', () => { + const { container } = render(); + expect(container.querySelector('.omni-tab-panel')?.textContent).toBe(''); + }); }); diff --git a/packages/omnibioai-ui/vitest.config.ts b/packages/omnibioai-ui/vitest.config.ts index b7b76ebd..d87991fb 100644 --- a/packages/omnibioai-ui/vitest.config.ts +++ b/packages/omnibioai-ui/vitest.config.ts @@ -15,6 +15,9 @@ export default defineConfig({ setupFiles: ['./src/test-setup.ts'], css: true, include: ['src/**/*.test.{ts,tsx}'], + coverage: { + thresholds: { statements: 95, lines: 95, functions: 95, branches: 95 }, + }, }, }, ], diff --git a/src/ui/App.jsx b/src/ui/App.jsx index 7bd4644b..4ca75168 100644 --- a/src/ui/App.jsx +++ b/src/ui/App.jsx @@ -22,6 +22,7 @@ import OAuthLinkConfirm from "./components/OAuthLinkConfirm"; import Login from "./components/Login"; import { GrafanaViewer } from "./components/GrafanaViewer"; import { getCurrentUser, onSessionChange, consumeOAuthRedirectParams, isElectron, refresh, getRefreshToken } from "./lib/session"; +import { loadConfig as loadWebConfig } from "./lib/web/webApi"; const BASE_NAV = [ { section: "Setup", items: [ @@ -112,6 +113,18 @@ export default function App() { // No config at all → first run → Settings setStep(8); } + } else if (!isElectron()) { + // Web/cloud deployment (e.g. webstudio.omnibioai.org): window.api + // (Electron's preload bridge) is never injected into a plain + // browser tab, so the branch above never runs here and `config` + // was silently stuck at its hardcoded initial default forever. + // webApi.loadConfig() (src/ui/lib/web/webApi.js) is the purpose- + // built web-mode stand-in — there's no local Docker stack or + // data_dir to configure when connecting to an already-running + // backend, so it always resolves with mode: "beta" rather than + // needing a first-run redirect the way the Electron branch does. + const saved = await loadWebConfig(); + setConfig(prev => ({ ...prev, ...saved, mode: saved.mode || "beta" })); } } catch (_) { // Dev mode — no Electron API, stay on Mode page @@ -325,8 +338,12 @@ export default function App() { {currentName} - {/* First run warning */} - {!config?.settings?.data_dir && ( + {/* First run warning — data_dir is an Electron-only concept (the + local Docker stack's data directory). Beta/web mode connects to + an already-running remote backend and never has one to set + (see webApi.loadConfig() above), so it's excluded here rather + than showing a "setup required" warning that doesn't apply. */} + {config?.mode !== "beta" && !config?.settings?.data_dir && (
({ + getCurrentUser: vi.fn(), + onSessionChange: vi.fn(() => vi.fn()), + consumeOAuthRedirectParams: vi.fn(() => null), + isElectron: vi.fn(() => true), + refresh: vi.fn(), + getRefreshToken: vi.fn(() => null), +})); +vi.mock("../../src/ui/lib/session", () => ({ + getCurrentUser, onSessionChange, consumeOAuthRedirectParams, isElectron, refresh, getRefreshToken, +})); + +vi.mock("../../src/ui/components/LicenseGate", () => ({ default: ({ children }) => <>{children} })); +vi.mock("../../src/ui/components/Login", () => ({ default: () =>
Login screen
})); +vi.mock("../../src/ui/components/BugReport", () => ({ default: () => null })); +vi.mock("../../src/ui/components/UpdateBanner", () => ({ default: () => null })); +vi.mock("../../src/ui/components/MobileNav", () => ({ + default: ({ open, onClose }) => open ?
: null, +})); +vi.mock("../../src/ui/components/OAuthLinkConfirm", () => ({ default: ({ onDone, onCancel }) =>
Link required
})); +vi.mock("../../src/ui/components/GrafanaViewer", () => ({ GrafanaViewer: ({ onBack }) =>
Grafana view
})); + +vi.mock("../../src/ui/pages/Mode", () => ({ default: () =>
Mode page
})); +vi.mock("../../src/ui/pages/LLM", () => ({ default: () =>
LLM page
})); +vi.mock("../../src/ui/pages/Cloud", () => ({ default: () =>
Cloud page
})); +vi.mock("../../src/ui/pages/HPC", () => ({ default: () =>
HPC page
})); +vi.mock("../../src/ui/pages/Launch", () => ({ default: ({ onStatusChange }) =>
Launch page
})); +vi.mock("../../src/ui/pages/Services", () => ({ default: () =>
Services page
})); +vi.mock("../../src/ui/pages/Logs", () => ({ default: () =>
Logs page
})); +vi.mock("../../src/ui/pages/Workbench", () => ({ default: () =>
Workbench page
})); +vi.mock("../../src/ui/pages/Settings", () => ({ default: () =>
Settings page
})); +vi.mock("../../src/ui/pages/Jobs", () => ({ default: () =>
Jobs page
})); +vi.mock("../../src/ui/pages/IdeServices", () => ({ default: () =>
IDE page
})); +vi.mock("../../src/ui/pages/RoleManagement", () => ({ default: () =>
Roles page
})); +vi.mock("../../src/ui/pages/ServiceViewer", () => ({ default: ({ url, label, onBack }) =>
ServiceViewer:{label}:{url}
})); +vi.mock("../../src/ui/pages/Videos", () => ({ default: ({ onBack }) =>
Videos page
})); + +import App from "../../src/ui/App"; + +beforeEach(() => { + delete window.api; + isElectron.mockReturnValue(true); + getCurrentUser.mockResolvedValue(null); + consumeOAuthRedirectParams.mockReturnValue(null); + getRefreshToken.mockReturnValue(null); + onSessionChange.mockReturnValue(vi.fn()); + window.history.replaceState({}, "", "/"); +}); +afterEach(() => { cleanup(); vi.restoreAllMocks(); vi.clearAllMocks(); delete window.api; }); + +const admin = { userId: 1, email: "admin@test", permissions: ["manage_roles"] }; + +describe("App shell — loading and first-run", () => { + it("shows a loading spinner until window.api.loadConfig resolves, then routes to Settings when no data_dir is set", async () => { + let resolveConfig; + window.api = { loadConfig: vi.fn(() => new Promise((res) => { resolveConfig = res; })) }; + render(); + expect(screen.getByText("Loading configuration...")).toBeInTheDocument(); + resolveConfig({ mode: "local", settings: {} }); + await waitFor(() => expect(screen.getByText("Settings page")).toBeInTheDocument()); + }); + + it("routes to Settings with no saved config at all, and stays on Mode when data_dir is already set", async () => { + window.api = { loadConfig: vi.fn().mockResolvedValue(null) }; + render(); + await waitFor(() => expect(screen.getByText("Settings page")).toBeInTheDocument()); + cleanup(); + + window.api = { loadConfig: vi.fn().mockResolvedValue({ mode: "local", settings: { data_dir: "/d" } }) }; + render(); + await waitFor(() => expect(screen.getByText("Mode page")).toBeInTheDocument()); + }); + + it("stays ready and on Mode when loadConfig throws (dev mode)", async () => { + window.api = { loadConfig: vi.fn().mockRejectedValue(new Error("no ipc")) }; + render(); + await waitFor(() => expect(screen.getByText("Mode page")).toBeInTheDocument()); + }); + + it("becomes ready immediately with no window.api at all", async () => { + render(); + await waitFor(() => expect(screen.getByText("Mode page")).toBeInTheDocument()); + }); + + it("defaults an unset saved mode to beta", async () => { + window.api = { loadConfig: vi.fn().mockResolvedValue({ settings: { data_dir: "/d" } }) }; + render(); + await waitFor(() => expect(screen.getByText("Mode page")).toBeInTheDocument()); + }); +}); + +describe("App shell — web auth gate", () => { + it("waits for authChecked before rendering, and shows Login when signed out", async () => { + isElectron.mockReturnValue(false); + let resolveUser; + getCurrentUser.mockReturnValue(new Promise((res) => { resolveUser = res; })); + render(); + expect(screen.getByText("Loading configuration...")).toBeInTheDocument(); + resolveUser(null); + await waitFor(() => expect(screen.getByText("Login screen")).toBeInTheDocument()); + }); + + it("renders the shell once signed in", async () => { + isElectron.mockReturnValue(false); + getCurrentUser.mockResolvedValue(admin); + render(); + await waitFor(() => expect(screen.getByText("Mode page")).toBeInTheDocument()); + expect(screen.getByText("Roles")).toBeInTheDocument(); + }); +}); + +describe("App shell — navigation and roles nav", () => { + it("hides the Roles nav item for a user without manage_roles, once known", async () => { + getCurrentUser.mockResolvedValue({ userId: 2, email: "u@test", permissions: [] }); + render(); + await waitFor(() => expect(screen.getByText("Mode page")).toBeInTheDocument()); + expect(screen.queryByText("Roles")).not.toBeInTheDocument(); + }); + + it("keeps Roles visible while currentUser is still unresolved, then navigates to it", async () => { + getCurrentUser.mockResolvedValue(admin); + render(); + await waitFor(() => expect(screen.getByText("Roles")).toBeInTheDocument()); + fireEvent.click(screen.getByText("Roles")); + await waitFor(() => expect(screen.getByText("Roles page")).toBeInTheDocument()); + }); + + it("responds to navigate and open-service window events", async () => { + getCurrentUser.mockResolvedValue(admin); + render(); + await waitFor(() => expect(screen.getByText("Mode page")).toBeInTheDocument()); + window.dispatchEvent(new CustomEvent("navigate", { detail: 9 })); + await waitFor(() => expect(screen.getByText("Jobs page")).toBeInTheDocument()); + + window.dispatchEvent(new CustomEvent("open-service", { detail: { url: "/_svc/other", label: "Other Service" } })); + await waitFor(() => expect(screen.getByText(/ServiceViewer:Other Service/)).toBeInTheDocument()); + fireEvent.click(screen.getByText("svback")); + await waitFor(() => expect(screen.getByText("Jobs page")).toBeInTheDocument()); + + window.dispatchEvent(new CustomEvent("open-service", { detail: { url: "/_svc/other", label: "Other Service" } })); + await waitFor(() => expect(screen.getByText(/ServiceViewer:Other Service/)).toBeInTheDocument()); + // breadcrumb "studio" click returns to Workbench and clears the service view + fireEvent.click(screen.getByText("studio")); + await waitFor(() => expect(screen.getByText("Workbench page")).toBeInTheDocument()); + }); + + it("routes videos and Grafana service opens to their dedicated viewers", async () => { + getCurrentUser.mockResolvedValue(admin); + render(); + await waitFor(() => expect(screen.getByText("Mode page")).toBeInTheDocument()); + + window.dispatchEvent(new CustomEvent("open-service", { detail: { url: "/_svc/videos", label: "Videos" } })); + await waitFor(() => expect(screen.getByText("Videos page")).toBeInTheDocument()); + fireEvent.click(screen.getByText("vback")); + await waitFor(() => expect(screen.queryByText("Videos page")).not.toBeInTheDocument()); + + window.dispatchEvent(new CustomEvent("open-service", { detail: { url: "/_svc/monitor", label: "Metrics" } })); + await waitFor(() => expect(screen.getByText("Grafana view")).toBeInTheDocument()); + fireEvent.click(screen.getByText("gback")); + await waitFor(() => expect(screen.queryByText("Grafana view")).not.toBeInTheDocument()); + }); + + it("opens and closes the mobile nav drawer, and shows the first-run warning banner", async () => { + getCurrentUser.mockResolvedValue(admin); + window.api = { loadConfig: vi.fn().mockResolvedValue({ mode: "local", settings: {} }) }; + render(); + await waitFor(() => expect(screen.getByText("Settings page")).toBeInTheDocument()); + expect(screen.getByText(/Setup required/)).toBeInTheDocument(); + fireEvent.click(screen.getByText(/Setup required/)); + + fireEvent.click(screen.getByLabelText("Open navigation")); + expect(screen.getByRole("dialog", { name: "mobile nav" })).toBeInTheDocument(); + fireEvent.click(screen.getByText("close-drawer")); + expect(screen.queryByRole("dialog", { name: "mobile nav" })).not.toBeInTheDocument(); + }); + + it("walks the wizard controls: dot navigation, Back/Next, and boundary disabling", async () => { + getCurrentUser.mockResolvedValue(admin); + render(); + await waitFor(() => expect(screen.getByText("Mode page")).toBeInTheDocument()); + expect(screen.getByText("Back")).toBeDisabled(); + fireEvent.click(screen.getByText("Next →")); + await waitFor(() => expect(screen.getByText("LLM page")).toBeInTheDocument()); + fireEvent.click(screen.getByText("Back")); + await waitFor(() => expect(screen.getByText("Mode page")).toBeInTheDocument()); + + fireEvent.click(screen.getByTitle("Launch")); + await waitFor(() => expect(screen.getByText("Launch page")).toBeInTheDocument()); + expect(screen.getByText("Next →")).toBeDisabled(); + }); + + it("reflects running and error system status from the Launch page", async () => { + getCurrentUser.mockResolvedValue(admin); + render(); + await waitFor(() => expect(screen.getByText("Mode page")).toBeInTheDocument()); + fireEvent.click(screen.getByTitle("Launch")); + await waitFor(() => expect(screen.getByText("Launch page")).toBeInTheDocument()); + fireEvent.click(screen.getByText("go-starting")); + await waitFor(() => expect(screen.getAllByText("STARTING").length).toBeGreaterThan(0)); + fireEvent.click(screen.getByText("go-running")); + await waitFor(() => expect(screen.getAllByText("RUNNING").length).toBeGreaterThan(0)); + fireEvent.click(screen.getByText("go-error")); + await waitFor(() => expect(screen.getAllByText("ERROR").length).toBeGreaterThan(0)); + }); +}); + +describe("App shell — OAuth redirect notices", () => { + it("shows the link-confirmation dialog and clears it on done/cancel", async () => { + consumeOAuthRedirectParams.mockReturnValue({ type: "link_required", linkToken: "lt", provider: "google", email: "a@b.test" }); + getCurrentUser.mockResolvedValue(admin); + render(); + expect(await screen.findByText("Link required")).toBeInTheDocument(); + fireEvent.click(screen.getByText("done")); + await waitFor(() => expect(screen.queryByText("Link required")).not.toBeInTheDocument()); + }); + + it("dismisses the link-confirmation dialog via cancel", async () => { + consumeOAuthRedirectParams.mockReturnValue({ type: "link_required", linkToken: "lt", provider: "google", email: "a@b.test" }); + getCurrentUser.mockResolvedValue(admin); + render(); + expect(await screen.findByText("Link required")).toBeInTheDocument(); + fireEvent.click(screen.getByText("cancel")); + await waitFor(() => expect(screen.queryByText("Link required")).not.toBeInTheDocument()); + }); + + it("shows and dismisses a sign-in error banner", async () => { + consumeOAuthRedirectParams.mockReturnValue({ type: "error", message: "denied" }); + getCurrentUser.mockResolvedValue(admin); + render(); + expect(await screen.findByText("Sign-in failed: denied")).toBeInTheDocument(); + fireEvent.click(screen.getByText("✕")); + await waitFor(() => expect(screen.queryByText(/Sign-in failed/)).not.toBeInTheDocument()); + }); +}); + +describe("App shell — token refresh and return_to redirect", () => { + it("refreshes the access token on mount when a refresh token exists", async () => { + getRefreshToken.mockReturnValue("rt"); + getCurrentUser.mockResolvedValue(admin); + render(); + await waitFor(() => expect(refresh).toHaveBeenCalled()); + }); + + it("does not refresh with no refresh token", async () => { + getRefreshToken.mockReturnValue(null); + getCurrentUser.mockResolvedValue(admin); + render(); + await waitFor(() => expect(screen.getByText("Mode page")).toBeInTheDocument()); + expect(refresh).not.toHaveBeenCalled(); + }); + + it("ignores an unsafe or cross-origin return_to and strips a safe one from the URL", async () => { + window.history.replaceState({}, "", "/?return_to=//evil.example"); + getCurrentUser.mockResolvedValue(null); + isElectron.mockReturnValue(false); + render(); + await waitFor(() => expect(screen.getByText("Login screen")).toBeInTheDocument()); + cleanup(); + + window.history.replaceState({}, "", "/?return_to=%2Fjobs"); + getCurrentUser.mockResolvedValue(null); + render(); + await waitFor(() => expect(screen.getByText("Login screen")).toBeInTheDocument()); + expect(window.location.search).toBe(""); + }); + + it("treats a malformed return_to as unsafe rather than throwing", async () => { + window.history.replaceState({}, "", "/?return_to=%2F%25zz"); + getCurrentUser.mockResolvedValue(null); + isElectron.mockReturnValue(false); + render(); + await waitFor(() => expect(screen.getByText("Login screen")).toBeInTheDocument()); + }); + + it("redirects to a safe return_to once the user is signed in", async () => { + window.history.replaceState({}, "", "/?return_to=%2Fjobs"); + getCurrentUser.mockResolvedValue(admin); + render(); + // jsdom doesn't implement real navigation, but the assignment itself + // must not throw and the shell should still render. + await waitFor(() => expect(screen.getByText("Mode page")).toBeInTheDocument()); + }); +}); diff --git a/tests/ui/bug-report.test.jsx b/tests/ui/bug-report.test.jsx new file mode 100644 index 00000000..b777cb18 --- /dev/null +++ b/tests/ui/bug-report.test.jsx @@ -0,0 +1,47 @@ +import React from "react"; +import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import BugReport from "../../src/ui/components/BugReport"; + +afterEach(() => { cleanup(); vi.useRealTimers(); }); + +describe("BugReport", () => { + it("opens and closes the dialog without submitting", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: /report bug/i })); + expect(screen.getByText("🐛 Report a Bug")).toBeInTheDocument(); + fireEvent.click(screen.getByText("✕")); + expect(screen.queryByText("🐛 Report a Bug")).not.toBeInTheDocument(); + }); + + it("disables submit until both title and description are filled, and accepts email and severity", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: /report bug/i })); + const submit = screen.getByRole("button", { name: /submit bug report/i }); + expect(submit).toBeDisabled(); + + fireEvent.change(screen.getByPlaceholderText("Bug title"), { target: { value: "Crash" } }); + expect(submit).toBeDisabled(); // description still empty + + fireEvent.change(screen.getByPlaceholderText("Describe what happened..."), { target: { value: "It crashed" } }); + expect(submit).not.toBeDisabled(); + + fireEvent.change(screen.getByPlaceholderText("Your email (optional)"), { target: { value: "a@b.test" } }); + fireEvent.change(screen.getByDisplayValue("🟡 Medium - Affects workflow"), { target: { value: "fatal" } }); + }); + + it("submits, shows confirmation, and resets the form after the timeout", async () => { + vi.useFakeTimers(); + render(); + fireEvent.click(screen.getByRole("button", { name: /report bug/i })); + fireEvent.change(screen.getByPlaceholderText("Bug title"), { target: { value: "Oops" } }); + fireEvent.change(screen.getByPlaceholderText("Describe what happened..."), { target: { value: "details" } }); + fireEvent.click(screen.getByRole("button", { name: /submit bug report/i })); + expect(screen.getByText(/bug reported/i)).toBeInTheDocument(); + + await vi.advanceTimersByTimeAsync(2000); + expect(screen.queryByText("🐛 Report a Bug")).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: /report bug/i })); + expect(screen.getByPlaceholderText("Bug title")).toHaveValue(""); + }); +}); diff --git a/tests/ui/config-pages.test.jsx b/tests/ui/config-pages.test.jsx new file mode 100644 index 00000000..c50835b9 --- /dev/null +++ b/tests/ui/config-pages.test.jsx @@ -0,0 +1,107 @@ +import React from "react"; +import { render, screen, fireEvent, cleanup } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import Cloud from "../../src/ui/pages/Cloud"; +import LLM from "../../src/ui/pages/LLM"; +import HPC from "../../src/ui/pages/HPC"; + +// Electron bypasses RequirePermission's login/RBAC gate entirely (see its own +// comment), which is the path these config pages are actually used through. +beforeEach(() => { window.api = {}; }); +afterEach(() => cleanup()); + +// Fires a change/click on every interactive control in the rendered form so +// each field's onChange callback body (not just its declaration) executes, +// and does it twice so both the `value || fallback` and populated-value +// branches run. +function exerciseAllControls(container) { + container.querySelectorAll('input[type="text"], input:not([type])').forEach((el) => { + fireEvent.focus(el); + fireEvent.change(el, { target: { value: `${el.value || "x"}1` } }); + fireEvent.blur(el); + }); + container.querySelectorAll('input[type="password"]').forEach((el) => { + fireEvent.change(el, { target: { value: "secret" } }); + }); + container.querySelectorAll("textarea").forEach((el) => { + fireEvent.focus(el); + fireEvent.change(el, { target: { value: '{"k":"v"}' } }); + fireEvent.blur(el); + }); + container.querySelectorAll("select").forEach((el) => { + const opts = Array.from(el.options).map((o) => o.value); + fireEvent.change(el, { target: { value: opts[opts.length - 1] } }); + }); + container.querySelectorAll("button.toggle").forEach((el) => { + fireEvent.click(el); + }); +} + +describe("Cloud configuration page", () => { + it("renders every provider panel and exercises every field with an empty config", () => { + const setConfig = vi.fn(); + const { container } = render(); + expect(screen.getByText("Cloud Configuration")).toBeInTheDocument(); + exerciseAllControls(container); + expect(setConfig).toHaveBeenCalled(); + // exercise the setConfig updater functions themselves + setConfig.mock.calls.forEach(([fn]) => fn({ cloud: {} })); + }); + + it("renders with every field pre-populated, taking the populated-value branches", () => { + const cloud = { + enable_aws: true, aws_access_key: "AKIA", aws_secret_key: "s3cr3t", aws_region: "us-west-2", enable_aws_batch: true, + enable_azure: true, azure_subscription_id: "sub", azure_tenant_id: "tenant", azure_batch_url: "https://x", enable_azure_batch: true, + enable_gcp: true, gcp_project_id: "proj", gcp_region: "us-east1", gcp_service_account_key: "{}", gcp_bucket: "gs://b", enable_gcp_batch: true, + enable_kubernetes: true, k8s_kubeconfig_path: "/k", k8s_context: "ctx", k8s_namespace: "ns", k8s_job_name_prefix: "p-", + k8s_service_account: "sa", k8s_sif_base_url: "s3://", k8s_results_uri_template: "s3://r", k8s_image_pull_policy: "Always", + k8s_aws_secret_name: "secretname", enable_k8s_jobs: true, + }; + render(); + expect(screen.getByDisplayValue("AKIA")).toBeInTheDocument(); + expect(screen.getByText("Future Providers")).toBeInTheDocument(); + expect(screen.getByText("Databricks Workflows")).toBeInTheDocument(); + }); +}); + +describe("LLM configuration page", () => { + it("renders every provider panel and exercises every field with an empty config", () => { + const setConfig = vi.fn(); + const { container } = render(); + expect(screen.getByText("LLM Configuration")).toBeInTheDocument(); + exerciseAllControls(container); + expect(setConfig).toHaveBeenCalled(); + setConfig.mock.calls.forEach(([fn]) => fn({ llm: {} })); + }); + + it("renders with every field pre-populated", () => { + const llm = { + enable_ollama: true, ollama_host: "http://x", local_model: "m", embedding_model: "e", enable_gpu: true, + enable_claude: true, claude_api_key: "sk-ant", claude_model: "claude", claude_max_tokens: "1000", enable_rag: true, + enable_openai: true, openai_api_key: "sk", openai_model: "gpt", offline_mode: true, default_model: "gpt-4o", + }; + render(); + expect(screen.getByDisplayValue("http://x")).toBeInTheDocument(); + }); +}); + +describe("HPC configuration page", () => { + it("renders every panel and exercises every field with an empty config", () => { + const setConfig = vi.fn(); + const { container } = render(); + expect(screen.getByText("HPC Configuration")).toBeInTheDocument(); + exerciseAllControls(container); + expect(setConfig).toHaveBeenCalled(); + setConfig.mock.calls.forEach(([fn]) => fn({ hpc: {} })); + }); + + it("renders with every field pre-populated", () => { + const hpc = { + enabled: true, scheduler: "pbs", enable_gpu: true, remote_execution: true, + hostname: "hpc.edu", port: "22", username: "u", private_key: "~/.ssh/id_rsa", + shared_mount: "/shared", apptainer_path: "/usr/bin/apptainer", partition: "gpu", + }; + render(); + expect(screen.getByDisplayValue("hpc.edu")).toBeInTheDocument(); + }); +}); diff --git a/tests/ui/coverage_matrix.test.jsx b/tests/ui/coverage_matrix.test.jsx new file mode 100644 index 00000000..5ef2dc64 --- /dev/null +++ b/tests/ui/coverage_matrix.test.jsx @@ -0,0 +1,132 @@ +import React from "react"; +import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../../src/ui/lib/session", () => ({ + isElectron: () => true, + getToken: () => "test-token", + getRefreshToken: () => null, + getCurrentUserSync: () => ({ userId: 1, email: "test@example.com", permissions: ["manage_config", "manage_roles"] }), + getCurrentUser: vi.fn().mockResolvedValue({ userId: 1, email: "test@example.com", permissions: ["manage_config", "manage_roles"] }), + onSessionChange: vi.fn(() => () => {}), + logout: vi.fn().mockResolvedValue(undefined), + loginWithPassword: vi.fn().mockResolvedValue({}), + loginWithLicenseKey: vi.fn().mockResolvedValue({}), + getOAuthLoginUrl: (provider) => `https://auth.test/${provider}`, + oauthProviders: () => ["google", "github"], + confirmOAuthLink: vi.fn().mockResolvedValue({}), + consumeOAuthRedirectParams: () => null, + refresh: vi.fn().mockResolvedValue(null), +})); + +vi.mock("../../src/ui/lib/rolesApi", () => ({ + listRoles: vi.fn().mockResolvedValue([]), + createRole: vi.fn().mockResolvedValue({ id: "new", name: "new", permissions: [] }), + getRole: vi.fn().mockResolvedValue({ id: "r1", name: "Reader", permissions: [] }), + updateRole: vi.fn().mockResolvedValue({}), + deleteRole: vi.fn().mockResolvedValue({}), + getUserRoles: vi.fn().mockResolvedValue([]), + setUserRoles: vi.fn().mockResolvedValue({}), +})); + +import Mode from "../../src/ui/pages/Mode"; +import LLM from "../../src/ui/pages/LLM"; +import Cloud from "../../src/ui/pages/Cloud"; +import HPC from "../../src/ui/pages/HPC"; +import Settings from "../../src/ui/pages/Settings"; +import Launch from "../../src/ui/pages/Launch"; +import Logs from "../../src/ui/pages/Logs"; +import Services from "../../src/ui/pages/Services"; +import IdeServices from "../../src/ui/pages/IdeServices"; +import Jobs from "../../src/ui/pages/Jobs"; +import Workbench from "../../src/ui/pages/Workbench"; +import RoleManagement from "../../src/ui/pages/RoleManagement"; +import ServiceViewer from "../../src/ui/pages/ServiceViewer"; +import Videos from "../../src/ui/pages/Videos"; +import Wizard from "../../src/ui/pages/Wizard"; +import BugReport from "../../src/ui/components/BugReport"; +import ErrorBoundary from "../../src/ui/components/ErrorBoundary"; +import LicenseGate from "../../src/ui/components/LicenseGate"; +import OAuthLinkConfirm from "../../src/ui/components/OAuthLinkConfirm"; +import UpdateBanner from "../../src/ui/components/UpdateBanner"; +import App from "../../src/ui/App"; + +const user = { userId: 1, email: "test@example.com", permissions: ["manage_config", "manage_roles"] }; +const config = { mode: "local", llm: {}, cloud: {}, hpc: {} }; +const setConfig = vi.fn((fn) => fn(config)); + +function okFetch(body = {}) { + return vi.fn().mockResolvedValue(new Response(JSON.stringify(body), { status: 200, headers: { "content-type": "application/json" } })); +} + +beforeEach(() => { + vi.stubGlobal("fetch", okFetch({})); + window.open = vi.fn(); + window.confirm = vi.fn(() => true); + delete window.api; + delete window.electronAPI; +}); +afterEach(() => { cleanup(); vi.unstubAllGlobals(); vi.clearAllMocks(); }); + +describe("page and component coverage matrix", () => { + it("walks the authorized production shell through every route", async () => { + render(); + await waitFor(() => expect(screen.getByText("Runtime Mode")).toBeInTheDocument()); + for (let step = 0; step <= 14; step += 1) { + window.dispatchEvent(new CustomEvent("navigate", { detail: step })); + await waitFor(() => expect(document.body.textContent.length).toBeGreaterThan(20)); + } + }); + it("renders configuration pages and exercises their controls", () => { + const pages = [[Mode, "Runtime Mode"], [LLM, "LLM Configuration"], [Cloud, "Cloud Configuration"], [HPC, "HPC Configuration"]]; + for (const [Page, title] of pages) { + const { unmount } = render(); + expect(screen.getByText(title)).toBeInTheDocument(); + unmount(); + } + render(); + expect(screen.getByText("Runtime Mode")).toBeInTheDocument(); + }); + + it("renders operational pages with safe failed/default service responses", async () => { + for (const [Page, props] of [ + [Settings, { config, setConfig, currentUser: user }], + [Launch, { config, onStatusChange: vi.fn() }], + [Logs, {}], + [Services, { config, currentUser: user }], + [IdeServices, { currentUser: user }], + [Jobs, {}], + [Workbench, {}], + ]) { + const { unmount } = render(); + await waitFor(() => expect(document.body.textContent.length).toBeGreaterThan(0)); + unmount(); + } + }); + + it("renders role, service, video, wizard, and overlay components", async () => { + const { unmount } = render(); + await waitFor(() => expect(document.body.textContent).toMatch(/role|permission/i)); + unmount(); + const back = vi.fn(); + render(); + fireEvent.click(screen.getByRole("button", { name: /back/i })); expect(back).toHaveBeenCalled(); cleanup(); + render(); await waitFor(() => expect(document.body.textContent).toMatch(/video|no videos|back/i)); cleanup(); + render(Wizard content); + expect(screen.getByText("Wizard content")).toBeInTheDocument(); cleanup(); + render(); + expect(screen.getByText(/link your account/i)).toBeInTheDocument(); cleanup(); + }); + + it("exercises bug reporting, license gate, update banner, and error recovery", async () => { + render(); fireEvent.click(screen.getByRole("button", { name: /report bug/i })); + fireEvent.change(screen.getByPlaceholderText("Bug title"), { target: { value: "Oops" } }); + fireEvent.change(screen.getByPlaceholderText("Describe what happened..."), { target: { value: "details" } }); + fireEvent.click(screen.getByRole("button", { name: /submit bug report/i })); expect(screen.getByText(/bug reported/i)).toBeInTheDocument(); cleanup(); + render(
licensed content
); await waitFor(() => expect(screen.getByText("licensed content")).toBeInTheDocument()); cleanup(); + const listeners = {}; window.api = { onUpdateAvailable: (cb) => { listeners.available = cb; }, onUpdateError: (cb) => { listeners.error = cb; } }; + render(); listeners.available({ version: "9" }); await waitFor(() => expect(screen.getByText(/v9/)).toBeInTheDocument()); cleanup(); + function Broken() { throw new Error("broken"); } + render(); expect(screen.getByText(/something went wrong/i)).toBeInTheDocument(); + }); +}); diff --git a/tests/ui/error-boundary.test.jsx b/tests/ui/error-boundary.test.jsx new file mode 100644 index 00000000..dc88d972 --- /dev/null +++ b/tests/ui/error-boundary.test.jsx @@ -0,0 +1,31 @@ +import React from "react"; +import { render, screen, fireEvent, cleanup } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import ErrorBoundary from "../../src/ui/components/ErrorBoundary"; + +function ThrowsWithMessage() { throw new Error("boom"); } +function ThrowsBare() { throw new Error(); } + +afterEach(() => { cleanup(); vi.restoreAllMocks(); }); + +describe("ErrorBoundary", () => { + it("renders children when there is no error", () => { + render(
fine
); + expect(screen.getByText("fine")).toBeInTheDocument(); + }); + + it("shows the caught error's message", () => { + render(); + expect(screen.getByText("Something went wrong")).toBeInTheDocument(); + expect(screen.getByText("boom")).toBeInTheDocument(); + }); + + it("falls back to the stringified error with no message, and reloads on click", () => { + const reload = vi.fn(); + Object.defineProperty(window, "location", { value: { ...window.location, reload }, writable: true, configurable: true }); + render(); + expect(screen.getByText("Error")).toBeInTheDocument(); // String(new Error()) + fireEvent.click(screen.getByText("Reload")); + expect(reload).toHaveBeenCalled(); + }); +}); diff --git a/tests/ui/grafana-viewer.test.jsx b/tests/ui/grafana-viewer.test.jsx new file mode 100644 index 00000000..3c2f4994 --- /dev/null +++ b/tests/ui/grafana-viewer.test.jsx @@ -0,0 +1,77 @@ +import React from "react"; +import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +// GRAFANA_BASE is computed once at module load from isElectron(), so each +// variant needs its own fresh module instance (vi.resetModules + a dynamic +// import) rather than toggling window state after import. +async function loadWith(isElectron) { + vi.resetModules(); + vi.doMock("../../src/ui/lib/session", () => ({ isElectron: () => isElectron })); + const mod = await import("../../src/ui/components/GrafanaViewer/GrafanaViewer"); + return mod.GrafanaViewer; +} + +afterEach(() => { cleanup(); vi.restoreAllMocks(); vi.doUnmock("../../src/ui/lib/session"); delete window.electronAPI; }); + +describe("GrafanaViewer — web build", () => { + it("skips login, renders the dashboard in an iframe, and switches tabs", async () => { + const GrafanaViewer = await loadWith(false); + const onBack = vi.fn(); + render(); + expect(await screen.findByText("Services")).toBeInTheDocument(); + const iframe = document.querySelector("iframe"); + expect(iframe).toBeTruthy(); + expect(iframe.title).toBe("My Dashboard"); + expect(document.querySelector("webview")).toBeNull(); + + fireEvent.click(screen.getByText("RAG")); + expect(document.querySelector("iframe").src).toContain("omnibioai-rag"); + + fireEvent.click(screen.getByText("← Back to Workbench")); + expect(onBack).toHaveBeenCalled(); + }); + + it("falls back to the default dashboard title with no label", async () => { + const GrafanaViewer = await loadWith(false); + render(); + expect(await screen.findByText("Metrics Dashboard")).toBeInTheDocument(); + }); +}); + +describe("GrafanaViewer — Electron build", () => { + it("shows a spinner while authenticating, then the dashboard in a webview on success", async () => { + const GrafanaViewer = await loadWith(true); + let resolveLogin; + window.electronAPI = { grafanaLogin: vi.fn(() => new Promise((res) => { resolveLogin = res; })) }; + const { container } = render(); + expect(container.querySelector(".omni-spinner, [class*=spinner]") || container.textContent).toBeDefined(); + expect(screen.queryByText("Services")).not.toBeInTheDocument(); + resolveLogin(); + expect(await screen.findByText("Services")).toBeInTheDocument(); + expect(document.querySelector("webview")).toBeTruthy(); + expect(document.querySelector("iframe")).toBeNull(); + }); + + it("shows the retry screen with the backend's error message on a failed login", async () => { + const GrafanaViewer = await loadWith(true); + window.electronAPI = { grafanaLogin: vi.fn().mockRejectedValue(new Error("Grafana unreachable")) }; + render(); + expect(await screen.findByText("Grafana unreachable")).toBeInTheDocument(); + expect(screen.getByText("Retry")).toBeInTheDocument(); + }); + + it("falls back to a generic error message and retries with a busy indicator", async () => { + const GrafanaViewer = await loadWith(true); + window.electronAPI = { grafanaLogin: vi.fn().mockRejectedValue(new Error()) }; + render(); + expect(await screen.findByText("Auth failed — check Grafana is running")).toBeInTheDocument(); + + let resolveRetry; + window.electronAPI.grafanaLogin.mockImplementationOnce(() => new Promise((res) => { resolveRetry = res; })); + fireEvent.click(screen.getByText("Retry")); + expect(await screen.findByText("Connecting…")).toBeInTheDocument(); + resolveRetry(); + await waitFor(() => expect(screen.getByText("Services")).toBeInTheDocument()); + }); +}); diff --git a/tests/ui/ide-services.test.jsx b/tests/ui/ide-services.test.jsx new file mode 100644 index 00000000..69140634 --- /dev/null +++ b/tests/ui/ide-services.test.jsx @@ -0,0 +1,84 @@ +import React from "react"; +import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import IdeServices from "../../src/ui/pages/IdeServices"; + +const admin = { email: "a@test", permissions: ["manage_config"] }; + +function jsonRes(body, status = 200) { + return new Response(JSON.stringify(body), { status }); +} + +function mockFetch(overrides = {}) { + return vi.fn((url) => { + const u = String(url); + for (const [match, respond] of Object.entries(overrides)) { + if (u.includes(match)) return respond(); + } + return Promise.resolve(jsonRes({ status: "stopped" })); + }); +} + +beforeEach(() => { delete window.api; delete window.electronAPI; }); +afterEach(() => { cleanup(); vi.restoreAllMocks(); delete window.api; delete window.electronAPI; }); + +describe("IdeServices page", () => { + it("maps running, starting, and a non-ok status response to their badges", async () => { + vi.stubGlobal("fetch", mockFetch({ + "status/jupyter/": () => Promise.resolve(jsonRes({ status: "Running" })), + "status/rstudio/": () => Promise.resolve(jsonRes({ status: "Starting" })), + "status/vscode/": () => Promise.resolve(new Response("", { status: 500 })), + })); + render(); + await waitFor(() => expect(screen.getByText("RUNNING")).toBeInTheDocument()); + expect(screen.getByText("STARTING")).toBeInTheDocument(); + expect(screen.getByText("STOPPED")).toBeInTheDocument(); + expect(screen.getByText("Open →")).toBeInTheDocument(); + expect(screen.getByText("Starting...")).toBeInTheDocument(); + expect(screen.getByText("Stopped — start from Services")).toBeInTheDocument(); + }); + + it("falls back to stopped when the status fetch throws", async () => { + vi.stubGlobal("fetch", mockFetch({ "status/jupyter/": () => Promise.reject(new Error("offline")) })); + render(); + await waitFor(() => expect(screen.getAllByText("STOPPED").length).toBeGreaterThan(0)); + }); + + it("opens a running tool via a nginx-proxied web path, or a direct Electron URL with a Jupyter token", async () => { + vi.stubGlobal("fetch", mockFetch({ "status/jupyter/": () => Promise.resolve(jsonRes({ status: "running" })) })); + const openSpy = vi.spyOn(window, "open").mockImplementation(() => {}); + render(); + await waitFor(() => expect(screen.getByText("Open →")).toBeInTheDocument()); + fireEvent.click(screen.getByText("Open →")); + expect(openSpy).toHaveBeenCalledWith("/jupyter/?token=devtoken", "_blank"); + cleanup(); + + window.api = {}; + window.electronAPI = { openExternal: vi.fn() }; + vi.stubGlobal("fetch", mockFetch({ + "status/jupyter/": () => Promise.resolve(jsonRes({ status: "running" })), + "status/rstudio/": () => Promise.resolve(jsonRes({ status: "running" })), + })); + render(); + const openButtons = await screen.findAllByText("Open →"); + fireEvent.click(openButtons[0]); + expect(window.electronAPI.openExternal).toHaveBeenCalledWith("http://192.168.86.234:8888?token=devtoken"); + fireEvent.click(openButtons[1]); + expect(window.electronAPI.openExternal).toHaveBeenCalledWith("http://192.168.86.234:8787"); + }); + + it("refreshes on demand and dims the Open button on hover out", async () => { + const fetchMock = mockFetch({ "status/jupyter/": () => Promise.resolve(jsonRes({ status: "running" })) }); + vi.stubGlobal("fetch", fetchMock); + render(); + const openBtn = await screen.findByText("Open →"); + fireEvent.mouseEnter(openBtn); + expect(openBtn.style.opacity).toBe("0.85"); + fireEvent.mouseLeave(openBtn); + expect(openBtn.style.opacity).toBe("1"); + + const before = fetchMock.mock.calls.length; + fireEvent.click(screen.getByText("↻ Refresh")); + await waitFor(() => expect(fetchMock.mock.calls.length).toBeGreaterThan(before)); + }); +}); diff --git a/tests/ui/jobs.test.jsx b/tests/ui/jobs.test.jsx new file mode 100644 index 00000000..df25fb00 --- /dev/null +++ b/tests/ui/jobs.test.jsx @@ -0,0 +1,234 @@ +import React from "react"; +import { render, screen, fireEvent, waitFor, cleanup, within } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { isElectron, getToken } = vi.hoisted(() => ({ + isElectron: vi.fn(() => false), + getToken: vi.fn(() => "tok"), +})); +vi.mock("../../src/ui/lib/session", () => ({ isElectron, getToken })); + +import Jobs from "../../src/ui/pages/Jobs"; + +const run1 = { run_id: "run-aaaaaaaaaaaaaaaaaaaa", tool_id: "align", state: "COMPLETED", server_id: "srv1", created_epoch: 1700000000 }; +const run2 = { run_id: "run-bbbbbbbbbbbbbbbbbbbb", tool_id: "call-variants", state: "RUNNING", server_id: null, created_epoch: 0 }; +const runs = [run1, run2]; +const tools = [ + { tool_id: "align", http: true }, + { tool_id: "call-variants", http: false, slurm: true }, + { tool_id: "plain-tool" }, +]; +const servers = [ + { server_id: "srv1", adapter_type: "local", capabilities: { cpu: 4 } }, + { server_id: "srv2", adapter_type: "slurm", capabilities: null }, +]; + +function jsonRes(body, status = 200) { + return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } }); +} + +function mockFetchByPath(handlers) { + return vi.fn((url) => { + for (const [suffix, respond] of handlers) { + if (String(url).endsWith(suffix)) return Promise.resolve(respond()); + } + return Promise.reject(new Error(`unhandled fetch: ${url}`)); + }); +} + +beforeEach(() => { + isElectron.mockReturnValue(false); + getToken.mockReturnValue("tok"); +}); +afterEach(() => { cleanup(); vi.restoreAllMocks(); vi.useRealTimers(); }); + +describe("Jobs page", () => { + it("loads runs, tools, and servers and renders the dashboard", async () => { + vi.stubGlobal("fetch", mockFetchByPath([ + ["/api/runs", () => jsonRes(runs)], + ["/api/tools", () => jsonRes(tools)], + ["/api/servers", () => jsonRes(servers)], + ])); + render(); + await waitFor(() => expect(screen.getByText("2", { selector: "div" })).toBeInTheDocument()); + expect(screen.getAllByText("align").length).toBeGreaterThan(0); + expect(screen.getByText("COMPLETED")).toBeInTheDocument(); + expect(screen.getByText("RUNNING")).toBeInTheDocument(); + expect(screen.getAllByText("—").length).toBeGreaterThan(0); // run2's null server/epoch -> dashes + // servers: one ready, one pending + expect(screen.getByText("● ready")).toBeInTheDocument(); + expect(screen.getByText("◐ pending")).toBeInTheDocument(); + // tools: http-only, slurm-only, local fallback + expect(screen.getByText("http")).toBeInTheDocument(); + expect(screen.getAllByText("slurm").length).toBeGreaterThan(0); + expect(screen.getAllByText("local").length).toBeGreaterThan(0); + }); + + it("shows an error banner when loading fails, and empty states with no data", async () => { + vi.stubGlobal("fetch", mockFetchByPath([ + ["/api/runs", () => new Response("boom", { status: 500, statusText: "Server Error" })], + ["/api/tools", () => jsonRes([])], + ["/api/servers", () => jsonRes([])], + ])); + render(); + await waitFor(() => expect(screen.getByText(/500 Server Error/)).toBeInTheDocument()); + expect(screen.getByText("No servers configured")).toBeInTheDocument(); + expect(screen.getByText("No tools registered")).toBeInTheDocument(); + }); + + it("shows the empty run-history state", async () => { + vi.stubGlobal("fetch", mockFetchByPath([ + ["/api/runs", () => jsonRes([])], + ["/api/tools", () => jsonRes([])], + ["/api/servers", () => jsonRes([])], + ])); + render(); + await waitFor(() => expect(screen.getByText("No runs yet — submit one above")).toBeInTheDocument()); + }); + + it("opens a run's detail panel with inputs and error, then closes it", async () => { + vi.stubGlobal("fetch", mockFetchByPath([ + ["/api/runs", () => jsonRes(runs)], + ["/api/tools", () => jsonRes([])], + ["/api/servers", () => jsonRes([])], + [`/api/runs/${run1.run_id}/logs`, () => jsonRes("line one\nline two")], + [`/api/runs/${run1.run_id}`, () => jsonRes({ ...run1, updated_epoch: 1700000100, exit_code: 0, inputs: { a: 1 }, error: { message: "oops" } })], + ])); + render(); + await waitFor(() => expect(screen.getByText("align")).toBeInTheDocument()); + fireEvent.click(screen.getByText("align")); + expect(await screen.findByText("Run Detail")).toBeInTheDocument(); + await waitFor(() => expect(screen.getByText(/"a": 1/)).toBeInTheDocument()); + expect(screen.getByText(/"message": "oops"/)).toBeInTheDocument(); + expect(screen.getByText(/line one/)).toBeInTheDocument(); + fireEvent.click(screen.getByText("✕")); + await waitFor(() => expect(screen.queryByText("Run Detail")).not.toBeInTheDocument()); + }); + + it("shows a logs error and renders minimal detail with no inputs/error", async () => { + vi.stubGlobal("fetch", mockFetchByPath([ + ["/api/runs", () => jsonRes(runs)], + ["/api/tools", () => jsonRes([])], + ["/api/servers", () => jsonRes([])], + [`/api/runs/${run2.run_id}/logs`, () => { throw new Error("network"); }], + [`/api/runs/${run2.run_id}`, () => jsonRes({ ...run2 })], + ])); + render(); + await waitFor(() => expect(screen.getByText("call-variants")).toBeInTheDocument()); + fireEvent.click(screen.getByText("call-variants")); + await waitFor(() => expect(screen.getByText(/Error loading logs/)).toBeInTheDocument()); + }); + + it("shows the 'No logs yet' placeholder when logs come back empty", async () => { + vi.stubGlobal("fetch", mockFetchByPath([ + ["/api/runs", () => jsonRes(runs)], + ["/api/tools", () => jsonRes([])], + ["/api/servers", () => jsonRes([])], + [`/api/runs/${run1.run_id}/logs`, () => jsonRes("")], + [`/api/runs/${run1.run_id}`, () => jsonRes({ ...run1 })], + ])); + render(); + await waitFor(() => expect(screen.getByText("align")).toBeInTheDocument()); + fireEvent.click(screen.getByText("align")); + await waitFor(() => expect(screen.getByText("No logs yet")).toBeInTheDocument()); + }); + + it("validates submit JSON, submits a new run, and resets the form", async () => { + const fetchMock = mockFetchByPath([ + ["/api/runs", () => jsonRes(runs)], + ["/api/tools", () => jsonRes(tools)], + ["/api/servers", () => jsonRes([])], + ["/api/runs/submit", () => jsonRes({ run_id: "new" })], + ]); + vi.stubGlobal("fetch", fetchMock); + render(); + await waitFor(() => expect(screen.getAllByText("align").length).toBeGreaterThan(0)); + + fireEvent.click(screen.getByRole("button", { name: "+ Submit Run" })); + expect(screen.getByText("Submit New Run")).toBeInTheDocument(); + + const submitBtn = screen.getByRole("button", { name: "Submit" }); + expect(submitBtn).toBeDisabled(); // no tool selected yet + + const select = screen.getByDisplayValue("— select tool —"); + fireEvent.change(select, { target: { value: "align" } }); + expect(submitBtn).not.toBeDisabled(); + + const [inputsBox, resourcesBox] = screen.getAllByRole("textbox").slice(-2); + fireEvent.change(inputsBox, { target: { value: "{not json" } }); + fireEvent.click(submitBtn); + expect(await screen.findByText(/JSON parse error/)).toBeInTheDocument(); + + fireEvent.change(inputsBox, { target: { value: '{"sample":"s1"}' } }); + fireEvent.change(resourcesBox, { target: { value: '{"cpu":2}' } }); + fireEvent.click(submitBtn); + await waitFor(() => expect(screen.queryByText("Submit New Run")).not.toBeInTheDocument()); + expect(fetchMock).toHaveBeenCalledWith( + expect.stringContaining("/api/runs/submit"), + expect.objectContaining({ method: "POST", body: expect.stringContaining("sample") }) + ); + + // cancel closes the form too + fireEvent.click(screen.getByRole("button", { name: "+ Submit Run" })); + fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + expect(screen.queryByText("Submit New Run")).not.toBeInTheDocument(); + }); + + it("surfaces a submit failure without closing the form", async () => { + vi.stubGlobal("fetch", mockFetchByPath([ + ["/api/runs", () => jsonRes([])], + ["/api/tools", () => jsonRes(tools)], + ["/api/servers", () => jsonRes([])], + ["/api/runs/submit", () => new Response("nope", { status: 400, statusText: "Bad Request" })], + ])); + render(); + await waitFor(() => expect(screen.getByText("No runs yet — submit one above")).toBeInTheDocument()); + fireEvent.click(screen.getByRole("button", { name: "+ Submit Run" })); + fireEvent.change(screen.getByDisplayValue("— select tool —"), { target: { value: "align" } }); + fireEvent.click(screen.getByRole("button", { name: "Submit" })); + await waitFor(() => expect(screen.getByText(/400 Bad Request/)).toBeInTheDocument()); + expect(screen.getByText("Submit New Run")).toBeInTheDocument(); + }); + + it("uses the direct TES host URL under Electron instead of the /_tes proxy", async () => { + isElectron.mockReturnValue(true); + const fetchMock = mockFetchByPath([ + ["/api/runs", () => jsonRes([])], + ["/api/tools", () => jsonRes([])], + ["/api/servers", () => jsonRes([])], + ]); + vi.stubGlobal("fetch", fetchMock); + render(); + await waitFor(() => expect(screen.getByText("No runs yet — submit one above")).toBeInTheDocument()); + expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining(":8081/api/runs"), expect.anything()); + }); + + it("highlights a run row on hover and clears it back out", async () => { + vi.stubGlobal("fetch", mockFetchByPath([ + ["/api/runs", () => jsonRes(runs)], + ["/api/tools", () => jsonRes([])], + ["/api/servers", () => jsonRes([])], + ])); + render(); + await waitFor(() => expect(screen.getAllByText("align").length).toBeGreaterThan(0)); + const row = screen.getAllByText("align")[0].closest("tr"); + fireEvent.mouseEnter(row); + expect(row.style.background).toBe("rgba(255, 255, 255, 0.02)"); + fireEvent.mouseLeave(row); + expect(row.style.background).toBe("transparent"); + }); + + it("refreshes on demand via the Refresh button", async () => { + const fetchMock = mockFetchByPath([ + ["/api/runs", () => jsonRes([])], + ["/api/tools", () => jsonRes([])], + ["/api/servers", () => jsonRes([])], + ]); + vi.stubGlobal("fetch", fetchMock); + render(); + await waitFor(() => expect(screen.getByText("No runs yet — submit one above")).toBeInTheDocument()); + const before = fetchMock.mock.calls.length; + fireEvent.click(screen.getByRole("button", { name: "↻ Refresh" })); + await waitFor(() => expect(fetchMock.mock.calls.length).toBeGreaterThan(before)); + }); +}); diff --git a/tests/ui/launch.test.jsx b/tests/ui/launch.test.jsx new file mode 100644 index 00000000..7c8c61dc --- /dev/null +++ b/tests/ui/launch.test.jsx @@ -0,0 +1,150 @@ +import React from "react"; +import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import Launch from "../../src/ui/pages/Launch"; + +beforeEach(() => { delete window.api; }); +afterEach(() => { cleanup(); vi.restoreAllMocks(); vi.useRealTimers(); delete window.api; }); + +describe("Launch page — local mode", () => { + it("shows idle status, polls health via window.api, and renders the summary table", async () => { + window.api = { + checkHealth: vi.fn().mockResolvedValue({ mysql: true, workbench: true, tes: true, ollama: false, rag: false }), + }; + const config = { mode: "local", llm: { enable_ollama: true, enable_claude: true }, cloud: { enable_aws_batch: true }, hpc: { scheduler: "slurm" } }; + render(); + expect(screen.getByText("Boot System")).toBeInTheDocument(); + await waitFor(() => expect(screen.getAllByText("● UP").length).toBe(3)); + expect(screen.getByText("◐ INIT")).toBeInTheDocument(); // ollama false -> warn + expect(screen.getAllByText("Enabled").length).toBeGreaterThan(0); + cleanup(); + + // Inverted health + fully-enabled config: covers the opposite half of + // every up/down ternary and every summary-row branch above. + window.api = { + checkHealth: vi.fn().mockResolvedValue({ mysql: false, workbench: false, tes: false, ollama: true, rag: true }), + }; + const fullConfig = { + mode: "local", + llm: { enable_ollama: true, enable_claude: true, enable_openai: true }, + cloud: { enable_aws_batch: true }, + hpc: { scheduler: "slurm" }, + }; + render(); + await waitFor(() => expect(screen.getAllByText("✕ DOWN").length).toBe(3)); + expect(screen.getAllByText("Enabled").length).toBe(4); // ollama, claude, openai, aws batch + }); + + it("shows the beta-mode default in the summary when mode is unset", async () => { + render(); + expect(await screen.findByText("beta")).toBeInTheDocument(); + }); + + it("shows disabled/not-configured summary rows and skips polling with no window.api", async () => { + render(); + expect(screen.getByText("Not configured")).toBeInTheDocument(); + expect(screen.getAllByText("Disabled").length).toBeGreaterThan(0); + expect(screen.getAllByText("— —").length).toBeGreaterThan(0); // health never resolved + }); + + it("registers a docker log listener and appends streamed lines", async () => { + let onLog; + window.api = { onDockerLog: vi.fn((cb) => { onLog = cb; }) }; + render(); + await waitFor(() => expect(window.api.onDockerLog).toHaveBeenCalled()); + onLog("pulling image..."); + await waitFor(() => expect(screen.getByText("pulling image...")).toBeInTheDocument()); + }); + + it("boots the system through the full Electron API path and reflects running state", async () => { + window.api = { + saveConfig: vi.fn().mockResolvedValue(), + startDocker: vi.fn().mockResolvedValue(), + openWorkbench: vi.fn().mockResolvedValue(), + }; + const onStatusChange = vi.fn(); + render(); + fireEvent.click(screen.getByText("Boot System")); + expect(screen.getByText("Booting...")).toBeInTheDocument(); + await waitFor(() => expect(screen.getByText("● Running")).toBeInTheDocument()); + expect(onStatusChange).toHaveBeenCalledWith("starting"); + expect(onStatusChange).toHaveBeenCalledWith("running"); + expect(screen.getByText("OmniBioAI Studio is running")).toBeInTheDocument(); + // clicking again while running is a no-op guard + fireEvent.click(screen.getByText("● Running")); + expect(window.api.startDocker).toHaveBeenCalledTimes(1); + }); + + it("falls back to a simulated boot with a warning when no Electron API is present", async () => { + vi.useFakeTimers(); + render(); + fireEvent.click(screen.getByText("Boot System")); + await vi.advanceTimersByTimeAsync(1200); + await vi.waitFor(() => expect(screen.getByText("Electron API not available — run via: npm run dev")).toBeInTheDocument()); + await vi.waitFor(() => expect(screen.getByText("● Running")).toBeInTheDocument()); + }); + + it("surfaces a boot failure and sets error status", async () => { + window.api = { saveConfig: vi.fn().mockRejectedValue(new Error("disk full")) }; + const onStatusChange = vi.fn(); + render(); + fireEvent.click(screen.getByText("Boot System")); + await waitFor(() => expect(screen.getByText("Boot failed: disk full")).toBeInTheDocument()); + expect(onStatusChange).toHaveBeenCalledWith("error"); + }); + + it("stops the stack via window.api, and simulates a stop without it", async () => { + window.api = { stopDocker: vi.fn().mockResolvedValue() }; + const onStatusChange = vi.fn(); + render(); + fireEvent.click(screen.getByText("Stop Stack")); + await waitFor(() => expect(screen.getByText("Stack stopped cleanly")).toBeInTheDocument()); + expect(onStatusChange).toHaveBeenCalledWith("idle"); + + cleanup(); + delete window.api; + vi.useFakeTimers(); + render(); + fireEvent.click(screen.getByText("Stop Stack")); + await vi.advanceTimersByTimeAsync(600); + await vi.waitFor(() => expect(screen.getByText("Stack stopped cleanly")).toBeInTheDocument()); + }); + + it("surfaces a stop failure", async () => { + window.api = { stopDocker: vi.fn().mockRejectedValue(new Error("timeout")) }; + render(); + fireEvent.click(screen.getByText("Stop Stack")); + await waitFor(() => expect(screen.getByText("Stop failed: timeout")).toBeInTheDocument()); + }); + + it("clears the log panel", async () => { + render(); + expect(screen.getByText("Studio initialized — waiting for boot")).toBeInTheDocument(); + fireEvent.click(screen.getByText("CLEAR")); + expect(screen.queryByText("Studio initialized — waiting for boot")).not.toBeInTheDocument(); + }); +}); + +describe("Launch page — beta mode", () => { + it("shows the connected-to-cloud badge and polls tunnel URLs, logging state transitions", async () => { + vi.useFakeTimers(); + const fetchMock = vi.fn().mockImplementation((url) => + Promise.resolve(new Response("", { status: String(url).includes("mysql") || String(url).includes("lims") ? 500 : 200 })) + ); + vi.stubGlobal("fetch", fetchMock); + render(); + expect(screen.getByText("Connected to Cloud")).toBeInTheDocument(); + expect(screen.getByText("Connection Events")).toBeInTheDocument(); + await vi.waitFor(() => expect(screen.getAllByText("● UP").length).toBeGreaterThan(0)); + expect(screen.getByText("✕ DOWN")).toBeInTheDocument(); // lims/mysql tunnel + + // flip the failing tunnel to healthy on the next poll to trigger a transition log + fetchMock.mockImplementation(() => Promise.resolve(new Response("", { status: 200 }))); + await vi.advanceTimersByTimeAsync(5000); + await vi.waitFor(() => expect(screen.getAllByText(/tunnel — reachable/).length).toBeGreaterThan(0)); + + fetchMock.mockImplementation(() => Promise.reject(new Error("offline"))); + await vi.advanceTimersByTimeAsync(5000); + await vi.waitFor(() => expect(screen.getAllByText(/tunnel — unreachable/).length).toBeGreaterThan(0)); + }); +}); diff --git a/tests/ui/license-gate.test.jsx b/tests/ui/license-gate.test.jsx new file mode 100644 index 00000000..e4503797 --- /dev/null +++ b/tests/ui/license-gate.test.jsx @@ -0,0 +1,68 @@ +import React from "react"; +import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import LicenseGate from "../../src/ui/components/LicenseGate"; + +beforeEach(() => { delete window.electronAPI; }); +afterEach(() => { cleanup(); delete window.electronAPI; }); + +describe("LicenseGate", () => { + it("bypasses the gate entirely outside Electron (dev mode)", async () => { + render(
protected content
); + await waitFor(() => expect(screen.getByText("protected content")).toBeInTheDocument()); + expect(screen.getByText(/dev · expires/)).toBeInTheDocument(); + }); + + it("reuses a cached, unexpired license", async () => { + window.electronAPI = { getLicense: vi.fn().mockResolvedValue({ valid: true, tier: "pro", expiry: "2099-01-01", days_remaining: 10 }) }; + render(
protected content
); + await waitFor(() => expect(screen.getByText("protected content")).toBeInTheDocument()); + expect(screen.getByText(/pro · expires 2099-01-01 · 10d left/)).toBeInTheDocument(); + }); + + it("falls through to the entry form for an expired or invalid cached license, or a lookup error", async () => { + window.electronAPI = { getLicense: vi.fn().mockResolvedValue({ valid: true, expiry: "2000-01-01" }) }; + render(
protected content
); + await waitFor(() => expect(screen.getByText("OmniBioAI Studio")).toBeInTheDocument()); + cleanup(); + + window.electronAPI = { getLicense: vi.fn().mockResolvedValue({ valid: false }) }; + render(
protected content
); + await waitFor(() => expect(screen.getByText("OmniBioAI Studio")).toBeInTheDocument()); + cleanup(); + + window.electronAPI = { getLicense: vi.fn().mockRejectedValue(new Error("ipc down")) }; + render(
protected content
); + await waitFor(() => expect(screen.getByText("OmniBioAI Studio")).toBeInTheDocument()); + }); + + it("validates a license key: empty input, invalid with and without a reason, a thrown error, and success", async () => { + window.electronAPI = { + getLicense: vi.fn().mockResolvedValue(null), + validateLicense: vi.fn(), + }; + render(
protected content
); + await waitFor(() => expect(screen.getByText("OmniBioAI Studio")).toBeInTheDocument()); + + fireEvent.click(screen.getByText("Activate License")); + expect(await screen.findByText("Please enter a license key")).toBeInTheDocument(); + + const input = screen.getByPlaceholderText("OMNI-XXXX-XXXX-XXXX-XXXX"); + fireEvent.change(input, { target: { value: "BAD-KEY" } }); + window.electronAPI.validateLicense.mockResolvedValueOnce({ valid: false, reason: "This key was revoked" }); + fireEvent.click(screen.getByText("Activate License")); + expect(await screen.findByText("This key was revoked")).toBeInTheDocument(); + + window.electronAPI.validateLicense.mockResolvedValueOnce({ valid: false }); + fireEvent.click(screen.getByText("Activate License")); + expect(await screen.findByText("Invalid license key")).toBeInTheDocument(); + + window.electronAPI.validateLicense.mockRejectedValueOnce(new Error("network down")); + fireEvent.click(screen.getByText("Activate License")); + expect(await screen.findByText("Validation failed: network down")).toBeInTheDocument(); + + window.electronAPI.validateLicense.mockResolvedValueOnce({ valid: true, tier: "beta", expiry: "2099-06-01", days_remaining: 30 }); + fireEvent.keyDown(input, { key: "Enter" }); + await waitFor(() => expect(screen.getByText("protected content")).toBeInTheDocument()); + }); +}); diff --git a/tests/ui/logs.test.jsx b/tests/ui/logs.test.jsx new file mode 100644 index 00000000..4e048588 --- /dev/null +++ b/tests/ui/logs.test.jsx @@ -0,0 +1,81 @@ +import React from "react"; +import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import Logs from "../../src/ui/pages/Logs"; + +beforeEach(() => { delete window.api; }); +afterEach(() => { cleanup(); vi.useRealTimers(); delete window.api; }); + +describe("Logs page", () => { + it("renders the demo log stream, filters by source and search text, and clears it", () => { + render(); + expect(screen.getByText(/Log Stream — 7 entries/)).toBeInTheDocument(); + expect(screen.getByText("● STREAMING")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "mysql" })); + expect(screen.getByText(/Log Stream — 1 entries/)).toBeInTheDocument(); + expect(screen.getByText("MySQL ready on port 3306")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "all" })); + fireEvent.change(screen.getByPlaceholderText("search logs..."), { target: { value: "toolserver ready" } }); + expect(screen.getByText(/Log Stream — 1 entries/)).toBeInTheDocument(); + + fireEvent.change(screen.getByPlaceholderText("search logs..."), { target: { value: "nothing matches this" } }); + expect(screen.getByText("No log entries match current filter.")).toBeInTheDocument(); + + fireEvent.change(screen.getByPlaceholderText("search logs..."), { target: { value: "" } }); + fireEvent.click(screen.getByText("Clear")); + expect(screen.getByText("No log entries match current filter.")).toBeInTheDocument(); + }); + + it("toggles pause/resume and highlights a row on hover", () => { + render(); + const row = screen.getByText("MySQL ready on port 3306").closest("div.studio-log-row"); + fireEvent.mouseEnter(row); + expect(row.style.background).toBe("rgba(255, 255, 255, 0.02)"); + fireEvent.mouseLeave(row); + expect(row.style.background).toBe("transparent"); + + fireEvent.click(screen.getByText("⏸ Pause")); + expect(screen.getByText("▶ Resume")).toBeInTheDocument(); + expect(screen.getByText("⏸ PAUSED")).toBeInTheDocument(); + fireEvent.click(screen.getByText("▶ Resume")); + expect(screen.getByText("⏸ Pause")).toBeInTheDocument(); + }); + + it("focuses and blurs the search box", () => { + render(); + const input = screen.getByPlaceholderText("search logs..."); + fireEvent.focus(input); + expect(input.style.borderColor).toBe("rgba(0, 229, 160, 0.4)"); + fireEvent.blur(input); + expect(input.style.borderColor).toBe("var(--border2)"); + }); + + it("streams lines from window.api.streamLogs and skips them while paused", async () => { + let onLine; + window.api = { streamLogs: vi.fn((cb) => { onLine = cb; }) }; + render(); + expect(screen.getByText("LIVE")).toBeInTheDocument(); + onLine("container started"); + await waitFor(() => expect(screen.getByText("container started")).toBeInTheDocument()); + + fireEvent.click(screen.getByText("⏸ Pause")); + onLine("should be dropped"); + expect(screen.queryByText("should be dropped")).not.toBeInTheDocument(); + }); + + it("simulates dev-mode log growth on an interval, cycling messages, and pausing it", async () => { + vi.useFakeTimers(); + render(); + expect(screen.queryByText("LIVE")).not.toBeInTheDocument(); + + await vi.advanceTimersByTimeAsync(2500 * 7); // one full cycle of the 6 demo messages + 1 more + expect(screen.getByText(/Log Stream — 1[0-9] entries/)).toBeInTheDocument(); + + fireEvent.click(screen.getByText("⏸ Pause")); + const before = screen.getByText(/Log Stream — /).textContent; + await vi.advanceTimersByTimeAsync(2500 * 3); + expect(screen.getByText(/Log Stream — /).textContent).toBe(before); + }); +}); diff --git a/tests/ui/mode.test.jsx b/tests/ui/mode.test.jsx new file mode 100644 index 00000000..39310302 --- /dev/null +++ b/tests/ui/mode.test.jsx @@ -0,0 +1,30 @@ +import React from "react"; +import { render, screen, fireEvent, cleanup } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import Mode from "../../src/ui/pages/Mode"; + +afterEach(() => cleanup()); + +const admin = { permissions: ["manage_config"] }; + +describe("Mode page", () => { + it("selects the active (enabled) mode and shows its selected styling", () => { + const setConfig = vi.fn(); + render(); + expect(screen.getByText("Beta Cloud")).toBeInTheDocument(); + fireEvent.click(screen.getByText("Beta Cloud")); // click bubbles up to the card's onClick + expect(setConfig).toHaveBeenCalled(); + const [updater] = setConfig.mock.calls[0]; + expect(updater({ mode: "x" })).toMatchObject({ mode: "beta" }); + }); + + it("does not select a disabled mode, and shows its tooltip and coming-soon label", () => { + const setConfig = vi.fn(); + render(); + expect(screen.getAllByText("(coming soon)").length).toBe(4); // Local, HPC, Cloud, Hybrid + const disabledCards = screen.getAllByTitle("Available in future release"); + expect(disabledCards).toHaveLength(4); + fireEvent.click(disabledCards[0]); + expect(setConfig).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/ui/nav-components.test.jsx b/tests/ui/nav-components.test.jsx new file mode 100644 index 00000000..a69132cf --- /dev/null +++ b/tests/ui/nav-components.test.jsx @@ -0,0 +1,87 @@ +import React from "react"; +import { render, screen, fireEvent, cleanup } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +const { logout } = vi.hoisted(() => ({ logout: vi.fn() })); +vi.mock("../../src/ui/lib/session", () => ({ logout })); + +import Sidebar from "../../src/ui/components/Sidebar"; +import MobileNav from "../../src/ui/components/MobileNav"; +import UpdateBanner from "../../src/ui/components/UpdateBanner"; + +const nav = [{ section: "Runtime", items: [{ name: "Launch", idx: 4 }] }]; +const user = { email: "u@test" }; + +afterEach(() => { cleanup(); vi.clearAllMocks(); delete window.api; }); + +describe("Sidebar", () => { + it("shows the signed-in user and signs out on click", () => { + render(); + expect(screen.getByText("u@test")).toBeInTheDocument(); + fireEvent.click(screen.getByText("Sign out")); + expect(logout).toHaveBeenCalled(); + }); + + it("hides the signed-in block when signed out, and falls back to IDLE for an unknown status", () => { + render(); + expect(screen.queryByText("Sign out")).not.toBeInTheDocument(); + expect(screen.getByText("IDLE")).toBeInTheDocument(); + }); +}); + +describe("MobileNav", () => { + it("closes on backdrop click, and signs out and closes on Sign out click", () => { + const onClose = vi.fn(); + const { container } = render(); + fireEvent.click(container.querySelector('[aria-hidden="true"]')); + expect(onClose).toHaveBeenCalledTimes(1); + + fireEvent.click(screen.getByText("Sign out")); + expect(logout).toHaveBeenCalled(); + expect(onClose).toHaveBeenCalledTimes(2); + }); + + it("navigates via the Space key", () => { + const setStep = vi.fn(); + const onClose = vi.fn(); + render(); + fireEvent.keyDown(screen.getByRole("button", { name: "Launch" }), { key: " " }); + expect(setStep).toHaveBeenCalledWith(4); + expect(onClose).toHaveBeenCalled(); + }); + + it("closes itself when the active step changes elsewhere while open", () => { + const onClose = vi.fn(); + const { rerender } = render(); + rerender(); + expect(onClose).toHaveBeenCalled(); + }); + + it("ignores other keys and renders closed with no drawer effects", () => { + const { container } = render(); + expect(container.querySelector('[role="dialog"]')).toHaveAttribute("aria-hidden", "true"); + }); +}); + +describe("UpdateBanner", () => { + it("renders nothing without window.api.onUpdateAvailable", () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it("shows an update-available message and an error with a dismiss button", async () => { + const listeners = {}; + window.api = { + onUpdateAvailable: (cb) => { listeners.available = cb; }, + onUpdateError: (cb) => { listeners.error = cb; }, + }; + render(); + listeners.available({ version: "9.0.0" }); + expect(await screen.findByText(/v9\.0\.0 is available/)).toBeInTheDocument(); + + listeners.error({ message: "checksum mismatch" }); + expect(await screen.findByText("Update failed: checksum mismatch")).toBeInTheDocument(); + fireEvent.click(screen.getByText("dismiss")); + expect(screen.queryByText(/Update failed/)).not.toBeInTheDocument(); + }); +}); diff --git a/tests/ui/oauth-link-confirm.test.jsx b/tests/ui/oauth-link-confirm.test.jsx new file mode 100644 index 00000000..6bc30a77 --- /dev/null +++ b/tests/ui/oauth-link-confirm.test.jsx @@ -0,0 +1,46 @@ +import React from "react"; +import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +const { confirmOAuthLink } = vi.hoisted(() => ({ confirmOAuthLink: vi.fn() })); +vi.mock("../../src/ui/lib/session", () => ({ confirmOAuthLink })); + +import OAuthLinkConfirm from "../../src/ui/components/OAuthLinkConfirm"; + +afterEach(() => { cleanup(); vi.clearAllMocks(); }); + +describe("OAuthLinkConfirm", () => { + it("links the account on submit and reports an unknown provider by name", async () => { + confirmOAuthLink.mockResolvedValueOnce({}); + const onDone = vi.fn(); + render(); + expect(screen.getByText(/link your okta sign-in/)).toBeInTheDocument(); + fireEvent.change(screen.getByPlaceholderText("••••••••"), { target: { value: "secret" } }); + fireEvent.click(screen.getByText("Link account")); + await waitFor(() => expect(confirmOAuthLink).toHaveBeenCalledWith("lt", "secret")); + await waitFor(() => expect(onDone).toHaveBeenCalled()); + }); + + it("maps known provider labels", () => { + render(); + expect(screen.getByText(/link your GitHub sign-in/)).toBeInTheDocument(); + }); + + it("shows an error message on failure, falling back to a generic one with no message", async () => { + confirmOAuthLink.mockRejectedValueOnce(new Error("wrong password")); + render(); + fireEvent.click(screen.getByText("Link account")); + expect(await screen.findByText("wrong password")).toBeInTheDocument(); + + confirmOAuthLink.mockRejectedValueOnce(new Error()); + fireEvent.click(screen.getByText("Link account")); + expect(await screen.findByText("Could not confirm the link")).toBeInTheDocument(); + }); + + it("calls onCancel", () => { + const onCancel = vi.fn(); + render(); + fireEvent.click(screen.getByText("Cancel")); + expect(onCancel).toHaveBeenCalled(); + }); +}); diff --git a/tests/ui/role-management.test.jsx b/tests/ui/role-management.test.jsx new file mode 100644 index 00000000..11302831 --- /dev/null +++ b/tests/ui/role-management.test.jsx @@ -0,0 +1,248 @@ +import React from "react"; +import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const rolesApi = vi.hoisted(() => ({ + listRoles: vi.fn(), + createRole: vi.fn(), + getRole: vi.fn(), + updateRole: vi.fn(), + deleteRole: vi.fn(), + getUserRoles: vi.fn(), + setUserRoles: vi.fn(), +})); +vi.mock("../../src/ui/lib/rolesApi", () => rolesApi); + +import RoleManagement from "../../src/ui/pages/RoleManagement"; + +const admin = { email: "admin@test", permissions: ["manage_roles"] }; +const twoRoles = [ + { id: "r1", name: "Reader", permission_count: 2, user_count: 0 }, + { id: "r2", name: "Writer", permission_count: 1, user_count: 3 }, +]; + +beforeEach(() => { + rolesApi.listRoles.mockReset().mockResolvedValue(twoRoles); + rolesApi.createRole.mockReset(); + rolesApi.getRole.mockReset(); + rolesApi.updateRole.mockReset(); + rolesApi.deleteRole.mockReset(); + rolesApi.getUserRoles.mockReset(); + rolesApi.setUserRoles.mockReset(); + window.confirm = vi.fn(() => true); +}); +afterEach(() => cleanup()); + +describe("RoleManagement gating", () => { + it("requires sign-in when there is no current user", () => { + render(); + expect(screen.getByText("Sign in required")).toBeInTheDocument(); + }); + + it("denies access without the manage_roles permission", () => { + render(); + expect(screen.getByText("Access denied")).toBeInTheDocument(); + expect(screen.getByText(/requires: manage_roles/)).toBeInTheDocument(); + }); + + it("denies access when the user carries no permissions list at all", () => { + render(); + expect(screen.getByText("Access denied")).toBeInTheDocument(); + }); +}); + +describe("RoleManagement console — roles list", () => { + it("loads and renders roles, and surfaces a load error", async () => { + render(); + await waitFor(() => expect(screen.getByText("Reader")).toBeInTheDocument()); + expect(screen.getByText("Writer")).toBeInTheDocument(); + cleanup(); + + rolesApi.listRoles.mockRejectedValueOnce(new Error("boom")); + render(); + await waitFor(() => expect(screen.getByText("boom")).toBeInTheDocument()); + cleanup(); + + rolesApi.listRoles.mockRejectedValueOnce(new Error()); + render(); + await waitFor(() => expect(screen.getByText("Failed to load roles")).toBeInTheDocument()); + }); + + it("shows the empty-state message with no roles", async () => { + rolesApi.listRoles.mockResolvedValueOnce([]); + render(); + await waitFor(() => expect(screen.getByText(/No roles yet/)).toBeInTheDocument()); + }); + + it("deletes a role after confirmation, and cancels without confirmation", async () => { + render(); + await waitFor(() => expect(screen.getByText("Reader")).toBeInTheDocument()); + const deleteButtons = screen.getAllByRole("button", { name: "Delete" }); + + window.confirm.mockReturnValueOnce(false); + fireEvent.click(deleteButtons[0]); + expect(rolesApi.deleteRole).not.toHaveBeenCalled(); + + rolesApi.deleteRole.mockResolvedValueOnce(undefined); + rolesApi.listRoles.mockResolvedValueOnce([twoRoles[1]]); + fireEvent.click(deleteButtons[0]); + await waitFor(() => expect(rolesApi.deleteRole).toHaveBeenCalledWith("r1")); + await waitFor(() => expect(screen.getByText(/deleted/)).toBeInTheDocument()); + }); + + it("shows a conflict message when deleting a role still assigned to users", async () => { + render(); + await waitFor(() => expect(screen.getByText("Writer")).toBeInTheDocument()); + const err = new Error("conflict"); err.status = 409; + rolesApi.deleteRole.mockRejectedValueOnce(err); + const deleteButtons = screen.getAllByRole("button", { name: "Delete" }); + fireEvent.click(deleteButtons[1]); // Writer, user_count 3 + await waitFor(() => expect(screen.getByText(/still assigned to 3 user\(s\)/)).toBeInTheDocument()); + }); + + it("shows a generic delete failure message, falling back when the error has no message", async () => { + render(); + await waitFor(() => expect(screen.getByText("Reader")).toBeInTheDocument()); + rolesApi.deleteRole.mockRejectedValueOnce(new Error("network down")); + fireEvent.click(screen.getAllByRole("button", { name: "Delete" })[0]); + await waitFor(() => expect(screen.getByText("network down")).toBeInTheDocument()); + + rolesApi.deleteRole.mockRejectedValueOnce(new Error()); + fireEvent.click(screen.getAllByRole("button", { name: "Delete" })[0]); + await waitFor(() => expect(screen.getByText("Delete failed")).toBeInTheDocument()); + }); +}); + +describe("RoleManagement console — role detail", () => { + it("creates a new role, validating the name and toggling permissions", async () => { + render(); + await waitFor(() => expect(screen.getByText("Reader")).toBeInTheDocument()); + fireEvent.click(screen.getByRole("button", { name: "+ New Role" })); + expect(screen.getByText(/New Role/)).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Save" })); + expect(await screen.findByText("Role name is required")).toBeInTheDocument(); + + fireEvent.change(screen.getByPlaceholderText("e.g. data_scientist"), { target: { value: "data_scientist" } }); + const permInput = screen.getByPlaceholderText("e.g. read:samples"); + fireEvent.change(permInput, { target: { value: "read:samples" } }); + fireEvent.click(screen.getByRole("button", { name: "Add" })); + expect(screen.getByText("read:samples")).toBeInTheDocument(); + // duplicate/empty adds are no-ops + fireEvent.change(permInput, { target: { value: "read:samples" } }); + fireEvent.click(screen.getByRole("button", { name: "Add" })); + expect(screen.getAllByText("read:samples")).toHaveLength(1); + fireEvent.click(screen.getByTitle("Remove read:samples")); + expect(screen.queryByText("read:samples")).not.toBeInTheDocument(); + expect(screen.getByText("No permissions assigned")).toBeInTheDocument(); + + fireEvent.change(permInput, { target: { value: "write:samples" } }); + fireEvent.submit(permInput.closest("form")); + expect(screen.getByText("write:samples")).toBeInTheDocument(); + + rolesApi.createRole.mockResolvedValueOnce({}); + rolesApi.listRoles.mockResolvedValueOnce(twoRoles); + fireEvent.click(screen.getByRole("button", { name: "Save" })); + await waitFor(() => expect(rolesApi.createRole).toHaveBeenCalledWith("data_scientist", ["write:samples"])); + await waitFor(() => expect(screen.getByText(/created/)).toBeInTheDocument()); + }); + + it("edits an existing role, handling load failure and a name-conflict save", async () => { + render(); + await waitFor(() => expect(screen.getByText("Reader")).toBeInTheDocument()); + rolesApi.getRole.mockResolvedValueOnce({ name: "Reader", permissions: ["read:samples"] }); + fireEvent.click(screen.getAllByRole("button", { name: "Edit" })[0]); + expect(await screen.findByText(/Edit Role — Reader/)).toBeInTheDocument(); + expect(screen.getByText("read:samples")).toBeInTheDocument(); + + const conflict = new Error("dup"); conflict.status = 409; + rolesApi.updateRole.mockRejectedValueOnce(conflict); + fireEvent.click(screen.getByRole("button", { name: "Save" })); + await waitFor(() => expect(screen.getByText("A role with that name already exists.")).toBeInTheDocument()); + + fireEvent.click(screen.getByRole("button", { name: "← Back to roles" })); + await waitFor(() => expect(screen.getByText("Role Management")).toBeInTheDocument()); + + rolesApi.getRole.mockRejectedValueOnce(new Error("not found")); + fireEvent.click(screen.getAllByRole("button", { name: "Edit" })[1]); + await waitFor(() => expect(screen.getByText("not found")).toBeInTheDocument()); + fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + await waitFor(() => expect(screen.getByText("Role Management")).toBeInTheDocument()); + + rolesApi.getRole.mockRejectedValueOnce(new Error()); + fireEvent.click(screen.getAllByRole("button", { name: "Edit" })[1]); + await waitFor(() => expect(screen.getByText("Failed to load role")).toBeInTheDocument()); + }); + + it("falls back to a generic save-failed message when the error carries no message", async () => { + render(); + await waitFor(() => expect(screen.getByText("Reader")).toBeInTheDocument()); + fireEvent.click(screen.getByRole("button", { name: "+ New Role" })); + fireEvent.change(screen.getByPlaceholderText("e.g. data_scientist"), { target: { value: "x" } }); + rolesApi.createRole.mockRejectedValueOnce(new Error()); + fireEvent.click(screen.getByRole("button", { name: "Save" })); + await waitFor(() => expect(screen.getByText("Save failed")).toBeInTheDocument()); + }); +}); + +describe("RoleManagement console — assign to user", () => { + it("looks up a user, handles a 404 and generic failure, toggles roles, and saves", async () => { + render(); + await waitFor(() => expect(screen.getByText("Reader")).toBeInTheDocument()); + fireEvent.click(screen.getByText("Assign to User")); + + const idInput = screen.getByPlaceholderText("e.g. 42"); + fireEvent.click(screen.getByRole("button", { name: "Load" })); // blank id: button is disabled + fireEvent.submit(idInput.closest("form")); // blank id via direct submit: load() itself no-ops + expect(rolesApi.getUserRoles).not.toHaveBeenCalled(); + + const notFound = new Error("nope"); notFound.status = 404; + rolesApi.getUserRoles.mockRejectedValueOnce(notFound); + fireEvent.change(idInput, { target: { value: "42" } }); + fireEvent.click(screen.getByRole("button", { name: "Load" })); + await waitFor(() => expect(screen.getByText("No user with id 42.")).toBeInTheDocument()); + + rolesApi.getUserRoles.mockRejectedValueOnce(new Error("server error")); + fireEvent.submit(idInput.closest("form")); + await waitFor(() => expect(screen.getByText("server error")).toBeInTheDocument()); + + rolesApi.getUserRoles.mockResolvedValueOnce({ user_id: 42, roles: ["Reader"] }); + fireEvent.click(screen.getByRole("button", { name: "Load" })); + expect(await screen.findByText(/Roles for user #42/)).toBeInTheDocument(); + expect(screen.getByText(/2 permissions/)).toBeInTheDocument(); + expect(screen.getByText(/1 permission\)/)).toBeInTheDocument(); + + const readerBox = screen.getByRole("checkbox", { name: /Reader/ }); + expect(readerBox.checked).toBe(true); + fireEvent.click(readerBox); // untoggle + expect(readerBox.checked).toBe(false); + const writerBox = screen.getByRole("checkbox", { name: /Writer/ }); + expect(writerBox.checked).toBe(false); + fireEvent.click(writerBox); // toggle on + expect(writerBox.checked).toBe(true); + + const forbidden = new Error("no"); forbidden.status = 403; + rolesApi.setUserRoles.mockRejectedValueOnce(forbidden); + fireEvent.click(screen.getByRole("button", { name: "Save Assignment" })); + await waitFor(() => expect(screen.getByText(/Cannot modify your own roles/)).toBeInTheDocument()); + + rolesApi.setUserRoles.mockRejectedValueOnce(new Error("server exploded")); + fireEvent.click(screen.getByRole("button", { name: "Save Assignment" })); + await waitFor(() => expect(screen.getByText("server exploded")).toBeInTheDocument()); + + rolesApi.setUserRoles.mockResolvedValueOnce({ roles: ["Writer"] }); + fireEvent.click(screen.getByRole("button", { name: "Save Assignment" })); + await waitFor(() => expect(screen.getByText("Roles updated.")).toBeInTheDocument()); + }); + + it("shows the no-roles-exist message when there are no roles to assign", async () => { + rolesApi.listRoles.mockResolvedValueOnce([]); + render(); + await waitFor(() => expect(screen.getByText(/No roles yet/)).toBeInTheDocument()); + fireEvent.click(screen.getByText("Assign to User")); + rolesApi.getUserRoles.mockResolvedValueOnce({ user_id: 1, roles: [] }); + fireEvent.change(screen.getByPlaceholderText("e.g. 42"), { target: { value: "1" } }); + fireEvent.click(screen.getByRole("button", { name: "Load" })); + await waitFor(() => expect(screen.getByText("No roles exist yet.")).toBeInTheDocument()); + }); +}); diff --git a/tests/ui/service-viewer.test.jsx b/tests/ui/service-viewer.test.jsx new file mode 100644 index 00000000..8298bd98 --- /dev/null +++ b/tests/ui/service-viewer.test.jsx @@ -0,0 +1,47 @@ +import React from "react"; +import { render, screen, fireEvent, cleanup } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import ServiceViewer from "../../src/ui/pages/ServiceViewer"; + +beforeEach(() => { delete window.api; delete window.electronAPI; }); +afterEach(() => { cleanup(); delete window.api; delete window.electronAPI; }); + +describe("ServiceViewer", () => { + it("renders an iframe in the web build, titled by label or the URL as a fallback", () => { + const { unmount } = render(); + const iframe = document.querySelector("iframe"); + expect(iframe.title).toBe("Service"); + expect(document.querySelector("webview")).toBeNull(); + unmount(); + + render(); + expect(document.querySelector("iframe").title).toBe("/service"); + }); + + it("calls onBack", () => { + const onBack = vi.fn(); + render(); + fireEvent.click(screen.getByText("← Back to Workbench")); + expect(onBack).toHaveBeenCalled(); + }); + + it("renders a webview under Electron and forwards open-external IPC messages", () => { + window.api = {}; + window.electronAPI = { openExternal: vi.fn() }; + render(); + const webview = document.querySelector("webview"); + expect(webview).toBeTruthy(); + expect(document.querySelector("iframe")).toBeNull(); + + const handlerEvent = new Event("ipc-message"); + handlerEvent.channel = "open-external"; + handlerEvent.args = ["https://example.com"]; + webview.dispatchEvent(handlerEvent); + expect(window.electronAPI.openExternal).toHaveBeenCalledWith("https://example.com"); + + const ignoredEvent = new Event("ipc-message"); + ignoredEvent.channel = "something-else"; + webview.dispatchEvent(ignoredEvent); + expect(window.electronAPI.openExternal).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/ui/services.test.jsx b/tests/ui/services.test.jsx new file mode 100644 index 00000000..a54b71ab --- /dev/null +++ b/tests/ui/services.test.jsx @@ -0,0 +1,161 @@ +import React from "react"; +import { render, screen, fireEvent, waitFor, cleanup, within } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import Services from "../../src/ui/pages/Services"; + +const admin = { email: "a@test", permissions: ["manage_config"] }; + +function jsonRes(body, status = 200) { + return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } }); +} + +// Generic router: health-check URLs (/_svc/*) resolve ok by default; launcher +// status/start/stop/restart calls resolve via explicit per-test overrides. +function mockFetch(overrides = {}) { + return vi.fn((url, opts) => { + const u = String(url); + for (const [match, respond] of Object.entries(overrides)) { + if (u.includes(match)) return respond(u, opts); + } + if (u.includes("/api/launcher/status/")) return Promise.resolve(jsonRes({ status: "stopped" })); + if (u.includes("/api/launcher/")) return Promise.resolve(new Response("", { status: 200 })); + return Promise.resolve(new Response("", { status: 200 })); + }); +} + +beforeEach(() => { delete window.api; delete window.electronAPI; }); +afterEach(() => { cleanup(); vi.restoreAllMocks(); vi.useRealTimers(); delete window.api; delete window.electronAPI; }); + +describe("Services page", () => { + it("polls direct health checks only (no window.api) and shows up/down counts", async () => { + vi.stubGlobal("fetch", mockFetch({ "/_svc/auth": () => Promise.resolve(new Response("", { status: 500 })) })); + render(); + await waitFor(() => expect(screen.getByText("MySQL")).toBeInTheDocument()); + await waitFor(() => expect(screen.getAllByText("● Running").length).toBeGreaterThan(0)); + expect(screen.getAllByText("✕ Stopped").length).toBeGreaterThan(0); // auth-service (and IDE tools) forced down + expect(screen.getByText(/last check:/)).toBeInTheDocument(); + }); + + it("maps window.api.checkHealth results including the ollama warn branch", async () => { + window.api = { + checkHealth: vi.fn().mockResolvedValue({ mysql: true, redis: false, workbench: true, tes: true, toolserver: false, ollama: false, rag: true }), + }; + vi.stubGlobal("fetch", mockFetch()); + render(); + await waitFor(() => expect(window.api.checkHealth).toHaveBeenCalled()); + await waitFor(() => expect(screen.getByText("◐ Starting")).toBeInTheDocument()); // ollama -> warn + cleanup(); + + // Inverted health: covers the other half of every up/down ternary above. + window.api = { + checkHealth: vi.fn().mockResolvedValue({ mysql: false, redis: true, workbench: false, tes: false, toolserver: true, ollama: true, rag: false }), + }; + vi.stubGlobal("fetch", mockFetch()); + render(); + await waitFor(() => expect(window.api.checkHealth).toHaveBeenCalled()); + await waitFor(() => expect(screen.getAllByText("✕ Stopped").length).toBeGreaterThan(0)); + }); + + it("maps IDE launcher status to running/starting/stopped, and to down on a bad response or network error", async () => { + vi.stubGlobal("fetch", mockFetch({ + "/api/launcher/status/jupyter/": () => Promise.resolve(jsonRes({ status: "Running" })), + "/api/launcher/status/rstudio/": () => Promise.resolve(jsonRes({ status: "Starting" })), + "/api/launcher/status/vscode/": () => Promise.resolve(new Response("", { status: 500 })), + })); + render(); + await waitFor(() => expect(screen.getByText("Open ↗")).toBeInTheDocument()); // jupyter running + expect(screen.getByText("Starting...")).toBeInTheDocument(); // rstudio starting + expect(screen.getAllByText("Start").length).toBeGreaterThan(0); // vscode down/unknown + }); + + it("starts, restarts, and stops an IDE tool via the launcher API", async () => { + const fetchMock = mockFetch(); + vi.stubGlobal("fetch", fetchMock); + render(); + await waitFor(() => expect(screen.getAllByText("Start").length).toBeGreaterThan(0)); + + fireEvent.click(screen.getAllByText("Start")[0]); + await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining("/api/launcher/start/"), expect.anything())); + + // now simulate it running so Restart/Stop render + const fetchMock2 = mockFetch({ "/api/launcher/status/jupyter/": () => Promise.resolve(jsonRes({ status: "running" })) }); + vi.stubGlobal("fetch", fetchMock2); + cleanup(); + render(); + await waitFor(() => expect(screen.getByText("MySQL").closest("tr")).toBeTruthy()); + const jupyterRow = screen.getByText("JupyterLab").closest("tr"); + fireEvent.click(within(jupyterRow).getByText("↻ Restart")); + await waitFor(() => expect(fetchMock2).toHaveBeenCalledWith(expect.stringContaining("/api/launcher/stop/jupyter/"), expect.anything())); + await waitFor(() => expect(fetchMock2).toHaveBeenCalledWith(expect.stringContaining("/api/launcher/start/jupyter/"), expect.anything())); + fireEvent.click(within(screen.getByText("JupyterLab").closest("tr")).getByText("Stop")); + await waitFor(() => expect(fetchMock2).toHaveBeenCalledWith(expect.stringContaining("/api/launcher/stop/jupyter/"), expect.anything())); + }); + + it("opens a running IDE tool with a nginx-proxied web path, or a direct Electron URL with a Jupyter token", async () => { + vi.stubGlobal("fetch", mockFetch({ "/api/launcher/status/jupyter/": () => Promise.resolve(jsonRes({ status: "running" })) })); + const openSpy = vi.spyOn(window, "open").mockImplementation(() => {}); + render(); + await waitFor(() => expect(screen.getByText("Open ↗")).toBeInTheDocument()); + fireEvent.click(screen.getByText("Open ↗")); + expect(openSpy).toHaveBeenCalledWith(expect.stringContaining("/jupyter/?token="), "_blank"); + cleanup(); + + window.api = {}; // isElectron() true + window.electronAPI = { openExternal: vi.fn() }; + vi.stubGlobal("fetch", mockFetch({ "/api/launcher/status/jupyter/": () => Promise.resolve(jsonRes({ status: "running" })) })); + render(); + await waitFor(() => expect(screen.getByText("Open ↗")).toBeInTheDocument()); + fireEvent.click(screen.getByText("Open ↗")); + expect(window.electronAPI.openExternal).toHaveBeenCalledWith(expect.stringContaining("192.168.86.234:8888?token=")); + }); + + it("restarts a non-IDE service via window.api, and simulates the restart without it", async () => { + window.api = { restartService: vi.fn().mockResolvedValue() }; + vi.stubGlobal("fetch", mockFetch()); + render(); + await waitFor(() => expect(screen.getAllByText("↻ Restart").length).toBeGreaterThan(0)); + fireEvent.click(screen.getAllByText("↻ Restart")[0]); + await waitFor(() => expect(window.api.restartService).toHaveBeenCalled()); + + cleanup(); + delete window.api; + vi.useFakeTimers(); + vi.stubGlobal("fetch", mockFetch()); + render(); + await vi.waitFor(() => expect(screen.getAllByText("↻ Restart").length).toBeGreaterThan(0)); + fireEvent.click(screen.getAllByText("↻ Restart")[0]); + await vi.advanceTimersByTimeAsync(2000); + await vi.waitFor(() => expect(screen.getAllByText("↻ Restart").length).toBeGreaterThan(0)); + }); + + it("marks a service down when its restart call fails", async () => { + window.api = { restartService: vi.fn().mockRejectedValue(new Error("nope")) }; + vi.stubGlobal("fetch", mockFetch()); + render(); + await waitFor(() => expect(screen.getAllByText("↻ Restart").length).toBeGreaterThan(0)); + fireEvent.click(screen.getAllByText("↻ Restart")[0]); + await waitFor(() => expect(window.api.restartService).toHaveBeenCalled()); + }); + + it("refreshes on demand and highlights a row on hover", async () => { + const fetchMock = mockFetch(); + vi.stubGlobal("fetch", fetchMock); + render(); + await waitFor(() => expect(screen.getByText("MySQL")).toBeInTheDocument()); + const before = fetchMock.mock.calls.length; + fireEvent.click(screen.getByText("↻ Refresh")); + await waitFor(() => expect(fetchMock.mock.calls.length).toBeGreaterThan(before)); + + const row = screen.getByText("MySQL").closest("tr"); + fireEvent.mouseEnter(row); + expect(row.style.background).toBe("rgba(255, 255, 255, 0.02)"); + fireEvent.mouseLeave(row); + expect(row.style.background).toBe("transparent"); + }); + + it("labels itself for beta mode", async () => { + vi.stubGlobal("fetch", mockFetch()); + render(); + expect(screen.getByText(/monitoring remote cloud services via tunnel/)).toBeInTheDocument(); + }); +}); diff --git a/tests/ui/session.test.js b/tests/ui/session.test.js index 6325f5bf..c9821801 100644 --- a/tests/ui/session.test.js +++ b/tests/ui/session.test.js @@ -1,7 +1,8 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { - authUrl, clearSession, consumeOAuthRedirectParams, getCurrentUser, - getOAuthLoginUrl, loginWithLicenseKey, logout, refresh, setSession, + authUrl, clearSession, confirmOAuthLink, consumeOAuthRedirectParams, getCurrentUser, + getCurrentUserSync, getOAuthLoginUrl, hasPermission, isElectron, loginWithLicenseKey, + loginWithPassword, logout, oauthProviders, onSessionChange, refresh, setSession, } from "../../src/ui/lib/session"; afterEach(() => vi.restoreAllMocks()); @@ -59,4 +60,110 @@ describe("session boundary", () => { expect(window.location.search).toBe(""); expect(getOAuthLoginUrl("google")).toContain("/auth/google/login"); }); + + it("falls back to a generic license error for an unmapped reason", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(new Response(JSON.stringify({ valid: false, reason: "unknown_thing" }), { status: 400 })); + await expect(loginWithLicenseKey("k", "a@b.test")).rejects.toThrow("License validation failed"); + }); + + it("activates a valid license key and starts a session", async () => { + vi.spyOn(globalThis, "fetch") + .mockResolvedValueOnce(new Response(JSON.stringify({ valid: true, access_token: "t", refresh_token: "r" }), { status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify({ valid: true, user_id: 4, email: "k@k.test" }), { status: 200 })); + const user = await loginWithLicenseKey(" KEY ", "k@k.test", "desktop"); + expect(user).toMatchObject({ userId: 4, email: "k@k.test" }); + clearSession(); + }); + + it("falls back to a fixed LAN IP when no host is configured", () => { + delete window.__OMNIBIOAI_CONFIG__; + expect(authUrl("/x")).toBe("http://192.168.86.234:8001/x"); + }); + + it("logs in with a password and rejects on a 401 or other failure", async () => { + vi.spyOn(globalThis, "fetch") + .mockResolvedValueOnce(new Response(JSON.stringify({ access_token: "tok", refresh_token: "ref" }), { status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify({ valid: true, user_id: 1, email: "a@b.test" }), { status: 200 })); + const user = await loginWithPassword("a@b.test", "secret"); + expect(user).toMatchObject({ userId: 1, email: "a@b.test" }); + expect(getCurrentUserSync()).toMatchObject({ email: "a@b.test" }); + expect(hasPermission("manage_roles")).toBe(false); + clearSession(); + + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(new Response("", { status: 401 })); + await expect(loginWithPassword("a@b.test", "wrong")).rejects.toThrow("Invalid email or password"); + + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(new Response("", { status: 500 })); + await expect(loginWithPassword("a@b.test", "wrong")).rejects.toThrow("Login failed"); + }); + + it("skips the logout request entirely when there is no refresh token", async () => { + setSession("access-only"); + const fetchMock = vi.spyOn(globalThis, "fetch"); + await logout(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("no-ops a refresh with no refresh token and fails open on a network error", async () => { + expect(await refresh()).toBeNull(); + setSession("a", "r"); + vi.spyOn(globalThis, "fetch").mockRejectedValueOnce(new Error("offline")); + await expect(refresh()).resolves.toBeNull(); + }); + + it("fails open on a network error while validating the current user", async () => { + setSession("tok"); + vi.spyOn(globalThis, "fetch").mockRejectedValueOnce(new Error("offline")); + expect(await getCurrentUser({ force: true })).toBeNull(); + }); + + it("detects Electron via either preload bridge", () => { + delete window.api; delete window.electronAPI; + expect(isElectron()).toBe(false); + window.api = {}; + expect(isElectron()).toBe(true); + delete window.api; + }); + + it("subscribes and unsubscribes from session-change events", () => { + const cb = vi.fn(); + const off = onSessionChange(cb); + setSession("x"); + expect(cb).toHaveBeenCalledTimes(1); + off(); + setSession("y"); + expect(cb).toHaveBeenCalledTimes(1); + }); + + it("lists OAuth providers and confirms an account link, surfacing JSON and non-JSON failures", async () => { + expect(oauthProviders()).toEqual(["google", "github", "microsoft"]); + + vi.spyOn(globalThis, "fetch") + .mockResolvedValueOnce(new Response(JSON.stringify({ access_token: "t", refresh_token: "r" }), { status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify({ valid: true, user_id: 9, email: "z@z.test" }), { status: 200 })); + const user = await confirmOAuthLink("lt", "pw"); + expect(user).toMatchObject({ userId: 9 }); + clearSession(); + + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(new Response(JSON.stringify({ detail: "bad password" }), { status: 400 })); + await expect(confirmOAuthLink("lt", "bad")).rejects.toThrow("bad password"); + + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(new Response("", { status: 500 })); + await expect(confirmOAuthLink("lt", "bad")).rejects.toThrow("Could not confirm the link"); + }); + + it("parses an error redirect and a bare success redirect with no tokens", () => { + expect(consumeOAuthRedirectParams()).toBeNull(); + + window.history.replaceState({}, "", "/?status=error&error=denied"); + expect(consumeOAuthRedirectParams()).toEqual({ type: "error", message: "denied" }); + + window.history.replaceState({}, "", "/?status=success"); + expect(consumeOAuthRedirectParams()).toEqual({ type: "success" }); + + window.history.replaceState({}, "", "/?status=success&access_token=at&refresh_token=rt"); + expect(consumeOAuthRedirectParams()).toEqual({ type: "success" }); + expect(localStorage.getItem("omnibioai_access_token")).toBe("at"); + clearSession(); + }); }); diff --git a/tests/ui/settings.test.jsx b/tests/ui/settings.test.jsx new file mode 100644 index 00000000..2aa2a83a --- /dev/null +++ b/tests/ui/settings.test.jsx @@ -0,0 +1,189 @@ +import React from "react"; +import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import Settings from "../../src/ui/pages/Settings"; + +beforeEach(() => { + window.api = {}; + Object.defineProperty(navigator, "clipboard", { value: { writeText: vi.fn().mockResolvedValue() }, configurable: true }); +}); +afterEach(() => { cleanup(); delete window.api; vi.restoreAllMocks(); }); + +function exerciseInputsAndToggles(container) { + container.querySelectorAll('input[type="text"], input:not([type])').forEach((el) => { + fireEvent.change(el, { target: { value: `${el.value}x` } }); + }); + container.querySelectorAll("select").forEach((el) => { + const opts = Array.from(el.options).map((o) => o.value); + fireEvent.change(el, { target: { value: opts[opts.length - 1] } }); + }); + container.querySelectorAll("button.toggle").forEach((el) => fireEvent.click(el)); +} + +describe("Settings page", () => { + it("loads saved settings on mount when window.api is available", async () => { + window.api.loadConfig = vi.fn().mockResolvedValue({ settings: { host_ip: "10.0.0.5" } }); + render(); + await waitFor(() => expect(screen.getByDisplayValue("10.0.0.5")).toBeInTheDocument()); + }); + + it("skips loading when window.api or its settings are absent", async () => { + render(); + expect(screen.getAllByText("Settings").length).toBeGreaterThan(0); + + window.api = { loadConfig: vi.fn().mockResolvedValue({}) }; + cleanup(); + render(); + await waitFor(() => expect(window.api.loadConfig).toHaveBeenCalled()); + }); + + it("shows the first-run banner until both data and work dirs are set", () => { + const { container } = render(); + expect(screen.getByText(/First time setup/)).toBeInTheDocument(); + const [dataDir, workDir] = [ + screen.getByPlaceholderText("/home/username/omnibioai/data"), + screen.getByPlaceholderText("/home/username/omnibioai/work"), + ]; + fireEvent.change(dataDir, { target: { value: "/d" } }); + fireEvent.change(workDir, { target: { value: "/w" } }); + expect(screen.queryByText(/First time setup/)).not.toBeInTheDocument(); + void container; + }); + + it("fills default paths, using Windows-style paths when navigator.platform says Win", () => { + render(); + fireEvent.click(screen.getByText("Use defaults")); + expect(screen.getByDisplayValue(/\/home\/omnibioai\/data/)).toBeInTheDocument(); + + const platformSpy = vi.spyOn(navigator, "platform", "get").mockReturnValue("Win32"); + cleanup(); + render(); + fireEvent.click(screen.getByText("Use defaults")); + expect(screen.getByDisplayValue("C:\\Users/omnibioai/data")).toBeInTheDocument(); + platformSpy.mockRestore(); + }); + + it("derives OmniBioAI defaults from an existing work_dir, or a home/user fallback", () => { + render(); + fireEvent.change(screen.getByPlaceholderText("/home/username/omnibioai/work"), { target: { value: "/x/y/work" } }); + fireEvent.click(screen.getByText("Use OmniBioAI defaults")); + expect(screen.getByDisplayValue("/x/y/data")).toBeInTheDocument(); + + cleanup(); + window.api.username = "alice"; + render(); + fireEvent.click(screen.getByText("Use OmniBioAI defaults")); + expect(screen.getByDisplayValue("/home/alice/Desktop/machine/omnibioai/data")).toBeInTheDocument(); + + cleanup(); + delete window.api.username; // window.api present but no username -> "user" fallback + render(); + fireEvent.click(screen.getByText("Use OmniBioAI defaults")); + expect(screen.getByDisplayValue("/home/user/Desktop/machine/omnibioai/data")).toBeInTheDocument(); + + const platformSpy = vi.spyOn(navigator, "platform", "get").mockReturnValue("Win32"); + cleanup(); + render(); + fireEvent.click(screen.getByText("Use OmniBioAI defaults")); + expect(screen.getByDisplayValue("C:\\Users\\omnibioai/data")).toBeInTheDocument(); + platformSpy.mockRestore(); + }); + + it("exercises every general/port/docker field and toggle", () => { + const { container } = render(); + exerciseInputsAndToggles(container); + expect(screen.getByText("Settings")).toBeInTheDocument(); + }); + + it("saves settings via window.api, calls setConfig, and shows the saved indicator", async () => { + window.api.saveConfig = vi.fn().mockResolvedValue(); + const setConfig = vi.fn(); + render(); + fireEvent.click(screen.getByText("Save Settings")); + await waitFor(() => expect(window.api.saveConfig).toHaveBeenCalledWith(expect.objectContaining({ mode: "local" }))); + expect(setConfig).toHaveBeenCalled(); + expect(await screen.findByText("✓ Saved")).toBeInTheDocument(); + }); + + it("saves without window.api.saveConfig or setConfig present", async () => { + render(); + fireEvent.click(screen.getByText("Save Settings")); + expect(await screen.findByText("✓ Saved")).toBeInTheDocument(); + }); + + it("resets via window.api.resetConfig and reloads, or just reloads without it", async () => { + const reload = vi.fn(); + Object.defineProperty(window, "location", { value: { ...window.location, reload }, writable: true, configurable: true }); + window.api.resetConfig = vi.fn().mockResolvedValue(); + render(); + fireEvent.click(screen.getByText("Reset All")); + await waitFor(() => expect(window.api.resetConfig).toHaveBeenCalled()); + expect(reload).toHaveBeenCalled(); + + reload.mockClear(); + delete window.api.resetConfig; + fireEvent.click(screen.getByText("Reset All")); + await waitFor(() => expect(reload).toHaveBeenCalled()); + }); + + it("shows and hides credentials, copying each value including the shortened auth secret", async () => { + window.api.getCredentials = vi.fn().mockResolvedValue({ + grafanaPassword: "gpass", authSecretKey: "abcdefghijklmnop", envPath: "/opt/.env", + // mysqlPassword/jupyterToken/rstudioPassword/vscodePassword intentionally absent -> "—" + }); + render(); + fireEvent.click(screen.getByText("Show Credentials")); + expect(await screen.findByText("Stored at: /opt/.env")).toBeInTheDocument(); + expect(screen.getByText("abcdefgh••••••••••••••••••••••••")).toBeInTheDocument(); + expect(screen.getByText("gpass")).toBeInTheDocument(); + expect(screen.getAllByText("—").length).toBeGreaterThan(0); + + fireEvent.click(screen.getAllByText("Copy")[0]); + await waitFor(() => expect(navigator.clipboard.writeText).toHaveBeenCalledWith("gpass")); + expect(await screen.findByText("✓ Copied")).toBeInTheDocument(); + + // the grafanaPassword button above now reads "✓ Copied", so the first + // remaining "Copy" button is mysqlPassword's (value absent -> copies ''). + fireEvent.click(screen.getAllByText("Copy")[0]); + await waitFor(() => expect(navigator.clipboard.writeText).toHaveBeenCalledWith("")); + + fireEvent.click(screen.getByText("Hide")); + expect(screen.queryByText("Stored at: /opt/.env")).not.toBeInTheDocument(); + }); + + it("no-ops Show Credentials when window.api.getCredentials is unavailable", () => { + render(); + fireEvent.click(screen.getByText("Show Credentials")); + expect(screen.getByText("Show Credentials")).toBeInTheDocument(); + }); + + it("reverts the saved and copied indicators after their timeouts elapse", async () => { + vi.useFakeTimers(); + window.api.saveConfig = vi.fn().mockResolvedValue(); + window.api.getCredentials = vi.fn().mockResolvedValue({ grafanaPassword: "gpass" }); + render(); + + fireEvent.click(screen.getByText("Save Settings")); + await vi.waitFor(() => expect(screen.getByText("✓ Saved")).toBeInTheDocument()); + await vi.advanceTimersByTimeAsync(2000); + expect(screen.getByText("Save Settings")).toBeInTheDocument(); + + fireEvent.click(screen.getByText("Show Credentials")); + await vi.waitFor(() => expect(screen.getAllByText("Copy").length).toBeGreaterThan(0)); + fireEvent.click(screen.getAllByText("Copy")[0]); + await vi.advanceTimersByTimeAsync(1500); + expect(screen.getAllByText("Copy").length).toBeGreaterThan(0); + }); + + it("opens external links via window.api.openExternal, or window.open as a fallback", () => { + window.api.openExternal = vi.fn(); + render(); + fireEvent.click(screen.getByText("Docs ↗")); + expect(window.api.openExternal).toHaveBeenCalledWith("https://docs.omnibioai.org"); + + delete window.api.openExternal; + const openSpy = vi.spyOn(window, "open").mockImplementation(() => {}); + fireEvent.click(screen.getByText("GitHub ↗")); + expect(openSpy).toHaveBeenCalledWith("https://github.com/OmniBioAI/omnibioai-studio", "_blank", "noopener,noreferrer"); + }); +}); diff --git a/tests/ui/store.test.js b/tests/ui/store.test.js index fb23808e..82b20a9e 100644 --- a/tests/ui/store.test.js +++ b/tests/ui/store.test.js @@ -16,4 +16,19 @@ describe("global wizard store", () => { expect(useStore.getState().systemStatus.docker).toBe("running"); expect(useStore.getState().isLaunching).toBe(false); }); + + it("resets isLaunching even when the launch itself fails", async () => { + window.api = { saveConfig: async () => { throw new Error("disk full"); } }; + await expect(useStore.getState().launchSystem()).rejects.toThrow("disk full"); + expect(useStore.getState().isLaunching).toBe(false); + }); + + it("patches config via setConfig, and merges system status", () => { + useStore.getState().setConfig({ mode: "cloud" }); + expect(useStore.getState().config.mode).toBe("cloud"); + useStore.getState().setSystemStatus({ tes: "up" }); + expect(useStore.getState().systemStatus).toMatchObject({ tes: "up" }); + useStore.getState().setLaunching(true); + expect(useStore.getState().isLaunching).toBe(true); + }); }); diff --git a/tests/ui/videos.test.jsx b/tests/ui/videos.test.jsx new file mode 100644 index 00000000..27d9c0d1 --- /dev/null +++ b/tests/ui/videos.test.jsx @@ -0,0 +1,89 @@ +import React from "react"; +import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import Videos from "../../src/ui/pages/Videos"; + +function jsonRes(body, status = 200) { + return new Response(JSON.stringify(body), { status }); +} + +afterEach(() => { cleanup(); vi.restoreAllMocks(); }); + +describe("Videos page", () => { + it("shows a loading spinner before the fetch resolves", async () => { + let resolveFetch; + vi.stubGlobal("fetch", vi.fn(() => new Promise((res) => { resolveFetch = res; }))); + render(); + expect(screen.getByText("Loading videos...")).toBeInTheDocument(); + resolveFetch(jsonRes([])); + await waitFor(() => expect(screen.getByText("No tutorials available yet")).toBeInTheDocument()); + }); + + it("renders an error banner with the HTTP status when videos.json 404s", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("", { status: 404 }))); + render(); + expect(await screen.findByText("No videos.json found (HTTP 404)")).toBeInTheDocument(); + }); + + it("renders an error banner when the fetch itself fails", async () => { + vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("offline"))); + render(); + expect(await screen.findByText("offline")).toBeInTheDocument(); + }); + + it("accepts a bare array, a {videos:[...]} wrapper, and an empty object", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonRes([{ filename: "a.mp4", title: "A" }]))); + render(); + expect(await screen.findByText("1 video")).toBeInTheDocument(); + cleanup(); + + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonRes({ videos: [{ filename: "a.mp4", title: "A" }, { filename: "b.mp4", title: "B" }] }))); + render(); + expect(await screen.findByText("2 videos")).toBeInTheDocument(); + cleanup(); + + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonRes({}))); + render(); + expect(await screen.findByText("No tutorials available yet")).toBeInTheDocument(); + }); + + it("renders a card's description and tags, hovers it, opens the player, and closes on Escape", async () => { + const video = { filename: "a.mp4", title: "Intro", description: "Getting started", tags: ["setup", "basics"] }; + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonRes([video]))); + const onBack = vi.fn(); + render(); + const card = await screen.findByRole("button", { name: "Play Intro" }); + expect(screen.getByText("Getting started")).toBeInTheDocument(); + expect(screen.getByText("setup")).toBeInTheDocument(); + + fireEvent.mouseEnter(card); + fireEvent.mouseLeave(card); + fireEvent.click(card); + expect(await screen.findByText("✕ Close")).toBeInTheDocument(); + expect(screen.getAllByText("Getting started").length).toBeGreaterThan(0); // also shown in modal footer + + fireEvent.keyDown(window, { key: "Escape" }); + await waitFor(() => expect(screen.queryByText("✕ Close")).not.toBeInTheDocument()); + + fireEvent.click(screen.getByText("← Back to Workbench")); + expect(onBack).toHaveBeenCalled(); + }); + + it("opens the player via Enter key, closes via the Close button, and ignores clicks inside the modal", async () => { + const video = { filename: "b.mp4", title: "No Extras" }; // no description/tags + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonRes([video]))); + render(); + const card = await screen.findByRole("button", { name: "Play No Extras" }); + fireEvent.keyDown(card, { key: "A" }); // non-Enter key is a no-op + expect(screen.queryByText("✕ Close")).not.toBeInTheDocument(); + fireEvent.keyDown(card, { key: "Enter" }); + const closeBtn = await screen.findByText("✕ Close"); + + const modalInner = closeBtn.closest("div").parentElement; + fireEvent.click(modalInner); // stopPropagation — should not close + expect(screen.getByText("✕ Close")).toBeInTheDocument(); + + fireEvent.click(closeBtn); + await waitFor(() => expect(screen.queryByText("✕ Close")).not.toBeInTheDocument()); + }); +}); diff --git a/tests/ui/web-lib.test.js b/tests/ui/web-lib.test.js new file mode 100644 index 00000000..5c922055 --- /dev/null +++ b/tests/ui/web-lib.test.js @@ -0,0 +1,121 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { isElectron, isWeb } from "../../src/ui/lib/web/platform"; +import { + checkHealth, checkUpdate, getPlatform, loadConfig, startDocker, stopDocker, streamLogs, +} from "../../src/ui/lib/web/webApi"; + +const { getToken, clearSession } = vi.hoisted(() => ({ + getToken: vi.fn(() => null), + clearSession: vi.fn(), +})); +vi.mock("../../src/ui/lib/session", () => ({ + authUrl: (path) => `http://auth.test:8001${path}`, + getToken, + clearSession, +})); + +import { createRole, deleteRole, getRole, getUserRoles, listRoles, setUserRoles, updateRole } from "../../src/ui/lib/rolesApi"; + +afterEach(() => vi.restoreAllMocks()); + +describe("web platform detection", () => { + it("reports web when neither Electron bridge is present, and Electron when either is", () => { + delete window.api; + delete window.electronAPI; + expect(isElectron()).toBe(false); + expect(isWeb()).toBe(true); + window.api = {}; + expect(isElectron()).toBe(true); + expect(isWeb()).toBe(false); + delete window.api; + window.electronAPI = {}; + expect(isElectron()).toBe(true); + delete window.electronAPI; + }); +}); + +describe("web API stand-in", () => { + it("returns beta-mode defaults and platform info", async () => { + expect(await loadConfig()).toMatchObject({ mode: "beta" }); + const platform = await getPlatform(); + expect(platform.platform).toBe("web"); + }); + + it("reports router health as ok or not, and never throws when the endpoint is unreachable", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(new Response("", { status: 200 })); + expect(await checkHealth()).toEqual({ ok: true }); + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(new Response("", { status: 503 })); + expect(await checkHealth()).toEqual({ ok: false }); + vi.spyOn(globalThis, "fetch").mockRejectedValueOnce(new Error("offline")); + expect(await checkHealth()).toEqual({ ok: false }); + }); + + it("no-ops Docker lifecycle calls and update checks in web mode", async () => { + expect((await startDocker()).web).toBe(true); + expect((await stopDocker()).web).toBe(true); + expect(await checkUpdate()).toEqual({ available: false, web: true }); + }); + + it("calls back once with an informational line and returns a no-op unsubscribe", () => { + const cb = vi.fn(); + const unsub = streamLogs(cb); + expect(cb).toHaveBeenCalledWith("Live log streaming isn't available in web mode."); + expect(() => unsub()).not.toThrow(); + expect(() => streamLogs()).not.toThrow(); // no callback supplied + }); +}); + +describe("roles API client", () => { + it("sends bearer-authenticated requests and parses JSON responses", async () => { + getToken.mockReturnValue("tok"); + const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( + new Response(JSON.stringify([{ id: "r1", name: "Reader" }]), { status: 200 }) + ); + const roles = await listRoles(); + expect(roles).toEqual([{ id: "r1", name: "Reader" }]); + expect(fetchMock).toHaveBeenCalledWith( + "http://auth.test:8001/roles", + expect.objectContaining({ headers: expect.objectContaining({ Authorization: "Bearer tok" }) }) + ); + }); + + it("omits the Authorization header when there is no token", async () => { + getToken.mockReturnValue(null); + const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(new Response(JSON.stringify({ id: "r1" }), { status: 200 })); + await getRole("r1"); + const [, opts] = fetchMock.mock.calls[0]; + expect(opts.headers.Authorization).toBeUndefined(); + }); + + it("clears the session on a 401 and still throws using the response detail", async () => { + getToken.mockReturnValue("tok"); + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(new Response(JSON.stringify({ detail: "expired token" }), { status: 401 })); + await expect(listRoles()).rejects.toThrow("expired token"); + expect(clearSession).toHaveBeenCalled(); + }); + + it("falls back to the status text when an error body isn't JSON", async () => { + getToken.mockReturnValue("tok"); + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(new Response("", { status: 500, statusText: "Server Error" })); + await expect(createRole("Reader", ["view"])).rejects.toThrow("Server Error"); + }); + + it("returns null for a 204 and posts JSON bodies for writes", async () => { + getToken.mockReturnValue("tok"); + const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(new Response(null, { status: 204 })); + expect(await deleteRole("r1")).toBeNull(); + const [, opts] = fetchMock.mock.calls[0]; + expect(opts.method).toBe("DELETE"); + }); + + it("covers the remaining role and user-role endpoints", async () => { + getToken.mockReturnValue("tok"); + vi.spyOn(globalThis, "fetch") + .mockResolvedValueOnce(new Response(JSON.stringify({ ok: true }), { status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify(["r1"]), { status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify({ ok: true }), { status: 200 })); + await updateRole("r1", ["manage_config"]); + await getUserRoles("u1"); + await setUserRoles("u1", ["r1"]); + }); +}); diff --git a/tests/ui/web-session.test.js b/tests/ui/web-session.test.js new file mode 100644 index 00000000..20be770a --- /dev/null +++ b/tests/ui/web-session.test.js @@ -0,0 +1,179 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + authUrl, clearSession, confirmOAuthLink, consumeOAuthRedirectParams, + getCurrentUser, getCurrentUserSync, getOAuthLoginUrl, getRefreshToken, getToken, + hasPermission, isElectron, loginWithLicenseKey, loginWithPassword, logout, + oauthProviders, onSessionChange, refresh, setSession, +} from "../../src/ui/lib/web/session"; + +afterEach(() => vi.restoreAllMocks()); + +describe("web session boundary", () => { + it("resolves auth URLs as same-origin relative paths", () => { + expect(authUrl("/auth/login")).toBe("/auth/login"); + }); + + it("stores and clears cookie-backed sessions", () => { + setSession("access", "refresh"); + expect(getToken()).toBe("access"); + expect(getRefreshToken()).toBe("refresh"); + expect(document.cookie).toContain("omnibioai_access_token=access"); + clearSession(); + expect(getToken()).toBeNull(); + expect(document.cookie).not.toContain("omnibioai_access_token=access"); + }); + + it("notifies session-change listeners and supports unsubscribe", () => { + const cb = vi.fn(); + const off = onSessionChange(cb); + setSession("a"); + expect(cb).toHaveBeenCalledTimes(1); + off(); + setSession("b"); + expect(cb).toHaveBeenCalledTimes(1); + clearSession(); + }); + + it("logs in with a password, maps a 401 to a friendly message, and logs a generic failure", async () => { + vi.spyOn(globalThis, "fetch") + .mockResolvedValueOnce(new Response(JSON.stringify({ access_token: "tok", refresh_token: "ref" }), { status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify({ valid: true, user_id: 1, email: "a@b.test", roles: ["admin"], permissions: ["manage_roles"] }), { status: 200 })); + const user = await loginWithPassword("a@b.test", "secret"); + expect(user).toMatchObject({ userId: 1, email: "a@b.test", roles: ["admin"], permissions: ["manage_roles"] }); + expect(getCurrentUserSync()).toMatchObject({ email: "a@b.test" }); + expect(hasPermission("manage_roles")).toBe(true); + expect(hasPermission("manage_config")).toBe(false); + clearSession(); + + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(new Response("", { status: 401 })); + await expect(loginWithPassword("a@b.test", "wrong")).rejects.toThrow("Invalid email or password"); + + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(new Response("", { status: 500 })); + await expect(loginWithPassword("a@b.test", "wrong")).rejects.toThrow("Login failed"); + }); + + it("maps every known license failure reason and falls back through backend message then status", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(new Response(JSON.stringify({ valid: false, reason: "revoked" }), { status: 400 })); + await expect(loginWithLicenseKey("k", "a@b.test")).rejects.toThrow("This license key has been revoked"); + + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(new Response(JSON.stringify({ valid: false, error: "custom backend error" }), { status: 400 })); + await expect(loginWithLicenseKey("k", "a@b.test")).rejects.toThrow("custom backend error"); + + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(new Response("gateway error", { status: 502 })); + await expect(loginWithLicenseKey("k", "a@b.test")).rejects.toThrow("License validation failed (502)"); + + vi.spyOn(globalThis, "fetch") + .mockResolvedValueOnce(new Response(JSON.stringify({ valid: true, access_token: "t", refresh_token: "r" }), { status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify({ valid: true, user_id: 2, email: "c@d.test" }), { status: 200 })); + const user = await loginWithLicenseKey(" KEY ", "c@d.test", "desktop"); + expect(user).toMatchObject({ userId: 2, email: "c@d.test", roles: [], permissions: [] }); + clearSession(); + }); + + it("caches the current user and only refetches when forced or invalid", async () => { + expect(await getCurrentUser()).toBeNull(); // no token yet + + setSession("tok"); + const fetchMock = vi.spyOn(globalThis, "fetch") + .mockResolvedValueOnce(new Response(JSON.stringify({ valid: true, user_id: 7, email: "a@b.test" }), { status: 200 })); + const first = await getCurrentUser(); + expect(first).toMatchObject({ userId: 7 }); + const second = await getCurrentUser(); + expect(second).toBe(first); // served from cache, no second fetch + expect(fetchMock).toHaveBeenCalledTimes(1); + + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ valid: false }), { status: 200 })); + expect(await getCurrentUser({ force: true })).toBeNull(); + expect(getToken()).toBeNull(); // cleared + + setSession("tok2"); + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(new Response("not json", { status: 200 })); + expect(await getCurrentUser({ force: true })).toBeNull(); + + setSession("tok3"); + vi.spyOn(globalThis, "fetch").mockRejectedValueOnce(new Error("offline")); + expect(await getCurrentUser({ force: true })).toBeNull(); + clearSession(); + }); + + it("refreshes access tokens and clears state on an expired or failed refresh", async () => { + expect(await refresh()).toBeNull(); // no refresh token + + setSession("old", "ref"); + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(new Response(JSON.stringify({ access_token: "new", refresh_token: "ref" }), { status: 200 })); + await expect(refresh()).resolves.toBe("new"); + expect(getToken()).toBe("new"); + + setSession("old2", "ref2"); + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(new Response("", { status: 401 })); + await expect(refresh()).resolves.toBeNull(); + expect(getToken()).toBeNull(); + + setSession("old3", "ref3"); + vi.spyOn(globalThis, "fetch").mockRejectedValueOnce(new Error("offline")); + await expect(refresh()).resolves.toBeNull(); + clearSession(); + }); + + it("logs out locally on both a successful and an unreachable server, and skips the call with no refresh token", async () => { + setSession("access", "refresh"); + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(new Response("", { status: 200 })); + await logout(); + expect(getToken()).toBeNull(); + + setSession("access2", "refresh2"); + vi.spyOn(globalThis, "fetch").mockRejectedValueOnce(new Error("offline")); + await logout(); + expect(getToken()).toBeNull(); + + setSession("access3"); // no refresh token at all + const fetchMock = vi.spyOn(globalThis, "fetch"); + fetchMock.mockClear(); + await logout(); + expect(fetchMock).not.toHaveBeenCalled(); + expect(getToken()).toBeNull(); + }); + + it("confirms an OAuth account link and surfaces backend or status-coded failures", async () => { + vi.spyOn(globalThis, "fetch") + .mockResolvedValueOnce(new Response(JSON.stringify({ access_token: "t", refresh_token: "r" }), { status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify({ valid: true, user_id: 3, email: "e@f.test" }), { status: 200 })); + const user = await confirmOAuthLink("link-token", "pw"); + expect(user).toMatchObject({ userId: 3 }); + clearSession(); + + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(new Response(JSON.stringify({ detail: "wrong password" }), { status: 400 })); + await expect(confirmOAuthLink("link-token", "bad")).rejects.toThrow("wrong password"); + + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(new Response("", { status: 500 })); + await expect(confirmOAuthLink("link-token", "bad")).rejects.toThrow("Could not confirm the link (500)"); + }); + + it("reports platform, providers, and static OAuth login URLs", () => { + expect(isElectron()).toBe(false); + expect(oauthProviders()).toEqual(["google", "github", "microsoft"]); + expect(getOAuthLoginUrl("github")).toBe("/auth/github/login"); + }); + + it("parses every OAuth redirect outcome and strips the query string", () => { + expect(consumeOAuthRedirectParams()).toBeNull(); // no status param + + window.history.replaceState({}, "", "/?status=error&error=denied"); + expect(consumeOAuthRedirectParams()).toEqual({ type: "error", message: "denied" }); + expect(window.location.search).toBe(""); + + window.history.replaceState({}, "", "/?status=error"); + expect(consumeOAuthRedirectParams()).toEqual({ type: "error", message: "Sign-in failed" }); + + window.history.replaceState({}, "", "/?status=link_required&link_token=lt&provider=github&email=a%40b.test"); + expect(consumeOAuthRedirectParams()).toEqual({ type: "link_required", linkToken: "lt", provider: "github", email: "a@b.test" }); + + window.history.replaceState({}, "", "/?status=success"); + expect(consumeOAuthRedirectParams()).toEqual({ type: "success" }); + + window.history.replaceState({}, "", "/?status=success&access_token=at&refresh_token=rt"); + expect(consumeOAuthRedirectParams()).toEqual({ type: "success" }); + expect(getToken()).toBe("at"); + clearSession(); + }); +}); diff --git a/tests/ui/wizard.test.jsx b/tests/ui/wizard.test.jsx new file mode 100644 index 00000000..e09c10ff --- /dev/null +++ b/tests/ui/wizard.test.jsx @@ -0,0 +1,37 @@ +import React from "react"; +import { render, screen, fireEvent, cleanup } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import Wizard from "../../src/ui/pages/Wizard"; + +afterEach(() => cleanup()); + +const steps = ["One", "Two", "Three"]; + +describe("Wizard", () => { + it("disables Back on the first step and advances on Next", () => { + const setStep = vi.fn(); + render(content); + expect(screen.getByText("Step 1 of 3")).toBeInTheDocument(); + expect(screen.getByText("Back")).toBeDisabled(); + expect(screen.getByText("Next")).not.toBeDisabled(); + fireEvent.click(screen.getByText("Next")); + expect(setStep).toHaveBeenCalledWith(1); + }); + + it("enables both controls on a middle step, coloring completed steps", () => { + const setStep = vi.fn(); + render(content); + expect(screen.getByText("Back")).not.toBeDisabled(); + expect(screen.getByText("Next")).not.toBeDisabled(); + fireEvent.click(screen.getByText("Back")); + expect(setStep).toHaveBeenCalledWith(0); + expect(screen.getByText("One")).toHaveStyle({ background: "rgb(22, 163, 74)" }); // completed + expect(screen.getByText("Three")).toHaveStyle({ color: "rgb(0, 0, 0)" }); // upcoming + }); + + it("disables Next on the last step", () => { + render(content); + expect(screen.getByText("Next")).toBeDisabled(); + expect(screen.getByText("Step 3 of 3")).toBeInTheDocument(); + }); +}); diff --git a/tests/ui/workbench.test.jsx b/tests/ui/workbench.test.jsx new file mode 100644 index 00000000..e7b2b72e --- /dev/null +++ b/tests/ui/workbench.test.jsx @@ -0,0 +1,137 @@ +import React from "react"; +import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { isElectron, getCurrentUserSync, getCurrentUser, onSessionChange } = vi.hoisted(() => ({ + isElectron: vi.fn(() => false), + getCurrentUserSync: vi.fn(() => null), + getCurrentUser: vi.fn().mockResolvedValue(null), + onSessionChange: vi.fn(() => vi.fn()), +})); +vi.mock("../../src/ui/lib/session", () => ({ isElectron, getCurrentUserSync, getCurrentUser, onSessionChange })); + +import Workbench from "../../src/ui/pages/Workbench"; + +beforeEach(() => { + isElectron.mockReturnValue(false); + getCurrentUserSync.mockReturnValue(null); + getCurrentUser.mockResolvedValue(null); + onSessionChange.mockReturnValue(vi.fn()); + delete window.api; +}); +afterEach(() => { cleanup(); vi.restoreAllMocks(); delete window.api; }); + +describe("Workbench page", () => { + it("shows online status once the health check succeeds and opens local links", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("", { status: 200 }))); + render(); + expect(screen.getByText("Checking...")).toBeInTheDocument(); + await waitFor(() => expect(screen.getByText("Online")).toBeInTheDocument()); + expect(screen.queryByText(/Workbench offline/)).not.toBeInTheDocument(); + + const opened = vi.fn(); + window.addEventListener("open-service", opened); + fireEvent.click(screen.getAllByRole("button", { name: "Open plugin catalog" })[0]); + expect(opened).toHaveBeenCalled(); + + const homeTile = screen.getByRole("button", { name: /^Home — /i }); + fireEvent.mouseEnter(homeTile); + expect(homeTile.style.background).toBe("rgba(255, 255, 255, 0.03)"); + fireEvent.mouseLeave(homeTile); + expect(homeTile.style.background).toBe("var(--bg3)"); + fireEvent.click(homeTile); + expect(opened).toHaveBeenCalledTimes(2); + window.removeEventListener("open-service", opened); + }); + + it("shows the offline banner and navigates to Launch from it", async () => { + vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("down"))); + render(); + await waitFor(() => expect(screen.getByText("Offline")).toBeInTheDocument()); + expect(screen.getByText(/Workbench offline/)).toBeInTheDocument(); + + const navigated = vi.fn(); + window.addEventListener("navigate", navigated); + fireEvent.click(screen.getByText("Go to Launch →")); + expect(navigated).toHaveBeenCalled(); + window.removeEventListener("navigate", navigated); + + // a non-local plugin tile is disabled while offline + const catalogTile = screen.getAllByRole("button", { name: "Open plugin catalog" })[0]; + expect(catalogTile).toHaveAttribute("aria-disabled", "true"); + }); + + it("launches the workbench dashboard from the header and the explore-more banner", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("", { status: 200 }))); + render(); + await waitFor(() => expect(screen.getByText("Online")).toBeInTheDocument()); + const opened = vi.fn(); + window.addEventListener("open-service", opened); + + fireEvent.click(screen.getAllByRole("button", { name: "Launch workbench dashboard" })[0]); + fireEvent.click(screen.getAllByRole("button", { name: "Open plugin catalog" })[1]); + fireEvent.click(screen.getAllByRole("button", { name: "Launch workbench dashboard" })[1]); + expect(opened).toHaveBeenCalledTimes(3); + window.removeEventListener("open-service", opened); + }); + + it("re-checks health on demand via the refresh button", async () => { + const fetchMock = vi.fn().mockResolvedValue(new Response("", { status: 200 })); + vi.stubGlobal("fetch", fetchMock); + render(); + await waitFor(() => expect(screen.getByText("Online")).toBeInTheDocument()); + const before = fetchMock.mock.calls.length; + fireEvent.click(screen.getByRole("button", { name: "Refresh connection status" })); + await waitFor(() => expect(fetchMock.mock.calls.length).toBeGreaterThan(before)); + }); + + it("loads a saved host from window.api.loadConfig", async () => { + window.api = { loadConfig: vi.fn().mockResolvedValue({ server: { host_ip: "10.1.1.1" } }) }; + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("", { status: 200 }))); + render(); + await waitFor(() => expect(screen.getByText("10.1.1.1")).toBeInTheDocument()); + }); + + it("hides the Admin Console tile without the required permission, and shows it when the user has it or is still unknown", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("", { status: 200 }))); + getCurrentUserSync.mockReturnValue({ permissions: [] }); + getCurrentUser.mockResolvedValue({ permissions: [] }); + render(); + await waitFor(() => expect(screen.getByText("Online")).toBeInTheDocument()); + expect(screen.queryByText("Admin Console")).not.toBeInTheDocument(); + cleanup(); + + getCurrentUserSync.mockReturnValue({ permissions: ["platform.manage_infra"] }); + getCurrentUser.mockResolvedValue({ permissions: ["platform.manage_infra"] }); + render(); + await waitFor(() => expect(screen.getByText("Admin Console")).toBeInTheDocument()); + cleanup(); + + getCurrentUserSync.mockReturnValue(null); + getCurrentUser.mockResolvedValue(null); + render(); + await waitFor(() => expect(screen.getByText("Admin Console")).toBeInTheDocument()); + }); + + it("unsubscribes from session changes on unmount", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("", { status: 200 }))); + const unsubscribe = vi.fn(); + onSessionChange.mockReturnValue(unsubscribe); + const { unmount } = render(); + await waitFor(() => expect(onSessionChange).toHaveBeenCalled()); + unmount(); + expect(unsubscribe).toHaveBeenCalled(); + }); + + it("builds an absolute Electron webview URL for local links", async () => { + isElectron.mockReturnValue(true); + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("", { status: 200 }))); + render(); + await waitFor(() => expect(screen.getByText("Online")).toBeInTheDocument()); + const opened = vi.fn(); + window.addEventListener("open-service", opened); + fireEvent.click(screen.getAllByRole("button", { name: "Open plugin catalog" })[0]); + expect(opened.mock.calls[0][0].detail.url).toMatch(/^http:\/\/localhost/); + window.removeEventListener("open-service", opened); + }); +}); diff --git a/vitest.app.config.js b/vitest.app.config.js index 7ee4ba3e..c5d17ac1 100644 --- a/vitest.app.config.js +++ b/vitest.app.config.js @@ -19,6 +19,7 @@ export default defineConfig({ reportsDirectory: "coverage/ui-app", include: ["src/ui/**/*.{js,jsx}"], exclude: ["src/ui/main.jsx"], + thresholds: { statements: 95, lines: 95, functions: 95, branches: 95 }, }, }, });