From 31c0e636d9a7409a7505fe8170c3a32b8bfaa772 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Sun, 27 Sep 2026 11:06:22 -0500 Subject: [PATCH] feat: make Studio the authenticated landing portal --- README.md | 2 +- src/ui/App.jsx | 29 +++++-- .../GrafanaViewer/GrafanaViewer.jsx | 2 +- src/ui/pages/ServiceViewer.jsx | 2 +- src/ui/pages/{Workbench.jsx => Studio.jsx} | 6 +- src/ui/pages/Videos.jsx | 2 +- tests/ui/app-shell.test.jsx | 51 +++++++++---- tests/ui/app.test.jsx | 4 +- tests/ui/coverage_matrix.test.jsx | 6 +- tests/ui/grafana-viewer.test.jsx | 2 +- tests/ui/service-viewer.test.jsx | 2 +- .../{workbench.test.jsx => studio.test.jsx} | 76 +++++++++++-------- tests/ui/videos.test.jsx | 2 +- vite.config.js | 4 +- 14 files changed, 119 insertions(+), 71 deletions(-) rename src/ui/pages/{Workbench.jsx => Studio.jsx} (99%) rename tests/ui/{workbench.test.jsx => studio.test.jsx} (92%) diff --git a/README.md b/README.md index 4377bfc7..5adefa27 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ forward to v0.9.0 (tracked in [#100](https://github.com/OmniBioAI/omnibioai-stud ### v0.7.0 โœ… - ๐Ÿ” **Unified license key system** (`OMNI-XXXX-XXXX-XXXX-XXXX`) โ€” one key works for web + desktop, auto-creates user on first validation, same JWT as OAuth login -- ๐ŸŒ **webstudio.omnibioai.org fully working** โ€” license key login enforced, all workbench pages loading correctly, service worker fixed, nginx routes fixed (`/license/`, `/roles/`), Control Center JWT cookie fallback +- ๐ŸŒ **webstudio.omnibioai.org fully working** โ€” license key login enforced, all Studio pages loading correctly, service worker fixed, nginx routes fixed (`/license/`, `/roles/`), Control Center JWT cookie fallback - ๐Ÿ‘ฅ **Team expansion** โ€” Dr. Rajnish Kumar (Scientific Consultant, SR University), Praveen C.V. Raghavulu (Scientific Advisor, KUMC); About page restructured as company team page - ๐Ÿ›  **12,110 bioinformatics tools** (up from 11,577) โ€” 100+ new HTTP API tools added, all tools validated (100% clean), 0 duplicates, 0 ToolSpec errors - ๐Ÿค– **Tool selection AI improved** โ€” GPU enabled for Ollama (57x faster: 120s โ†’ 2.1s), accuracy 0% โ†’ 60%, Recall@K 60% โ†’ 85% diff --git a/src/ui/App.jsx b/src/ui/App.jsx index 30c1b9b4..a32fdc06 100644 --- a/src/ui/App.jsx +++ b/src/ui/App.jsx @@ -12,7 +12,7 @@ import Launch from "./pages/Launch"; import Services from "./pages/Services"; import Logs from "./pages/Logs"; import Settings from "./pages/Settings"; -import Workbench from "./pages/Workbench"; +import Studio from "./pages/Studio"; import Jobs from "./pages/Jobs"; import ServiceViewer from "./pages/ServiceViewer"; import Videos from "./pages/Videos"; @@ -37,7 +37,7 @@ const BASE_NAV = [ { name:"Services", idx:5 }, { name:"IDE Services", idx:10 }, { name:"Logs", idx:6 }, - { name:"Workbench", idx:7 }, + { name:"Studio", idx:7 }, { name:"Jobs", idx:9 }, { name:"Billing", idx:12 }, ]}, @@ -63,9 +63,12 @@ const WIZARD_MAX = 4; const PAGE_NAMES = [ "mode","llm","cloud","hpc","launch", - "services","logs","workbench","settings","jobs","ide-services","roles","billing" + "services","logs","studio","settings","jobs","ide-services","roles","billing" ]; +const STUDIO_PATH = "/studio"; +const LEGACY_PORTAL_PATH = "/workbench"; + // Same-origin relative paths only -- must start with exactly one "/", // never "//" (browsers treat a leading "//" as protocol-relative, an open // redirect to any host), and must not resolve to a different origin. @@ -86,7 +89,7 @@ function getSafeReturnTo() { } export default function App() { - const [step, setStep] = useState(0); + const [step, setStep] = useState(7); const [systemStatus, setSystemStatus] = useState("idle"); const [ready, setReady] = useState(false); const [config, setConfig] = useState({ @@ -137,6 +140,18 @@ export default function App() { init(); }, []); + // The shell historically loaded at `/`. Keep that entry point working while + // giving the authenticated portal a stable `/studio` URL. `/workbench` is + // retained as a bookmark-safe alias for the portal; the real Workbench + // service remains under `/_svc/workbench/` and is never claimed here. + useEffect(() => { + const url = new URL(window.location.href); + if (url.pathname === "/" || url.pathname === LEGACY_PORTAL_PATH) { + url.pathname = STUDIO_PATH; + window.history.replaceState(window.history.state, "", `${url.pathname}${url.search}${url.hash}`); + } + }, []); + // โ”€โ”€โ”€ Listen for navigate events (from Workbench page) โ”€โ”€ useEffect(() => { const handler = (e) => setStep(e.detail); @@ -226,7 +241,7 @@ export default function App() { , , , - , + , , , , @@ -239,7 +254,7 @@ export default function App() { function handleStudioClick() { setService(null); - setStep(7); // return to Workbench + setStep(7); // return to Studio } // Electron webview bypasses Vite proxy for relative URLs; prefix /_svc/* @@ -335,7 +350,7 @@ export default function App() { studio {" / "} {currentName} diff --git a/src/ui/components/GrafanaViewer/GrafanaViewer.jsx b/src/ui/components/GrafanaViewer/GrafanaViewer.jsx index 4521d114..f9b34839 100644 --- a/src/ui/components/GrafanaViewer/GrafanaViewer.jsx +++ b/src/ui/components/GrafanaViewer/GrafanaViewer.jsx @@ -82,7 +82,7 @@ export function GrafanaViewer({ onBack, label }) { fontWeight: 600, cursor: "pointer", }} > - โ† Back to Workbench + โ† Back to Studio - โ† Back to Workbench + โ† Back to Studio
- Workbench + OmniBioAI Studio
- OmniBioAI bioinformatics platform โ€” quick access to key modules + Unified access to OmniBioAI platform services, workflows, AI, and security
diff --git a/src/ui/pages/Videos.jsx b/src/ui/pages/Videos.jsx index 1e893018..183c9fe3 100644 --- a/src/ui/pages/Videos.jsx +++ b/src/ui/pages/Videos.jsx @@ -92,7 +92,7 @@ function Toolbar({ onBack, count }) { fontSize:"var(--font-size-xs)", fontWeight:600, cursor:"pointer", }} > - โ† Back to Workbench + โ† Back to Studio diff --git a/tests/ui/app-shell.test.jsx b/tests/ui/app-shell.test.jsx index 27123da4..af6765ce 100644 --- a/tests/ui/app-shell.test.jsx +++ b/tests/ui/app-shell.test.jsx @@ -31,7 +31,7 @@ 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/Studio", () => ({ default: () =>
Studio 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
})); @@ -50,6 +50,25 @@ beforeEach(() => { onSessionChange.mockReturnValue(vi.fn()); window.history.replaceState({}, "", "/"); }); + +describe("App shell โ€” Studio landing", () => { + it("lands authenticated users on Studio and keeps Mode accessible", async () => { + getCurrentUser.mockResolvedValue(admin); + render(); + await waitFor(() => expect(screen.getByText("Studio page")).toBeInTheDocument()); + expect(screen.getByText("Studio", { selector: "div" })).toBeInTheDocument(); + fireEvent.click(screen.getByText("Mode", { selector: "div" })); + await waitFor(() => expect(screen.getByText("Mode page")).toBeInTheDocument()); + }); + + it("maps the legacy portal path to /studio", async () => { + window.history.replaceState({}, "", "/workbench"); + getCurrentUser.mockResolvedValue(admin); + render(); + await waitFor(() => expect(screen.getByText("Studio page")).toBeInTheDocument()); + expect(window.location.pathname).toBe("/studio"); + }); +}); afterEach(() => { cleanup(); vi.restoreAllMocks(); vi.clearAllMocks(); delete window.api; }); const admin = { userId: 1, email: "admin@test", permissions: ["manage_roles"] }; @@ -72,24 +91,24 @@ describe("App shell โ€” loading and first-run", () => { window.api = { loadConfig: vi.fn().mockResolvedValue({ mode: "local", settings: { data_dir: "/d" } }) }; render(); - await waitFor(() => expect(screen.getByText("Mode page")).toBeInTheDocument()); + await waitFor(() => expect(screen.getByText("Studio 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()); + await waitFor(() => expect(screen.getByText("Studio page")).toBeInTheDocument()); }); it("becomes ready immediately with no window.api at all", async () => { render(); - await waitFor(() => expect(screen.getByText("Mode page")).toBeInTheDocument()); + await waitFor(() => expect(screen.getByText("Studio 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()); + await waitFor(() => expect(screen.getByText("Studio page")).toBeInTheDocument()); }); }); @@ -108,7 +127,7 @@ describe("App shell โ€” web auth gate", () => { isElectron.mockReturnValue(false); getCurrentUser.mockResolvedValue(admin); render(); - await waitFor(() => expect(screen.getByText("Mode page")).toBeInTheDocument()); + await waitFor(() => expect(screen.getByText("Studio page")).toBeInTheDocument()); expect(screen.getByText("Roles")).toBeInTheDocument(); }); }); @@ -117,7 +136,7 @@ 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()); + await waitFor(() => expect(screen.getByText("Studio page")).toBeInTheDocument()); expect(screen.queryByText("Roles")).not.toBeInTheDocument(); }); @@ -132,7 +151,7 @@ describe("App shell โ€” navigation and roles nav", () => { it("responds to navigate and open-service window events", async () => { getCurrentUser.mockResolvedValue(admin); render(); - await waitFor(() => expect(screen.getByText("Mode page")).toBeInTheDocument()); + await waitFor(() => expect(screen.getByText("Studio page")).toBeInTheDocument()); window.dispatchEvent(new CustomEvent("navigate", { detail: 9 })); await waitFor(() => expect(screen.getByText("Jobs page")).toBeInTheDocument()); @@ -145,13 +164,13 @@ describe("App shell โ€” navigation and roles nav", () => { 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()); + await waitFor(() => expect(screen.getByText("Studio 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()); + await waitFor(() => expect(screen.getByText("Studio page")).toBeInTheDocument()); window.dispatchEvent(new CustomEvent("open-service", { detail: { url: "/_svc/videos", label: "Videos" } })); await waitFor(() => expect(screen.getByText("Videos page")).toBeInTheDocument()); @@ -181,6 +200,8 @@ describe("App shell โ€” navigation and roles nav", () => { it("walks the wizard controls: dot navigation, Back/Next, and boundary disabling", async () => { getCurrentUser.mockResolvedValue(admin); render(); + await waitFor(() => expect(screen.getByText("Studio page")).toBeInTheDocument()); + fireEvent.click(screen.getByText("Mode", { selector: "div" })); await waitFor(() => expect(screen.getByText("Mode page")).toBeInTheDocument()); expect(screen.getByText("Back")).toBeDisabled(); fireEvent.click(screen.getByText("Next โ†’")); @@ -188,7 +209,7 @@ describe("App shell โ€” navigation and roles nav", () => { fireEvent.click(screen.getByText("Back")); await waitFor(() => expect(screen.getByText("Mode page")).toBeInTheDocument()); - fireEvent.click(screen.getByTitle("Launch")); + fireEvent.click(screen.getByText("Launch", { selector: "div" })); await waitFor(() => expect(screen.getByText("Launch page")).toBeInTheDocument()); expect(screen.getByText("Next โ†’")).toBeDisabled(); }); @@ -196,8 +217,8 @@ describe("App shell โ€” navigation and roles nav", () => { 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("Studio page")).toBeInTheDocument()); + fireEvent.click(screen.getByText("Launch", { selector: "div" })); await waitFor(() => expect(screen.getByText("Launch page")).toBeInTheDocument()); fireEvent.click(screen.getByText("go-starting")); await waitFor(() => expect(screen.getAllByText("STARTING").length).toBeGreaterThan(0)); @@ -249,7 +270,7 @@ describe("App shell โ€” token refresh and return_to redirect", () => { getRefreshToken.mockReturnValue(null); getCurrentUser.mockResolvedValue(admin); render(); - await waitFor(() => expect(screen.getByText("Mode page")).toBeInTheDocument()); + await waitFor(() => expect(screen.getByText("Studio page")).toBeInTheDocument()); expect(refresh).not.toHaveBeenCalled(); }); @@ -282,6 +303,6 @@ describe("App shell โ€” token refresh and return_to redirect", () => { 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()); + await waitFor(() => expect(screen.getByText("Studio page")).toBeInTheDocument()); }); }); diff --git a/tests/ui/app.test.jsx b/tests/ui/app.test.jsx index 8343249b..1a973e45 100644 --- a/tests/ui/app.test.jsx +++ b/tests/ui/app.test.jsx @@ -28,7 +28,7 @@ vi.mock("../../src/ui/pages/HPC", () => ({ default: () =>
HPC page
}) vi.mock("../../src/ui/pages/Launch", () => ({ default: () =>
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/Studio", () => ({ default: () =>
Studio 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
})); @@ -49,7 +49,7 @@ describe("application shell", () => { it("renders the authorized shell and responds to application navigation events", async () => { getCurrentUser.mockResolvedValueOnce({ userId: 1, email: "admin@test", permissions: ["manage_roles"] }); render(); - await waitFor(() => expect(screen.getByText("Mode page")).toBeInTheDocument()); + await waitFor(() => expect(screen.getByText("Studio page")).toBeInTheDocument()); expect(screen.getByText("Roles")).toBeInTheDocument(); window.dispatchEvent(new CustomEvent("navigate", { detail: 9 })); await waitFor(() => expect(screen.getByText("Jobs page")).toBeInTheDocument()); diff --git a/tests/ui/coverage_matrix.test.jsx b/tests/ui/coverage_matrix.test.jsx index 5ef2dc64..03cf85d7 100644 --- a/tests/ui/coverage_matrix.test.jsx +++ b/tests/ui/coverage_matrix.test.jsx @@ -39,7 +39,7 @@ 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 Studio from "../../src/ui/pages/Studio"; import RoleManagement from "../../src/ui/pages/RoleManagement"; import ServiceViewer from "../../src/ui/pages/ServiceViewer"; import Videos from "../../src/ui/pages/Videos"; @@ -71,7 +71,7 @@ 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()); + await waitFor(() => expect(screen.getByText("OmniBioAI Studio")).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)); @@ -96,7 +96,7 @@ describe("page and component coverage matrix", () => { [Services, { config, currentUser: user }], [IdeServices, { currentUser: user }], [Jobs, {}], - [Workbench, {}], + [Studio, {}], ]) { const { unmount } = render(); await waitFor(() => expect(document.body.textContent.length).toBeGreaterThan(0)); diff --git a/tests/ui/grafana-viewer.test.jsx b/tests/ui/grafana-viewer.test.jsx index 3c2f4994..54476a15 100644 --- a/tests/ui/grafana-viewer.test.jsx +++ b/tests/ui/grafana-viewer.test.jsx @@ -28,7 +28,7 @@ describe("GrafanaViewer โ€” web build", () => { fireEvent.click(screen.getByText("RAG")); expect(document.querySelector("iframe").src).toContain("omnibioai-rag"); - fireEvent.click(screen.getByText("โ† Back to Workbench")); + fireEvent.click(screen.getByText("โ† Back to Studio")); expect(onBack).toHaveBeenCalled(); }); diff --git a/tests/ui/service-viewer.test.jsx b/tests/ui/service-viewer.test.jsx index 8298bd98..d537b194 100644 --- a/tests/ui/service-viewer.test.jsx +++ b/tests/ui/service-viewer.test.jsx @@ -21,7 +21,7 @@ describe("ServiceViewer", () => { it("calls onBack", () => { const onBack = vi.fn(); render(); - fireEvent.click(screen.getByText("โ† Back to Workbench")); + fireEvent.click(screen.getByText("โ† Back to Studio")); expect(onBack).toHaveBeenCalled(); }); diff --git a/tests/ui/workbench.test.jsx b/tests/ui/studio.test.jsx similarity index 92% rename from tests/ui/workbench.test.jsx rename to tests/ui/studio.test.jsx index 274491ef..82cffd7a 100644 --- a/tests/ui/workbench.test.jsx +++ b/tests/ui/studio.test.jsx @@ -10,7 +10,7 @@ const { isElectron, getCurrentUserSync, getCurrentUser, onSessionChange } = vi.h })); vi.mock("../../src/ui/lib/session", () => ({ isElectron, getCurrentUserSync, getCurrentUser, onSessionChange })); -import Workbench from "../../src/ui/pages/Workbench"; +import Studio from "../../src/ui/pages/Studio"; function usePermissions(permissions) { const user = { permissions }; @@ -27,10 +27,20 @@ beforeEach(() => { }); afterEach(() => { cleanup(); vi.restoreAllMocks(); delete window.api; }); -describe("Workbench page", () => { +describe("Studio portal", () => { + it("renders the Studio identity, security section, and real Workbench service tile", () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("", { status: 200 }))); + render(); + expect(screen.getByText("OmniBioAI Studio")).toBeInTheDocument(); + expect(screen.getByText("Unified access to OmniBioAI platform services, workflows, AI, and security")).toBeInTheDocument(); + expect(screen.getByText("Platform Services")).toBeInTheDocument(); + expect(screen.getByText("Security Control Plane")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Workbench โ€” Dashboard" })).toBeInTheDocument(); + }); + 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(); + render(); expect(screen.getByText("Checking...")).toBeInTheDocument(); await waitFor(() => expect(screen.getByText("Online")).toBeInTheDocument()); expect(screen.queryByText(/Workbench offline/)).not.toBeInTheDocument(); @@ -52,7 +62,7 @@ describe("Workbench page", () => { it("shows the offline banner and navigates to Launch from it", async () => { vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("down"))); - render(); + render(); await waitFor(() => expect(screen.getByText("Offline")).toBeInTheDocument()); expect(screen.getByText(/Workbench offline/)).toBeInTheDocument(); @@ -69,7 +79,7 @@ describe("Workbench page", () => { 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(); + render(); await waitFor(() => expect(screen.getByText("Online")).toBeInTheDocument()); const opened = vi.fn(); window.addEventListener("open-service", opened); @@ -84,7 +94,7 @@ describe("Workbench page", () => { 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(); + render(); await waitFor(() => expect(screen.getByText("Online")).toBeInTheDocument()); const before = fetchMock.mock.calls.length; fireEvent.click(screen.getByRole("button", { name: "Refresh connection status" })); @@ -94,7 +104,7 @@ describe("Workbench page", () => { 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(); + render(); await waitFor(() => expect(screen.getByText("10.1.1.1")).toBeInTheDocument()); }); @@ -102,20 +112,20 @@ describe("Workbench page", () => { vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("", { status: 200 }))); getCurrentUserSync.mockReturnValue({ permissions: [] }); getCurrentUser.mockResolvedValue({ permissions: [] }); - render(); + 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(); + render(); await waitFor(() => expect(screen.getByText("Admin Console")).toBeInTheDocument()); cleanup(); getCurrentUserSync.mockReturnValue(null); getCurrentUser.mockResolvedValue(null); - render(); + render(); await waitFor(() => expect(screen.getByText("Admin Console")).toBeInTheDocument()); }); @@ -124,7 +134,7 @@ describe("Workbench page", () => { (permission) => { usePermissions([permission]); vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("", { status: 200 }))); - render(); + render(); expect(screen.getByRole("button", { name: "API Keys & Service Accounts โ€” API Keys ยท OAuth ยท Revocation" })).toBeInTheDocument(); }, ); @@ -132,7 +142,7 @@ describe("Workbench page", () => { it("opens the canonical API Keys & Service Accounts destination without exposing sensitive details", () => { usePermissions(["manage_api_keys"]); vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("", { status: 200 }))); - render(); + render(); const tile = screen.getByRole("button", { name: "API Keys & Service Accounts โ€” API Keys ยท OAuth ยท Revocation" }); expect(screen.getByText("API Keys ยท OAuth ยท Revocation")).toBeInTheDocument(); @@ -152,14 +162,14 @@ describe("Workbench page", () => { it("hides API Keys & Service Accounts without any accepted permission", () => { usePermissions([]); vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("", { status: 200 }))); - render(); + render(); expect(screen.queryByText("API Keys & Service Accounts")).not.toBeInTheDocument(); }); it("keeps single-permission tiles independent from requiresAnyPermission tiles", () => { usePermissions(["manage_config"]); vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("", { status: 200 }))); - render(); + render(); expect(screen.getByText("LLM Runtime")).toBeInTheDocument(); expect(screen.queryByText("API Keys & Service Accounts")).not.toBeInTheDocument(); }); @@ -167,7 +177,7 @@ describe("Workbench page", () => { it("shows Compliance Center to manage_all_orgs users and opens its HIPAA page", () => { usePermissions(["manage_all_orgs"]); vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("", { status: 200 }))); - render(); + render(); const tile = screen.getByRole("button", { name: "Compliance Center โ€” HIPAA ยท Controls ยท Evidence" }); expect(screen.getByText("HIPAA ยท Controls ยท Evidence")).toBeInTheDocument(); @@ -184,14 +194,14 @@ describe("Workbench page", () => { it("hides Compliance Center without manage_all_orgs", () => { usePermissions([]); vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("", { status: 200 }))); - render(); + render(); expect(screen.queryByText("Compliance Center")).not.toBeInTheDocument(); }); it("shows Security Posture to manage_all_orgs users and opens its canonical page", () => { usePermissions(["manage_all_orgs"]); vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("", { status: 200 }))); - render(); + render(); const tile = screen.getByRole("button", { name: "Security Posture โ€” Controls ยท Enforcement ยท Readiness" }); expect(screen.getByText("Controls ยท Enforcement ยท Readiness")).toBeInTheDocument(); @@ -208,14 +218,14 @@ describe("Workbench page", () => { it("hides Security Posture without manage_all_orgs", () => { usePermissions([]); vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("", { status: 200 }))); - render(); + render(); expect(screen.queryByText("Security Posture")).not.toBeInTheDocument(); }); it("shows Audit Explorer to manage_all_orgs users and opens its canonical page", () => { usePermissions(["manage_all_orgs"]); vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("", { status: 200 }))); - render(); + render(); const tile = screen.getByRole("button", { name: "Audit Explorer \u2014 Events \u00b7 Evidence \u00b7 Investigation" }); const opened = vi.fn(); @@ -232,7 +242,7 @@ describe("Workbench page", () => { it("shows Audit Logs to manage_all_orgs users and opens its canonical page", () => { usePermissions(["manage_all_orgs"]); vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("", { status: 200 }))); - render(); + render(); const tile = screen.getByRole("button", { name: "Audit Logs \u2014 Identity \u00b7 Access \u00b7 Changes" }); const opened = vi.fn(); @@ -249,7 +259,7 @@ describe("Workbench page", () => { it("hides Audit Explorer and Audit Logs without manage_all_orgs", () => { usePermissions(["manage_api_keys"]); vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("", { status: 200 }))); - render(); + render(); expect(screen.getByText("API Keys & Service Accounts")).toBeInTheDocument(); expect(screen.queryByText("Audit Explorer")).not.toBeInTheDocument(); @@ -259,7 +269,7 @@ describe("Workbench page", () => { it("shows LLM Runtime to manage_config users and navigates to the existing LLM page", () => { usePermissions(["manage_config"]); vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("", { status: 200 }))); - render(); + render(); const tile = screen.getByRole("button", { name: "LLM Runtime โ€” Local models ยท GPU ยท Ollama" }); expect(screen.getByText("Local models ยท GPU ยท Ollama")).toBeInTheDocument(); @@ -281,14 +291,14 @@ describe("Workbench page", () => { it("hides LLM Runtime without manage_config", () => { usePermissions([]); vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("", { status: 200 }))); - render(); + render(); expect(screen.queryByText("LLM Runtime")).not.toBeInTheDocument(); }); it("shows Entitlements to manage_licenses users and opens Admin Console billing", () => { usePermissions(["manage_licenses"]); vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("", { status: 200 }))); - render(); + render(); const tile = screen.getByRole("button", { name: "Entitlements โ€” Plans ยท Licenses ยท Access" }); expect(screen.getByText("Plans ยท Licenses ยท Access")).toBeInTheDocument(); @@ -307,14 +317,14 @@ describe("Workbench page", () => { it("hides Entitlements without manage_licenses", () => { usePermissions([]); vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("", { status: 200 }))); - render(); + render(); expect(screen.queryByText("Entitlements")).not.toBeInTheDocument(); }); it("shows all 12 Security Control Plane modules to a fully authorized user", () => { usePermissions(["manage_api_keys", "manage_oauth_clients", "manage_all_orgs"]); vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("", { status: 200 }))); - render(); + render(); [ "API Gateway", @@ -336,13 +346,13 @@ describe("Workbench page", () => { it("shows 18 Platform Services modules to a fully authorized user", () => { usePermissions(["platform.manage_infra", "manage_config", "manage_licenses"]); vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("", { status: 200 }))); - render(); + render(); expect(screen.getByText("18 modules")).toBeInTheDocument(); }); it("uses the Platform Services cyan accent for indicators and names only", () => { vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("", { status: 200 }))); - render(); + render(); const heading = screen.getByText("Platform Services"); expect(heading.style.color).toBe("var(--text)"); @@ -355,7 +365,7 @@ describe("Workbench page", () => { vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("", { status: 200 }))); const unsubscribe = vi.fn(); onSessionChange.mockReturnValue(unsubscribe); - const { unmount } = render(); + const { unmount } = render(); await waitFor(() => expect(onSessionChange).toHaveBeenCalled()); unmount(); expect(unsubscribe).toHaveBeenCalled(); @@ -364,7 +374,7 @@ describe("Workbench page", () => { it("opens Neo4j Browser through the same-origin embedded service route", () => { usePermissions(["platform.manage_infra"]); vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("", { status: 200 }))); - render(); + render(); const tile = screen.getByRole("button", { name: "Neo4j Browser โ€” Knowledge-graph Cypher console" }); const opened = vi.fn(); @@ -380,7 +390,7 @@ describe("Workbench page", () => { isElectron.mockReturnValue(true); usePermissions(["platform.manage_infra"]); vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("", { status: 200 }))); - render(); + render(); const tile = screen.getByRole("button", { name: "Neo4j Browser โ€” Knowledge-graph Cypher console" }); const opened = vi.fn(); @@ -395,14 +405,14 @@ describe("Workbench page", () => { it("hides Neo4j Browser without platform.manage_infra", () => { usePermissions([]); vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("", { status: 200 }))); - render(); + render(); expect(screen.queryByText("Neo4j Browser")).not.toBeInTheDocument(); }); 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(); + render(); await waitFor(() => expect(screen.getByText("Online")).toBeInTheDocument()); const opened = vi.fn(); window.addEventListener("open-service", opened); diff --git a/tests/ui/videos.test.jsx b/tests/ui/videos.test.jsx index 30afd012..b7fe9dbc 100644 --- a/tests/ui/videos.test.jsx +++ b/tests/ui/videos.test.jsx @@ -102,7 +102,7 @@ describe("Videos page", () => { fireEvent.keyDown(window, { key: "Escape" }); await waitFor(() => expect(screen.queryByText("โœ• Close")).not.toBeInTheDocument()); - fireEvent.click(screen.getByText("โ† Back to Workbench")); + fireEvent.click(screen.getByText("โ† Back to Studio")); expect(onBack).toHaveBeenCalled(); }); diff --git a/vite.config.js b/vite.config.js index a951e00e..3cb9e4a6 100644 --- a/vite.config.js +++ b/vite.config.js @@ -39,7 +39,9 @@ export default defineConfig(({ mode }) => { // shell; every nginx-router-proxied path must reach the // network. Matched against url.pathname only (query strings, // e.g. an OAuth redirect's "/?status=...", still match "/"). - navigateFallbackAllowlist: [/^\/$/], + // `/studio` is the canonical portal path and `/workbench` is a + // legacy alias. Service paths remain network-only. + navigateFallbackAllowlist: [/^\/(?:studio|workbench)?$/], }, manifest: { name: "OmniBioAI Studio",