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
49 changes: 49 additions & 0 deletions src/components/CopyCurlButton.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
// @vitest-environment jsdom

import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import CopyCurlButton from "./CopyCurlButton";

describe("CopyCurlButton", () => {
beforeEach(() => {
Object.assign(navigator, {
clipboard: { writeText: vi.fn().mockResolvedValue(undefined) },
});
});

afterEach(() => {
cleanup();
vi.restoreAllMocks();
});

it("renders an accessible default label", () => {
render(<CopyCurlButton request={{ url: "https://x.test/ping" }} />);
expect(
screen.getByRole("button", { name: /copy request as a curl command/i }),
).toBeTruthy();
expect(screen.getByText("Copy as cURL")).toBeTruthy();
});

it("writes the cURL command to the clipboard on click", async () => {
const writeText = navigator.clipboard.writeText as ReturnType<typeof vi.fn>;
render(
<CopyCurlButton
request={{ method: "POST", url: "https://x.test/echo", body: { a: 1 } }}
/>,
);
fireEvent.click(screen.getByRole("button"));
await waitFor(() => expect(writeText).toHaveBeenCalledTimes(1));
const copied = writeText.mock.calls[0][0] as string;
expect(copied).toContain("curl -X POST 'https://x.test/echo'");
expect(copied).toContain('--data \'{"a":1}\'');
});

it("shows success feedback after copying", async () => {
render(<CopyCurlButton request={{ url: "https://x.test/ping" }} />);
fireEvent.click(screen.getByRole("button"));
await waitFor(() => expect(screen.getByText("Copied")).toBeTruthy());
expect(
screen.getByText("cURL command copied to clipboard"),
).toBeTruthy();
});
});
119 changes: 119 additions & 0 deletions src/components/CopyCurlButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { useEffect, useRef, useState } from "react";
import { toCurl, type CurlRequest } from "../utils/toCurl";

/**
* CopyCurlButton — copies a request to the clipboard as a ready-to-run `curl`
* command and gives clear, accessible success feedback (issue #284).
*
* Accessibility (WCAG 2.1 AA):
* - Real <button> with a descriptive `aria-label`.
* - Success/failure is announced via an `aria-live="polite"` region.
* - Focus styles rely on the app's design tokens.
*/

type CopyCurlButtonProps = {
/** The request to serialise into a cURL command. */
request: CurlRequest;
/** Optional class for layout integration. */
className?: string;
};

const RESET_MS = 2000;

export default function CopyCurlButton({
request,
className,
}: CopyCurlButtonProps): JSX.Element {
const [status, setStatus] = useState<"idle" | "copied" | "error">("idle");
const timerRef = useRef<number | null>(null);

useEffect(() => {
return () => {
if (timerRef.current !== null) window.clearTimeout(timerRef.current);
};
}, []);

const scheduleReset = () => {
if (timerRef.current !== null) window.clearTimeout(timerRef.current);
timerRef.current = window.setTimeout(() => setStatus("idle"), RESET_MS);
};

const handleCopy = async () => {
const command = toCurl(request);
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(command);
} else {
// Fallback for browsers without the async clipboard API.
const textarea = document.createElement("textarea");
textarea.value = command;
textarea.style.position = "fixed";
textarea.style.opacity = "0";
document.body.appendChild(textarea);
textarea.select();
document.execCommand("copy");
document.body.removeChild(textarea);
}
setStatus("copied");
} catch (err) {
console.error("Failed to copy cURL command:", err);
setStatus("error");
}
scheduleReset();
};

const label =
status === "copied"
? "Copied"
: status === "error"
? "Copy failed"
: "Copy as cURL";

return (
<>
<button
type="button"
className={className ?? "ghost-button"}
onClick={handleCopy}
aria-label="Copy request as a cURL command"
style={{
display: "inline-flex",
alignItems: "center",
gap: "0.375rem",
fontSize: "0.75rem",
padding: "0.3125rem 0.75rem",
color:
status === "copied"
? "var(--success, #10b981)"
: status === "error"
? "var(--danger, #ef4444)"
: undefined,
}}
>
<span aria-hidden="true">{status === "copied" ? "✓" : "⌘"}</span>
{label}
</button>
<span
aria-live="polite"
aria-atomic="true"
style={{
position: "absolute",
width: "1px",
height: "1px",
margin: "-1px",
padding: 0,
overflow: "hidden",
clip: "rect(0 0 0 0)",
whiteSpace: "nowrap",
border: 0,
}}
>
{status === "copied"
? "cURL command copied to clipboard"
: status === "error"
? "Failed to copy cURL command"
: ""}
</span>
</>
);
}
47 changes: 47 additions & 0 deletions src/utils/toCurl.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { describe, it, expect } from "vitest";
import { toCurl } from "./toCurl";

describe("toCurl", () => {
it("defaults to GET", () => {
expect(toCurl({ url: "https://api.example.com/v1/ping" })).toBe(
"curl -X GET 'https://api.example.com/v1/ping'",
);
});

it("includes headers in order", () => {
const out = toCurl({
url: "https://api.example.com/v1/data",
headers: { Authorization: "Bearer xyz", Accept: "application/json" },
});
expect(out).toContain("-H 'Authorization: Bearer xyz'");
expect(out).toContain("-H 'Accept: application/json'");
});

it("serialises an object body as JSON for POST", () => {
const out = toCurl({
method: "post",
url: "https://api.example.com/v1/items",
body: { name: "widget", qty: 2 },
});
expect(out).toContain("curl -X POST");
expect(out).toContain('--data \'{"name":"widget","qty":2}\'');
});

it("omits the body for GET/HEAD", () => {
const out = toCurl({
method: "GET",
url: "https://api.example.com/v1/items",
body: { ignored: true },
});
expect(out).not.toContain("--data");
});

it("escapes single quotes in values safely", () => {
const out = toCurl({
method: "POST",
url: "https://api.example.com/v1/echo",
body: "it's fine",
});
expect(out).toContain(`--data 'it'\\''s fine'`);
});
});
60 changes: 60 additions & 0 deletions src/utils/toCurl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/**
* toCurl — build a copy-pasteable `curl` command from a structured HTTP request.
*
* The output is intentionally readable (one option per line via `\` line
* continuations) and shell-safe: every dynamic value is single-quoted and any
* embedded single quotes are escaped using the standard `'\''` trick.
*/

export type CurlRequest = {
/** HTTP method. Defaults to "GET". */
method?: string;
/** Full request URL. */
url: string;
/** Header map. Order is preserved as provided. */
headers?: Record<string, string>;
/**
* Optional request body. Objects are JSON-stringified; strings are sent
* verbatim. Ignored for GET/HEAD.
*/
body?: unknown;
};

const BODILESS_METHODS = new Set(["GET", "HEAD"]);

/** Single-quote a value for safe use in a POSIX shell. */
function shellQuote(value: string): string {
return `'${value.replace(/'/g, `'\\''`)}'`;
}

/** Serialize a request body to a string suitable for `--data`. */
function serializeBody(body: unknown): string {
if (typeof body === "string") return body;
return JSON.stringify(body);
}

/**
* Convert a {@link CurlRequest} into a multi-line `curl` command string.
*/
export function toCurl(request: CurlRequest): string {
const method = (request.method ?? "GET").toUpperCase();
const lines: string[] = [`curl -X ${method} ${shellQuote(request.url)}`];

for (const [name, value] of Object.entries(request.headers ?? {})) {
if (!name) continue;
lines.push(`-H ${shellQuote(`${name}: ${value}`)}`);
}

const hasBody =
request.body !== undefined &&
request.body !== null &&
!BODILESS_METHODS.has(method);

if (hasBody) {
lines.push(`--data ${shellQuote(serializeBody(request.body))}`);
}

return lines.join(" \\\n ");
}

export default toCurl;