It was a section at the bottom of the Popular page, so finding the episode you were halfway through meant opening a list of feeds first. It is a place in the feed list now, between Popular and All Subscriptions, with its own page and count. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
117 lines
5.6 KiB
JavaScript
117 lines
5.6 KiB
JavaScript
// Executes web/index.html's script against a stub DOM and fails on anything thrown.
|
|
//
|
|
// This exists because a ReferenceError at load once blanked the whole UI: a patch
|
|
// anchored on a function that no longer existed, so `prefsModal` was referenced but
|
|
// never defined. `node --check` passes that happily -- it is a parse, not a run --
|
|
// and every server-side test passed too, because the server was fine.
|
|
//
|
|
// node tests/page-smoke.js
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const vm = require('vm');
|
|
|
|
const html = fs.readFileSync(path.join(__dirname, '..', 'web', 'index.html'), 'utf8');
|
|
const script = html.split('<script>')[1].split('</script>')[0];
|
|
const ids = new Set([...html.matchAll(/id="([^"]+)"/g)].map(m => m[1]));
|
|
|
|
const missing = [];
|
|
const el = (name) => new Proxy({ style: { setProperty(){}, getPropertyValue(){ return ''; } }, dataset: {}, classList: { add(){}, remove(){}, toggle(){}, contains(){ return false; } },
|
|
value: '', textContent: '', innerHTML: '', hidden: false, children: [], firstElementChild: null,
|
|
appendChild(){}, removeChild(){}, remove(){}, insertAdjacentHTML(){}, addEventListener(){},
|
|
setAttribute(){}, getAttribute(){ return null; }, select(){}, setSelectionRange(){}, focus(){},
|
|
replaceWith(){}, querySelector(){ return el('nested'); }, querySelectorAll(){ return []; },
|
|
play(){ return Promise.resolve(); }, pause(){}, closest(){ return null; } },
|
|
{ get: (t, k) => k in t ? t[k] : undefined, set: (t, k, v) => (t[k] = v, true) });
|
|
|
|
const document = {
|
|
querySelector(sel) {
|
|
if (sel.startsWith('#') && !ids.has(sel.slice(1))) { missing.push(sel); return null; }
|
|
return el(sel);
|
|
},
|
|
querySelectorAll: () => [],
|
|
createElement: () => el('created'),
|
|
addEventListener(){}, body: el('body'),
|
|
documentElement: { dataset: {} },
|
|
};
|
|
|
|
const ctx = {
|
|
document, console,
|
|
window: { isSecureContext: false, addEventListener(){} },
|
|
localStorage: { getItem: () => null, setItem(){}, removeItem(){} },
|
|
navigator: { clipboard: undefined, sendBeacon(){}, mediaSession: undefined },
|
|
fetch: (url) => Promise.resolve({
|
|
ok: true, status: 200, text: () => Promise.resolve(''),
|
|
json: () => Promise.resolve(
|
|
String(url).includes('/api/settings')
|
|
? { schedule: 'every 60m', every_mins: 60, download_dir: '/tmp', max_total_gb: 0, max_age_days: 0 }
|
|
: String(url).includes('/api/users')
|
|
? [{ id: 1, name: 'admin', admin: true, password: true }, { id: 2, name: 'sam', admin: false, password: false }]
|
|
: /\/api\/(popular|directory)/.test(String(url))
|
|
? [{ id: 'f', title: 'A Feed', image: null, subscribers: 2, subscribed: true },
|
|
{ id: 'g', title: null, image: null, subscribers: 1, subscribed: false }]
|
|
: /entries/.test(String(url)) ? { total: 0, entries: [] } : []),
|
|
}),
|
|
EventSource: function () { this.close = () => {}; },
|
|
MediaMetadata: function () {},
|
|
Blob: function () {},
|
|
setTimeout, clearTimeout, setInterval, clearInterval,
|
|
confirm: () => false, prompt: () => null, alert(){},
|
|
Date, Math, JSON, Object, Array, String, Number, Promise, Error, FormData: function(){},
|
|
URLSearchParams, encodeURIComponent, decodeURIComponent, parseInt, parseFloat, isNaN,
|
|
};
|
|
ctx.globalThis = ctx;
|
|
ctx.window.location = { href: '' };
|
|
|
|
try {
|
|
vm.createContext(ctx);
|
|
vm.runInContext(script, ctx, { filename: 'index.html<script>', timeout: 5000 });
|
|
} catch (e) {
|
|
console.error('FAIL: the page script threw while loading\n ' + e.stack.split('\n').slice(0, 3).join('\n '));
|
|
process.exit(1);
|
|
}
|
|
|
|
// The modals are built on demand, so a load-time check never reaches them. Drive the
|
|
// ones that construct markup from live data, which is where a bad field reference hides.
|
|
const feed = {
|
|
id: 'f', url: 'https://x/rss', title: 'A Feed', image: null, folder: null,
|
|
keywords: ['a'], allow_explicit: false, auto_download: true, max_new_per_check: 3,
|
|
schedule: 'every 6h', schedule_mins: 360, every_mins: 360,
|
|
last_checked: 1, next_check: 2, entries: 1, downloaded: 0, unread: 1, last_error: null,
|
|
};
|
|
const drive = [
|
|
['settingsModal', () => ctx.settingsModal(feed)],
|
|
['settingsModal (no override)', () => ctx.settingsModal({ ...feed, schedule: null, schedule_mins: null })],
|
|
['downloadLatestModal', () => ctx.downloadLatestModal(feed)],
|
|
['removeFeed', () => ctx.removeFeed(feed)],
|
|
['prefsModal', () => ctx.prefsModal()],
|
|
['usersModal', () => ctx.usersModal()],
|
|
['opmlModal', () => ctx.opmlModal()],
|
|
['selectFeed (directory)', () => ctx.selectFeed(':directory')],
|
|
['selectFeed (popular)', () => ctx.selectFeed(':popular')],
|
|
['selectFeed (currently listening)', () => ctx.selectFeed(':listening')],
|
|
['selectFeed (all subscriptions)', () => ctx.selectFeed(':all')],
|
|
['logsModal', () => ctx.logsModal()],
|
|
// `const S` is not reachable from here: top-level const/let do not become properties
|
|
// of a vm context the way var and function declarations do.
|
|
['renderGroup', () => ctx.renderGroup(feed, [{ ...feed, id: 'child', group: 'f', orphaned: true }])],
|
|
];
|
|
for (const [name, fn] of drive) {
|
|
try {
|
|
const r = fn();
|
|
if (r && typeof r.catch === 'function') r.catch(e => {
|
|
console.error(`FAIL: ${name} rejected: ${e.message}`); process.exit(1);
|
|
});
|
|
} catch (e) {
|
|
console.error(`FAIL: ${name} threw: ${e.message}`);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
if (missing.length) {
|
|
console.error('FAIL: handlers wired to elements that do not exist: ' + [...new Set(missing)].join(', '));
|
|
process.exit(1);
|
|
}
|
|
console.log('OK: page script loads clean, every selector it wires at load exists');
|
|
// logsModal arms a poll timer; without this the pending interval keeps node alive.
|
|
process.exit(0);
|