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

Merged opened this

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

Merged

scout/forge:scout/6909-a-test-pins-the-terminal-s-where-strip-e → main 2 commits fork 1 thread · 1 unresolved

  • Merged. scout/6909-a-test-pins-the-terminal-s-where-strip-e was merged into main as 0399f05.

Review

Sign in to review this change.
3 files changed +290 −7
src/tui/screens/contribute.ts +2 −2
@@ -40,7 +40,7 @@ import type { Session } from "../session.ts";
4040 import { confirmScreen, errorText, runAsync } from "./common.ts";
4141 import { editErrorText } from "./commit.ts";
4242 import { showMirrorJob } from "./mirror.ts";
43 import { hostOf, liveOrigin, localRepo } from "./where.ts";
43 import { hostLabel, hostOf, liveOrigin, localRepo } from "./where.ts";
4444
4545 /** A repository at an origin, as this screen has to name it. */
4646 export interface OriginTarget {
@@ -71,7 +71,7 @@ export function originTarget(src: RepoSource): OriginTarget | undefined {
7171 return {
7272 originId: link.originId,
7373 fullName: link.fullName,
74 host: hostOf(link.htmlUrl === "" ? link.fullName : link.htmlUrl),
74 host: hostLabel(link.htmlUrl, link.fullName),
7575 };
7676 }
7777
src/tui/screens/where.ts +17 −5
@@ -113,10 +113,22 @@ export function originFileUrl(
113113 : `${base}/src/branch/${at}/${encoded}`;
114114 }
115115
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 */
117120 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, "");

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.

120132 }
121133
122134 /**
@@ -156,7 +168,7 @@ export function otherSideLabel(
156168 : "the workbench copy";
157169 const link = src.where.repo.origin;
158170 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)}`;
160172 }
161173
162174 /**
@@ -217,7 +229,7 @@ export function places(f: Session, src: RepoSource): Place[] {
217229 out.push({
218230 mark: "present",
219231 kind: "origin",
220 text: hostOf(link.htmlUrl === "" ? link.fullName : link.htmlUrl),
232 text: hostLabel(link.htmlUrl, link.fullName),
221233 });
222234 out.push({
223235 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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs