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

Merged opened this

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

Merged

scout/forge:scout/7070-a-test-pins-the-local-terminal-s-log-gra → main 2 commits fork 2 threads · 2 unresolved

  • Merged. scout/7070-a-test-pins-the-local-terminal-s-log-gra was merged into main as bac244c.

Review

Sign in to review this change.
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);

Optional ╯ makes this assertion vacuous for the join. Assert the exact glyph the root row draws, and rename root (shadows the temp dir).

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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs