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/' }, { 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, 'Title'); 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('', { 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: '', 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 '); assert.equal(copy.children[1].localName, 'h3'); assert.equal(copy.children[1].textContent, ''); 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(); });