561 lines
29 KiB
JavaScript
Executable File
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/);
|
|
});
|