Merge remote-tracking branch 'origin/main' into feature/corporate-ca
Build App / compute-version (pull_request) Successful in 4s
Build App / build-macos (pull_request) Successful in 2m28s
Build App / build-windows (pull_request) Successful in 5m20s
Build Container / build-container (pull_request) Successful in 9m58s
Build App / build-linux (pull_request) Successful in 5m12s
Build App / create-tag (pull_request) Skipped
Build App / sync-to-github (pull_request) Skipped

# Conflicts:
#	app/src/lib/tauri-commands.ts
This commit is contained in:
2026-08-10 11:20:23 -07:00
19 changed files with 3198 additions and 171 deletions
@@ -31,6 +31,22 @@ vi.mock("@tauri-apps/api/event", () => ({
}),
}));
/** Every event the hook subscribes to, so the unmount test counts the right
* number of teardowns instead of a magic number that drifts. */
const EVENT_NAMES = [
"claude-token-progress",
"claude-token-output",
"claude-token-link",
"claude-token-code-rejected",
];
/** The sign-in URL at its real length (346 characters, measured against
* Claude Code 2.1.226) and the 80-column slice of it that is all the visible
* transcript ever contains. */
const FULL_URL =
"https://claude.com/cai/oauth/authorize?code=true&client_id=9d1c250a-e61b-44d9-88ed-5944d1962f5e&response_type=code&redirect_uri=https%3A%2F%2Fplatform.claude.com%2Foauth%2Fcode%2Fcallback&scope=user%3Ainference&code_challenge=RUX5MlWvwld1dmpvF_aPIJQWMBmffuJt4dOdL13zWAg&code_challenge_method=S256&state=su-x9PgZzvkBd3-um6G1llLNDgxptyO6HERvvCSrTbg";
const TRUNCATED_URL = FULL_URL.slice(0, 80);
function emitOutput(chunk: string, projectId = "p1") {
act(() => {
handlers.get("claude-token-output")?.({
@@ -39,6 +55,26 @@ function emitOutput(chunk: string, projectId = "p1") {
});
}
function emitLink(url: string, projectId = "p1") {
act(() => {
handlers.get("claude-token-link")?.({
payload: { project_id: projectId, url },
});
});
}
function emitCodeRejected(message: string, attemptsRemaining: number) {
act(() => {
handlers.get("claude-token-code-rejected")?.({
payload: {
project_id: "p1",
message,
attempts_remaining: attemptsRemaining,
},
});
});
}
function renderModal(
overrides: { onClose?: () => void; onAuthenticated?: () => void } = {},
) {
@@ -200,6 +236,93 @@ describe("ClaudeAuthModal", () => {
const { unmount } = renderModal();
await flowStarted();
unmount();
await waitFor(() => expect(unlisten).toHaveBeenCalledTimes(2));
await waitFor(() =>
expect(unlisten).toHaveBeenCalledTimes(EVENT_NAMES.length),
);
});
// ── The hyperlink target, not the wrapped display text ────────────────
//
// `claude setup-token` slices the *visible* text of its OSC 8 hyperlink to
// the terminal width, so the transcript holds five 80-character pieces of a
// 346-character URL. The backend lifts the whole thing out of the hyperlink
// parameter and sends it on `claude-token-link`.
it("prefers the hyperlink target over the wrapped copy in the transcript", async () => {
renderModal();
await flowStarted();
// What the transcript holds: the first slice only.
emitOutput(`Browser didn't open? Use the url below to sign in\n${TRUNCATED_URL}\n`);
// What the hyperlink parameter holds: all of it.
emitLink(FULL_URL);
const link = await screen.findByRole("link", { name: FULL_URL });
fireEvent.click(link);
await waitFor(() => expect(openUrl).toHaveBeenCalledWith(FULL_URL));
expect(openUrl).not.toHaveBeenCalledWith(TRUNCATED_URL);
});
it("refuses a hyperlink target that is not an Anthropic sign-in address", async () => {
renderModal();
await flowStarted();
emitLink("https://evil.tld/cai/oauth/authorize?code=true");
expect(screen.queryByRole("link")).not.toBeInTheDocument();
expect(openUrl).not.toHaveBeenCalled();
});
it("ignores a hyperlink belonging to a different project", async () => {
renderModal();
await flowStarted();
emitLink(FULL_URL, "p2");
expect(screen.queryByRole("link")).not.toBeInTheDocument();
});
// ── A refused code is recoverable, not a hang ─────────────────────────
it("reports a rejected code and lets another one be submitted", async () => {
renderModal();
await flowStarted();
const input = screen.getByLabelText("Authentication code");
fireEvent.change(input, { target: { value: "truncated" } });
fireEvent.click(screen.getByRole("button", { name: "Submit code" }));
await waitFor(() =>
expect(submitClaudeTokenCode).toHaveBeenCalledWith("truncated"),
);
// Before the rejection arrives the UI claims the sign-in is completing.
expect(screen.getByText("Finishing sign-in")).toBeInTheDocument();
emitCodeRejected(
"That code was rejected — `claude setup-token` reports the full code was not copied. Copy it again from the Anthropic page and submit it; 2 attempts left.",
2,
);
// Reported, not waited out — and the flow is still live.
await screen.findByText(/That code was rejected/);
expect(screen.getByText("Code rejected — try again")).toBeInTheDocument();
expect(screen.queryByText("Finishing sign-in")).not.toBeInTheDocument();
expect(screen.queryByTestId("claude-auth-error")).not.toBeInTheDocument();
// A second code goes through without restarting the whole flow.
fireEvent.change(input, { target: { value: "the-whole-code" } });
fireEvent.click(screen.getByRole("button", { name: "Submit code" }));
await waitFor(() =>
expect(submitClaudeTokenCode).toHaveBeenLastCalledWith("the-whole-code"),
);
expect(acquireClaudeToken).toHaveBeenCalledTimes(1);
});
it("ends with a reported failure when the retries run out", async () => {
acquireClaudeToken.mockRejectedValue(
"`claude setup-token` rejected the code 3 times, so the sign-in was abandoned. No token was stored.",
);
renderModal();
const banner = await screen.findByTestId("claude-auth-error");
expect(banner).toHaveTextContent(/rejected the code 3 times/);
});
});
@@ -27,6 +27,9 @@ interface Props {
const PHASE_STATUS: Record<string, { tone: StatusTone; label: string }> = {
waiting: { tone: "busy", label: "Waiting for sign-in" },
finishing: { tone: "busy", label: "Finishing sign-in" },
// The CLI refused a code and is back at its prompt. Distinct from "failed":
// the flow is still live and another code will be accepted.
rejected: { tone: "error", label: "Code rejected — try again" },
succeeded: { tone: "ok", label: "Token stored" },
failed: { tone: "error", label: "Authentication failed" },
};
@@ -88,7 +91,9 @@ export default function ClaudeAuthModal({
? PHASE_STATUS.failed
: flow.codeSubmitted
? PHASE_STATUS.finishing
: PHASE_STATUS.waiting;
: flow.codeRejections > 0
? PHASE_STATUS.rejected
: PHASE_STATUS.waiting;
// Split for display only. `flow.signInUrl` has already passed the host
// allowlist; this decides which half of it an ellipsis is allowed to eat.