Public: anyone can read this; the crew can change it.

A test pins writing a commit in the local terminal: an empty subject is refused, and a subject past 50 columns is marked #7083

Merged opened this

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

Merged

scout/forge:scout/7069-a-test-pins-writing-a-commit-in-the-loca → main 1 commit fork 1 thread · 1 unresolved

  • Merged. scout/7069-a-test-pins-writing-a-commit-in-the-loca was merged into main as a6d93c8.

Review

Sign in to review this change.
2 files changed +171 −2
src/tui/local/commit.ts +13 −2
@@ -135,8 +135,19 @@ export function commitScreen(ctx: Ctx, dir: string, o: CommitOpts): Screen {
135135 ctx.setCursor(focus === "subject" ? cursor : bodyCursor);
136136 },
137137 paste(chunk) {
138 if (focus === "subject") subject += chunk.replace(/\s+/g, " ");
139 else body.paste(chunk);
138 if (focus === "body") {
139 body.paste(chunk);
140 return true;
141 }
142 // A pasted message is a subject and a body: the first line stays in
143 // the field, the rest moves to the body, where the cursor follows.
144 const [first = "", ...rest] = chunk.replace(/\r\n?/g, "\n").split("\n");
145 subject += first.replace(/\s+/g, " ");
146 const more = rest.join("\n").replace(/^\n+/, "");
147 if (more.trim() !== "") {

Fine: a first line followed only by blank lines stays in the subject and focus doesn't move. That matches the description.

148 body.paste(more);
149 focus = "body";
150 }
140151 return true;
141152 },
142153 key(e) {
test/tui-local-commit.test.ts added +158
@@ -0,0 +1,158 @@
1 /**
2 * The commit screen's message rules, driven through `createLocalApp` over a
3 * real temporary repository: an empty subject is refused, a long one is
4 * marked but still commits, and a pasted message splits into subject + body.
5 */
6
7 import { afterEach, beforeEach, describe, expect, test } from "bun:test";
8 import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
9 import { tmpdir } from "node:os";
10 import { join } from "node:path";
11 import * as git from "../src/git/local.ts";
12 import type { App } from "../src/tui/app/app.ts";
13 import { createLocalApp } from "../src/tui/local/app.ts";
14 import { localBusy, localIdle } from "../src/tui/local/common.ts";
15 import {
16 headlessTerminal,
17 type HeadlessTerminal,
18 } from "../src/tui/term/terminal.ts";
19 import { gitClientEnv } from "./helpers/git-client.ts";
20
21 const IDENTITY = {
22 GIT_AUTHOR_NAME: "Ada",
23 GIT_AUTHOR_EMAIL: "[email protected]",
24 GIT_COMMITTER_NAME: "Ada",
25 GIT_COMMITTER_EMAIL: "[email protected]",
26 };
27
28 async function sh(args: string[], cwd: string): Promise<void> {
29 const proc = Bun.spawn(["git", ...args], {
30 cwd,
31 env: gitClientEnv(IDENTITY),
32 stdout: "pipe",
33 stderr: "pipe",
34 });
35 const [err, code] = await Promise.all([
36 new Response(proc.stderr).text(),
37 proc.exited,
38 ]);
39 if (code !== 0) throw new Error(`git ${args.join(" ")}: ${err}`);
40 }
41
42 const sleep = (ms: number): Promise<void> =>
43 new Promise((resolve) => setTimeout(resolve, ms));
44
45 async function settle(app: App): Promise<void> {
46 for (let i = 0; i < 400; i++) {
47 await localIdle();
48 if (!app.busy && !localBusy()) {
49 await sleep(4);
50 if (!app.busy && !localBusy()) break;
51 }
52 await sleep(4);
53 }
54 app.frame();
55 }
56
57 async function type(
58 term: HeadlessTerminal,
59 app: App,
60 keys: string,
61 ): Promise<void> {
62 term.feed(keys);
63 if (keys.includes("\x1b")) await sleep(40);
64 await settle(app);
65 }
66
67 describe("the local commit screen", () => {
68 let root = "";
69 let dir = "";
70 let term: HeadlessTerminal;
71 let app: App;
72 const ambient: Record<string, string | undefined> = {};
73
74 beforeEach(async () => {
75 // The app inherits this process's identity on purpose; pin it to Ada.
76 for (const [key, value] of Object.entries(IDENTITY)) {
77 ambient[key] = process.env[key];
78 process.env[key] = value;
79 }
80 root = await mkdtemp(join(tmpdir(), "forge-tui-commit-"));
81 dir = join(root, "forge.test", "ada", "workshop");
82 await mkdir(dir, { recursive: true });
83 await sh(["init", "-b", "main", "."], dir);
84 await sh(["config", "user.name", "Ada"], dir);
85 await sh(["config", "user.email", "[email protected]"], dir);
86 await writeFile(join(dir, "poem.txt"), "one\n");
87 await sh(["add", "-A"], dir);
88 await sh(["commit", "-m", "the first commit"], dir);
89 await writeFile(join(dir, "poem.txt"), "one\ntwo\n");
90 await sh(["add", "-A"], dir);
91
92 term = headlessTerminal(120, 40);
93 app = createLocalApp({ root, terminal: term, theme: "dark" }).app;
94 app.start();
95 await settle(app);
96 await type(term, app, "\r"); // open the working tree
97 await type(term, app, "c"); // open the commit screen
98 expect(term.toText()).toContain("1 file");
99 });
100
101 afterEach(async () => {
102 app.stop();
103 for (const [key, value] of Object.entries(ambient)) {
104 if (value === undefined) delete process.env[key];
105 else process.env[key] = value;
106 }
107 await rm(root, { recursive: true, force: true });
108 });
109
110 async function head(): Promise<string> {
111 const m = await git.headMessage(dir);
112 return m.ok ? m.value : "";
113 }
114
115 /** The colour of the subject field's left bar, found via its hint line. */
116 function barColour(): number {
117 const lines = term.toText().split("\n");
118 const hint = lines.findIndex((l) => /\d+ (over|left)/.test(l));
119 expect(hint).toBeGreaterThan(0);
120 const x = lines[hint]!.search(/\d/);
121 return term.screen.styleAt(x, hint - 1).fg as number;
122 }
123
124 for (const subject of ["", " "]) {
125 test(`ctrl+d with subject ${JSON.stringify(subject)} is refused`, async () => {
126 await type(term, app, subject);
127 await type(term, app, "\x04");
128 expect(term.toText()).toContain("a subject is required");
129 expect(await head()).toBe("the first commit");
130 // Still on the commit screen, nothing committed.
131 expect(term.toText()).toContain("Commit");
132 });
133 }
134
135 test("a subject past the limit is marked, by display width, and commits", async () => {
136 // 25 wide characters are 50 columns: at the limit, not over it.
137 await type(term, app, "世".repeat(25));
138 expect(term.toText()).toContain("0 left");
139 const ok = barColour();
140
141 // Two columns more is 25 chars + 1 = 52 columns, though only 26 chars.
142 await type(term, app, "世");
143 expect(term.toText()).toContain("2 over the 50-column subject line");
144 expect(barColour()).not.toBe(ok);
145
146 await type(term, app, "\x04");
147 expect(await head()).toBe("世".repeat(26));
148 });
149
150 test("a pasted first line is the subject and the rest the body", async () => {
151 term.feed("\x1b[200~fix the thing\n\nbecause it was broken\nand slow\x1b[201~");
152 await settle(app);
153 await type(term, app, "\x04");
154 expect(await head()).toBe(
155 "fix the thing\n\nbecause it was broken\nand slow",
156 );
157 });
158 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs