import test from 'node:test'; import assert from 'node:assert/strict'; import { readFile } from 'node:fs/promises'; import { ORIGIN, PAGE_SIZE, MAX_PAGES, MAX_BYTES, MAX_CONTENT_BYTES, REFRESH_MS, validateRepositoryURL, normalizeDate, normalizeActivities, fetchActivityPage, fetchRecentProjects, createCard, initGiteaProjects } from '../htdocs/static/gitea-projects.mjs'; const NOW = Date.parse('2026-10-31T12:00:00Z'); const HASH = 'b96be7076ecae7fcc6db1cc357feb613196a9bd2'; function event(id = 111164, created = '2026-10-30T20:38:17Z', fullName = 'Gaming/the-button', extra = {}) { return { id: 110459 + id, op_type: 'commit_repo', created, is_private: false, act_user: { login: 'eric' }, ref_name: 'refs/heads/main', repo: { id, name: fullName.split('/')[1], full_name: fullName, html_url: `${ORIGIN}/${fullName}`, private: false, description: 'The Button Game', updated_at: '2099-01-01T00:00:00Z' }, content: JSON.stringify({ HeadCommit: { Sha1: HASH, Message: 'SSO and turn gate\n', AuthorEmail: 'never-display@example.test' }, Commits: [], Len: 2, CompareURL: 'https://evil.test/compare/tracking' }), ...extra }; } const jsonResponse = (value, headers = {}) => new Response(JSON.stringify(value), { headers: { 'Content-Type': 'application/json; charset=utf-8', ...headers } }); const options = fetchImpl => ({ fetchImpl, now: () => NOW }); const fullPage = (entries = []) => [...entries, ...Array(PAGE_SIZE - entries.length).fill(null)]; const tick = () => new Promise(resolve => setImmediate(resolve)); function deferred() { let resolve; const promise = new Promise(done => { resolve = done; }); return { promise, resolve }; } // Minimal dependency-free DOM; deliberately throws on unsafe HTML sinks. 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(); } set innerHTML(_) { throw new Error('Unsafe HTML sink'); } set outerHTML(_) { throw new Error('Unsafe HTML sink'); } 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; } querySelector(selector) { return this.selectors.get(selector) ?? null; } addEventListener(name, callback) { this.listeners.set(name, callback); } removeEventListener(name, callback) { if (this.listeners.get(name) === callback) this.listeners.delete(name); } dispatch(name) { this.listeners.get(name)?.(); } } class Document extends Node { constructor() { super(); this.hidden = false; } createElement(tag) { return new Node(tag); } } const walk = node => [node, ...node.children.flatMap(walk)]; function fixture({ hidden = false } = {}) { const document = new Document(); document.hidden = hidden; const root = new Node('section'); const items = new Node(); items.className = 'project-grid latest-grid'; const fallback = new Node('article', 'Selected project'); items.append(fallback); const status = new Node('p'); root.selectors.set('[data-project-items]', items); root.selectors.set('[data-project-status]', status); document.selectors.set('#gitea-projects', root); const permanentLink = new Node('a', 'Public activity'); permanentLink.href = `${ORIGIN}/eric`; document.append(permanentLink, root); const intervals = new Map(); const cleared = []; let nextID = 0; const timers = { setIntervalImpl(callback, delay) { const id = ++nextID; intervals.set(id, { callback, delay }); return id; }, clearIntervalImpl(id) { cleared.push(id); intervals.delete(id); } }; return { document, root, items, fallback, status, permanentLink, timers, intervals, cleared }; } test('repository URLs require exact raw HTTPS origin and exact owner/name attribution', () => { assert.equal(validateRepositoryURL(`${ORIGIN}/Gaming/the-button`, 'Gaming/the-button'), `${ORIGIN}/Gaming/the-button`); for (const name of ['a/b', 'Org-1/name_2.3', '_org/.repo', `${'a'.repeat(100)}/${'b'.repeat(100)}`]) { assert.equal(validateRepositoryURL(`${ORIGIN}/${name}`, name), `${ORIGIN}/${name}`); } for (const value of [null, {}, '/Gaming/the-button', '//gitea.eom.dev/Gaming/the-button', 'javascript:alert(1)', 'data:text/html,attack', 'http://gitea.eom.dev/Gaming/the-button', 'https://evil.test/Gaming/the-button', 'https://gitea.eom.dev.evil.test/Gaming/the-button', 'https://user@gitea.eom.dev/Gaming/the-button', 'https://user:pass@gitea.eom.dev/Gaming/the-button', 'https://gitea.eom.dev:443/Gaming/the-button', 'https://gitea.eom.dev:8443/Gaming/the-button', `${ORIGIN}/x/../Gaming/the-button`, `${ORIGIN}/Gaming/%74he-button`, `${ORIGIN}/Gaming/the-button#`, `${ORIGIN}/Gaming/the-button?`, `${ORIGIN}/Gaming/the-button/`, `${ORIGIN}/Gaming/the-button\n`, `${ORIGIN}/Gaming/the-button\\`, ` ${ORIGIN}/Gaming/the-button`, `${ORIGIN}/gaming/the-button`]) { assert.equal(validateRepositoryURL(value, 'Gaming/the-button'), null, String(value)); } for (const name of ['', '.', '..', './repo', '../repo', 'Org/.', 'Org/..', 'Org/repo/more', 'Org//repo', 'Org/', 'Org/a b', 'Org/a%2fb', 'Org/repo\n', 'Org/répo', `${'a'.repeat(101)}/b`, null, {}]) { assert.equal(validateRepositoryURL(`${ORIGIN}/${name}`, name), null, String(name)); } }); test('timestamps are strict valid activity times, never repo.updated_at', () => { assert.equal(normalizeDate('2026-10-30T20:38:17Z', { now: NOW }), '2026-10-30T20:38:17.000Z'); assert.equal(normalizeDate('2026-10-30T22:38:17+02:00', { now: NOW }), '2026-10-30T20:38:17.000Z'); assert.equal(normalizeDate('2024-02-29T00:00:00.123Z', { now: NOW }), '2024-02-29T00:00:00.123Z'); for (const value of [null, 123, {}, 'not a date', '2026-10-30', 'Oct 30', '2026-02-30T00:00:00Z', '2026-02-29T00:00:00Z', '2026-00-01T00:00:00Z', '2026-13-01T00:00:00Z', '2026-10-30T24:00:00Z', '2026-10-30T00:60:00Z', '2026-10-30T00:00:60Z', '2026-10-30T00:00:00+24:00', '2026-10-30T00:00:00+00:60', '2026-10-30T00:00:00', '2026-10-30T00:00:00Z\n', '2099-01-01T00:00:00Z']) assert.equal(normalizeDate(value, { now: NOW }), null, String(value)); assert.equal(normalizeDate('2026-10-30T00:00:00Z', { now: NaN }), null); }); test('normalization finds personal public pushes across organizations, sorts and deduplicates repo IDs', () => { const entries = [event(1, '2026-10-28T00:00:00Z', 'eric/old'), event(2, '2026-10-29T00:00:00Z', 'Engineering/tool'), event(3, '2026-10-30T00:00:00Z', 'Gaming/game'), event(2, '2026-10-30T01:00:00Z', 'Engineering/tool'), event(4, '2026-10-30T02:00:00Z', 'Community/site')]; const result = normalizeActivities(entries, { now: NOW }); assert.deepEqual(result.map(item => item.fullName), ['Community/site', 'Engineering/tool', 'Gaming/game']); assert.deepEqual(result.map(item => item.id), [4, 2, 3]); assert.deepEqual(result.map(item => item.created), ['2026-10-30T02:00:00.000Z', '2026-10-30T01:00:00.000Z', '2026-10-30T00:00:00.000Z']); assert.deepEqual(result, normalizeActivities([...entries].reverse(), { now: NOW })); const tied = [event(10), event(9)]; assert.deepEqual(normalizeActivities(tied, { now: NOW }), normalizeActivities([...tied].reverse(), { now: NOW })); assert.deepEqual(normalizeActivities([], { now: NOW }), []); for (const entries of [null, {}, Array(PAGE_SIZE * MAX_PAGES + 1).fill(null)]) assert.throws(() => normalizeActivities(entries)); }); test('all privacy, actor, operation, shape, identity and original URL gates fail closed', () => { const good = event(); const invalid = [null, [], {}, ...[true, undefined, null, 0, 'false'].map(is_private => event(1, undefined, undefined, { is_private })), ...[true, undefined, null, 0, 'false'].map(privateValue => ({ ...good, repo: { ...good.repo, private: privateValue } })), ...['Eric', 'other', '', null].map(login => ({ ...good, act_user: { login } })), ...['create_repo', 'push', 'pull_request', null].map(op_type => ({ ...good, op_type })), ...[null, [], {}].map(act_user => ({ ...good, act_user })), ...[null, [], {}].map(repo => ({ ...good, repo })), ...[0, -1, '111164', 1.5, Number.MAX_SAFE_INTEGER + 1, undefined].map(id => ({ ...good, repo: { ...good.repo, id } })), ...[0, -1, '1', undefined].map(id => ({ ...good, id })), ...[undefined, 'invalid', '2099-01-01T00:00:00Z'].map(created => ({ ...good, created })), { ...good, repo: { ...good.repo, name: 'mismatch' } }, { ...good, repo: { ...good.repo, html_url: 'https://evil.test/Gaming/the-button' } }]; assert.deepEqual(normalizeActivities(invalid, { now: NOW }), []); assert.equal(normalizeActivities([...invalid, good], { now: NOW }).length, 1); }); test('embedded commit JSON is bounded, first-line text only; hashes are constructed, sensitive metadata discarded', () => { const [project] = normalizeActivities([event()], { now: NOW }); assert.equal(project.summary, 'SSO and turn gate'); assert.equal(project.commitURL, `${ORIGIN}/Gaming/the-button/commit/${HASH}`); assert.deepEqual(Object.keys(project).sort(), ['commitURL', 'created', 'description', 'fullName', 'id', 'summary', 'url']); assert.doesNotMatch(JSON.stringify(project), /never-display|CompareURL|AuthorEmail|updated_at|ref_name/); for (const content of [undefined, null, {}, 'invalid JSON', 'null', '[]', '{}', '{"HeadCommit":[]}', 'x'.repeat(MAX_CONTENT_BYTES + 1), JSON.stringify({ HeadCommit: { Message: 'é'.repeat(MAX_CONTENT_BYTES / 2) } })]) { const [fallback] = normalizeActivities([event(1, undefined, undefined, { content })], { now: NOW }); assert.equal(fallback.summary, 'The Button Game'); assert.equal(fallback.commitURL, null); } for (const Sha1 of ['../bad', HASH + '\n', 'javascript:alert(1)', 'a'.repeat(39), 'g'.repeat(40), null]) { const content = JSON.stringify({ HeadCommit: { Sha1, Message: '\r\nprivate second line' } }); const [item] = normalizeActivities([event(1, undefined, undefined, { content })], { now: NOW }); assert.equal(item.summary, ''); assert.equal(item.commitURL, null); } for (const Sha1 of [HASH.toUpperCase(), 'A'.repeat(64)]) { const [item] = normalizeActivities([event(1, undefined, undefined, { content: JSON.stringify({ HeadCommit: { Sha1 } }) })], { now: NOW }); assert.ok(item.commitURL.endsWith(Sha1.toLowerCase())); } const long = event(); long.repo.description = 'd'.repeat(500); long.content = JSON.stringify({ HeadCommit: { Message: 's'.repeat(500) + '\nignored' } }); const [bounded] = normalizeActivities([long], { now: NOW }); assert.equal(bounded.description.length, 240); assert.equal(bounded.summary.length, 200); }); test('fetch uses only the fixed public activity endpoint, anonymous safe headers and bounded pages', async () => { assert.equal(PAGE_SIZE, 10); assert.equal(MAX_PAGES, 15); assert.equal(PAGE_SIZE * MAX_PAGES, 150); const requests = []; const result = await fetchActivityPage(1, options(async (url, init) => { requests.push({ url, init }); return jsonResponse([event()], { 'X-Total-Count': '1166' }); })); assert.equal(result.total, 1166); assert.equal(result.events.length, 1); const [{ url, init }] = requests; assert.equal(url, `${ORIGIN}/api/v1/users/eric/activities/feeds?only-performed-by=true&limit=${PAGE_SIZE}&page=1`); assert.equal(init.method, 'GET'); assert.equal(init.credentials, 'omit'); assert.equal(init.redirect, 'error'); assert.equal(init.referrerPolicy, 'no-referrer'); assert.equal(init.cache, 'no-store'); assert.deepEqual(init.headers, { Accept: 'application/json' }); assert.ok(init.signal instanceof AbortSignal); let calls = 0; for (const page of [0, MAX_PAGES + 1, '1', 1.5, -1, null, 'https://evil.test/', `${ORIGIN}/api/v1/users/eric/repos`]) { await assert.rejects(fetchActivityPage(page, options(async () => { calls++; })), /page/); } assert.equal(calls, 0); assert.equal((await fetchActivityPage(1, options(async () => jsonResponse([])))).total, null); }); test('fetch rejects status, redirects, offorigin response, MIME, invalid totals and malformed/oversized arrays', async () => { for (const response of [new Response('{}', { status: 503 }), new Response('[]', { headers: { 'Content-Type': 'text/html' } }), new Response('[]'), new Response('[]', { headers: { 'Content-Type': 'application/problem+json' } }), { ok: true, redirected: true }, { ok: true, url: 'https://evil.test/' }, { ok: true, headers: new Headers({ 'Content-Type': 'application/json' }) }, ...['-1', '1.5', 'NaN', '', '9007199254740992'].map(total => jsonResponse([], { 'X-Total-Count': total })), ...[{}, null, { data: [] }, '[]', Array(PAGE_SIZE + 1).fill(null)].map(value => jsonResponse(value)), new Response('broken json', { headers: { 'Content-Type': 'application/json' } })]) { await assert.rejects(fetchActivityPage(1, options(async () => response))); } // Unknown fields and malformed individual records are tolerated, but never normalized to cards. const result = await fetchRecentProjects(options(async () => jsonResponse([null, [], { irrelevant: 'field' }]))); assert.deepEqual(result.items, []); }); test('streamed response byte limits include UTF-8 and content-length, without response.text allocation', async () => { let cancelled = false; const stream = new ReadableStream({ start(controller) { controller.enqueue(new TextEncoder().encode('["ééé"]')); }, cancel() { cancelled = true; } }); await assert.rejects(fetchActivityPage(1, { maxBytes: 8, fetchImpl: async () => new Response(stream, { headers: { 'Content-Type': 'application/json' } }) }), /too large/); assert.equal(cancelled, true); for (const length of [String(MAX_BYTES + 1), '-1', 'not-a-number']) { await assert.rejects(fetchActivityPage(1, options(async () => jsonResponse([], { 'Content-Length': length }))), /too large/); } const invalidUTF8 = new Response(new Uint8Array([0xff]), { headers: { 'Content-Type': 'application/json' } }); await assert.rejects(fetchActivityPage(1, options(async () => invalidUTF8))); let calls = 0; for (const bounds of [{ maxBytes: MAX_BYTES + 1 }, { maxBytes: 0 }, { maxBytes: 1.5 }, { timeoutMs: 0 }, { timeoutMs: 10_001 }, { timeoutMs: Infinity }]) { await assert.rejects(fetchActivityPage(1, { ...bounds, fetchImpl: async () => { calls++; } }), /bounds/); } assert.equal(calls, 0); }); test('10-second default timeout is bounded; injected short timeout covers fetch and stalled body', async () => { let signal; await assert.rejects(fetchActivityPage(1, { timeoutMs: 5, fetchImpl: async (_, init) => { signal = init.signal; return new Promise(() => {}); } }), { name: 'AbortError' }); assert.equal(signal.aborted, true); let cancelled = false; await assert.rejects(fetchActivityPage(1, { 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); }); test('external cancellation and already-aborted signals prevent delayed fetch/body results', async () => { const controller = new AbortController(); const pending = fetchActivityPage(1, { signal: controller.signal, fetchImpl: async () => new Promise(() => {}) }); controller.abort(); await assert.rejects(pending, { name: 'AbortError' }); let calls = 0; await assert.rejects(fetchActivityPage(1, { signal: controller.signal, fetchImpl: async () => { calls++; } }), { name: 'AbortError' }); assert.equal(calls, 0); }); test('pagination stops after three valid unique projects, not three events or repository owners', async () => { const pages = []; const result = await fetchRecentProjects(options(async url => { pages.push(Number(new URL(url).searchParams.get('page'))); return jsonResponse(fullPage([event(1, undefined, 'Gaming/game'), event(2, undefined, 'Engineering/tool'), event(3, undefined, 'Community/site')]), { 'X-Total-Count': '1166' }); })); assert.deepEqual(pages, [1]); assert.equal(result.items.length, 3); assert.equal(result.scannedEvents, PAGE_SIZE); assert.equal(result.total, 1166); assert.equal(result.pages, 1); }); test('multiple pages merge and sort activity.created across owners; invalid/private records do not stop scanning', async () => { const requested = []; const result = await fetchRecentProjects(options(async url => { const page = Number(new URL(url).searchParams.get('page')); requested.push(page); const entries = page === 1 ? fullPage([event(1, '2026-10-28T00:00:00Z', 'Gaming/game'), event(1, '2026-10-29T00:00:00Z', 'Gaming/game'), event(2, undefined, 'Private/secret', { is_private: true })]) : [event(1, '2026-10-30T00:00:00Z', 'Gaming/game'), event(3, '2026-10-29T23:00:00Z', 'Engineering/tool'), event(4, '2026-10-29T22:00:00Z', 'Community/site')]; return jsonResponse(entries, { 'X-Total-Count': page === 1 ? '1166' : '1167' }); })); assert.deepEqual(requested, [1, 2]); assert.deepEqual(result.items.map(item => item.id), [1, 3, 4]); assert.equal(result.scannedEvents, PAGE_SIZE + 3); assert.equal(result.total, 1166); }); test('bounded page cap and shorter pages terminate regardless of huge, missing or zero total headers', async () => { for (const headers of [{}, { 'X-Total-Count': '999999999' }, { 'X-Total-Count': '0' }]) { let calls = 0; const requested = []; const capped = await fetchRecentProjects(options(async url => { calls++; requested.push(url); return jsonResponse(fullPage([event()]), headers); })); assert.equal(calls, MAX_PAGES); assert.deepEqual(requested, Array.from({ length: MAX_PAGES }, (_, index) => `${ORIGIN}/api/v1/users/eric/activities/feeds?only-performed-by=true&limit=${PAGE_SIZE}&page=${index + 1}`)); assert.equal(capped.pages, MAX_PAGES); assert.equal(capped.scannedEvents, PAGE_SIZE * MAX_PAGES); assert.equal(capped.items.length, 1); calls = 0; const short = await fetchRecentProjects(options(async () => { calls++; return jsonResponse([event()], headers); })); assert.equal(calls, 1); assert.equal(short.scannedEvents, 1); } }); test('a required later page failure rejects the whole refresh instead of publishing partial cards', async () => { let calls = 0; await assert.rejects(fetchRecentProjects(options(async () => { if (++calls === 1) return jsonResponse(fullPage([event()])); throw new Error('Later page unavailable'); })), /Later page/); assert.equal(calls, 2); }); test('card DOM uses honest org/name heading, safe plain text, push date and exact repository/commit links', () => { const document = new Document(); const sample = event(); sample.repo.description = ''; sample.content = JSON.stringify({ HeadCommit: { Message: '\nnever show this line', Sha1: HASH, AuthorEmail: 'never-display@example.test' } }); const [project] = normalizeActivities([sample], { now: NOW }); const card = createCard(document, project); const nodes = walk(card); assert.equal(card.localName, 'article'); assert.equal(card.className, 'project-card'); assert.equal(nodes.find(node => node.localName === 'h3').textContent, 'Gaming/the-button'); assert.ok(nodes.some(node => node.textContent === '')); assert.ok(nodes.some(node => node.textContent === '')); assert.ok(nodes.every(node => !['script', 'img', 'iframe'].includes(node.localName))); assert.ok(nodes.every(node => !/never-display|never show this line|2099/.test(node.textContent))); const time = nodes.find(node => node.localName === 'time'); assert.equal(time.dateTime, project.created); assert.match(time.textContent, /^Public push · Oct 30, 2026$/); const links = nodes.filter(node => node.localName === 'a'); assert.deepEqual(links.map(node => node.textContent), ['Explore repository →', 'View this commit']); assert.deepEqual(links.map(node => node.href), [project.url, project.commitURL]); assert.ok(links.every(node => node.className === 'text-link')); assert.ok(links.every(node => node.rel === 'noopener noreferrer' && node.referrerPolicy === 'no-referrer')); }); test('public card boundary revalidates URLs and dates, limits text and never accepts arbitrary commit links', () => { const document = new Document(); const [project] = normalizeActivities([event()], { now: NOW }); for (const changes of [{ url: 'https://evil.test/Gaming/the-button' }, { fullName: '../bad' }, { created: 'invalid' }]) { assert.throws(() => createCard(document, { ...project, ...changes }), /card/); } for (const commitURL of ['https://evil.test/commit/' + HASH, project.url + '/compare/' + HASH, project.commitURL + '?tracking=1', project.commitURL + '\n', 'javascript:alert(1)', null]) { const nodes = walk(createCard(document, { ...project, commitURL, description: 'd'.repeat(500), summary: 's'.repeat(500) })); assert.equal(nodes.filter(node => node.localName === 'a').length, 1); assert.equal(nodes.find(node => node.className === 'project-description').textContent.length, 240); assert.equal(nodes.find(node => node.className === 'project-summary').textContent.length, 200); } }); test('init leaves the host grid and permanent activity link intact, renders results and checks every 15 minutes', async t => { const f = fixture(); let calls = 0; const controller = initGiteaProjects(f.document, { ...f.timers, ...options(async () => { calls++; return jsonResponse([event()]); }) }); t.after(() => controller.stop()); await controller.ready; assert.equal(f.items.children.length, 1); assert.notEqual(f.items.children[0], f.fallback); assert.equal(f.items.className, 'project-grid latest-grid'); assert.equal(f.status.getAttribute('role'), 'status'); assert.match(f.status.textContent, /^Recent public pushes · Checked/); assert.match(f.status.textContent, /1 recent activity events checked.*not all history/); assert.match(f.status.textContent, /up to 150; not all history/); assert.equal(f.document.children[0], f.permanentLink); const [{ callback, delay }] = [...f.intervals.values()]; assert.equal(delay, REFRESH_MS); callback(); await tick(); assert.equal(calls, 2); controller.stop(); assert.equal(f.intervals.size, 0); assert.equal(f.document.listeners.has('visibilitychange'), false); await controller.refresh(); assert.equal(calls, 2); }); test('successful zero results replace static fallback with honest empty message', async t => { const f = fixture(); const controller = initGiteaProjects(f.document, { ...f.timers, ...options(async () => jsonResponse([])) }); t.after(() => controller.stop()); await controller.ready; assert.equal(f.items.children.length, 1); assert.equal(f.items.children[0].localName, 'p'); assert.equal(f.items.children[0].textContent, 'No recent public pushes found.'); assert.match(f.status.textContent, /Recent public pushes · Checked/); }); test('initial fetch or later pagination failure retains selected fallback; refresh failure retains previous cards', async t => { for (const partial of [false, true]) { const f = fixture(); let calls = 0; let succeed = false; const controller = initGiteaProjects(f.document, { ...f.timers, ...options(async () => { calls++; if (succeed) return jsonResponse([event()]); if (partial && calls === 1) return jsonResponse(fullPage([event()])); throw new Error(''); }) }); t.after(() => controller.stop()); await controller.ready; assert.equal(f.items.children[0], f.fallback); assert.equal(f.status.textContent, 'Recent public pushes unavailable; showing selected projects.'); succeed = true; await controller.refresh(); const previous = f.items.children[0]; succeed = false; await controller.refresh(); assert.equal(f.items.children[0], previous); assert.equal(f.status.textContent, 'Recent public pushes unavailable; showing previously checked results.'); } }); test('hidden initial page makes no request or timer; visible return retries and starts timer', async t => { const f = fixture({ hidden: true }); let calls = 0; const controller = initGiteaProjects(f.document, { ...f.timers, ...options(async () => { calls++; return jsonResponse([]); }) }); t.after(() => controller.stop()); await controller.ready; assert.equal(calls, 0); assert.equal(f.intervals.size, 0); assert.equal(f.items.children[0], f.fallback); f.document.hidden = false; f.document.dispatch('visibilitychange'); await tick(); assert.equal(calls, 1); assert.equal(f.intervals.size, 1); }); test('hiding aborts in-flight refresh, pauses timers and blocks delayed swaps; returning retries', async t => { const f = fixture(); const late = deferred(); let calls = 0; let signal; const controller = initGiteaProjects(f.document, { ...f.timers, ...options(async (_, init) => { calls++; if (calls === 1) { signal = init.signal; return late.promise; } return jsonResponse([event(2, undefined, 'Engineering/current')]); }) }); t.after(() => controller.stop()); f.document.hidden = true; f.document.dispatch('visibilitychange'); await controller.ready; assert.equal(signal.aborted, true); assert.equal(f.intervals.size, 0); assert.equal(f.items.children[0], f.fallback); assert.match(f.status.textContent, /paused.*selected/); f.document.hidden = false; f.document.dispatch('visibilitychange'); await tick(); const current = f.items.children[0]; assert.equal(walk(current).find(node => node.localName === 'h3').textContent, 'Engineering/current'); late.resolve(jsonResponse([event(1, undefined, 'Gaming/stale')])); await tick(); assert.equal(f.items.children[0], current); assert.equal(calls, 2); }); test('visible fresh return avoids requests, stale return refreshes using injected time', async t => { const f = fixture(); let now = NOW; let calls = 0; const controller = initGiteaProjects(f.document, { ...f.timers, now: () => now, fetchImpl: async () => { calls++; return jsonResponse([event()]); } }); t.after(() => controller.stop()); await controller.ready; const hideAndReturn = () => { f.document.hidden = true; f.document.dispatch('visibilitychange'); f.document.hidden = false; f.document.dispatch('visibilitychange'); }; hideAndReturn(); await tick(); assert.equal(calls, 1); assert.match(f.status.textContent, /Previously checked/); now += REFRESH_MS; hideAndReturn(); await tick(); assert.equal(calls, 2); assert.equal(f.intervals.size, 1); }); test('failed or interrupted refresh retries on return even when last success is fresh', async t => { const f = fixture(); let calls = 0; const late = deferred(); const controller = initGiteaProjects(f.document, { ...f.timers, ...options(async () => { calls++; return calls === 2 ? late.promise : jsonResponse([event()]); }) }); t.after(() => controller.stop()); await controller.ready; const pending = controller.refresh(); f.document.hidden = true; f.document.dispatch('visibilitychange'); await pending; f.document.hidden = false; f.document.dispatch('visibilitychange'); await tick(); assert.equal(calls, 3); late.resolve(jsonResponse([])); await tick(); assert.equal(f.items.children[0].localName, 'article'); }); test('new refresh generations and stop both prevent non-cooperative delayed result swaps', async () => { for (const stop of [false, true]) { const f = fixture(); const late = deferred(); let calls = 0; let signal; const controller = initGiteaProjects(f.document, { ...f.timers, ...options(async (_, init) => { if (++calls === 1) { signal = init.signal; return late.promise; } return jsonResponse([event(2, undefined, 'Community/current')]); }) }); try { if (stop) controller.stop(); else await controller.refresh(); await controller.ready; assert.equal(signal.aborted, true); const current = f.items.children[0]; late.resolve(jsonResponse([event(1, undefined, 'Gaming/stale')])); await tick(); assert.equal(f.items.children[0], current); if (stop) assert.equal(current, f.fallback); else assert.equal(walk(current).find(node => node.localName === 'h3').textContent, 'Community/current'); } finally { controller.stop(); } } }); test('absent/incomplete DOM contracts are inert and imports have no browser dependency', async () => { let calls = 0; for (const document of [undefined, new Document(), fixture().root]) { const controller = initGiteaProjects(document, options(async () => { calls++; })); await controller.ready; await controller.refresh(); controller.stop(); } assert.equal(calls, 0); const source = await readFile(new URL('../htdocs/static/gitea-projects.mjs', import.meta.url), 'utf8'); assert.doesNotMatch(source, /\b(?:innerHTML|outerHTML|insertAdjacentHTML|eval|localStorage|sessionStorage)\b|document\.write|peertube-feeds|Creator Launch/); });