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

561 lines
29 KiB
JavaScript
Executable File

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/<img>', '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: '<img onerror=alert(1)>\r\nprivate second line' } });
const [item] = normalizeActivities([event(1, undefined, undefined, { content })], { now: NOW });
assert.equal(item.summary, '<img onerror=alert(1)>');
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 = '<script>alert(1)</script>';
sample.content = JSON.stringify({ HeadCommit: { Message: '<img onerror=alert(1)>\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 === '<script>alert(1)</script>'));
assert.ok(nodes.some(node => node.textContent === '<img onerror=alert(1)>'));
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('<script>do not expose response details</script>');
}) });
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/);
});