import assert from 'node:assert/strict'; import { test } from 'node:test'; import { commentsFrom, mediaFromLink, resolveInlineImages, toPost, treeFromDepths } from '../src/platforms/reddit.ts'; import { reddit } from '../src/platforms/reddit.ts'; import { originalUrlFor } from '../src/platforms/index.ts'; import { fixture } from './helpers.ts'; const URL_ = 'https://www.reddit.com/r/aww/comments/abc123/a_post/'; test('a text post is its title, and carries no media', () => { const post = toPost(fixture('reddit/self.json'), URL_); assert.equal(post.platform, 'reddit'); assert.equal(post.author.handle, 'r/AskReddit'); assert.match(post.author.displayName ?? '', /^u\//); assert.equal(post.segments[0]?.title, "What's a healthy food that pleases the taste buds too?"); assert.deepEqual(post.segments[0]?.media, []); assert.ok(post.segments[0]?.postedAt); }); test('a gallery keeps every picture, in the order the post arranged them', () => { const link = fixture<[{ data: { children: Array<{ data: Record }> } }]>( 'reddit/gallery.json', )[0].data.children[0]?.data as Parameters[0]; const media = mediaFromLink(link); const ids = (link.gallery_data?.items ?? []).map((item) => item.media_id); assert.equal(media.length, ids.length); assert.ok(media.every((m) => m.kind === 'image')); // The pictures live in `media_metadata`, keyed and unordered; the order is // only in `gallery_data`, so joining the two is the whole job. media.forEach((item, index) => assert.ok(item.url.includes(String(ids[index])))); assert.ok(media.every((m) => m.width && m.height)); }); test('a silent video is served as the plain MP4, with a real image for a poster', () => { const media = toPost(fixture('reddit/video.json'), URL_).segments[0]?.media ?? []; assert.equal(media.length, 1); const video = media[0]; assert.equal(video?.kind, 'video'); assert.match(video?.url ?? '', /\.mp4/); assert.ok(video?.kind === 'video' && video.poster); // Not the scrubber file, which is itself an MP4 and would render nothing. assert.ok(video?.kind === 'video' && !video.poster?.url.endsWith('.mp4')); assert.equal(video?.kind === 'video' ? video.hls : undefined, undefined); }); test('a video with sound is the HLS playlist, because the MP4 has no audio track', () => { const media = mediaFromLink({ secure_media: { reddit_video: { fallback_url: 'https://v.redd.it/abc/CMAF_720.mp4?source=fallback', hls_url: 'https://v.redd.it/abc/HLSPlaylist.m3u8', has_audio: true, width: 1920, height: 1080, duration: 42, }, }, }); assert.deepEqual(media, [ { kind: 'video', url: 'https://v.redd.it/abc/HLSPlaylist.m3u8', hls: true, direct: true, width: 1920, height: 1080, durationSec: 42, }, ]); }); test('a link post keeps the destination, since it is the whole content of the post', () => { const post = toPost(fixture('reddit/link.json'), URL_); assert.match(post.segments[0]?.text ?? '', /^https:\/\/www\.nytimes\.com\//); }); test('a crosspost shows what it is crossposting', () => { const media = mediaFromLink({ is_self: false, crosspost_parent_list: [ { url_overridden_by_dest: 'https://i.redd.it/inner.jpg', post_hint: 'image' }, ], }); assert.deepEqual(media, [{ kind: 'image', url: 'https://i.redd.it/inner.jpg' }]); }); test('comments come back as a tree, with the counts of what is missing', () => { const post = toPost(fixture('reddit/gallery.json'), URL_); assert.equal(post.comments?.length, 3); assert.ok((post.moreComments ?? 0) > 0, 'the "more comments" node should be counted, not dropped'); assert.equal(post.commentCount, 443); const first = post.comments?.[0]; assert.equal(first?.author, 'u/Background_Round_853'); assert.equal(first?.isAuthor, true, 'the poster replying under their own post'); assert.equal(first?.score, 6583); assert.ok((first?.replies.length ?? 0) > 0); assert.ok(first?.replies.every((reply) => Array.isArray(reply.replies))); }); test('a score the platform is still hiding is left off rather than guessed at', () => { const { comments } = commentsFrom({ data: { children: [ { kind: 't1', data: { author: 'a', body: 'new', score: 1, score_hidden: true, replies: '' } }, { kind: 't1', data: { author: 'b', body: 'older', score: 42, score_hidden: false, replies: '' } }, ], }, }); assert.equal(comments[0]?.score, undefined); assert.equal(comments[1]?.score, 42); }); test('a deleted commenter keeps the platform\'s own word for it', () => { const { comments } = commentsFrom({ data: { children: [{ kind: 't1', data: { author: '[deleted]', body: '[removed]', replies: '' } }] }, }); assert.equal(comments[0]?.author, '[deleted]'); }); test('an empty reply listing is the string "", not an object', () => { // Reddit says "no replies" with an empty string, which is the shape most // likely to be read as a listing and crash the walk. const { comments } = commentsFrom({ data: { children: [{ kind: 't1', data: { author: 'a', body: 'x', replies: '' } }] }, }); assert.deepEqual(comments[0]?.replies, []); }); test('a redd.it share code is rebuilt, since the rewrite drops the host', () => { assert.equal(originalUrlFor(reddit, 'abc123', ''), 'https://redd.it/abc123'); assert.equal( originalUrlFor(reddit, 'r/aww/comments/abc123/a_post/', '?share_id=xyz&utm_source=share'), 'https://www.reddit.com/r/aww/comments/abc123/a_post/', ); }); test('reddit links route to the adapter, and its media hosts do not', () => { assert.ok(reddit.matchesHost('www.reddit.com')); assert.ok(reddit.matchesHost('old.reddit.com')); assert.ok(reddit.matchesHost('redd.it')); assert.ok(!reddit.matchesHost('bsky.app')); }); test('the page fallback rebuilds nesting from the depth on each comment', () => { // Reddit renders the tree flat, so depth is the only thing saying what // replies to what. const tree = treeFromDepths([ { depth: 0, author: 'a', score: 5, created: '', text: 'first' }, { depth: 1, author: 'b', score: 4, created: '', text: 'under first' }, { depth: 2, author: 'c', score: 3, created: '', text: 'under b' }, // Back up two levels: this belongs to `first`, not to `c`. { depth: 1, author: 'd', score: 2, created: '', text: 'also under first' }, { depth: 0, author: 'e', score: 1, created: '', text: 'second' }, ]); assert.equal(tree.length, 2); assert.equal(tree[0]?.replies.length, 2); assert.equal(tree[0]?.replies[0]?.replies[0]?.text, 'under b'); assert.equal(tree[0]?.replies[1]?.text, 'also under first'); assert.deepEqual(tree[1]?.replies, []); }); test('a comment the page gave no text for is dropped rather than shown empty', () => { assert.deepEqual(treeFromDepths([{ depth: 0, author: 'a', score: 1, created: '', text: '' }]), []); }); // Real shapes, captured from comments carrying each kind. const GIPHY = { 'giphy|Ve7wX45gaOFmw8eeEM': { status: 'valid', e: 'AnimatedImage', m: 'image/gif', s: { y: 200, x: 304, gif: 'https://external-preview.redd.it/CTp8.gif?width=304&height=200&s=b0e9', mp4: 'https://external-preview.redd.it/CTp8.gif?width=304&height=200&format=mp4&s=a389', }, }, }; const UPLOAD = { jo8gf0ca92zd1: { status: 'valid', e: 'Image', m: 'image/jpeg', s: { y: 1270, x: 1274, u: 'https://preview.redd.it/jo8gf0ca92zd1.jpeg?width=1274&s=c226' }, }, }; test('a giphy comment points at the gif rather than at a token', () => { // `![gif](giphy|ID)` is not an address, and renders as nothing at all until // it is looked up in the comment's own media_metadata. assert.equal( resolveInlineImages('![gif](giphy|Ve7wX45gaOFmw8eeEM)', GIPHY), '![gif](https://external-preview.redd.it/CTp8.gif?width=304&height=200&s=b0e9)', ); }); test('the animated form takes the gif, which plays on its own', () => { const out = resolveInlineImages('![gif](giphy|Ve7wX45gaOFmw8eeEM)', GIPHY); assert.ok(out.includes('.gif?'), out); assert.ok(!out.includes('format=mp4'), 'an mp4 would need a player to move'); }); test('an uploaded image resolves through the same lookup', () => { // Giphy, emotes and uploads are all a token that is a key in the same map, // so none of them needs naming. assert.equal( resolveInlineImages('![img](jo8gf0ca92zd1)', UPLOAD), '![img](https://preview.redd.it/jo8gf0ca92zd1.jpeg?width=1274&s=c226)', ); }); test('a target that is already an address is left alone', () => { const already = '![a](https://i.redd.it/x.jpg)'; assert.equal(resolveInlineImages(already, UPLOAD), already); assert.equal(resolveInlineImages(already, undefined), already); }); test('a token with no entry, or a broken one, is not invented', () => { // An upload id and an emote id mean nothing off Reddit, so with no entry to // look them up in there is nothing to point them at. assert.equal(resolveInlineImages('![img](missing)', GIPHY), '![img](missing)'); assert.equal( resolveInlineImages('![img](gone)', { gone: { status: 'failed', e: 'Image' } }), '![img](gone)', ); }); test('a giphy token resolves even when the comment carried no metadata', () => { // Reddit ships plenty of these with no `media_metadata` at all. The id in // the token is Giphy's own, so it does not need Reddit to be readable. assert.equal( resolveInlineImages('![gif](giphy|zUW23b6FmzB5e)', undefined), '![gif](https://i.giphy.com/media/zUW23b6FmzB5e/giphy.gif)', ); assert.equal( resolveInlineImages('![gif](giphy|missing)', GIPHY), '![gif](https://i.giphy.com/media/missing/giphy.gif)', ); }); test('the metadata still wins where there is any, being what Reddit will serve', () => { assert.match(resolveInlineImages('![gif](giphy|Ve7wX45gaOFmw8eeEM)', GIPHY), /redd\.it/); }); test('a variant name after the id is dropped, not all of them being served', () => { assert.equal( resolveInlineImages('![gif](giphy|zUW23b6FmzB5e|downsized)', undefined), '![gif](https://i.giphy.com/media/zUW23b6FmzB5e/giphy.gif)', ); }); test('only a giphy token is guessed at, and only a well-formed one', () => { for (const token of ['emote|t5_2th52|4358', 'giphy|', 'giphy|../evil', 'giphy|a|b|c']) { assert.equal(resolveInlineImages(`![x](${token})`, undefined), `![x](${token})`); } }); test('a real comment carrying a giphy token gets the gif', () => { // Captured from the post itself: the comment has the token and no // `media_metadata`, which is the shape that used to show the token instead. const post = toPost(fixture('reddit/video.json'), URL_); const all: string[] = []; const walk = (list: typeof post.comments) => { for (const comment of list ?? []) { if (comment.text) all.push(comment.text); walk(comment.replies); } }; walk(post.comments); const gif = all.find((text) => text.includes('giphy')); assert.ok(gif, 'the fixture should still carry a giphy comment'); assert.match(gif, /!\[gif\]\(https:\/\/i\.giphy\.com\/media\/QfzMP70zmNQiDf5sGP\/giphy\.gif\)/); }); test('inline images survive the walk into the comment tree', () => { const { comments } = commentsFrom({ data: { children: [{ kind: 't1', data: { author: 'a', body: 'ha ![gif](giphy|Ve7wX45gaOFmw8eeEM)', media_metadata: GIPHY, replies: '', }, }], }, }); assert.match(comments[0]?.text ?? '', /external-preview\.redd\.it/); });