Public: anyone can read this; the crew can change it.
A `.env.example` in a repository opens as the variables an app needs: name, example value and its comment per row #11130
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/11109-a-env-example-in-a-repository-opens-as-t → main 4 commits fork
8 files
changed +274
−1
docs/design/areas/render.md +4
| @@ -40,6 +40,10 @@ A .zip, .tar or .tar.gz is drawn by `src/web/render/archive-view.ts` as the exis | ||
| 40 | 40 | |
| 41 | 41 | `src/web/render/junit.ts` draws the totals as a plain line, opened by one sentence of what the file is, then per suite an `h3`, one existing `.table` (`data-dense`) of its failing cases with each body in a `pre` below, and the passing cases inside one native `details`. `.junit-view` is only a hook on the wrapper and has no rule. The XML reader is its own: no DOCTYPE, no entity but the five predefined, at most 2,000 cases drawn (failing first). How it works: `docs/features/file-junit.md`. |
| 42 | 42 | |
| 43 | ## Environment example (.env.example) | |
| 44 | ||
| 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 | ||
| 43 | 47 | ## OpenAPI operations (openapi.json, *.openapi.yaml) |
| 44 | 48 | |
| 45 | 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`. |
docs/features/file-env.md added +13
| @@ -0,0 +1,13 @@ | ||
| 1 | # A `.env.example` opens as the variables an app needs | |
| 2 | ||
| 3 | A file named exactly `.env.example`, `.env.sample` or `.env.template` opens as a table instead of raw text: **name**, **example value** and the **comment** written above the line. Source is one tap away (`?view=source`). A plain `.env` (or `.env.local`, `.env.production`) is never drawn this way and keeps the source view. | |
| 4 | ||
| 5 | - **Value:** shown as written, quotes removed; `—` when empty. A name ending `_KEY`, `_SECRET` or `_TOKEN`, or containing `PASSWORD`, shows `•••` instead of its value, even in an example file. | |
| 6 | - **Comment:** the `#` lines directly above a variable, plus an inline ` # note`. `export ` is allowed; `#` inside quotes or a URL fragment is part of the value. | |
| 7 | - **Groups:** a `#` block followed by a blank line is a group heading; the variables below it sit under it. | |
| 8 | - **Cap:** at most 500 variables, then one line `N more not shown`. Files over 1 MB keep Source. A file with no variable shows Source with the reason. | |
| 9 | - Every cell is escaped and the table is drawn on the server. No script, no new CSS (`.env-view` is a hook with no rule). | |
| 10 | ||
| 11 | **Terminal:** the workspace opens the same table (`envTable` in `src/tui/screens/workspace.ts`, over the same `parseEnv`); `s` flips to the source. | |
| 12 | ||
| 13 | Code: `src/web/render/env.ts`, `src/web/render/kinds.ts`. Tests: `test/render-env.test.ts`, `test/tui-workspace.test.ts`. |
src/tui/screens/workspace.ts +20 −1
| @@ -50,7 +50,7 @@ import { canAdmin, canWrite, DECISIONS_BY, getRepoById, type Repo } from "../../ | ||
| 50 | 50 | import type { TreeEntry } from "../../git/read.ts"; |
| 51 | 51 | import { parseUnifiedDiff, type Diff } from "../../git/diff.ts"; |
| 52 | 52 | import { renderDiff as renderPatch } from "../ui/diff.ts"; |
| 53 | import { fileKind, kindByName, NO_BYTES } from "../../web/render/kinds.ts"; | |
| 53 | import { fileKind, isEnvExample, kindByName, NO_BYTES } from "../../web/render/kinds.ts"; | |
| 54 | 54 | import { downloadHref } from "../../web/pages/media.ts"; |
| 55 | 55 | import { isModelPath, modelFacts } from "../../web/render/model-facts.ts"; |
| 56 | 56 | import type { RepoFacts, RepoSource } from "../../view/source.ts"; |
| @@ -66,6 +66,7 @@ import { RECORD_CAP, parseLcov, worstFirst, type Ratio } from "../../web/render/ | ||
| 66 | 66 | import { EVENT_CAP, parseIcs } from "../../web/render/ics.ts"; |
| 67 | 67 | import { fileSize } from "../../web/ui.ts"; |
| 68 | 68 | import { parseEml } from "../../web/render/eml-parse.ts"; |
| 69 | import { parseEnv, ROW_CAP as ENV_ROW_CAP } from "../../web/render/env.ts"; | |
| 69 | 70 | import { CARD_CAP, parseVcf } from "../../web/render/vcf-parse.ts"; |
| 70 | 71 | import { CUE_CAP, parseSubtitles } from "../../web/render/subtitles.ts"; |
| 71 | 72 | import { isJsonlPath, kindView, type KindLines } from "./workspace_kinds.ts"; |
| @@ -371,6 +372,21 @@ function vcfTable(source: string): Table | null { | ||
| 371 | 372 | }; |
| 372 | 373 | } |
| 373 | 374 | |
| 375 | /** The variables of a `.env.example` as a table; null when it holds none. Shares the web's parser. */ | |
| 376 | function envTable(source: string): Table | null { | |
| 377 | const doc = parseEnv(source); | |
| 378 | if (doc.total === 0) return null; | |
| 379 | const dash = (v: string): string => (v === "" ? "—" : v); | |
| 380 | const rows: string[][] = []; | |
| 381 | let last = ""; | |
| 382 | for (const r of doc.rows.slice(0, ENV_ROW_CAP)) { | |
| 383 | if (r.group !== "" && r.group !== last) rows.push([`# ${r.group}`, "", ""]); | |
| 384 | last = r.group; | |
| 385 | rows.push([r.name, dash(r.value), dash(r.comment)]); | |
| 386 | } | |
| 387 | return { header: ["Name", "Example", "Comment"], rows, more: doc.total - doc.rows.length, delimiter: ",", columns: 3 }; | |
| 388 | } | |
| 389 | ||
| 374 | 390 | /** `.eml`: a message, drawn as rows of its headers, body and attachments. */ |
| 375 | 391 | function isEmlPath(path: string): boolean { |
| 376 | 392 | return /\.eml$/i.test(path); |
| @@ -683,6 +699,9 @@ class Tab { | ||
| 683 | 699 | } else if (isVcfPath(this.path)) { |
| 684 | 700 | this.table = vcfTable(new TextDecoder().decode(source)); |
| 685 | 701 | if (this.table !== null) this.grid = layoutTable(this.table); |
| 702 | } else if (isEnvExample(this.path)) { | |
| 703 | this.table = envTable(new TextDecoder().decode(source)); | |
| 704 | if (this.table !== null) this.grid = layoutTable(this.table); | |
| 686 | 705 | } else if (isEmlPath(this.path)) { |
| 687 | 706 | this.table = emlTable(source); |
| 688 | 707 | if (this.table !== null) this.grid = layoutTable(this.table); |
src/web/render/env.ts added +118
| @@ -0,0 +1,118 @@ | ||
| 1 | /** | |
| 2 | * A `.env.example`, `.env.sample` or `.env.template` as the variables an app | |
| 3 | * needs: name, example value and the comment written above it, one row each. | |
| 4 | * A comment block followed by a blank line is a group heading; one directly | |
| 5 | * above a variable is that variable's comment (an inline ` # note` joins it). | |
| 6 | * A value whose name looks secret is shown as `•••`, even in an example file. | |
| 7 | * A plain `.env` is never this kind (`isEnvExample` in `kinds.ts`, matched by exact name). | |
| 8 | * Every cell is escaped by the template; at most `ROW_CAP` variables are drawn. | |
| 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 | export const MASK = "•••"; | |
| 17 | ||
| 18 | export interface EnvRow { | |
| 19 | readonly name: string; | |
| 20 | /** The example value as written (quotes removed); `•••` when the name looks secret. */ | |
| 21 | readonly value: string; | |
| 22 | readonly comment: string; | |
| 23 | /** The heading of the group this variable sits under; empty before any. */ | |
| 24 | readonly group: string; | |
| 25 | } | |
| 26 | ||
| 27 | export interface EnvDoc { | |
| 28 | readonly rows: readonly EnvRow[]; | |
| 29 | /** Variables in the file, drawn or not. */ | |
| 30 | readonly total: number; | |
| 31 | } | |
| 32 | ||
| 33 | /** `*_KEY`, `*_SECRET`, `*_TOKEN`, `*PASSWORD*` (and the bare words). */ | |
| 34 | export const looksSecret = (name: string): boolean => /(^|_)(KEY|SECRET|TOKEN)$|PASSWORD/i.test(name); | |
| 35 | ||
| 36 | const ENTRY = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_.-]*)\s*=(.*)$/; | |
| 37 | ||
| 38 | /** The value and any inline comment of what follows `=`. */ | |
| 39 | function value(rest: string): { value: string; comment: string } { | |
| 40 | const s = rest.trimStart(); | |
| 41 | const q = s[0]; | |
| 42 | if (q === '"' || q === "'") { | |
| 43 | let out = ""; | |
| 44 | for (let i = 1; i < s.length; i++) { | |
| 45 | const ch = s[i]!; | |
| 46 | if (q === '"' && ch === "\\" && i + 1 < s.length) { | |
| 47 | const n = s[++i]!; | |
| 48 | out += n === "n" ? "\n" : n; | |
| 49 | } else if (ch === q) { | |
| 50 | const tail = s.slice(i + 1).trim(); | |
| 51 | return { value: out, comment: tail.startsWith("#") ? tail.slice(1).trim() : "" }; | |
| 52 | } else out += ch; | |
| 53 | } | |
| 54 | return { value: out, comment: "" }; // unterminated: the line's rest | |
| 55 | } | |
| 56 | const hash = s.search(/(^|\s)#/); | |
| 57 | if (hash === -1) return { value: s.trim(), comment: "" }; | |
| 58 | return { value: s.slice(0, hash).trim(), comment: s.slice(s.indexOf("#", hash) + 1).trim() }; | |
| 59 | } | |
| 60 | ||
| 61 | /** `# ---- Database ----` is `Database`. */ | |
| 62 | const commentText = (line: string): string => | |
| 63 | line.trim().replace(/^#+\s?/, "").replace(/\s*[-=*#_~]{2,}\s*$/, "").replace(/^[-=*#_~]{2,}\s*/, "").trim(); | |
| 64 | ||
| 65 | /** Read a dotenv example. Pure; never throws; reads at most `ROW_CAP` rows but counts all. */ | |
| 66 | export function parseEnv(source: string): EnvDoc { | |
| 67 | const rows: EnvRow[] = []; | |
| 68 | let total = 0; | |
| 69 | let group = ""; | |
| 70 | let pending: string[] = []; | |
| 71 | for (const raw of source.replace(/^/, "").split(/\r?\n/)) { | |
| 72 | const line = raw.trim(); | |
| 73 | if (line === "") { | |
| 74 | // A comment block set off by a blank line names the group that follows. | |
| 75 | const heading = pending.filter((p) => p !== "").join(" "); | |
| 76 | if (heading !== "") group = heading; | |
| 77 | pending = []; | |
| 78 | } else if (line.startsWith("#")) pending.push(commentText(line)); | |
| 79 | else { | |
| 80 | const m = ENTRY.exec(raw); | |
| 81 | if (m !== null) { | |
| 82 | total++; | |
| 83 | if (rows.length < ROW_CAP) { | |
| 84 | const v = value(m[2]!); | |
| 85 | const comment = [...pending.filter((p) => p !== ""), v.comment].filter((p) => p !== "").join(" "); | |
| 86 | rows.push({ | |
| 87 | name: m[1]!, | |
| 88 | value: v.value !== "" && looksSecret(m[1]!) ? MASK : v.value, | |
| 89 | comment, | |
| 90 | group, | |
| 91 | }); | |
| 92 | } | |
| 93 | } | |
| 94 | pending = []; | |
| 95 | } | |
| 96 | } | |
| 97 | return { rows, total }; | |
| 98 | } | |
| 99 | ||
| 100 | const cell = (v: string): string => (v === "" ? "—" : v); | |
| 101 | ||
| 102 | export function envView(input: RenderInput): Rendered { | |
| 103 | const doc = parseEnv(new TextDecoder().decode(input.body)); | |
| 104 | if (doc.total === 0) return { content: html``, lines: 0, fallback: "This file holds no variables to draw." }; | |
| 105 | let last: string | undefined; | |
| 106 | const body: Html[] = []; | |
| 107 | for (const r of doc.rows) { | |
| 108 | if (r.group !== "" && r.group !== last) body.push(html`<tr><th scope="rowgroup" colspan="3">${r.group}</th></tr>`); | |
| 109 | last = r.group; | |
| 110 | body.push(html`<tr><th scope="row"><code>${r.name}</code></th><td>${cell(r.value)}</td><td>${cell(r.comment)}</td></tr>`); | |
| 111 | } | |
| 112 | const more = doc.total - doc.rows.length; | |
| 113 | const foot = more > 0 ? html`<p class="muted small">${more.toLocaleString()} more not shown</p>` : html``; | |
| 114 | const name = input.path.slice(input.path.lastIndexOf("/") + 1); | |
| 115 | const content = html`<div class="env-view"><table class="table" data-dense aria-label="${name} variables"><thead><tr><th scope="col">Name</th><th scope="col">Example</th><th scope="col">Comment</th></tr></thead><tbody>${body}</tbody></table>${foot}</div>`; | |
| 116 | const n = doc.total; | |
| 117 | return { content, lines: 0, summary: `${n.toLocaleString()} ${n === 1 ? "variable" : "variables"} · ${fileSize(input.size)}` }; | |
| 118 | } |
src/web/render/kinds.ts +8
| @@ -40,6 +40,7 @@ export type FileKind = | ||
| 40 | 40 | | "sarif" |
| 41 | 41 | | "lcov" |
| 42 | 42 | | "junit" |
| 43 | | "env" | |
| 43 | 44 | | "openapi" |
| 44 | 45 | | "text" |
| 45 | 46 | | "binary"; |
| @@ -79,6 +80,7 @@ export const RENDER_CAP: Readonly<Record<FileKind, number>> = { | ||
| 79 | 80 | sarif: 5 * MB, |
| 80 | 81 | lcov: 5 * MB, |
| 81 | 82 | junit: 5 * MB, |
| 83 | env: 1 * MB, | |
| 82 | 84 | openapi: 1 * MB, |
| 83 | 85 | text: 1 * MB, |
| 84 | 86 | binary: 0, |
| @@ -205,6 +207,10 @@ const TEXTUAL: Readonly<Record<string, FileKind>> = { | ||
| 205 | 207 | lcov: "lcov", |
| 206 | 208 | }; |
| 207 | 209 | |
| 210 | /** The three example names; a plain `.env` is never one. */ | |
| 211 | export const isEnvExample = (path: string): boolean => | |
| 212 | /^\.env\.(example|sample|template)$/i.test(path.slice(path.lastIndexOf("/") + 1)); | |
| 213 | ||
| 208 | 214 | /** `openapi.json|yaml|yml` and `*.openapi.*`: the name of an OpenAPI document; its content decides whether it is drawn as one. */ |
| 209 | 215 | const isOpenApiName = (path: string): boolean => /(^|\/|\.)openapi\.(json|ya?ml)$/i.test(path); |
| 210 | 216 | |
| @@ -277,6 +283,7 @@ export function fileKind(path: string, head: Uint8Array): FileKind { | ||
| 277 | 283 | if (isArchive(path, head)) return "archive"; |
| 278 | 284 | if (looksBinary(head)) return "binary"; |
| 279 | 285 | if (isLcovName(path)) return "lcov"; |
| 286 | if (isEnvExample(path)) return "env"; | |
| 280 | 287 | if (isJunit(path, head)) return "junit"; |
| 281 | 288 | if (isOpenApiName(path)) return "openapi"; |
| 282 | 289 | return TEXTUAL[extension(path)] ?? "text"; |
| @@ -293,6 +300,7 @@ export const needsPaths = (kind: FileKind): boolean => kind === "sarif" || kind | ||
| 293 | 300 | export function kindByName(path: string): FileKind { |
| 294 | 301 | const ext = extension(path); |
| 295 | 302 | if (isLcovName(path)) return "lcov"; |
| 303 | if (isEnvExample(path)) return "env"; | |
| 296 | 304 | if (isOpenApiName(path)) return "openapi"; |
| 297 | 305 | return MEDIA[ext]?.kind ?? TEXTUAL[ext] ?? "text"; |
| 298 | 306 | } |
src/web/render/renderers.ts +2
| @@ -10,6 +10,7 @@ import { archive } from "./archive-view.ts"; | ||
| 10 | 10 | import { audio } from "./audio.ts"; |
| 11 | 11 | import { config } from "./config.ts"; |
| 12 | 12 | import { isFormConfig } from "./config-format.ts"; |
| 13 | import { envView } from "./env.ts"; | |
| 13 | 14 | import { eml } from "./eml.ts"; |
| 14 | 15 | import { csv } from "./csv.ts"; |
| 15 | 16 | import { geojson } from "./geojson.ts"; |
| @@ -98,6 +99,7 @@ export const RENDERERS: Partial<Readonly<Record<FileKind, Renderer>>> = { | ||
| 98 | 99 | sarif, |
| 99 | 100 | lcov, |
| 100 | 101 | junit, |
| 102 | env: envView, | |
| 101 | 103 | openapi, |
| 102 | 104 | mail: eml, |
| 103 | 105 | archive, |
test/render-env.test.ts added +90
| @@ -0,0 +1,90 @@ | ||
| 1 | import { describe, expect, test } from "bun:test"; | |
| 2 | import { envView, looksSecret, parseEnv, ROW_CAP } from "../src/web/render/env.ts"; | |
| 3 | import { fileKind, isEnvExample, NO_BYTES } from "../src/web/render/kinds.ts"; | |
| 4 | import { rendererFor } from "../src/web/render/renderers.ts"; | |
| 5 | ||
| 6 | const draw = (text: string, path = ".env.example"): { html: string; summary: string | undefined; fallback: string | undefined } => { | |
| 7 | const body = new TextEncoder().encode(text); | |
| 8 | const r = envView({ path, size: body.length, body, media: "", download: "" }); | |
| 9 | return { html: String(r.content), summary: r.summary, fallback: r.fallback }; | |
| 10 | }; | |
| 11 | ||
| 12 | describe("parseEnv", () => { | |
| 13 | test("quotes, export and inline comments", () => { | |
| 14 | const { rows } = parseEnv( | |
| 15 | ['A="hello world" # quoted', "export B='x # not a comment'", "C=plain # note", 'D="a\\"b"', "E=", "F=http://x/#frag"].join("\n"), | |
| 16 | ); | |
| 17 | expect(rows.map((r) => [r.name, r.value, r.comment])).toEqual([ | |
| 18 | ["A", "hello world", "quoted"], | |
| 19 | ["B", "x # not a comment", ""], | |
| 20 | ["C", "plain", "note"], | |
| 21 | ["D", 'a"b', ""], | |
| 22 | ["E", "", ""], | |
| 23 | ["F", "http://x/#frag", ""], | |
| 24 | ]); | |
| 25 | }); | |
| 26 | test("comment above a line, and blank-separated groups", () => { | |
| 27 | const { rows } = parseEnv("# Database\n\n# The url\n# of it\nDB=pg\n\n# Mail\n\nSMTP=x\n"); | |
| 28 | expect(rows[0]).toMatchObject({ name: "DB", comment: "The url of it", group: "Database" }); | |
| 29 | expect(rows[1]).toMatchObject({ name: "SMTP", group: "Mail" }); | |
| 30 | }); | |
| 31 | }); | |
| 32 | ||
| 33 | describe("masking", () => { | |
| 34 | test("by name, even in an example", () => { | |
| 35 | expect(looksSecret("API_KEY")).toBe(true); | |
| 36 | expect(looksSecret("CLIENT_SECRET")).toBe(true); | |
| 37 | expect(looksSecret("GH_TOKEN")).toBe(true); | |
| 38 | expect(looksSecret("DB_PASSWORD_HASH")).toBe(true); | |
| 39 | expect(looksSecret("PORT")).toBe(false); | |
| 40 | const out = draw("API_KEY=sk-live-123\nDB_PASSWORD=hunter2\nPORT=3000\nEMPTY_TOKEN=\n").html; | |
| 41 | expect(out).toContain("•••"); | |
| 42 | expect(out).not.toContain("sk-live-123"); | |
| 43 | expect(out).not.toContain("hunter2"); | |
| 44 | expect(out).toContain("3000"); | |
| 45 | }); | |
| 46 | }); | |
| 47 | ||
| 48 | describe("drawing", () => { | |
| 49 | test("empty cells are a dash; groups are headings", () => { | |
| 50 | const out = draw("# Web\n\nPORT=\n").html; | |
| 51 | expect(out).toContain('<th scope="rowgroup" colspan="3">Web</th>'); | |
| 52 | expect(out).toContain("<td>—</td><td>—</td>"); | |
| 53 | }); | |
| 54 | test("every cell is escaped", () => { | |
| 55 | const out = draw('# <b>x</b>\n\nA="<script>alert(1)</script>" # <img src=x onerror=1>\n').html; | |
| 56 | expect(out).not.toContain("<script>"); | |
| 57 | expect(out).not.toContain("<img"); | |
| 58 | expect(out).not.toContain("<b>"); | |
| 59 | expect(out).toContain("<script>"); | |
| 60 | }); | |
| 61 | test("the cap says how many are left", () => { | |
| 62 | const text = Array.from({ length: ROW_CAP + 7 }, (_, i) => `V${i}=1`).join("\n"); | |
| 63 | const r = draw(text); | |
| 64 | expect(r.html).toContain("7 more not shown"); | |
| 65 | expect(r.html).not.toContain("V510"); | |
| 66 | expect(r.summary).toContain("507 variables"); | |
| 67 | }); | |
| 68 | test("a file with no variables falls back to source", () => { | |
| 69 | expect(draw("# nothing\n").fallback).toBeDefined(); | |
| 70 | }); | |
| 71 | }); | |
| 72 | ||
| 73 | describe("which files", () => { | |
| 74 | test("only the three example names", () => { | |
| 75 | for (const n of [".env.example", ".env.sample", ".env.template", "app/.env.example"]) { | |
| 76 | expect(isEnvExample(n)).toBe(true); | |
| 77 | expect(fileKind(n, NO_BYTES)).toBe("env"); | |
| 78 | } | |
| 79 | for (const n of [".env", ".env.local", "env.example", ".env.production", "a.env.example"]) { | |
| 80 | expect(isEnvExample(n)).toBe(false); | |
| 81 | expect(fileKind(n, NO_BYTES)).not.toBe("env"); | |
| 82 | } | |
| 83 | }); | |
| 84 | test("a plain .env keeps the source view; an example gets a renderer", () => { | |
| 85 | const body = new TextEncoder().encode("A=1\n"); | |
| 86 | const blob = { kind: "text", body, size: body.length } as never; | |
| 87 | expect(rendererFor(".env", blob)).toBeUndefined(); | |
| 88 | expect(rendererFor(".env.example", blob)).toBeDefined(); | |
| 89 | }); | |
| 90 | }); |
test/tui-workspace.test.ts +19
| @@ -635,6 +635,25 @@ describe("the terminal workspace", () => { | ||
| 635 | 635 | } |
| 636 | 636 | }); |
| 637 | 637 | |
| 638 | test("a .env.example opens as a table with secrets masked, and s flips to the source", async () => { | |
| 639 | const made = await createRepo(forge, alice, { name: "envrepo", visibility: "public", description: "tests" }); | |
| 640 | if (!made.ok) throw new Error("repo failed"); | |
| 641 | await commitToBranch(forge, alice, made.value, { message: "seed", files: [{ path: ".env.example", content: "# Port to listen on\nPORT=3000\nAPI_KEY=sk-live-abc\n" }] }); | |
| 642 | const h = boot(); | |
| 643 | try { | |
| 644 | await h.settle(); | |
| 645 | await open(h, "envrepo"); | |
| 646 | await openFile(h, ".env.example"); | |
| 647 | expect(h.text()).toContain("Port to listen on"); | |
| 648 | expect(h.text()).toContain("•••"); | |
| 649 | expect(h.text()).not.toContain("sk-live-abc"); | |
| 650 | await h.press("s"); | |
| 651 | expect(h.text()).toContain("API_KEY=sk-live-abc"); | |
| 652 | } finally { | |
| 653 | h.stop(); | |
| 654 | } | |
| 655 | }); | |
| 656 | ||
| 638 | 657 | test("a JUnit-named file that is not a report opens as source and says why", async () => { |
| 639 | 658 | const made = await createRepo(forge, alice, { name: "badjunit", visibility: "public", description: "tests" }); |
| 640 | 659 | if (!made.ok) throw new Error("repo failed"); |