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

A test pins the local terminal's stash: `p` pops, `d` drops only after a confirm, and cancelling keeps the entry #7079

Merged opened this

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

Merged

scout/forge:scout/7068-a-test-pins-the-local-terminal-s-stash-p → main 1 commit fork

  • Merged. scout/7068-a-test-pins-the-local-terminal-s-stash-p was merged into main as f720c4f.

Review

Sign in to review this change.
1 file changed +192 −0
test/tui-local-stash-verbs.test.ts added +192
@@ -0,0 +1,192 @@
1 /**
2 * The stash screen's verbs beyond enter: `p` pops, `d` drops after a confirm,
3 * and a pop that conflicts says so and keeps the entry. Driven through the
4 * headless terminal over a real temporary repository, as tui-local.test.ts is.
5 */
6
7 import { afterAll, beforeAll, 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 type { App } from "../src/tui/app/app.ts";
12 import {
13 headlessTerminal,
14 type HeadlessTerminal,
15 } from "../src/tui/term/terminal.ts";
16 import { createLocalApp } from "../src/tui/local/app.ts";
17 import { localBusy, localIdle } from "../src/tui/local/common.ts";
18 import { gitClientEnv } from "./helpers/git-client.ts";
19
20 const IDENTITY = {
21 GIT_AUTHOR_NAME: "Ada",
22 GIT_AUTHOR_EMAIL: "[email protected]",
23 GIT_COMMITTER_NAME: "Ada",
24 GIT_COMMITTER_EMAIL: "[email protected]",
25 };
26
27 async function git(args: string[], cwd: string): Promise<string> {
28 const proc = Bun.spawn(["git", ...args], {
29 cwd,
30 env: gitClientEnv(IDENTITY),
31 stdout: "pipe",
32 stderr: "pipe",
33 });
34 const [out, err, code] = await Promise.all([
35 new Response(proc.stdout).text(),
36 new Response(proc.stderr).text(),
37 proc.exited,
38 ]);
39 if (code !== 0) throw new Error(`git ${args.join(" ")}: ${err}`);
40 return out;
41 }
42
43 const sleep = (ms: number): Promise<void> =>
44 new Promise((resolve) => setTimeout(resolve, ms));
45
46 async function settle(app: App): Promise<void> {
47 for (let i = 0; i < 400; i++) {
48 await localIdle();
49 if (!app.busy && !localBusy()) {
50 await sleep(4);
51 if (!app.busy && !localBusy()) break;
52 }
53 await sleep(4);
54 }
55 app.frame();
56 }
57
58 async function type(
59 term: HeadlessTerminal,
60 app: App,
61 keys: string,
62 ): Promise<void> {
63 term.feed(keys);
64 if (keys.includes("\x1b")) await sleep(40);
65 await settle(app);
66 }
67
68 const subjects = async (dir: string): Promise<string[]> =>
69 (await git(["stash", "list", "--format=%gs"], dir))
70 .split("\n")
71 .filter((l) => l !== "");
72
73 describe("the local terminal's stash verbs", () => {
74 let root = "";
75 let dir = "";
76 let term: HeadlessTerminal;
77 let app: App;
78 const ambient: Record<string, string | undefined> = {};
79
80 beforeAll(async () => {
81 for (const [key, value] of Object.entries(IDENTITY)) {
82 ambient[key] = process.env[key];
83 process.env[key] = value;
84 }
85 root = await mkdtemp(join(tmpdir(), "forge-tui-stash-"));
86 dir = join(root, "forge.test", "ada", "workshop");
87 await mkdir(dir, { recursive: true });
88 await git(["init", "-b", "main", "."], dir);
89 await git(["config", "user.name", "Ada"], dir);
90 await git(["config", "user.email", "[email protected]"], dir);
91 await writeFile(join(dir, "a.txt"), "a\n");
92 await writeFile(join(dir, "b.txt"), "b\n");
93 await git(["add", "-A"], dir);
94 await git(["commit", "-m", "first"], dir);
95
96 await writeFile(join(dir, "a.txt"), "a from first stash\n");
97 await git(["stash", "push", "-m", "first stash"], dir);
98 await writeFile(join(dir, "b.txt"), "b from second stash\n");
99 await git(["stash", "push", "-m", "second stash"], dir);
100
101 term = headlessTerminal(120, 40);
102 app = createLocalApp({ root, terminal: term, theme: "dark" }).app;
103 app.start();
104 await settle(app);
105 await type(term, app, "\r"); // library → working tree
106 await type(term, app, "z"); // stashes
107 });
108
109 afterAll(async () => {
110 app.stop();
111 for (const [key, value] of Object.entries(ambient)) {
112 if (value === undefined) delete process.env[key];
113 else process.env[key] = value;
114 }
115 await rm(root, { recursive: true, force: true });
116 });
117
118 test("the diff beside the list follows the cursor", async () => {
119 let text = term.toText();
120 expect(text).toContain("second stash");
121 expect(text).toContain("+ b from second stash");
122 expect(text).not.toContain("a from first stash");
123
124 await type(term, app, "j");
125 text = term.toText();
126 expect(text).toContain("+ a from first stash");
127 expect(text).not.toContain("b from second stash\n+");
128 await type(term, app, "k");
129 expect(term.toText()).toContain("+ b from second stash");
130 });
131
132 test("`d` asks first, and escape changes nothing", async () => {
133 const before = await subjects(dir);
134 expect(before.length).toBe(2);
135
136 await type(term, app, "d");
137 const text = term.toText();
138 expect(text).toContain("Drop stash");
139 expect(text).toContain("A dropped stash cannot be recovered.");
140 // Nothing is dropped while the question is open.
141 expect(await subjects(dir)).toEqual(before);
142
143 await type(term, app, "\x1b");
144 expect(term.toText()).not.toContain("Drop stash");
145 expect(term.toText()).toContain("second stash");
146 expect(term.toText()).toContain("first stash");
147 expect(await subjects(dir)).toEqual(before);
148 });
149
150 test("confirming the drop removes only that entry", async () => {
151 await type(term, app, "j"); // the older one
152 await type(term, app, "d");
153 expect(term.toText()).toContain("Drop stash");
154 // Cancel is first; move to Drop and choose it.
155 await type(term, app, "\x1b[C");
156 await type(term, app, "\r");
157
158 const left = await subjects(dir);
159 expect(left.length).toBe(1);
160 expect(left[0]).toContain("second stash");
161 const text = term.toText();
162 expect(text).toContain("second stash");
163 expect(text).not.toContain("first stash");
164 });
165
166 test("`p` applies the top entry and removes it from the list", async () => {
167 await type(term, app, "p");
168 expect(await Bun.file(join(dir, "b.txt")).text()).toBe(
169 "b from second stash\n",
170 );
171 expect(await subjects(dir)).toEqual([]);
172 expect(term.toText()).toContain("No stashes");
173 });
174
175 test("a pop that conflicts says so and keeps the entry", async () => {
176 await git(["checkout", "--", "."], dir);
177 await writeFile(join(dir, "a.txt"), "a from a stash\n");
178 await git(["stash", "push", "-m", "conflicting stash"], dir);
179 await writeFile(join(dir, "a.txt"), "a changed since\n");
180 await git(["commit", "-am", "change a"], dir);
181
182 await type(term, app, "\x1b"); // back to the working tree
183 await type(term, app, "z");
184 expect(term.toText()).toContain("conflicting stash");
185
186 await type(term, app, "p");
187 expect(await subjects(dir)).toHaveLength(1);
188 const text = term.toText();
189 expect(text).toContain("conflicting stash");
190 expect(text).toMatch(/conflict|overwritten|failed|could not/i);
191 });
192 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs