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
108 changes: 108 additions & 0 deletions e2e/tests/endpoint-screen.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
16 changes: 16 additions & 0 deletions public/endpoint.js
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@
sendForm: { method: "POST", body: "", headers: "" },
sendStatus: "",
copyLabel: "Copy",
copiedKey: null,
_baseTitle: document.title,
_unread: 0,

Expand Down Expand Up @@ -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;
Expand Down
92 changes: 92 additions & 0 deletions public/har.js
Original file line number Diff line number Diff line change
@@ -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/<endpoint> 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,
);
};
})();
6 changes: 5 additions & 1 deletion public/render-body.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -141,3 +143,5 @@ window.renderBody = function (body, headers) {
}
return htmlEscape(body);
};

window.headerValue = headerValue;
Loading
Loading