Files
www/tests/peertube-feeds.test.mjs
2026-10-31 15:11:21 -04:00

580 lines
31 KiB
JavaScript
Executable File

import test from 'node:test';
import assert from 'node:assert/strict';
import {
ORIGIN, ORIGINALS_UUID, MAX_BYTES, REFRESH_MS, validateOriginURL, validateWatchURL,
validateThumbnailURL, normalizeDate, sortAndDedupe, normalizeJSONVideos,
fetchText, fetchJSON, channelVideosURL, fetchChannel, fetchPaginated, normalizeCollections,
discoverCollections, fetchCollection, loadRegion, createCard, createLibraryController, initPeerTubeFeeds
} from '../htdocs/static/peertube-feeds.mjs';
const UUID = '12345678-1234-1234-1234-123456789abc';
const video = (id, date = '2026-10-01T12:00:00Z', extra = {}) => ({
url: `${ORIGIN}/w/${id}`, title: id, publishedAt: date, source: 'Travel', ...extra
});
const jsonResponse = value => new Response(JSON.stringify(value), { headers: { 'Content-Type': 'application/json' } });
// Minimal DOM contract for cards, selections and refresh behavior.
class Node {
constructor(tag = 'div', content = '') {
this.localName = tag;
this.textContent = content;
this.children = [];
this.attributes = new Map();
this.listeners = new Map();
this.selectors = new Map();
this.value = '';
}
append(...nodes) { this.children.push(...nodes); }
replaceChildren(...nodes) { this.children = nodes; }
setAttribute(name, value) { this.attributes.set(name, value); }
getAttribute(name) { return this.attributes.get(name) ?? null; }
addEventListener(name, callback, options) { this.listeners.set(name, { callback, options }); }
removeEventListener(name) { this.listeners.delete(name); }
dispatch(name) {
const listener = this.listeners.get(name);
listener?.callback();
if (listener?.options?.once) this.listeners.delete(name);
}
querySelector(selector) { return this.selectors.get(selector) ?? null; }
}
class Document extends Node {
constructor() { super(); this.hidden = false; this.regions = []; }
createElement(tag) { return new Node(tag); }
querySelectorAll(selector) { return selector === '[data-peertube-feed]' ? this.regions : []; }
}
function region(document, channels = 'eric_tech,eric_gaming') {
const root = new Node();
root.setAttribute('data-channels', channels);
root.setAttribute('data-title', 'Workshop');
const items = new Node();
const fallback = new Node('article', 'Selected recording');
items.append(fallback);
const status = new Node('p');
root.selectors.set('[data-feed-items]', items);
root.selectors.set('[data-feed-status]', status);
document.regions.push(root);
return { root, items, fallback, status };
}
function library(document) {
const root = new Node();
const select = new Node('select');
select.disabled = true;
const items = new Node();
const status = new Node('p');
const link = new Node('a');
link.href = `${ORIGIN}/accounts/eric/videos`;
for (const [selector, node] of [['#peertube-collection', select], ['[data-library-items]', items], ['[data-library-status]', status], ['[data-library-link]', link]]) root.selectors.set(selector, node);
document.selectors.set('#peertube-library', root);
return { root, select, items, status, link };
}
const timers = { setIntervalImpl: () => 1, clearIntervalImpl: () => {} };
const deferred = () => {
let resolve;
const promise = new Promise(done => { resolve = done; });
return { promise, resolve };
};
test('watch and thumbnail URLs use exact secure origin and narrow paths', () => {
assert.equal(validateWatchURL('/w/Ab123'), `${ORIGIN}/w/Ab123`);
assert.equal(validateWatchURL(`${ORIGIN}/videos/watch/${UUID}`), `${ORIGIN}/videos/watch/${UUID}`);
assert.equal(validateThumbnailURL('/lazy-static/thumbnails/test.webp'), `${ORIGIN}/lazy-static/thumbnails/test.webp`);
assert.equal(validateThumbnailURL('/static/thumbnails/ab/test.jpg'), `${ORIGIN}/static/thumbnails/ab/test.jpg`);
for (const value of [null, {}, 'javascript:alert(1)', 'data:image/png;base64,x', 'http://peertube.eom.dev/w/a',
'https://evil.test/w/a', 'https://peertube.eom.dev.evil.test/w/a', 'https://user@peertube.eom.dev/w/a',
'https://peertube.eom.dev:443/w/a', 'https://peertube.eom.dev:8443/w/a', '//peertube.eom.dev/w/a',
`${ORIGIN}/x/../w/a`, '/w/../w/a', '/w/%2e%2e/a', '/w/%61', '/w/a#x', '/w/a?x=1',
'/w/a\\b', '/w/<script>', '/w/a\n', '/videos/watch/not-a-uuid', '/w/a/b', `${ORIGIN}/w/a/`]) {
assert.equal(validateWatchURL(value), null, String(value));
}
for (const value of ['/static/thumbnails/a.svg', '/lazy-static/thumbnails/../a.jpg', '/static/thumbnails/a.jpg?track=1',
'/static/thumbnails/%2f.jpg', '/other/a.jpg', 'https://evil.test/static/thumbnails/a.png']) assert.equal(validateThumbnailURL(value), null, value);
assert.equal(validateOriginURL(`${ORIGIN}/api/v1/accounts/eric/video-channels?count=100&start=0`).origin, ORIGIN);
});
test('normalization skips invalid dates, deduplicates and sorts latest, not playlist position', () => {
const items = [video('older', '2020-01-01T00:00:00Z'), video('same', '2026-10-02T00:00:00Z'),
video('same', '2026-10-03T00:00:00Z'), video('invalid', 'not a date'), video('bad', '2026-02-30T00:00:00Z'),
video('newest', '2026-10-04T00:00:00Z')];
assert.deepEqual(sortAndDedupe(items).map(item => item.title), ['newest', 'same', 'older']);
assert.equal(normalizeDate('2026-02-30'), null);
assert.equal(normalizeDate('Mon, 30 Feb 2026 12:00:00 GMT'), null);
assert.equal(normalizeDate('Jan 1'), null);
assert.equal(normalizeDate('2026-13-01'), null);
assert.equal(normalizeDate(1234), null);
assert.equal(normalizeDate('2027-01-01', { now: Date.parse('2026-10-01'), maxFutureMs: 60_000 }), null);
assert.equal(normalizeDate('Wed, 01 Oct 2025 12:00:00 GMT'), '2025-10-01T12:00:00.000Z');
const tied = [video('z'), video('a')];
assert.deepEqual(sortAndDedupe(tied), sortAndDedupe([...tied].reverse()));
assert.throws(() => sortAndDedupe(Array(1001).fill(video('a'))));
assert.throws(() => sortAndDedupe([], -1));
const normalized = normalizeJSONVideos([
{ video: { url: `${ORIGIN}/w/old`, name: 'Older', publishedAt: '2020-01-01', privacy: { id: 1 } } },
{ video: { url: `${ORIGIN}/videos/watch/${UUID}`, uuid: UUID, name: '<img onerror=alert(1)>', publishedAt: '2026-10-02', thumbnailPath: '/static/thumbnails/a.jpg' } },
{ video: { url: `${ORIGIN}/w/private`, name: 'Private', publishedAt: '2026-10-03', privacy: { id: 2 } } }
], 'Originals');
assert.equal(normalized[0].title, '<img onerror=alert(1)>');
assert.equal(normalized[0].thumbnail, `${ORIGIN}/static/thumbnails/a.jpg`);
assert.equal(normalized[0].source, 'Originals');
assert.equal(normalized.length, 2);
});
test('actual channel and nested playlist API shapes validate privacy, URLs and plain metadata', () => {
const result = normalizeJSONVideos([
{ id: 10, uuid: UUID, shortUUID: 'Short123', url: `/videos/watch/${UUID}`, name: '<b>Title</b>',
publishedAt: '2026-10-02T12:00:00Z', thumbnailPath: '/lazy-static/thumbnails/a.jpg',
privacy: { id: 1, label: 'Public' }, description: '<script>not rendered</script>' },
{ position: 1, startTimestamp: null, video: { url: '/w/Short123', name: 'Short', publishedAt: '2026-10-01T12:00:00Z' } },
...[2, 3, 4, '1', null].map(id => ({ url: '/w/private', publishedAt: '2026-10-03', privacy: { id } })),
{ url: '/w/malformed', publishedAt: '2026-10-03', privacy: null },
{ url: 'https://evil.test/w/attack', publishedAt: '2026-10-03', privacy: { id: 1 } },
{ url: '/w/unsafe', publishedAt: '2026-10-03', thumbnailPath: 'javascript:alert(1)' }
], 'Music');
assert.deepEqual(result.map(item => item.url), [`${ORIGIN}/w/unsafe`, `${ORIGIN}/videos/watch/${UUID}`, `${ORIGIN}/w/Short123`]);
assert.equal(result[0].thumbnail, null);
assert.equal(result[1].title, '<b>Title</b>');
assert.equal(result[1].thumbnail, `${ORIGIN}/lazy-static/thumbnails/a.jpg`);
assert.equal(result[1].source, 'Music');
assert.equal('description' in result[1], false);
assert.deepEqual(normalizeJSONVideos([]), []);
});
test('JSON UUID identity deduplicates short and long watch URLs only after original URL validation', async () => {
const entries = [
{ url: '/w/Short123', uuid: UUID.toUpperCase(), name: 'Newest copy', publishedAt: '2026-10-03' },
{ video: { url: `/videos/watch/${UUID}`, uuid: UUID, name: 'Older copy', publishedAt: '2026-10-02' } },
...['https://evil.test/w/Short123', `${ORIGIN}/w/../w/Short123`, '/w/Short123?track=1',
'https://peertube.eom.dev:443/w/Short123', 'javascript:alert(1)', undefined]
.map(url => ({ url, uuid: UUID, name: 'Unsafe copy', publishedAt: '2026-10-04' }))
];
const normalized = normalizeJSONVideos(entries, 'Originals');
assert.equal(normalized.length, 1);
assert.equal(normalized[0].url, `${ORIGIN}/videos/watch/${UUID}`);
assert.equal(normalized[0].title, 'Newest copy');
assert.equal(normalized[0].source, 'Originals');
assert.deepEqual(normalized, normalizeJSONVideos([...entries].reverse(), 'Originals'));
for (const uuid of [undefined, null, {}, 'invalid', `${UUID}/../bad`]) {
assert.equal(normalizeJSONVideos([{ url: '/w/Short123', uuid, publishedAt: '2026-10-03' }])[0].url, `${ORIGIN}/w/Short123`);
}
const merged = await loadRegion(['eric_channel', 'eric_music'], 'Publications', { fetchImpl: async value =>
jsonResponse({ total: 1, data: [value.includes('/eric_channel/') ? entries[0] : entries[1]] }) });
assert.equal(merged.items.length, 1);
assert.equal(merged.items[0].url, `${ORIGIN}/videos/watch/${UUID}`);
});
test('invalid local channel names cannot trigger fetches through any channel entry point', async () => {
let calls = 0;
const dependencies = { fetchImpl: async () => { calls++; return jsonResponse({ total: 0, data: [] }); } };
for (const name of ['2', '../evil', 'eric@host', 'eric/other', 'eric?x=1', 'eric%2fother', 'eric#x', 'eric\n', `a${'b'.repeat(100)}`, '', null]) {
await assert.rejects(fetchChannel(name, 'Invalid', dependencies), /channel name/);
await assert.rejects(fetchCollection({ type: 'channel', id: name, title: 'Invalid' }, dependencies), /channel name/);
await assert.rejects(loadRegion(['eric_tech', name], 'Invalid', dependencies), /feed channels/);
if (typeof name === 'string') await assert.rejects(fetchPaginated(`/api/v1/video-channels/${name}/videos`, dependencies), /endpoint/);
assert.deepEqual(normalizeCollections([{ id: 2, name }], []).channels, []);
}
assert.equal(calls, 0);
});
test('fetch enforces MIME, status, redirect, omitted credentials and streamed byte cap', async () => {
const url = channelVideosURL('eric_channel', { count: 3 });
let options;
assert.equal(await fetchText(url, { fetchImpl: async (_, init) => { options = init; return jsonResponse({ total: 0, data: [] }); } }), '{"total":0,"data":[]}');
assert.equal(options.headers.Accept, 'application/json');
assert.equal(options.credentials, 'omit');
assert.equal(options.redirect, 'error');
assert.equal(options.referrerPolicy, 'no-referrer');
assert.ok(options.signal instanceof AbortSignal);
for (const response of [new Response('<html/>', { headers: { 'Content-Type': 'text/html' } }),
new Response('fail', { status: 503, headers: { 'Content-Type': 'application/json' } }),
new Response('{}', { headers: { 'Content-Type': 'application/json', 'Content-Length': String(MAX_BYTES + 1) } }),
new Response('123456789', { headers: { 'Content-Type': 'application/json' } })]) {
await assert.rejects(fetchText(url, { fetchImpl: async () => response, maxBytes: 8 }));
}
await assert.rejects(fetchJSON(url, { fetchImpl: async () => new Response('invalid JSON', { headers: { 'Content-Type': 'application/json' } }) }), SyntaxError);
await assert.rejects(fetchText(url, { fetchImpl: async () => ({ ok: true, redirected: true }) }));
await assert.rejects(fetchText(url, { fetchImpl: async () => ({ ok: true, url: 'https://evil.test/' }) }));
let calls = 0;
await assert.rejects(fetchText('https://evil.test/', { fetchImpl: async () => { calls++; } }));
await assert.rejects(fetchText(`${ORIGIN}/w/notAnAPI`, { fetchImpl: async () => { calls++; } }));
assert.equal(calls, 0);
});
test('timeout and external abort settle even a non-cooperative fetch or hanging reader', async () => {
const url = channelVideosURL('eric_channel');
let observedSignal;
await assert.rejects(fetchText(url, { timeoutMs: 5, fetchImpl: async (_, init) => { observedSignal = init.signal; return new Promise(() => {}); } }), { name: 'AbortError' });
assert.equal(observedSignal.aborted, true);
const controller = new AbortController();
const pending = fetchText(url, { signal: controller.signal, fetchImpl: async () => new Promise(() => {}) });
controller.abort();
await assert.rejects(pending, { name: 'AbortError' });
let cancelled = false;
await assert.rejects(fetchText(url, { timeoutMs: 5, fetchImpl: async () => ({ ok: true, headers: new Headers({ 'Content-Type': 'application/json' }),
body: { getReader: () => ({ read: () => new Promise(() => {}), cancel: async () => { cancelled = true; } }) } }) }), { name: 'AbortError' });
assert.equal(cancelled, true);
const already = new AbortController();
already.abort();
let calls = 0;
await assert.rejects(fetchText(url, { signal: already.signal, fetchImpl: async () => { calls++; } }), { name: 'AbortError' });
assert.equal(calls, 0);
});
test('channel latest and library selection request exactly three newest videos without pagination', async () => {
assert.equal(channelVideosURL('eric_tech'), `${ORIGIN}/api/v1/video-channels/eric_tech/videos?count=100&start=0&sort=-publishedAt`);
assert.equal(channelVideosURL('eric_gaming', { start: 100 }), `${ORIGIN}/api/v1/video-channels/eric_gaming/videos?count=100&start=100&sort=-publishedAt`);
for (const name of ['eric_channel', 'eric_music', 'a', 'A.name-1_', `a${'b'.repeat(99)}`]) assert.ok(channelVideosURL(name).includes(`/video-channels/${name}/videos`));
for (const name of [0, 2, -1, '2', '02', '2/videos', 'eric?count=100', '../eric', 'eric/../other', 'eric%2fother', 'eric#fragment',
'eric@host', '//evil.test', '_eric', ' eric', 'eric ', 'eric\\other', 'eric\n', 'eric\r', `a${'b'.repeat(100)}`, '', 1.5, null, {}, undefined]) assert.throws(() => channelVideosURL(name));
for (const bounds of [{ count: 101 }, { count: 0 }, { count: '3' }, { start: -1 }, { start: 1.5 }, { start: 1001 }]) assert.throws(() => channelVideosURL('eric_channel', bounds));
const requests = [];
const dependencies = { fetchImpl: async value => {
const url = new URL(value);
requests.push(url.href);
assert.equal(url.pathname, '/api/v1/video-channels/eric_tech/videos');
assert.deepEqual([...url.searchParams], [['count', '3'], ['start', '0'], ['sort', '-publishedAt']]);
return jsonResponse({ total: 5000, data: [
{ url: `/videos/watch/${UUID}`, name: 'Newest', publishedAt: '2026-10-03', privacy: { id: 1 } },
{ url: '/w/second', name: 'Second', publishedAt: '2026-10-02' },
{ url: '/w/third', name: 'Third', publishedAt: '2026-10-01' }
] });
} };
assert.deepEqual((await fetchChannel('eric_tech', 'Travel', dependencies)).map(item => item.title), ['Newest', 'Second', 'Third']);
assert.equal(requests.length, 1);
const collection = normalizeCollections([{ id: 701, name: 'eric_tech', displayName: 'Travel' }], []).channels[0];
assert.equal(collection.key, 'channel:701');
const selected = await fetchCollection(collection, dependencies);
assert.equal(selected.length, 3);
assert.ok(selected.every(item => item.source === 'Travel'));
assert.equal(requests.length, 2);
});
test('latest channel JSON rejects malformed envelopes and filters nonpublic or unsafe cards', async () => {
for (const result of [null, {}, [], { total: '1', data: [] }, { total: -1, data: [] },
{ total: 2, data: [] }, { total: 1, data: [null] }, { total: 1, data: [[]] },
{ total: 4, data: Array(4).fill(video('a')) }, { total: 0, data: [video('a')] }, { total: 2, data: [video('a')] }]) {
await assert.rejects(fetchChannel('eric_tech', 'Tech', { fetchImpl: async () => jsonResponse(result) }));
}
const result = await fetchChannel('eric_tech', 'Tech', { fetchImpl: async () => jsonResponse({ total: 3, data: [
{ url: '/w/public', publishedAt: '2026-10-03', privacy: { id: 1 } },
{ url: '/w/unlisted', publishedAt: '2026-10-02', privacy: { id: 2 } },
{ url: 'https://evil.test/w/unsafe', publishedAt: '2026-10-01' }
] }) });
assert.deepEqual(result.map(item => item.url), [`${ORIGIN}/w/public`]);
});
test('optional full channel pagination requests sorted API pages', async () => {
const starts = [];
const result = await fetchPaginated('/api/v1/video-channels/eric_gaming/videos', { fetchImpl: async value => {
const url = new URL(value);
assert.equal(url.pathname, '/api/v1/video-channels/eric_gaming/videos');
assert.equal(url.searchParams.get('count'), '100');
assert.equal(url.searchParams.get('sort'), '-publishedAt');
const start = Number(url.searchParams.get('start'));
starts.push(start);
return jsonResponse({ total: 101, data: Array.from({ length: Math.min(100, 101 - start) }, (_, i) => ({ id: start + i })) });
} });
assert.deepEqual(starts, [0, 100]);
assert.equal(result.length, 101);
});
test('playlist discovery paginates and includes only explicitly public playlists plus new channels', async () => {
const requests = [];
const result = await discoverCollections({ fetchImpl: async value => {
const url = new URL(value);
requests.push(url);
if (url.pathname.endsWith('video-channels')) return jsonResponse({ total: 1, data: [{ id: 900, name: 'new_channel', displayName: 'New channel' }] });
const start = +url.searchParams.get('start');
const all = [
...Array.from({ length: 100 }, (_, id) => ({ uuid: UUID, displayName: `Private ${id}`, privacy: { id: 2 } })),
{ uuid: ORIGINALS_UUID, displayName: 'Originals', privacy: { id: 1 } }
];
return jsonResponse({ total: 101, data: all.slice(start, start + 100) });
} });
assert.equal(result.playlists.length, 1);
assert.equal(result.playlists[0].id, ORIGINALS_UUID);
assert.equal(result.playlists[0].link, `${ORIGIN}/video-playlists/${ORIGINALS_UUID}`);
assert.equal(result.channels[0].id, 'new_channel');
assert.equal(result.channels[0].key, 'channel:900');
assert.equal(result.channels[0].title, 'New channel');
assert.equal(requests.filter(url => url.pathname.endsWith('video-playlists')).length, 2);
assert.ok(requests.every(url => url.searchParams.get('count') === '100'));
assert.ok(requests.some(url => url.searchParams.get('start') === '100'));
const normalized = normalizeCollections([{ id: 2, name: '../evil' }], [
{ uuid: UUID, displayName: 'Missing privacy' }, { uuid: 'invalid', displayName: 'Invalid UUID', privacy: { id: 1 } }
]);
assert.deepEqual(normalized, { channels: [], playlists: [] });
});
test('pagination rejects malformed totals, too-large data, changing totals and incomplete pages', async () => {
const path = '/api/v1/accounts/eric/video-playlists';
for (const result of [{ total: -1, data: [] }, { total: '0', data: [] }, { total: 1.1, data: [] },
{ total: 1001, data: [] }, { total: 0, data: [{}] }, { total: 1, data: [] }, { total: 200, data: Array(101).fill({}) }, { total: 1, data: {} }]) {
await assert.rejects(fetchPaginated(path, { fetchImpl: async () => jsonResponse(result) }));
}
let pages = 0;
await assert.rejects(fetchPaginated(path, { fetchImpl: async () => jsonResponse({ total: ++pages === 1 ? 2 : 3, data: [{}] }) }), /changed/);
pages = 0;
await assert.rejects(fetchPaginated(path, { fetchImpl: async () => { pages++; return jsonResponse({ total: 11, data: [{}] }); } }), /limit/);
assert.equal(pages, 10);
await assert.rejects(fetchPaginated('/api/v1/accounts/somebody/video-playlists'), /endpoint/);
assert.deepEqual(await fetchPaginated(path, { fetchImpl: async () => jsonResponse({ total: 0, data: [] }) }), []);
});
test('playlist items paginate and sort by video publication date, with an exact 1000-entry ceiling', async () => {
let pages = 0;
const result = await fetchCollection({ type: 'playlist', id: UUID, title: 'Playlist' }, { fetchImpl: async value => {
const url = new URL(value);
assert.equal(url.pathname, `/api/v1/video-playlists/${UUID}/videos`);
assert.equal(url.searchParams.get('count'), '100');
assert.equal(url.searchParams.has('sort'), false);
pages++;
const start = +url.searchParams.get('start');
return jsonResponse({ total: 1000, data: Array.from({ length: 100 }, (_, offset) => ({ position: start + offset,
video: { url: `${ORIGIN}/w/item${start + offset}`, name: `Item ${start + offset}`,
publishedAt: new Date(Date.UTC(2020, 0, 1 + start + offset)).toISOString(), privacy: { id: 1 } } })) });
} });
assert.equal(pages, 10);
assert.deepEqual(result.map(item => item.title), ['Item 999', 'Item 998', 'Item 997']);
});
test('visibility check prevents the next API page from making a hidden-tab network request', async () => {
let visible = true;
let calls = 0;
await assert.rejects(fetchPaginated('/api/v1/accounts/eric/video-playlists', {
shouldFetch: () => visible,
fetchImpl: async () => {
calls++;
visible = false;
return jsonResponse({ total: 2, data: [{}] });
}
}), { name: 'AbortError' });
assert.equal(calls, 1);
});
test('API region merging deduplicates watch URLs, with honest partial/empty/all-failed results', async () => {
const dependencies = { fetchImpl: async url => {
assert.equal(new URL(url).searchParams.get('count'), '3');
assert.equal(new URL(url).searchParams.get('sort'), '-publishedAt');
if (url.includes('/eric_tech/')) return jsonResponse({ total: 0, data: [] });
return jsonResponse({ total: 2, data: [video('same'), video('same')] });
} };
const available = await loadRegion(['eric_tech', 'eric_gaming'], 'Workshop', dependencies);
assert.equal(available.ok, true);
assert.equal(available.items.length, 1);
const partial = await loadRegion(['eric_tech', 'eric_gaming'], 'Workshop', { ...dependencies, fetchImpl: async url => {
if (url.includes('/eric_tech/')) throw new Error('offline');
return jsonResponse({ total: 2, data: [video('same'), video('same')] });
} });
assert.equal(partial.unavailable, 1);
assert.equal(partial.items.length, 1);
const empty = await loadRegion(['eric_tech'], 'Tech', dependencies);
assert.deepEqual(empty, { ok: true, unavailable: 0, items: [] });
const failed = await loadRegion(['eric_channel', 'eric_music'], 'Films', { fetchImpl: async () => { throw new Error('offline'); } });
assert.deepEqual(failed, { ok: false, unavailable: 2, items: [] });
});
test('safe cards preserve plain title, have accessible same-tab links, and hide only failed image', () => {
const doc = new Document();
const card = createCard(doc, video('safe', undefined, { title: '<script>alert(1)</script>', thumbnail: '/static/thumbnails/a.jpg' }));
assert.equal(card.className, 'project-card media-card');
const [media, copy] = card.children;
assert.equal(media.getAttribute('aria-label'), 'Watch <script>alert(1)</script>');
assert.equal(copy.children[1].localName, 'h3');
assert.equal(copy.children[1].textContent, '<script>alert(1)</script>');
assert.equal(copy.children[2].dateTime, '2026-10-01T12:00:00.000Z');
assert.equal(media.target, undefined);
const [image, label] = media.children;
assert.equal(image.alt, '');
assert.equal(image.loading, 'lazy');
assert.equal(image.width, 560);
assert.equal(image.height, 315);
image.dispatch('error');
assert.equal(image.hidden, true);
assert.equal(image.listeners.has('error'), false);
assert.equal(label.textContent, 'Watch ↗');
assert.throws(() => createCard(doc, video('bad', undefined, { url: 'javascript:alert(1)' })));
});
test('feed failure retains fallback; partial success replaces only item children; empty success is honest', async () => {
for (const result of [{ ok: false, unavailable: 2, items: [] }, { ok: true, unavailable: 1, items: [video('a')] }, { ok: true, unavailable: 0, items: [] }]) {
const doc = new Document();
const state = region(doc);
const sources = new Node('a', 'Static source link');
state.root.append(sources);
const app = initPeerTubeFeeds(doc, { ...timers, loadRegion: async () => result });
await app.ready;
if (!result.ok) {
assert.equal(state.items.children[0], state.fallback);
assert.match(state.status.textContent, /unavailable/);
} else if (result.unavailable) {
assert.equal(state.items.children[0].localName, 'article');
assert.match(state.status.textContent, /Some channels unavailable/);
assert.doesNotMatch(state.status.textContent, /Latest uploads/);
} else assert.match(state.items.children[0].textContent, /No public uploads/);
assert.equal(state.root.children[0], sources);
app.stop();
}
});
test('library selects Originals, sorts content, keeps select enabled and blocks stale selection responses', async () => {
const doc = new Document();
const state = library(doc);
const collections = normalizeCollections([{ id: 2, name: 'travel', displayName: 'Travel' }], [
{ uuid: ORIGINALS_UUID, displayName: 'Originals', privacy: { id: 1 } }
]);
const slow = deferred();
let firstSignal;
let calls = 0;
const controller = createLibraryController(doc, state.root, {
discoverCollections: async () => collections,
fetchCollection: async (collection, dependencies) => {
calls++;
if (calls === 1) { firstSignal = dependencies.signal; return slow.promise; }
return [video('travel')];
}
});
const discovery = controller.discover();
await Promise.resolve();
assert.equal(state.select.value, `playlist:${ORIGINALS_UUID}`);
assert.equal(state.select.disabled, false);
assert.deepEqual(state.select.children.map(group => group.label), ['Channels', 'Playlists']);
await controller.selectCollection('channel:2');
assert.equal(firstSignal.aborted, true);
slow.resolve([video('stale')]);
await discovery;
assert.equal(state.items.children[0].children[1].children[1].textContent, 'travel');
assert.equal(state.link.href, `${ORIGIN}/video-channels/travel/videos`);
controller.stop();
assert.equal(state.select.listeners.has('change'), false);
});
test('library failure keeps direct playlist link and discovery failure exposes account fallback', async () => {
const doc = new Document();
const state = library(doc);
const controller = createLibraryController(doc, state.root, {
discoverCollections: async () => normalizeCollections([], [{ uuid: UUID, displayName: 'First', privacy: { id: 1 } }]),
fetchCollection: async () => { throw new Error('timeout'); }
});
await controller.discover();
assert.equal(state.link.href, `${ORIGIN}/video-playlists/${UUID}`);
assert.equal(state.select.disabled, false);
assert.match(state.status.textContent, /unavailable/);
controller.stop();
const failureState = library(new Document());
const failed = createLibraryController(doc, failureState.root, { discoverCollections: async () => { throw new Error('offline'); } });
await failed.discover();
assert.equal(failureState.select.disabled, true);
assert.equal(failureState.link.hidden, false);
assert.equal(failureState.link.href, `${ORIGIN}/accounts/eric/videos`);
failed.stop();
});
test('refresh is visibility gated, minimum interval bounded, disjoint and stoppable', async () => {
const doc = new Document();
region(doc);
doc.hidden = true;
let now = 0;
let calls = 0;
let tick;
let cleared;
const pending = deferred();
const app = initPeerTubeFeeds(doc, { now: () => now,
setIntervalImpl: callback => { tick = callback; return 42; }, clearIntervalImpl: handle => { cleared = handle; },
loadRegion: async () => { calls++; return pending.promise; }
});
await app.ready;
assert.equal(calls, 0);
doc.hidden = false;
const running = app.refresh();
await app.refresh({ force: true });
assert.equal(calls, 1);
pending.resolve({ ok: true, unavailable: 0, items: [] });
await running;
tick();
assert.equal(calls, 1);
now = REFRESH_MS;
doc.hidden = true;
tick();
assert.equal(calls, 1);
doc.hidden = false;
doc.dispatch('visibilitychange');
assert.equal(calls, 2);
app.stop();
assert.equal(cleared, 42);
assert.equal(doc.listeners.has('visibilitychange'), false);
now += REFRESH_MS;
tick();
assert.equal(calls, 2);
});
test('hiding a document aborts active region requests and never renders late thumbnail cards', async () => {
const doc = new Document();
const state = region(doc);
const pending = deferred();
let signal;
const app = initPeerTubeFeeds(doc, { ...timers, loadRegion: async (_ids, _source, dependencies) => {
signal = dependencies.signal;
return pending.promise;
} });
doc.hidden = true;
doc.dispatch('visibilitychange');
assert.equal(signal.aborted, true);
pending.resolve({ ok: true, unavailable: 0, items: [video('late', undefined, { thumbnail: '/static/thumbnails/a.jpg' })] });
await app.ready;
assert.equal(state.items.children[0], state.fallback);
app.stop();
});
test('returning to a visible tab retries interrupted regions immediately and ignores stale responses', async () => {
const doc = new Document();
const state = region(doc);
const first = deferred();
const second = deferred();
let calls = 0;
const app = initPeerTubeFeeds(doc, { ...timers, now: () => 0, loadRegion: async names => {
assert.deepEqual(names, ['eric_tech', 'eric_gaming']);
return ++calls === 1 ? first.promise : second.promise;
} });
doc.hidden = true;
doc.dispatch('visibilitychange');
doc.hidden = false;
const resumed = app.refresh();
assert.equal(calls, 2);
first.resolve({ ok: true, unavailable: 0, items: [video('stale')] });
await app.ready;
assert.equal(state.items.children[0], state.fallback);
await app.refresh({ force: true });
assert.equal(calls, 2, 'stale completion must not release the new request busy guard');
second.resolve({ ok: true, unavailable: 0, items: [video('fresh')] });
await resumed;
assert.equal(state.items.children[0].children[1].children[1].textContent, 'fresh');
app.stop();
});
test('account discovery is reused across selections and refreshed only on the fifteen-minute schedule', async () => {
const doc = new Document();
const state = library(doc);
let now = 0;
let discoveries = 0;
let selections = 0;
const app = initPeerTubeFeeds(doc, { ...timers, now: () => now,
discoverCollections: async () => {
discoveries++;
return normalizeCollections([{ id: 2, name: 'travel' }], [{ uuid: ORIGINALS_UUID, displayName: 'Originals', privacy: { id: 1 } }]);
},
fetchCollection: async () => { selections++; return []; }
});
await app.ready;
await app.library.selectCollection('channel:2');
await app.library.selectCollection(`playlist:${ORIGINALS_UUID}`);
assert.equal(discoveries, 1);
assert.equal(selections, 3);
assert.equal(state.select.disabled, false);
now = REFRESH_MS - 1;
await app.refresh();
assert.equal(discoveries, 1);
now++;
await app.refresh();
assert.equal(discoveries, 2);
assert.equal(selections, 4);
app.stop();
});