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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/11108-an-openapi-document-in-a-repository-open → main 5 commits fork 3 threads · 3 unresolved

  • Merged. scout/11108-an-openapi-document-in-a-repository-open was merged into main as c4c83b1.

Review

Sign in to review this change.
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
3939 ## JUnit results (.junit.xml, TEST-*.xml)
4040
4141 `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";
6060 import { gcodeFacts, parseGcode } from "../../web/render/gcode.ts";
6161 import { gpxFacts, parseGpx } from "../../web/render/gpx.ts";
6262 import { CASE_CAP, parseJunit } from "../../web/render/junit.ts";
63 import { parseOpenApi } from "../../web/render/openapi.ts";
6364 import { RESULT_CAP, parseSarif } from "../../web/render/sarif.ts";
6465 import { RECORD_CAP, parseLcov, worstFirst, type Ratio } from "../../web/render/lcov.ts";
6566 import { EVENT_CAP, parseIcs } from "../../web/render/ics.ts";
@@ -434,6 +435,23 @@ function lcovTable(source: string): Table | string {
434435 return { header: ["File", "Lines", "Functions", "Branches"], rows, more: sorted.length - rows.length, delimiter: ",", columns: 4 };
435436 }
436437
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
437455 /** A JUnit report, by the web's own rule (file name, or root element). */
438456 function isJunitPath(path: string, source: Uint8Array): boolean {
439457 return /\.xml$/i.test(path) && fileKind(path, source.subarray(0, 4096)) === "junit";
@@ -653,6 +671,7 @@ class Tab {
653671 this.lines = kind.lines;
654672 }
655673 }
674 const api = isOpenApiPath(this.path) ? openapiTable(text, this.path) : null;
656675 if (isSubtitlePath(this.path)) {
657676 const subs = subtitleTable(new TextDecoder().decode(source));
658677 this.table = subs.table;
@@ -680,6 +699,10 @@ class Tab {
680699 this.table = typeof coverage === "string" ? null : coverage;
681700 this.fault = typeof coverage === "string" ? coverage : "";
682701 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);
683706 } else if (isJunitPath(this.path, source)) {
684707 const results = junitTable(new TextDecoder().decode(source));
685708 this.table = typeof results === "string" ? null : results;
@@ -2801,7 +2824,7 @@ export function workspaceScreen(
28012824 t.tabular && t.table !== null
28022825 ? t.raw !== null && isJunitPath(t.path, t.raw)
28032826 ? 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))
28052828 : `${plural(
28062829 t.reading ? t.doc.length : t.plain.length,
28072830 "line",
@@ -3135,9 +3158,9 @@ function drawCode(s: Surface, r: Rect, c: Tokens, t: Tab): void {
31353158 }
31363159
31373160 /** `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 {
31393162 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")}${
31413164 unread > 0 ? ` · ${unread} unreadable, check their arrow lines` : ""
31423165 }${
31433166 table.more > 0
src/web/render/kinds.ts +7
@@ -40,6 +40,7 @@ export type FileKind =
4040 | "sarif"
4141 | "lcov"
4242 | "junit"
43 | "openapi"
4344 | "text"
4445 | "binary";
4546
@@ -78,6 +79,7 @@ export const RENDER_CAP: Readonly<Record<FileKind, number>> = {
7879 sarif: 5 * MB,
7980 lcov: 5 * MB,
8081 junit: 5 * MB,
82 openapi: 1 * MB,
8183 text: 1 * MB,
8284 binary: 0,
8385 };
@@ -203,6 +205,9 @@ const TEXTUAL: Readonly<Record<string, FileKind>> = {
203205 lcov: "lcov",
204206 };
205207
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
206211 /** `lcov.info`: the name `lcov` and `bun test` write, whatever its extension. */
207212 const isLcovName = (path: string): boolean => /(^|\/)lcov\.info$/i.test(path);
208213
@@ -273,6 +278,7 @@ export function fileKind(path: string, head: Uint8Array): FileKind {
273278 if (looksBinary(head)) return "binary";
274279 if (isLcovName(path)) return "lcov";
275280 if (isJunit(path, head)) return "junit";
281 if (isOpenApiName(path)) return "openapi";
276282 return TEXTUAL[extension(path)] ?? "text";
277283 }
278284
@@ -287,6 +293,7 @@ export const needsPaths = (kind: FileKind): boolean => kind === "sarif" || kind
287293 export function kindByName(path: string): FileKind {
288294 const ext = extension(path);
289295 if (isLcovName(path)) return "lcov";
296 if (isOpenApiName(path)) return "openapi";
290297 return MEDIA[ext]?.kind ?? TEXTUAL[ext] ?? "text";
291298 }
292299
src/web/render/openapi.ts added +121
@@ -0,0 +1,121 @@
1 /**

Principle 7 and 1: drop the empty Status column and put deprecated in 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.

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";
2828 import { notebook } from "./notebook.ts";
2929 import { patch } from "./patch.ts";
3030 import { pdf } from "./pdf.ts";
31 import { openapi } from "./openapi.ts";
3132 import { lcov } from "./lcov.ts";
3233 import { sarif } from "./sarif.ts";
3334 import { sqlite } from "./sqlite.ts";
@@ -97,6 +98,7 @@ export const RENDERERS: Partial<Readonly<Record<FileKind, Renderer>>> = {
9798 sarif,
9899 lcov,
99100 junit,
101 openapi,
100102 mail: eml,
101103 archive,
102104 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("&lt;img src=x onerror=alert(1)&gt;");
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", () => {
595595 }
596596 });
597597
598 test("an openapi.json opens as its operations, and s shows the source", async () => {

The title promises that s shows the source, but the test never presses s. Add the key and assert the raw text.

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
598619 test("a JUnit report opens as its totals and failing cases, sharing the web parser", async () => {
599620 const made = await createRepo(forge, alice, { name: "junitrepo", visibility: "public", description: "tests" });
600621 if (!made.ok) throw new Error("repo failed");

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs