From 9960befed51d6355ac416178634e22d355073687 Mon Sep 17 00:00:00 2001 From: rays Date: Thu, 10 Sep 2026 02:31:18 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01AdXho5tTkjFLeUXKbEjKBh --- PROGRESS.md | 30 +++++++++++++++++++++ tests/page-smoke.js | 66 +++++++++++++++++++++++++++++++++++++++++++++ web/index.html | 54 ++++++++++++++++++++++++++++++++++++- 3 files changed, 149 insertions(+), 1 deletion(-) create mode 100644 tests/page-smoke.js diff --git a/PROGRESS.md b/PROGRESS.md index 9f6d19e..e9f9be4 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -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 ``; the schedule lived diff --git a/tests/page-smoke.js b/tests/page-smoke.js new file mode 100644 index 0000000..91454b5 --- /dev/null +++ b/tests/page-smoke.js @@ -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('')[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