CI / Typecheck, test, build (pull_request) Successful in 26s
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
304 lines
12 KiB
TypeScript
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', () => {
|
|
// `` 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('', GIPHY),
|
|
'',
|
|
);
|
|
});
|
|
|
|
test('the animated form takes the gif, which plays on its own', () => {
|
|
const out = resolveInlineImages('', 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('', UPLOAD),
|
|
'',
|
|
);
|
|
});
|
|
|
|
test('a target that is already an address is left alone', () => {
|
|
const already = '';
|
|
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('', GIPHY), '');
|
|
assert.equal(
|
|
resolveInlineImages('', { gone: { status: 'failed', e: 'Image' } }),
|
|
'',
|
|
);
|
|
});
|
|
|
|
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('', undefined),
|
|
'',
|
|
);
|
|
assert.equal(
|
|
resolveInlineImages('', GIPHY),
|
|
'',
|
|
);
|
|
});
|
|
|
|
test('the metadata still wins where there is any, being what Reddit will serve', () => {
|
|
assert.match(resolveInlineImages('', GIPHY), /redd\.it/);
|
|
});
|
|
|
|
test('a variant name after the id is dropped, not all of them being served', () => {
|
|
assert.equal(
|
|
resolveInlineImages('', undefined),
|
|
'',
|
|
);
|
|
});
|
|
|
|
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(``, undefined), ``);
|
|
}
|
|
});
|
|
|
|
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 ',
|
|
media_metadata: GIPHY,
|
|
replies: '',
|
|
},
|
|
}],
|
|
},
|
|
});
|
|
assert.match(comments[0]?.text ?? '', /external-preview\.redd\.it/);
|
|
});
|