import assert from 'node:assert/strict'; import { test } from 'node:test'; import { commentsFrom, mediaFromLink, 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: '' }]), []); });