Open History to see the last commit
1import { describe, expect, it } from "vite-plus/test";
2
3import {
4 changeRequestReferenceAutolinker,
5 resolveChangeRequestMediaUrl,
6 splitPullRequestBody,
7 type PullRequestBodySegment,
8} from "./pullRequestMarkdown.ts";
9
10describe("pull request body segmentation", () => {
11 // GitHub writes a dropped image into the body as an `<img>` tag, so a bare attachment link on
12 // its own line is the shape it uses for a video — every one sampled in the wild was one.
13 it("lifts a dropped video out and keeps the prose around it", () => {
14 expect(
15 splitPullRequestBody(
16 "Before\n\nhttps://github.com/user-attachments/assets/2f8c1a90-1b2c-4d5e-8f90-abcdef123456\n\nAfter",
17 ),
18 ).toEqual([
19 { id: "markdown:0", kind: "markdown", text: "Before", sourceOffset: 0 },
20 {
21 id: "attachment:1",
22 kind: "attachment",
23 url: "https://github.com/user-attachments/assets/2f8c1a90-1b2c-4d5e-8f90-abcdef123456",
24 media: "video",
25 },
26 { id: "markdown:2", kind: "markdown", text: "After", sourceOffset: 89 },
27 ]);
28 });
29
30 it("places each prose run at its offset in the body, including inside a media table", () => {
31 const body =
32 '\n\n- [ ] Before\r\n\nhttps://example.com/shot.png\n\n\n<video controls>\n <source src="https://example.com/a.mp4">\n</video>\n - [ ] code\n- [x] After\n\r\n<table>\r\n<tr><td>\r\n\r\n- [ ] In a cell\r\n\r\n</td><td>\r\n\r\nhttps://example.com/b.mp4\r\n\r\n</td></tr>\r\n</table>\r\n- [ ] Last\n';
33 type Prose = Extract<PullRequestBodySegment, { kind: "markdown" }>;
34 const prose = (segments: ReadonlyArray<PullRequestBodySegment>): Prose[] =>
35 segments.flatMap((segment) =>
36 segment.kind === "markdown"
37 ? [segment]
38 : segment.kind === "table"
39 ? segment.rows.flat().flatMap((cell) => prose(cell.segments))
40 : [],
41 );
42 const runs = prose(splitPullRequestBody(body));
43 expect(runs.map((segment) => segment.text)).toEqual([
44 "- [ ] Before",
45 " - [ ] code\n- [x] After",
46 "- [ ] In a cell",
47 "- [ ] Last",
48 ]);
49 for (const segment of runs) {
50 expect(body.slice(segment.sourceOffset, segment.sourceOffset + segment.text.length)).toBe(
51 segment.text,
52 );
53 }
54 });
55
56 it("reads the source out of a video tag, including a multi-line one", () => {
57 expect(
58 splitPullRequestBody(
59 '<video controls>\n <source src="https://example.com/a.webm">\n</video>',
60 ),
61 ).toEqual([
62 {
63 id: "attachment:0",
64 kind: "attachment",
65 url: "https://example.com/a.webm",
66 media: "video",
67 },
68 ]);
69 });
70
71 it("leaves the img tag GitHub writes for a dropped image as markdown", () => {
72 const body =
73 '<img width="1414" alt="image" src="https://github.com/user-attachments/assets/7195f963-51a9-4331-be74-3e06be760422" />';
74 expect(splitPullRequestBody(body)).toEqual([
75 { id: "markdown:0", kind: "markdown", text: body, sourceOffset: 0 },
76 ]);
77 });
78
79 it("leaves an ordinary link alone, whether it is bare or written as markdown", () => {
80 const body = "https://example.com/page\n\n[the docs](https://example.com/docs)";
81 expect(splitPullRequestBody(body)).toEqual([
82 { id: "markdown:0", kind: "markdown", text: body, sourceOffset: 0 },
83 ]);
84 });
85
86 it("never lifts a link inside fenced code out of it", () => {
87 const body = "```\nhttps://example.com/demo.mp4\n```";
88 expect(splitPullRequestBody(body)).toEqual([
89 { id: "markdown:0", kind: "markdown", text: body, sourceOffset: 0 },
90 ]);
91 });
92
93 // GitHub keeps CRLF in a body written in its web editor.
94 it("recognizes fences in a body with CRLF line endings", () => {
95 const body = "```\r\nhttps://example.com/demo.mp4\r\n```";
96 expect(splitPullRequestBody(body)).toEqual([
97 { id: "markdown:0", kind: "markdown", text: body, sourceOffset: 0 },
98 ]);
99 });
100
101 it("closes a CRLF fence, so a video after it still lifts out", () => {
102 const code = '```html\r\n<video src="https://example.com/example.mp4"></video>\r\n```';
103 expect(splitPullRequestBody(`${code}\r\n\r\nhttps://example.com/demo.mp4\r\n`)).toEqual([
104 { id: "markdown:0", kind: "markdown", text: code, sourceOffset: 0 },
105 {
106 id: "attachment:1",
107 kind: "attachment",
108 url: "https://example.com/demo.mp4",
109 media: "video",
110 },
111 ]);
112 });
113
114 it("does not let a different fence marker close an open fence", () => {
115 const body = "```\n~~~\nhttps://example.com/demo.mp4\n```";
116 expect(splitPullRequestBody(body)).toEqual([
117 { id: "markdown:0", kind: "markdown", text: body, sourceOffset: 0 },
118 ]);
119 });
120
121 it("keeps a video tag that shares its line with prose as markdown", () => {
122 const body = 'Before <video src="https://example.com/a.mp4"></video> after';
123 expect(splitPullRequestBody(body)).toEqual([
124 { id: "markdown:0", kind: "markdown", text: body, sourceOffset: 0 },
125 ]);
126 });
127
128 it("treats an info-string fence as a nested opener, not a close", () => {
129 const body = "```\n```javascript\nhttps://example.com/demo.mp4\n```";
130 expect(splitPullRequestBody(body)).toEqual([
131 { id: "markdown:0", kind: "markdown", text: body, sourceOffset: 0 },
132 ]);
133 });
134
135 it("leaves an indented code line alone even when it is only a video link", () => {
136 const body = " https://example.com/demo.mp4";
137 expect(splitPullRequestBody(body)).toEqual([
138 { id: "markdown:0", kind: "markdown", text: body, sourceOffset: 0 },
139 ]);
140 });
141
142 it("leaves an unclosed video tag as prose without eating the rest of the body", () => {
143 const body = ['<video src="https://example.com/a.mp4">', "still prose", "more prose"].join(
144 "\n",
145 );
146 expect(splitPullRequestBody(body)).toEqual([
147 { id: "markdown:0", kind: "markdown", text: body, sourceOffset: 0 },
148 ]);
149 });
150
151 it("refuses a non-http source rather than making a link out of it", () => {
152 const body = '<video src="javascript:alert(1)"></video>';
153 expect(splitPullRequestBody(body)).toEqual([
154 { id: "markdown:0", kind: "markdown", text: body, sourceOffset: 0 },
155 ]);
156 });
157});
158
159describe("media on custom hosts", () => {
160 it.each([
161 ["https://drop.example.com/capture.PNG?token=abc#preview", "image"],
162 ["[Recording](https://drop.example.com/recording.mov)", "video"],
163 ["<https://drop.example.com/shot.webp>", "image"],
164 ])("recognizes %s without a request", (body, media) => {
165 expect(splitPullRequestBody(body)).toEqual([
166 expect.objectContaining({ kind: "attachment", media }),
167 ]);
168 });
169
170 it("keeps inline links and media examples in code as prose", () => {
171 const body =
172 "See [recording](https://drop.example.com/a.mp4) for context.\n\n```md\nhttps://drop.example.com/a.png\n```";
173 expect(splitPullRequestBody(body)).toEqual([
174 { id: "markdown:0", kind: "markdown", text: body, sourceOffset: 0 },
175 ]);
176 });
177});
178
179describe("media tables", () => {
180 const before = "https://github.com/user-attachments/assets/c69c7c1c-ef2f-47dd-97e8-fe42e40712eb";
181 const after = "https://github.com/user-attachments/assets/1867b6ec-4f88-4248-b2df-6b0f9808105b";
182 const video = "https://example.com/a.mp4";
183
184 it("keeps GitHub's before/after video table together, splitting each cell on its own", () => {
185 const segments = splitPullRequestBody(
186 `Intro\n\n<table>\n<tr><th>Before</th><th>After</th></tr>\n<tr>\n<td>\n\n${before}\n\n</td>\n<td>\n\n${after}\n\n</td>\n</tr>\n</table>\n\nOutro`,
187 );
188 expect(segments.map((segment) => segment.kind)).toEqual(["markdown", "table", "markdown"]);
189 expect(segments[0]).toMatchObject({ text: "Intro" });
190 expect(segments[2]).toMatchObject({ text: "Outro" });
191 const table = segments[1]!;
192 if (table.kind !== "table") throw new Error("expected a table segment");
193 expect(
194 table.rows.map((row) =>
195 row.map((cell) =>
196 cell.segments.map((segment) =>
197 segment.kind === "attachment" ? `attachment:${segment.url}` : segment,
198 ),
199 ),
200 ),
201 ).toEqual([
202 [
203 [{ id: "markdown:0", kind: "markdown", text: "Before", sourceOffset: 23 }],
204 [{ id: "markdown:0", kind: "markdown", text: "After", sourceOffset: 38 }],
205 ],
206 [[`attachment:${before}`], [`attachment:${after}`]],
207 ]);
208 });
209
210 it("keeps a quoted `>` inside a cell's attributes out of its text", () => {
211 const [table] = splitPullRequestBody(
212 `<table>\n<tr><td title="Before > after">Caption</td><td>\n\n${video}\n\n</td></tr>\n</table>`,
213 );
214 expect(table).toMatchObject({
215 kind: "table",
216 rows: [[{ segments: [{ kind: "markdown", text: "Caption" }] }, {}]],
217 });
218 });
219
220 it("leaves a table without media links as markdown", () => {
221 const body = "<table>\n<tr><td>a</td><td>b</td></tr>\n</table>";
222 expect(splitPullRequestBody(body)).toEqual([
223 { id: "markdown:0", kind: "markdown", text: body, sourceOffset: 0 },
224 ]);
225 });
226
227 it.each([
228 ["a caption", "<table>\n<caption>Before upgrading</caption>\n<tr><td>", "</td></tr>\n</table>"],
229 ["text outside cells", "<table>\nBefore upgrading\n<tr><td>", "</td></tr>\n</table>"],
230 [
231 "text inside a row but outside cells",
232 "<table>\n<tr>Before upgrading<td>",
233 "</td></tr>\n</table>",
234 ],
235 [
236 "a table nested in another",
237 "<table>\n<tr><td>\n<table>\n<tr><td>",
238 "</td></tr>\n</table>\n</td></tr>\n</table>",
239 ],
240 ["an unclosed table", "<table>\n<tr><td>", ""],
241 ["a spanning cell", '<table>\n<tr><td colspan="2">', "</td></tr>\n</table>"],
242 [
243 "an indented cell",
244 "<table>\n <tr>\n <th>\n Before\n </th>\n </tr>\n <tr><td>",
245 "</td></tr>\n</table>",
246 ],
247 [
248 "a cell indented by a space and a tab",
249 "<table>\n<tr><td>\n \tBefore\n</td><td>",
250 "</td></tr>\n</table>",
251 ],
252 ])("splits a media table with %s line by line, as before", (_name, open, close) => {
253 const body = `${open}\n\n${video}\n\n${close}`;
254 expect(splitPullRequestBody(body)).toEqual([
255 { id: "markdown:0", kind: "markdown", text: open, sourceOffset: 0 },
256 { id: "attachment:1", kind: "attachment", url: video, media: "video" },
257 ...(close
258 ? [
259 {
260 id: "markdown:2",
261 kind: "markdown",
262 text: close,
263 sourceOffset: body.length - close.length,
264 },
265 ]
266 : []),
267 ]);
268 });
269});
270
271describe("change request reference autolinks", () => {
272 const urls = (provider: string, url: string, text: string) =>
273 changeRequestReferenceAutolinker({ provider, url })?.(text).map((link) => link.url) ?? null;
274
275 it("links references against the change request's own repository", () => {
276 expect(urls("github", "https://ghe.example.com/acme/web/pull/7", "Fixes #5")).toEqual([
277 "https://ghe.example.com/acme/web/issues/5",
278 ]);
279 });
280
281 // regression: the mobile review guide linked every host's `#5` with GitHub's grammar
282 it("links nothing on a host whose grammar is not applied", () => {
283 expect(urls("forgejo", "https://codeberg.org/acme/web/pulls/7", "#5")).toBeNull();
284 });
285});
286
287describe("change request media urls", () => {
288 // regression: GitCafe's `/api/attachments/<id>` uploads rendered as missing local files
289 it.each([
290 ["/api/attachments/attach_1", "https://git.cafe/api/attachments/attach_1"],
291 ["//cdn.example.com/a.png", "//cdn.example.com/a.png"],
292 ["/\\evil.example.com/a.png", "/\\evil.example.com/a.png"],
293 ["https://example.com/a.png", "https://example.com/a.png"],
294 ["docs/a.png", "docs/a.png"],
295 ])("resolves %s against the forge", (url, expected) => {
296 expect(resolveChangeRequestMediaUrl(url, "https://git.cafe/pingdotgg/t3code/pulls/31251")).toBe(
297 expected,
298 );
299 });
300});