Files
ipodderx-rs/tests/page-smoke.js
rays 5d3fdde4da Upload an OPML file to import; tests for every way in and out
- The import screen has a file picker beside the paste box. The page
  reads the file, checks it looks like OPML before sending, and clears
  the picker when it is refused and after it is imported. The file is
  sent as text and never written to disk on the server.
- The server parses the OPML before touching anything and answers 400
  "that is not an OPML file" (was a 500). subscribe_opml takes a parsed
  document, so ipx import also refuses a non-OPML file by name.
- Tests: Settings' Export OPML download and paste import; uploading an
  RSS file (refused) and a real OPML; the server's 400; the admin's
  export round-tripped into a second account; ipx import/export in a
  scratch config.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016HdTEWQNrzyFULijigkmMn
2026-09-11 13:42:38 +00:00

110 lines
4.9 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 }
: String(url).includes('/api/users')
? [{ id: 1, name: 'admin', admin: true, password: true }, { id: 2, name: 'sam', admin: false, password: false }]
: []),
}),
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()],
['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);