From be2a38818ec383a8d1857c8e9289be0a86de9e06 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Mon, 22 Jun 2026 17:10:59 -0300 Subject: [PATCH] fix(dashboard): surface manual config CTA when Open Claw CLI auto-detect fails (#4562) Integrated into release/v3.8.34 (rebuilt onto tip) --- .../cli-code/components/OpenClawToolCard.tsx | 14 ++ ...awToolCard-manual-config-fallback.test.tsx | 160 ++++++++++++++++++ 2 files changed, 174 insertions(+) create mode 100644 tests/unit/ui/OpenClawToolCard-manual-config-fallback.test.tsx diff --git a/src/app/(dashboard)/dashboard/cli-code/components/OpenClawToolCard.tsx b/src/app/(dashboard)/dashboard/cli-code/components/OpenClawToolCard.tsx index ff023a2f31..c817d72c4c 100644 --- a/src/app/(dashboard)/dashboard/cli-code/components/OpenClawToolCard.tsx +++ b/src/app/(dashboard)/dashboard/cli-code/components/OpenClawToolCard.tsx @@ -339,6 +339,20 @@ export default function OpenClawToolCard({ : t("installCliPrompt", { tool: "Open Claw" })}

+ {/* + Always surface Manual Config even when the CLI is not + detected locally — typical of remote OmniRoute + deployments where the CLI lives on the user's machine, + not on the server. Upstream report: #579. + */} + )} diff --git a/tests/unit/ui/OpenClawToolCard-manual-config-fallback.test.tsx b/tests/unit/ui/OpenClawToolCard-manual-config-fallback.test.tsx new file mode 100644 index 0000000000..4c5ea89ef3 --- /dev/null +++ b/tests/unit/ui/OpenClawToolCard-manual-config-fallback.test.tsx @@ -0,0 +1,160 @@ +// @vitest-environment jsdom +// +// Regression test: when the Open Claw CLI is not detected locally (typical of +// remote OmniRoute deployments where the CLI lives on the user's laptop, not +// on the server), the card must still surface a "Manual Config" button so the +// user can copy the settings.json snippet and paste it into the CLI on their +// local machine. Before this fix the Manual Config button only rendered when +// `cliReady === true`, which made the card useless for remote deployments +// (upstream report: decolua/9router#579, port of decolua/9router#615). +import React from "react"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// ── Mocks ───────────────────────────────────────────────────────────────────── + +vi.mock("next-intl", () => ({ + useTranslations: () => (key: string, values?: Record) => { + const messages: Record = { + cliNotInstalled: "{tool} CLI not detected locally", + cliNotRunnable: "{tool} CLI installed but not runnable", + installCliPrompt: + "Manual configuration is still available if OmniRoute is deployed on a remote server.", + cliFoundFailedHealthcheck: "{tool} CLI was found but failed runtime healthcheck{reason}.", + manualConfig: "Manual Config", + checkingCli: "Checking {tool}...", + openClawManualConfiguration: "Open Claw Manual Configuration", + "toolDescriptions.openclaw": "Open Claw CLI", + }; + const raw = messages[key] ?? key; + if (!values) return raw; + return Object.entries(values).reduce( + (acc, [k, v]) => acc.replaceAll(`{${k}}`, String(v ?? "")), + raw + ); + }, + useLocale: () => "en", +})); + +vi.mock("next/image", () => ({ + default: () => , +})); + +vi.mock("@/app/(dashboard)/dashboard/cli-code/components/CliStatusBadge", () => ({ + default: () => , +})); + +// Surface ManualConfigModal as a marker so we can assert it gets rendered with +// isOpen=true after clicking the new Manual Config button. +vi.mock("@/shared/components", async () => { + const React = await import("react"); + return { + Card: ({ children }: { children: React.ReactNode }) =>
{children}
, + Button: ({ + children, + onClick, + }: { + children: React.ReactNode; + onClick?: () => void; + }) => ( + + ), + ModelSelectModal: () => null, + ManualConfigModal: ({ isOpen, title }: { isOpen: boolean; title?: string }) => + isOpen ?
{title}
: null, + }; +}); + +// ── Fetch stub ──────────────────────────────────────────────────────────────── + +beforeEach(() => { + ( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true; + // Return: CLI not installed (the broken-on-remote case from upstream #579). + globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => { + const url = typeof input === "string" ? input : input.toString(); + if (url.includes("/api/cli-tools/openclaw-settings")) { + return new Response(JSON.stringify({ installed: false }), { status: 200 }); + } + if (url.includes("/api/models/alias")) { + return new Response(JSON.stringify({ aliases: {} }), { status: 200 }); + } + if (url.includes("/api/cli-tools/backups")) { + return new Response(JSON.stringify({ backups: [] }), { status: 200 }); + } + return new Response("{}", { status: 200 }); + }) as unknown as typeof fetch; +}); + +const containers: HTMLElement[] = []; + +afterEach(() => { + while (containers.length > 0) { + containers.pop()?.remove(); + } + document.body.innerHTML = ""; + vi.restoreAllMocks(); +}); + +// ── Import under test (after mocks) ─────────────────────────────────────────── + +const { default: OpenClawToolCard } = await import( + "@/app/(dashboard)/dashboard/cli-code/components/OpenClawToolCard" +); + +async function renderExpanded() { + const container = document.createElement("div"); + document.body.appendChild(container); + containers.push(container); + const root = createRoot(container); + + await act(async () => { + root.render( + {}} + activeProviders={[]} + baseUrl="http://localhost:20128" + hasActiveProviders={false} + apiKeys={[]} + cloudEnabled={false} + batchStatus={null} + lastConfiguredAt={null} + /> + ); + }); + // Allow microtasks for the fetch() promise + state update to flush. + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + return container; +} + +describe("OpenClawToolCard — manual-config CTA when CLI is not detected", () => { + it("renders the Manual Config button when CLI is not installed", async () => { + const container = await renderExpanded(); + const buttons = Array.from(container.querySelectorAll("button")); + const labels = buttons.map((b) => b.textContent ?? ""); + expect(labels.some((l) => l.includes("Manual Config"))).toBe(true); + }); + + it("opens the ManualConfigModal when the Manual Config button is clicked", async () => { + const container = await renderExpanded(); + const manualBtn = Array.from(container.querySelectorAll("button")).find((b) => + (b.textContent ?? "").includes("Manual Config") + ); + expect(manualBtn).toBeTruthy(); + + await act(async () => { + manualBtn!.click(); + }); + + expect(container.querySelector("[data-testid='manual-config-modal']")).not.toBeNull(); + }); +});