diff options
| author | Joe Mou <dev@mou.fo> | 2026-09-21 17:55:06 -0400 |
|---|---|---|
| committer | Joe Mou <dev@mou.fo> | 2026-09-25 01:43:53 -0400 |
| commit | 499bba129c842c00c1a28d85bc044ab609aee44e (patch) | |
| tree | bb69fa242a20e3c285fb6c41092b59f207f6855d /src/app.test.ts | |
| parent | 3204ecf1c48e730f02fb83d7c09d2c778a6f6bdd (diff) | |
Add discussions
Discussions are served from GitHub's older server-rendered HTML, with no
embeddedData JSON to read, so the scraper walks the DOM the way the pull
request and wiki code does. A discussion's timeline opens with its own
post and continues with its comments, each carrying its replies nested
inside it, which is where the recursion in _discussionComment.eta comes
from. Categories, upvotes, and the answer a Q&A discussion is marked with
all come along.
Only what GitHub renders up front is shown: neither the next page of
discussions nor the replies it hides behind a "Show N previous replies"
button are fetched, so a comment says how many replies it is missing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'src/app.test.ts')
| -rw-r--r-- | src/app.test.ts | 156 |
1 files changed, 156 insertions, 0 deletions
diff --git a/src/app.test.ts b/src/app.test.ts index 450c1cc..3fdacd5 100644 --- a/src/app.test.ts +++ b/src/app.test.ts @@ -358,6 +358,162 @@ describe("wiki", () => { }); }); +describe("discussions", () => { + const repo = { owner: "jj-vcs", name: "jj" }; + const categories = [ + { slug: "general", name: "General", emoji: "\u{1F4AC}" }, + { slug: "q-a", name: "Q&A", emoji: "\u{1F64F}" }, + ]; + const comment = { + anchor: "discussioncomment-2258263", + author: "martinvonz", + createdAt: "2022-02-26T21:56:47Z", + labels: ["Maintainer"], + bodyHtml: "<p>Good point.</p>", + hiddenReason: null, + isAnswer: false, + replies: [], + hiddenReplies: 0, + }; + const discussion = { + repo, + number: 88, + titleHtml: ""at least" .DS_Store was added back", + author: "arxanas", + category: categories[1], + answerAnchor: null, + body: { ...comment, anchor: "discussion-3903561", author: "arxanas" }, + comments: [comment], + }; + + it("should link every category beside the list", () => { + const html = eta.render("discussions.eta", { + repo, + categories, + category: categories[1], + discussions: [], + q: "hooks", + }); + + assert.match(html, /href="\/jj-vcs\/jj\/discussions">All<\/a>/); + assert.match(html, /href="\/jj-vcs\/jj\/discussions\/categories\/q-a">\u{1F64F} Q&A<\/a>/u); + // The search box keeps the query the list was narrowed by. + assert.match(html, /<input id="search-input"[^>]*value="hooks">/); + assert.match(html, /<option value="discussions" selected>/); + assert.match(html, /No discussions found\./); + }); + + it("should render a row per discussion", () => { + const html = eta.render("discussions.eta", { + repo, + categories, + category: null, + discussions: [ + { + number: 3549, + titleHtml: "Feedback for <code>advance-branches</code>", + author: "emesterhazy", + createdAt: "2024-04-20T14:32:15Z", + category: categories[0], + answered: true, + }, + ], + }); + + assert.match(html, /href="\/jj-vcs\/jj\/discussions\/3549">Feedback for <code>/); + assert.match(html, /href="\/jj-vcs\/jj\/discussions\/categories\/general">General<\/a>/); + assert.match(html, /<span class="answered pill">Answered<\/span>/); + assert.match(html, /<time title="2024-04-20T14:32:15Z">2024-04-20<\/time>/); + }); + + it("should render the discussion above its comments", () => { + const html = eta.render("discussion.eta", discussion); + + assert.match(html, /<title>jj-vcs\/jj - discussions\/88<\/title>/); + assert.match(html, /<h2>"at least" \.DS_Store was added back<\/h2>/); + assert.match( + html, + /#88\s+in <a href="\/jj-vcs\/jj\/discussions\/categories\/q-a">Q&A<\/a>/, + ); + // The author is named on the body comment, not in the header. + assert.match(html, /<a href="\/arxanas">arxanas<\/a>/); + assert.match(html, /<div class="box comment" id="discussion-3903561">/); + // The timestamp is the comment's permalink. + assert.match( + html, + /<a href="#discussioncomment-2258263"><time title="2022-02-26T21:56:47Z">2022-02-26<\/time><\/a>/, + ); + assert.match(html, /<div class="box comment" id="discussioncomment-2258263">/); + assert.match(html, /<span class="pill">Maintainer<\/span>/); + }); + + // The answer is called out both in the header, which links down to it, and + // on the comment itself. + it("should mark the comment that answered the discussion", () => { + const html = eta.render("discussion.eta", { + ...discussion, + answerAnchor: "discussioncomment-2258263", + comments: [{ ...comment, isAnswer: true }], + }); + + assert.match(html, /<a class="answered pill" href="#discussioncomment-2258263">Answered<\/a>/); + assert.match(html, /<div class="box comment answer" id="discussioncomment-2258263">/); + assert.match(html, /<span class="answered pill">Answer<\/span>/); + }); + + it("should run replies on from the comment they answer", () => { + const html = eta.render("_discussionComment.eta", { + comment: { + ...comment, + hiddenReplies: 5, + replies: [{ ...comment, anchor: "discussioncomment-2258264", author: "arxanas" }], + }, + }); + + assert.match( + html, + /<div class="box reply placeholder">\s+5 earlier\s+replies are only on GitHub\./, + ); + assert.match(html, /<div class="box comment reply" id="discussioncomment-2258264">/); + }); + + // A comment GitHub collapsed has nothing to show but why it was collapsed. + it("should render a hidden comment as its reason", () => { + const html = eta.render("_discussionComment.eta", { + comment: { + ...comment, + author: null, + createdAt: null, + labels: [], + bodyHtml: "", + hiddenReason: "This comment has been hidden.", + }, + }); + + assert.match(html, /<div class="box comment" id="discussioncomment-2258263">/); + assert.match(html, /<div class="placeholder">This comment has been hidden\.<\/div>/); + assert.doesNotMatch(html, /<header>/); + assert.doesNotMatch(html, /ghost/); + }); + + it("should send a discussions search to the discussion list", async () => { + const res = await app.request( + "http://cgithub.example/jj-vcs/jj/search?q=hooks&type=discussions", + ); + + assert.strictEqual(res.status, 302); + assert.strictEqual(res.headers.get("location"), "/jj-vcs/jj/discussions?discussions_q=hooks"); + }); + + // GitHub's own actions live where a number goes, so they are not ours. + it("should only scrape a discussion path that names a number", async () => { + const res = await app.request("http://cgithub.example/jj-vcs/jj/discussions/new"); + + assert.strictEqual(res.status, 200); + assert.match(await res.text(), /url=https:\/\/github.com\/jj-vcs\/jj\/discussions\/new"/); + }); +}); + describe("global search", () => { it("should keep the rest of the query around the qualifier", async () => { const res = await app.request( |
