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

Merged opened this

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

Merged

scout/forge:scout/11188-a-dockerfile-opens-as-its-stages-base-im → main 7 commits fork 3 threads · 3 unresolved

  • Merged. scout/11188-a-dockerfile-opens-as-its-stages-base-im was merged into main as 1aa7807.

Review

Sign in to review this change.
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
4848
4949 `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`.
5050
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
5155 ## SQL schema (*.sql)
5256
5357 `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";
6161 import { gpxFacts, parseGpx } from "../../web/render/gpx.ts";
6262 import { CASE_CAP, parseJunit } from "../../web/render/junit.ts";
6363 import { parseOpenApi } from "../../web/render/openapi.ts";
64 import { parseDockerfile } from "../../web/render/dockerfile.ts";
6465 import { parseSql } from "../../web/render/sql.ts";
6566 import { RESULT_CAP, parseSarif } from "../../web/render/sarif.ts";
6667 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 {
469470 return { header: ["Tag", "Method", "Path", "Summary"], rows, more: parsed.more, delimiter: ",", columns: 4 };
470471 }
471472
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
472496 /** A `.sql` file by name. */
473497 function isSqlPath(path: string): boolean {
474498 return kindByName(path) === "sql";
@@ -741,6 +765,11 @@ class Tab {
741765 this.table = typeof api === "string" ? null : api;
742766 this.fault = typeof api === "string" ? api : "";
743767 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);
744773 } else if (isSqlPath(this.path)) {
745774 const schema = sqlTable(new TextDecoder().decode(source));
746775 this.table = typeof schema === "string" ? null : schema;
@@ -2867,7 +2896,7 @@ export function workspaceScreen(
28672896 t.tabular && t.table !== null
28682897 ? t.raw !== null && isJunitPath(t.path, t.raw)
28692898 ? 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))
28712900 : `${plural(
28722901 t.reading ? t.doc.length : t.plain.length,
28732902 "line",
@@ -3201,9 +3230,9 @@ function drawCode(s: Surface, r: Rect, c: Tokens, t: Tab): void {
32013230 }
32023231
32033232 /** `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 {
32053234 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")}${
32073236 unread > 0 ? ` · ${unread} unreadable, check their arrow lines` : ""
32083237 }${
32093238 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",

Both doors: nothing in src/tui/ uses parseDockerfile; wire it into src/tui/screens/workspace.ts as openapi is.

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

instructions reads heredoc bodies as instructions, so RUN <<EOF files fall back. Skip lines up to the heredoc terminator instead.

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 summary both say how many stages there are. Say it once.

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 =
4242 | "junit"
4343 | "env"
4444 | "openapi"
45 | "dockerfile"
4546 | "sql"
4647 | "text"
4748 | "binary";
@@ -83,6 +84,7 @@ export const RENDER_CAP: Readonly<Record<FileKind, number>> = {
8384 junit: 5 * MB,
8485 env: 1 * MB,
8586 openapi: 1 * MB,
87 dockerfile: 1 * MB,
8688 sql: 1 * MB,
8789 text: 1 * MB,
8890 binary: 0,
@@ -217,6 +219,9 @@ export const isEnvExample = (path: string): boolean =>
217219 /** `openapi.json|yaml|yml` and `*.openapi.*`: the name of an OpenAPI document; its content decides whether it is drawn as one. */
218220 const isOpenApiName = (path: string): boolean => /(^|\/|\.)openapi\.(json|ya?ml)$/i.test(path);
219221
222 /** `Dockerfile`, `Containerfile` and `*.Dockerfile`, in any case. */
223 const isDockerfileName = (path: string): boolean => /(^|\/)(Dockerfile|Containerfile|[^/]+\.Dockerfile)$/i.test(path);
224
220225 /** `lcov.info`: the name `lcov` and `bun test` write, whatever its extension. */
221226 const isLcovName = (path: string): boolean => /(^|\/)lcov\.info$/i.test(path);
222227
@@ -289,6 +294,7 @@ export function fileKind(path: string, head: Uint8Array): FileKind {
289294 if (isEnvExample(path)) return "env";
290295 if (isJunit(path, head)) return "junit";
291296 if (isOpenApiName(path)) return "openapi";
297 if (isDockerfileName(path)) return "dockerfile";
292298 return TEXTUAL[extension(path)] ?? "text";
293299 }
294300
@@ -305,6 +311,7 @@ export function kindByName(path: string): FileKind {
305311 if (isLcovName(path)) return "lcov";
306312 if (isEnvExample(path)) return "env";
307313 if (isOpenApiName(path)) return "openapi";
314 if (isDockerfileName(path)) return "dockerfile";
308315 return MEDIA[ext]?.kind ?? TEXTUAL[ext] ?? "text";
309316 }
310317
src/web/render/renderers.ts +2
@@ -30,6 +30,7 @@ import { model3d } from "./model3d.ts";
3030 import { notebook } from "./notebook.ts";
3131 import { patch } from "./patch.ts";
3232 import { pdf } from "./pdf.ts";
33 import { dockerfile } from "./dockerfile.ts";
3334 import { openapi } from "./openapi.ts";
3435 import { lcov } from "./lcov.ts";
3536 import { sarif } from "./sarif.ts";
@@ -103,6 +104,7 @@ export const RENDERERS: Partial<Readonly<Record<FileKind, Renderer>>> = {
103104 env: envView,
104105 sql: sqlView,
105106 openapi,
107 dockerfile,
106108 mail: eml,
107109 archive,
108110 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("&lt;script&gt;");
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", () => {
616616 }
617617 });
618618
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
619642 test("a .sql file opens as its columns, and s shows the source", async () => {
620643 const made = await createRepo(forge, alice, { name: "sqlrepo", visibility: "public", description: "db" });
621644 if (!made.ok) throw new Error("repo failed");

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs