diff --git a/src/components/CopyCurlButton.test.tsx b/src/components/CopyCurlButton.test.tsx new file mode 100644 index 0000000..8b5ad5e --- /dev/null +++ b/src/components/CopyCurlButton.test.tsx @@ -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(); + 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; + render( + , + ); + 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(); + fireEvent.click(screen.getByRole("button")); + await waitFor(() => expect(screen.getByText("Copied")).toBeTruthy()); + expect( + screen.getByText("cURL command copied to clipboard"), + ).toBeTruthy(); + }); +}); diff --git a/src/components/CopyCurlButton.tsx b/src/components/CopyCurlButton.tsx new file mode 100644 index 0000000..80341cd --- /dev/null +++ b/src/components/CopyCurlButton.tsx @@ -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 + + {status === "copied" + ? "cURL command copied to clipboard" + : status === "error" + ? "Failed to copy cURL command" + : ""} + + + ); +} diff --git a/src/utils/toCurl.test.ts b/src/utils/toCurl.test.ts new file mode 100644 index 0000000..24319c5 --- /dev/null +++ b/src/utils/toCurl.test.ts @@ -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'`); + }); +}); diff --git a/src/utils/toCurl.ts b/src/utils/toCurl.ts new file mode 100644 index 0000000..c988284 --- /dev/null +++ b/src/utils/toCurl.ts @@ -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; + /** + * 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;