GET /api/feeds/{id}/entries for an OPML or a Patreon creator now lists the
items of the shows in it the person subscribes to, as All Subscriptions does
every feed's. The shows are looked up first and queried by list: an OR with a
subquery, or an IN over one, made a single feed's page a sequential scan of
entries (clarkesworld 10 ms to 26 ms or a second, EXPLAIN on production), so a
single feed keeps its `feed_id = $1`.
The page draws the OPML with its own header and the item list, tabs, search
and sort of any feed, with the feed column showing which show each is from.
A show's scan or change refreshes its OPML's list.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
98 lines
4.8 KiB
JavaScript
98 lines
4.8 KiB
JavaScript
// Builds a page from web/src as build.rs does, runs its script against a stub DOM, and fails
|
|
// on anything thrown: web/index.html, then web/admin.html in a second run of this file.
|
|
//
|
|
// 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 { makeContext, vm } = require('./dom-stub.js');
|
|
const PAGE = process.argv[2] || 'index.html';
|
|
|
|
const { buildPage } = require('../web/build.mjs');
|
|
// What ships: minified, so an id may have lost its quotes.
|
|
const { html, js: script, script: file } = buildPage(PAGE);
|
|
if (!/<link rel=stylesheet href="?\/app\.css\?v=[0-9a-f]{12}"?>/.test(html) && PAGE !== 'login.html') {
|
|
console.error(`FAIL: ${PAGE} does not load /app.css?v=<hash>`); process.exit(1);
|
|
}
|
|
if (!new RegExp(`<script src="?/${file.replace('.', '\\.')}\\?v=[0-9a-f]{12}"?>`).test(html)) {
|
|
console.error(`FAIL: the page does not load /${file}?v=<hash>`); process.exit(1);
|
|
}
|
|
const { ctx, missing } = makeContext(html, script);
|
|
|
|
try {
|
|
vm.createContext(ctx);
|
|
vm.runInContext(script, ctx, { filename: `${PAGE}<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 = PAGE === 'admin.html' ? [
|
|
['drawServer', () => ctx.drawServer()],
|
|
['drawAccounts', () => ctx.drawAccounts()],
|
|
['drawLogView', () => ctx.drawLogView()],
|
|
] : [
|
|
['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()],
|
|
['opmlModal', () => ctx.opmlModal()],
|
|
['selectFeed (directory)', () => ctx.selectFeed(':directory')],
|
|
['selectFeed (currently listening)', () => ctx.selectFeed(':listening')],
|
|
['selectFeed (all subscriptions)', () => ctx.selectFeed(':all')],
|
|
['keysModal', () => ctx.keysModal()],
|
|
// `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.
|
|
['groupHead', () => ctx.groupHead(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);
|
|
}
|
|
}
|
|
|
|
// A trace is one tap's (#219): the same id while requests keep coming, a new one after a quiet
|
|
// minute, so a refresh hours later is not filed under the last tap.
|
|
if (PAGE === 'index.html') {
|
|
const id = () => ctx.traceparent().split('-')[1];
|
|
vm.runInContext('Date.now = (n => () => n)(1e12)', ctx);
|
|
const first = id();
|
|
vm.runInContext('Date.now = (n => () => n)(1e12 + 30_000)', ctx);
|
|
if (id() !== first) { console.error('FAIL: a trace ended 30 s into a tap'); process.exit(1); }
|
|
vm.runInContext('Date.now = (n => () => n)(1e12 + 120_000)', ctx);
|
|
if (id() === first) { console.error('FAIL: a trace outlived a quiet minute'); process.exit(1); }
|
|
}
|
|
|
|
// theme.ts keeps the Settings theme controls in step when Settings is open, and looks before it
|
|
// touches them. The admin page has no Settings, so those are the ones it may ask for and not find.
|
|
const OPTIONAL = new Set(['#stheme', '#smode', '#smodefield']);
|
|
missing.splice(0, missing.length, ...missing.filter(sel => !OPTIONAL.has(sel)));
|
|
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}: its script loads clean, every selector it wires at load exists`);
|
|
// The admin page's log arms a poll timer; without an exit the pending interval keeps node alive.
|
|
if (PAGE === 'index.html') {
|
|
const r = require('child_process').spawnSync(process.execPath, [__filename, 'admin.html'], { stdio: 'inherit' });
|
|
process.exit(r.status);
|
|
}
|
|
process.exit(0);
|