Files
antisocial/test/reddit.test.ts
thatguygriffandClaude Opus 5 5b4378a838
CI / Typecheck, test, build (pull_request) Successful in 26s
Give a video its shape before it has any data
A Reddit video sat in the wrong box until you pressed play, for two reasons
that looked like one.

The renderer put only `aspect-ratio` on the `<video>`. A video with no data
has a natural size of 300x150, and WebKit sizes a replaced element from that
rather than from the ratio, so a portrait video got a squat landscape box and
kept it until playback supplied real dimensions. Chromium stretch-fits
instead and gets it right, which is why this only showed on Safari. A
video's size before its data arrives is its poster's, so one with no poster
of its own now gets an empty SVG of the right shape as a stand-in: a data
URI, so it costs no request. Measured in both engines across portrait,
landscape, square and small.

And Reddit's videos with sound had no poster to be sized by, because
`fromRedditVideo` attached the still from `preview.images` to the MP4 branch
alone. The still belongs to the video, not to the format it is served in, so
every post with sound showed an empty box where a silent one showed a frame.
It now goes on both.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01PLkmgp1fWbA4XbarxKdRKt
2026-08-30 23:37:59 -03:00

304 lines
12 KiB
TypeScript

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<string, unknown> }> } }]>(
'reddit/gallery.json',
)[0].data.children[0]?.data as Parameters<typeof mediaFromLink>[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,
},
},
preview: { images: [{ source: { url: 'https://external-preview.redd.it/still.png', width: 1920, height: 1080 } }] },
});
assert.deepEqual(media, [
{
kind: 'video',
url: 'https://v.redd.it/abc/HLSPlaylist.m3u8',
hls: true,
direct: true,
width: 1920,
height: 1080,
durationSec: 42,
// The still is the video's, not the MP4's. Dropping it here left every
// post with sound showing an empty box where a silent one showed a frame.
poster: { url: 'https://external-preview.redd.it/still.png' },
},
]);
});
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/);
});