Files
antisocial/test/render.test.ts
T
thatguygriffandClaude Opus 5 60a9468875
CI / Typecheck, test, build (pull_request) Successful in 46s
Show the author's own chain on Bluesky and Threads
People write in chains on both, and a link into one arrives pointing at a
single post out of several. Showing only that post loses the thing that was
being said. Other people's replies are a different matter: they are a
conversation rather than the thing that was shared, and on a busy post there
are hundreds of them.

A Post is now a list of Segments instead of one body. Most platforms produce
exactly one and say so through oneSegment(); the two that thread produce the
whole chain, with isAnchor marking the post that was actually linked, which
need not be the first.

Bluesky walks parent upward and the author's own replies downward, stopping at
the first post by anyone else. That needs depth and parentHeight on
getPostThread, which drags the entire reply tree along -- a few hundred KB on a
popular post -- because there is no way to ask the API for one author's branch.

Threads is harder to read. The page ships the linked post, the author's
follow-ups, other people's replies and a pile of unrelated recommendations, all
as flat thread_items containers with no nesting to go on. What separates a
follow-up from a stranger's reply is that a follow-up is the author replying to
themselves; a reply from someone else carries the same reply_to_author with a
different name on it. The first post of a chain replies to nothing at all, so
it is reachable only by walking backwards from the post that answers it -- a
test caught that, when linking the second post of a thread returned just the
one post.

Fixtures for both are real captures. The Bluesky one keeps two of every level's
outside replies rather than pruning them away, because a filter is only worth
testing against the thing it is supposed to exclude.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01BGkRmLfiWuJHx6tQ12EELY
2026-08-26 17:16:02 -03:00

111 lines
4.5 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('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('Open on TikTok'));
assert.ok(page.includes('verification puzzle'));
});