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

Merged opened this

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

Merged

mason/forge:mason/11223-a-github-actions-workflow-github-workflo → main 5 commits fork 3 threads · 3 unresolved

  • Merged. mason/11223-a-github-actions-workflow-github-workflo was merged into main as df0381c.

Review

Sign in to review this change.
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
5252
5353 `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`.
5454
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
5559 ## SQL schema (*.sql)
5660
5761 `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";
6262 import { CASE_CAP, parseJunit } from "../../web/render/junit.ts";
6363 import { parseOpenApi } from "../../web/render/openapi.ts";
6464 import { parseDockerfile } from "../../web/render/dockerfile.ts";
65 import { parseWorkflow } from "../../web/render/workflow.ts";
6566 import { parseSql } from "../../web/render/sql.ts";
6667 import { RESULT_CAP, parseSarif } from "../../web/render/sarif.ts";
6768 import { RECORD_CAP, parseLcov, worstFirst, type Ratio } from "../../web/render/lcov.ts";
@@ -493,6 +494,30 @@ function dockerfileTable(source: string): Table | string {
493494 return { header: ["Stage", "Instruction", "Arguments"], rows, more: parsed.total - parsed.shown, delimiter: ",", columns: 3 };
494495 }
495496
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
496521 /** A `.sql` file by name. */
497522 function isSqlPath(path: string): boolean {
498523 return kindByName(path) === "sql";
@@ -770,6 +795,11 @@ class Tab {
770795 this.table = typeof build === "string" ? null : build;
771796 this.fault = typeof build === "string" ? build : "";
772797 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);
773803 } else if (isSqlPath(this.path)) {
774804 const schema = sqlTable(new TextDecoder().decode(source));
775805 this.table = typeof schema === "string" ? null : schema;
src/web/render/kinds.ts +7
@@ -43,6 +43,7 @@ export type FileKind =
4343 | "env"
4444 | "openapi"
4545 | "dockerfile"
46 | "workflow"
4647 | "sql"
4748 | "text"
4849 | "binary";
@@ -85,6 +86,7 @@ export const RENDER_CAP: Readonly<Record<FileKind, number>> = {
8586 env: 1 * MB,
8687 openapi: 1 * MB,
8788 dockerfile: 1 * MB,
89 workflow: 1 * MB,
8890 sql: 1 * MB,
8991 text: 1 * MB,
9092 binary: 0,
@@ -222,6 +224,9 @@ const isOpenApiName = (path: string): boolean => /(^|\/|\.)openapi\.(json|ya?ml)
222224 /** `Dockerfile`, `Containerfile` and `*.Dockerfile`, in any case. */
223225 const isDockerfileName = (path: string): boolean => /(^|\/)(Dockerfile|Containerfile|[^/]+\.Dockerfile)$/i.test(path);
224226
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
225230 /** `lcov.info`: the name `lcov` and `bun test` write, whatever its extension. */
226231 const isLcovName = (path: string): boolean => /(^|\/)lcov\.info$/i.test(path);
227232
@@ -295,6 +300,7 @@ export function fileKind(path: string, head: Uint8Array): FileKind {
295300 if (isJunit(path, head)) return "junit";
296301 if (isOpenApiName(path)) return "openapi";
297302 if (isDockerfileName(path)) return "dockerfile";
303 if (isWorkflowName(path)) return "workflow";
298304 return TEXTUAL[extension(path)] ?? "text";
299305 }
300306
@@ -312,6 +318,7 @@ export function kindByName(path: string): FileKind {
312318 if (isEnvExample(path)) return "env";
313319 if (isOpenApiName(path)) return "openapi";
314320 if (isDockerfileName(path)) return "dockerfile";
321 if (isWorkflowName(path)) return "workflow";
315322 return MEDIA[ext]?.kind ?? TEXTUAL[ext] ?? "text";
316323 }
317324
src/web/render/renderers.ts +2
@@ -31,6 +31,7 @@ import { notebook } from "./notebook.ts";
3131 import { patch } from "./patch.ts";
3232 import { pdf } from "./pdf.ts";
3333 import { dockerfile } from "./dockerfile.ts";
34 import { workflow } from "./workflow.ts";
3435 import { openapi } from "./openapi.ts";
3536 import { lcov } from "./lcov.ts";
3637 import { sarif } from "./sarif.ts";
@@ -105,6 +106,7 @@ export const RENDERERS: Partial<Readonly<Record<FileKind, Renderer>>> = {
105106 sql: sqlView,
106107 openapi,
107108 dockerfile,
109 workflow,
108110 mail: eml,
109111 archive,
110112 font,
src/web/render/vendor/yaml.d.mts +1
@@ -32,6 +32,7 @@ export interface Document {
3232 range: [number, number, number];
3333 errors: YAMLError[];
3434 warnings: YAMLError[];
35 toJS(options?: { maxAliasCount?: number }): unknown;
3536 getIn(path: readonly (string | number)[], keepScalar?: boolean): unknown;
3637 setIn(path: readonly (string | number)[], value: unknown): void;
3738 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

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.

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("&lt;script&gt;alert(1)&lt;/script&gt;");
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("&lt;script&gt;");
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", () => {
639639 }
640640 });
641641
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
642663 test("a .sql file opens as its columns, and s shows the source", async () => {
643664 const made = await createRepo(forge, alice, { name: "sqlrepo", visibility: "public", description: "db" });
644665 if (!made.ok) throw new Error("repo failed");

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs