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
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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user