Public: anyone can read this; the crew can change it.
A `Dockerfile` opens as its stages: base image, ports, and each instruction as one row #11199
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/11188-a-dockerfile-opens-as-its-stages-base-im → main 7 commits fork 3 threads · 3 unresolved
8 files
changed +329
−3
docs/design/areas/render.md +4
| @@ -48,6 +48,10 @@ A .zip, .tar or .tar.gz is drawn by `src/web/render/archive-view.ts` as the exis | ||
| 48 | 48 | |
| 49 | 49 | `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`. |
| 50 | 50 | |
| 51 | ## Dockerfile stages (Dockerfile, Containerfile, *.Dockerfile) | |
| 52 | ||
| 53 | `src/web/render/dockerfile.ts` draws one `section` per stage: an `h3` with the image, `small` paragraphs for exposed ports and ENV names, then one existing `.table` (`data-dense`) of keyword and arguments. `.dockerfile-view` is only a hook on the wrapper and has no rule. How it works: `docs/features/file-dockerfile.md`. | |
| 54 | ||
| 51 | 55 | ## SQL schema (*.sql) |
| 52 | 56 | |
| 53 | 57 | `src/web/render/sql.ts` draws, per statement, either an `h3` with the table name and one existing `.table` (`data-dense`) of its columns, or a one-row `.table` with the statement's first line. `.sql-view` is only a hook on the wrapper and has no rule. How it works: `docs/features/file-sql.md`. |
docs/features/file-dockerfile.md added +12
| @@ -0,0 +1,12 @@ | ||
| 1 | # A `Dockerfile` opens as its stages | |
| 2 | ||
| 3 | A file named `Dockerfile`, `Containerfile` or `*.Dockerfile` (any case) opens as one block per stage instead of raw text. Source is one tap away (`?view=source`). | |
| 4 | ||
| 5 | - **Stage head:** each `FROM` starts a block titled with its image and `AS` name. `EXPOSE` ports and `ENV` names (never their values, which may be secrets) show under the title. | |
| 6 | - **Rows:** every other instruction is one row, keyword and arguments, with `\` continuation lines joined and comment lines dropped. `ARG`s before the first `FROM` sit in a block of their own. | |
| 7 | - **Fallback:** a file with an unknown instruction or no `FROM` shows Source with the reason. Heredoc bodies (`RUN <<EOF`) are skipped to their terminator, so the instruction shows and its body lines do not. | |
| 8 | - **Cap:** at most 500 instructions, then `N more instructions not shown`. Files over 1 MB keep Source. Arguments are cut at 400 characters. | |
| 9 | - **Terminal:** the workspace screen opens a Dockerfile as a table (stage, instruction, arguments: the ports and ENV names first, then one row per instruction), with the same fallback reason; `s` shows the source. | |
| 10 | - Every value is escaped, drawn on the server; no script, no new CSS, no dependency. | |
| 11 | ||
| 12 | Code: `src/web/render/dockerfile.ts`, `src/web/render/kinds.ts`, `src/tui/screens/workspace.ts`. Test: `test/render-dockerfile.test.ts`, `test/tui-workspace.test.ts`. |
src/tui/screens/workspace.ts +32 −3
| @@ -61,6 +61,7 @@ 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 | 63 | import { parseOpenApi } from "../../web/render/openapi.ts"; |
| 64 | import { parseDockerfile } from "../../web/render/dockerfile.ts"; | |
| 64 | 65 | import { parseSql } from "../../web/render/sql.ts"; |
| 65 | 66 | import { RESULT_CAP, parseSarif } from "../../web/render/sarif.ts"; |
| 66 | 67 | import { RECORD_CAP, parseLcov, worstFirst, type Ratio } from "../../web/render/lcov.ts"; |
| @@ -469,6 +470,29 @@ function openapiTable(text: string, path: string): Table | string | null { | ||
| 469 | 470 | return { header: ["Tag", "Method", "Path", "Summary"], rows, more: parsed.more, delimiter: ",", columns: 4 }; |
| 470 | 471 | } |
| 471 | 472 | |
| 473 | /** A Dockerfile by file name. */ | |
| 474 | function isDockerfilePath(path: string): boolean { | |
| 475 | return kindByName(path) === "dockerfile"; | |
| 476 | } | |
| 477 | ||
| 478 | /** | |
| 479 | * A Dockerfile as a table: per stage, its ports and ENV names, then one row per instruction; | |
| 480 | * the web's reason when it is not drawn. Shares the web's parser. | |
| 481 | */ | |
| 482 | function dockerfileTable(source: string): Table | string { | |
| 483 | const parsed = parseDockerfile(source); | |
| 484 | if ("error" in parsed) return `not drawn as a build: ${parsed.error}`; | |
| 485 | const rows: string[][] = []; | |
| 486 | for (const s of parsed.stages) { | |
| 487 | const title = s.image === "" ? "(before the first stage)" : `${s.image}${s.name === "" ? "" : ` as ${s.name}`}`; | |
| 488 | rows.push([title, "FROM", s.image]); | |
| 489 | if (s.ports.length > 0) rows.push([title, "EXPOSE", s.ports.join(" ")]); | |
| 490 | if (s.env.length > 0) rows.push([title, "ENV", s.env.join(" ")]); | |
| 491 | for (const r of s.rows) rows.push([title, r.keyword, r.args.replace(/\s+/g, " ")]); | |
| 492 | } | |
| 493 | return { header: ["Stage", "Instruction", "Arguments"], rows, more: parsed.total - parsed.shown, delimiter: ",", columns: 3 }; | |
| 494 | } | |
| 495 | ||
| 472 | 496 | /** A `.sql` file by name. */ |
| 473 | 497 | function isSqlPath(path: string): boolean { |
| 474 | 498 | return kindByName(path) === "sql"; |
| @@ -741,6 +765,11 @@ class Tab { | ||
| 741 | 765 | this.table = typeof api === "string" ? null : api; |
| 742 | 766 | this.fault = typeof api === "string" ? api : ""; |
| 743 | 767 | if (this.table !== null) this.grid = layoutTable(this.table); |
| 768 | } else if (isDockerfilePath(this.path)) { | |
| 769 | const build = dockerfileTable(new TextDecoder().decode(source)); | |
| 770 | this.table = typeof build === "string" ? null : build; | |
| 771 | this.fault = typeof build === "string" ? build : ""; | |
| 772 | if (this.table !== null) this.grid = layoutTable(this.table); | |
| 744 | 773 | } else if (isSqlPath(this.path)) { |
| 745 | 774 | const schema = sqlTable(new TextDecoder().decode(source)); |
| 746 | 775 | this.table = typeof schema === "string" ? null : schema; |
| @@ -2867,7 +2896,7 @@ export function workspaceScreen( | ||
| 2867 | 2896 | t.tabular && t.table !== null |
| 2868 | 2897 | ? t.raw !== null && isJunitPath(t.path, t.raw) |
| 2869 | 2898 | ? plural(t.table.rows.length - 1 + t.table.more, "failing case") |
| 2870 | : 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)) | |
| 2899 | : 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), isDockerfilePath(t.path)) | |
| 2871 | 2900 | : `${plural( |
| 2872 | 2901 | t.reading ? t.doc.length : t.plain.length, |
| 2873 | 2902 | "line", |
| @@ -3201,9 +3230,9 @@ function drawCode(s: Surface, r: Rect, c: Tokens, t: Tab): void { | ||
| 3201 | 3230 | } |
| 3202 | 3231 | |
| 3203 | 3232 | /** `80 rows, first 50 shown · 3 more columns`: what the table leaves out. */ |
| 3204 | 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 { | |
| 3233 | function tableStatus(table: Table, unread = 0, subs = false, events = false, facts = false, people = false, features = false, jsonl = false, findings = false, files = false, operations = false, instructions = false): string { | |
| 3205 | 3234 | const rows = table.rows.length + table.more; |
| 3206 | return `${plural(rows, subs ? "cue" : events ? "event" : people ? "contact" : facts ? "fact" : features ? "feature" : findings ? "finding" : files ? "file" : operations ? "operation" : "row")}${ | |
| 3235 | return `${plural(rows, subs ? "cue" : events ? "event" : people ? "contact" : facts ? "fact" : features ? "feature" : findings ? "finding" : files ? "file" : operations ? "operation" : instructions ? "instruction" : "row")}${ | |
| 3207 | 3236 | unread > 0 ? ` · ${unread} unreadable, check their arrow lines` : "" |
| 3208 | 3237 | }${ |
| 3209 | 3238 | table.more > 0 |
src/web/render/dockerfile.ts added +159
| @@ -0,0 +1,159 @@ | ||
| 1 | /** | |
| 2 | * A `Dockerfile`, `Containerfile` or `*.Dockerfile` as its stages: each `FROM` | |
| 3 | * starts a block headed by its image (and `AS` name), with the `EXPOSE`d | |
| 4 | * ports and the `ENV` names (never their values) at the head; every other | |
| 5 | * instruction is one row, keyword and arguments, continuation lines joined. | |
| 6 | * Comment lines (and parser directives) are dropped. `ARG`s before the first | |
| 7 | * `FROM` sit in a block of their own. A file with an unknown instruction, or | |
| 8 | * no `FROM`, is not drawn: the source shows with the reason. Heredoc bodies | |
| 9 | * (`RUN <<EOF` … `EOF`) are skipped up to their terminator; the instruction | |
| 10 | * line stays. Every string is escaped and cut to `TEXT_CAP`; at most | |
| 11 | * `ROW_CAP` instructions are drawn. | |
| 12 | */ | |
| 13 | ||
| 14 | import { escapeHtml, html, raw } from "../html.ts"; | |
| 15 | import { fileSize } from "../ui.ts"; | |
| 16 | import type { Rendered, RenderInput } from "./renderers.ts"; | |
| 17 | ||
| 18 | export const ROW_CAP = 500; | |
| 19 | const TEXT_CAP = 400; | |
| 20 | ||
| 21 | const KEYWORDS: ReadonlySet<string> = new Set([ | |
| 22 | "FROM", "RUN", "CMD", "LABEL", "MAINTAINER", "EXPOSE", "ENV", "ADD", "COPY", "ENTRYPOINT", | |
| 23 | "VOLUME", "USER", "WORKDIR", "ARG", "ONBUILD", "STOPSIGNAL", "HEALTHCHECK", "SHELL", | |
| 24 | ]); | |
| 25 | ||
| 26 | export interface Row { | |
| 27 | readonly keyword: string; | |
| 28 | readonly args: string; | |
| 29 | } | |
| 30 | export interface Stage { | |
| 31 | /** Empty for the `ARG`s before the first `FROM`. */ | |
| 32 | readonly image: string; | |
| 33 | readonly name: string; | |
| 34 | readonly ports: string[]; | |
| 35 | readonly env: string[]; | |
| 36 | readonly rows: Row[]; | |
| 37 | } | |
| 38 | export type Dockerfile = | |
| 39 | | { readonly stages: Stage[]; readonly total: number; readonly shown: number } | |
| 40 | | { readonly error: string }; | |
| 41 | ||
| 42 | const cut = (s: string): string => s.slice(0, TEXT_CAP); | |
| 43 | ||
| 44 | /** The heredoc terminators an instruction line opens (`<<EOF`, `<<-"EOF"`), in order. */ | |
| 45 | function heredocs(instruction: string): { word: string; strip: boolean }[] { | |
| 46 | if (!/^(RUN|COPY|ADD)\s/i.test(instruction)) return []; | |
| 47 | const out: { word: string; strip: boolean }[] = []; | |
| 48 | const re = /(?<!<)<<(-?)\s*(?:"([^"]+)"|'([^']+)'|([A-Za-z_]\w*))/g; | |
| 49 | for (let m = re.exec(instruction); m !== null; m = re.exec(instruction)) out.push({ word: m[2] ?? m[3] ?? m[4]!, strip: m[1] === "-" }); | |
| 50 | return out; | |
| 51 | } | |
| 52 | ||
| 53 | /** Logical instructions: comments dropped, `\` continuations joined, heredoc bodies skipped. */ | |
| 54 | function instructions(source: string): string[] { | |
| 55 | const out: string[] = []; | |
| 56 | let cur: string | undefined; | |
| 57 | let pending: { word: string; strip: boolean }[] = []; | |
| 58 | for (const line of source.replace(/^\uFEFF/, "").split(/\r?\n/)) { | |
| 59 | if (pending.length > 0) { | |
outdated
|
||
| 60 | const end = pending[0]!; | |
| 61 | if ((end.strip ? line.replace(/^\t+/, "") : line).trimEnd() === end.word) pending.shift(); | |
| 62 | continue; | |
| 63 | } | |
| 64 | const t = line.trim(); | |
| 65 | if (t.startsWith("#")) continue; // also inside a continuation, as Docker does | |
| 66 | if (t === "") continue; | |
| 67 | const more = /\\$/.test(t); | |
| 68 | const text = more ? t.slice(0, -1).trimEnd() : t; | |
| 69 | cur = cur === undefined ? text : `${cur} ${text}`; | |
| 70 | if (!more) { | |
| 71 | out.push(cur); | |
| 72 | pending = heredocs(cur); | |
| 73 | cur = undefined; | |
| 74 | } | |
| 75 | } | |
| 76 | if (cur !== undefined) out.push(cur); | |
| 77 | return out; | |
| 78 | } | |
| 79 | ||
| 80 | /** Names set by `ENV a=1 b="x y"` or the old `ENV a some value`. */ | |
| 81 | function envNames(args: string): string[] { | |
| 82 | if (!/^[^\s=]+=/.test(args)) return [args.split(/\s+/)[0] ?? ""].filter((n) => n !== ""); | |
| 83 | const names: string[] = []; | |
| 84 | const re = /([^\s=]+)=(?:"(?:[^"\\]|\\.)*"|'[^']*'|(?:\\.|\S)*)/g; | |
| 85 | for (let m = re.exec(args); m !== null; m = re.exec(args)) names.push(m[1]!); | |
| 86 | return names; | |
| 87 | } | |
| 88 | ||
| 89 | export function parseDockerfile(source: string): Dockerfile { | |
| 90 | const stages: Stage[] = []; | |
| 91 | let total = 0; | |
| 92 | let shown = 0; | |
| 93 | let sawFrom = false; | |
| 94 | for (const line of instructions(source)) { | |
| 95 | const m = /^(\S+)(?:\s+([\s\S]*))?$/.exec(line); | |
| 96 | const keyword = m![1]!.toUpperCase(); | |
| 97 | const args = (m![2] ?? "").trim(); | |
| 98 | if (!KEYWORDS.has(keyword)) return { error: `"${cut(m![1]!).slice(0, 40)}" is not a Dockerfile instruction` }; | |
| 99 | if (keyword === "FROM") { | |
| 100 | const words = args.split(/\s+/).filter((w) => !w.startsWith("--")); | |
| 101 | if (words[0] === undefined) return { error: "a FROM has no image" }; | |
| 102 | const as = words[1]?.toUpperCase() === "AS" ? (words[2] ?? "") : ""; | |
| 103 | stages.push({ image: cut(words[0]), name: cut(as), ports: [], env: [], rows: [] }); | |
| 104 | sawFrom = true; | |
| 105 | continue; | |
| 106 | } | |
| 107 | if (!sawFrom) { | |
| 108 | if (keyword !== "ARG") return { error: `${keyword} comes before the first FROM` }; | |
| 109 | if (stages.length === 0) stages.push({ image: "", name: "", ports: [], env: [], rows: [] }); | |
| 110 | } | |
| 111 | const stage = stages[stages.length - 1]!; | |
| 112 | if (keyword === "EXPOSE") { | |
| 113 | for (const p of args.split(/\s+/)) if (p !== "" && stage.ports.length < ROW_CAP) stage.ports.push(cut(p)); | |
| 114 | continue; | |
| 115 | } | |
| 116 | if (keyword === "ENV") { | |
| 117 | for (const n of envNames(args)) if (stage.env.length < ROW_CAP) stage.env.push(cut(n)); | |
| 118 | continue; | |
| 119 | } | |
| 120 | total++; | |
| 121 | if (shown >= ROW_CAP) continue; | |
| 122 | shown++; | |
| 123 | stage.rows.push({ keyword, args: cut(args) }); | |
| 124 | } | |
| 125 | if (!sawFrom) return { error: "it has no FROM" }; | |
| 126 | return { stages, total, shown }; | |
| 127 | } | |
| 128 | ||
| 129 | export const moreLine = (n: number): string => `${n.toLocaleString()} more instruction${n === 1 ? "" : "s"} not shown`; | |
| 130 | ||
| 131 | export function dockerfile(input: RenderInput): Rendered { | |
| 132 | const doc = parseDockerfile(new TextDecoder().decode(input.body)); | |
| 133 | if ("error" in doc) return { content: html``, lines: 0, fallback: `This file is not drawn as a build: ${doc.error}.` }; | |
| 134 | let out = ""; | |
| 135 | let n = 0; | |
| 136 | for (const s of doc.stages) { | |
| 137 | if (s.image !== "") n++; | |
| 138 | const title = s.image === "" ? "Before the first stage" : `${s.image}${s.name === "" ? "" : ` as ${s.name}`}`; | |
| 139 | const label = s.image === "" ? title : `Stage ${n}: ${title}`; | |
outdated
Principle 1: the lead paragraph and |
||
| 140 | let head = ""; | |
| 141 | if (s.ports.length > 0) head += `<p class="small">Exposes ${s.ports.map((p) => `<code>${escapeHtml(p)}</code>`).join(" ")}</p>`; | |
| 142 | if (s.env.length > 0) head += `<p class="small">Sets ${s.env.map((p) => `<code>${escapeHtml(p)}</code>`).join(" ")}</p>`; | |
| 143 | let rows = ""; | |
| 144 | for (const r of s.rows) rows += `<tr><td><code>${escapeHtml(r.keyword)}</code></td><td><code>${escapeHtml(r.args)}</code></td></tr>`; | |
| 145 | const table = | |
| 146 | rows === "" | |
| 147 | ? "" | |
| 148 | : `<table class="table" data-dense aria-label="${escapeHtml(title)} instructions"><thead><tr><th scope="col">Instruction</th><th scope="col">Arguments</th></tr></thead><tbody>${rows}</tbody></table>`; | |
| 149 | out += `<section><h3>${escapeHtml(label)}</h3>${head}${table}</section>`; | |
| 150 | } | |
| 151 | if (doc.total > doc.shown) out += `<p class="muted small">${moreLine(doc.total - doc.shown)}</p>`; | |
| 152 | const built = doc.stages.filter((s) => s.image !== "").length; | |
| 153 | const lead = "What this image is built from."; | |
| 154 | return { | |
| 155 | content: html`<div class="dockerfile-view"><p>${lead}</p>${raw(out)}</div>`, | |
| 156 | lines: 0, | |
| 157 | summary: `${built} ${built === 1 ? "stage" : "stages"} · ${fileSize(input.size)}`, | |
| 158 | }; | |
| 159 | } |
src/web/render/kinds.ts +7
| @@ -42,6 +42,7 @@ export type FileKind = | ||
| 42 | 42 | | "junit" |
| 43 | 43 | | "env" |
| 44 | 44 | | "openapi" |
| 45 | | "dockerfile" | |
| 45 | 46 | | "sql" |
| 46 | 47 | | "text" |
| 47 | 48 | | "binary"; |
| @@ -83,6 +84,7 @@ export const RENDER_CAP: Readonly<Record<FileKind, number>> = { | ||
| 83 | 84 | junit: 5 * MB, |
| 84 | 85 | env: 1 * MB, |
| 85 | 86 | openapi: 1 * MB, |
| 87 | dockerfile: 1 * MB, | |
| 86 | 88 | sql: 1 * MB, |
| 87 | 89 | text: 1 * MB, |
| 88 | 90 | binary: 0, |
| @@ -217,6 +219,9 @@ export const isEnvExample = (path: string): boolean => | ||
| 217 | 219 | /** `openapi.json|yaml|yml` and `*.openapi.*`: the name of an OpenAPI document; its content decides whether it is drawn as one. */ |
| 218 | 220 | const isOpenApiName = (path: string): boolean => /(^|\/|\.)openapi\.(json|ya?ml)$/i.test(path); |
| 219 | 221 | |
| 222 | /** `Dockerfile`, `Containerfile` and `*.Dockerfile`, in any case. */ | |
| 223 | const isDockerfileName = (path: string): boolean => /(^|\/)(Dockerfile|Containerfile|[^/]+\.Dockerfile)$/i.test(path); | |
| 224 | ||
| 220 | 225 | /** `lcov.info`: the name `lcov` and `bun test` write, whatever its extension. */ |
| 221 | 226 | const isLcovName = (path: string): boolean => /(^|\/)lcov\.info$/i.test(path); |
| 222 | 227 | |
| @@ -289,6 +294,7 @@ export function fileKind(path: string, head: Uint8Array): FileKind { | ||
| 289 | 294 | if (isEnvExample(path)) return "env"; |
| 290 | 295 | if (isJunit(path, head)) return "junit"; |
| 291 | 296 | if (isOpenApiName(path)) return "openapi"; |
| 297 | if (isDockerfileName(path)) return "dockerfile"; | |
| 292 | 298 | return TEXTUAL[extension(path)] ?? "text"; |
| 293 | 299 | } |
| 294 | 300 | |
| @@ -305,6 +311,7 @@ export function kindByName(path: string): FileKind { | ||
| 305 | 311 | if (isLcovName(path)) return "lcov"; |
| 306 | 312 | if (isEnvExample(path)) return "env"; |
| 307 | 313 | if (isOpenApiName(path)) return "openapi"; |
| 314 | if (isDockerfileName(path)) return "dockerfile"; | |
| 308 | 315 | return MEDIA[ext]?.kind ?? TEXTUAL[ext] ?? "text"; |
| 309 | 316 | } |
| 310 | 317 |
src/web/render/renderers.ts +2
| @@ -30,6 +30,7 @@ import { model3d } from "./model3d.ts"; | ||
| 30 | 30 | import { notebook } from "./notebook.ts"; |
| 31 | 31 | import { patch } from "./patch.ts"; |
| 32 | 32 | import { pdf } from "./pdf.ts"; |
| 33 | import { dockerfile } from "./dockerfile.ts"; | |
| 33 | 34 | import { openapi } from "./openapi.ts"; |
| 34 | 35 | import { lcov } from "./lcov.ts"; |
| 35 | 36 | import { sarif } from "./sarif.ts"; |
| @@ -103,6 +104,7 @@ export const RENDERERS: Partial<Readonly<Record<FileKind, Renderer>>> = { | ||
| 103 | 104 | env: envView, |
| 104 | 105 | sql: sqlView, |
| 105 | 106 | openapi, |
| 107 | dockerfile, | |
| 106 | 108 | mail: eml, |
| 107 | 109 | archive, |
| 108 | 110 | font, |
test/render-dockerfile.test.ts added +90
| @@ -0,0 +1,90 @@ | ||
| 1 | /** A Dockerfile as its stages: two stages, continuation, comments, escaping, the fallback, the registry. */ | |
| 2 | import { describe, expect, test } from "bun:test"; | |
| 3 | import { dockerfile, parseDockerfile, ROW_CAP } from "../src/web/render/dockerfile.ts"; | |
| 4 | import { fileKind, kindByName } from "../src/web/render/kinds.ts"; | |
| 5 | import { RENDERERS } from "../src/web/render/renderers.ts"; | |
| 6 | ||
| 7 | const enc = (s: string): Uint8Array => new TextEncoder().encode(s); | |
| 8 | const draw = (text: string, path = "Dockerfile") => dockerfile({ path, size: text.length, body: enc(text), media: "/m", download: "/d" }); | |
| 9 | ||
| 10 | const TWO = `# build it | |
| 11 | ARG V=1 | |
| 12 | FROM --platform=linux/amd64 oven/bun:1 AS build | |
| 13 | ENV NODE_ENV=production TOKEN="a b" | |
| 14 | WORKDIR /app | |
| 15 | RUN bun install \\ | |
| 16 | # not part of it | |
| 17 | && bun build | |
| 18 | FROM debian:12 | |
| 19 | EXPOSE 3000 8080/udp | |
| 20 | COPY --from=build /app/out /srv | |
| 21 | CMD ["/srv/app"] | |
| 22 | `; | |
| 23 | ||
| 24 | describe("dockerfile", () => { | |
| 25 | test("the registry names the files and a renderer draws them", () => { | |
| 26 | for (const p of ["Dockerfile", "a/Containerfile", "api.Dockerfile", "x/dev.dockerfile", "dockerfile"]) { | |
| 27 | expect(fileKind(p, enc("FROM a"))).toBe("dockerfile"); | |
| 28 | expect(kindByName(p)).toBe("dockerfile"); | |
| 29 | } | |
| 30 | expect(fileKind("Dockerfile.md", enc("x"))).not.toBe("dockerfile"); | |
| 31 | expect(RENDERERS.dockerfile).toBe(dockerfile); | |
| 32 | }); | |
| 33 | ||
| 34 | test("two stages: images, names, ports, env names, rows", () => { | |
| 35 | const d = parseDockerfile(TWO); | |
| 36 | if ("error" in d) throw new Error(d.error); | |
| 37 | expect(d.stages.map((s) => [s.image, s.name])).toEqual([["", ""], ["oven/bun:1", "build"], ["debian:12", ""]]); | |
| 38 | expect(d.stages[1]!.env).toEqual(["NODE_ENV", "TOKEN"]); | |
| 39 | expect(d.stages[2]!.ports).toEqual(["3000", "8080/udp"]); | |
| 40 | expect(d.stages[0]!.rows).toEqual([{ keyword: "ARG", args: "V=1" }]); | |
| 41 | const h = String(draw(TWO).content); | |
| 42 | expect(h).toContain("Stage 1: oven/bun:1 as build"); | |
| 43 | expect(h).toContain("Stage 2: debian:12"); | |
| 44 | expect(h).not.toContain("a b"); | |
| 45 | expect(h).toContain("<code>NODE_ENV</code>"); | |
| 46 | }); | |
| 47 | ||
| 48 | test("a continued RUN is one row and a comment is dropped", () => { | |
| 49 | const d = parseDockerfile(TWO); | |
| 50 | if ("error" in d) throw new Error(d.error); | |
| 51 | const run = d.stages[1]!.rows.find((r) => r.keyword === "RUN")!; | |
| 52 | expect(run.args).toBe("bun install && bun build"); | |
| 53 | expect(String(draw(TWO).content)).not.toContain("build it"); | |
| 54 | expect(String(draw(TWO).content)).not.toContain("not part of it"); | |
| 55 | }); | |
| 56 | ||
| 57 | test("a value with <script> is escaped", () => { | |
| 58 | const h = String(draw('FROM <script>alert(1)</script> AS <b>\nRUN echo "<script>x</script>"\nEXPOSE <i>\nENV <u>=1').content); | |
| 59 | expect(h).not.toContain("<script>"); | |
| 60 | expect(h).not.toContain("<b>"); | |
| 61 | expect(h).toContain("<script>"); | |
| 62 | }); | |
| 63 | ||
| 64 | test("an unparsable file falls back to the source with a reason", () => { | |
| 65 | for (const bad of ["hello world\nthis is not docker", "RUN ls\nFROM a", "ARG x=1", ""]) { | |
| 66 | const r = draw(bad); | |
| 67 | expect(r.fallback).toContain("not drawn"); | |
| 68 | } | |
| 69 | }); | |
| 70 | ||
| 71 | test("a heredoc body is skipped to its terminator, not read as instructions", () => { | |
| 72 | const text = "FROM a\nRUN <<EOF\nnot an instruction\n# nor this\nEOF\nCOPY <<-'X' /f\n\tbogus line\n\tX\nUSER me\n"; | |
| 73 | const d = parseDockerfile(text); | |
| 74 | if ("error" in d) throw new Error(d.error); | |
| 75 | expect(d.stages[0]!.rows.map((r) => r.keyword)).toEqual(["RUN", "COPY", "USER"]); | |
| 76 | expect(draw(text).fallback).toBeUndefined(); | |
| 77 | expect(String(draw(text).content)).not.toContain("not an instruction"); | |
| 78 | }); | |
| 79 | ||
| 80 | test("the stage count is said once", () => { | |
| 81 | const r = draw(TWO); | |
| 82 | expect(String(r.content)).not.toContain("2 stages"); | |
| 83 | expect(r.summary).toContain("2 stages"); | |
| 84 | }); | |
| 85 | ||
| 86 | test("the instruction cap", () => { | |
| 87 | const r = draw("FROM a\n" + "RUN x\n".repeat(ROW_CAP + 3)); | |
| 88 | expect(String(r.content)).toContain("3 more instructions not shown"); | |
| 89 | }); | |
| 90 | }); |
test/tui-workspace.test.ts +23
| @@ -616,6 +616,29 @@ describe("the terminal workspace", () => { | ||
| 616 | 616 | } |
| 617 | 617 | }); |
| 618 | 618 | |
| 619 | test("a Dockerfile opens as its stages, and s shows the source", async () => { | |
| 620 | const made = await createRepo(forge, alice, { name: "dockerdoc", visibility: "public", description: "build" }); | |
| 621 | if (!made.ok) throw new Error("repo failed"); | |
| 622 | const df = "FROM oven/bun:1 AS build\nEXPOSE 3000\nENV TOKEN=abc\nRUN <<EOF\nbun install\nEOF\nFROM scratch\nCOPY --from=build /app /app\n"; | |
| 623 | await commitToBranch(forge, alice, made.value, { message: "seed", files: [{ path: "Dockerfile", content: df }, { path: "sub/Containerfile", content: "RUN x\n" }] }); | |
| 624 | const h = boot(); | |
| 625 | try { | |
| 626 | await h.settle(); | |
| 627 | await open(h, "dockerdoc"); | |
| 628 | await openFile(h, "Dockerfile"); | |
| 629 | expect(h.text()).toContain("oven/bun:1 as build"); | |
| 630 | expect(h.text()).toContain("3000"); | |
| 631 | expect(h.text()).toContain("TOKEN"); | |
| 632 | expect(h.text()).not.toContain("abc"); | |
| 633 | expect(h.text()).toContain("COPY"); | |
| 634 | expect(h.text()).toContain("instructions"); | |
| 635 | await h.press("s"); | |
| 636 | expect(h.text()).toContain("TOKEN=abc"); | |
| 637 | } finally { | |
| 638 | h.stop(); | |
| 639 | } | |
| 640 | }); | |
| 641 | ||
| 619 | 642 | test("a .sql file opens as its columns, and s shows the source", async () => { |
| 620 | 643 | const made = await createRepo(forge, alice, { name: "sqlrepo", visibility: "public", description: "db" }); |
| 621 | 644 | if (!made.ok) throw new Error("repo failed"); |
Both doors: nothing in
src/tui/usesparseDockerfile; wire it intosrc/tui/screens/workspace.tsas openapi is.