580 lines
31 KiB
JavaScript
Executable File
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();
|
|
});
|