Public: anyone can read this; the crew can change it.
A `package.json` opens as its manifest: name and version, scripts, dependencies with their ranges, engines #11275
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/11208-a-package-json-opens-as-its-manifest-nam → main 8 commits fork 3 threads · 3 unresolved
9 files
changed +278
−5
docs/design/areas/render.md +4
| @@ -44,6 +44,10 @@ A .zip, .tar or .tar.gz is drawn by `src/web/render/archive-view.ts` as the exis | ||
| 44 | 44 | |
| 45 | 45 | `src/web/render/env.ts` draws one existing `.table` (`data-dense`): name, example, comment, with group headings as full-width `th` rows. `.env-view` is only a hook on the wrapper and has no rule. How it works: `docs/features/file-env.md`. |
| 46 | 46 | |
| 47 | ## Package manifest (package.json) | |
| 48 | ||
| 49 | `src/web/render/package-json.ts` draws an `h2`, a `muted small` facts line, then per section an `h3` and one existing `.table` (`data-dense`) in a `.table-scroll`; commands and ranges are `code.break`. `.package-view` is only a hook and has no rule. How it works: `docs/features/file-package-json.md`. | |
| 50 | ||
| 47 | 51 | ## OpenAPI operations (openapi.json, *.openapi.yaml) |
| 48 | 52 | |
| 49 | 53 | `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-package-json.md added +11
| @@ -0,0 +1,11 @@ | ||
| 1 | # A `package.json` opens as its manifest | |
| 2 | ||
| 3 | A file named exactly `package.json` opens as rows instead of raw text: name and version, one line of facts (`type`, `engines`, license, package manager), then **Scripts** (name, command in mono), **Dependencies**, **Dev dependencies**, and peer/optional dependencies, each with its range. Source is one tap away (`?view=source`). `package-lock.json` and other `.json` keep the config form. | |
| 4 | ||
| 5 | - Malformed JSON, JSON that is not an object, or a manifest with no name, facts or sections keeps the source view with no banner. | |
| 6 | - At most 500 rows per section, then `N more not shown`. Files over 1 MB keep Source. | |
| 7 | - Every cell is escaped; drawn on the server; no script, no new CSS (`.package-view` is a hook with no rule). Commands and ranges are `code.break` (wrap anywhere) in the existing `.table-scroll`, so a phone never gets a wide page. | |
| 8 | ||
| 9 | **Terminal:** the workspace opens the same rows (`packageTable` in `src/tui/screens/workspace.ts`, over the same `parsePackage`); `s` flips to the source. | |
| 10 | ||
| 11 | Code: `src/web/render/package-json.ts`, `src/web/render/kinds.ts`. Tests: `test/render-package-json.test.ts`, `test/tui-workspace.test.ts`. |
src/tui/screens/workspace.ts +20
| @@ -69,6 +69,7 @@ import { RECORD_CAP, parseLcov, worstFirst, type Ratio } from "../../web/render/ | ||
| 69 | 69 | import { EVENT_CAP, parseIcs } from "../../web/render/ics.ts"; |
| 70 | 70 | import { fileSize } from "../../web/ui.ts"; |
| 71 | 71 | import { parseEml } from "../../web/render/eml-parse.ts"; |
| 72 | import { parsePackage, ROW_CAP as PKG_ROW_CAP } from "../../web/render/package-json.ts"; | |
| 72 | 73 | import { parseEnv, ROW_CAP as ENV_ROW_CAP } from "../../web/render/env.ts"; |
| 73 | 74 | import { CARD_CAP, parseVcf } from "../../web/render/vcf-parse.ts"; |
| 74 | 75 | import { CUE_CAP, parseSubtitles } from "../../web/render/subtitles.ts"; |
| @@ -390,6 +391,22 @@ function envTable(source: string): Table | null { | ||
| 390 | 391 | return { header: ["Name", "Example", "Comment"], rows, more: doc.total - doc.rows.length, delimiter: ",", columns: 3 }; |
| 391 | 392 | } |
| 392 | 393 | |
| 394 | /** A `package.json` as Section/Name/Value rows; null when it is not a JSON object. Shares the web's parser. */ | |
| 395 | function packageTable(source: string): Table | null { | |
| 396 | const doc = parsePackage(source); | |
| 397 | if (doc === undefined) return null; | |
| 398 | const rows: string[][] = []; | |
| 399 | if (doc.name !== "") rows.push(["package", doc.name, doc.version]); | |
| 400 | if (doc.facts.length > 0) rows.push(["facts", doc.facts.join(" · "), ""]); | |
| 401 | let more = 0; | |
| 402 | for (const s of doc.sections) { | |
| 403 | for (const [k, v] of s.rows.slice(0, PKG_ROW_CAP)) rows.push([s.title, k, v]); | |
| 404 | more += s.total - s.rows.length; | |
| 405 | } | |
| 406 | if (rows.length === 0) return null; | |
| 407 | return { header: ["Section", "Name", "Value"], rows, more, delimiter: ",", columns: 3 }; | |
| 408 | } | |
| 409 | ||
| 393 | 410 | /** `.eml`: a message, drawn as rows of its headers, body and attachments. */ |
| 394 | 411 | function isEmlPath(path: string): boolean { |
| 395 | 412 | return /\.eml$/i.test(path); |
| @@ -770,6 +787,9 @@ class Tab { | ||
| 770 | 787 | } else if (isEnvExample(this.path)) { |
| 771 | 788 | this.table = envTable(new TextDecoder().decode(source)); |
| 772 | 789 | if (this.table !== null) this.grid = layoutTable(this.table); |
| 790 | } else if (kindByName(this.path) === "package") { | |
| 791 | this.table = packageTable(new TextDecoder().decode(source)); | |
| 792 | if (this.table !== null) this.grid = layoutTable(this.table); | |
| 773 | 793 | } else if (isEmlPath(this.path)) { |
| 774 | 794 | this.table = emlTable(source); |
| 775 | 795 | if (this.table !== null) this.grid = layoutTable(this.table); |
src/web/render/kinds.ts +7
| @@ -42,6 +42,7 @@ export type FileKind = | ||
| 42 | 42 | | "junit" |
| 43 | 43 | | "env" |
| 44 | 44 | | "openapi" |
| 45 | | "package" | |
| 45 | 46 | | "makefile" |
| 46 | 47 | | "dockerfile" |
| 47 | 48 | | "workflow" |
| @@ -86,6 +87,7 @@ export const RENDER_CAP: Readonly<Record<FileKind, number>> = { | ||
| 86 | 87 | junit: 5 * MB, |
| 87 | 88 | env: 1 * MB, |
| 88 | 89 | openapi: 1 * MB, |
| 90 | package: 1 * MB, | |
| 89 | 91 | makefile: 1 * MB, |
| 90 | 92 | dockerfile: 1 * MB, |
| 91 | 93 | workflow: 1 * MB, |
| @@ -223,6 +225,9 @@ export const isEnvExample = (path: string): boolean => | ||
| 223 | 225 | /** `openapi.json|yaml|yml` and `*.openapi.*`: the name of an OpenAPI document; its content decides whether it is drawn as one. */ |
| 224 | 226 | const isOpenApiName = (path: string): boolean => /(^|\/|\.)openapi\.(json|ya?ml)$/i.test(path); |
| 225 | 227 | |
| 228 | /** A manifest is named exactly `package.json`; `package-lock.json` and the rest stay config. */ | |
| 229 | const isPackageName = (path: string): boolean => /(^|\/)package\.json$/.test(path); | |
| 230 | ||
| 226 | 231 | /** `Makefile`, `GNUmakefile` and `*.mk`, by name. */ |
| 227 | 232 | const isMakefileName = (path: string): boolean => /(^|\/)(makefile|gnumakefile|[^/]+\.mk)$/i.test(path); |
| 228 | 233 | /** `Dockerfile`, `Containerfile` and `*.Dockerfile`, in any case. */ |
| @@ -303,6 +308,7 @@ export function fileKind(path: string, head: Uint8Array): FileKind { | ||
| 303 | 308 | if (isEnvExample(path)) return "env"; |
| 304 | 309 | if (isJunit(path, head)) return "junit"; |
| 305 | 310 | if (isOpenApiName(path)) return "openapi"; |
| 311 | if (isPackageName(path)) return "package"; | |
| 306 | 312 | if (isMakefileName(path)) return "makefile"; |
| 307 | 313 | if (isDockerfileName(path)) return "dockerfile"; |
| 308 | 314 | if (isWorkflowName(path)) return "workflow"; |
| @@ -322,6 +328,7 @@ export function kindByName(path: string): FileKind { | ||
| 322 | 328 | if (isLcovName(path)) return "lcov"; |
| 323 | 329 | if (isEnvExample(path)) return "env"; |
| 324 | 330 | if (isOpenApiName(path)) return "openapi"; |
| 331 | if (isPackageName(path)) return "package"; | |
| 325 | 332 | if (isMakefileName(path)) return "makefile"; |
| 326 | 333 | if (isDockerfileName(path)) return "dockerfile"; |
| 327 | 334 | if (isWorkflowName(path)) return "workflow"; |
src/web/render/package-json.ts added +107
| @@ -0,0 +1,107 @@ | ||
| 1 | /** | |
| 2 | * A `package.json` as its manifest: name and version, the facts in one line | |
| 3 | * (`type`, `engines`, `license`), then scripts, dependencies, devDependencies | |
| 4 | * and peer/optional dependencies as rows. Source is one tap away. | |
| 5 | * Pure over the text; a file that is not a JSON object is not drawn at all | |
| 6 | * (`parsePackage` returns undefined and `rendererFor` declines it: the source view, no banner); | |
| 7 | * so is a manifest with nothing to list. | |
| 8 | * Every cell is escaped by the template; at most `ROW_CAP` rows per section. | |
| 9 | */ | |
| 10 | ||
| 11 | import { html, type Html } from "../html.ts"; | |
| 12 | import { fileSize } from "../ui.ts"; | |
| 13 | import type { Rendered, RenderInput } from "./renderers.ts"; | |
| 14 | ||
| 15 | export const ROW_CAP = 500; | |
| 16 | ||
| 17 | export interface PackageSection { | |
| 18 | readonly title: string; | |
| 19 | /** Name and value (a command, or a version range), in the file's order. */ | |
| 20 | readonly rows: readonly (readonly [string, string])[]; | |
| 21 | /** Entries in the file, drawn or not. */ | |
| 22 | readonly total: number; | |
| 23 | } | |
| 24 | ||
| 25 | export interface PackageDoc { | |
| 26 | readonly name: string; | |
| 27 | readonly version: string; | |
| 28 | readonly description: string; | |
| 29 | /** `type: module`, `node >=20`, `MIT`: the one-line facts. */ | |
| 30 | readonly facts: readonly string[]; | |
| 31 | readonly sections: readonly PackageSection[]; | |
| 32 | } | |
| 33 | ||
| 34 | const isObject = (v: unknown): v is Record<string, unknown> => | |
| 35 | typeof v === "object" && v !== null && !Array.isArray(v); | |
| 36 | const str = (v: unknown): string => (typeof v === "string" ? v : ""); | |
| 37 | ||
| 38 | /** The string-valued entries of an object field; anything else is skipped. */ | |
| 39 | function section(title: string, v: unknown): PackageSection | undefined { | |
| 40 | if (!isObject(v)) return undefined; | |
| 41 | const all = Object.entries(v).filter((e): e is [string, string] => typeof e[1] === "string"); | |
| 42 | if (all.length === 0) return undefined; | |
| 43 | return { title, rows: all.slice(0, ROW_CAP), total: all.length }; | |
| 44 | } | |
| 45 | ||
| 46 | /** Read a manifest. Pure; never throws; undefined when the text is not a JSON object. */ | |
| 47 | export function parsePackage(source: string): PackageDoc | undefined { | |
| 48 | let raw: unknown; | |
| 49 | try { | |
| 50 | raw = JSON.parse(source.replace(/^\uFEFF/, "")); | |
| 51 | } catch { | |
| 52 | return undefined; | |
| 53 | } | |
| 54 | if (!isObject(raw)) return undefined; | |
| 55 | const facts: string[] = []; | |
| 56 | if (typeof raw["type"] === "string") facts.push(`type ${raw["type"]}`); | |
| 57 | if (isObject(raw["engines"])) | |
| 58 | for (const [k, v] of Object.entries(raw["engines"])) if (typeof v === "string") facts.push(`${k} ${v}`); | |
| 59 | if (typeof raw["license"] === "string") facts.push(raw["license"]); | |
| 60 | if (typeof raw["packageManager"] === "string") facts.push(raw["packageManager"]); | |
| 61 | const sections = [ | |
| 62 | section("Scripts", raw["scripts"]), | |
| 63 | section("Dependencies", raw["dependencies"]), | |
| 64 | section("Dev dependencies", raw["devDependencies"]), | |
| 65 | section("Peer dependencies", raw["peerDependencies"]), | |
| 66 | section("Optional dependencies", raw["optionalDependencies"]), | |
| 67 | ].filter((s): s is PackageSection => s !== undefined); | |
| 68 | const name = str(raw["name"]); | |
| 69 | // Nothing to show as rows: the source view, on the web and in the terminal alike. | |
| 70 | if (name === "" && facts.length === 0 && sections.length === 0) return undefined; | |
| 71 | return { name, version: str(raw["version"]), description: str(raw["description"]), facts, sections }; | |
| 72 | } | |
| 73 | ||
| 74 | /** | |
| 75 | * The manifest in a file's bytes, parsed once per body: `rendererFor` asks it | |
| 76 | * whether to draw at all (no: the source view, with no banner), and the | |
outdated
Principle 8/box 3: the fallback carries a message, so the source view shows a banner. The issue and your own doc say no banner. Drop the message. |
||
| 77 | * renderer is then handed the same bytes and reads the same answer. | |
| 78 | */ | |
| 79 | const parsed = new WeakMap<Uint8Array, PackageDoc | null>(); | |
| 80 | export function readPackage(body: Uint8Array): PackageDoc | undefined { | |
| 81 | let doc = parsed.get(body); | |
| 82 | if (doc === undefined) { | |
| 83 | doc = parsePackage(new TextDecoder().decode(body)) ?? null; | |
| 84 | parsed.set(body, doc); | |
| 85 | } | |
| 86 | return doc ?? undefined; | |
| 87 | } | |
| 88 | ||
| 89 | export function packageJson(input: RenderInput): Rendered { | |
| 90 | const doc = readPackage(input.body); | |
| 91 | // Only reached when called directly: `rendererFor` declines such a file first. | |
| 92 | if (doc === undefined) return { content: html``, lines: 0, fallback: "This file is not a JSON object to draw." }; | |
| 93 | const scripts = (s: PackageSection): boolean => s.title === "Scripts"; | |
| 94 | const blocks: Html[] = doc.sections.map((s) => { | |
| 95 | const rows = s.rows.map(([k, v]) => | |
| 96 | scripts(s) | |
| 97 | ? html`<tr><th scope="row"><code>${k}</code></th><td><code>${v}</code></td></tr>` | |
| 98 | : html`<tr><th scope="row" class="break">${k}</th><td><code class="break">${v}</code></td></tr>`, | |
| 99 | ); | |
| 100 | const more = s.total - s.rows.length; | |
| 101 | return html`<h3>${s.title}</h3><div class="table-scroll"><table class="table" data-dense aria-label="${s.title}"><thead><tr><th scope="col">Name</th><th scope="col">${scripts(s) ? "Command" : "Range"}</th></tr></thead><tbody>${rows}</tbody></table></div>${more > 0 ? html`<p class="muted small">${more} more not shown</p>` : html``}`; | |
| 102 | }); | |
| 103 | const title = doc.name === "" ? "package.json" : doc.version === "" ? doc.name : `${doc.name} ${doc.version}`; | |
| 104 | const content = html`<div class="package-view"><h2>${title}</h2>${doc.description !== "" ? html`<p>${doc.description}</p>` : html``}${doc.facts.length > 0 ? html`<p class="muted small">${doc.facts.join(" · ")}</p>` : html``}${blocks}</div>`; | |
| 105 | const n = doc.sections.reduce((a, s) => a + s.total, 0); | |
| 106 | return { content, lines: 0, summary: `${n} ${n === 1 ? "entry" : "entries"} · ${fileSize(input.size)}` }; | |
| 107 | } |
src/web/render/renderers.ts +8 −4
| @@ -29,6 +29,7 @@ import { makefile } from "./makefile.ts"; | ||
| 29 | 29 | import { markdown } from "./markdown.ts"; |
| 30 | 30 | import { model3d } from "./model3d.ts"; |
| 31 | 31 | import { notebook } from "./notebook.ts"; |
| 32 | import { packageJson, readPackage } from "./package-json.ts"; | |
| 32 | 33 | import { patch } from "./patch.ts"; |
| 33 | 34 | import { pdf } from "./pdf.ts"; |
| 34 | 35 | import { dockerfile } from "./dockerfile.ts"; |
| @@ -106,6 +107,7 @@ export const RENDERERS: Partial<Readonly<Record<FileKind, Renderer>>> = { | ||
| 106 | 107 | env: envView, |
| 107 | 108 | sql: sqlView, |
| 108 | 109 | openapi, |
| 110 | package: packageJson, | |
| 109 | 111 | makefile, |
| 110 | 112 | dockerfile, |
| 111 | 113 | workflow, |
| @@ -157,10 +159,12 @@ export function rendererFor( | ||
| 157 | 159 | // A kind may draw only some of its files: `config` draws JSON, YAML and |
| 158 | 160 | // TOML as a form, and leaves the rest (INI, .env …) their source. |
| 159 | 161 | // A log with no escape in it keeps the source view, exactly as plain text. |
| 160 | const render = | |
| 161 | (kind === "config" && !isFormConfig(path)) || (kind === "log" && blob.kind !== "too_large" && !hasEscape(blob.body)) | |
| 162 | ? undefined | |
| 163 | : renderers[kind]; | |
| 162 | // A package.json that is no manifest (malformed, or nothing to list) too. | |
| 163 | const declined = | |
outdated
Parses the whole file here and again in packageJson. Extract a helper, or let the renderer decline once, and break up this line. |
||
| 164 | (kind === "config" && !isFormConfig(path)) || | |
| 165 | (kind === "log" && blob.kind !== "too_large" && !hasEscape(blob.body)) || | |
| 166 | (kind === "package" && blob.kind !== "too_large" && readPackage(blob.body) === undefined); | |
| 167 | const render = declined ? undefined : renderers[kind]; | |
| 164 | 168 | if (render === undefined) return undefined; |
| 165 | 169 | return { render, body: blob.kind === "too_large" ? NO_BYTES : blob.body }; |
| 166 | 170 | } |
test/render-openapi.test.ts +1 −1
| @@ -27,7 +27,7 @@ describe("openapi", () => { | ||
| 27 | 27 | expect(fileKind(p, enc("{}"))).toBe("openapi"); |
| 28 | 28 | expect(kindByName(p)).toBe("openapi"); |
| 29 | 29 | } |
| 30 | expect(fileKind("package.json", enc("{}"))).toBe("config"); | |
| 30 | expect(fileKind("tsconfig.json", enc("{}"))).toBe("config"); | |
| 31 | 31 | expect(fileKind("myopenapi.json", enc("{}"))).toBe("config"); |
| 32 | 32 | expect(RENDERERS.openapi).toBe(openapi); |
| 33 | 33 | }); |
test/render-package-json.test.ts added +102
| @@ -0,0 +1,102 @@ | ||
| 1 | import { describe, expect, test } from "bun:test"; | |
| 2 | import { packageJson, parsePackage, ROW_CAP } from "../src/web/render/package-json.ts"; | |
| 3 | import { fileKind, kindByName } from "../src/web/render/kinds.ts"; | |
| 4 | import { RENDERERS, rendererFor } from "../src/web/render/renderers.ts"; | |
| 5 | import { renderBlob } from "../src/web/pages/repo.ts"; | |
| 6 | import type { RepoScope } from "../src/web/scope.ts"; | |
| 7 | ||
| 8 | const enc = (s: string) => new TextEncoder().encode(s); | |
| 9 | const draw = (text: string) => { | |
| 10 | const body = enc(text); | |
| 11 | const r = packageJson({ path: "package.json", size: body.length, body, media: "", download: "" }); | |
| 12 | return { html: String(r.content), summary: r.summary, fallback: r.fallback }; | |
| 13 | }; | |
| 14 | const MANIFEST = JSON.stringify({ | |
| 15 | name: "demo", version: "1.2.3", type: "module", engines: { node: ">=20" }, | |
| 16 | scripts: { test: "bun test", build: "<script>alert(1)</script>" }, | |
| 17 | dependencies: { hono: "^4.0.0" }, devDependencies: { typescript: "~5.4" }, | |
| 18 | }); | |
| 19 | ||
| 20 | describe("package.json", () => { | |
| 21 | test("the registry names exactly package.json", () => { | |
| 22 | expect(fileKind("package.json", enc("{}"))).toBe("package"); | |
| 23 | expect(fileKind("apps/web/package.json", enc("{}"))).toBe("package"); | |
| 24 | expect(kindByName("package.json")).toBe("package"); | |
| 25 | expect(fileKind("package-lock.json", enc("{}"))).toBe("config"); | |
| 26 | expect(fileKind("mypackage.json", enc("{}"))).toBe("config"); | |
| 27 | expect(RENDERERS.package).toBe(packageJson); | |
| 28 | }); | |
| 29 | test("rows for scripts, dependencies with ranges, and the facts line", () => { | |
| 30 | const h = draw(MANIFEST).html; | |
| 31 | expect(h).toContain("demo 1.2.3"); | |
| 32 | expect(h).toContain("type module · node >=20"); | |
| 33 | expect(h).toContain("<code>test</code></th><td><code>bun test</code>"); | |
| 34 | expect(h).toContain("hono"); | |
| 35 | expect(h).toContain("^4.0.0"); | |
| 36 | expect(h).toContain("Dev dependencies"); | |
| 37 | expect(h).toContain("~5.4"); | |
| 38 | }); | |
| 39 | test("every value is escaped", () => { | |
| 40 | const h = draw(MANIFEST).html; | |
| 41 | expect(h).not.toContain("<script>"); | |
| 42 | expect(h).toContain("<script>"); | |
| 43 | expect(draw('{"name":"<img src=x>"}').html).not.toContain("<img"); | |
| 44 | }); | |
| 45 | test("malformed JSON, a non-object or an empty manifest keep the source view", () => { | |
| 46 | for (const bad of ["{ nope", "[1]", "{}", '{"description":"x"}']) { | |
| 47 | expect(parsePackage(bad)).toBeUndefined(); | |
| 48 | const body = enc(bad); | |
| 49 | expect(rendererFor("package.json", { kind: "text", body, size: body.length } as never)).toBeUndefined(); | |
| 50 | } | |
| 51 | const body = enc(MANIFEST); | |
| 52 | expect(rendererFor("package.json", { kind: "text", body, size: body.length } as never)?.render).toBe(packageJson); | |
| 53 | expect(draw(MANIFEST).fallback).toBeUndefined(); | |
| 54 | }); | |
| 55 | test("a BOM is stripped", () => { | |
| 56 | expect(parsePackage("\uFEFF" + MANIFEST)?.name).toBe("demo"); | |
| 57 | }); | |
| 58 | test("facts: license and packageManager; peer and optional sections", () => { | |
| 59 | const doc = parsePackage(JSON.stringify({ | |
| 60 | name: "x", license: "MIT", packageManager: "[email protected]", | |
| 61 | peerDependencies: { react: "^18 || ~19" }, optionalDependencies: { fsevents: "*" }, | |
| 62 | })); | |
| 63 | expect(doc?.facts).toEqual(["MIT", "[email protected]"]); | |
| 64 | const h = draw('{"name":"x","peerDependencies":{"react":"^18 || ~19"},"optionalDependencies":{"fsevents":"*"}}').html; | |
| 65 | expect(h).toContain("Peer dependencies"); | |
| 66 | expect(h).toContain("^18 || ~19"); | |
| 67 | expect(h).toContain("Optional dependencies"); | |
| 68 | expect(h).toContain("fsevents"); | |
| 69 | }); | |
| 70 | test("ranges and commands are wrapping code inside a scrolling wrapper, not numbers", () => { | |
| 71 | const h = draw(MANIFEST).html; | |
| 72 | expect(h).toContain('<code class="break">^4.0.0</code>'); | |
| 73 | expect(h).toContain('class="table-scroll"'); | |
| 74 | expect(h).not.toContain('class="num"'); | |
| 75 | }); | |
| 76 | test("cap per section", () => { | |
| 77 | const deps = Object.fromEntries(Array.from({ length: ROW_CAP + 3 }, (_, i) => [`p${i}`, "1"])); | |
| 78 | expect(draw(JSON.stringify({ dependencies: deps })).html).toContain("3 more not shown"); | |
| 79 | }); | |
| 80 | }); | |
| 81 | ||
| 82 | describe("package.json on the blob page", () => { | |
| 83 | const scope = { base: "/o/r", facts: { name: "r" }, caps: { write: false }, kind: "local" } as unknown as RepoScope; | |
| 84 | const page = (text: string) => { | |
| 85 | const body = enc(text); | |
| 86 | return String(renderBlob(scope, { refs: { branches: ["main"], tags: [] }, ref: "main", path: "package.json", blob: { kind: "text", body, size: body.length } })); | |
| 87 | }; | |
| 88 | test("a malformed manifest is its source, with no banner and no Rendered tab", () => { | |
| 89 | for (const bad of ['{ "name": "x",', "{}"]) { | |
| 90 | const h = page(bad); | |
| 91 | expect(h).not.toContain("Showing the source"); | |
| 92 | expect(h).not.toContain("package-view"); | |
| 93 | expect(h).not.toMatch(/>\s*Rendered\s*</); | |
| 94 | expect(h).toContain('id="L1"'); | |
| 95 | } | |
| 96 | }); | |
| 97 | test("a manifest is drawn, Rendered current", () => { | |
| 98 | const h = page(MANIFEST); | |
| 99 | expect(h).toContain("package-view"); | |
| 100 | expect(h).toMatch(/<[^>]*aria-current[^>]*>\s*Rendered/); | |
| 101 | }); | |
| 102 | }); |
test/tui-workspace.test.ts +18
| @@ -719,6 +719,24 @@ describe("the terminal workspace", () => { | ||
| 719 | 719 | } |
| 720 | 720 | }); |
| 721 | 721 | |
| 722 | test("a package.json opens as rows of scripts and dependencies, and s flips to the source", async () => { | |
| 723 | const made = await createRepo(forge, alice, { name: "pkgrepo", visibility: "public", description: "tests" }); | |
| 724 | if (!made.ok) throw new Error("repo failed"); | |
| 725 | await commitToBranch(forge, alice, made.value, { message: "seed", files: [{ path: "package.json", content: '{"name":"demo","scripts":{"test":"bun test"},"dependencies":{"hono":"^4.0.0"}}' }] }); | |
| 726 | const h = boot(); | |
| 727 | try { | |
| 728 | await h.settle(); | |
| 729 | await open(h, "pkgrepo"); | |
| 730 | await openFile(h, "package.json"); | |
| 731 | expect(h.text()).toContain("Dependencies"); | |
| 732 | expect(h.text()).toContain("^4.0.0"); | |
| 733 | await h.press("s"); | |
| 734 | expect(h.text()).toContain('"name":"demo"'); | |
| 735 | } finally { | |
| 736 | h.stop(); | |
| 737 | } | |
| 738 | }); | |
| 739 | ||
| 722 | 740 | test("a JUnit-named file that is not a report opens as source and says why", async () => { |
| 723 | 741 | const made = await createRepo(forge, alice, { name: "badjunit", visibility: "public", description: "tests" }); |
| 724 | 742 | if (!made.ok) throw new Error("repo failed"); |
Literal BOM character in a regex: invisible in review. Use /^\uFEFF/.