Public: anyone can read this; the crew can change it.
An OpenAPI document in a repository opens as its operations: method, path and summary per row, grouped by tag #11123
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/11108-an-openapi-document-in-a-repository-open → main 5 commits fork 3 threads · 3 unresolved
8 files
changed +303
−3
docs/design/areas/render.md +4
| @@ -39,3 +39,7 @@ A .zip, .tar or .tar.gz is drawn by `src/web/render/archive-view.ts` as the exis | ||
| 39 | 39 | ## JUnit results (.junit.xml, TEST-*.xml) |
| 40 | 40 | |
| 41 | 41 | `src/web/render/junit.ts` draws the totals as a plain line, opened by one sentence of what the file is, then per suite an `h3`, one existing `.table` (`data-dense`) of its failing cases with each body in a `pre` below, and the passing cases inside one native `details`. `.junit-view` is only a hook on the wrapper and has no rule. The XML reader is its own: no DOCTYPE, no entity but the five predefined, at most 2,000 cases drawn (failing first). How it works: `docs/features/file-junit.md`. |
| 42 | ||
| 43 | ## OpenAPI operations (openapi.json, *.openapi.yaml) | |
| 44 | ||
| 45 | `src/web/render/openapi.ts` draws one `h3` per tag and under it one existing `.table` (`data-dense`) of method, path, summary and a `deprecated` mark; the cap line is a `muted small` paragraph. `.openapi-view` is only a hook on the wrapper and has no rule. How it works: `docs/features/file-openapi.md`. |
docs/features/file-openapi.md added +13
| @@ -0,0 +1,13 @@ | ||
| 1 | # An OpenAPI document opens as its operations | |
| 2 | ||
| 3 | `openapi.json`, `openapi.yaml`, `openapi.yml` and `*.openapi.{json,yaml,yml}` open as the API they describe, not as the schema; Source is one tap away (`?view=source`). | |
| 4 | ||
| 5 | - **Is it OpenAPI?** The top-level `openapi` starts with `3.`, or `swagger` is `2.0`. A file under one of those names that parses as anything else keeps the config view it always had; one that says `openapi:` or `swagger:` at the top but does not parse shows its source with the reason. Past 500 operations the first 500 are drawn and one line counts the rest. | |
| 6 | - **One table per tag**, in the order the document's `tags` list gives, then tags only operations name, then untagged operations last. One row per operation: method, path, summary, and `deprecated` when marked. An operation with several tags is listed under its first. | |
| 7 | - **Cap:** at most 500 operations are drawn, cut while reading, then one line `N more operations not shown`. Files over 1 MB stay source. | |
| 8 | - **Fallback:** an OpenAPI document with no `paths` object, or with no operations, shows Source with the reason. | |
| 9 | - Every string is escaped and cut to 300 characters. No script, no new CSS: it uses the `table` component. YAML is read by the parser `yaml-config.ts` uses; no dependency added. | |
| 10 | ||
| 11 | **Terminal:** the workspace opens it as Tag, Method, Path and Summary rows ("N operations" in the status line, deprecated in the summary), over the same `parseOpenApi`; `s` flips to the source. | |
| 12 | ||
| 13 | Code: `src/web/render/openapi.ts`, `src/web/render/kinds.ts`, `src/tui/screens/workspace.ts`. Tests: `test/render-openapi.test.ts`, `test/tui-workspace.test.ts`. |
src/tui/screens/workspace.ts +26 −3
| @@ -60,6 +60,7 @@ import { parseGeoJson, propertyRows } from "../../web/render/geojson.ts"; | ||
| 60 | 60 | import { gcodeFacts, parseGcode } from "../../web/render/gcode.ts"; |
| 61 | 61 | import { gpxFacts, parseGpx } from "../../web/render/gpx.ts"; |
| 62 | 62 | import { CASE_CAP, parseJunit } from "../../web/render/junit.ts"; |
| 63 | import { parseOpenApi } from "../../web/render/openapi.ts"; | |
| 63 | 64 | import { RESULT_CAP, parseSarif } from "../../web/render/sarif.ts"; |
| 64 | 65 | import { RECORD_CAP, parseLcov, worstFirst, type Ratio } from "../../web/render/lcov.ts"; |
| 65 | 66 | import { EVENT_CAP, parseIcs } from "../../web/render/ics.ts"; |
| @@ -434,6 +435,23 @@ function lcovTable(source: string): Table | string { | ||
| 434 | 435 | return { header: ["File", "Lines", "Functions", "Branches"], rows, more: sorted.length - rows.length, delimiter: ",", columns: 4 }; |
| 435 | 436 | } |
| 436 | 437 | |
| 438 | /** An OpenAPI document by file name; whether its content is one is `openapiTable`'s to say. */ | |
| 439 | function isOpenApiPath(path: string): boolean { | |
| 440 | return kindByName(path) === "openapi"; | |
| 441 | } | |
| 442 | ||
| 443 | /** | |
| 444 | * The operations of an OpenAPI document as a table, tag by tag; null when the file is not one, | |
| 445 | * the reason when it is but cannot be drawn. Shares the web's parser. | |
| 446 | */ | |
| 447 | function openapiTable(text: string, path: string): Table | string | null { | |
| 448 | const parsed = parseOpenApi(text, path); | |
| 449 | if (parsed === "not") return null; | |
| 450 | if ("error" in parsed) return `not drawn as an API: ${parsed.error}`; | |
| 451 | const rows = parsed.groups.flatMap((g) => g.ops.map((o) => [g.tag === "" ? "-" : g.tag, o.method, o.path, o.deprecated ? `${o.summary} (deprecated)`.trim() : o.summary])); | |
| 452 | return { header: ["Tag", "Method", "Path", "Summary"], rows, more: parsed.more, delimiter: ",", columns: 4 }; | |
| 453 | } | |
| 454 | ||
| 437 | 455 | /** A JUnit report, by the web's own rule (file name, or root element). */ |
| 438 | 456 | function isJunitPath(path: string, source: Uint8Array): boolean { |
| 439 | 457 | return /\.xml$/i.test(path) && fileKind(path, source.subarray(0, 4096)) === "junit"; |
| @@ -653,6 +671,7 @@ class Tab { | ||
| 653 | 671 | this.lines = kind.lines; |
| 654 | 672 | } |
| 655 | 673 | } |
| 674 | const api = isOpenApiPath(this.path) ? openapiTable(text, this.path) : null; | |
| 656 | 675 | if (isSubtitlePath(this.path)) { |
| 657 | 676 | const subs = subtitleTable(new TextDecoder().decode(source)); |
| 658 | 677 | this.table = subs.table; |
| @@ -680,6 +699,10 @@ class Tab { | ||
| 680 | 699 | this.table = typeof coverage === "string" ? null : coverage; |
| 681 | 700 | this.fault = typeof coverage === "string" ? coverage : ""; |
| 682 | 701 | if (this.table !== null) this.grid = layoutTable(this.table); |
| 702 | } else if (api !== null) { | |
| 703 | this.table = typeof api === "string" ? null : api; | |
| 704 | this.fault = typeof api === "string" ? api : ""; | |
| 705 | if (this.table !== null) this.grid = layoutTable(this.table); | |
| 683 | 706 | } else if (isJunitPath(this.path, source)) { |
| 684 | 707 | const results = junitTable(new TextDecoder().decode(source)); |
| 685 | 708 | this.table = typeof results === "string" ? null : results; |
| @@ -2801,7 +2824,7 @@ export function workspaceScreen( | ||
| 2801 | 2824 | t.tabular && t.table !== null |
| 2802 | 2825 | ? t.raw !== null && isJunitPath(t.path, t.raw) |
| 2803 | 2826 | ? plural(t.table.rows.length - 1 + t.table.more, "failing case") |
| 2804 | : tableStatus(t.table, t.unread, isSubtitlePath(t.path), isIcsPath(t.path), isGpxPath(t.path), isVcfPath(t.path), isGeoJsonPath(t.path), isJsonlPath(t.path), isSarifPath(t.path), isLcovPath(t.path)) | |
| 2827 | : tableStatus(t.table, t.unread, isSubtitlePath(t.path), isIcsPath(t.path), isGpxPath(t.path), isVcfPath(t.path), isGeoJsonPath(t.path), isJsonlPath(t.path), isSarifPath(t.path), isLcovPath(t.path), isOpenApiPath(t.path)) | |
| 2805 | 2828 | : `${plural( |
| 2806 | 2829 | t.reading ? t.doc.length : t.plain.length, |
| 2807 | 2830 | "line", |
| @@ -3135,9 +3158,9 @@ function drawCode(s: Surface, r: Rect, c: Tokens, t: Tab): void { | ||
| 3135 | 3158 | } |
| 3136 | 3159 | |
| 3137 | 3160 | /** `80 rows, first 50 shown · 3 more columns`: what the table leaves out. */ |
| 3138 | function tableStatus(table: Table, unread = 0, subs = false, events = false, facts = false, people = false, features = false, jsonl = false, findings = false, files = false): string { | |
| 3161 | function tableStatus(table: Table, unread = 0, subs = false, events = false, facts = false, people = false, features = false, jsonl = false, findings = false, files = false, operations = false): string { | |
| 3139 | 3162 | const rows = table.rows.length + table.more; |
| 3140 | return `${plural(rows, subs ? "cue" : events ? "event" : people ? "contact" : facts ? "fact" : features ? "feature" : findings ? "finding" : files ? "file" : "row")}${ | |
| 3163 | return `${plural(rows, subs ? "cue" : events ? "event" : people ? "contact" : facts ? "fact" : features ? "feature" : findings ? "finding" : files ? "file" : operations ? "operation" : "row")}${ | |
| 3141 | 3164 | unread > 0 ? ` · ${unread} unreadable, check their arrow lines` : "" |
| 3142 | 3165 | }${ |
| 3143 | 3166 | table.more > 0 |
src/web/render/kinds.ts +7
| @@ -40,6 +40,7 @@ export type FileKind = | ||
| 40 | 40 | | "sarif" |
| 41 | 41 | | "lcov" |
| 42 | 42 | | "junit" |
| 43 | | "openapi" | |
| 43 | 44 | | "text" |
| 44 | 45 | | "binary"; |
| 45 | 46 | |
| @@ -78,6 +79,7 @@ export const RENDER_CAP: Readonly<Record<FileKind, number>> = { | ||
| 78 | 79 | sarif: 5 * MB, |
| 79 | 80 | lcov: 5 * MB, |
| 80 | 81 | junit: 5 * MB, |
| 82 | openapi: 1 * MB, | |
| 81 | 83 | text: 1 * MB, |
| 82 | 84 | binary: 0, |
| 83 | 85 | }; |
| @@ -203,6 +205,9 @@ const TEXTUAL: Readonly<Record<string, FileKind>> = { | ||
| 203 | 205 | lcov: "lcov", |
| 204 | 206 | }; |
| 205 | 207 | |
| 208 | /** `openapi.json|yaml|yml` and `*.openapi.*`: the name of an OpenAPI document; its content decides whether it is drawn as one. */ | |
| 209 | const isOpenApiName = (path: string): boolean => /(^|\/|\.)openapi\.(json|ya?ml)$/i.test(path); | |
| 210 | ||
| 206 | 211 | /** `lcov.info`: the name `lcov` and `bun test` write, whatever its extension. */ |
| 207 | 212 | const isLcovName = (path: string): boolean => /(^|\/)lcov\.info$/i.test(path); |
| 208 | 213 | |
| @@ -273,6 +278,7 @@ export function fileKind(path: string, head: Uint8Array): FileKind { | ||
| 273 | 278 | if (looksBinary(head)) return "binary"; |
| 274 | 279 | if (isLcovName(path)) return "lcov"; |
| 275 | 280 | if (isJunit(path, head)) return "junit"; |
| 281 | if (isOpenApiName(path)) return "openapi"; | |
| 276 | 282 | return TEXTUAL[extension(path)] ?? "text"; |
| 277 | 283 | } |
| 278 | 284 | |
| @@ -287,6 +293,7 @@ export const needsPaths = (kind: FileKind): boolean => kind === "sarif" || kind | ||
| 287 | 293 | export function kindByName(path: string): FileKind { |
| 288 | 294 | const ext = extension(path); |
| 289 | 295 | if (isLcovName(path)) return "lcov"; |
| 296 | if (isOpenApiName(path)) return "openapi"; | |
| 290 | 297 | return MEDIA[ext]?.kind ?? TEXTUAL[ext] ?? "text"; |
| 291 | 298 | } |
| 292 | 299 |
src/web/render/openapi.ts added +121
| @@ -0,0 +1,121 @@ | ||
| 1 | /** | |
| 2 | * An OpenAPI document (`openapi.json|yaml|yml`, `*.openapi.json|yaml|yml`) as | |
| 3 | * the API it describes: one table per tag, one row per operation with its | |
| 4 | * method, path, summary and deprecated mark. Untagged operations go last; an | |
| 5 | * operation with several tags is listed under its first. | |
| 6 | * | |
| 7 | * A document is OpenAPI when its top-level `openapi` starts with `3.` or its | |
| 8 | * `swagger` is `2.0`. A file under one of these names that parses as | |
| 9 | * something else is drawn by the config renderer as before; a file that | |
| 10 | * declares itself OpenAPI but does not parse, or has no usable `paths`, falls | |
| 11 | * back to the source with the reason. The file is | |
| 12 | * parsed once (JSON natively, YAML by the parser `yaml-config.ts` uses) and | |
| 13 | * cut at `OPERATION_CAP` operations before anything is drawn. Every string | |
| 14 | * is escaped and cut to `TEXT_CAP` characters. | |
| 15 | */ | |
| 16 | ||
| 17 | import { escapeHtml, html, raw } from "../html.ts"; | |
| 18 | import { fileSize } from "../ui.ts"; | |
| 19 | import { plainOf } from "./config-diff.ts"; | |
| 20 | import { config } from "./config.ts"; | |
| 21 | import type { Rendered, RenderInput } from "./renderers.ts"; | |
| 22 | import { parseYaml } from "./yaml-config.ts"; | |
| 23 | ||
| 24 | export const OPERATION_CAP = 500; | |
| 25 | const TEXT_CAP = 300; | |
| 26 | const METHODS = ["get", "put", "post", "delete", "options", "head", "patch", "trace"] as const; | |
| 27 | ||
| 28 | export interface Operation { | |
| 29 | readonly method: string; | |
| 30 | readonly path: string; | |
| 31 | readonly summary: string; | |
| 32 | readonly deprecated: boolean; | |
| 33 | } | |
| 34 | export interface Group { | |
| 35 | /** Empty for the operations with no tag. */ | |
| 36 | readonly tag: string; | |
| 37 | readonly ops: Operation[]; | |
| 38 | } | |
| 39 | export type OpenApi = | |
| 40 | | { readonly groups: Group[]; readonly shown: number; readonly more: number } | |
| 41 | | { readonly error: string }; | |
| 42 | ||
| 43 | type Obj = Record<string, unknown>; | |
| 44 | const isObj = (v: unknown): v is Obj => typeof v === "object" && v !== null && !Array.isArray(v); | |
| 45 | const text = (v: unknown): string => (typeof v === "string" ? v.replace(/\s+/g, " ").trim().slice(0, TEXT_CAP) : ""); | |
| 46 | ||
| 47 | /** A top-level `openapi` or `swagger` key, quoted or not: the file says it is an API description. */ | |
| 48 | const CLAIMS = /^\s*\{?\s*["']?(openapi|swagger)["']?\s*:/m; | |
| 49 | ||
| 50 | /** The parsed value of the file, or undefined when it does not parse. */ | |
| 51 | function load(source: string, path: string): unknown { | |
| 52 | const body = source.replace(/^/, ""); | |
| 53 | if (/\.json$/i.test(path)) { | |
| 54 | try { | |
| 55 | return JSON.parse(body); | |
| 56 | } catch { | |
| 57 | return undefined; | |
| 58 | } | |
| 59 | } | |
| 60 | const y = parseYaml(body); | |
| 61 | return y.ok ? plainOf(y.value) : undefined; | |
| 62 | } | |
| 63 | ||
| 64 | /** Whether a parsed value is an OpenAPI 3.x or Swagger 2.0 document. */ | |
| 65 | const isSpec = (doc: unknown): doc is Obj => | |
| 66 | isObj(doc) && ((typeof doc.openapi === "string" && doc.openapi.startsWith("3.")) || doc.swagger === "2.0"); | |
| 67 | ||
| 68 | /** `"not"` when the file is not an OpenAPI document at all; otherwise its operations, capped. */ | |
| 69 | export function parseOpenApi(source: string, path: string): OpenApi | "not" { | |
| 70 | const doc = load(source, path); | |
| 71 | if (doc === undefined) return CLAIMS.test(source) ? { error: "it does not parse" } : "not"; | |
| 72 | if (!isSpec(doc)) return "not"; | |
| 73 | if (!isObj(doc.paths)) return { error: "it has no paths object" }; | |
| 74 | const order: string[] = []; | |
| 75 | if (Array.isArray(doc.tags)) for (const t of doc.tags) if (isObj(t) && text(t.name) !== "") order.push(text(t.name)); | |
| 76 | const byTag = new Map<string, Operation[]>(); | |
| 77 | let total = 0; | |
| 78 | let shown = 0; | |
| 79 | for (const [route, item] of Object.entries(doc.paths)) { | |
| 80 | if (!isObj(item)) continue; | |
| 81 | for (const method of METHODS) { | |
| 82 | const op = item[method]; | |
| 83 | if (!isObj(op)) continue; | |
| 84 | total++; | |
| 85 | if (shown >= OPERATION_CAP) continue; | |
| 86 | shown++; | |
| 87 | const tag = Array.isArray(op.tags) ? text(op.tags[0]) : ""; | |
| 88 | const list = byTag.get(tag) ?? []; | |
| 89 | list.push({ method: method.toUpperCase(), path: route.slice(0, TEXT_CAP), summary: text(op.summary), deprecated: op.deprecated === true }); | |
| 90 | byTag.set(tag, list); | |
| 91 | } | |
| 92 | } | |
| 93 | if (total === 0) return { error: "it describes no operations" }; | |
| 94 | const names = [...order.filter((t) => byTag.has(t)), ...[...byTag.keys()].filter((t) => t !== "" && !order.includes(t))]; | |
| 95 | if (byTag.has("")) names.push(""); | |
| 96 | return { groups: names.map((tag) => ({ tag, ops: byTag.get(tag)! })), shown, more: total - shown }; | |
| 97 | } | |
| 98 | ||
| 99 | export const moreLine = (n: number): string => `${n.toLocaleString()} more operation${n === 1 ? "" : "s"} not shown`; | |
| 100 | ||
| 101 | export function openapi(input: RenderInput): Rendered { | |
| 102 | const doc = parseOpenApi(new TextDecoder().decode(input.body), input.path); | |
| 103 | if (doc === "not") return config(input); | |
| 104 | if ("error" in doc) return { content: html``, lines: 0, fallback: `This file is not drawn as an API: ${doc.error}.` }; | |
| 105 | let out = ""; | |
| 106 | for (const g of doc.groups) { | |
| 107 | const label = g.tag === "" ? "Untagged" : g.tag; | |
| 108 | let rows = ""; | |
| 109 | for (const o of g.ops) | |
| 110 | rows += `<tr><td><code>${escapeHtml(o.method)}</code></td><td><code>${escapeHtml(o.path)}</code></td><td>${escapeHtml(o.summary)}${o.deprecated ? ` <span class="muted">deprecated</span>` : ""}</td></tr>`; | |
| 111 | out += `<h3>${escapeHtml(label)}</h3><table class="table" data-dense aria-label="${escapeHtml(label)} operations"><thead><tr><th scope="col">Method</th><th scope="col">Path</th><th scope="col">Summary</th></tr></thead><tbody>${rows}</tbody></table>`; | |
| 112 | } | |
| 113 | if (doc.more > 0) out += `<p class="muted small">${moreLine(doc.more)}</p>`; | |
| 114 | const n = doc.shown + doc.more; | |
| 115 | const lead = `The API this file describes: ${n.toLocaleString()} ${n === 1 ? "operation" : "operations"} by tag.`; | |
| 116 | return { | |
| 117 | content: html`<div class="openapi-view"><p>${lead}</p>${raw(out)}</div>`, | |
| 118 | lines: 0, | |
| 119 | summary: `${n.toLocaleString()} ${n === 1 ? "operation" : "operations"} · ${fileSize(input.size)}`, | |
| 120 | }; | |
| 121 | } |
src/web/render/renderers.ts +2
| @@ -28,6 +28,7 @@ import { model3d } from "./model3d.ts"; | ||
| 28 | 28 | import { notebook } from "./notebook.ts"; |
| 29 | 29 | import { patch } from "./patch.ts"; |
| 30 | 30 | import { pdf } from "./pdf.ts"; |
| 31 | import { openapi } from "./openapi.ts"; | |
| 31 | 32 | import { lcov } from "./lcov.ts"; |
| 32 | 33 | import { sarif } from "./sarif.ts"; |
| 33 | 34 | import { sqlite } from "./sqlite.ts"; |
| @@ -97,6 +98,7 @@ export const RENDERERS: Partial<Readonly<Record<FileKind, Renderer>>> = { | ||
| 97 | 98 | sarif, |
| 98 | 99 | lcov, |
| 99 | 100 | junit, |
| 101 | openapi, | |
| 100 | 102 | mail: eml, |
| 101 | 103 | archive, |
| 102 | 104 | font, |
test/render-openapi.test.ts added +109
| @@ -0,0 +1,109 @@ | ||
| 1 | /** | |
Add a test that the malformed fallback is the source view, the 300-char cut, and swagger 2.0 YAML. |
||
| 2 | * OpenAPI documents as their operations: 3.x JSON and YAML, 2.0, escaping, the cap, the fallbacks, the registry. | |
| 3 | * The terminal screen is in tui-workspace.test.ts. | |
| 4 | */ | |
| 5 | import { describe, expect, test } from "bun:test"; | |
| 6 | import { OPERATION_CAP, openapi, parseOpenApi } from "../src/web/render/openapi.ts"; | |
| 7 | import { fileKind, kindByName } from "../src/web/render/kinds.ts"; | |
| 8 | import { RENDERERS } from "../src/web/render/renderers.ts"; | |
| 9 | ||
| 10 | const enc = (s: string): Uint8Array => new TextEncoder().encode(s); | |
| 11 | const draw = (text: string, path = "openapi.json") => openapi({ path, size: text.length, body: enc(text), media: "/m", download: "/d" }); | |
| 12 | ||
| 13 | const JSON3 = JSON.stringify({ | |
| 14 | openapi: "3.0.3", | |
| 15 | tags: [{ name: "pets" }, { name: "store" }], | |
| 16 | paths: { | |
| 17 | "/pets": { get: { tags: ["pets"], summary: "List pets" }, post: { tags: ["pets"], summary: "Add a pet" } }, | |
| 18 | "/pets/{id}": { delete: { tags: ["pets"], summary: "Remove", deprecated: true } }, | |
| 19 | "/health": { get: { summary: "Alive" } }, | |
| 20 | "/orders": { get: { tags: ["store"], summary: "Orders" } }, | |
| 21 | }, | |
| 22 | }); | |
| 23 | ||
| 24 | describe("openapi", () => { | |
| 25 | test("the registry names the files and a renderer draws them", () => { | |
| 26 | for (const p of ["openapi.json", "api/openapi.yaml", "x/openapi.yml", "pets.openapi.json", "pets.OPENAPI.yaml"]) { | |
| 27 | expect(fileKind(p, enc("{}"))).toBe("openapi"); | |
| 28 | expect(kindByName(p)).toBe("openapi"); | |
| 29 | } | |
| 30 | expect(fileKind("package.json", enc("{}"))).toBe("config"); | |
| 31 | expect(fileKind("myopenapi.json", enc("{}"))).toBe("config"); | |
| 32 | expect(RENDERERS.openapi).toBe(openapi); | |
| 33 | }); | |
| 34 | ||
| 35 | test("3.x JSON: a table per tag in declared order, untagged last, deprecated marked", () => { | |
| 36 | const r = draw(JSON3); | |
| 37 | expect(r.fallback).toBeUndefined(); | |
| 38 | const h = String(r.content); | |
| 39 | expect(h.indexOf("<h3>pets</h3>")).toBeLessThan(h.indexOf("<h3>store</h3>")); | |
| 40 | expect(h.indexOf("<h3>store</h3>")).toBeLessThan(h.indexOf("<h3>Untagged</h3>")); | |
| 41 | expect(h).toContain(`<td><code>DELETE</code></td><td><code>/pets/{id}</code></td><td>Remove <span class="muted">deprecated</span></td>`); | |
| 42 | expect(h).toContain("<td><code>GET</code></td><td><code>/health</code></td><td>Alive</td></tr>"); | |
| 43 | expect(r.summary).toContain("5 operations"); | |
| 44 | }); | |
| 45 | ||
| 46 | test("3.x YAML", () => { | |
| 47 | const r = draw("openapi: 3.1.0\npaths:\n /a:\n get:\n summary: Fetch a\n tags: [x]\n", "pets.openapi.yaml"); | |
| 48 | expect(r.fallback).toBeUndefined(); | |
| 49 | expect(String(r.content)).toContain("<h3>x</h3>"); | |
| 50 | expect(String(r.content)).toContain("Fetch a"); | |
| 51 | }); | |
| 52 | ||
| 53 | test("Swagger 2.0", () => { | |
| 54 | const p = parseOpenApi('{"swagger":"2.0","paths":{"/x":{"get":{"summary":"s"}}}}', "openapi.json"); | |
| 55 | expect(p).toEqual({ groups: [{ tag: "", ops: [{ method: "GET", path: "/x", summary: "s", deprecated: false }] }], shown: 1, more: 0 }); | |
| 56 | expect(parseOpenApi('{"swagger":"1.2","paths":{}}', "openapi.json")).toBe("not"); | |
| 57 | }); | |
| 58 | ||
| 59 | test("summary, path and tag are escaped", () => { | |
| 60 | const evil = '<img src=x onerror=alert(1)>'; | |
| 61 | const r = draw(JSON.stringify({ openapi: "3.0.0", paths: { [`/${evil}`]: { get: { summary: evil, tags: [evil] } } } })); | |
| 62 | const h = String(r.content); | |
| 63 | expect(h).not.toContain("<img"); | |
| 64 | expect(h).toContain("<img src=x onerror=alert(1)>"); | |
| 65 | }); | |
| 66 | ||
| 67 | test("past the cap, 500 are drawn and one line counts the rest", () => { | |
| 68 | const paths: Record<string, unknown> = {}; | |
| 69 | for (let i = 0; i < OPERATION_CAP + 7; i++) paths[`/p${i}`] = { get: { summary: "s" } }; | |
| 70 | const r = draw(JSON.stringify({ openapi: "3.0.0", paths })); | |
| 71 | const h = String(r.content); | |
| 72 | expect(h.match(/<tr><td><code>GET/g)?.length).toBe(OPERATION_CAP); | |
| 73 | expect(h).toContain("7 more operations not shown"); | |
| 74 | expect(h).not.toContain("/p500<"); | |
| 75 | }); | |
| 76 | ||
| 77 | test("a spec with no paths or operations falls back to source with the reason", () => { | |
| 78 | expect(draw('{"openapi":"3.0.0"}').fallback).toContain("no paths"); | |
| 79 | expect(draw('{"openapi":"3.0.0","paths":{"/a":{"parameters":[]}}}').fallback).toContain("no operations"); | |
| 80 | }); | |
| 81 | ||
| 82 | test("a file that says it is OpenAPI but does not parse falls back to the source with the reason", () => { | |
| 83 | for (const [text, path] of [['{"openapi": "3.0.0", "paths": {', "openapi.json"], ["openapi: 3.0.0\npaths: [unclosed\n", "api.openapi.yaml"]] as const) { | |
| 84 | const r = draw(text, path); | |
| 85 | expect(r.fallback).toContain("does not parse"); | |
| 86 | expect(String(r.content)).not.toContain("openapi-view"); | |
| 87 | } | |
| 88 | }); | |
| 89 | ||
| 90 | test("a summary is cut at 300 characters", () => { | |
| 91 | const p = parseOpenApi(JSON.stringify({ openapi: "3.0.0", paths: { "/a": { get: { summary: "x".repeat(400) } } } }), "openapi.json"); | |
| 92 | if (p === "not" || "error" in p) throw new Error("not drawn"); | |
| 93 | expect(p.groups[0]!.ops[0]!.summary).toBe("x".repeat(300)); | |
| 94 | }); | |
| 95 | ||
| 96 | test("Swagger 2.0 YAML", () => { | |
| 97 | const r = draw("swagger: '2.0'\npaths:\n /y:\n get:\n summary: Why\n", "swagger.openapi.yml"); | |
| 98 | expect(r.fallback).toBeUndefined(); | |
| 99 | expect(String(r.content)).toContain("Why"); | |
| 100 | }); | |
| 101 | ||
| 102 | test("a file that is not OpenAPI, or is not an API description, is left to the config view", () => { | |
| 103 | expect(parseOpenApi('{"openapi":"2.5"}', "openapi.json")).toBe("not"); | |
| 104 | expect(parseOpenApi("{broken", "openapi.json")).toBe("not"); | |
| 105 | expect(parseOpenApi("[1, 2]", "openapi.json")).toBe("not"); | |
| 106 | const r = draw('{"name":"x"}'); | |
| 107 | expect(String(r.content)).not.toContain("openapi-view"); | |
| 108 | }); | |
| 109 | }); |
test/tui-workspace.test.ts +21
| @@ -595,6 +595,27 @@ describe("the terminal workspace", () => { | ||
| 595 | 595 | } |
| 596 | 596 | }); |
| 597 | 597 | |
| 598 | test("an openapi.json opens as its operations, and s shows the source", async () => { | |
The title promises that |
||
| 599 | const made = await createRepo(forge, alice, { name: "apidoc", visibility: "public", description: "api" }); | |
| 600 | if (!made.ok) throw new Error("repo failed"); | |
| 601 | const spec = JSON.stringify({ openapi: "3.0.0", paths: { "/pets": { get: { tags: ["pets"], summary: "List pets" } }, "/old": { post: { summary: "Gone", deprecated: true } } } }); | |
| 602 | await commitToBranch(forge, alice, made.value, { message: "seed", files: [{ path: "openapi.json", content: spec }] }); | |
| 603 | const h = boot(); | |
| 604 | try { | |
| 605 | await h.settle(); | |
| 606 | await open(h, "apidoc"); | |
| 607 | await openFile(h, "openapi.json"); | |
| 608 | expect(h.text()).toContain("/pets"); | |
| 609 | expect(h.text()).toContain("List pets"); | |
| 610 | expect(h.text()).toContain("Gone (deprecated)"); | |
| 611 | expect(h.text()).toContain("2 operations"); | |
| 612 | await h.press("s"); | |
| 613 | expect(h.text()).toContain('"openapi":"3.0.0"'); | |
| 614 | } finally { | |
| 615 | h.stop(); | |
| 616 | } | |
| 617 | }); | |
| 618 | ||
| 598 | 619 | test("a JUnit report opens as its totals and failing cases, sharing the web parser", async () => { |
| 599 | 620 | const made = await createRepo(forge, alice, { name: "junitrepo", visibility: "public", description: "tests" }); |
| 600 | 621 | if (!made.ok) throw new Error("repo failed"); |
Principle 7 and 1: drop the empty Status column and put
deprecatedin the summary cell; add a one-sentence lead above the tables. Also remove the stray blank lines at the end of the file and the doubled blank line after the OpenApi type. Malformed files under an OpenAPI name should show source with a reason, per box 2.