CI / Typecheck, test, build (pull_request) Successful in 26s
The plain address is the one thing that button must not offer -- followed in the browser the rewrite rules are installed in, it comes straight back here -- so there is nothing to show until there is a browser to hand it to. The copy button and the selectable URL were always the part carrying the weight; the open link now ships hidden and the script reveals it along with the scheme. That also settles what the button should say. It went back to "Open on <platform>": the browser's name was there to explain a tap that went nowhere, and a button that is not shown until it works needs no such explanation. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01KF5YF3iZVKbezwALap8LYd
262 lines
10 KiB
TypeScript
262 lines
10 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><script>alert(1)</script></p>');
|
|
assert.equal(html`<p>${raw('<b>ok</b>')}</p>`.value, '<p><b>ok</b></p>');
|
|
assert.equal(html`${['a', '<b>']}`.value, 'a<b>');
|
|
assert.equal(html`${undefined}${null}${false}`.value, '');
|
|
});
|
|
|
|
test('attribute-breaking characters are escaped', () => {
|
|
assert.equal(escapeHtml(`" onload='x'`), '" onload='x'');
|
|
});
|
|
|
|
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('<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: '\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="" onerror=alert(1) x=""'), 'alt text must be escaped');
|
|
assert.ok(!/alt="" onerror/.test(page), 'the attribute must not be closable');
|
|
});
|