Public: anyone can read this; the crew can change it.

A .har file in a repository opens as a request waterfall: method, status, size and time per row, drawn on the server #10953

opened this

Check · Review · Land · Live — Pushed since the last review. Next: reviewer, pushed since the last review.

Ready to land

torque/forge:torque/10936-a-har-file-in-a-repository-opens-as-a-re → main 12 commits fork 6 threads · 6 unresolved

Review

Sign in to review this change.
10 files changed +347 −0
docs/design/areas/render.md +4
@@ -32,6 +32,10 @@ A .zip, .tar or .tar.gz is drawn by `src/web/render/archive-view.ts` as the exis
3232
3333 `src/web/render/geojson.ts` draws a GeoJSON file as one inline SVG in `.gj-view` (same rules as `.gpx-view`: no fixed size, `max-width: 100%`, `currentColor`), and the first 100 features' properties as a plain table in `.gj-table`, which scrolls sideways inside its own box on a phone.
3434
35 ## Request waterfall (.har)
36
37 `src/web/render/har.ts` draws a HAR as the existing `.table` (`data-dense`) inside `.har-view`, which scrolls sideways on a phone. Each row's timing bar is one inline SVG, `.har-url` (the URL cell, wraps anywhere) and `.har-bar` (the full width of the URL cell, under the URL, 10px tall, `--muted-fg` (it is data, not a control, so not the accent), `aria-hidden` since the Time column says the same in words, no inline style). `.har-url` has `min-width: 200px` so a row never squeezes to a sliver; that cannot make the page scroll, because `.har-view` is `overflow-x: auto`, so any excess width scrolls inside the table's own box, not the page. Headers sit in a native `<details>` under the URL. How it works: `docs/features/file-har.md`.
38
3539 ## SARIF findings (.sarif)
3640
3741 `src/web/render/sarif.ts` draws one existing `.table` (`data-dense`) per run under an `h3` with the tool's name, with the level counts as a plain line above. `.sarif-view` is only a hook on the wrapper and has no rule. How it works: `docs/features/file-sarif.md`.
docs/features/file-har.md added +18
@@ -0,0 +1,18 @@
1 # A .har file as a request waterfall
2
3 A `.har` file (HTTP Archive 1.2, saved from a browser's network panel) opens on
4 its blob page as a table: method, status, URL, size and time per request, with a
5 timing bar placed on the session's own timeline. Source is one tap away.
6
7 - `src/web/render/har.ts` parses and draws; `kinds.ts` maps `.har` to the `har`
8 kind (cap 5 MB); `renderers.ts` names the renderer.
9 - Size is `response.content.size`, else `bodySize`; `-1` shows as "—".
10 - The bar is a server-drawn inline SVG per row; no script, no inline style.
11 - Every URL, status text and header value is escaped. Text is cut at 2,000
12 characters; 50 headers per side are shown, and the page prints
outdated

Wrong: the page prints 'N more headers not shown', it does not leave the count out. Fix the sentence, and add a line that the terminal workspace opens a .har as the same table (same row cap, s for source).

13 "N more headers not shown" for the rest.
14 - At most 500 rows are drawn; the page says how many were left out.
15 - Bad JSON, no `log.entries`, or no requests: the source view, with the reason.
16 - The terminal workspace opens a `.har` as the same table (same parser, same
17 500-row cap); `s` flips to the source, and a malformed file opens as source.
18 - Try it: open any `.har` in a repository's file view.
src/tui/screens/workspace.ts +29
@@ -58,6 +58,7 @@ import { detectLanguage, highlightLines } from "../../web/highlight.ts";
5858 import { MAX_COLUMNS, parseCsv, type Table } from "../../text/csv.ts";
5959 import { parseGeoJson, propertyRows } from "../../web/render/geojson.ts";
6060 import { gcodeFacts, parseGcode } from "../../web/render/gcode.ts";
61 import { parseHar, ROW_CAP as HAR_ROW_CAP, duration as harDuration } from "../../web/render/har.ts";
6162 import { gpxFacts, parseGpx } from "../../web/render/gpx.ts";
6263 import { CASE_CAP, parseJunit } from "../../web/render/junit.ts";
6364 import { parseOpenApi } from "../../web/render/openapi.ts";
@@ -421,6 +422,31 @@ function gpxTable(source: string, path: string): Table | null {
421422 return { header: ["Fact", "Value"], rows, more: 0, delimiter: ",", columns: 2 };
422423 }
423424
425 /** `.har`: a browser's captured session, drawn as a table of its requests. */
426 function isHarPath(path: string): boolean {
427 return /\.har$/i.test(path);
428 }
429
430 /** The requests of a HAR as a table; null when the file is not one. Shares the web's parser and row cap. */
431 export function harTable(source: string): Table | null {
432 const parsed = parseHar(source);
433 if ("error" in parsed || parsed.entries.length === 0) return null;
434 const shown = parsed.entries.slice(0, HAR_ROW_CAP);
435 return {
436 header: ["Method", "Status", "URL", "Size", "Time"],
437 rows: shown.map((e) => [
438 e.method,
439 e.status === 0 ? "-" : String(e.status),
440 e.url,
441 e.bytes === undefined ? "-" : fileSize(e.bytes),
442 harDuration(e.ms),
443 ]),
444 more: parsed.entries.length - shown.length,
445 delimiter: ",",
446 columns: 5,
447 };
448 }
449
424450 /** `.sarif`: a scanner's findings, drawn as a table of level, rule, location and message. */
425451 function isSarifPath(path: string): boolean {
426452 return /\.sarif$/i.test(path);
@@ -727,6 +753,9 @@ class Tab {
727753 } else if (isGpxPath(this.path)) {
728754 this.table = gpxTable(new TextDecoder().decode(source), this.path);
729755 if (this.table !== null) this.grid = layoutTable(this.table);
756 } else if (isHarPath(this.path)) {
757 this.table = harTable(new TextDecoder().decode(source));
758 if (this.table !== null) this.grid = layoutTable(this.table);
730759 } else if (isSarifPath(this.path)) {
731760 const findings = sarifTable(new TextDecoder().decode(source));
732761 this.table = typeof findings === "string" ? null : findings;
src/web/render/har.ts added +137
@@ -0,0 +1,137 @@
1 /**
2 * A HAR file (HTTP Archive 1.2, a browser's captured session) as a request
3 * waterfall: method, status, URL, size and time per row, and a bar placed
4 * on the session's own timeline. The bar is one small inline SVG per row,
5 * drawn here (no script, no inline style, so the page's CSP holds). Every
6 * URL, status text and header value is untrusted text and is escaped.
7 * Fields are read defensively; a file that is not JSON, or has no
8 * `log.entries`, falls back to its source with the reason.
9 * Spec: http://www.softwareishard.com/blog/har-12-spec/ (entries, timings).
10 */
11
12 import { escapeHtml, html, raw } from "../html.ts";
13 import { fileSize } from "../ui.ts";
14 import type { Rendered, RenderInput } from "./renderers.ts";
15
16 /** Rows drawn, whatever the file holds. */
17 export const ROW_CAP = 500;
18 /** Header lines drawn per side of a row. */
19 export const HEADER_CAP = 50;
20 /** Longest URL or header value shown, in characters. */
21 const TEXT_CAP = 2000;
22
23 export interface HarEntry {
24 readonly method: string;
25 readonly url: string;
26 /** 0 when the request never got a response (HAR 1.2 §response.status). */
27 readonly status: number;
28 readonly statusText: string;
29 /** Body bytes: `content.size`, else `bodySize`; undefined when unknown (-1). */
30 readonly bytes: number | undefined;
31 /** Milliseconds, the entry's `time`; 0 when unknown. */
32 readonly ms: number;
33 /** Start in ms since the epoch; NaN when unreadable. */
34 readonly start: number;
35 readonly requestHeaders: readonly (readonly [string, string])[];
36 readonly responseHeaders: readonly (readonly [string, string])[];
37 }
38
39 export type Har = { readonly entries: HarEntry[] } | { readonly error: string };
40
41 const isObj = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v);
42 const str = (v: unknown): string => (typeof v === "string" ? v.slice(0, TEXT_CAP) : "");
43 const num = (v: unknown): number | undefined => (typeof v === "number" && Number.isFinite(v) ? v : undefined);
44
45 function headers(v: unknown): [string, string][] {
46 if (!Array.isArray(v)) return [];
47 const out: [string, string][] = [];
48 for (const h of v) if (isObj(h)) out.push([str(h.name), str(h.value)]);
49 return out;
50 }
51
52 /** Parse a HAR's text. Pure; never throws. */
53 export function parseHar(source: string): Har {
54 let doc: unknown;
55 try {
56 doc = JSON.parse(source.replace(/^/, ""));
57 } catch {
58 return { error: "it is not valid JSON" };
59 }
60 const log = isObj(doc) ? doc.log : undefined;
61 if (!isObj(log) || !Array.isArray(log.entries)) return { error: "it has no log.entries list" };
62 const entries: HarEntry[] = [];
63 for (const e of log.entries) {
64 if (!isObj(e)) return { error: "an entry is not an object" };
65 const req = isObj(e.request) ? e.request : {};
66 const res = isObj(e.response) ? e.response : {};
67 const content = isObj(res.content) ? res.content : {};
68 const size = num(content.size) ?? num(res.bodySize);
69 const time = num(e.time);

The aria-label on this bar repeats the Time column to screen readers, so use aria-hidden="true" here. This also touches principle 8, plain words, once.

70 entries.push({
71 method: str(req.method).toUpperCase().slice(0, 16) || "?",
72 url: str(req.url),
73 status: Math.trunc(num(res.status) ?? 0),
74 statusText: str(res.statusText).slice(0, 100),
75 bytes: size === undefined || size < 0 ? undefined : size,
76 ms: time === undefined || time < 0 ? 0 : time,
77 start: typeof e.startedDateTime === "string" ? Date.parse(e.startedDateTime) : NaN,
78 requestHeaders: headers(req.headers),
79 responseHeaders: headers(res.headers),
80 });
81 }
82 return { entries };
83 }
84
85 const WIDTH = 200;
86
87 /** `1.23 s` or `45 ms`. */
88 export const duration = (ms: number): string => (ms >= 1000 ? `${(ms / 1000).toFixed(2)} s` : `${Math.round(ms)} ms`);
89
90 /** The bar (decorative: the Time column says the same in words): x and width are the share of the session's span (0..WIDTH). */
91 function bar(x: number, w: number): string {
92 const width = Math.max(1, Math.min(WIDTH - x, w));
93 return `<svg class="har-bar" viewBox="0 0 ${WIDTH} 10" preserveAspectRatio="none" aria-hidden="true" focusable="false"><rect x="${x.toFixed(2)}" y="0" width="${width.toFixed(2)}" height="10" fill="currentColor"/></svg>`;
94 }
95
96 const headerList = (title: string, hs: readonly (readonly [string, string])[]): string =>
97 hs.length === 0
98 ? ""
99 : `<p class="muted small">${title}</p><ul class="small">${hs
100 .slice(0, HEADER_CAP)
101 .map(([n, v]) => `<li><code>${escapeHtml(n)}</code>: ${escapeHtml(v)}</li>`)
102 .join("")}</ul>${hs.length > HEADER_CAP ? `<p class="muted small">${hs.length - HEADER_CAP} more headers not shown.</p>` : ""}`;
103
104 export function har(input: RenderInput): Rendered {
105 const parsed = parseHar(new TextDecoder().decode(input.body));
106 if ("error" in parsed) return { content: html``, lines: 0, fallback: `This file is not drawn as a HAR: ${parsed.error}.` };
107 if (parsed.entries.length === 0) return { content: html``, lines: 0, fallback: "This HAR holds no requests." };
108 const shown = parsed.entries.slice(0, ROW_CAP);
109 // The timeline is the shown rows' own: from the earliest start to the latest end.
110 const starts = shown.map((e) => e.start).filter(Number.isFinite);
111 const t0 = starts.length > 0 ? Math.min(...starts) : 0;
112 const span = Math.max(1, ...shown.map((e) => (Number.isFinite(e.start) ? e.start - t0 : 0) + e.ms));
113 let rows = "";
114 for (const e of shown) {
115 const off = Number.isFinite(e.start) ? e.start - t0 : 0;
116 const x = (off / span) * WIDTH;
117 const status = e.status === 0 ? "—" : String(e.status);
118 const detail = headerList("Request headers", e.requestHeaders) + headerList("Response headers", e.responseHeaders);
119 const line = detail === ""
120 ? escapeHtml(e.url)
121 : `<details><summary>${escapeHtml(e.url)}</summary>${detail}</details>`;
122 // The bar sits under the URL, so a phone's sideways scroll never hides it.
123 const url = line + bar(x, (e.ms / span) * WIDTH);
124 rows += `<tr><td>${escapeHtml(e.method)}</td><td>${escapeHtml(status)}${e.statusText ? ` <span class="muted">${escapeHtml(e.statusText)}</span>` : ""}</td><td class="har-url">${url}</td><td>${e.bytes === undefined ? "—" : escapeHtml(fileSize(e.bytes))}</td><td>${escapeHtml(duration(e.ms))}</td></tr>`;
125 }
126 const left = parsed.entries.length - shown.length;
127 const more = left > 0
128 ? `<p class="muted small">Showing ${ROW_CAP.toLocaleString()} of ${parsed.entries.length.toLocaleString()} requests, ${left.toLocaleString()} left out. The rest is in the raw file.</p>`
129 : "";
130 const name = input.path.slice(input.path.lastIndexOf("/") + 1);
131 const content = html`<div class="har-view"><table class="table" data-dense aria-label="${name} requests">
132 <thead><tr><th scope="col">Method</th><th scope="col">Status</th><th scope="col">URL and waterfall (0 to ${duration(span)})</th><th scope="col">Size</th><th scope="col">Time</th></tr></thead>
133 <tbody>${raw(rows)}</tbody>
134 </table>${raw(more)}</div>`;
135 const n = parsed.entries.length;
outdated

No TUI screen for .har (principle: both doors). Add one in src/tui/screens/workspace.ts and test it.

136 return { content, lines: 0, summary: `${n.toLocaleString()} ${n === 1 ? "request" : "requests"} · ${fileSize(input.size)}` };
137 }
src/web/render/kinds.ts +3
@@ -32,6 +32,7 @@ export type FileKind =
3232 | "log"
3333 | "subtitles"
3434 | "gpx"
35 | "har"
3536 | "geojson"
3637 | "calendar"
3738 | "contacts"
@@ -73,6 +74,7 @@ export const RENDER_CAP: Readonly<Record<FileKind, number>> = {
7374 log: 1 * MB,
7475 subtitles: 5 * MB,
7576 gpx: 20 * MB,
77 har: 5 * MB,
7678 geojson: 5 * MB,
7779 calendar: 5 * MB,
7880 contacts: 5 * MB,
@@ -198,6 +200,7 @@ const TEXTUAL: Readonly<Record<string, FileKind>> = {
198200 srt: "subtitles",
199201 vtt: "subtitles",
200202 gpx: "gpx",
203 har: "har",
201204 geojson: "geojson",
202205 ics: "calendar",
203206 vcf: "contacts",
src/web/render/renderers.ts +2
@@ -17,6 +17,7 @@ import { csv } from "./csv.ts";
1717 import { geojson } from "./geojson.ts";
1818 import { gcode } from "./gcode.ts";
1919 import { gpx } from "./gpx.ts";
20 import { har } from "./har.ts";
2021 import { font } from "./font.ts";
2122 import { ics } from "./ics.ts";
2223 import { junit } from "./junit.ts";
@@ -93,6 +94,7 @@ export const RENDERERS: Partial<Readonly<Record<FileKind, Renderer>>> = {
9394 log,
9495 subtitles,
9596 gpx,
97 har,
9698 gcode,
9799 geojson,
98100 calendar: ics,
src/web/style/content.ts +3
@@ -513,6 +513,9 @@ form.inline { display: inline; margin-left: auto; }
513513 .gpx-view { margin: 0; }
514514 .gpx-view svg { display: block; max-width: 100%; height: auto; margin: 0 auto; color: var(--fg); }
515515 .gpx-view .status-rows { margin-top: 12px; }
516 .har-view { overflow-x: auto; }
517 .har-view .har-bar { display: block; width: 100%; height: 10px; margin-top: 4px; color: var(--muted-fg); }
518 .har-view .har-url { overflow-wrap: anywhere; min-width: 200px; }
516519 .gj-view { margin: 0; }
517520 .gj-view svg { display: block; max-width: 100%; height: auto; margin: 0 auto; color: var(--fg); }
518521 .gj-table { overflow-x: auto; max-width: 100%; }
test/render-har.test.ts added +115
@@ -0,0 +1,115 @@
1 /** HAR files as a request waterfall: a fixture by hand, a bad file, hostile text, the row cap. */

Missing tests: header cap and its 'more headers' line, 2,000-char text cap, statusText escaping, and the terminal fallback to source for a malformed HAR. Also remove the stale not.toContain("aria-label=\"starts") assertion.

2 import { describe, expect, test } from "bun:test";
3 import { harTable } from "../src/tui/screens/workspace.ts";
4 import { har, HEADER_CAP, parseHar, ROW_CAP } from "../src/web/render/har.ts";
5 import { fileKind } from "../src/web/render/kinds.ts";
6 import { RENDERERS } from "../src/web/render/renderers.ts";
7 import { content } from "../src/web/style/content.ts";
8
9 const enc = (s: string): Uint8Array => new TextEncoder().encode(s);
10 const draw = (text: string) => har({ path: "a/s.har", size: text.length, body: enc(text), media: "/m", download: "/d" });
11 const entry = (url: string, start: string, time: number, extra: object = {}) => ({
12 startedDateTime: start,
13 time,
14 request: { method: "get", url, headers: [{ name: "Accept", value: "*/*" }] },
15 response: { status: 200, statusText: "OK", content: { size: 2048 }, bodySize: 100, headers: [] },
16 ...extra,
17 });
18 const file = (...entries: object[]) => JSON.stringify({ log: { version: "1.2", entries } });
19
20 describe("HAR files", () => {
21 test("the registry names .har and a renderer draws it", () => {
22 expect(fileKind("x/a.HAR", enc("{}"))).toBe("har");
23 expect(RENDERERS.har).toBe(har);
24 });
25
26 test("rows carry method, status, size, time and a bar on the shared timeline", () => {
27 const r = draw(file(entry("https://a.test/", "2026-01-01T00:00:00.000Z", 250), entry("https://a.test/x.js", "2026-01-01T00:00:00.500Z", 500)));
28 expect(r.fallback).toBeUndefined();
29 expect(r.summary).toContain("2 requests");
30 const out = String(r.content);
31 expect(out).toContain("<td>GET</td>");
32 expect(out).toContain("250 ms");
33 expect(out).toContain("2.0 KB");
34 // span is 1000 ms of 200 units: the second bar starts at 100 and is 100 wide.
35 expect(out).toContain('x="100.00" y="0" width="100.00"');
36 expect(out).toContain('aria-hidden="true"');
37 expect(out).toContain('x="0.00" y="0" width="50.00"');
38 expect(out).not.toContain("<script");
39 });
40
41 test("a malformed file falls back to source with a reason", () => {
42 expect(draw("{not json").fallback).toContain("not valid JSON");
43 expect(draw("{}").fallback).toContain("log.entries");
44 expect(draw(file()).fallback).toContain("no requests");
45 expect(parseHar("[1]")).toEqual({ error: "it has no log.entries list" });
46 });
47
48 test("a script tag in a URL or header is text, never markup", () => {
49 const bad = "https://x.test/<script>alert(1)</script>";
50 const e = entry(bad, "2026-01-01T00:00:00Z", 1);
51 (e.request.headers as object[]).push({ name: "X<b>", value: '"><img src=x onerror=alert(1)>' });
52 const out = String(draw(file(e)).content);
53 expect(out).not.toContain("<script");
54 expect(out).not.toContain("<img");
55 expect(out).toContain("&lt;script&gt;");
56 });
57
58 test("unknown size and no response are shown plainly", () => {
59 const e = entry("https://a.test/", "2026-01-01T00:00:00Z", 5, { response: { status: 0, content: { size: -1 }, bodySize: -1 } });
60 const out = String(draw(file(e)).content);
61 expect(out).toContain("<td>GET</td><td>—</td>");
62 expect(out).toContain("</td><td>—</td><td>5 ms</td>");
63 });
64
65 test("over the header cap, the rest is counted in words", () => {
66 const e = entry("https://a.test/", "2026-01-01T00:00:00Z", 1);
67 e.request.headers = Array.from({ length: HEADER_CAP + 4 }, (_, i) => ({ name: `X-H${i}`, value: "v" }));
68 const out = String(draw(file(e)).content);
69 expect(out.match(/<li>/g)?.length).toBe(HEADER_CAP);
70 expect(out).toContain(`X-H${HEADER_CAP - 1}`);
71 expect(out).not.toContain(`X-H${HEADER_CAP}<`);
72 expect(out).toContain("4 more headers not shown.");
73 });
74
75 test("a field over 2,000 characters is cut at 2,000", () => {
76 const long = `https://a.test/${"a".repeat(3000)}`;
77 const parsed = parseHar(file(entry(long, "2026-01-01T00:00:00Z", 1)));
78 if ("error" in parsed) throw new Error(parsed.error);
79 expect(parsed.entries[0]!.url.length).toBe(2000);
80 const out = String(draw(file(entry(long, "2026-01-01T00:00:00Z", 1))).content);
81 expect(out).toContain(`https://a.test/${"a".repeat(1985)}<`);
82 expect(out).not.toContain("a".repeat(1986));
83 });
84
85 test("a script tag in the status text is escaped", () => {
86 const e = entry("https://a.test/", "2026-01-01T00:00:00Z", 1);
87 e.response.statusText = "<script>alert(1)</script>";
88 const out = String(draw(file(e)).content);
89 expect(out).not.toContain("<script");
90 expect(out).toContain('<span class="muted">&lt;script&gt;alert(1)&lt;/script&gt;</span>');
91 });
92
93 test("over the row cap, rows are cut and the count says how many", () => {
94 const many = Array.from({ length: ROW_CAP + 3 }, (_, i) => entry(`https://a.test/${i}`, "2026-01-01T00:00:00Z", 1));
95 const out = String(draw(file(...many)).content);
96 expect(out.match(/<tr>/g)?.length).toBe(ROW_CAP + 1);
97 expect(out).toContain("3 left out");
98 });
99
100 test("the terminal door draws no table for a malformed HAR, so the workspace shows its source", () => {
101 // null is the workspace's signal to fall back to the source view (src/tui/screens/workspace.ts).
102 expect(harTable('{"log": {"entries": [1, ')).toBeNull();
103 expect(harTable("not json")).toBeNull();
104 expect(harTable(file())).toBeNull();
105 const t = harTable(file(entry("https://a.test/", "2026-01-01T00:00:00Z", 1)));
106 expect(t?.header).toEqual(["Method", "Status", "URL", "Size", "Time"]);
107 expect(t?.rows[0]?.slice(0, 3)).toEqual(["GET", "200", "https://a.test/"]);
108 });
109 });
110
111 test("the timing bar is data, so it is drawn in a quiet colour, never the accent (DESIGN principle 5)", () => {
112 const rule = content.match(/\.har-view \.har-bar \{[^}]*\}/)?.[0] ?? "";
113 expect(rule).toContain("color: var(--muted-fg)");
114 expect(rule).not.toContain("--accent");
115 });
test/render-kinds.test.ts +1
@@ -81,6 +81,7 @@ const SAMPLES: Record<string, [Uint8Array, FileKind, string]> = {
8181 ics: [enc("BEGIN:VCALENDAR\nEND:VCALENDAR\n"), "calendar", "application/octet-stream"],
8282 gcode: [enc("G28\nG1 X1 E1\n"), "gcode", "application/octet-stream"],
8383 gco: [enc("G28\n"), "gcode", "application/octet-stream"],
84 har: [enc(`{"log":{"entries":[]}}`), "har", "application/octet-stream"],
8485 sql: [enc("CREATE TABLE a (id int);\n"), "sql", "application/octet-stream"],
8586 sarif: [enc('{"runs":[]}'), "sarif", "application/octet-stream"],
8687 lcov: [enc("SF:a.ts\nend_of_record\n"), "lcov", "application/octet-stream"],
test/tui-workspace.test.ts +35
@@ -740,6 +740,41 @@ describe("the terminal workspace", () => {
740740 }
741741 });
742742
743 test("a .har opens as its requests table, sharing the web parser, and s flips to the source", async () => {
744 const made = await createRepo(forge, alice, { name: "captures", visibility: "public", description: "har" });
745 if (!made.ok) throw new Error("repo failed");
746 await commitToBranch(forge, alice, made.value, {
747 message: "seed",
748 files: [
749 {
750 path: "s.har",
751 content: JSON.stringify({
752 log: { entries: [{ startedDateTime: "2026-01-01T00:00:00Z", time: 1230, request: { method: "post", url: "https://example.test/api" }, response: { status: 201, content: { size: 2048 } } }] },
753 }),
754 },
755 { path: "bad.har", content: '{"log": {"entries": [1, ' },
756 ],
757 });
758 const h = boot();
759 try {
760 await h.settle();
761 await open(h, "captures");
762 // A malformed HAR is not a table: the workspace shows its source as it stands.
763 await openFile(h, "bad.har");
764 expect(h.text()).toContain('{"log": {"entries": [1,');
765 expect(h.text()).not.toContain("Method");
766 await openFile(h, "s.har");
767 expect(h.text()).toContain("POST");
768 expect(h.text()).toContain("201");
769 expect(h.text()).toContain("https://example.test/api");
770 expect(h.text()).toContain("1.23 s");
771 await h.press("s");
772 expect(h.text()).toContain("startedDateTime");
773 } finally {
774 h.stop();
775 }
776 });
777
743778 test("tables: search, edit, the caps in the status line, a ; file, and s on a broken one", async () => {
744779 const made = await createRepo(forge, alice, {
745780 name: "sheets",

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs