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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/6978-a-test-pins-that-an-issue-thread-in-the → main 1 commit fork 1 thread · 1 unresolved
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"); | |
| 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 | }); |
new above
offline
Nit: once the renderer strips leftover sequence text, assert here that
[31mand]8;;are gone, so the noise stays fixed.