Files
antisocial/test/render.test.ts
T
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

283 lines
11 KiB
TypeScript

import assert from 'node:assert/strict';
import { test } from 'node:test';
import { escapeHtml, html, raw } from '../src/render/html.ts';
import { linkify } from '../src/render/text.ts';
import { renderPost } from '../src/render/post.ts';
import { renderError } from '../src/render/error.ts';
import { oneSegment, type Media, type Post } from '../src/types.ts';
test('interpolations are escaped, Raw values are not', () => {
assert.equal(html`<p>${'<script>alert(1)</script>'}</p>`.value,
'<p>&lt;script&gt;alert(1)&lt;/script&gt;</p>');
assert.equal(html`<p>${raw('<b>ok</b>')}</p>`.value, '<p><b>ok</b></p>');
assert.equal(html`${['a', '<b>']}`.value, 'a&lt;b&gt;');
assert.equal(html`${undefined}${null}${false}`.value, '');
});
test('attribute-breaking characters are escaped', () => {
assert.equal(escapeHtml(`" onload='x'`), '&quot; onload=&#39;x&#39;');
});
test('post text is escaped before links are made', () => {
const out = linkify('<img src=x onerror=alert(1)> https://example.com/a', 'x').value;
assert.ok(!out.includes('<img'), 'markup in post text must not survive');
assert.ok(out.includes('<a href="https://example.com/a"'));
});
test('mentions and hashtags point back at the right platform', () => {
assert.ok(linkify('hi @nasa', 'tiktok').value.includes('https://www.tiktok.com/@nasa'));
assert.ok(linkify('hi @nasa', 'bluesky').value.includes('https://bsky.app/profile/nasa'));
// Threads has no hashtag pages, so a tag stays plain text there.
assert.ok(!linkify('#moon', 'threads').value.includes('<a '));
assert.ok(linkify('#moon', 'instagram').value.includes('explore/tags/moon'));
});
test('newlines become line breaks', () => {
assert.equal(linkify('a\nb', 'x').value, 'a<br>b');
});
function post(overrides: Partial<Post> = {}): Post {
return {
platform: 'instagram',
platformLabel: 'Instagram',
originalUrl: 'https://www.instagram.com/p/ABC/',
author: { handle: '@nasa' },
textPosition: 'below',
segments: oneSegment({
text: 'caption',
media: [{ kind: 'image', url: 'https://cdn/1.jpg' }, { kind: 'image', url: 'https://cdn/2.jpg' }],
}),
...overrides,
};
}
function withMedia(media: Media[], overrides: Partial<Post> = {}): Post {
return post({ segments: oneSegment({ text: 'caption', media }), ...overrides });
}
test('media is proxied, never linked straight at the CDN', () => {
const page = renderPost(post());
assert.ok(!page.includes('https://cdn/1.jpg'), 'upstream URLs must not reach the page');
assert.equal((page.match(/src="\/m\//g) ?? []).length, 2);
});
test('an HLS video is linked directly, because a proxy cannot rewrite a playlist', () => {
const page = renderPost(withMedia([
{ kind: 'video', url: 'https://video.bsky.app/x/playlist.m3u8', hls: true, direct: true },
]));
assert.ok(page.includes('https://video.bsky.app/x/playlist.m3u8'));
});
test('text sits below the media for Instagram and above it for X', () => {
const below = renderPost(post());
assert.ok(below.indexOf('class="media"') < below.indexOf('class="text"'));
const above = renderPost(post({ platform: 'x', platformLabel: 'X', textPosition: 'above' }));
assert.ok(above.indexOf('class="text"') < above.indexOf('class="media"'));
});
test('the layout toggle only appears when there is more than one item', () => {
assert.ok(renderPost(post()).includes('data-view="grid"'));
assert.ok(!renderPost(withMedia([{ kind: 'image', url: 'https://cdn/1.jpg' }]))
.includes('data-view="grid"'));
});
test('a video gets native controls and a source, not an iframe', () => {
const page = renderPost(withMedia([{ kind: 'video', url: 'https://cdn/v.mp4' }]));
assert.ok(page.includes('<video'));
assert.ok(page.includes('controls'));
assert.ok(page.includes('playsinline'));
assert.ok(!page.includes('<iframe'));
});
test('a video with no poster still carries its shape, so the box is right before play', () => {
// WebKit sizes a video from its natural size, which without data or a
// poster is 300x150 — a portrait video sat in a landscape box until you
// pressed play. The poster is where the shape comes from until then.
const page = renderPost(withMedia([
{ kind: 'video', url: 'https://video.example/p.m3u8', hls: true, direct: true, width: 720, height: 1280 },
]));
assert.ok(page.includes('aspect-ratio: 720 / 1280;'));
assert.match(page, /poster="data:image\/svg\+xml,[^"]*width%3D%22720%22[^"]*height%3D%221280%22/);
});
test('a real poster is left in place, and an unmeasured video gets none', () => {
const withPoster = renderPost(withMedia([
{ kind: 'video', url: 'https://cdn/v.mp4', width: 720, height: 1280, poster: { url: 'https://cdn/p.jpg' } },
]));
assert.match(withPoster, /poster="\/m\//);
assert.ok(!withPoster.includes('data:image/svg'));
assert.ok(!renderPost(withMedia([{ kind: 'video', url: 'https://cdn/v.mp4' }])).includes('poster='));
});
test('the copy button carries the clean original URL, and so does the page text', () => {
const page = renderPost(post());
assert.ok(page.includes('data-url="https://www.instagram.com/p/ABC/"'));
assert.ok(page.includes('<code>https://www.instagram.com/p/ABC/</code>'));
});
test('a failure still hands the link back', () => {
const page = renderError({
platform: 'tiktok',
platformLabel: 'TikTok',
originalUrl: 'https://www.tiktok.com/@a/video/1',
heading: 'Could not read that TikTok post',
detail: 'TikTok showed a verification puzzle instead of the post.',
});
assert.ok(page.includes('data-url="https://www.tiktok.com/@a/video/1"'));
assert.ok(page.includes('<code>https://www.tiktok.com/@a/video/1</code>'));
assert.ok(page.includes('verification puzzle'));
// Following the original link in the browser the rewrite rules are
// installed in only comes back here, so it stays hidden until the script
// has a browser to hand it to.
assert.ok(/<a class="original__open"[^>]* hidden>Open on TikTok<\/a>/.test(page));
});
function redditPost(overrides: Partial<Post> = {}): Post {
return post({
platform: 'reddit',
platformLabel: 'Reddit',
originalUrl: 'https://www.reddit.com/r/aww/comments/abc/a/',
author: { handle: 'r/aww' },
textPosition: 'above',
segments: oneSegment({ title: 'A headline', media: [] }),
...overrides,
});
}
test('a post with no comments renders no comment section at all', () => {
assert.ok(!renderPost(redditPost()).includes('class="thread"'));
assert.ok(!renderPost(post()).includes('class="thread"'));
});
test('comments nest, so collapsing one takes its replies with it', () => {
const page = renderPost(redditPost({
comments: [
{
author: 'u/a',
text: 'top',
replies: [{ author: 'u/b', text: 'nested', replies: [] }],
},
],
}));
// The reply is inside the parent's <details>, not a sibling of it.
const parent = page.slice(page.indexOf('<details class="c"'));
const closing = parent.indexOf('</details>');
assert.ok(parent.slice(0, closing).includes('nested'), 'a reply must live inside its parent');
assert.ok(page.includes('<details class="c" open'), 'threads start expanded');
});
test('a comment says how much a fold would hide', () => {
const page = renderPost(redditPost({
comments: [
{
author: 'u/a',
text: 'top',
replies: [
{ author: 'u/b', text: 'one', replies: [{ author: 'u/c', text: 'two', replies: [] }] },
],
},
],
}));
assert.ok(page.includes('+2 replies'), 'the whole subtree is counted, not just direct replies');
});
test('comment text is escaped, and its markdown is rendered', () => {
const page = renderPost(redditPost({
comments: [{ author: 'u/a', text: '**bold** <img src=x onerror=alert(1)>', replies: [] }],
}));
assert.ok(!page.includes('<img src=x'), 'markup in a comment must not survive');
assert.ok(page.includes('<strong>bold</strong>'));
});
test('what the first page did not carry is counted rather than pretended away', () => {
const page = renderPost(redditPost({
comments: [{ author: 'u/a', text: 'x', replies: [], moreReplies: 3 }],
moreComments: 40,
commentCount: 443,
}));
assert.ok(page.includes('3 more replies, on Reddit'));
assert.ok(page.includes('40 more, behind'));
assert.ok(page.includes('1 of 443'));
});
function quoting(quoted: Post['segments'][number]['quoted']): Post {
return post({
platform: 'x',
platformLabel: 'X',
textPosition: 'above',
segments: oneSegment({ text: 'look at this', media: [], quoted }),
});
}
test('a quoted post is drawn with its own author, not folded under the quoter', () => {
const page = quoting({
author: { handle: '@other', displayName: 'Someone Else' },
text: 'the original words',
media: [],
url: 'https://x.com/other/status/9',
});
const html_ = renderPost(page);
assert.ok(html_.includes('class="quote"'));
assert.ok(html_.includes('@other'), 'the quoted handle must be on the page');
assert.ok(html_.includes('Someone Else'));
assert.ok(html_.includes('the original words'));
assert.ok(html_.includes('href="https://x.com/other/status/9"'));
});
test('a quoted post with no quote renders no block', () => {
assert.ok(!renderPost(quoting(undefined)).includes('class="quote"'));
});
test("the quoted post's media is proxied like any other", () => {
const html_ = renderPost(quoting({
author: { handle: '@other' },
media: [{ kind: 'image', url: 'https://pbs.twimg.com/q.jpg' }],
}));
assert.ok(!html_.includes('https://pbs.twimg.com/q.jpg'), 'upstream URLs must not reach the page');
assert.ok(html_.includes('src="/m/'));
});
test('a quoted post is separated from the words that quote it', () => {
const html_ = renderPost(quoting({
author: { handle: '@other' },
text: 'quoted words',
media: [],
}));
// The quoter's text closes before the block opens, so the two can never
// read as one paragraph by the same person.
assert.ok(html_.indexOf('look at this') < html_.indexOf('class="quote"'));
assert.ok(html_.indexOf('class="quote"') < html_.indexOf('quoted words'));
});
test('markup in a quoted post is escaped like any other stranger\'s text', () => {
const html_ = renderPost(quoting({
author: { handle: '<img src=x onerror=alert(1)>' },
text: '<script>alert(1)</script>',
media: [],
}));
assert.ok(!html_.includes('<script>alert(1)</script>'));
assert.ok(!html_.includes('<img src=x'));
});
test('a picture in a comment is proxied, and its alt text cannot break out', () => {
const page = renderPost(redditPost({
comments: [{
author: 'u/a',
text: '![" onerror=alert(1) x="](https://preview.redd.it/x.jpeg?s=abc)\n\nhttps://i.redd.it/y.png',
replies: [],
}],
}));
assert.ok(!page.includes('https://preview.redd.it/x.jpeg'), 'upstream URLs must not reach the page');
assert.ok(!page.includes('https://i.redd.it/y.png'), 'a pasted address is proxied too');
assert.equal((page.match(/<img class="c__img" src="\/m\//g) ?? []).length, 2);
// The payload survives as text inside the attribute, which is the point:
// its quotes are neutered, so it cannot close `alt="` and become markup.
assert.ok(page.includes('alt="&quot; onerror=alert(1) x=&quot;"'), 'alt text must be escaped');
assert.ok(!/alt="" onerror/.test(page), 'the attribute must not be closable');
});