From f910452fa5153d090abe2f90b8950ed62a998d82 Mon Sep 17 00:00:00 2001 From: Baskarayelu Date: Mon, 29 Jun 2026 13:21:42 +0530 Subject: [PATCH] feat: Add 'Test in browser' affordance on endpoints (#290) Adds a new TestInBrowser component that renders a one-click inline test runner on each endpoint card in the Documentation tab. Users can fill in query/body parameters and fire a live request directly from the page, with the HTTP status and formatted response body shown inline. Accessibility: trigger button carries aria-expanded, the panel is a labelled region, and all inputs have aria-label attributes (WCAG 2.1 AA). Co-Authored-By: Claude Sonnet 4.6 --- src/components/TestInBrowser.test.tsx | 86 ++++++++ src/components/TestInBrowser.tsx | 305 ++++++++++++++++++++++++++ src/pages/ApiDetailPage.tsx | 12 + 3 files changed, 403 insertions(+) create mode 100644 src/components/TestInBrowser.test.tsx create mode 100644 src/components/TestInBrowser.tsx diff --git a/src/components/TestInBrowser.test.tsx b/src/components/TestInBrowser.test.tsx new file mode 100644 index 0000000..73c02ed --- /dev/null +++ b/src/components/TestInBrowser.test.tsx @@ -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(); + + 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(); + + 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(); + 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(); + 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(); + }); +}); diff --git a/src/components/TestInBrowser.tsx b/src/components/TestInBrowser.tsx new file mode 100644 index 0000000..b3cf08e --- /dev/null +++ b/src/components/TestInBrowser.tsx @@ -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>({}); + const [running, setRunning] = useState(false); + const [result, setResult] = useState(null); + const [error, setError] = useState(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 ( +
+ {/* Trigger button */} + + + {/* Inline test panel */} + {open && ( +
+

+ Fill in parameters below, then click Run to send a + live request from your browser. +

+ + {/* Parameter inputs */} + {params.length > 0 ? ( +
+ {params.map((p) => ( + + ))} +
+ ) : ( +

+ No parameters defined for this endpoint. +

+ )} + + {/* Run button */} + + + {/* Error */} + {error && ( +
+ {error} +
+ )} + + {/* Response */} + {result && ( +
+
+ = 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} + + + Response + +
+
+                {result.body || "(empty response)"}
+              
+
+ )} +
+ )} +
+ ); +} diff --git a/src/pages/ApiDetailPage.tsx b/src/pages/ApiDetailPage.tsx index cd13cfc..fc7b292 100644 --- a/src/pages/ApiDetailPage.tsx +++ b/src/pages/ApiDetailPage.tsx @@ -1,6 +1,7 @@ import { useMemo, useState, useEffect } from "react"; import useDocumentTitle from "../hooks/useDocumentTitle"; import Breadcrumb from "../components/Breadcrumb"; +import TestInBrowser from "../components/TestInBrowser"; import Skeleton from "../components/Skeleton"; import ApiDetailPageSkeleton from "./ApiDetailPage.skeleton"; import EmbedPreview from "../components/EmbedPreview"; @@ -625,6 +626,17 @@ getApiData().then(console.log).catch(console.error); snippets={allSnippets} defaultLanguage="bash" /> + + {/* One-click inline test runner (issue #290) */} + ({ + name: p.name, + type: p.type ?? "string", + required: Boolean(p.required), + }))} + /> ))}