Public: anyone can read this; the crew can change it.
A GitHub Actions workflow (`.github/workflows/*.yml`) opens as its jobs: triggers, then each job's runner, needs and steps #11235
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/11223-a-github-actions-workflow-github-workflo → main 5 commits fork 3 threads · 3 unresolved
10 files
changed +371
−0
docs/design/areas/render.md +4
| @@ -52,6 +52,10 @@ A .zip, .tar or .tar.gz is drawn by `src/web/render/archive-view.ts` as the exis | ||
| 52 | 52 | |
| 53 | 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 | 54 | |
| 55 | ## GitHub Actions workflow (.github/workflows/*.yml) | |
| 56 | ||
| 57 | `src/web/render/workflow.ts` draws a lead line with the triggers, then one `section` per job: an `h3` with the job, one existing `dl.status-rows` of its runner, needs and matrix, and one existing `.table` (`data-dense`) of its steps. `.workflow-view` is only a hook on the wrapper and has no rule. How it works: `docs/features/file-workflow.md`. | |
| 58 | ||
| 55 | 59 | ## SQL schema (*.sql) |
| 56 | 60 | |
| 57 | 61 | `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-workflow.md added +13
| @@ -0,0 +1,13 @@ | ||
| 1 | # A GitHub Actions workflow opens as its jobs | |
| 2 | ||
| 3 | A file under `.github/workflows/` ending `.yml` or `.yaml` opens as what it does instead of raw YAML. Source is one tap away (`?view=source`), and Raw is beside it. | |
| 4 | ||
| 5 | - **Triggers:** the `on:` triggers in one line at the top. | |
| 6 | - **Jobs:** one block per job, titled with its id (and `name`). Its runner (`runs-on`), `needs` and matrix (one entry per axis; `include` and `exclude` as a count) show as fact rows. | |
| 7 | - **Steps:** a two-column table, one row per step: its name, and what it does (`uses …`, else the first line of `run`). | |
| 8 | - **Fallback:** a file that is not valid YAML, holds several documents, is not a map or has no `jobs` shows as YAML with one line saying why. | |
| 9 | - **Cap:** at most 100 jobs and 200 steps per job, then a `more … not shown` line. Files over 1 MB keep Source. Values are cut at 300 characters. | |
| 10 | - **Terminal:** the workspace screen opens a workflow as a table (job, kind, value: triggers, then each job's runs-on, needs, matrix and steps), with the same fallback reason; `s` shows the source. | |
| 11 | - `${{ … }}` expressions show as written; nothing is evaluated. Every value is escaped, drawn on the server; no script, no new CSS, no dependency. | |
| 12 | ||
| 13 | Code: `src/web/render/workflow.ts`, `src/web/render/kinds.ts`, `src/tui/screens/workspace.ts`. Test: `test/render-workflow.test.ts`, `test/blob-workflow-page.test.ts`, `test/tui-workspace.test.ts`. |
src/tui/screens/workspace.ts +30
| @@ -62,6 +62,7 @@ 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 | 64 | import { parseDockerfile } from "../../web/render/dockerfile.ts"; |
| 65 | import { parseWorkflow } from "../../web/render/workflow.ts"; | |
| 65 | 66 | import { parseSql } from "../../web/render/sql.ts"; |
| 66 | 67 | import { RESULT_CAP, parseSarif } from "../../web/render/sarif.ts"; |
| 67 | 68 | import { RECORD_CAP, parseLcov, worstFirst, type Ratio } from "../../web/render/lcov.ts"; |
| @@ -493,6 +494,30 @@ function dockerfileTable(source: string): Table | string { | ||
| 493 | 494 | return { header: ["Stage", "Instruction", "Arguments"], rows, more: parsed.total - parsed.shown, delimiter: ",", columns: 3 }; |
| 494 | 495 | } |
| 495 | 496 | |
| 497 | /** A GitHub Actions workflow by file name. */ | |
| 498 | function isWorkflowPath(path: string): boolean { | |
| 499 | return kindByName(path) === "workflow"; | |
| 500 | } | |
| 501 | ||
| 502 | /** | |
| 503 | * A workflow as a table: the triggers, then per job its runner, needs and matrix, then one row | |
| 504 | * per step; the web's reason when it is not drawn. Shares the web's parser. | |
| 505 | */ | |
| 506 | function workflowTable(source: string): Table | string { | |
| 507 | const parsed = parseWorkflow(source); | |
| 508 | if ("error" in parsed) return `not drawn as a workflow: ${parsed.error}`; | |
| 509 | const rows: string[][] = [["-", "on", parsed.triggers.join(" ")]]; | |
| 510 | let more = parsed.jobsTotal - parsed.jobs.length; | |
| 511 | for (const j of parsed.jobs) { | |
| 512 | if (j.runsOn !== "") rows.push([j.id, "runs-on", j.runsOn]); | |
| 513 | if (j.needs.length > 0) rows.push([j.id, "needs", j.needs.join(" ")]); | |
| 514 | if (j.matrix.length > 0) rows.push([j.id, "matrix", j.matrix.join("; ")]); | |
| 515 | for (const s of j.steps) rows.push([j.id, s.kind === "" ? "step" : s.kind, [s.name, s.does].filter((t) => t !== "").join(": ").replace(/\s+/g, " ")]); | |
| 516 | more += j.stepsTotal - j.steps.length; | |
| 517 | } | |
| 518 | return { header: ["Job", "Kind", "Value"], rows, more, delimiter: ",", columns: 3 }; | |
| 519 | } | |
| 520 | ||
| 496 | 521 | /** A `.sql` file by name. */ |
| 497 | 522 | function isSqlPath(path: string): boolean { |
| 498 | 523 | return kindByName(path) === "sql"; |
| @@ -770,6 +795,11 @@ class Tab { | ||
| 770 | 795 | this.table = typeof build === "string" ? null : build; |
| 771 | 796 | this.fault = typeof build === "string" ? build : ""; |
| 772 | 797 | if (this.table !== null) this.grid = layoutTable(this.table); |
| 798 | } else if (isWorkflowPath(this.path)) { | |
| 799 | const flow = workflowTable(new TextDecoder().decode(source)); | |
| 800 | this.table = typeof flow === "string" ? null : flow; | |
| 801 | this.fault = typeof flow === "string" ? flow : ""; | |
| 802 | if (this.table !== null) this.grid = layoutTable(this.table); | |
| 773 | 803 | } else if (isSqlPath(this.path)) { |
| 774 | 804 | const schema = sqlTable(new TextDecoder().decode(source)); |
| 775 | 805 | this.table = typeof schema === "string" ? null : schema; |
src/web/render/kinds.ts +7
| @@ -43,6 +43,7 @@ export type FileKind = | ||
| 43 | 43 | | "env" |
| 44 | 44 | | "openapi" |
| 45 | 45 | | "dockerfile" |
| 46 | | "workflow" | |
| 46 | 47 | | "sql" |
| 47 | 48 | | "text" |
| 48 | 49 | | "binary"; |
| @@ -85,6 +86,7 @@ export const RENDER_CAP: Readonly<Record<FileKind, number>> = { | ||
| 85 | 86 | env: 1 * MB, |
| 86 | 87 | openapi: 1 * MB, |
| 87 | 88 | dockerfile: 1 * MB, |
| 89 | workflow: 1 * MB, | |
| 88 | 90 | sql: 1 * MB, |
| 89 | 91 | text: 1 * MB, |
| 90 | 92 | binary: 0, |
| @@ -222,6 +224,9 @@ const isOpenApiName = (path: string): boolean => /(^|\/|\.)openapi\.(json|ya?ml) | ||
| 222 | 224 | /** `Dockerfile`, `Containerfile` and `*.Dockerfile`, in any case. */ |
| 223 | 225 | const isDockerfileName = (path: string): boolean => /(^|\/)(Dockerfile|Containerfile|[^/]+\.Dockerfile)$/i.test(path); |
| 224 | 226 | |
| 227 | /** A GitHub Actions workflow: a `.yml` or `.yaml` directly under `.github/workflows/`. */ | |
| 228 | const isWorkflowName = (path: string): boolean => /(^|\/)\.github\/workflows\/[^/]+\.ya?ml$/i.test(path); | |
| 229 | ||
| 225 | 230 | /** `lcov.info`: the name `lcov` and `bun test` write, whatever its extension. */ |
| 226 | 231 | const isLcovName = (path: string): boolean => /(^|\/)lcov\.info$/i.test(path); |
| 227 | 232 | |
| @@ -295,6 +300,7 @@ export function fileKind(path: string, head: Uint8Array): FileKind { | ||
| 295 | 300 | if (isJunit(path, head)) return "junit"; |
| 296 | 301 | if (isOpenApiName(path)) return "openapi"; |
| 297 | 302 | if (isDockerfileName(path)) return "dockerfile"; |
| 303 | if (isWorkflowName(path)) return "workflow"; | |
| 298 | 304 | return TEXTUAL[extension(path)] ?? "text"; |
| 299 | 305 | } |
| 300 | 306 | |
| @@ -312,6 +318,7 @@ export function kindByName(path: string): FileKind { | ||
| 312 | 318 | if (isEnvExample(path)) return "env"; |
| 313 | 319 | if (isOpenApiName(path)) return "openapi"; |
| 314 | 320 | if (isDockerfileName(path)) return "dockerfile"; |
| 321 | if (isWorkflowName(path)) return "workflow"; | |
| 315 | 322 | return MEDIA[ext]?.kind ?? TEXTUAL[ext] ?? "text"; |
| 316 | 323 | } |
| 317 | 324 |
src/web/render/renderers.ts +2
| @@ -31,6 +31,7 @@ import { notebook } from "./notebook.ts"; | ||
| 31 | 31 | import { patch } from "./patch.ts"; |
| 32 | 32 | import { pdf } from "./pdf.ts"; |
| 33 | 33 | import { dockerfile } from "./dockerfile.ts"; |
| 34 | import { workflow } from "./workflow.ts"; | |
| 34 | 35 | import { openapi } from "./openapi.ts"; |
| 35 | 36 | import { lcov } from "./lcov.ts"; |
| 36 | 37 | import { sarif } from "./sarif.ts"; |
| @@ -105,6 +106,7 @@ export const RENDERERS: Partial<Readonly<Record<FileKind, Renderer>>> = { | ||
| 105 | 106 | sql: sqlView, |
| 106 | 107 | openapi, |
| 107 | 108 | dockerfile, |
| 109 | workflow, | |
| 108 | 110 | mail: eml, |
| 109 | 111 | archive, |
| 110 | 112 | font, |
src/web/render/vendor/yaml.d.mts +1
| @@ -32,6 +32,7 @@ export interface Document { | ||
| 32 | 32 | range: [number, number, number]; |
| 33 | 33 | errors: YAMLError[]; |
| 34 | 34 | warnings: YAMLError[]; |
| 35 | toJS(options?: { maxAliasCount?: number }): unknown; | |
| 35 | 36 | getIn(path: readonly (string | number)[], keepScalar?: boolean): unknown; |
| 36 | 37 | setIn(path: readonly (string | number)[], value: unknown): void; |
| 37 | 38 | deleteIn(path: readonly (string | number)[]): boolean; |
src/web/render/workflow.ts added +147
| @@ -0,0 +1,147 @@ | ||
| 1 | /** | |
| 2 | * A GitHub Actions workflow (`.github/workflows/*.yml|yaml`) as what it does: | |
| 3 | * the `on:` triggers in one line, then one block per job with its runner, | |
| 4 | * its `needs`, its matrix, and each step as one row (`uses`, else the first | |
| 5 | * line of `run`, headed by the step's name when it has one). A file that is | |
| 6 | * not YAML, or not a workflow (no `jobs` map), is not drawn: the source | |
| 7 | * shows with the reason. Every string is escaped and cut to `TEXT_CAP`; at | |
| 8 | * most `JOB_CAP` jobs and `STEP_CAP` steps per job are drawn. | |
| 9 | */ | |
| 10 | ||
| 11 | import { escapeHtml, html, raw } from "../html.ts"; | |
| 12 | import { fileSize } from "../ui.ts"; | |
| 13 | import type { Rendered, RenderInput } from "./renderers.ts"; | |
| 14 | import { parseAllDocuments } from "./vendor/yaml.mjs"; | |
| 15 | ||
| 16 | export const JOB_CAP = 100; | |
| 17 | export const STEP_CAP = 200; | |
| 18 | const TEXT_CAP = 300; | |
| 19 | ||
| 20 | export interface Step { | |
| 21 | readonly name: string; | |
| 22 | /** `uses: x` or the first line of `run`; empty when the step has neither. */ | |
| 23 | readonly does: string; | |
| 24 | readonly kind: "uses" | "run" | ""; | |
| 25 | } | |
| 26 | export interface Job { | |
| 27 | readonly id: string; | |
| 28 | readonly name: string; | |
| 29 | readonly runsOn: string; | |
| 30 | readonly needs: string[]; | |
| 31 | /** `os: a, b` per matrix axis. */ | |
| 32 | readonly matrix: string[]; | |
| 33 | readonly steps: Step[]; | |
| 34 | readonly stepsTotal: number; | |
| 35 | } | |
| 36 | export type Workflow = | |
| 37 | | { readonly name: string; readonly triggers: string[]; readonly jobs: Job[]; readonly jobsTotal: number } | |
| 38 | | { readonly error: string }; | |
| 39 | ||
| 40 | const cut = (s: string): string => s.slice(0, TEXT_CAP); | |
| 41 | const isObj = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v); | |
| 42 | const text = (v: unknown): string => (typeof v === "string" || typeof v === "number" || typeof v === "boolean" ? cut(String(v)) : ""); | |
| 43 | ||
| 44 | /** A string or a list of strings as a list of strings. */ | |
| 45 | const words = (v: unknown): string[] => | |
| 46 | (Array.isArray(v) ? v : v === undefined || v === null ? [] : [v]).map(text).filter((s) => s !== ""); | |
| 47 | ||
| 48 | function triggers(on: unknown): string[] { | |
| 49 | if (isObj(on)) return Object.keys(on).map(cut); | |
| 50 | return words(on); | |
| 51 | } | |
| 52 | ||
| 53 | function matrixOf(strategy: unknown): string[] { | |
| 54 | if (!isObj(strategy) || !isObj(strategy["matrix"])) return []; | |
| 55 | const out: string[] = []; | |
| 56 | for (const [axis, v] of Object.entries(strategy["matrix"])) { | |
| 57 | if (axis === "include" || axis === "exclude") { | |
| 58 | if (Array.isArray(v)) out.push(`${axis}: ${v.length} ${v.length === 1 ? "entry" : "entries"}`); | |
| 59 | continue; | |
| 60 | } | |
| 61 | const vals = words(v); | |
| 62 | out.push(`${cut(axis)}: ${vals.length > 0 ? vals.join(", ") : text(v)}`.slice(0, TEXT_CAP)); | |
| 63 | } | |
| 64 | return out; | |
| 65 | } | |
| 66 | ||
| 67 | function stepOf(raw: unknown): Step { | |
| 68 | if (!isObj(raw)) return { name: "", does: "", kind: "" }; | |
| 69 | const name = text(raw["name"]); | |
| 70 | if (typeof raw["uses"] === "string") return { name, does: cut(raw["uses"]), kind: "uses" }; | |
| 71 | if (typeof raw["run"] === "string") { | |
| 72 | const first = raw["run"].split(/\r?\n/).find((l) => l.trim() !== "") ?? ""; | |
| 73 | return { name, does: cut(first.trim()), kind: "run" }; | |
| 74 | } | |
| 75 | return { name, does: "", kind: "" }; | |
| 76 | } | |
| 77 | ||
| 78 | export function parseWorkflow(source: string): Workflow { | |
| 79 | const docs = parseAllDocuments(source, { maxAliasCount: 100 }); | |
| 80 | const doc = docs[0]; | |
| 81 | if (doc === undefined || docs.length > 1) return { error: "it is not one YAML document" }; | |
| 82 | if (doc.errors.length > 0) return { error: `it is not valid YAML (${cut(doc.errors[0]!.message.split(" at line")[0]!.split("\n")[0]!)})` }; | |
| 83 | let top: unknown; | |
| 84 | try { | |
| 85 | top = doc.toJS({ maxAliasCount: 100 }); | |
| 86 | } catch { | |
| 87 | return { error: "it is not valid YAML" }; | |
| 88 | } | |
| 89 | if (!isObj(top)) return { error: "it is not a map" }; | |
| 90 | const jobsRaw = top["jobs"]; | |
| 91 | if (!isObj(jobsRaw)) return { error: "it has no jobs" }; | |
| 92 | // YAML 1.1 readers turn a bare `on` key into `true`; this one keeps the string, but accept both. | |
| 93 | const on = top["on"] ?? top["true"]; | |
| 94 | const ids = Object.keys(jobsRaw); | |
| 95 | const jobs: Job[] = []; | |
| 96 | for (const id of ids.slice(0, JOB_CAP)) { | |
| 97 | const j = jobsRaw[id]; | |
| 98 | const o = isObj(j) ? j : {}; | |
| 99 | const steps = Array.isArray(o["steps"]) ? o["steps"] : []; | |
| 100 | jobs.push({ | |
| 101 | id: cut(id), | |
| 102 | name: text(o["name"]), | |
| 103 | runsOn: isObj(o["runs-on"]) ? words(o["runs-on"]["labels"] ?? o["runs-on"]["group"]).join(", ") : words(o["runs-on"]).join(", "), | |
| 104 | needs: words(o["needs"]), | |
| 105 | matrix: matrixOf(o["strategy"]), | |
| 106 | steps: steps.slice(0, STEP_CAP).map(stepOf), | |
| 107 | stepsTotal: steps.length, | |
| 108 | }); | |
| 109 | } | |
| 110 | return { name: text(top["name"]), triggers: triggers(on), jobs, jobsTotal: ids.length }; | |
| 111 | } | |
| 112 | ||
| 113 | const code = (s: string): string => `<code>${escapeHtml(s)}</code>`; | |
| 114 | ||
| 115 | export function workflow(input: RenderInput): Rendered { | |
| 116 | const doc = parseWorkflow(new TextDecoder().decode(input.body)); | |
| 117 | if ("error" in doc) return { content: html``, lines: 0, fallback: `This file is shown as YAML because it is not drawn as a workflow: ${doc.error}.` }; | |
| 118 | const on = | |
| 119 | doc.triggers.length === 0 ? "No triggers are set." : `Runs on ${doc.triggers.map(code).join(" ")}.`; // code() escapes | |
| 120 | let out = ""; | |
| 121 | for (const j of doc.jobs) { | |
| 122 | const title = j.name === "" || j.name === j.id ? j.id : `${j.id}: ${j.name}`; | |
| 123 | const facts: [string, string[]][] = [["Runs on", j.runsOn === "" ? [] : [j.runsOn]], ["Needs", j.needs], ["Matrix", j.matrix]]; | |
outdated
Principle 7: runner, needs and matrix are facts about the job; draw them as dl rows rather than loose <p class=small> lines. |
||
| 124 | const dl = facts.filter(([, v]) => v.length > 0); | |
| 125 | const list = | |
| 126 | dl.length === 0 | |
| 127 | ? "" | |
| 128 | : `<dl class="status-rows" aria-label="${escapeHtml(title)} facts">${dl.map(([k, v]) => `<div class="status-row"><dt>${k}</dt><dd>${v.map(code).join(" ")}</dd></div>`).join("")}</dl>`; | |
| 129 | let rows = ""; | |
| 130 | for (const s of j.steps) { | |
| 131 | const doing = s.does === "" ? "" : `${s.kind === "uses" ? "uses " : ""}${code(s.does)}`; | |
| 132 | rows += `<tr><td>${escapeHtml(s.name)}</td><td>${doing}</td></tr>`; | |
| 133 | } | |
| 134 | const table = | |
| 135 | rows === "" | |
| 136 | ? "" | |
| 137 | : `<table class="table" data-dense aria-label="${escapeHtml(title)} steps"><thead><tr><th scope="col">Step</th><th scope="col">Does</th></tr></thead><tbody>${rows}</tbody></table>`; | |
| 138 | const more = j.stepsTotal > j.steps.length ? `<p class="muted small">${(j.stepsTotal - j.steps.length).toLocaleString()} more steps not shown</p>` : ""; | |
| 139 | out += `<section><h3>${escapeHtml(title)}</h3>${list}${table}${more}</section>`; | |
| 140 | } | |
| 141 | if (doc.jobsTotal > doc.jobs.length) out += `<p class="muted small">${(doc.jobsTotal - doc.jobs.length).toLocaleString()} more jobs not shown</p>`; | |
| 142 | return { | |
| 143 | content: html`<div class="workflow-view"><p>${raw(on)}</p>${raw(out)}</div>`, | |
| 144 | lines: 0, | |
| 145 | summary: `${doc.jobsTotal} ${doc.jobsTotal === 1 ? "job" : "jobs"} · ${fileSize(input.size)}`, | |
| 146 | }; | |
| 147 | } |
test/blob-workflow-page.test.ts added +63
| @@ -0,0 +1,63 @@ | ||
| 1 | /** | |
| 2 | * A workflow committed to a repository, opened on its blob page over HTTP: drawn as jobs, escaped, | |
| 3 | * and a broken one shown as YAML with the reason and the Source link. | |
| 4 | */ | |
| 5 | ||
| 6 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 7 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 8 | import { tmpdir } from "node:os"; | |
| 9 | import { join } from "node:path"; | |
| 10 | import { createApp, type App } from "../src/app.ts"; | |
| 11 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 12 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 13 | import { seedAdmin } from "./helpers/admin.ts"; | |
| 14 | import { quietLogs } from "./helpers/globals.ts"; | |
| 15 | ||
| 16 | quietLogs(); | |
| 17 | ||
| 18 | describe("a workflow on its blob page", () => { | |
| 19 | let dataDir = ""; | |
| 20 | let app: App; | |
| 21 | let servers: Servers; | |
| 22 | let base = ""; | |
| 23 | ||
| 24 | beforeAll(async () => { | |
| 25 | dataDir = await mkdtemp(join(tmpdir(), "forge-blob-workflow-")); | |
| 26 | app = createApp({ dataDir, port: 0 }); | |
| 27 | servers = await serve(app); | |
| 28 | base = servers.url; | |
| 29 | const a = await seedAdmin(app, { username: "alice", email: "[email protected]", password: "a-long-patch-passphrase" }); | |
| 30 | if (!a.ok) throw new Error("signup failed"); | |
| 31 | const created = await createRepo(app, a.value, { name: "p", visibility: "public" }); | |
| 32 | if (!created.ok) throw new Error("repo failed"); | |
| 33 | const made = await commitToBranch(app, a.value, created.value, { | |
| 34 | message: "workflows", | |
| 35 | files: [ | |
| 36 | { path: ".github/workflows/ok.yml", content: "on: push\njobs:\n t:\n runs-on: ubuntu-latest\n steps:\n - name: <script>alert(1)</script>\n run: bun test\n" }, | |
| 37 | { path: ".github/workflows/bad.yml", content: "a: [\n" }, | |
| 38 | ], | |
| 39 | }); | |
| 40 | if (!made.ok) throw new Error("commit failed"); | |
| 41 | }, 60_000); | |
| 42 | ||
| 43 | afterAll(async () => { | |
| 44 | await servers?.stop(); | |
| 45 | app?.close(); | |
| 46 | await rm(dataDir, { recursive: true, force: true }); | |
| 47 | }); | |
| 48 | ||
| 49 | test("a workflow is drawn as jobs, its step name escaped", async () => { | |
| 50 | const text = await (await fetch(`${base}/alice/p/blob/main/.github/workflows/ok.yml`)).text(); | |
| 51 | expect(text).toContain("workflow-view"); | |
| 52 | expect(text).toContain("<script>alert(1)</script>"); | |
| 53 | expect(text).not.toContain("<script>alert(1)"); | |
| 54 | }); | |
| 55 | ||
| 56 | test("a broken one shows its YAML with one plain line, and Source is one click away", async () => { | |
| 57 | const text = await (await fetch(`${base}/alice/p/blob/main/.github/workflows/bad.yml`)).text(); | |
| 58 | expect(text).not.toContain("workflow-view"); | |
| 59 | expect(text).toContain("This file is shown as YAML because it is not drawn as a workflow: it is not valid YAML"); | |
| 60 | expect(text).toContain("bad.yml?view=source"); | |
| 61 | expect(text).toContain('<tr id="L1">'); | |
| 62 | }); | |
| 63 | }); |
test/render-workflow.test.ts added +83
| @@ -0,0 +1,83 @@ | ||
| 1 | /** A workflow as its jobs: triggers, needs, a matrix, escaping, the fallback, the registry. */ | |
| 2 | import { describe, expect, test } from "bun:test"; | |
| 3 | import { fileKind, kindByName } from "../src/web/render/kinds.ts"; | |
| 4 | import { RENDERERS } from "../src/web/render/renderers.ts"; | |
| 5 | import { parseWorkflow, workflow } from "../src/web/render/workflow.ts"; | |
| 6 | ||
| 7 | const enc = (s: string): Uint8Array => new TextEncoder().encode(s); | |
| 8 | const draw = (text: string) => workflow({ path: ".github/workflows/ci.yml", size: text.length, body: enc(text), media: "/m", download: "/d" }); | |
| 9 | const html = (r: ReturnType<typeof draw>): string => String(r.content); | |
| 10 | ||
| 11 | const CI = `name: CI | |
| 12 | on: | |
| 13 | push: | |
| 14 | branches: [main] | |
| 15 | pull_request: | |
| 16 | jobs: | |
| 17 | test: | |
| 18 | runs-on: \${{ matrix.os }} | |
| 19 | strategy: | |
| 20 | matrix: | |
| 21 | os: [ubuntu-latest, macos-latest] | |
| 22 | bun: [1, 2] | |
| 23 | steps: | |
| 24 | - uses: actions/checkout@v4 | |
| 25 | - name: <script>alert(1)</script> | |
| 26 | run: | | |
| 27 | bun install | |
| 28 | bun test | |
| 29 | deploy: | |
| 30 | needs: [test] | |
| 31 | runs-on: ubuntu-latest | |
| 32 | steps: | |
| 33 | - run: echo "<img src=x onerror=1>" | |
| 34 | `; | |
| 35 | ||
| 36 | describe("workflow", () => { | |
| 37 | test("the registry names workflow files and a renderer draws them", () => { | |
| 38 | for (const p of [".github/workflows/ci.yml", "a/.github/workflows/x.yaml"]) { | |
| 39 | expect(fileKind(p, enc("on: push"))).toBe("workflow"); | |
| 40 | expect(kindByName(p)).toBe("workflow"); | |
| 41 | } | |
| 42 | expect(fileKind(".github/workflows/notes.md", enc("x"))).not.toBe("workflow"); | |
| 43 | expect(fileKind(".github/other/ci.yml", enc("x"))).not.toBe("workflow"); | |
| 44 | expect(RENDERERS.workflow).toBe(workflow); | |
| 45 | }); | |
| 46 | ||
| 47 | test("triggers, needs, matrix and steps", () => { | |
| 48 | const w = parseWorkflow(CI); | |
| 49 | if ("error" in w) throw new Error(w.error); | |
| 50 | expect(w.triggers).toEqual(["push", "pull_request"]); | |
| 51 | expect(w.jobs.map((j) => j.id)).toEqual(["test", "deploy"]); | |
| 52 | expect(w.jobs[0]!.matrix).toEqual(["os: ubuntu-latest, macos-latest", "bun: 1, 2"]); | |
| 53 | expect(w.jobs[0]!.steps[1]!.does).toBe("bun install"); | |
| 54 | expect(w.jobs[1]!.needs).toEqual(["test"]); | |
| 55 | const out = html(draw(CI)); | |
| 56 | expect(out).toContain("Runs on <code>push</code> <code>pull_request</code>"); | |
| 57 | expect(out).toContain("<dt>Needs</dt><dd><code>test</code></dd>"); | |
| 58 | expect(out).toContain("uses <code>actions/checkout@v4</code>"); | |
| 59 | expect(out).toContain("Matrix"); | |
| 60 | }); | |
| 61 | ||
| 62 | test("every value is escaped", () => { | |
| 63 | const out = html(draw(CI)); | |
| 64 | expect(out).not.toContain("<script>"); | |
| 65 | expect(out).not.toContain("<img"); | |
| 66 | expect(out).toContain("<script>"); | |
| 67 | }); | |
| 68 | ||
| 69 | test("a file that is not a workflow falls back with a reason", () => { | |
| 70 | expect(draw("a: [").fallback).toContain("is shown as YAML because it is not drawn as a workflow: it is not valid YAML"); | |
| 71 | expect(draw("on: push\n").fallback).toContain("no jobs"); | |
Box 2: also assert the fallback result is the one the viewer turns into the YAML view with the plain line (not just the string), and cover multi-document and a matrix with include/exclude. |
||
| 72 | expect(draw("- a\n").fallback).toContain("not a map"); | |
| 73 | }); | |
| 74 | }); | |
| 75 | ||
| 76 | describe("workflow edges", () => { | |
| 77 | test("a matrix with include and exclude, and a file of two documents", () => { | |
| 78 | const w = parseWorkflow("on: push\njobs:\n t:\n runs-on: x\n strategy:\n matrix:\n v: [1]\n include: [{v: 2}, {v: 3}]\n exclude: [{v: 1}]\n"); | |
| 79 | if ("error" in w) throw new Error(w.error); | |
| 80 | expect(w.jobs[0]!.matrix).toEqual(["v: 1", "include: 2 entries", "exclude: 1 entry"]); | |
| 81 | expect(draw("on: push\njobs: {}\n---\na: 1\n").fallback).toContain("not one YAML document"); | |
| 82 | }); | |
| 83 | }); |
test/tui-workspace.test.ts +21
| @@ -639,6 +639,27 @@ describe("the terminal workspace", () => { | ||
| 639 | 639 | } |
| 640 | 640 | }); |
| 641 | 641 | |
| 642 | test("a workflow opens as its jobs, a broken one says why, and s shows the source", async () => { | |
| 643 | const made = await createRepo(forge, alice, { name: "flowdoc", visibility: "public", description: "ci" }); | |
| 644 | if (!made.ok) throw new Error("repo failed"); | |
| 645 | const wf = "on: [push]\njobs:\n build:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@v4\n ship:\n needs: build\n runs-on: ubuntu-latest\n steps:\n - run: bun test\n"; | |
| 646 | await commitToBranch(forge, alice, made.value, { message: "seed", files: [{ path: ".github/workflows/ci.yml", content: wf }, { path: ".github/workflows/bad.yml", content: "a: [\n" }] }); | |
| 647 | const h = boot(); | |
| 648 | try { | |
| 649 | await h.settle(); | |
| 650 | await open(h, "flowdoc"); | |
| 651 | await openFile(h, ".github/workflows/ci.yml"); | |
| 652 | expect(h.text()).toContain("runs-on"); | |
| 653 | expect(h.text()).toContain("needs"); | |
| 654 | expect(h.text()).toContain("actions/checkout@v4"); | |
| 655 | expect(h.text()).toContain("bun test"); | |
| 656 | await h.press("s"); | |
| 657 | expect(h.text()).toContain("runs-on: ubuntu-latest"); | |
| 658 | } finally { | |
| 659 | h.stop(); | |
| 660 | } | |
| 661 | }); | |
| 662 | ||
| 642 | 663 | test("a .sql file opens as its columns, and s shows the source", async () => { |
| 643 | 664 | const made = await createRepo(forge, alice, { name: "sqlrepo", visibility: "public", description: "db" }); |
| 644 | 665 | if (!made.ok) throw new Error("repo failed"); |
Both doors: nothing in src/tui draws a workflow. Add a screen/table in workspace.ts reusing parseWorkflow, with a test, and docs/features/file-workflow.md.