Skip to content

Commit 363a8b5

Browse files
fix(ui): honest extension-token clipboard toasts and Copy button (#6932)
Create token no longer swallows clipboard failures then claims success. Add a manual Copy affordance beside the truncated token display. Closes #6824 Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent e1df4aa commit 363a8b5

2 files changed

Lines changed: 147 additions & 7 deletions

File tree

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
2+
import { beforeEach, describe, expect, it, vi } from "vitest";
3+
4+
// #6824: create-token used to swallow clipboard rejections and still toast success. Mock the toast
5+
// and api layers so the regression can assert the exact success/failure branch without a live API.
6+
const { success, error } = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() }));
7+
vi.mock("sonner", () => ({ toast: { success, error } }));
8+
9+
const { apiFetch } = vi.hoisted(() => ({ apiFetch: vi.fn() }));
10+
vi.mock("@/lib/api/request", () => ({
11+
apiFetch: (...args: unknown[]) => apiFetch(...args),
12+
}));
13+
vi.mock("@/lib/api/origin", () => ({
14+
getApiOrigin: () => "https://api.example.test",
15+
}));
16+
17+
import { ExtensionTokenButton } from "./extension";
18+
19+
const TOKEN = "ext_test_token_6824";
20+
21+
function mockClipboard(writeText: () => Promise<void>) {
22+
const spy = vi.fn(writeText);
23+
Object.defineProperty(navigator, "clipboard", {
24+
value: { writeText: spy },
25+
configurable: true,
26+
writable: true,
27+
});
28+
return spy;
29+
}
30+
31+
describe("ExtensionTokenButton clipboard honesty (#6824)", () => {
32+
beforeEach(() => {
33+
vi.clearAllMocks();
34+
apiFetch.mockResolvedValue({
35+
ok: true,
36+
data: { token: TOKEN },
37+
status: 200,
38+
durationMs: 1,
39+
message: undefined,
40+
});
41+
});
42+
43+
it("toasts success only when the auto-copy after create actually writes", async () => {
44+
const writeText = mockClipboard(() => Promise.resolve());
45+
render(<ExtensionTokenButton />);
46+
47+
fireEvent.click(screen.getByRole("button", { name: "Create extension token" }));
48+
49+
await waitFor(() => expect(writeText).toHaveBeenCalledWith(TOKEN));
50+
await waitFor(() =>
51+
expect(success).toHaveBeenCalledWith("Extension token created", {
52+
description: "Copied to clipboard.",
53+
}),
54+
);
55+
expect(error).not.toHaveBeenCalled();
56+
expect(screen.getByTestId("extension-token-value").textContent).toBe(TOKEN);
57+
expect(screen.getByRole("button", { name: "Copy extension token" })).toBeTruthy();
58+
});
59+
60+
it("does not claim clipboard success when writeText rejects after create", async () => {
61+
// The exact gap: `.catch(() => undefined)` discarded the rejection, then success still fired.
62+
mockClipboard(() => Promise.reject(new Error("denied")));
63+
render(<ExtensionTokenButton />);
64+
65+
fireEvent.click(screen.getByRole("button", { name: "Create extension token" }));
66+
67+
await waitFor(() =>
68+
expect(error).toHaveBeenCalledWith("Couldn't copy extension token", expect.any(Object)),
69+
);
70+
expect(success).not.toHaveBeenCalled();
71+
// Token must still render so the user can recover via the manual copy button.
72+
expect(screen.getByTestId("extension-token-value").textContent).toBe(TOKEN);
73+
expect(screen.getByRole("button", { name: "Copy extension token" })).toBeTruthy();
74+
});
75+
76+
it("manual Copy button copies the displayed token and reports failure honestly", async () => {
77+
mockClipboard(() => Promise.resolve());
78+
render(<ExtensionTokenButton />);
79+
fireEvent.click(screen.getByRole("button", { name: "Create extension token" }));
80+
await waitFor(() => screen.getByRole("button", { name: "Copy extension token" }));
81+
vi.clearAllMocks();
82+
83+
const writeText = mockClipboard(() => Promise.resolve());
84+
fireEvent.click(screen.getByRole("button", { name: "Copy extension token" }));
85+
await waitFor(() => expect(writeText).toHaveBeenCalledWith(TOKEN));
86+
await waitFor(() =>
87+
expect(success).toHaveBeenCalledWith("Extension token copied", expect.any(Object)),
88+
);
89+
90+
vi.clearAllMocks();
91+
mockClipboard(() => Promise.reject(new Error("denied")));
92+
fireEvent.click(screen.getByRole("button", { name: "Copy extension token" }));
93+
await waitFor(() =>
94+
expect(error).toHaveBeenCalledWith("Couldn't copy extension token", expect.any(Object)),
95+
);
96+
expect(success).not.toHaveBeenCalled();
97+
});
98+
});

apps/loopover-ui/src/routes/extension.tsx

Lines changed: 49 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { createFileRoute } from "@tanstack/react-router";
2-
import { Download, Lock, Shield, GitPullRequestArrow } from "lucide-react";
2+
import { Copy, Download, Lock, Shield, GitPullRequestArrow } from "lucide-react";
33
import { useState } from "react";
44
import { motion } from "motion/react";
55
import { toast } from "sonner";
@@ -11,6 +11,14 @@ import { apiFetch } from "@/lib/api/request";
1111
import { getApiOrigin } from "@/lib/api/origin";
1212
import { cn } from "@/lib/utils";
1313

14+
/** Write `text` to the clipboard or throw when the Clipboard API is missing / rejects. */
15+
async function writeClipboardText(text: string): Promise<void> {
16+
if (typeof navigator === "undefined" || !navigator.clipboard?.writeText) {
17+
throw new Error("Clipboard API unavailable");
18+
}
19+
await navigator.clipboard.writeText(text);
20+
}
21+
1422
export const Route = createFileRoute("/extension")({
1523
head: () => ({
1624
meta: [
@@ -145,9 +153,31 @@ const EXTENSION_PANELS = [
145153
},
146154
] as const;
147155

148-
function ExtensionTokenButton() {
156+
// Exported for unit tests — the create-token path used to swallow clipboard failures and report
157+
// false success (#6824). Keeping the button extractable lets the regression suite drive it without
158+
// mounting the full marketing page / router.
159+
export function ExtensionTokenButton() {
149160
const [token, setToken] = useState("");
150161
const [busy, setBusy] = useState(false);
162+
163+
const copyToken = async (value: string, { created }: { created: boolean }) => {
164+
try {
165+
await writeClipboardText(value);
166+
toast.success(created ? "Extension token created" : "Extension token copied", {
167+
description: created
168+
? "Copied to clipboard."
169+
: "The token is ready to paste into the extension.",
170+
});
171+
} catch {
172+
// Match the app.runs / CodeBlock copy-fail channel: never claim success when writeText rejected.
173+
toast.error("Couldn't copy extension token", {
174+
description: created
175+
? "Token was created — use the Copy button next to it, or select the token and copy manually."
176+
: "Select the token and copy it manually.",
177+
});
178+
}
179+
};
180+
151181
return (
152182
<div className="flex max-w-full flex-col gap-2">
153183
<button
@@ -175,17 +205,29 @@ function ExtensionTokenButton() {
175205
return;
176206
}
177207
setToken(result.data.token);
178-
await navigator.clipboard?.writeText(result.data.token).catch(() => undefined);
179-
toast.success("Extension token created", { description: "Copied to clipboard." });
208+
await copyToken(result.data.token, { created: true });
180209
}}
181210
className="inline-flex items-center gap-2 rounded-token border border-border bg-transparent px-4 py-2 text-token-sm font-medium hover:border-foreground/30 disabled:cursor-not-allowed disabled:opacity-60"
182211
>
183212
{busy ? "Creating token…" : "Create extension token"}
184213
</button>
185214
{token && (
186-
<code className="max-w-[320px] truncate rounded-token border border-border bg-background/60 px-2 py-1 font-mono text-token-2xs text-muted-foreground">
187-
{token}
188-
</code>
215+
<div className="flex max-w-full items-center gap-1.5">
216+
<code
217+
data-testid="extension-token-value"
218+
className="max-w-[320px] truncate rounded-token border border-border bg-background/60 px-2 py-1 font-mono text-token-2xs text-muted-foreground"
219+
>
220+
{token}
221+
</code>
222+
<button
223+
type="button"
224+
aria-label="Copy extension token"
225+
onClick={() => void copyToken(token, { created: false })}
226+
className="inline-flex size-7 shrink-0 items-center justify-center rounded-token border border-border text-muted-foreground hover:border-foreground/30 hover:text-foreground"
227+
>
228+
<Copy className="size-3.5" aria-hidden />
229+
</button>
230+
</div>
189231
)}
190232
</div>
191233
);

0 commit comments

Comments
 (0)