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
Check · Review · Land · Live — Pushed since the last review.
torque/forge:torque/10936-a-har-file-in-a-repository-opens-as-a-re → main 12 commits fork 6 threads · 6 unresolved
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 | ||
| 32 | 32 | |
| 33 | 33 | `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. |
| 34 | 34 | |
| 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 | ||
| 35 | 39 | ## SARIF findings (.sarif) |
| 36 | 40 | |
| 37 | 41 | `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 | |
| 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"; | ||
| 58 | 58 | import { MAX_COLUMNS, parseCsv, type Table } from "../../text/csv.ts"; |
| 59 | 59 | import { parseGeoJson, propertyRows } from "../../web/render/geojson.ts"; |
| 60 | 60 | 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"; | |
| 61 | 62 | import { gpxFacts, parseGpx } from "../../web/render/gpx.ts"; |
| 62 | 63 | import { CASE_CAP, parseJunit } from "../../web/render/junit.ts"; |
| 63 | 64 | import { parseOpenApi } from "../../web/render/openapi.ts"; |
| @@ -421,6 +422,31 @@ function gpxTable(source: string, path: string): Table | null { | ||
| 421 | 422 | return { header: ["Fact", "Value"], rows, more: 0, delimiter: ",", columns: 2 }; |
| 422 | 423 | } |
| 423 | 424 | |
| 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 | ||
| 424 | 450 | /** `.sarif`: a scanner's findings, drawn as a table of level, rule, location and message. */ |
| 425 | 451 | function isSarifPath(path: string): boolean { |
| 426 | 452 | return /\.sarif$/i.test(path); |
| @@ -727,6 +753,9 @@ class Tab { | ||
| 727 | 753 | } else if (isGpxPath(this.path)) { |
| 728 | 754 | this.table = gpxTable(new TextDecoder().decode(source), this.path); |
| 729 | 755 | 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); | |
| 730 | 759 | } else if (isSarifPath(this.path)) { |
| 731 | 760 | const findings = sarifTable(new TextDecoder().decode(source)); |
| 732 | 761 | 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 = | ||
| 32 | 32 | | "log" |
| 33 | 33 | | "subtitles" |
| 34 | 34 | | "gpx" |
| 35 | | "har" | |
| 35 | 36 | | "geojson" |
| 36 | 37 | | "calendar" |
| 37 | 38 | | "contacts" |
| @@ -73,6 +74,7 @@ export const RENDER_CAP: Readonly<Record<FileKind, number>> = { | ||
| 73 | 74 | log: 1 * MB, |
| 74 | 75 | subtitles: 5 * MB, |
| 75 | 76 | gpx: 20 * MB, |
| 77 | har: 5 * MB, | |
| 76 | 78 | geojson: 5 * MB, |
| 77 | 79 | calendar: 5 * MB, |
| 78 | 80 | contacts: 5 * MB, |
| @@ -198,6 +200,7 @@ const TEXTUAL: Readonly<Record<string, FileKind>> = { | ||
| 198 | 200 | srt: "subtitles", |
| 199 | 201 | vtt: "subtitles", |
| 200 | 202 | gpx: "gpx", |
| 203 | har: "har", | |
| 201 | 204 | geojson: "geojson", |
| 202 | 205 | ics: "calendar", |
| 203 | 206 | vcf: "contacts", |
src/web/render/renderers.ts +2
| @@ -17,6 +17,7 @@ import { csv } from "./csv.ts"; | ||
| 17 | 17 | import { geojson } from "./geojson.ts"; |
| 18 | 18 | import { gcode } from "./gcode.ts"; |
| 19 | 19 | import { gpx } from "./gpx.ts"; |
| 20 | import { har } from "./har.ts"; | |
| 20 | 21 | import { font } from "./font.ts"; |
| 21 | 22 | import { ics } from "./ics.ts"; |
| 22 | 23 | import { junit } from "./junit.ts"; |
| @@ -93,6 +94,7 @@ export const RENDERERS: Partial<Readonly<Record<FileKind, Renderer>>> = { | ||
| 93 | 94 | log, |
| 94 | 95 | subtitles, |
| 95 | 96 | gpx, |
| 97 | har, | |
| 96 | 98 | gcode, |
| 97 | 99 | geojson, |
| 98 | 100 | calendar: ics, |
src/web/style/content.ts +3
| @@ -513,6 +513,9 @@ form.inline { display: inline; margin-left: auto; } | ||
| 513 | 513 | .gpx-view { margin: 0; } |
| 514 | 514 | .gpx-view svg { display: block; max-width: 100%; height: auto; margin: 0 auto; color: var(--fg); } |
| 515 | 515 | .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; } | |
| 516 | 519 | .gj-view { margin: 0; } |
| 517 | 520 | .gj-view svg { display: block; max-width: 100%; height: auto; margin: 0 auto; color: var(--fg); } |
| 518 | 521 | .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 |
||
| 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("<script>"); | |
| 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"><script>alert(1)</script></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]> = { | ||
| 81 | 81 | ics: [enc("BEGIN:VCALENDAR\nEND:VCALENDAR\n"), "calendar", "application/octet-stream"], |
| 82 | 82 | gcode: [enc("G28\nG1 X1 E1\n"), "gcode", "application/octet-stream"], |
| 83 | 83 | gco: [enc("G28\n"), "gcode", "application/octet-stream"], |
| 84 | har: [enc(`{"log":{"entries":[]}}`), "har", "application/octet-stream"], | |
| 84 | 85 | sql: [enc("CREATE TABLE a (id int);\n"), "sql", "application/octet-stream"], |
| 85 | 86 | sarif: [enc('{"runs":[]}'), "sarif", "application/octet-stream"], |
| 86 | 87 | 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", () => { | ||
| 740 | 740 | } |
| 741 | 741 | }); |
| 742 | 742 | |
| 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 | ||
| 743 | 778 | test("tables: search, edit, the caps in the status line, a ; file, and s on a broken one", async () => { |
| 744 | 779 | const made = await createRepo(forge, alice, { |
| 745 | 780 | name: "sheets", |
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).