Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,20 @@ export default function OpenClawToolCard({
: t("installCliPrompt", { tool: "Open Claw" })}
</p>
</div>
{/*
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.
*/}
<Button
variant="ghost"
size="sm"
onClick={() => setShowManualConfigModal(true)}
>
<span className="material-symbols-outlined text-[14px] mr-1">content_copy</span>
{t("manualConfig")}
</Button>
</div>
)}

Expand Down
160 changes: 160 additions & 0 deletions tests/unit/ui/OpenClawToolCard-manual-config-fallback.test.tsx
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) => {
const messages: Record<string, string> = {
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: () => <span data-testid="next-image" />,
}));

vi.mock("@/app/(dashboard)/dashboard/cli-code/components/CliStatusBadge", () => ({
default: () => <span data-testid="status-badge" />,
}));

// 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 }) => <div>{children}</div>,
Button: ({
children,
onClick,
}: {
children: React.ReactNode;
onClick?: () => void;
}) => (
<button type="button" onClick={onClick}>
{children}
</button>
),
ModelSelectModal: () => null,
ManualConfigModal: ({ isOpen, title }: { isOpen: boolean; title?: string }) =>
isOpen ? <div data-testid="manual-config-modal">{title}</div> : 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(
<OpenClawToolCard
tool={{ name: "Open Claw", defaultModels: [] }}
isExpanded={true}
onToggle={() => {}}
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();
});
});
Loading