Writing 82 derived feeds into a hand-edited config.toml made it unreadable. The OPML is the source of truth, so its feeds are re-derived each scan and held in the database, inheriting the subscription's settings; editing one promotes it to a real entry. A migration moves existing children out -- 611 lines to 38 -- keeping all entries and files. max_new_per_check defaulted to unlimited, so subscribing to an OPML of 82 feeds pulled whole back catalogues. It now defaults to 3 via [general], capping every feed that does not set its own, and the pending queue orders by publish date so a cap of 3 means the three newest. Scans and downloads travelled as socket events only, so the log view showed no daemon activity. They are mirrored into tracing, with routine skips at debug -- at 82 feeds those alone would flush the buffer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AdXho5tTkjFLeUXKbEjKBh
106 lines
4.7 KiB
JavaScript
106 lines
4.7 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: {}, 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 }
|
|
: []),
|
|
}),
|
|
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()],
|
|
['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);
|