diff --git a/e2e/tests/endpoint-screen.spec.ts b/e2e/tests/endpoint-screen.spec.ts index 8121a19..1c214c4 100644 --- a/e2e/tests/endpoint-screen.spec.ts +++ b/e2e/tests/endpoint-screen.spec.ts @@ -249,6 +249,114 @@ test.describe("Endpoint screen", () => { expect(parsed["X-Sample"]).toBe("value"); }); + test("request copy button writes a HAR-shaped document to the clipboard", async ({ + page, + request, + }) => { + const raw = '{"hello":"world"}'; + const response = await request.post(`${endpointUrl}?a=1&b=2`, { + data: raw, + headers: { "Content-Type": "application/json", "X-Sample": "value" }, + }); + const uuid = response.headers()["httphq-request-uuid"]; + const card = page.locator(`#request-${uuid}`); + await expect(card).toBeAttached(); + + await card.locator('[data-test="copy-request-har"]').click(); + const clipboard = await page.evaluate(() => + navigator.clipboard.readText(), + ); + const har = JSON.parse(clipboard); + + expect(har.creator.name).toBe("httphq"); + expect(har.entries).toHaveLength(1); + const entry = har.entries[0]; + expect(entry.id).toBe(uuid); + expect(entry.request.method).toBe("POST"); + expect(entry.request.url).toBe(`${endpointUrl}?a=1&b=2`); + expect(entry.request.httpVersion).toBe("HTTP/1.1"); + expect(entry.request.queryString).toEqual([ + { name: "a", value: "1" }, + { name: "b", value: "2" }, + ]); + expect(entry.request.headers).toContainEqual({ + name: "X-Sample", + value: "value", + }); + expect(entry.request.postData).toEqual({ + mimeType: "application/json", + text: raw, + }); + expect(entry.request.bodySize).toBe(raw.length); + }); + + test("request copy button label flips to Copied!", async ({ + page, + request, + }) => { + await post(request, endpointUrl, { data: "x" }); + const card = page.locator('[data-test="request"]').first(); + await card.locator('[data-test="copy-request-har"]').click(); + await expect( + card.locator('[data-test="copy-request-har-label"]'), + ).toContainText("Copied!"); + }); + + test("copy-all writes every visible request, newest first", async ({ + page, + request, + }) => { + await post(request, endpointUrl, { data: "first" }); + await expect(page.locator('[data-test="request"]')).toHaveCount(1); + await post(request, endpointUrl, { data: "second" }); + await expect(page.locator('[data-test="request"]')).toHaveCount(2); + + await page.locator('[data-test="copy-all-har"]').click(); + const clipboard = await page.evaluate(() => + navigator.clipboard.readText(), + ); + const har = JSON.parse(clipboard); + + expect(har.entries).toHaveLength(2); + expect( + har.entries.map( + (e: { request: { postData: { text: string } } }) => + e.request.postData.text, + ), + ).toEqual(["second", "first"]); + }); + + test("copy-all is scoped to the method filter", async ({ + page, + request, + }) => { + await post(request, endpointUrl, { data: "p" }); + await request.fetch(endpointUrl, { method: "PUT", data: "u" }); + await expect(page.locator('[data-test="search-results"]')).toContainText( + "2 results", + ); + + await page.locator('[data-test="method-filter"]').selectOption("PUT"); + await expect( + page.locator('[data-test="copy-all-har-label"]'), + ).toContainText("Copy all (1)"); + + await page.locator('[data-test="copy-all-har"]').click(); + const clipboard = await page.evaluate(() => + navigator.clipboard.readText(), + ); + const har = JSON.parse(clipboard); + + expect(har.entries).toHaveLength(1); + expect(har.entries[0].request.method).toBe("PUT"); + }); + + test("copy-all is disabled while nothing has been captured", async ({ + page, + }) => { + await expect(page.locator('[data-test="copy-all-har"]')).toBeDisabled(); + }); + test("filters by request body via the search box", async ({ page, request, diff --git a/public/endpoint.js b/public/endpoint.js index d9f0057..00bced4 100644 --- a/public/endpoint.js +++ b/public/endpoint.js @@ -94,6 +94,7 @@ sendForm: { method: "POST", body: "", headers: "" }, sendStatus: "", copyLabel: "Copy", + copiedKey: null, _baseTitle: document.title, _unread: 0, @@ -171,6 +172,21 @@ } }, + // Copies requests as a HAR-shaped document. `key` names the button that + // should read "Copied!" — several buttons share this one component + // instance, so a single label field can't tell them apart. + async copyHar(requests, key) { + try { + await window.copyToClipboard(window.buildHarExport(requests)); + this.copiedKey = key; + setTimeout(() => { + if (this.copiedKey === key) this.copiedKey = null; + }, 1500); + } catch (err) { + console.error(err); + } + }, + async sendCustom() { const store = Alpine.store("main"); if (!store.endpointId) return; diff --git a/public/har.js b/public/har.js new file mode 100644 index 0000000..79d757f --- /dev/null +++ b/public/har.js @@ -0,0 +1,92 @@ +/* Serialises captured requests to a HAR-shaped JSON document for the clipboard. + + The field names and value shapes follow HAR 1.2 so the output is familiar to + HAR tooling, but entries carry only a `request`: httphq never observes a + response, so `response`, `timings` and `cache` are omitted rather than + emitted as stubs that would read as captured data. Loaded on every page. */ + +(function () { + const CREATOR = { name: "httphq", version: "1" }; + + // Not captured: the capture handler never records the protocol, so every + // entry claims HTTP/1.1. Revisit if the request model starts storing it. + const ASSUMED_HTTP_VERSION = "HTTP/1.1"; + + /* Absolute URL for a captured request. `path` already carries the + /to/ prefix; the scheme comes from the page because it is not + stored, and the Host header is preferred over the page's so a request + captured through another hostname still round-trips. */ + function entryURL(request) { + const host = window.headerValue(request.headers, "Host") || location.host; + const query = request.queryString ? `?${request.queryString}` : ""; + return `${location.protocol}//${host}${request.path}${query}`; + } + + /* HAR represents a repeated header as one entry per value, so the stored + scalar-or-array value is expanded rather than joined. + + Order is alphabetical, not wire order: the capture handler flattens + headers through a Go map and encoding/json sorts keys on marshal. Nothing + downstream may treat this ordering as meaningful. */ + function entryHeaders(headers) { + const out = []; + for (const [name, value] of Object.entries(headers || {})) { + for (const one of Array.isArray(value) ? value : [value]) { + out.push({ name, value: String(one) }); + } + } + return out; + } + + function entryQueryString(queryString) { + if (!queryString) return []; + return [...new URLSearchParams(queryString)].map(([name, value]) => ({ + name, + value, + })); + } + + function harEntry(request) { + const body = request.body || ""; + // Omitted entirely for bodyless requests, matching HAR, where postData is + // absent rather than empty when there was no payload. + const postData = body + ? { + mimeType: window.headerValue(request.headers, "Content-Type") || "", + // Verbatim, never base64: the body carries the same U+FFFD + // substitutions the request panel displays. + text: body, + } + : null; + + return { + id: request.uuid, + startedDateTime: new Date(request.createdAt).toISOString(), + clientIPAddress: request.ip, + request: { + method: request.method, + url: entryURL(request), + httpVersion: ASSUMED_HTTP_VERSION, + headers: entryHeaders(request.headers), + queryString: entryQueryString(request.queryString), + ...(postData ? { postData } : {}), + // Byte length of the captured body, which httphq stores as a Go + // string — encoding/json replaces invalid UTF-8 with U+FFFD on + // marshal, so for genuinely binary uploads this can differ from the + // true original size. See database.Request.Body. + bodySize: new TextEncoder().encode(body).length, + }, + }; + } + + /* Takes captured requests in display order (newest first) and returns the + document as pretty-printed JSON. A single request and the whole list share + one envelope, so consumers parse the same shape either way. */ + window.buildHarExport = function (requests) { + return JSON.stringify( + { creator: CREATOR, entries: (requests || []).map(harEntry) }, + null, + 2, + ); + }; +})(); diff --git a/public/render-body.js b/public/render-body.js index b86187e..8e40d90 100644 --- a/public/render-body.js +++ b/public/render-body.js @@ -20,7 +20,9 @@ function highlightPrettyJSON(value) { } /* Case-insensitive lookup into a headers object whose values are either a - scalar string or a string[] (see the flattening in application.go). */ + scalar string or a string[] (see the flattening in application.go). Exposed + as window.headerValue because that scalar-or-array contract is shared by + every consumer of a captured request, not just body rendering. */ function headerValue(headers, name) { if (!headers) return undefined; const key = Object.keys(headers).find( @@ -141,3 +143,5 @@ window.renderBody = function (body, headers) { } return htmlEscape(body); }; + +window.headerValue = headerValue; diff --git a/src/views/endpoint.html b/src/views/endpoint.html index 82dbeb7..3011b34 100644 --- a/src/views/endpoint.html +++ b/src/views/endpoint.html @@ -146,27 +146,55 @@ Requests are deleted after 4 hours

- + + + Delete all + + @@ -243,26 +271,55 @@ class="text-xs text-slate-500 font-mono truncate hover:text-indigo-600" > - + + + + +
diff --git a/src/views/layouts/main.html b/src/views/layouts/main.html index 23f2307..85c5e28 100644 --- a/src/views/layouts/main.html +++ b/src/views/layouts/main.html @@ -37,6 +37,7 @@ queueMicrotask at the end of its own script execution. --> +