diff --git a/docs-site/src/content/docs/guides/web-dashboard.md b/docs-site/src/content/docs/guides/web-dashboard.md index 23ff93dfc..689cbe08f 100644 --- a/docs-site/src/content/docs/guides/web-dashboard.md +++ b/docs-site/src/content/docs/guides/web-dashboard.md @@ -41,6 +41,12 @@ bun run dev:gui | **Usage / Debug** | Inspect token-usage coverage and trends, or enable opt-in provider transport and usage-extraction diagnostics. | | **Stop** | Gracefully stop the proxy and installed background service, restore native Codex, and exit (`POST /api/stop`). | +### Classic vs Workspace + +The sidebar **Workspace / Classic** control switches the Dashboard and Providers layouts. The +preference is stored in the browser and kept consistent across those pages. On Providers, the URL +also reflects the mode (`#providers` vs `#providers/workspace`). + Cost values in **Logs** and **Usage** are API list-price equivalents calculated from reported tokens. They are not billing receipts or evidence of an actual charge; subscription usage or provider credits may apply instead. diff --git a/docs-site/src/content/docs/ja/guides/web-dashboard.md b/docs-site/src/content/docs/ja/guides/web-dashboard.md index c4052a2fb..dd80021ea 100644 --- a/docs-site/src/content/docs/ja/guides/web-dashboard.md +++ b/docs-site/src/content/docs/ja/guides/web-dashboard.md @@ -41,6 +41,10 @@ bun run dev:gui | **使用量 / デバッグ** | トークン使用量の測定範囲と推移を見るか、オプションのプロバイダートランスポート/使用量抽出診断をオンにします。 | | **停止** | プロキシとインストールされたバックグラウンドサービスを正常終了しネイティブ Codex を復元した後終了します(`POST /api/stop`)。 | +### Classic / Workspace + +サイドバーの **Workspace / Classic** コントロールで Dashboard と Providers のレイアウトを切り替えます。設定はブラウザに保存され、これらのページ間で揃います。Providers では URL もモードを反映します(`#providers` と `#providers/workspace`)。 + **ログ**と**使用量**のコスト値は報告されたトークンで計算した API 定価換算値です。請求明細や 実際の請求証拠ではなく、サブスクリプション使用量またはプロバイダークレジットが代わりに適用される場合があります。 diff --git a/docs-site/src/content/docs/ko/guides/web-dashboard.md b/docs-site/src/content/docs/ko/guides/web-dashboard.md index e665d6d0a..0982f4d83 100644 --- a/docs-site/src/content/docs/ko/guides/web-dashboard.md +++ b/docs-site/src/content/docs/ko/guides/web-dashboard.md @@ -41,6 +41,10 @@ bun run dev:gui | **Usage / Debug** | 토큰 사용량의 측정 범위와 추이를 보거나, 선택적 프로바이더 전송/사용량 추출 진단을 켭니다. | | **Stop** | 프록시와 설치된 백그라운드 서비스를 정상 종료하고 네이티브 Codex를 복원한 뒤 끝냅니다(`POST /api/stop`). | +### Classic / Workspace + +사이드바의 **Workspace / Classic** 컨트롤로 Dashboard와 Providers 레이아웃을 전환합니다. 설정은 브라우저에 저장되며 이 페이지들 사이에서 맞춰집니다. Providers에서는 URL도 모드를 반영합니다(`#providers` vs `#providers/workspace`). + **Logs**와 **Usage**의 비용 값은 보고된 토큰으로 계산한 API 정가 환산치입니다. 결제 영수증이나 실제 청구 증거가 아니며, 구독 사용량 또는 프로바이더 크레딧이 대신 적용될 수 있습니다. diff --git a/docs-site/src/content/docs/ru/guides/web-dashboard.md b/docs-site/src/content/docs/ru/guides/web-dashboard.md index 98a43ce45..e48b63e90 100644 --- a/docs-site/src/content/docs/ru/guides/web-dashboard.md +++ b/docs-site/src/content/docs/ru/guides/web-dashboard.md @@ -41,6 +41,10 @@ bun run dev:gui | **Usage / Debug** | Просмотр покрытия и трендов расхода токенов либо включение опциональной диагностики транспорта провайдеров и извлечения данных об использовании. | | **Stop** | Корректная остановка прокси и установленного фонового сервиса, восстановление нативного Codex и выход (`POST /api/stop`). | +### Classic / Workspace + +Элемент боковой панели **Workspace / Classic** переключает макеты Dashboard и Providers. Предпочтение хранится в браузере и согласуется между этими страницами. На Providers URL также отражает режим (`#providers` и `#providers/workspace`). + Значения стоимости в **Logs** и **Usage** — это эквиваленты стоимости по прайс-листу API, рассчитанные на основе сообщённых токенов. Они не являются счётом и не подтверждают фактическое списание; расходы могут покрываться подпиской или кредитами провайдера. diff --git a/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md b/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md index 65dc53e27..fc1919c0f 100644 --- a/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md +++ b/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md @@ -40,6 +40,10 @@ bun run dev:gui | **Usage / Debug** | 查看 token usage 覆盖率与趋势,或启用可选的 provider transport 和 usage 提取诊断。 | | **Stop** | 优雅地停止代理和已安装的后台服务,恢复原生 Codex 并退出(`POST /api/stop`)。 | +### Classic / Workspace + +侧边栏的 **Workspace / Classic** 控件切换 Dashboard 与 Providers 的布局。偏好保存在浏览器中,并在这些页面间保持一致。Providers 页面的 URL 也会反映模式(`#providers` 与 `#providers/workspace`)。 + **Logs** 和 **Usage** 中的费用是根据已报告 token 计算的 API 标价折算值,不是账单,也不能证明 实际发生了扣费;实际可能计入订阅用量或消耗服务商额度。 diff --git a/gui/src/App.tsx b/gui/src/App.tsx index 3d384cce5..1d6cfbc91 100644 --- a/gui/src/App.tsx +++ b/gui/src/App.tsx @@ -12,18 +12,17 @@ import ApiKeys from "./pages/ApiKeys"; import ClaudeCode from "./pages/ClaudeCode"; import Startup from "./pages/Startup"; import ErrorBoundary from "./components/ErrorBoundary"; -import { IconGrid, IconServer, IconBoxes, IconBot, IconList, IconActivity, IconHardDrive, IconKey, IconGithub, IconMenu, IconSun, IconMoon, IconMonitor, IconGlobe, IconPower, IconSparkle, IconX } from "./icons"; +import { IconGrid, IconServer, IconBoxes, IconBot, IconList, IconActivity, IconHardDrive, IconKey, IconGithub, IconMenu, IconSun, IconMoon, IconMonitor, IconGlobe, IconPower, IconSparkle, IconX, IconLayoutSidebar } from "./icons"; import { useI18n, useT, LOCALES, type Locale, type TKey } from "./i18n"; import { Select, Switch } from "./ui"; import { installApiAuthFetch } from "./api"; +import { type Page } from "./app-routing"; +import { useAppRouteState } from "./use-app-route-state"; installApiAuthFetch(); -type Page = "dashboard" | "startup" | "providers" | "models" | "combos" | "subagents" | "logs" | "usage" | "storage" | "codex-auth" | "api" | "claude"; type Theme = "light" | "dark" | "system"; -const VALID_PAGES = new Set(["dashboard", "startup", "providers", "models", "combos", "subagents", "logs", "usage", "storage", "codex-auth", "api", "claude"]); - const PAGE_TKEY: Record = { dashboard: "nav.dashboard", startup: "nav.startup", @@ -39,44 +38,8 @@ const PAGE_TKEY: Record = { claude: "nav.claude", }; -function readPageFromHash(): Page { - const raw = location.hash.replace(/^#\/?/, ""); - // Sub-views use a "/" suffix (e.g. #providers/workspace); the first segment is the page id. - const pageId = raw.split("/")[0] as Page; - // Legacy: Debug used to be a standalone page; it now lives as a tab on Logs. - if (pageId === ("debug" as Page)) return "logs"; - return VALID_PAGES.has(pageId) ? pageId : "dashboard"; -} - -function hashBelongsToPage(rawHash: string, page: Page): boolean { - return rawHash === page - || (page === "providers" && rawHash === "providers/workspace") - || (page === "logs" && rawHash === "logs/debug"); -} - const API_BASE = import.meta.env.VITE_API_BASE || ""; const THEME_KEY = "ocx-theme"; -const PROVIDERS_VIEW_KEY = "ocx-providers-view"; - -function readProvidersViewPreference(): "classic" | "workspace" { - try { - return localStorage.getItem(PROVIDERS_VIEW_KEY) === "workspace" ? "workspace" : "classic"; - } catch { - return "classic"; - } -} - -function writeProvidersViewPreference(view: "classic" | "workspace"): void { - try { - localStorage.setItem(PROVIDERS_VIEW_KEY, view); - } catch { - /* ignore quota / private-mode failures */ - } -} - -function providersHashForPage(): string { - return readProvidersViewPreference() === "workspace" ? "providers/workspace" : "providers"; -} const NAV: { id: Page; tkey: TKey; Icon: typeof IconGrid }[] = [ { id: "dashboard", tkey: "nav.dashboard", Icon: IconGrid }, @@ -106,7 +69,7 @@ function readStoredTheme(): Theme { } export default function App() { - const [page, setPageState] = useState(readPageFromHash); + const { page, viewMode, toggleGlobalWorkspace, navigateToPage } = useAppRouteState(); const [theme, setTheme] = useState(readStoredTheme); const [runtimeVersion, setRuntimeVersion] = useState(null); const { locale, setLocale } = useI18n(); @@ -120,62 +83,15 @@ export default function App() { useEffect(() => { // External navigation (hash edit, back/forward) also dismisses the mobile drawer. - const onHash = () => { - const nextPage = readPageFromHash(); - const rawHash = window.location.hash.replace(/^#\/?/, ""); - setNavOpen(false); - // Legacy #debug deep links → the Debug tab on Logs. - if (rawHash === "debug" || rawHash.startsWith("debug/")) { - window.location.hash = "logs/debug"; - return; - } - if (!hashBelongsToPage(rawHash, nextPage)) { - window.location.hash = nextPage === "providers" ? providersHashForPage() : nextPage; - return; - } - // Preference is source of truth for Classic/Workspace. Bare #providers must not - // wipe a saved workspace choice (that regressed when leaving Providers and returning). - if (nextPage === "providers") { - const preferred = readProvidersViewPreference(); - if (rawHash === "providers/workspace") { - writeProvidersViewPreference("workspace"); - } else if (rawHash === "providers" && preferred === "workspace") { - window.location.hash = "providers/workspace"; - return; - } else if (rawHash === "providers") { - writeProvidersViewPreference("classic"); - } - } - setPageState(nextPage); + const dismissNav = () => setNavOpen(false); + window.addEventListener("hashchange", dismissNav); + window.addEventListener("popstate", dismissNav); + return () => { + window.removeEventListener("hashchange", dismissNav); + window.removeEventListener("popstate", dismissNav); }; - window.addEventListener("hashchange", onHash); - return () => window.removeEventListener("hashchange", onHash); }, []); - useEffect(() => { - const rawHash = window.location.hash.replace(/^#\/?/, ""); - // Legacy #debug deep links must resolve before generic normalization - // (otherwise the hash collapses to bare #logs and the tab choice is lost). - if (rawHash === "debug" || rawHash.startsWith("debug/")) { - window.location.hash = "logs/debug"; - return; - } - if (page === "providers") { - // Honor an explicit workspace deep link on first load before normalizing - // to the saved preference (bookmarks/shared links must not open Classic). - if (rawHash === "providers/workspace") { - writeProvidersViewPreference("workspace"); - return; - } - const wanted = providersHashForPage(); - if (rawHash !== wanted) window.location.hash = wanted; - return; - } - if (!hashBelongsToPage(rawHash, page)) { - window.location.hash = page; - } - }, [page]); - useEffect(() => { const el = document.documentElement; if (theme === "system") { el.removeAttribute("data-theme"); localStorage.removeItem(THEME_KEY); } @@ -302,9 +218,8 @@ export default function App() {
({ value: e, label: e })), - ]} - onChange={async (v) => { - if (effortCapSaving) return; - setEffortCapSaving(true); - try { - const res = await fetch(`${apiBase}/api/effort-caps`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ effortCap: v || null }), - }); - if (res.ok) { - const data = await res.json() as { ok: boolean; effortCap?: string | null; subagentEffortCap?: string | null }; - setEffortCap(data.effortCap ?? ""); - setSubagentEffortCap(data.subagentEffortCap ?? ""); - } - } catch { /* ignore */ } - finally { setEffortCapSaving(false); } - }} - disabled={effortCapSaving} - label={t("dash.effortCapLabel")} - /> - ({ value: m.namespaced, label: `${m.provider} / ${m.model}` })), - ]} - onChange={async (v) => { - if (injectionSaving) return; - setInjectionSaving(true); - try { - const res = await fetch(`${apiBase}/api/injection-model`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ model: v || null, effort: injectionEffort || null }), - }); - if (res.ok) { - const data = await res.json() as { model?: string | null; effort?: string | null }; - setInjectionModel(data.model ?? ""); - setInjectionEffort(data.effort ?? ""); - } - } catch { /* ignore */ } - finally { setInjectionSaving(false); } - }} - disabled={injectionSaving || !multiAgentGuidanceEnabled} - label={t("dash.injectionLabel")} - /> - {injectionModel && injectionEfforts.length > 0 && ( - { void saveSidecar({ webSearch: { model, backend: sidecarBackendForModel(models, model) } }); }} - disabled={!sidecar || sidecarSaving} - label={t("dash.sidecarModel")} - /> -
-
- +{maModeResolved && maMode !== "v1" && ( +
+
+ + {t("dash.effortCapLabel")} + + + ({ value: e, label: e })), + ]} + onChange={async (v) => { + if (effortCapSaving) return; + setEffortCapSaving(true); + try { + const res = await fetch(`${apiBase}/api/effort-caps`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ subagentEffortCap: v || null }), + }); + if (res.ok) { + const data = await res.json() as { ok: boolean; effortCap?: string | null; subagentEffortCap?: string | null }; + setEffortCap(data.effortCap ?? ""); + setSubagentEffortCap(data.subagentEffortCap ?? ""); + } + } catch { /* ignore */ } + finally { setEffortCapSaving(false); } + }} + disabled={effortCapSaving} + label={t("dash.subagentEffortCapLabel")} + /> +
+
+)} -
-
-
-
{t("dash.visionSidecar")}
-
{t("dash.visionSidecarHint")}
-
-
- ({ value: m.namespaced, label: `${m.provider} / ${m.model}` })), + ]} + onChange={async (v) => { + if (injectionSaving) return; + setInjectionSaving(true); + try { + const res = await fetch(`${apiBase}/api/injection-model`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: v || null, effort: injectionEffort || null }), + }); + if (res.ok) { + const data = await res.json() as { model?: string | null; effort?: string | null }; + setInjectionModel(data.model ?? ""); + setInjectionEffort(data.effort ?? ""); + } + } catch { /* ignore */ } + finally { setInjectionSaving(false); } + }} + disabled={injectionSaving || !multiAgentGuidanceEnabled} + label={t("dash.injectionLabel")} + /> + {injectionModel && injectionEfforts.length > 0 && ( + { void saveSidecar({ webSearch: { model, backend: sidecarBackendForModel(models, model) } }); }} + disabled={!sidecar || sidecarSaving} + label={t("dash.sidecarModel")} + /> +
+
{t("dash.webSearchSidecarHint")}
+
+ +
+
+
{t("dash.visionSidecar")}
+ ({ value: m.id, label: `${m.provider}/${m.id}` }))]} + onChange={v => { void saveShadowCall({ model: v }); }} + disabled={!shadowCall || shadowCallSaving || !shadowCall?.enabled} + label={t("dash.shadowCallModel")} + align="right" + /> +
+
+
+ + + ); + + const providersSection = ( + <> +
{t("dash.activeProviders")} {providers.length}
+{providers.length === 0 ? ( + } /> +) : ( +
+ + + + {providers.map(p => ( + + + + + + + ))} + +
{t("dash.col.name")}{t("dash.col.adapter")}{t("dash.col.baseUrl")}{t("dash.col.model")}
{p.name}{p.adapter}{p.baseUrl}{p.defaultModel ?? "—"}
+
+)} + + + ); + + const modelsSection = ( + <> +
+ {t("dash.availableModels")} {models.length} + {modelsLoading && } +
+{models.length === 0 && !modelsLoading ? ( + +) : ( + <> +
+
+ {filteredGroups.length === 0 ? ( +

{t("dash.modelsNoResults")}

+ ) : ( +
+ {filteredGroups.map(([provider, rows]) => { + const q = modelQuery.trim().toLowerCase(); + const open = q !== "" || expandedProviders.has(provider); + return ( +
- changeUpdateChannel(v as UpdateChannel)} + disabled={updateLoading} + label={t("dash.updateChannel")} + // Native top-layer paints above body portals. + portal={false} + /> +
+ {updateLoading && } title={t("dash.updateChecking")} />} + {updateError && ( +
{updateError}
)} - - { event.preventDefault(); closeUpdateDialog(); }} - > -
-
-

{t("dash.updateTitle")}

-
-
{t("dash.updateDesc")}
-
- - + + + + + {enabled && model ? Active : null} +
+ ); +} + +test("guidance-off keeps stored selection but hides Active and disables controls", async () => { + const { createRoot } = await import("react-dom/client"); + const host = document.createElement("div"); + document.body.append(host); + let root!: Root; + function Shell() { + const [enabled, setEnabled] = useState(false); + useEffect(() => {}, []); + return ( +
+ + +
+ ); + } + await act(async () => { + root = createRoot(host); + root.render(); + }); + const model = host.querySelector('select[aria-label="model"]')!; + expect(model.disabled).toBe(true); + expect(model.value).toBe("claude"); + expect(host.textContent).not.toContain("Active"); + await act(async () => { host.querySelector("button")!.click(); }); + expect(model.disabled).toBe(false); + expect(host.textContent).toContain("Active"); + await act(async () => { root.unmount(); }); +}); diff --git a/gui/tests/providers-hash-history.test.tsx b/gui/tests/providers-hash-history.test.tsx new file mode 100644 index 000000000..59055a5de --- /dev/null +++ b/gui/tests/providers-hash-history.test.tsx @@ -0,0 +1,427 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act } from "react"; +import type { Root } from "react-dom/client"; +import { resolveAppHashChange } from "../src/app-routing"; +import { + navigateHash, + normalizeHashPath, + replaceHash, + resolveProvidersHashChange, +} from "../src/hash-routing"; +import { useAppRouteState } from "../src/use-app-route-state"; +import { + GLOBAL_VIEW_KEY, + providersHashForViewMode, + readViewMode, + writeViewMode, + type ViewMode, +} from "../src/view-mode"; + +const globals = ["document", "window", "navigator", "localStorage", "IS_REACT_ACT_ENVIRONMENT"] as const; +let previousGlobals: Record<(typeof globals)[number], unknown>; +let testWindow: Window; +let fetchMock: typeof fetch; + +beforeEach(() => { + previousGlobals = Object.fromEntries(globals.map((key) => [key, Reflect.get(globalThis, key)])) as typeof previousGlobals; + testWindow = new Window({ url: "http://localhost/#dashboard" }); + Object.defineProperties(globalThis, { + document: { configurable: true, value: testWindow.document }, + window: { configurable: true, value: testWindow }, + navigator: { configurable: true, value: testWindow.navigator }, + localStorage: { configurable: true, value: testWindow.localStorage }, + }); + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + testWindow.localStorage.clear(); + fetchMock = globalThis.fetch; + globalThis.fetch = (async () => new Response("{}", { status: 200 })) as typeof fetch; +}); + +afterEach(() => { + globalThis.fetch = fetchMock; + testWindow.close(); + for (const key of globals) { + Object.defineProperty(globalThis, key, { configurable: true, value: previousGlobals[key] }); + } +}); + +describe("hash-routing helpers", () => { + test("replaceHash does not increase history length", () => { + testWindow.location.hash = "#providers"; + const afterAssign = testWindow.history.length; + replaceHash("providers/workspace", testWindow as unknown as Window); + expect(normalizeHashPath(testWindow.location.hash)).toBe("providers/workspace"); + expect(testWindow.history.length).toBe(afterAssign); + }); + + test("navigateHash creates a deliberate history entry", () => { + testWindow.location.hash = "#providers"; + const before = testWindow.history.length; + navigateHash("providers/workspace", testWindow as unknown as Window); + expect(normalizeHashPath(testWindow.location.hash)).toBe("providers/workspace"); + expect(testWindow.history.length).toBeGreaterThan(before); + }); + + test("resolveProvidersHashChange rewrites bare providers when workspace is preferred", () => { + expect(resolveProvidersHashChange("providers", "workspace")).toEqual({ + replaceTo: "providers/workspace", + viewMode: "workspace", + handledPassively: true, + }); + }); + + test("resolveAppHashChange keeps page and viewMode paired for passive normalize", () => { + expect(resolveAppHashChange("providers", "workspace")).toEqual({ + page: "providers", + viewMode: "workspace", + persistViewMode: "workspace", + replaceTo: "providers/workspace", + }); + }); +}); + +/** + * Thin harness around the production useAppRouteState hook (the same controller App uses). + * Does not reimplement hash ownership — only renders the controller's state. + */ +function AppRouteHarness({ + onRender, +}: { + onRender?: (snapshot: { page: string; mode: ViewMode }) => void; +}) { + const { page, viewMode, toggleGlobalWorkspace, navigateToPage } = useAppRouteState(); + onRender?.({ page, mode: viewMode }); + return ( +
+
{page}
+
{viewMode}
+ + + + + + + +
+ ); +} + +async function awaitNavigation(action: () => void): Promise { + await act(async () => { + action(); + // happy-dom may deliver popstate/hashchange just after history.back/forward. + await new Promise((resolve) => setTimeout(resolve, 40)); + }); +} + +async function historyBack(): Promise { + await awaitNavigation(() => { + testWindow.history.back(); + }); +} + +async function historyForward(): Promise { + await awaitNavigation(() => { + testWindow.history.forward(); + }); +} + +describe("production App route state (useAppRouteState)", () => { +async function mount(initialMode: ViewMode, initialHash: string) { + writeViewMode(initialMode); + testWindow.localStorage.setItem(GLOBAL_VIEW_KEY, initialMode); + // Seed history with the initial hash via assignment (one entry), before React mounts. + const raw = initialHash.replace(/^#/, ""); + if (normalizeHashPath(testWindow.location.hash) !== raw) { + testWindow.location.hash = raw; + } + // Allow happy-dom to settle the hash before the route hook reads window.location. + await Promise.resolve(); + const { createRoot } = await import("react-dom/client"); + const host = document.createElement("div"); + document.body.append(host); + let root!: Root; + const renders: Array<{ page: string; mode: ViewMode }> = []; + const historyBefore = testWindow.history.length; + await act(async () => { + root = createRoot(host); + root.render( renders.push(snap)} />); + }); + // Allow the sync effect to passively replaceHash when needed. + await act(async () => { + await Promise.resolve(); + }); + return { root, host, historyBefore, renders }; + } + + test("1. stored Workspace + bare #providers normalizes without growing history", async () => { + const { root, host, historyBefore, renders } = await mount("workspace", "#providers"); + expect(normalizeHashPath(window.location.hash)).toBe("providers/workspace"); + expect(host.querySelector('[data-testid="mode"]')?.textContent).toBe("workspace"); + expect(host.querySelector('[data-testid="page"]')?.textContent).toBe("providers"); + expect(testWindow.history.length).toBe(historyBefore); + expect(renders[0]).toEqual({ page: "providers", mode: "workspace" }); + await act(async () => { root.unmount(); }); + }); + + test("2. explicit #providers/workspace deep link forces Workspace without duplicate history", async () => { + const { root, host, historyBefore } = await mount("classic", "#providers/workspace"); + expect(normalizeHashPath(window.location.hash)).toBe("providers/workspace"); + expect(host.querySelector('[data-testid="mode"]')?.textContent).toBe("workspace"); + expect(readViewMode()).toBe("workspace"); + expect(testWindow.history.length).toBe(historyBefore); + await act(async () => { root.unmount(); }); + }); + + test("3. global toggle Classic→Workspace replaces hash (no history push)", async () => { + const { root, host } = await mount("classic", "#providers"); + const before = testWindow.history.length; + await act(async () => { + host.querySelector('[data-testid="toggle"]')!.click(); + }); + expect(normalizeHashPath(window.location.hash)).toBe("providers/workspace"); + expect(host.querySelector('[data-testid="mode"]')?.textContent).toBe("workspace"); + expect(readViewMode()).toBe("workspace"); + expect(testWindow.history.length).toBe(before); + await act(async () => { root.unmount(); }); + }); + + test("4. global toggle Workspace→Classic replaces hash (no history push)", async () => { + const { root, host } = await mount("workspace", "#providers/workspace"); + const before = testWindow.history.length; + await act(async () => { + host.querySelector('[data-testid="toggle"]')!.click(); + }); + expect(normalizeHashPath(window.location.hash)).toBe("providers"); + expect(host.querySelector('[data-testid="mode"]')?.textContent).toBe("classic"); + expect(readViewMode()).toBe("classic"); + expect(testWindow.history.length).toBe(before); + await act(async () => { root.unmount(); }); + }); + + test("5–6. page nav + Workspace toggle: Back returns to Dashboard; Forward restores Providers Workspace", async () => { + const { root, host } = await mount("classic", "#dashboard"); + await act(async () => { + host.querySelector('[data-testid="go-providers"]')!.click(); + }); + expect(normalizeHashPath(window.location.hash)).toBe("providers"); + expect(host.querySelector('[data-testid="page"]')?.textContent).toBe("providers"); + + const afterProviders = testWindow.history.length; + await act(async () => { + host.querySelector('[data-testid="toggle"]')!.click(); + }); + expect(normalizeHashPath(window.location.hash)).toBe("providers/workspace"); + expect(testWindow.history.length).toBe(afterProviders); + + await historyBack(); + expect(normalizeHashPath(window.location.hash)).toBe("dashboard"); + expect(host.querySelector('[data-testid="page"]')?.textContent).toBe("dashboard"); + + await historyForward(); + expect(normalizeHashPath(window.location.hash)).toBe("providers/workspace"); + expect(host.querySelector('[data-testid="page"]')?.textContent).toBe("providers"); + expect(host.querySelector('[data-testid="mode"]')?.textContent).toBe("workspace"); + await act(async () => { root.unmount(); }); + }); + + test("7. stale #providers/#providers/workspace history does not trap Back", async () => { + writeViewMode("workspace"); + // Build a legacy stack that still contains both Classic and Workspace Providers entries. + testWindow.location.hash = "dashboard"; + navigateHash("providers", testWindow as unknown as Window); + navigateHash("providers/workspace", testWindow as unknown as Window); + expect(testWindow.history.length).toBeGreaterThanOrEqual(3); + + const { root, host } = await mount("workspace", "#providers/workspace"); + const lengthBeforeBack = testWindow.history.length; + + await historyBack(); + // Landing on bare #providers must passively replace to workspace without appending. + expect(normalizeHashPath(window.location.hash)).toBe("providers/workspace"); + expect(host.querySelector('[data-testid="mode"]')?.textContent).toBe("workspace"); + expect(testWindow.history.length).toBeLessThanOrEqual(lengthBeforeBack); + + await historyBack(); + expect(normalizeHashPath(window.location.hash)).toBe("dashboard"); + expect(host.querySelector('[data-testid="page"]')?.textContent).toBe("dashboard"); + await act(async () => { root.unmount(); }); + }); + + test("8. non-Providers routes keep their hash when view mode changes", async () => { + const { root, host } = await mount("workspace", "#dashboard"); + for (const id of ["go-dashboard", "go-claude", "go-models", "go-logs"] as const) { + await act(async () => { + host.querySelector(`[data-testid="${id}"]`)!.click(); + }); + const beforeHash = normalizeHashPath(window.location.hash); + await act(async () => { + host.querySelector('[data-testid="toggle"]')!.click(); + }); + expect(normalizeHashPath(window.location.hash)).toBe(beforeHash); + } + await act(async () => { root.unmount(); }); + }); + + test("9. unsaved local state survives a global view-mode toggle", async () => { + const { root, host } = await mount("classic", "#dashboard"); + const input = host.querySelector('input[aria-label="unsaved-draft"]')!; + input.value = "keep-me"; + expect(input.value).toBe("keep-me"); + await act(async () => { + host.querySelector('[data-testid="toggle"]')!.click(); + }); + expect(host.querySelector('[data-testid="mode"]')?.textContent).toBe("workspace"); + // Uncontrolled input retains DOM value across viewMode updates (no remount). + expect(host.querySelector('input[aria-label="unsaved-draft"]')?.value).toBe("keep-me"); + await act(async () => { root.unmount(); }); + }); + + test("Providers first paint matches final route contract for workspace deep link", async () => { + const { root, renders } = await mount("classic", "#providers/workspace"); + const providersRenders = renders.filter(r => r.page === "providers"); + expect(providersRenders.length).toBeGreaterThan(0); + expect(providersRenders[0]?.mode).toBe("workspace"); + expect(providersRenders.every(r => r.mode === "workspace")).toBe(true); + await act(async () => { root.unmount(); }); + }); + + test("preferred providers hash helper stays aligned with view mode", () => { + expect(providersHashForViewMode("workspace")).toBe("providers/workspace"); + expect(providersHashForViewMode("classic")).toBe("providers"); + }); +}); + +describe("runtime viewMode survives storage failure", () => { + function installThrowingStorage() { + const throwing = { + getItem() { throw new Error("blocked"); }, + setItem() { throw new Error("blocked"); }, + removeItem() { throw new Error("blocked"); }, + clear() { throw new Error("blocked"); }, + key() { throw new Error("blocked"); }, + get length() { throw new Error("blocked"); }, + }; + Object.defineProperty(globalThis, "localStorage", { configurable: true, value: throwing }); + Object.defineProperty(testWindow, "localStorage", { configurable: true, value: throwing }); + } + + async function mountThrowing(initialHash: string) { + installThrowingStorage(); + if (normalizeHashPath(testWindow.location.hash) !== initialHash.replace(/^#/, "")) { + testWindow.location.hash = initialHash.replace(/^#/, ""); + } + await Promise.resolve(); + const { createRoot } = await import("react-dom/client"); + const host = document.createElement("div"); + document.body.append(host); + let root!: Root; + await act(async () => { + root = createRoot(host); + root.render(); + }); + await act(async () => { await Promise.resolve(); }); + return { root, host }; + } + + test("getItem failure initializes Classic without crashing", async () => { + const { root, host } = await mountThrowing("#dashboard"); + expect(host.querySelector('[data-testid="mode"]')?.textContent).toBe("classic"); + expect(host.querySelector('[data-testid="page"]')?.textContent).toBe("dashboard"); + await act(async () => { root.unmount(); }); + }); + + test("toggle Workspace while setItem throws keeps in-memory Workspace on Providers", async () => { + const { root, host } = await mountThrowing("#dashboard"); + await act(async () => { + host.querySelector('[data-testid="toggle"]')!.click(); + }); + expect(host.querySelector('[data-testid="mode"]')?.textContent).toBe("workspace"); + // Storage still fails — readViewMode must not win over React state. + expect(readViewMode()).toBe("classic"); + + await act(async () => { + host.querySelector('[data-testid="go-providers"]')!.click(); + }); + expect(normalizeHashPath(window.location.hash)).toBe("providers/workspace"); + expect(host.querySelector('[data-testid="page"]')?.textContent).toBe("providers"); + expect(host.querySelector('[data-testid="mode"]')?.textContent).toBe("workspace"); + await act(async () => { root.unmount(); }); + }); + + test("toggle Classic while storage throws updates Providers hash from React state", async () => { + const { root, host } = await mountThrowing("#dashboard"); + await act(async () => { + host.querySelector('[data-testid="toggle"]')!.click(); + }); + await act(async () => { + host.querySelector('[data-testid="go-providers"]')!.click(); + }); + expect(normalizeHashPath(window.location.hash)).toBe("providers/workspace"); + + await act(async () => { + host.querySelector('[data-testid="toggle"]')!.click(); + }); + expect(host.querySelector('[data-testid="mode"]')?.textContent).toBe("classic"); + expect(normalizeHashPath(window.location.hash)).toBe("providers"); + await act(async () => { root.unmount(); }); + }); + + test("Back/Forward after failed persistence stay coherent with in-memory mode", async () => { + const { root, host } = await mountThrowing("#dashboard"); + await act(async () => { + host.querySelector('[data-testid="toggle"]')!.click(); + }); + await act(async () => { + host.querySelector('[data-testid="go-providers"]')!.click(); + }); + expect(normalizeHashPath(window.location.hash)).toBe("providers/workspace"); + + await historyBack(); + expect(normalizeHashPath(window.location.hash)).toBe("dashboard"); + expect(host.querySelector('[data-testid="mode"]')?.textContent).toBe("workspace"); + + await historyForward(); + expect(normalizeHashPath(window.location.hash)).toBe("providers/workspace"); + expect(host.querySelector('[data-testid="page"]')?.textContent).toBe("providers"); + expect(host.querySelector('[data-testid="mode"]')?.textContent).toBe("workspace"); + await act(async () => { root.unmount(); }); + }); + + test("explicit #providers/workspace deep link works while storage throws", async () => { + const { root, host } = await mountThrowing("#providers/workspace"); + expect(host.querySelector('[data-testid="mode"]')?.textContent).toBe("workspace"); + expect(normalizeHashPath(window.location.hash)).toBe("providers/workspace"); + await act(async () => { root.unmount(); }); + }); + + test("multiple toggles keep the latest React mode when storage throws", async () => { + const { root, host } = await mountThrowing("#dashboard"); + for (let i = 0; i < 3; i++) { + await act(async () => { + host.querySelector('[data-testid="toggle"]')!.click(); + }); + } + expect(host.querySelector('[data-testid="mode"]')?.textContent).toBe("workspace"); + await act(async () => { + host.querySelector('[data-testid="go-providers"]')!.click(); + }); + expect(normalizeHashPath(window.location.hash)).toBe("providers/workspace"); + await act(async () => { root.unmount(); }); + }); + + test("non-Providers hashes stay put when toggling with throwing storage", async () => { + const { root, host } = await mountThrowing("#models"); + await act(async () => { + host.querySelector('[data-testid="go-models"]')!.click(); + }); + expect(normalizeHashPath(window.location.hash)).toBe("models"); + await act(async () => { + host.querySelector('[data-testid="toggle"]')!.click(); + }); + expect(normalizeHashPath(window.location.hash)).toBe("models"); + expect(host.querySelector('[data-testid="mode"]')?.textContent).toBe("workspace"); + await act(async () => { root.unmount(); }); + }); +}); diff --git a/gui/tests/select-keyboard.test.tsx b/gui/tests/select-keyboard.test.tsx new file mode 100644 index 000000000..c4b1991b2 --- /dev/null +++ b/gui/tests/select-keyboard.test.tsx @@ -0,0 +1,312 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act, useState } from "react"; +import type { Root } from "react-dom/client"; +import { Select } from "../src/ui"; + +const globals = ["document", "window", "navigator", "IS_REACT_ACT_ENVIRONMENT"] as const; +let previousGlobals: Record<(typeof globals)[number], unknown>; +let testWindow: Window; + +beforeEach(() => { + previousGlobals = Object.fromEntries(globals.map((key) => [key, Reflect.get(globalThis, key)])) as typeof previousGlobals; + testWindow = new Window({ url: "http://localhost/" }); + Object.defineProperties(globalThis, { + document: { configurable: true, value: testWindow.document }, + window: { configurable: true, value: testWindow }, + navigator: { configurable: true, value: testWindow.navigator }, + }); + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; +}); + +afterEach(() => { + testWindow.close(); + for (const key of globals) { + Object.defineProperty(globalThis, key, { configurable: true, value: previousGlobals[key] }); + } +}); + +const OPTIONS = [ + { value: "a", label: "Alpha" }, + { value: "b", label: "Beta" }, + { value: "c", label: "Charlie" }, +]; + +async function mountSelect(node: React.ReactElement): Promise<{ root: Root; trigger: HTMLButtonElement; host: HTMLDivElement }> { + const { createRoot } = await import("react-dom/client"); + const host = document.createElement("div"); + document.body.append(host); + let root!: Root; + await act(async () => { + root = createRoot(host); + root.render(node); + }); + const trigger = host.querySelector("button.select-trigger")!; + return { root, trigger, host }; +} + +function key(target: HTMLElement, name: string) { + target.dispatchEvent(new testWindow.KeyboardEvent("keydown", { key: name, bubbles: true }) as unknown as KeyboardEvent); +} + +function listbox() { + return document.body.querySelector('[role="listbox"]'); +} + +async function assertComboboxContract(portal: boolean) { + function Harness() { + const [value, setValue] = useState("a"); + return ( +
+
{value}
+ { value = next; }} label="Pick" />, + ); + await act(async () => { key(trigger, "ArrowDown"); }); + expect(listbox()).not.toBeNull(); + expect(trigger.getAttribute("aria-activedescendant")).toBeTruthy(); + await act(async () => { key(trigger, "ArrowDown"); }); + await act(async () => { key(trigger, "Enter"); }); + expect(value).toBe("b"); + expect(listbox()).toBeNull(); + expect(document.activeElement).toBe(trigger); + await act(async () => { root.unmount(); }); +}); + +test("Home/End and Escape restore focus for non-portal Select", async () => { + let value = "b"; + const { root, trigger } = await mountSelect( + {}} label="Off" disabled />, + ); + await act(async () => { key(trigger, "ArrowDown"); }); + expect(listbox()).toBeNull(); + await act(async () => { trigger.click(); }); + expect(listbox()).toBeNull(); + await act(async () => { root.unmount(); }); +}); + +test("empty options do not open", async () => { + const { root, trigger } = await mountSelect( + {}} label="Pick" />, + ); + await act(async () => { key(trigger, "ArrowDown"); }); + expect(listbox()).not.toBeNull(); + await act(async () => { + document.body.dispatchEvent(new testWindow.MouseEvent("mousedown", { bubbles: true }) as unknown as MouseEvent); + }); + expect(listbox()).toBeNull(); + await act(async () => { root.unmount(); }); +}); + +test("clicking an option selects it and returns focus", async () => { + function Harness() { + const [value, setValue] = useState("a"); + return ( +
+
{value}
+ +
+ ); + } + const { root, host } = await mountSelect(); + const trigger = host.querySelector("button.select-trigger")!; + await act(async () => { host.querySelector('[data-testid="set-c"]')!.click(); }); + await act(async () => { key(trigger, "ArrowDown"); }); + const activeId = trigger.getAttribute("aria-activedescendant")!; + expect(document.getElementById(activeId)?.textContent).toContain("Charlie"); + await act(async () => { root.unmount(); }); +}); + +test("shrinking options clamps active descendant", async () => { + function Harness() { + const [opts, setOpts] = useState(OPTIONS); + return ( +
+ + +
+ ); + } + const { root, host } = await mountSelect(); + const trigger = () => host.querySelector("button.select-trigger")!; + const opts = () => [...listbox()!.querySelectorAll('[role="option"]')]; + const activeEl = () => listbox()!.querySelector(".select-option-active"); + + await act(async () => { key(trigger(), "ArrowDown"); }); + expect(opts()[0]!.classList.contains("active")).toBe(true); + expect(opts()[0]!.classList.contains("select-option-active")).toBe(true); + expect(trigger().getAttribute("aria-activedescendant")).toBe(opts()[0]!.id); + + await act(async () => { key(trigger(), "ArrowDown"); }); + expect(opts()[0]!.classList.contains("select-option-active")).toBe(false); + expect(opts()[1]!.classList.contains("select-option-active")).toBe(true); + expect(opts()[0]!.classList.contains("active")).toBe(true); + expect(trigger().getAttribute("aria-activedescendant")).toBe(opts()[1]!.id); + expect(activeEl()?.textContent).toContain("Beta"); + + await act(async () => { key(trigger(), "Home"); }); + expect(activeEl()?.textContent).toContain("Alpha"); + expect(opts().filter(o => o.classList.contains("select-option-active")).length).toBe(1); + + await act(async () => { key(trigger(), "End"); }); + expect(activeEl()?.textContent).toContain("Charlie"); + expect(trigger().getAttribute("aria-activedescendant")).toBe(opts()[2]!.id); + + await act(async () => { + // React listens for mouseover to drive onMouseEnter in this environment. + opts()[1]!.dispatchEvent(new testWindow.MouseEvent("mouseover", { bubbles: true }) as unknown as MouseEvent); + }); + expect(activeEl()?.textContent).toContain("Beta"); + expect(trigger().getAttribute("aria-activedescendant")).toBe(opts()[1]!.id); + + await act(async () => { activeEl()!.click(); }); + expect(host.querySelector('[data-testid="value"]')!.textContent).toBe("b"); + expect(listbox()).toBeNull(); + expect(document.activeElement).toBe(trigger()); + + await act(async () => { root.unmount(); }); +} + +test("portaled Select moves select-option-active with keyboard and hover", async () => { + await assertKeyboardActiveClassMoves(true); +}); + +test("non-portal Select moves select-option-active with keyboard and hover", async () => { + await assertKeyboardActiveClassMoves(false); +}); diff --git a/gui/tests/select-portal.test.tsx b/gui/tests/select-portal.test.tsx index 2bf9db984..a86b72baa 100644 --- a/gui/tests/select-portal.test.tsx +++ b/gui/tests/select-portal.test.tsx @@ -4,10 +4,9 @@ import { act } from "react"; import type { Root } from "react-dom/client"; import { Select } from "../src/ui"; -// Mounted regressions for #340 / PR #393: the portal fix must (1) render an opt-in portaled -// dropdown under document.body (outside the clipping card) with fixed positioning, and (2) leave a -// NON-portal Select (the language menu contract) as a descendant of .custom-select keeping the -// .select-dropdown-beside class so its glass fallback + mobile upward-placement CSS still apply. +// Mounted regressions for #340 / PR #393: Select portals by default so menus flip inside the +// viewport. The language menu is the explicit opt-out (portal={false} + placement="right") so its +// glass fallback + mobile upward-placement CSS still apply as a .custom-select descendant. const globals = ["document", "window", "navigator", "IS_REACT_ACT_ENVIRONMENT"] as const; let previousGlobals: Record<(typeof globals)[number], unknown>; @@ -56,7 +55,7 @@ async function mountAndOpen(node: React.ReactElement): Promise<{ container: HTML test("a portal Select renders its dropdown under document.body, outside the clipping card", async () => { const { container, root } = await mountAndOpen( - {}} label="Backend" />, ); const listbox = document.body.querySelector('[role="listbox"]'); expect(listbox).not.toBeNull(); @@ -69,7 +68,7 @@ test("a portal Select renders its dropdown under document.body, outside the clip test("a non-portal Select (language-menu contract) stays inside .custom-select and keeps .select-dropdown-beside", async () => { const { container, root } = await mountAndOpen( - {}} label="Language" placement="right" portal={false} />, ); // Not portaled: nothing lands directly under body outside the mount container. const bodyListboxes = Array.from(document.body.querySelectorAll('[role="listbox"]')); diff --git a/gui/tests/select-position.test.ts b/gui/tests/select-position.test.ts index fd3d7e9b7..b3e0c1dc0 100644 --- a/gui/tests/select-position.test.ts +++ b/gui/tests/select-position.test.ts @@ -45,7 +45,7 @@ test("flips upward near the bottom of the viewport", () => { }, { menuHeight: 180 }); expect(style.bottom).toBe(108); expect(style.top).toBeUndefined(); - expect(style.maxHeight).toBe(688); + expect(style.maxHeight).toBe(280); }); test("right placement opens beside the trigger", () => { diff --git a/gui/tests/view-mode-remount.test.tsx b/gui/tests/view-mode-remount.test.tsx new file mode 100644 index 000000000..f6fec8120 --- /dev/null +++ b/gui/tests/view-mode-remount.test.tsx @@ -0,0 +1,79 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act, useEffect, useState } from "react"; +import type { Root } from "react-dom/client"; +import type { ViewMode } from "../src/view-mode"; + +const globals = ["document", "window", "navigator", "IS_REACT_ACT_ENVIRONMENT"] as const; +let previousGlobals: Record<(typeof globals)[number], unknown>; +let testWindow: Window; + +beforeEach(() => { + previousGlobals = Object.fromEntries(globals.map((key) => [key, Reflect.get(globalThis, key)])) as typeof previousGlobals; + testWindow = new Window({ url: "http://localhost/" }); + Object.defineProperties(globalThis, { + document: { configurable: true, value: testWindow.document }, + window: { configurable: true, value: testWindow }, + navigator: { configurable: true, value: testWindow.navigator }, + }); + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; +}); + +afterEach(() => { + testWindow.close(); + for (const key of globals) { + Object.defineProperty(globalThis, key, { configurable: true, value: previousGlobals[key] }); + } +}); + +test("toggling viewMode does not remount an unsupported stateful page", async () => { + const { createRoot } = await import("react-dom/client"); + const container = document.createElement("div"); + document.body.append(container); + let mounts = 0; + + function StatefulUnsupportedPage() { + useEffect(() => { + mounts += 1; + }, []); + return ; + } + + function SupportedPage({ viewMode }: { viewMode: ViewMode }) { + return
{viewMode}
; + } + + function Shell() { + const [viewMode, setViewMode] = useState("classic"); + return ( +
+ + + +
+ ); + } + + let root!: Root; + await act(async () => { + root = createRoot(container); + root.render(); + }); + + const input = container.querySelector('input[aria-label="draft"]')!; + input.value = "keep-me"; + expect(input.value).toBe("keep-me"); + expect(mounts).toBe(1); + + await act(async () => { + container.querySelector("button")!.click(); + }); + + expect(container.querySelector('[data-testid="supported"]')?.textContent).toBe("workspace"); + expect(container.querySelector('input[aria-label="draft"]')?.value).toBe("keep-me"); + expect(mounts).toBe(1); + + await act(async () => { root.unmount(); }); +}); diff --git a/gui/tests/view-mode.test.ts b/gui/tests/view-mode.test.ts new file mode 100644 index 000000000..47d20ce75 --- /dev/null +++ b/gui/tests/view-mode.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, test } from "bun:test"; +import { + GLOBAL_VIEW_KEY, + LEGACY_GLOBAL_VIEW_KEY, + ensureMigratedViewMode, + migrateLegacyViewMode, + providersHashForGlobalViewChange, + providersHashForViewMode, + readViewMode, + toggleViewMode, + viewModeFromProvidersHash, + writeViewMode, + type StorageLike, +} from "../src/view-mode"; + +function memoryStorage(initial: Record = {}): StorageLike & { data: Record } { + const data = { ...initial }; + return { + data, + getItem(key) { + return Object.prototype.hasOwnProperty.call(data, key) ? data[key]! : null; + }, + setItem(key, value) { + data[key] = value; + }, + }; +} + +class ThrowingStorage implements StorageLike { + getItem(): string | null { + throw new Error("blocked"); + } + setItem(): void { + throw new Error("blocked"); + } +} + +describe("view-mode preference", () => { + test("defaults to classic when nothing is stored", () => { + expect(readViewMode(memoryStorage())).toBe("classic"); + }); + + test("reads canonical classic and workspace", () => { + expect(readViewMode(memoryStorage({ [GLOBAL_VIEW_KEY]: "classic" }))).toBe("classic"); + expect(readViewMode(memoryStorage({ [GLOBAL_VIEW_KEY]: "workspace" }))).toBe("workspace"); + }); + + test("accepts the interim legacy global key", () => { + expect(readViewMode(memoryStorage({ [LEGACY_GLOBAL_VIEW_KEY]: "workspace" }))).toBe("workspace"); + }); + + test("migrates providers-only workspace preference", () => { + const storage = memoryStorage({ "ocx-providers-view": "workspace" }); + expect(migrateLegacyViewMode(storage)).toBe("workspace"); + expect(ensureMigratedViewMode(storage)).toBe("workspace"); + expect(storage.data[GLOBAL_VIEW_KEY]).toBe("workspace"); + expect(storage.data["ocx-dashboard-view"]).toBe("workspace"); + }); + + test("mixed old keys prefer dashboard then providers", () => { + expect(migrateLegacyViewMode(memoryStorage({ + "ocx-dashboard-view": "classic", + "ocx-providers-view": "workspace", + }))).toBe("classic"); + expect(migrateLegacyViewMode(memoryStorage({ + "ocx-providers-view": "workspace", + }))).toBe("workspace"); + }); + + test("storage access throwing falls back to classic", () => { + expect(readViewMode(new ThrowingStorage())).toBe("classic"); + writeViewMode("workspace", new ThrowingStorage()); // must not throw + }); + + test("toggle updates canonical key", () => { + const storage = memoryStorage(); + const next = toggleViewMode(readViewMode(storage)); + writeViewMode(next, storage); + expect(next).toBe("workspace"); + expect(storage.data[GLOBAL_VIEW_KEY]).toBe("workspace"); + expect(readViewMode(storage)).toBe("workspace"); + }); +}); + +describe("providers hash sync helpers", () => { + test("maps both toggle directions while Providers is active", () => { + expect(providersHashForGlobalViewChange("#providers", "workspace", true)).toBe("providers/workspace"); + expect(providersHashForGlobalViewChange("#providers/workspace", "classic", true)).toBe("providers"); + expect(providersHashForGlobalViewChange("#providers/workspace", "workspace", true)).toBeNull(); + expect(providersHashForGlobalViewChange("#providers", "workspace", false)).toBeNull(); + }); + + test("round-trips hash helpers", () => { + expect(providersHashForViewMode("workspace")).toBe("providers/workspace"); + expect(viewModeFromProvidersHash("providers/workspace")).toBe("workspace"); + expect(viewModeFromProvidersHash("providers")).toBe("classic"); + expect(viewModeFromProvidersHash("dashboard")).toBeNull(); + }); +}); diff --git a/tests/provider-workspace-rail.test.ts b/tests/provider-workspace-rail.test.ts index 7c33ac2cb..bf193f0d4 100644 --- a/tests/provider-workspace-rail.test.ts +++ b/tests/provider-workspace-rail.test.ts @@ -73,10 +73,21 @@ describe("provider rail source contract", () => { }); test("preserves only the exact workspace subroute on page synchronization", async () => { + const { hashBelongsToPage } = await import("../gui/src/app-routing"); + // Exact subroute only — unknown Providers suffixes must not count as belonging. + expect(hashBelongsToPage("providers/workspace", "providers")).toBe(true); + expect(hashBelongsToPage("providers", "providers")).toBe(true); + expect(hashBelongsToPage("providers/other", "providers")).toBe(false); + expect(hashBelongsToPage("providers/workspace/extra", "providers")).toBe(false); + + const routing = await Bun.file("gui/src/app-routing.ts").text(); + const routeState = await Bun.file("gui/src/use-app-route-state.ts").text(); const app = await Bun.file("gui/src/App.tsx").text(); - expect(app).toContain('rawHash === "providers/workspace"'); - expect(app).toContain("hashBelongsToPage(rawHash, nextPage)"); - expect(app).toContain("hashBelongsToPage(rawHash, page)"); - expect(app).not.toContain("window.location.hash !== nextHash"); + expect(routing).toContain('rawHash === "providers/workspace"'); + expect(routing).toContain("hashBelongsToPage(rawHash, nextPage)"); + expect(routeState).toContain('rawHash === "providers/workspace"'); + expect(routeState).toContain("hashBelongsToPage(rawHash, page)"); + expect(app).toContain("useAppRouteState"); + expect(`${routing}\n${routeState}\n${app}`).not.toContain("window.location.hash !== nextHash"); }); }); diff --git a/tests/startup-health-ui.test.ts b/tests/startup-health-ui.test.ts index 7d6d93987..4356b7dcb 100644 --- a/tests/startup-health-ui.test.ts +++ b/tests/startup-health-ui.test.ts @@ -1,5 +1,12 @@ import { describe, expect, test } from "bun:test"; -import { settingsPollMayCommit, startupRiskDetailKey } from "../gui/src/startup-health-ui"; +import { + PROJECT_CONFIG_DIAGNOSTICS_POLL_MS, + beginPollEpochs, + mapStartupHealthProbe, + seedStartupHealthFromSettings, + settingsPollMayCommit, + startupRiskDetailKey, +} from "../gui/src/startup-health-ui"; describe("startup health UI decisions", () => { test("selects the shared risk-detail message", () => { @@ -18,4 +25,77 @@ describe("startup health UI decisions", () => { expect(settingsPollMayCommit(started, { request: 4, mutation: 3, mutationInFlight: false })).toBe(false); expect(settingsPollMayCommit(started, { request: 4, mutation: 2, mutationInFlight: true })).toBe(false); }); + + test("maps stale diagnostics to error and rejects invalid payloads", () => { + expect(mapStartupHealthProbe({ status: "protected", diagnosticStale: true })).toBe("error"); + expect(mapStartupHealthProbe({ status: "native", diagnosticStale: false })).toBe("native"); + expect(mapStartupHealthProbe({ status: "nope" })).toBeNull(); + }); + + test("settings may only seed while startup health is still unknown", () => { + expect(seedStartupHealthFromSettings(null, { status: "protected", diagnosticStale: false })).toBe("protected"); + expect(seedStartupHealthFromSettings("error", { status: "protected", diagnosticStale: false })).toBe("error"); + expect(seedStartupHealthFromSettings(null, { status: "at-risk", diagnosticStale: true })).toBe("error"); + }); +}); + +describe("dashboard poll epochs", () => { + test("captures and bumps request epochs before fetches", () => { + const refs = { + settingsRequest: { current: 0 }, + settingsMutation: { current: 2 }, + shadowRequest: { current: 0 }, + shadowMutation: { current: 4 }, + }; + const first = beginPollEpochs(refs); + const second = beginPollEpochs(refs); + expect(first.settings).toEqual({ request: 1, mutation: 2 }); + expect(second.settings).toEqual({ request: 2, mutation: 2 }); + expect(first.shadow).toEqual({ request: 1, mutation: 4 }); + expect(second.shadow.request).toBe(2); + }); + + test("stale poll after successful mutation cannot commit", () => { + const started = beginPollEpochs({ + settingsRequest: { current: 3 }, + settingsMutation: { current: 1 }, + shadowRequest: { current: 3 }, + shadowMutation: { current: 1 }, + }); + expect(settingsPollMayCommit(started.settings, { + request: started.settings.request, + mutation: 2, + mutationInFlight: false, + })).toBe(false); + expect(settingsPollMayCommit(started.shadow, { + request: started.shadow.request, + mutation: 2, + mutationInFlight: false, + })).toBe(false); + }); + + test("overlapping polls resolving out of order keep only the latest request identity", () => { + const refs = { + settingsRequest: { current: 0 }, + settingsMutation: { current: 0 }, + shadowRequest: { current: 0 }, + shadowMutation: { current: 0 }, + }; + const older = beginPollEpochs(refs); + const newer = beginPollEpochs(refs); + expect(settingsPollMayCommit(older.settings, { + request: refs.settingsRequest.current, + mutation: 0, + mutationInFlight: false, + })).toBe(false); + expect(settingsPollMayCommit(newer.settings, { + request: refs.settingsRequest.current, + mutation: 0, + mutationInFlight: false, + })).toBe(true); + }); + + test("project-config diagnostics uses a single 30s owner cadence", () => { + expect(PROJECT_CONFIG_DIAGNOSTICS_POLL_MS).toBe(30_000); + }); });