Skip to content
130 changes: 130 additions & 0 deletions __tests__/api/acp-service/acp-service.api.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
Comment thread
simonrosenberg marked this conversation as resolved.
import type { BashOutput } from "@openhands/typescript-client";
import AcpService from "#/api/acp-service/acp-service.api";

// Capture the command the service runs and control the BashOutput it sees.
const executeCommand = vi.hoisted(() => vi.fn());
vi.mock("@openhands/typescript-client/clients", () => ({
BashClient: class {
executeCommand = executeCommand;
},
}));
vi.mock("#/api/agent-server-client-options", () => ({
getAgentServerClientOptions: () => ({
host: "http://localhost",
workingDir: "/",
}),
}));

function bashOutput(partial: Partial<BashOutput>): BashOutput {
return {
id: "1",
timestamp: "2026-01-01T00:00:00Z",
command_id: "c1",
order: 0,
exit_code: 0,
stdout: null,
stderr: null,
kind: "BashOutput",
...partial,
} as BashOutput;
}

beforeEach(() => vi.clearAllMocks());

describe("AcpService.getAuthStatus", () => {
describe("claude-code (claude auth status --json)", () => {
it("runs the right command and maps loggedIn:true → authenticated", async () => {
executeCommand.mockResolvedValue(
bashOutput({
stdout: JSON.stringify({ loggedIn: true, authMethod: "claude.ai" }),
}),
);
await expect(AcpService.getAuthStatus("claude-code")).resolves.toBe(
"authenticated",
);
expect(executeCommand).toHaveBeenCalledWith(
"claude auth status --json",
undefined,
expect.any(Number),
);
});

it("maps loggedIn:false (even with a non-zero exit) → unauthenticated", async () => {
executeCommand.mockResolvedValue(
bashOutput({
stdout: JSON.stringify({ loggedIn: false }),
exit_code: 1,
}),
);
await expect(AcpService.getAuthStatus("claude-code")).resolves.toBe(
"unauthenticated",
);
});

it("→ unknown when the CLI is missing (exit 127, no JSON on stdout)", async () => {
// The "no available ACP process / CLI not installed" path.
executeCommand.mockResolvedValue(
bashOutput({
exit_code: 127,
stderr: "env: claude: No such file or directory",
}),
);
await expect(AcpService.getAuthStatus("claude-code")).resolves.toBe(
"unknown",
);
});
});

describe("codex (codex login status)", () => {
it("→ authenticated even though the CLI writes to stderr", async () => {
executeCommand.mockResolvedValue(
bashOutput({ stderr: "Logged in using ChatGPT\n" }),
);
await expect(AcpService.getAuthStatus("codex")).resolves.toBe(
"authenticated",
);
});

it("→ unauthenticated on 'Not logged in'", async () => {
executeCommand.mockResolvedValue(
bashOutput({ stderr: "Not logged in\n" }),
);
await expect(AcpService.getAuthStatus("codex")).resolves.toBe(
"unauthenticated",
);
});

it("→ unknown when the CLI is missing", async () => {
executeCommand.mockResolvedValue(
bashOutput({ exit_code: 127, stderr: "codex: command not found" }),
);
await expect(AcpService.getAuthStatus("codex")).resolves.toBe("unknown");
});
});

describe("gemini-cli (credentials file check)", () => {
it("→ authenticated when the creds file is present", async () => {
executeCommand.mockResolvedValue(bashOutput({ stdout: "present\n" }));
await expect(AcpService.getAuthStatus("gemini-cli")).resolves.toBe(
"authenticated",
);
// Probes the OAuth creds file, not a (nonexistent) gemini status command.
expect(executeCommand.mock.calls[0][0]).toContain("oauth_creds.json");
});

it("→ unauthenticated when the creds file is absent", async () => {
executeCommand.mockResolvedValue(bashOutput({ stdout: "absent\n" }));
await expect(AcpService.getAuthStatus("gemini-cli")).resolves.toBe(
"unauthenticated",
);
});
});

it("→ unknown for an unprobeable provider, without running any command", async () => {
await expect(AcpService.getAuthStatus("openhands")).resolves.toBe(
"unknown",
);
expect(executeCommand).not.toHaveBeenCalled();
});
});
62 changes: 27 additions & 35 deletions __tests__/components/onboarding/onboarding-modal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,17 @@ vi.mock("#/hooks/mutation/use-create-conversation", () => ({
}),
}));

// The ACP credentials slide runs a login-detection probe (calls
// GET /api/acp/auth-status). Stub it here so the modal routing tests don't hit
// the network; the probe itself is covered in use-acp-auth-status.test.tsx.
vi.mock("#/hooks/query/use-acp-auth-status", () => ({
useAcpAuthStatus: () => ({
status: "unknown",
isChecking: false,
isSupported: false,
}),
}));

function renderModal(onClose = vi.fn()) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
Expand Down Expand Up @@ -283,26 +294,14 @@ describe("OnboardingModal", () => {
expect(settings.contains(next)).toBe(false);
});

it("skips the step-2 slide for an ACP agent with no credentials to collect", async () => {
it("shows slide 2 with Gemini's credential fields", async () => {
renderModal();
const user = userEvent.setup();

// Pick Gemini CLI: it authenticates via an interactive OAuth login
// (no env-var API key), so it has no credentials step and slide 2 is
// skipped — unlike Claude Code / Codex, which now render one there.
// Pick Gemini CLI: its key/base-URL come from the SDK registry like the
// other providers, so the slide shows the GEMINI_API_KEY field.
await user.click(screen.getByTestId("onboarding-agent-option-gemini-cli"));
await user.click(screen.getByTestId("onboarding-agent-next"));
await waitFor(
() =>
expect(screen.getByTestId("onboarding-modal")).toHaveAttribute(
"data-current-step",
"1",
),
{ timeout: 3000 },
);

// Advancing again should jump straight to Say Hello (index 3) and
// bypass slide 2 — Gemini owns its own auth via the OAuth login.
await waitFor(
() =>
expect(
Expand All @@ -312,43 +311,36 @@ describe("OnboardingModal", () => {
);
await user.click(screen.getByTestId("onboarding-backend-next"));

// Lands on slide 2 (the ACP step) — not jumped past to Say Hello.
await waitFor(
() =>
expect(screen.getByTestId("onboarding-modal")).toHaveAttribute(
"data-current-step",
"3",
"2",
),
{ timeout: 3000 },
);
// All four slides remain mounted (the rail just translates them);
// the assertion that the LLM step was skipped is that slide 3 (Say
// Hello) is the active one immediately after the backend step,
// *not* slide 2 (LLM).
expect(screen.getByTestId("onboarding-slide-2")).toHaveAttribute(
"data-active",
"false",
);
expect(screen.getByTestId("onboarding-slide-3")).toHaveAttribute(
"data-active",
"true",
);
expect(
screen.getByTestId("onboarding-step-setup-acp-secrets"),
).toBeInTheDocument();
// Gemini exposes credential fields (GEMINI_API_KEY), derived from the SDK
// registry like Claude Code / Codex.
expect(
screen.getByTestId("onboarding-acp-secret-GEMINI_API_KEY"),
).toBeInTheDocument();

// Progress bar reflects the *visited* step count, not the slide
// index — 3 segments total (not 4), and segment 2 is current (not
// segment 3, which would imply LLM was completed). Without this
// mapping, picking an ACP agent makes the bar show segment 2 as
// "completed" despite the user never visiting it.
// The flow keeps all four progress segments (nothing is skipped).
expect(
screen.queryByTestId("onboarding-progress-step-3"),
).not.toBeInTheDocument();
screen.getByTestId("onboarding-progress-step-3"),
).toBeInTheDocument();
expect(screen.getByTestId("onboarding-progress-step-2")).toHaveAttribute(
"data-state",
"current",
);
expect(screen.getByTestId("onboarding-progress-step-1")).toHaveAttribute(
"data-state",
"completed",
);
});

it("shows the ACP credentials step on slide 2 for Claude Code and saves entered keys as secrets", async () => {
Expand Down
101 changes: 100 additions & 1 deletion __tests__/components/onboarding/setup-acp-secrets-step.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,18 @@ import { SetupAcpSecretsStep } from "#/components/features/onboarding/steps/setu
import { type OnboardingAgentId } from "#/components/features/onboarding/steps/choose-agent-step";
import { SecretsService } from "#/api/secrets-service";

function renderStep(providerKey: OnboardingAgentId = "claude-code") {
// The login-detection probe is exercised in its own hook test; here we stub it
// so rendering the step doesn't spin a conversation, and so we can drive the
// banner states directly.
const acpAuthStatusMock = vi.hoisted(() => vi.fn());
vi.mock("#/hooks/query/use-acp-auth-status", () => ({
useAcpAuthStatus: (...args: unknown[]) => acpAuthStatusMock(...args),
}));

function renderStep(
providerKey: OnboardingAgentId = "claude-code",
isActive = true,
) {
const onBack = vi.fn();
const onNext = vi.fn();
const user = userEvent.setup();
Expand All @@ -23,6 +34,7 @@ function renderStep(providerKey: OnboardingAgentId = "claude-code") {
<ActiveBackendProvider>
<SetupAcpSecretsStep
providerKey={providerKey}
isActive={isActive}
onBack={onBack}
onNext={onNext}
/>
Expand Down Expand Up @@ -53,6 +65,11 @@ async function renderWithSavedApiKey() {
beforeEach(() => {
vi.restoreAllMocks();
__resetActiveStoreForTests();
acpAuthStatusMock.mockReturnValue({
status: "unknown",
isChecking: false,
isSupported: false,
});
vi.spyOn(SecretsService, "getSecrets").mockResolvedValue([]);
vi.spyOn(SecretsService, "createSecret").mockResolvedValue();
});
Expand Down Expand Up @@ -166,4 +183,86 @@ describe("SetupAcpSecretsStep", () => {
);
expect(onNext).not.toHaveBeenCalled();
});

it("runs the login probe scoped to the active step and provider", () => {
renderStep("claude-code", true);
expect(acpAuthStatusMock).toHaveBeenCalledWith("claude-code", {
enabled: true,
});
});

it("disables the login probe when the step is not active", () => {
renderStep("claude-code", false);
expect(acpAuthStatusMock).toHaveBeenCalledWith("claude-code", {
enabled: false,
});
});

it("shows the 'checking' banner while the login probe is in flight", () => {
acpAuthStatusMock.mockReturnValue({
status: "unknown",
isChecking: true,
isSupported: true,
});
renderStep("claude-code");

expect(
screen.getByTestId("onboarding-acp-auth-checking"),
).toBeInTheDocument();
expect(
screen.queryByTestId("onboarding-acp-auth-detected"),
).not.toBeInTheDocument();
});

it("shows the 'already signed in' banner when authenticated, keeping the key fields", () => {
acpAuthStatusMock.mockReturnValue({
status: "authenticated",
isChecking: false,
isSupported: true,
});
renderStep("claude-code");

expect(
screen.getByTestId("onboarding-acp-auth-detected"),
).toBeInTheDocument();
// The fields stay visible (now optional) even when already logged in.
expect(
screen.getByTestId("onboarding-acp-secret-ANTHROPIC_API_KEY"),
).toBeInTheDocument();
});

it("renders Gemini's credential fields and the 'signed in' banner together", () => {
// Gemini's key/base-URL come from the SDK registry like the others, so the
// step shows the GEMINI_API_KEY field AND the detection banner (its Google
// login takes precedence, but a key can still be entered).
acpAuthStatusMock.mockReturnValue({
status: "authenticated",
isChecking: false,
isSupported: true,
});
renderStep("gemini-cli");

expect(
screen.getByTestId("onboarding-acp-auth-detected"),
).toBeInTheDocument();
expect(
screen.getByTestId("onboarding-acp-secret-GEMINI_API_KEY"),
).toBeInTheDocument();
});

it("shows no banner when the provider is not authenticated", () => {
acpAuthStatusMock.mockReturnValue({
status: "unauthenticated",
isChecking: false,
isSupported: true,
});
renderStep("claude-code");

expect(
screen.queryByTestId("onboarding-acp-auth-detected"),
).not.toBeInTheDocument();
expect(
screen.queryByTestId("onboarding-acp-auth-checking"),
).not.toBeInTheDocument();
});
});
Loading
Loading