/admin, with Server, Accounts and Log sections chosen by the URL's hash. The server sends the page and /admin.js to admins only (anyone else asking for the page goes back to the app, and the script is 403), and removes the header's link to it from everyone else's page rather than hiding it. The API keeps refusing all of it to non-admins as before. Settings becomes personal: theme, OPML import and export, and the schedule and download folder to read. The server fields, the Users dialog and the Log dialog move out of dialogs.ts into admin.ts. The CSS moves out of index.html into web/app.css, which both pages load as /app.css?v=<hash>, served immutable like the scripts. The smoke test checks both pages. Closes #19. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
137 lines
6.8 KiB
JavaScript
137 lines
6.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 vm = require('vm');
|
|
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);
|
|
}
|
|
// Ids in the page, and in the markup the script builds for its dialogs.
|
|
const ids = new Set([...(html + script).matchAll(/\bid=(?:"([^"]+)"|([^\s>"']+))/g)].map(m => m[1] || m[2]));
|
|
|
|
const missing = [];
|
|
const el = (name) => new Proxy({ style: { setProperty(){}, getPropertyValue(){ return ''; } }, 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 }]
|
|
: /\/api\/(popular|directory)/.test(String(url))
|
|
? [{ id: 'f', title: 'A Feed', image: null, subscribers: 2, subscribed: true },
|
|
{ id: 'g', title: null, image: null, subscribers: 1, subscribed: false }]
|
|
: /entries/.test(String(url)) ? { total: 0, entries: [] } : []),
|
|
}),
|
|
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: '', hash: '' };
|
|
ctx.location = ctx.window.location;
|
|
|
|
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 (popular)', () => ctx.selectFeed(':popular')],
|
|
['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.
|
|
['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);
|
|
}
|
|
}
|
|
|
|
// 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);
|