Public: anyone can read this; the crew can change it.
A test pins the local terminal's log graph across a merge: two lanes that join, and a narrow pane that keeps the graph #7081
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7070-a-test-pins-the-local-terminal-s-log-gra → main 2 commits fork 2 threads · 2 unresolved
1 file
changed +170
−0
test/tui-local-log-graph.test.ts added +170
| @@ -0,0 +1,170 @@ | ||
| 1 | /** | |
| 2 | * The local terminal's log across a merge: two lanes that join, a graph that | |
| 3 | * survives a 40-column pane, and enter on a commit opening its diff. | |
| 4 | */ | |
| 5 | ||
| 6 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 7 | import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; | |
| 8 | import { tmpdir } from "node:os"; | |
| 9 | import { join } from "node:path"; | |
| 10 | import type { App } from "../src/tui/app/app.ts"; | |
| 11 | import { | |
| 12 | headlessTerminal, | |
| 13 | type HeadlessTerminal, | |
| 14 | } from "../src/tui/term/terminal.ts"; | |
| 15 | import { createLocalApp } from "../src/tui/local/app.ts"; | |
| 16 | import { localBusy, localIdle } from "../src/tui/local/common.ts"; | |
| 17 | import { gitClientEnv } from "./helpers/git-client.ts"; | |
| 18 | ||
| 19 | const IDENTITY = { | |
| 20 | GIT_AUTHOR_NAME: "Ada", | |
| 21 | GIT_AUTHOR_EMAIL: "[email protected]", | |
| 22 | GIT_COMMITTER_NAME: "Ada", | |
| 23 | GIT_COMMITTER_EMAIL: "[email protected]", | |
| 24 | }; | |
| 25 | ||
| 26 | async function sh(args: string[], cwd: string): Promise<void> { | |
| 27 | const proc = Bun.spawn(["git", ...args], { | |
| 28 | cwd, | |
| 29 | env: gitClientEnv(IDENTITY), | |
| 30 | stdout: "pipe", | |
| 31 | stderr: "pipe", | |
| 32 | }); | |
| 33 | const [err, code] = await Promise.all([ | |
| 34 | new Response(proc.stderr).text(), | |
| 35 | proc.exited, | |
| 36 | ]); | |
| 37 | if (code !== 0) throw new Error(`git ${args.join(" ")}: ${err}`); | |
| 38 | } | |
| 39 | ||
| 40 | const sleep = (ms: number): Promise<void> => | |
| 41 | new Promise((resolve) => setTimeout(resolve, ms)); | |
| 42 | ||
| 43 | async function settle(app: App): Promise<void> { | |
| 44 | for (let i = 0; i < 400; i++) { | |
| 45 | await localIdle(); | |
| 46 | if (!app.busy && !localBusy()) { | |
| 47 | await sleep(4); | |
| 48 | if (!app.busy && !localBusy()) break; | |
| 49 | } | |
| 50 | await sleep(4); | |
| 51 | } | |
| 52 | app.frame(); | |
| 53 | } | |
| 54 | ||
| 55 | async function type( | |
| 56 | term: HeadlessTerminal, | |
| 57 | app: App, | |
| 58 | keys: string, | |
| 59 | ): Promise<void> { | |
| 60 | term.feed(keys); | |
| 61 | if (keys.includes("\x1b")) await sleep(40); | |
| 62 | await settle(app); | |
| 63 | } | |
| 64 | ||
| 65 | const SUBJECTS = [ | |
| 66 | "root commit", | |
| 67 | "work on main", | |
| 68 | "work on feature", | |
| 69 | "merge feature", | |
| 70 | ]; | |
| 71 | ||
| 72 | describe("the local log graph across a merge", () => { | |
| 73 | let root = ""; | |
| 74 | let term: HeadlessTerminal; | |
| 75 | let app: App; | |
| 76 | const ambient: Record<string, string | undefined> = {}; | |
| 77 | ||
| 78 | beforeAll(async () => { | |
| 79 | for (const [key, value] of Object.entries(IDENTITY)) { | |
| 80 | ambient[key] = process.env[key]; | |
| 81 | process.env[key] = value; | |
| 82 | } | |
| 83 | root = await mkdtemp(join(tmpdir(), "forge-tui-log-graph-")); | |
| 84 | const dir = join(root, "forge.test", "ada", "braid"); | |
| 85 | await mkdir(dir, { recursive: true }); | |
| 86 | await sh(["init", "-b", "main", "."], dir); | |
| 87 | await sh(["config", "user.name", "Ada"], dir); | |
| 88 | await sh(["config", "user.email", "[email protected]"], dir); | |
| 89 | await writeFile(join(dir, "base.txt"), "base\n"); | |
| 90 | await sh(["add", "-A"], dir); | |
| 91 | await sh(["commit", "-m", SUBJECTS[0]!], dir); | |
| 92 | await sh(["checkout", "-b", "feature"], dir); | |
| 93 | await writeFile(join(dir, "feature.txt"), "feature\n"); | |
| 94 | await sh(["add", "-A"], dir); | |
| 95 | await sh(["commit", "-m", SUBJECTS[2]!], dir); | |
| 96 | await sh(["checkout", "main"], dir); | |
| 97 | await writeFile(join(dir, "main.txt"), "main\n"); | |
| 98 | await sh(["add", "-A"], dir); | |
| 99 | await sh(["commit", "-m", SUBJECTS[1]!], dir); | |
| 100 | await sh(["merge", "--no-ff", "-m", SUBJECTS[3]!, "feature"], dir); | |
| 101 | ||
| 102 | term = headlessTerminal(120, 30); | |
| 103 | app = createLocalApp({ root, terminal: term, theme: "dark" }).app; | |
| 104 | app.start(); | |
| 105 | await settle(app); | |
| 106 | await type(term, app, "\r"); // the repo | |
| 107 | await type(term, app, "l"); // its log | |
| 108 | }); | |
| 109 | ||
| 110 | afterAll(async () => { | |
| 111 | app.stop(); | |
| 112 | for (const [key, value] of Object.entries(ambient)) { | |
| 113 | if (value === undefined) delete process.env[key]; | |
| 114 | else process.env[key] = value; | |
| 115 | } | |
| 116 | await rm(root, { recursive: true, force: true }); | |
| 117 | }); | |
| 118 | ||
| 119 | const rowOf = (text: string, subject: string): string => | |
| 120 | text.split("\n").find((l) => l.includes(subject)) ?? ""; | |
| 121 | ||
| 122 | test("two lanes join at the merge, every commit once", () => { | |
| 123 | const text = term.toText(); | |
| 124 | for (const subject of SUBJECTS) | |
| 125 | expect(text.split(subject).length - 1).toBe(1); | |
| 126 | ||
| 127 | const merge = rowOf(text, "merge feature"); | |
| 128 | const rootRow = rowOf(text, "root commit"); | |
| 129 | // The merge opens a second lane; the root is where they meet again. | |
| 130 | expect(merge).toMatch(/^▸ ●╮ {2}merge feature/); | |
| 131 | expect(rootRow).toMatch(/^ {2}●╯ {2}root commit/); | |
| 132 | // Both sides sit between the two, one on each lane. | |
| 133 | expect(rowOf(text, "work on main")).toMatch(/●│\s+work on main/); | |
| 134 | expect(rowOf(text, "work on feature")).toMatch( | |
| 135 | /│●\s+work on feature/, | |
| 136 | ); | |
| 137 | ||
| 138 | const order = SUBJECTS.map((s) => text.indexOf(s)); | |
| 139 | // Newest first: the merge, the feature side, the main side, the root. | |
| 140 | expect(order[3]).toBeLessThan(order[2]!); | |
| 141 | expect(order[2]).toBeLessThan(order[1]!); | |
| 142 | expect(order[1]).toBeLessThan(order[0]!); | |
| 143 | }); | |
| 144 | ||
| 145 | test("at 40 columns the graph column is drawn and nothing overflows", async () => { | |
| 146 | term.resize(40, 30); | |
| 147 | await settle(app); | |
| 148 | const lines = term.toText().split("\n"); | |
| 149 | for (const line of lines) expect([...line].length).toBeLessThanOrEqual(40); | |
| 150 | const merge = lines.find((l) => l.includes("merge")) ?? ""; | |
| 151 | expect(merge).toMatch(/●╮/); | |
| 152 | expect(lines.some((l) => /│●/.test(l))).toBe(true); | |
| 153 | }); | |
| 154 | ||
| 155 | test("enter on a commit opens its diff", async () => { | |
| 156 | term.resize(120, 30); | |
| 157 | await settle(app); | |
| 158 | await type(term, app, "j"); // one below the merge: the feature side | |
| 159 | expect(rowOf(term.toText(), "work on feature")).toMatch(/^▸/); | |
| 160 | expect(term.toText().match(/^▸.*$/gm)).toHaveLength(1); | |
outdated
Which commit 'j' lands on is left open (comment says 'or feature'), and the regex accepts either file. Assert the selected subject first, then that its own file (and only it) shows in the diff. |
||
| 161 | await type(term, app, "\r"); | |
| 162 | const text = term.toText(); | |
| 163 | expect(text).toContain("feature.txt"); | |
| 164 | expect(text).not.toContain("main.txt"); | |
| 165 | expect(text).toContain("commit"); | |
| 166 | expect(text).toContain("Ada"); | |
| 167 | await type(term, app, "\x1b"); | |
| 168 | expect(term.toText()).toContain("merge feature"); | |
| 169 | }); | |
| 170 | }); |
new above
offline
Optional ╯ makes this assertion vacuous for the join. Assert the exact glyph the root row draws, and rename
root(shadows the temp dir).