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

A test pins that an issue thread in the terminal never passes a terminal escape from a comment through #6989

Merged opened this

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

Merged

scout/forge:scout/6978-a-test-pins-that-an-issue-thread-in-the → main 1 commit fork 1 thread · 1 unresolved

  • Merged. scout/6978-a-test-pins-that-an-issue-thread-in-the was merged into main as 6f2c306.

Review

Sign in to review this change.
1 file changed +179 −0
test/tui-thread.test.ts added +179
@@ -0,0 +1,179 @@
1 /**
2 * The issue thread in the terminal, against a real forge. A comment is
3 * markdown anyone with access can write, so the question is whether a
4 * terminal escape in it can reach the viewer's terminal: a clipboard write
5 * (OSC 52), a disguised link (OSC 8), a colour that outlives the line, a bell.
6 */
7
8 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
9 import { mkdtemp, rm } from "node:fs/promises";
10 import { tmpdir } from "node:os";
11 import { join } from "node:path";
12 import { addComment, openIssue } from "../src/actions/issues.ts";
13 import { createRepo } from "../src/actions/repos.ts";
14 import { signUp } from "../src/actions/users.ts";
15 import { createApp, type App as Forge } from "../src/app.ts";
16 import { listComments, getIssue } from "../src/core/issues.ts";
17 import type { Repo } from "../src/core/repos.ts";
18 import type { User } from "../src/core/users.ts";
19 import { createForgeApp } from "../src/tui/forge.ts";
20 import type { TuiIdentity } from "../src/tui/session.ts";
21 import { headlessTerminal } from "../src/tui/term/terminal.ts";
22 import { quietLogs } from "./helpers/globals.ts";
23 import { fixtureSlot } from "./helpers/slot.ts";
24 import { seedAdmin } from "./helpers/admin.ts";
25 import { settleApp } from "./helpers/tui.ts";
26
27 quietLogs();
28
29 const ESC = "\x1b";
30 const BEL = "\x07";
31 const BODY = [
32 `Lamp ${ESC}[31mred${ESC}[0m text`,
33 `Link ${ESC}]8;;https://evil.example${BEL}click here${ESC}]8;;${BEL} done`,
34 `Clip ${ESC}]52;c;ZXZpbA==${BEL}copied`,
35 `Bare${BEL}bell`,
36 ].join("\n\n");
37 const COMMENT = [
38 `Reply ${ESC}[1;31mloud${ESC}[0m words`,
39 `${ESC}]52;c;ZXZpbA==${ESC}\\after-st`,
40 `ring${BEL}end`,
41 ].join("\n\n");
42
43 /** Our renderer's own cursor and colour sequences, which are not the text's. */
44 const OWN = /\x1b\[[0-9;:?]*[A-Za-z]/g;
45
46 describe("the issue thread in the terminal", () => {
47 let dataDir: string;
48 let forge: Forge;
49 let alice: User;
50 let bob: User;
51 let tower: Repo;
52
53 const as = (user: User): TuiIdentity => ({
54 sshUsername: user.username,
55 remoteAddress: "127.0.0.1",
56 fingerprint: "SHA256:test",
57 user,
58 });
59
60 function boot(user: User) {
61 const terminal = headlessTerminal(120, 32);
62 const { app } = createForgeApp(forge, as(user), {
63 terminal,
64 exitHooks: false,
65 });
66 app.start();
67 const press = async (key: string): Promise<void> => {
68 const ctrl = key.startsWith("ctrl+");
69 app.dispatch({
70 type: "key",
71 key: ctrl ? key.slice(5) : key,
72 ctrl,
73 alt: false,
74 shift: false,
75 });
76 await settleApp(app);
77 };
78 const typeText = async (text: string): Promise<void> => {
79 for (const ch of text)
80 app.dispatch({ type: "key", key: ch, ctrl: false, alt: false, shift: false });
81 await settleApp(app);
82 };
83 return { app, terminal, press, typeText, text: () => app.screen.toText() };
84 }
85
86 beforeAll(async () => {
87 dataDir = await mkdtemp(join(tmpdir(), "forge-tui-thread-"));
88 forge = createApp({ dataDir, port: 0, disk: () => ({ freeBytes: 95, totalBytes: 100 }) });
89 const a = await seedAdmin(forge, {
90 username: "alice",
91 email: "[email protected]",
92 password: "a-long-passphrase",
93 });
94 const b = await signUp(
95 forge,
96 { username: "bob", email: "[email protected]", password: "another-long-passphrase" },
97 { slot: fixtureSlot(forge) },
98 );
99 if (!a.ok || !b.ok) throw new Error("signup failed");
100 alice = a.value;
101 bob = b.value;
102 const t = await createRepo(forge, alice, { name: "tower", visibility: "public" });
103 const v = await createRepo(forge, alice, { name: "crypt", visibility: "private" });
104 if (!t.ok || !v.ok) throw new Error("repo creation failed");
105 tower = t.value;
106 const issue = openIssue(forge, bob, tower, { title: "Escapes", body: BODY });
107 if (!issue.ok) throw new Error("issue failed");
108 const said = addComment(forge, bob, tower, issue.value, COMMENT);
109 if (!said.ok) throw new Error("comment failed");
110 const secret = openIssue(forge, alice, v.value, { title: "Hidden title zebra" });
111 if (!secret.ok) throw new Error("issue failed");
112 });
113
114 afterAll(async () => {
115 forge.close();
116 await rm(dataDir, { recursive: true, force: true });
117 });
118
119 async function openThread(h: ReturnType<typeof boot>): Promise<void> {
120 await settleApp(h.app);
121 await h.press("/");
122 await h.typeText("tower");
123 await h.press("enter");
124 await h.press("enter");
125 await h.press("i");

Nit: once the renderer strips leftover sequence text, assert here that [31m and ]8;; are gone, so the noise stays fixed.

126 await h.press("enter");
127 }
128
129 test("no escape or bell from an issue body or a comment reaches the output", async () => {
130 const h = boot(alice);
131 await openThread(h);
132 const text = h.text();
133 expect(text).toContain("Escapes");
134 // The words still read, whatever was wrapped around them.
135 for (const word of ["Lamp", "red", "click here", "copied", "Bare", "bell"])
136 expect(text).toContain(word);
137 for (const word of ["Reply", "loud", "words", "end"])
138 expect(text).toContain(word);
139 expect(text).not.toContain(ESC);
140 expect(text).not.toContain(BEL);
141
142 const wire = h.terminal.output();
143 expect(wire).not.toContain(BEL);
144 expect(wire).not.toContain(`${ESC}]`);
145 // Only our own sequences are left once the renderer's are taken out.
146 expect(wire.replace(OWN, "")).not.toContain(ESC);
147 h.app.stop();
148 });
149
150 test("a viewer who cannot read the repository gets the refusal and no title", async () => {
151 const h = boot(bob);
152 await settleApp(h.app);
153 await h.press("ctrl+k");
154 await h.typeText("Open a repository");
155 await h.press("enter");
156 await h.typeText("alice/crypt");
157 await h.press("enter");
158 expect(h.text()).toContain("not found");
159 expect(h.text()).not.toContain("zebra");
160 expect(h.terminal.output()).not.toContain("zebra");
161 h.app.stop();
162 });
163
164 test("the composer posts as the signed-in person and it shows in the timeline", async () => {
165 const h = boot(alice);
166 await openThread(h);
167 await h.press("c");
168 await h.typeText("Fixed it, bell and all.");
169 await h.press("ctrl+d");
170 const issue = getIssue(forge.db, tower.id, 1)!;
171 const mine = listComments(forge.db, issue.id).filter(
172 (c) => c.body === "Fixed it, bell and all.",
173 );
174 expect(mine).toHaveLength(1);
175 expect(mine[0]!.authorId).toBe(alice.id);
176 expect(h.text()).toContain("Fixed it, bell and all.");
177 h.app.stop();
178 });
179 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs