Public: anyone can read this; the crew can change it.
A test pins the terminal's history: the log pages through a real "load more" row, and a commit opens its diff #7003
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/6982-a-test-pins-the-terminal-s-history-the-l → main 1 commit fork 1 thread · 1 unresolved
1 file
changed +176
−0
test/tui-commits.test.ts added +176
| @@ -0,0 +1,176 @@ | ||
| 1 | /** | |
| 2 | * The terminal's history, against a real repository: the log pages through | |
| 3 | * a real "Load more…" row, a commit opens its message and diff, and another | |
| 4 | * branch shows its own log. | |
| 5 | */ | |
| 6 | ||
| 7 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 8 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 9 | import { tmpdir } from "node:os"; | |
| 10 | import { join } from "node:path"; | |
| 11 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 12 | import { createApp, type App } from "../src/app.ts"; | |
| 13 | import { getRepo, type Repo } from "../src/core/repos.ts"; | |
| 14 | import type { User } from "../src/core/users.ts"; | |
| 15 | import type { Ctx, Screen } from "../src/tui/app/app.ts"; | |
| 16 | import { commitsScreen } from "../src/tui/screens/commits.ts"; | |
| 17 | import { local } from "../src/tui/screens/where.ts"; | |
| 18 | import { Session } from "../src/tui/session.ts"; | |
| 19 | import { Screen as Surface } from "../src/tui/term/screen.ts"; | |
| 20 | import { theme } from "../src/tui/ui/theme.ts"; | |
| 21 | import { seedAdmin } from "./helpers/admin.ts"; | |
| 22 | import { quietLogs } from "./helpers/globals.ts"; | |
| 23 | ||
| 24 | quietLogs(); | |
| 25 | ||
| 26 | /** The log's page size in src/view/local.ts. */ | |
| 27 | const PAGE = 50; | |
| 28 | const TOTAL = PAGE + 7; | |
| 29 | ||
| 30 | let dir: string; | |
| 31 | let app: App; | |
| 32 | let alice: User; | |
| 33 | let repo: Repo; | |
| 34 | ||
| 35 | const W = 110; | |
| 36 | const H = 80; | |
| 37 | ||
| 38 | /** A stand-in app: records pushed screens, lets the test await loads. */ | |
| 39 | function harness() { | |
| 40 | const pending: Promise<unknown>[] = []; | |
| 41 | const pushed: Screen[] = []; | |
| 42 | const toasts: string[] = []; | |
| 43 | const ctx = { | |
| 44 | theme: theme("dark"), | |
| 45 | push: (s: Screen) => void pushed.push(s), | |
| 46 | pop: () => {}, | |
| 47 | replace: () => {}, | |
| 48 | invalidate: () => {}, | |
| 49 | toast: (t: string) => void toasts.push(t), | |
| 50 | hit: () => {}, | |
| 51 | track: <T>(p: Promise<T>) => { | |
| 52 | pending.push(p); | |
| 53 | return p; | |
| 54 | }, | |
| 55 | } as unknown as Ctx; | |
| 56 | const f = new Session(app, { | |
| 57 | sshUsername: "t", | |
| 58 | remoteAddress: "127.0.0.1", | |
| 59 | user: alice, | |
| 60 | }); | |
| 61 | const settle = async () => { | |
| 62 | while (pending.length > 0) await pending.shift(); | |
| 63 | }; | |
| 64 | const draw = (screen: Screen): string => { | |
| 65 | const s = new Surface(W, H); | |
| 66 | screen.render({ x: 0, y: 0, w: W, h: H }, s); | |
| 67 | return s.toText(); | |
| 68 | }; | |
| 69 | const press = (screen: Screen, key: string, n = 1) => { | |
| 70 | for (let i = 0; i < n; i++) | |
| 71 | screen.key({ type: "key", key, ctrl: false, alt: false, shift: false }); | |
| 72 | }; | |
| 73 | const open = async (ref: string) => { | |
| 74 | const screen = commitsScreen(f, ctx, local(f, repo), ref); | |
| 75 | await screen.load!(new AbortController().signal); | |
| 76 | return screen; | |
| 77 | }; | |
| 78 | return { ctx, f, pushed, toasts, settle, draw, press, open }; | |
| 79 | } | |
| 80 | ||
| 81 | const subjects = (text: string): number[] => | |
| 82 | [...text.matchAll(/change #(\d+)/g)].map((m) => Number(m[1])); | |
| 83 | ||
| 84 | beforeAll(async () => { | |
| 85 | dir = await mkdtemp(join(tmpdir(), "forge-tui-commits-")); | |
| 86 | app = createApp({ dataDir: dir, port: 0 }); | |
| 87 | const a = await seedAdmin(app, { | |
| 88 | username: "alice", | |
| 89 | email: "[email protected]", | |
| 90 | password: "a-long-passphrase", | |
| 91 | }); | |
| 92 | if (!a.ok) throw new Error("signup failed"); | |
| 93 | alice = a.value; | |
| 94 | const made = await createRepo(app, alice, { name: "log", visibility: "public" }); | |
| 95 | if (!made.ok) throw new Error("repo creation failed"); | |
| 96 | repo = getRepo(app.db, "alice", "log")!; | |
| 97 | for (let i = 1; i <= TOTAL; i++) { | |
| 98 | const r = await commitToBranch(app, alice, repo, { | |
| 99 | message: `change #${i}`, | |
| 100 | files: [{ path: "counter.txt", content: `count ${i}\n` }], | |
| 101 | }); | |
| 102 | if (!r.ok) throw new Error(`commit ${i} failed: ${JSON.stringify(r.error)}`); | |
| 103 | } | |
| 104 | const side = await commitToBranch(app, alice, repo, { | |
| 105 | branch: "side", | |
| 106 | message: "side only work", | |
| 107 | files: [{ path: "side.txt", content: "on the side\n" }], | |
| 108 | }); | |
| 109 | if (!side.ok) throw new Error("side branch failed"); | |
| 110 | }, 120_000); | |
| 111 | ||
| 112 | afterAll(async () => { | |
| 113 | app.close(); | |
| 114 | await rm(dir, { recursive: true, force: true }); | |
| 115 | }); | |
| 116 | ||
| 117 | describe("the log pages through a real row", () => { | |
| 118 | test("the first page is newest first and ends in 'Load more…'", async () => { | |
| 119 | const h = harness(); | |
| 120 | const screen = await h.open(repo.defaultBranch); | |
| 121 | const text = h.draw(screen); | |
| 122 | const seen = subjects(text); | |
| 123 | expect(seen[0]).toBe(TOTAL); | |
| 124 | expect(seen).toEqual([...seen].sort((a, b) => b - a)); | |
| 125 | expect(text).toContain("Load more…"); | |
| 126 | expect(text).not.toContain(`change #${TOTAL - PAGE}\n`); | |
| 127 | }); | |
| 128 | ||
| 129 | test("the cursor lands on it and choosing it appends the rest, none twice, none missing", async () => { | |
| 130 | const h = harness(); | |
| 131 | const screen = await h.open(repo.defaultBranch); | |
| 132 | const first = subjects(h.draw(screen)); | |
| 133 | // PAGE commits, then the row: moving down PAGE times is the row. | |
| 134 | h.press(screen, "j", PAGE); | |
| 135 | h.press(screen, "enter"); | |
| 136 | await h.settle(); | |
| 137 | expect(h.pushed).toHaveLength(0); | |
| 138 | const text = h.draw(screen); | |
| 139 | const all = subjects(text); | |
| 140 | expect(first.length).toBeLessThan(all.length); | |
| 141 | expect(all).toHaveLength(new Set(all).size); | |
| 142 | expect(all.length).toBeGreaterThanOrEqual(TOTAL - 1); // the initial commit has another subject | |
| 143 | const want = Array.from({ length: all.length }, (_, i) => TOTAL - i); | |
| 144 | expect(all).toEqual(want); | |
| 145 | expect(text).not.toContain("Load more…"); | |
| 146 | }); | |
| 147 | }); | |
| 148 | ||
| 149 | describe("a commit opens", () => { | |
| 150 | test("choosing it shows its message and its diff", async () => { | |
| 151 | const h = harness(); | |
| 152 | const screen = await h.open(repo.defaultBranch); | |
| 153 | h.press(screen, "j", 2); | |
| 154 | h.press(screen, "enter"); | |
| 155 | expect(h.pushed).toHaveLength(1); | |
| 156 | const detail = h.pushed[0]!; | |
| 157 | await detail.load!(new AbortController().signal); | |
| 158 | const text = h.draw(detail); | |
| 159 | expect(text).toContain(`change #${TOTAL - 2}`); | |
| 160 | expect(text).toContain("counter.txt"); | |
| 161 | expect(text).toContain(`- count ${TOTAL - 3}`); | |
| 162 | expect(text).toContain(`+ count ${TOTAL - 2}`); | |
| 163 | }); | |
| 164 | }); | |
| 165 | ||
| 166 | describe("a branch other than the default", () => { | |
| 167 | test("shows its own log", async () => { | |
| 168 | const h = harness(); | |
| 169 | const screen = await h.open("side"); | |
| 170 | const text = h.draw(screen); | |
| 171 | expect(text).toContain("side only work"); | |
| 172 | expect(text).toContain(`change #${TOTAL}`); | |
| 173 | const main = h.draw(await h.open(repo.defaultBranch)); | |
| 174 | expect(main).not.toContain("side only work"); | |
| 175 | }); | |
| 176 | }); |
new above
offline
Vacuous risk: if toText pads rows with spaces,
\nright after the number never matches. Useexpect(seen).toHaveLength(PAGE).