Fix the UI dying at load, and add a page smoke test

$('#prefs').onclick referenced a prefsModal that was never defined, and an
uncaught ReferenceError stops the whole script -- taking the theme toggle,
the feed filter, the event stream and loadFeeds() down with it, so the app
rendered an empty shell.

The scheduling patch had anchored on a function the rewrite already
deleted; str.replace matched nothing and said nothing.

tests/page-smoke.js executes the page against a stub DOM so this class of
failure is visible, since every server-side check passed while the UI was
completely dead. Handler wiring now skips a bad reference instead of
throwing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AdXho5tTkjFLeUXKbEjKBh
This commit is contained in:
2026-09-10 02:31:18 +00:00
parent 9dc4c1ddfa
commit 9960befed5
3 changed files with 149 additions and 1 deletions

View File

@@ -56,6 +56,36 @@ and until now nothing set them.
---
## 2026-09-10 — The whole UI was dead, and server-side tests could not see it
Reported as "my feeds seem to have disappeared", then "settings and dark/light mode don't do
anything either". One cause for all three.
`$('#prefs').onclick = prefsModal` referenced a function that did not exist. An uncaught
`ReferenceError` stops the entire script, and that line sits above the theme toggle, the feed
filter, the SSE connection and the `loadFeeds()` call that fills the sidebar — so everything below
it silently never ran.
**Cause: a patch anchored on something already deleted.** The scheduling UI was inserted with
`str.replace` anchored on `function toggleSettings(){`, which belonged to the *old* basic UI that
the full rewrite had already removed. Python's replace matches nothing and says nothing, and there
was no assert — so `prefsModal`, `everyText`, `due` and `globalEvery` were never added, while the
line *calling* `prefsModal` went in fine via a different anchor that did match.
**Why it got through.** Every check was server-side: curl for status codes, JSON shape, config
contents. All passed, because the server was fine. `node --check` also passed — it parses, and a
ReferenceError is a runtime failure. The page was never executed.
`tests/page-smoke.js` now runs the real page script against a stub DOM, fails on anything thrown,
and flags handlers wired to elements that do not exist. Confirmed non-vacuous by reintroducing the
exact bug: exit 1 pointing at the offending line, clean once restored. Run it with
`node tests/page-smoke.js`.
Wiring is also defensive now — `on(sel, ev, fn)` logs and skips rather than throwing, so one dead
reference cannot blank the app again.
---
## 2026-09-10 — Scheduling, and two bugs it uncovered
**Scheduling.** The original engine had none — it only skipped feeds on `<ttl>`; the schedule lived

66
tests/page-smoke.js Normal file
View File

@@ -0,0 +1,66 @@
// 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: () => Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve([]), text: () => Promise.resolve('') }),
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);
}
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');

View File

@@ -670,6 +670,52 @@ $('#addFeed').onclick=()=>{
};
};
let globalEvery = 60;
function everyText(m){
if(!m) return '\u2014';
if(m % 1440 === 0) return (m/1440)+(m===1440?' day':' days');
if(m % 60 === 0) return (m/60)+(m===60?' hour':' hours');
return m+' min';
}
function due(ts){
const d = ts - Date.now()/1000;
if(d <= 0) return 'due now';
if(d < 3600) return 'in '+Math.max(1,Math.round(d/60))+'m';
if(d < 86400) return 'in '+Math.round(d/3600)+'h';
return 'in '+Math.round(d/86400)+'d';
}
async function prefsModal(){
const g = await api('/api/settings');
globalEvery = g.every_mins;
openModal(`<h3>Settings</h3>
<div class="field"><label>Check feeds</label>
<input type="text" id="gsched" value="${esc(g.schedule)}">
<span class="hint">How often every feed is re-checked unless it overrides this.
Try <b>every 30m</b>, <b>every 4h</b>, <b>1d</b>. A feed's own suggested interval
(its <b>ttl</b>) is honoured when it asks to be polled less often than this.</span></div>
<div class="field"><label>Disk quota (GB, 0 = unlimited)</label>
<input type="number" id="gquota" min="0" step="0.5" value="${g.max_total_gb}">
<span class="hint">Over this, the oldest played episodes are deleted first. Starred
episodes are never touched.</span></div>
<div class="field"><label>Delete episodes older than (days, 0 = keep)</label>
<input type="number" id="gage" min="0" value="${g.max_age_days}"></div>
<div class="field"><label>Download folder</label>
<span class="hint" style="overflow-wrap:anywhere">${esc(g.download_dir)}</span></div>
<div class="cardacts"><button class="btn" onclick="closeModal()">Cancel</button>
<button class="btn primary" id="gsave">Save</button></div>`);
$('#gsave').onclick=async()=>{
try{
await api('/api/settings',{method:'PATCH',body:JSON.stringify({
schedule:$('#gsched').value.trim(),
max_total_gb:Number($('#gquota').value)||0,
max_age_days:Number($('#gage').value)||0})});
closeModal(); toast('Settings saved');
await loadFeeds(true); if(S.feed){ renderFeed(); loadEntries(); }
}catch(e){ toast(e.message,true); }
};
}
function settingsModal(f){
openModal(`<h3>${esc(f.title||f.id)}</h3>
<div class="field"><label>Download folder</label>
@@ -760,8 +806,14 @@ $('#opml').onclick=()=>{
};
};
function on(sel,ev,fn){
const el=$(sel);
if(!el){ console.error('ipx: no element',sel); return; }
if(typeof fn!=='function'){ console.error('ipx: handler for',sel,'is not a function'); return; }
el[ev]=fn;
}
$('#scanAll').onclick=async()=>{ toast('Scanning all feeds…'); await api('/api/fetch',{method:'POST',body:JSON.stringify({force:true})}); };
$('#prefs').onclick=prefsModal;
on('#prefs','onclick',prefsModal);
$('#feedFilter').oninput=renderFeeds;
$('#burger').onclick=()=>$('#sidebar').classList.toggle('open');
$('#theme').onclick=()=>{