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

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

afterEach(cleanup);

const defaultProps = {
endpointUrl: "https://api.callora.com/v1/forecast",
method: "GET",
params: [
{ name: "lat", type: "number", required: true },
{ name: "lon", type: "number", required: true },
{ name: "units", type: "string", required: false },
],
};

describe("TestInBrowser", () => {
it("renders the trigger button in a collapsed state by default", () => {
render(<TestInBrowser {...defaultProps} />);

const trigger = screen.getByRole("button", { name: /test in browser/i });
expect(trigger).toBeTruthy();
expect(trigger.getAttribute("aria-expanded")).toBe("false");

// Panel should not be visible
expect(screen.queryByRole("region")).toBeNull();
});

it("opens the test panel when the trigger is clicked", () => {
render(<TestInBrowser {...defaultProps} />);

fireEvent.click(screen.getByRole("button", { name: /test in browser/i }));

// Panel should be present
expect(screen.getByRole("region")).toBeTruthy();
// Trigger should now say "close"
expect(
screen.getByRole("button", { name: /close test runner/i }),
).toBeTruthy();
// Parameter inputs should appear
expect(screen.getByLabelText(/lat parameter value/i)).toBeTruthy();
expect(screen.getByLabelText(/lon parameter value/i)).toBeTruthy();
});

it("shows an error message when the fetch fails", async () => {
// Simulate a network error
vi.stubGlobal(
"fetch",
vi.fn().mockRejectedValue(new Error("Network error")),
);

render(<TestInBrowser {...defaultProps} />);
fireEvent.click(screen.getByRole("button", { name: /test in browser/i }));
fireEvent.click(screen.getByRole("button", { name: /^run$/i }));

await waitFor(() => {
expect(screen.getByRole("alert")).toBeTruthy();
expect(screen.getByText(/network error/i)).toBeTruthy();
});

vi.unstubAllGlobals();
});

it("displays the response body and HTTP status on success", async () => {
const mockResponse = {
ok: true,
status: 200,
text: async () => JSON.stringify({ temperature: 22 }),
} as unknown as Response;

vi.stubGlobal("fetch", vi.fn().mockResolvedValue(mockResponse));

render(<TestInBrowser {...defaultProps} />);
fireEvent.click(screen.getByRole("button", { name: /test in browser/i }));
fireEvent.click(screen.getByRole("button", { name: /^run$/i }));

await waitFor(() => {
expect(screen.getByLabelText(/http status 200/i)).toBeTruthy();
expect(screen.getByLabelText(/response body/i).textContent).toContain("temperature");
});

vi.unstubAllGlobals();
});
});
305 changes: 305 additions & 0 deletions src/components/TestInBrowser.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,305 @@
import { useState } from "react";

/**
* TestInBrowser
*
* Renders a one-click inline test runner affordance for a single API endpoint.
* Clicking "Test in browser" expands a panel where the user can fill in query
* parameters and fire a live GET/POST request directly from the page.
*
* Accessibility: the trigger button has an aria-expanded attribute and the
* panel is labelled via aria-labelledby so it is announced correctly by
* screen readers (WCAG 2.1 AA).
*/

export interface EndpointParam {
name: string;
type: string;
required: boolean;
}

export interface TestInBrowserProps {
/** Full URL of the endpoint, e.g. "https://api.example.com/v1/forecast" */
endpointUrl: string;
/** HTTP method, e.g. "GET" */
method: string;
/** List of query / body parameters for this endpoint */
params?: EndpointParam[];
}

interface RunResult {
status: number;
body: string;
}

export default function TestInBrowser({
endpointUrl,
method,
params = [],
}: TestInBrowserProps) {
const [open, setOpen] = useState(false);
const [values, setValues] = useState<Record<string, string>>({});
const [running, setRunning] = useState(false);
const [result, setResult] = useState<RunResult | null>(null);
const [error, setError] = useState<string | null>(null);

const panelId = `tib-panel-${endpointUrl.replace(/[^a-z0-9]/gi, "-")}`;
const triggerId = `tib-trigger-${endpointUrl.replace(/[^a-z0-9]/gi, "-")}`;

function handleChange(name: string, value: string) {
setValues((prev) => ({ ...prev, [name]: value }));
}

async function handleRun() {
setRunning(true);
setResult(null);
setError(null);

try {
let url = endpointUrl;
let fetchInit: RequestInit = { method };

if (method.toUpperCase() === "GET" || method.toUpperCase() === "DELETE") {
const qs = new URLSearchParams(
Object.entries(values).filter(([, v]) => v !== ""),
).toString();
if (qs) url += (url.includes("?") ? "&" : "?") + qs;
} else {
fetchInit.headers = { "Content-Type": "application/json" };
fetchInit.body = JSON.stringify(
Object.fromEntries(Object.entries(values).filter(([, v]) => v !== "")),
);
}

const response = await fetch(url, fetchInit);
const text = await response.text();
let body: string;
try {
body = JSON.stringify(JSON.parse(text), null, 2);
} catch {
body = text;
}
setResult({ status: response.status, body });
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setRunning(false);
}
}

return (
<div className="tib-root" style={{ marginTop: 12 }}>
{/* Trigger button */}
<button
id={triggerId}
type="button"
aria-expanded={open}
aria-controls={panelId}
className="ghost-button tib-trigger"
onClick={() => {
setOpen((o) => !o);
setResult(null);
setError(null);
}}
style={{
fontSize: 13,
display: "inline-flex",
alignItems: "center",
gap: 6,
}}
>
{/* Play icon */}
<svg
width="14"
height="14"
viewBox="0 0 20 20"
fill="none"
aria-hidden="true"
focusable="false"
>
<circle cx="10" cy="10" r="9" stroke="currentColor" strokeWidth="1.5" />
<path d="M8 7l6 3-6 3V7z" fill="currentColor" />
</svg>
{open ? "Close test runner" : "Test in browser"}
</button>

{/* Inline test panel */}
{open && (
<div
id={panelId}
role="region"
aria-labelledby={triggerId}
className="tib-panel"
style={{
marginTop: 12,
padding: 16,
borderRadius: 8,
border: "1px solid var(--border-subtle)",
background: "var(--bg-subtle)",
}}
>
<p
style={{
margin: "0 0 12px 0",
fontSize: 12,
color: "var(--muted)",
}}
>
Fill in parameters below, then click <strong>Run</strong> to send a
live request from your browser.
</p>

{/* Parameter inputs */}
{params.length > 0 ? (
<div
style={{ display: "grid", gap: 10, marginBottom: 14 }}
aria-label="Endpoint parameters"
>
{params.map((p) => (
<label
key={p.name}
style={{
display: "grid",
gridTemplateColumns: "1fr 2fr",
gap: 8,
alignItems: "center",
fontSize: 13,
}}
>
<span>
<code style={{ color: "var(--accent)" }}>{p.name}</code>
{p.required && (
<span
aria-label="required"
style={{ color: "#ef4444", marginLeft: 2 }}
>
*
</span>
)}
<span
style={{
display: "block",
fontSize: 11,
color: "var(--muted)",
}}
>
{p.type}
</span>
</span>
<input
type="text"
placeholder={p.required ? "Required" : "Optional"}
value={values[p.name] ?? ""}
onChange={(e) => handleChange(p.name, e.target.value)}
aria-label={`${p.name} parameter value`}
style={{
padding: "6px 10px",
borderRadius: 6,
border: "1px solid var(--border-subtle)",
background: "var(--bg-card, #fff)",
color: "var(--text-main)",
fontSize: 13,
}}
/>
</label>
))}
</div>
) : (
<p style={{ fontSize: 13, color: "var(--muted)", marginBottom: 14 }}>
No parameters defined for this endpoint.
</p>
)}

{/* Run button */}
<button
type="button"
className="primary-button tib-run"
onClick={handleRun}
disabled={running}
style={{ fontSize: 13 }}
aria-busy={running}
>
{running ? "Running…" : "Run"}
</button>

{/* Error */}
{error && (
<div
role="alert"
style={{
marginTop: 12,
padding: "10px 14px",
borderRadius: 6,
background: "rgba(239,68,68,0.08)",
border: "1px solid rgba(239,68,68,0.3)",
color: "#ef4444",
fontSize: 13,
}}
>
{error}
</div>
)}

{/* Response */}
{result && (
<div style={{ marginTop: 12 }}>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
marginBottom: 6,
}}
>
<span
style={{
fontSize: 12,
fontWeight: 700,
padding: "2px 8px",
borderRadius: 999,
background:
result.status >= 200 && result.status < 300
? "rgba(16,185,129,0.12)"
: "rgba(239,68,68,0.12)",
color:
result.status >= 200 && result.status < 300
? "#10b981"
: "#ef4444",
border:
result.status >= 200 && result.status < 300
? "1px solid rgba(16,185,129,0.3)"
: "1px solid rgba(239,68,68,0.3)",
}}
aria-label={`HTTP status ${result.status}`}
>
{result.status}
</span>
<span style={{ fontSize: 12, color: "var(--muted)" }}>
Response
</span>
</div>
<pre
aria-label="Response body"
style={{
margin: 0,
padding: "12px 14px",
borderRadius: 6,
background: "var(--bg-code, #0f172a)",
color: "var(--text-code, #e2e8f0)",
fontSize: 12,
overflowX: "auto",
maxHeight: 240,
overflowY: "auto",
whiteSpace: "pre-wrap",
wordBreak: "break-word",
}}
>
{result.body || "(empty response)"}
</pre>
</div>
)}
</div>
)}
</div>
);
}
Loading