Public: anyone can read this; the crew can change it.
A test pins the terminal's where-strip: every repository screen says where it reads from in plain words, and an origin's host prints inert #6930
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/6909-a-test-pins-the-terminal-s-where-strip-e → main 2 commits fork 1 thread · 1 unresolved
3 files
changed +290
−7
src/tui/screens/contribute.ts +2 −2
| @@ -40,7 +40,7 @@ import type { Session } from "../session.ts"; | ||
| 40 | 40 | import { confirmScreen, errorText, runAsync } from "./common.ts"; |
| 41 | 41 | import { editErrorText } from "./commit.ts"; |
| 42 | 42 | import { showMirrorJob } from "./mirror.ts"; |
| 43 | import { hostOf, liveOrigin, localRepo } from "./where.ts"; | |
| 43 | import { hostLabel, hostOf, liveOrigin, localRepo } from "./where.ts"; | |
| 44 | 44 | |
| 45 | 45 | /** A repository at an origin, as this screen has to name it. */ |
| 46 | 46 | export interface OriginTarget { |
| @@ -71,7 +71,7 @@ export function originTarget(src: RepoSource): OriginTarget | undefined { | ||
| 71 | 71 | return { |
| 72 | 72 | originId: link.originId, |
| 73 | 73 | fullName: link.fullName, |
| 74 | host: hostOf(link.htmlUrl === "" ? link.fullName : link.htmlUrl), | |
| 74 | host: hostLabel(link.htmlUrl, link.fullName), | |
| 75 | 75 | }; |
| 76 | 76 | } |
| 77 | 77 |
src/tui/screens/where.ts +17 −5
| @@ -113,10 +113,22 @@ export function originFileUrl( | ||
| 113 | 113 | : `${base}/src/branch/${at}/${encoded}`; |
| 114 | 114 | } |
| 115 | 115 | |
| 116 | /** `codeberg.org` from `https://codeberg.org/octo/widgets`. */ | |
| 116 | /** | |
| 117 | * `codeberg.org` from `https://codeberg.org/octo/widgets`; "" when it is not | |
| 118 | * a URL, so text a remote sent never reaches the terminal as itself. | |
| 119 | */ | |
| 117 | 120 | export function hostOf(url: string): string { |
| 118 | const parsed = URL.parse(url); | |
| 119 | return parsed === null ? url : parsed.host; | |
| 121 | return URL.parse(url)?.host ?? ""; | |
| 122 | } | |
| 123 | ||
| 124 | /** | |
| 125 | * What names a mirror's origin: its host, or the `owner/name` it was given | |
| 126 | * when the page address is empty or not a URL. That name came from the remote, | |
| 127 | * so C0 and C1 control characters are dropped — it must print inert. | |
| 128 | */ | |
| 129 | export function hostLabel(htmlUrl: string, fullName: string): string { | |
| 130 | // eslint-disable-next-line no-control-regex | |
| 131 | return (hostOf(htmlUrl) || fullName).replace(/[\x00-\x1f\x7f-\x9f]/g, ""); | |
| 120 | 132 | } |
| 121 | 133 | |
| 122 | 134 | /** |
| @@ -156,7 +168,7 @@ export function otherSideLabel( | ||
| 156 | 168 | : "the workbench copy"; |
| 157 | 169 | const link = src.where.repo.origin; |
| 158 | 170 | if (link === null || f.user === undefined) return undefined; |
| 159 | return `live on ${hostOf(link.htmlUrl === "" ? link.fullName : link.htmlUrl)}`; | |
| 171 | return `live on ${hostLabel(link.htmlUrl, link.fullName)}`; | |
| 160 | 172 | } |
| 161 | 173 | |
| 162 | 174 | /** |
| @@ -217,7 +229,7 @@ export function places(f: Session, src: RepoSource): Place[] { | ||
| 217 | 229 | out.push({ |
| 218 | 230 | mark: "present", |
| 219 | 231 | kind: "origin", |
| 220 | text: hostOf(link.htmlUrl === "" ? link.fullName : link.htmlUrl), | |
| 232 | text: hostLabel(link.htmlUrl, link.fullName), | |
| 221 | 233 | }); |
| 222 | 234 | out.push({ |
| 223 | 235 | mark: "reading", |
test/tui-where.test.ts added +271
| @@ -0,0 +1,271 @@ | ||
| 1 | /** | |
| 2 | * The terminal's where-strip: every repository screen says, in plain words, | |
| 3 | * whether it reads live from an origin, from the workbench or from this | |
| 4 | * machine — and what an origin's URL carries never reaches the terminal as | |
| 5 | * a command. | |
| 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 { createRepo } from "../src/actions/repos.ts"; | |
| 13 | import { createApp, type App } from "../src/app.ts"; | |
| 14 | import { createOrigin, linkRepoToOrigin } from "../src/core/origins.ts"; | |
| 15 | import { pin } from "../src/core/pins.ts"; | |
| 16 | import { getRepo, type Repo } from "../src/core/repos.ts"; | |
| 17 | import type { User } from "../src/core/users.ts"; | |
| 18 | import type { Origin } from "../src/origins/types.ts"; | |
| 19 | import { originTarget } from "../src/tui/screens/contribute.ts"; | |
| 20 | import { Session } from "../src/tui/session.ts"; | |
| 21 | import { | |
| 22 | hostLabel, | |
| 23 | hostOf, | |
| 24 | local, | |
| 25 | originKind, | |
| 26 | otherSide, | |
| 27 | otherSideLabel, | |
| 28 | whereRows, | |
| 29 | whereSpans, | |
| 30 | whereStrip, | |
| 31 | } from "../src/tui/screens/where.ts"; | |
| 32 | import { Screen as Surface } from "../src/tui/term/screen.ts"; | |
| 33 | import { theme } from "../src/tui/ui/theme.ts"; | |
| 34 | import type { RepoSource } from "../src/view/source.ts"; | |
| 35 | import { sourceFor } from "../src/view/source.ts"; | |
| 36 | import { quietLogs } from "./helpers/globals.ts"; | |
| 37 | import { seedAdmin } from "./helpers/admin.ts"; | |
| 38 | ||
| 39 | quietLogs(); | |
| 40 | ||
| 41 | const c = theme("dark").c; | |
| 42 | ||
| 43 | let dir: string; | |
| 44 | let app: App; | |
| 45 | let alice: User; | |
| 46 | let native: Repo; | |
| 47 | let mirror: Repo; | |
| 48 | let origin: Origin; | |
| 49 | let remote: RepoSource; | |
| 50 | ||
| 51 | const session = (user: User | undefined): Session => | |
| 52 | new Session(app, { sshUsername: "t", remoteAddress: "127.0.0.1", user }); | |
| 53 | ||
| 54 | /** A live browse of `fullName` at the origin, with a mirror if given. */ | |
| 55 | const live = (o: Origin, mirrored?: Repo): RepoSource => | |
| 56 | ({ | |
| 57 | ...remote, | |
| 58 | where: { | |
| 59 | kind: "origin", | |
| 60 | origin: o, | |
| 61 | fullName: "octo/widgets", | |
| 62 | htmlUrl: `${o.webUrl}/octo/widgets`, | |
| 63 | ...(mirrored === undefined ? {} : { mirroredAs: mirrored }), | |
| 64 | }, | |
| 65 | }) as RepoSource; | |
| 66 | ||
| 67 | /** The two rows a screen gives the strip, as the terminal would show them. */ | |
| 68 | const strip = (f: Session, src: RepoSource, w = 120): string[] => { | |
| 69 | const s = new Surface(w, 2); | |
| 70 | whereStrip(s, { x: 0, y: 0, w, h: whereRows(src) }, c, f, src); | |
| 71 | return s.toText().split("\n"); | |
| 72 | }; | |
| 73 | ||
| 74 | beforeAll(async () => { | |
| 75 | dir = await mkdtemp(join(tmpdir(), "forge-tui-where-")); | |
| 76 | app = createApp({ dataDir: dir, port: 0 }); | |
| 77 | const a = await seedAdmin(app, { | |
| 78 | username: "alice", | |
| 79 | email: "[email protected]", | |
| 80 | password: "a-long-passphrase", | |
| 81 | }); | |
| 82 | if (!a.ok) throw new Error("signup failed"); | |
| 83 | alice = a.value; | |
| 84 | const n = await createRepo(app, alice, { name: "plain", visibility: "public" }); | |
| 85 | const m = await createRepo(app, alice, { name: "widgets", visibility: "public" }); | |
| 86 | if (!n.ok || !m.ok) throw new Error("repo creation failed"); | |
| 87 | const o = createOrigin(app.db, dir, { | |
| 88 | userId: alice.id, | |
| 89 | kind: "forgejo", | |
| 90 | apiUrl: "https://codeberg.org/api/v1", | |
| 91 | webUrl: "https://codeberg.org", | |
| 92 | login: "alice", | |
| 93 | token: "t0ken", | |
| 94 | }); | |
| 95 | if (!o.ok) throw new Error("origin failed"); | |
| 96 | origin = o.value; | |
| 97 | linkRepoToOrigin(app.db, m.value.id, { | |
| 98 | originId: origin.id, | |
| 99 | fullName: "octo/widgets", | |
| 100 | remoteId: "1", | |
| 101 | htmlUrl: "https://codeberg.org/octo/widgets", | |
| 102 | }); | |
| 103 | native = getRepo(app.db, "alice", "plain")!; | |
| 104 | mirror = getRepo(app.db, "alice", "widgets")!; | |
| 105 | remote = local(session(alice), native); | |
| 106 | }); | |
| 107 | ||
| 108 | afterAll(async () => { | |
| 109 | app.db.close(); | |
| 110 | await rm(dir, { recursive: true, force: true }); | |
| 111 | }); | |
| 112 | ||
| 113 | describe("the strip names the one place being read", () => { | |
| 114 | test("a live origin reads from the origin and says read-only", () => { | |
| 115 | const f = session(alice); | |
| 116 | const src = live(origin); | |
| 117 | const [first, second] = strip(f, src); | |
| 118 | expect(whereRows(src)).toBe(2); | |
| 119 | expect(first).toContain("● codeberg.org"); | |
| 120 | expect(first).toContain("○ workbench"); | |
| 121 | expect(first).toContain("○ this machine"); | |
| 122 | expect(first!.split("●").length).toBe(2); | |
| 123 | expect(second).toContain("reading live from codeberg.org"); | |
| 124 | expect(second).toContain("read-only"); | |
| 125 | }); | |
| 126 | ||
| 127 | test("a mirror in the workbench is the current place; its origin is only present", () => { | |
| 128 | const f = session(alice); | |
| 129 | const src = local(f, mirror); | |
| 130 | const rows = strip(f, src); | |
| 131 | expect(whereRows(src)).toBe(1); | |
| 132 | expect(rows[0]).toContain("codeberg.org"); | |
| 133 | expect(rows[0]).toContain("● workbench (never synced)"); | |
| 134 | expect(rows[0]!.split("● workbench").length).toBe(2); | |
| 135 | expect(rows[1]!.trim()).toBe(""); | |
| 136 | const spans = whereSpans(f, src, c); | |
| 137 | const dots = spans.filter((s) => s.text === "●" || s.text === "○"); | |
| 138 | expect(dots.map((s) => s.text)).toEqual(["●", "●", "○"]); | |
| 139 | // accent marks the place being read; the origin dot is success-coloured | |
| 140 | expect(dots[1]!.style.fg).not.toEqual(dots[0]!.style.fg); | |
| 141 | }); | |
| 142 | ||
| 143 | test("a native repository has two places and no origin or banner", () => { | |
| 144 | const f = session(alice); | |
| 145 | const src = local(f, native); | |
| 146 | const rows = strip(f, src); | |
| 147 | expect(rows[0]).toContain("● workbench"); | |
| 148 | expect(rows[0]).toContain("○ this machine"); | |
| 149 | expect(rows[0]).not.toContain("codeberg"); | |
| 150 | expect(rows[1]!.trim()).toBe(""); | |
| 151 | }); | |
| 152 | ||
| 153 | test("this machine is marked, and diamonded, once pinned", () => { | |
| 154 | const f = session(alice); | |
| 155 | const src = local(f, native); | |
| 156 | expect(strip(f, src)[0]).toContain("○ this machine"); | |
| 157 | pin(app.db, alice.id, native.id); | |
| 158 | expect(strip(f, src)[0]).toContain("◆ this machine"); | |
| 159 | }); | |
| 160 | ||
| 161 | test("a live browse with a mirror names it on the banner", () => { | |
| 162 | const f = session(alice); | |
| 163 | const rows = strip(f, live(origin, mirror)); | |
| 164 | expect(rows[0]).toContain("workbench (alice/widgets)"); | |
| 165 | expect(rows[1]).toContain("the workbench copy is alice/widgets"); | |
| 166 | }); | |
| 167 | }); | |
| 168 | ||
| 169 | describe("the other side", () => { | |
| 170 | test("a mirror can switch to its live origin, and says where", () => { | |
| 171 | const f = session(alice); | |
| 172 | const src = local(f, mirror); | |
| 173 | const other = otherSide(f, src); | |
| 174 | expect(other?.where.kind).toBe("origin"); | |
| 175 | expect(otherSideLabel(f, src)).toBe("live on codeberg.org"); | |
| 176 | }); | |
| 177 | ||
| 178 | test("a live browse with a mirror switches to the workbench copy", () => { | |
| 179 | const f = session(alice); | |
| 180 | const src = live(origin, mirror); | |
| 181 | const other = otherSide(f, src); | |
| 182 | expect(other?.where.kind).toBe("local"); | |
| 183 | expect(otherSideLabel(f, src)).toBe("the workbench copy"); | |
| 184 | }); | |
| 185 | ||
| 186 | test("nothing when there is no other side", () => { | |
| 187 | const f = session(alice); | |
| 188 | const native1 = local(f, native); | |
| 189 | expect(otherSide(f, native1)).toBeUndefined(); | |
| 190 | expect(otherSideLabel(f, native1)).toBeUndefined(); | |
| 191 | const bare = live(origin); | |
| 192 | expect(otherSide(f, bare)).toBeUndefined(); | |
| 193 | expect(otherSideLabel(f, bare)).toBeUndefined(); | |
| 194 | }); | |
| 195 | ||
| 196 | test("a guest is offered no live side of a mirror", () => { | |
| 197 | const guest = session(undefined); | |
| 198 | const src = local(guest, mirror); | |
| 199 | expect(otherSide(guest, src)).toBeUndefined(); | |
| 200 | expect(otherSideLabel(guest, src)).toBeUndefined(); | |
| 201 | expect(sourceFor(app, undefined, { originId: origin.id, fullName: "octo/widgets" }).ok).toBe(false); | |
| 202 | }); | |
| 203 | }); | |
| 204 | ||
| 205 | describe("what an origin's URL carries prints inert", () => { | |
| 206 | test("originKind says which forge software, or nothing for a native repo", () => { | |
| 207 | const f = session(alice); | |
| 208 | expect(originKind(live(origin))).toBe("forgejo"); | |
| 209 | expect(originKind(local(f, mirror))).toBe("forgejo"); | |
| 210 | expect(originKind(local(f, native))).toBeUndefined(); | |
| 211 | }); | |
| 212 | ||
| 213 | test("hostOf gives the host and nothing of the path", () => { | |
| 214 | expect(hostOf("https://codeberg.org/octo/widgets")).toBe("codeberg.org"); | |
| 215 | expect(hostOf("https://git.example:8443/a/b")).toBe("git.example:8443"); | |
| 216 | }); | |
| 217 | ||
| 218 | test("a control character in the path never reaches the host", () => { | |
| 219 | expect(hostOf("https://codeberg.org/a\x1b[31mb\x07/c")).toBe("codeberg.org"); | |
| 220 | }); | |
| 221 | ||
| 222 | test("a host that is not a URL gives an empty string", () => { | |
| 223 | expect(hostOf("not a url")).toBe(""); | |
| 224 | expect(hostOf("")).toBe(""); | |
| 225 | expect(hostOf("\x1b]0;pwned\x07")).toBe(""); | |
| 226 | expect(hostOf("https://ev\x1b[2Jil.example/")).toBe(""); | |
| 227 | }); | |
| 228 | ||
| 229 | test("an origin whose web address is hostile paints no control byte", () => { | |
| 230 | const f = session(alice); | |
| 231 | for (const webUrl of [ | |
| 232 | "https://evil.example/\x1b]0;pwned\x07", | |
| 233 | "https://ev\x1b[2Jil.example/", | |
| 234 | "\x1b[31mnot-a-url\x07", | |
| 235 | ]) { | |
| 236 | const hostile = { ...origin, webUrl } as Origin; | |
| 237 | const src = live(hostile); | |
| 238 | const s = new Surface(120, 2); | |
| 239 | whereStrip(s, { x: 0, y: 0, w: 120, h: 2 }, c, f, src); | |
| 240 | // only the renderer's own SGR sequences may carry an escape byte | |
| 241 | const bare = s | |
| 242 | .toStyledText() | |
| 243 | // eslint-disable-next-line no-control-regex | |
| 244 | .replace(/\x1b\[[0-9;:]*m/g, ""); | |
| 245 | // eslint-disable-next-line no-control-regex | |
| 246 | expect(bare).not.toMatch(/[\x00-\x09\x0b-\x1f\x7f-\x9f]/); | |
| 247 | expect(s.toText()).not.toMatch(/[\x00-\x08\x0b-\x1f\x7f]/); | |
| 248 | expect(JSON.stringify(whereSpans(f, src, c))).not.toContain("\\u001b"); | |
| 249 | } | |
| 250 | }); | |
| 251 | ||
| 252 | test("a full name from the remote, with no usable page address, prints inert", () => { | |
| 253 | const f = session(alice); | |
| 254 | for (const fullName of ["octo/\x1b[2Jwidgets", "octo/\x1b]0;x\x07widgets"]) { | |
| 255 | const repo = { | |
| 256 | ...mirror, | |
| 257 | origin: { ...mirror.origin!, htmlUrl: "", fullName }, | |
| 258 | } as Repo; | |
| 259 | const src = local(f, repo); | |
| 260 | const s = new Surface(120, 2); | |
| 261 | whereStrip(s, { x: 0, y: 0, w: 120, h: 2 }, c, f, src); | |
| 262 | // eslint-disable-next-line no-control-regex | |
| 263 | const ctl = /[\x00-\x1f\x7f-\x9f]/; | |
| 264 | expect(s.toText().split("\n")[0]).toContain("octo/"); | |
| 265 | expect(JSON.stringify(whereSpans(f, src, c))).not.toContain("\\u001b"); | |
| 266 | expect(otherSideLabel(f, src)).not.toMatch(ctl); | |
| 267 | expect(originTarget(src)!.host).not.toMatch(ctl); | |
| 268 | expect(hostLabel("", fullName)).not.toMatch(ctl); | |
| 269 | } | |
| 270 | }); | |
| 271 | }); |
new above
offline
Correct place to strip. The regex also removes DEL and C1 (0x7f-0x9f), which covers 8-bit CSI. Consider reusing a shared sanitiser if one exists in src/tui/term so the rule has one home.