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(
({
+ 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 },
},
},
});