Files
ipx/tests/page-unit.js
rays 708b69c083 Feed links are followed only when they are web addresses (#326)
An item's link and an enclosure's URL are the publisher's, stored as
written, and the page put them in href and window.open through esc(),
which escapes characters, not schemes: a javascript: or data: link
survived as Open the original, View in a new tab (for a file not on
disk), the Directory's item links and the v key. Show notes already
lose those schemes to ammonia; these four sinks were the exception.

webUrl() passes http(s) only, and each sink goes through it. A link
that fails it shows no link, as an item with none does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:07:47 +00:00

231 lines
12 KiB
JavaScript

// Unit tests for the page's own functions (#234): the script as shipped, loaded into the stub DOM
// page-smoke.js uses, and its helpers called directly. The browser suite tests what people do;
// this tests the small pieces it is made of, in a second, with nothing running.
//
// node --test tests/page-unit.js
//
// The files in web/src are one script, not modules. Its top-level `function`s are properties of
// the context; its top-level `const`s are not, but a script run in the same context sees them, as
// a second <script> on a page would, so `run` evaluates an expression there.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { makeContext, vm } = require('./dom-stub.js');
const { buildPage } = require('../web/build.mjs');
const { html, js } = buildPage('index.html');
const { ctx } = makeContext(html, js);
vm.createContext(ctx);
vm.runInContext(js, ctx, { filename: 'index.html<script>', timeout: 5000 });
const run = expr => vm.runInContext(expr, ctx);
// Time as the page sees it, for the helpers that count from now.
const NOW = 1_800_000_000;
run(`Date.now = () => ${NOW * 1000}`);
test('esc makes text safe to put in markup and attributes', () => {
assert.equal(run(`esc('<a href="x">Tom & Jerry\\'s</a>')`), '&lt;a href=&quot;x&quot;&gt;Tom &amp; Jerry&#39;s&lt;/a&gt;');
assert.equal(run('esc(null)'), '');
assert.equal(run('esc(undefined)'), '');
});
test('webUrl keeps only web addresses from a feed', () => {
assert.equal(run(`webUrl('https://example.com/a')`), 'https://example.com/a');
assert.equal(run(`webUrl('HTTP://example.com/')`), 'HTTP://example.com/');
for (const u of ['javascript:alert(1)', ' javascript:alert(1)', 'data:text/html,x', 'vbscript:x', '//example.com', ''])
assert.equal(run(`webUrl(${JSON.stringify(u)})`), null, u);
assert.equal(run('webUrl(null)'), null);
});
test('clock reads seconds as a player does', () => {
assert.equal(run('clock(0)'), '0:00');
assert.equal(run('clock(65)'), '1:05');
assert.equal(run('clock(3661)'), '1:01:01');
assert.equal(run('clock(59.9)'), '0:59');
assert.equal(run('clock(-5)'), '0:00');
assert.equal(run('clock(undefined)'), '0:00');
});
test('mb sizes a file in the unit that reads best', () => {
assert.equal(run('mb(0)'), '');
// An article's small image is not "0 KB" or "0 MB".
assert.equal(run('mb(100)'), '1 KB');
assert.equal(run('mb(2048)'), '2 KB');
assert.equal(run('mb(5 * 1048576)'), '5 MB');
assert.equal(run('mb(1.5 * 1073741824)'), '1.5 GB');
});
test('ago says how long since, and never for nothing', () => {
assert.equal(run('ago(0)'), 'never');
assert.equal(run(`ago(${NOW - 10})`), '1m ago');
assert.equal(run(`ago(${NOW - 45 * 60})`), '45m ago');
assert.equal(run(`ago(${NOW - 5 * 3600})`), '5h ago');
assert.equal(run(`ago(${NOW - 3 * 86400})`), '3d ago');
// Past a month, a date: whatever the locale, it names the year.
assert.match(run(`ago(${NOW - 90 * 86400})`), /2026/);
});
test('due says how long until a feed is checked', () => {
assert.equal(run(`due(${NOW - 1})`), 'due now');
assert.equal(run(`due(${NOW + 10})`), 'in 1m');
assert.equal(run(`due(${NOW + 90 * 60})`), 'in 2h');
assert.equal(run(`due(${NOW + 3 * 86400})`), 'in 3d');
});
test('DeadAir savings read as time', () => {
assert.equal(run('spanText(40)'), '40 s');
assert.equal(run('spanText(12 * 60 + 5)'), '12 min');
assert.equal(run('spanText(3 * 3600 + 12 * 60)'), '3 h 12 min');
assert.equal(run('shorter(45)'), '45 s shorter');
assert.equal(run('shorter(150)'), '3 min shorter');
});
test('initials and plural', () => {
assert.equal(run(`initials('The Verge')`), 'TV');
assert.equal(run(`initials('accidental tech podcast')`), 'AT');
assert.equal(run(`initials('')`), '?');
assert.equal(run(`initials('!!!')`), '?');
assert.equal(run(`plural(1, 'feed')`), '1 feed');
assert.equal(run(`plural(0, 'feed')`), '0 feeds');
assert.equal(run(`plural(2, 'feed')`), '2 feeds');
});
test("kindOf names a file by its type, or else its address's extension", () => {
const kind = enc => run(`kindOf(${JSON.stringify(enc)})`);
assert.equal(kind({ mime: 'image/png' }), 'image');
assert.equal(kind({ mime: 'Video/MP4' }), 'video');
assert.equal(kind({ mime: 'audio/mpeg' }), 'audio');
assert.equal(kind({ mime: 'application/pdf' }), 'pdf');
assert.equal(kind({ mime: 'application/x-bittorrent' }), 'torrent');
assert.equal(kind({ url: 'https://x/ep.MP3?token=1' }), 'mp3');
assert.equal(kind({ url: 'https://x/download' }), 'file');
assert.equal(kind({ url: 'https://x/a.toolongext' }), 'file');
assert.equal(kind({}), 'file');
});
test('an item without a title is named by its file', () => {
const name = e => run(`entryName(${JSON.stringify(e)})`);
assert.deepEqual({ ...name({ title: 'Episode 1' }) }, { text: 'Episode 1', derived: false });
assert.deepEqual({ ...name({ title: ' ', enclosures: [{ url: 'https://x/My%20Show%20101.mp3?x=1' }] }) },
{ text: 'My Show 101', derived: true });
// A name that does not decode is shown as it is, not dropped.
assert.deepEqual({ ...name({ enclosures: [{ url: 'https://x/100%.mp3' }] }) }, { text: '100%', derived: true });
});
test('the directory sorts by name as people read, and by subscribers', () => {
const sorted = (list, by) => run(`${JSON.stringify(list)}.sort(${by}).map(p => p.title)`);
assert.deepEqual([...sorted([{ title: 'ep 10' }, { title: 'Ep 2' }, { title: 'ep 1' }], 'byName')], ['ep 1', 'Ep 2', 'ep 10']);
assert.deepEqual([...sorted([{ title: 'B', subscribers: 1 }, { title: 'C', subscribers: 5 }, { title: 'A', subscribers: 1 }], 'bySubs')],
['C', 'A', 'B']);
});
test('feeds with something unread go first, in the order they came', () => {
const ids = run(`[{id:'a',unread:0},{id:'b',unread:3},{id:'c',unread:0},{id:'d',unread:1}].sort(unreadFirst).map(f => f.id)`);
assert.deepEqual([...ids], ['b', 'd', 'a', 'c']);
});
test('words for filters split on commas, blanks dropped', () => {
assert.deepEqual([...run(`splitWords(' one, two words ,, three ')`)], ['one', 'two words', 'three']);
assert.deepEqual([...run(`splitWords('')`)], []);
});
test("a file plays from DeadAir's playlist when it has one", () => {
assert.equal(run(`mediaSrc({ id: 7, deadair: null })`), '/media/7');
assert.equal(run(`mediaSrc({ id: 7 })`), '/media/7');
assert.equal(run(`mediaSrc({ id: 7, deadair: 0 })`), '/media/7/deadair.m3u8');
});
test('kept lists: the newest few dozen, and a changed feed forgets its own and All Subscriptions\'', () => {
run('lists.clear()');
for (let i = 0; i < 45; i++) run(`keepList(listKey('f${i}'), {total: 0, entries: []})`);
assert.equal(run('lists.size'), 40, 'never more than LISTS_KEPT');
assert.equal(run(`lists.has(listKey('f0'))`), false, 'the oldest go first');
run(`keepList(listKey('f10'), {total: 1, entries: []})`);
run(`keepList(listKey(':all'), {total: 0, entries: []}); keepList(listKey(':all', 'unread'), {total: 0, entries: []})`);
run(`forgetLists('f10')`);
assert.equal(run(`lists.has(listKey('f10'))`), false);
assert.equal(run(`lists.has(listKey(':all')) || lists.has(listKey(':all', 'unread'))`), false, 'All Subscriptions holds its items too');
assert.equal(run(`lists.has(listKey('f11'))`), true, 'the rest stay');
});
test('a Patreon feed is told by its address', () => {
assert.equal(run(`isPatreon({ url: 'https://www.patreon.com/rss/show?auth=x' })`), true);
assert.equal(run(`isPatreon({ url: 'https://example.com/feed' })`), false);
assert.equal(run('isPatreon(null)'), false);
});
test("All Subscriptions' unread count leaves out feeds hidden from it, and an OPML's shows with it", () => {
run(`S.feeds = [
{id: 'o', group: null, unread: 0, hide_from_all: true},
{id: 'a', group: 'o', unread: 2, hide_from_all: false},
{id: 'c', group: null, unread: 3, hide_from_all: true},
{id: 'd', group: null, unread: 5, hide_from_all: false}]`);
assert.equal(run('unreadInAll()'), 5);
run(`S.feeds[0].hide_from_all = false`);
assert.equal(run('unreadInAll()'), 7);
run(`S.feeds = []`);
});
test('a Signal is a place of its own, found by its id', () => {
run(`S.signals = [{id: 7, name: 'F1', keywords: ['f1'], unread: 2}]`);
assert.equal(run(`signalId(':signal:7')`), 7);
assert.equal(run(`signalId(':all')`), null);
assert.equal(run(`signalId('signal-7')`), null, 'a feed id is never a Signal');
assert.equal(run(`signalOf(':signal:7').name`), 'F1');
assert.equal(run(`signalOf(':signal:8')`), undefined);
assert.match(run(`listUrl(':signal:7')`), /^\/api\/signals\/7\/entries\?/);
run(`S.signals = []`);
});
test('the icon picker finds icons by the start of their words, the one named it first', () => {
const all = JSON.stringify([
['solid/caret-down', 'Caret Down', 'arrow'], ['solid/scarf', 'Scarf', 'clothing'], ['solid/taxi', 'Taxi', 'cab car'],
['solid/car-side', 'Car Side', 'auto vehicle'], ['solid/car', 'Car', 'auto vehicle'], ['solid/robot', 'Robot', 'ai bot machine'],
['brands/apple', 'Apple', 'fruit ios mac'],
]);
const ids = q => JSON.parse(run(`JSON.stringify(iconMatches(${all}, ${JSON.stringify(q)}, 10).map(i => i[0]))`));
// Named car, then car as a word of the name, then as a search term, then caret; never scarf.
assert.deepEqual(ids('car'), ['solid/car', 'solid/car-side', 'solid/taxi', 'solid/caret-down']);
assert.deepEqual(ids('ai'), ['solid/robot'], 'search terms count');
assert.deepEqual(ids('mac fruit'), ['brands/apple'], 'every word');
assert.equal(run(`iconMatches(${all}, '', 2).length`), 2, 'nothing typed is the first few');
});
test('editing a Signal sends only what changed, so a new icon asks Jev nothing again', () => {
const sg = `{id: 1, name: 'F1', keywords: ['f1'], description: null, icon: null, unread: 0}`;
assert.deepEqual(JSON.parse(run(`JSON.stringify(signalChanges(${sg}, {name: 'F1', keywords: ['f1'], description: null, icon: 'solid/car'}))`)),
{ icon: 'solid/car' });
assert.deepEqual(JSON.parse(run(`JSON.stringify(signalChanges(${sg}, {name: 'F1', keywords: ['f1', 'gp'], description: null, icon: null}))`)),
{ keywords: ['f1', 'gp'] });
});
test('a Signal draws its icon only when it is a style and a plain name', () => {
assert.match(run(`signalTile({name: 'F1', icon: 'solid/flag-checkered'})`), /url\('\/icons\/solid\/flag-checkered\.svg'\)/);
assert.doesNotMatch(run(`signalTile({name: 'F1', icon: "solid/x');background:url(evil"})`), /evil/);
assert.doesNotMatch(run(`signalTile({name: 'F1', icon: null})`), /ficon/);
});
test('Jev is offered the icons found for each word of a Signal', () => {
const all = JSON.stringify([
['solid/robot', 'Robot', 'ai bot machine'], ['solid/code', 'Code', 'programming brackets'],
['solid/car', 'Car', 'auto vehicle'], ['solid/the-red-yeti', 'The Red Yeti', ''],
]);
const ids = JSON.parse(run(`JSON.stringify(iconCandidates(${all}, 'AI Development', ['programming'], 'The tools', 40).map(c => c[0]))`));
assert.deepEqual(ids, ['solid/robot', 'solid/code'], 'each word its icons, "the" none');
const brands = JSON.stringify([
['brands/mandalorian', 'Mandalorian', 'disney plus helmet star wars'], ['brands/apple', 'Apple', 'fruit ios mac'],
['solid/fort-awesome', 'Fort', 'castle'], ['solid/apple-whole', 'Apple Whole', 'fruit'],
]);
const offered = q => JSON.parse(run(`JSON.stringify(iconCandidates(${brands}, ${JSON.stringify(q)}, [], null, 40).map(c => c[0]))`));
assert.deepEqual(offered('Disney castle'), ['solid/fort-awesome'], 'a brand only by its own name, never its search terms (#264)');
assert.deepEqual(offered('Apple'), ['brands/apple', 'solid/apple-whole']);
assert.equal(run(`signalCounts({items: 12, unread: 1})`), '12 items · 1 unread');
});
test('Signals are everyone\'s on the server\'s key, and yours on your own once a key is saved', () => {
const on = me => { run(`S.me = ${JSON.stringify(me)}`); return run('signalsOn()'); };
assert.equal(on({ signals: 'server', typesafe_key_set: false }), true);
assert.equal(on({ signals: 'own', typesafe_key_set: true }), true);
assert.equal(on({ signals: 'own', typesafe_key_set: false }), false);
assert.equal(on(null), false);
run('S.me = null');
});