Frontend load test in CI, health page checks Seafile, OnlyOffice, audiobooks, podcasts and Mealie; roadmap updated
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
94d13b82d7
commit
db58bfa982
7 files changed
+56
-13
No files matched your search
@@ -18,6 +18,7 @@ jobs:
|
||||
node-version: 22
|
||||
- run: npm ci
|
||||
- run: npm audit --omit=dev --audit-level=high
|
||||
- run: node tools/frontend-test.js # every page script loads (no syntax errors, no names declared twice)
|
||||
- run: node tools/smoke-test.js
|
||||
- run: node tools/tasks-test.js # repeating tasks and subtasks
|
||||
- run: node tools/push-test.js # reminders: when they are sent, never twice
|
||||
|
||||
+11
-11
@@ -45,7 +45,7 @@ Not planned: voice and video calls, stories, usernames and phone-number lookup,
|
||||
- [x] **Family Home (M):** one shared start page: who is around, today's family calendar, shared shopping list and chores, what everyone is listening to or reading **Done: the Family page: calendar, chores, shopping, notes, chat and tasks on one start page.**
|
||||
- [x] **Shared lists and notes (M): done (notes.js, the Notes tab):** a real Notes and Lists tab the whole family edits (grocery, packing, gift ideas, house to-dos)
|
||||
- [x] **Chores and routines (M):** recurring chores per person, check-offs, optional points and a weekly summary; kids see their own **Done: chores.js and the Chores tab: repeats, points, weekly and monthly board.**
|
||||
- [ ] **Family calendar (M):** a colour per person, a shared week view, birthdays from Contacts, event invitations inside the family
|
||||
- [x] **Family calendar (M):** **Done: the Family calendar shows each event in the colour of whoever added it (Settings > Look sets yours); birthdays and anniversaries come from Contacts; inviting people puts the event on their calendar and tells them (no accept or decline). The week view already existed.**
|
||||
- [~] **Meal planning (M):** **Done: a shared week plan (Recipes > Meal plan) that adds the week's ingredients to the shopping list. Not built: flagging what is already in the pantry.**
|
||||
- [x] **Worship and devotion (S):** a shared daily verse, a Bible reading plan with check-ins, a family prayer list **Done: Bible > Devotion (devotion-view.js, bible.js).**
|
||||
|
||||
@@ -59,28 +59,28 @@ Not planned: voice and video calls, stories, usernames and phone-number lookup,
|
||||
- [x] **Search everywhere (M):** one search across mail, tasks, notes, recipes, contacts, books, songs, chats and Bible verses **Done: one search across mail, tasks, events, notes, chat, contacts, chores, recipes, files, music, books and the web.**
|
||||
- [x] **Continue row (S):** **Done: Home, today.js.** the last audiobook, episode, book, movie and song on Home
|
||||
- [x] **Notifications center (M):** one inbox for reminders, chores, new episodes, mail and chat, with quiet hours per person **Done: a bell with a per-person list (chat, reminders, announcements); quiet hours still apply to phone alerts.**
|
||||
- [ ] **Tasks 2.0 (M):** projects, subtasks everywhere, due-time reminders, Today and Upcoming views
|
||||
- [ ] **Mail 2.0 (L):** unified inbox, snooze, templates, attachments saved to Files
|
||||
- [~] **Tasks 2.0 (M):** **Done: a due time with a notification at that time, and Today and Upcoming views. Lists serve as projects. Not built: subtasks on iCloud lists.**
|
||||
- [x] **Mail 2.0 (L):** **Done: an All inboxes view (newest of each account), snooze, templates, and saving an attachment to Files. Not tried against real Outlook, Gmail or iCloud accounts, only the tests.**
|
||||
- [x] **Contacts 2.0 (S):** anniversaries and reminders, gift ideas **Done: anniversaries show in the calendar every year; gift ideas on each contact.**
|
||||
|
||||
## 5. Media
|
||||
- [ ] **Movies & TV 2.0 (L):** mixes, favorites and stats like Music; a shared watchlist and a "movie night" vote
|
||||
- [~] **Movies & TV 2.0 (L):** **Done: favorites, a shared Movie night list with votes, and mixes (random, family night, short, classics). Not built: watch stats. The mixes and favorites need a real Plex to try.**
|
||||
- [x] **Podcast and audiobook stats (S):** listening history like Music has **Done: time listened per day, streak and audiobooks finished, as a Home card (progress.js).**
|
||||
- [~] **Reading goals (S):** **Done: a yearly goal for audiobooks finished, with a listening streak. Not built: a family-wide challenge, e-book goals, exporting notes and highlights.**
|
||||
- [~] **Browse and search by genre (M):** in the Store, search books and audiobooks by genre (fiction, mystery, history, children's, Christian and so on) across the sources, with genre buttons, and the same genre filter on your own library and shelf. Needs each source's genre data (Gutenberg subjects, Open Library subjects, Standard Ebooks tags, Smashwords categories, LibriVox genres), normalised into one list. **Search by genre is done in the Store, Free Books and LibriVox (genres.js); filtering your own library by genre is not built.**
|
||||
- [x] **Casting (M):** **Done: cast.js (AirPlay and the browser's cast picker).** pick AirPlay or Cast targets for audio
|
||||
|
||||
## 6. Reliability (the admin's side)
|
||||
- [ ] **Browser smoke tests in CI (M):** many bugs this year showed only in the interface
|
||||
- [ ] **Admin health page (S):** all good or something is down: Seafile, Plex, Mealie, mail, storage
|
||||
- [~] **Browser smoke tests in CI (M):** **Done: tools/frontend-test.js loads every page script in one shared scope and fails on a syntax error, a name declared twice or a script that throws as it loads. Not a real browser: nothing clicks through the screens.**
|
||||
- [x] **Admin health page (S):** **Done: Settings > Admin checks Plex, Seafile, OnlyOffice, audiobooks, podcasts, Mealie, mail and storage.**
|
||||
- [x] **Backup and restore screen (S):** one-click restore, per-person export **Done: Settings > Admin restores a backup (old data kept aside); Settings > General downloads your own data.**
|
||||
- [ ] **Offline mode (L):** tasks, lists, recipes, saved books and music keep working with no signal (worth it if the family travels)
|
||||
- [ ] **Performance pass (S):** lazy-load the big pieces (the Bible text, the reader libraries)
|
||||
- [ ] **Accessibility (M):** larger text and high-contrast settings, keyboard navigation everywhere, reduced motion
|
||||
- [~] **Offline mode (L):** **Done: tasks, notes, recipes, the calendar, contacts and chores can be read with no signal (the last saved copy, kept per person); saved podcast episodes and albums already played. Not built: changing things offline, or reading saved books.**
|
||||
- [x] **Performance pass (S):** **Done: the Bible text, the reader libraries and the video player were already loaded on demand; page scripts now load deferred. Text was already compressed and cached.**
|
||||
- [x] **Accessibility (M):** **Done: text size, high contrast and reduce motion (Settings > Look and reading); focus outlines and the skip link already existed. Keyboard use of every screen was not audited.**
|
||||
|
||||
## 7. Platform (later)
|
||||
- [ ] **Extensions 2.0 (L):** more hub data in the bridge (notes, music, recipes), a permissions screen, extension updates
|
||||
- [ ] **Automations (L):** "when X then Y" rules (a new episode adds a task, a low pantry item adds to Shopping)
|
||||
- [x] **Extensions 2.0 (L):** **Done: notes, recipes and what is playing in the bridge; a Permissions screen per extension; an Updated notice when an admin changes one.**
|
||||
- [~] **Automations (L):** **Done: per-person rules (Settings > Automations): every day at a time, a task is finished or new mail arrives, then add a task, add to Shopping or notify. Not built: new podcast episodes or a low pantry item as triggers.**
|
||||
- [x] **Themes (S):** accent colours and an AMOLED dark mode per person **Done: themes.js, Settings > General > Look.**
|
||||
|
||||
## Suggested order
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"test": "for t in smoke tasks push users cal plex player lyrics signal mail mail-imap files podcast abapi abdirect books store librivox radio vbv savefiles bible lrclib visualizer body transcript recipes contacts mystore caldav musicdata screentime chat notes chores kidlimits search inbox family board chores-view kidcontrols kidsearch; do node tools/$t-test.js || exit 1; done",
|
||||
"test": "for t in smoke frontend tasks push users cal plex player lyrics signal mail mail-imap files podcast abapi abdirect books store librivox radio vbv savefiles bible lrclib visualizer body transcript recipes contacts mystore caldav musicdata screentime chat notes chores kidlimits search inbox family board chores-view kidcontrols kidsearch; do node tools/$t-test.js || exit 1; done",
|
||||
"audit": "npm audit --omit=dev --audit-level=high"
|
||||
},
|
||||
"keywords": [],
|
||||
|
||||
@@ -154,6 +154,7 @@ function viewSettings(args) {
|
||||
const up = h.uptime > 86400 ? Math.floor(h.uptime / 86400) + ' days' : h.uptime > 3600 ? Math.floor(h.uptime / 3600) + ' hours' : Math.max(1, Math.floor(h.uptime / 60)) + ' min';
|
||||
$('#health').innerHTML =
|
||||
(h.plex.configured ? row('Plex', h.plex.ok ? 'Answering in ' + h.plex.ms + ' ms' + (h.plex.user ? ' · using ' + esc(h.plex.user) : '') : esc(h.plex.error || 'Not answering'), !h.plex.ok) : row('Plex', 'Not set up', false))
|
||||
+ (h.services || []).map(x => row(esc(x.name), x.ok ? 'Answering in ' + x.ms + ' ms' : esc(x.error || 'Not answering'), !x.ok)).join('')
|
||||
+ (h.mail.length ? h.mail.map(m => row('Mail: ' + esc(m.address), m.ok ? 'Connected (' + m.ms + ' ms)' : esc(m.error || 'Could not connect'), !m.ok)).join('') : row('Mail', 'No account connected', false))
|
||||
+ (h.disk ? row('Disk', gb(h.disk.free) + ' free of ' + gb(h.disk.total), h.disk.free / h.disk.total < .1 || h.disk.free < 2e9) : '')
|
||||
+ row('Last backup download', h.backup ? days(h.backup.t) + ' day' + (days(h.backup.t) === 1 ? '' : 's') + ' ago (by ' + esc(h.backup.who || '?') + ')' : 'Never. Use Download a backup below.', !h.backup || days(h.backup.t) > 30)
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
// App-shell cache only; Seafile/API traffic always goes to the network.
|
||||
const CACHE = 'hub-v115';
|
||||
const CACHE = 'hub-v117';
|
||||
const KEEP = /^hub-(offline|share|who|data-)/; // caches that outlive a new version
|
||||
const SHARE = 'hub-share'; // files shared into the app from other apps, held until the person picks a folder
|
||||
const SHELL = ['/icon.svg', '/manifest.webmanifest', '/icons/icon-192.png', '/icons/apple-touch-icon.png'];
|
||||
|
||||
@@ -679,6 +679,11 @@ async function handleRequest(req, res) {
|
||||
const ps = plexLoad();
|
||||
out.plex = !ps.srv ? { configured: false } : await (async () => { const t = Date.now(); try { const r = await fetch(ps.srv + '/identity', { headers: { 'X-Plex-Token': ps.stk || ps.token || '', Accept: 'application/json' }, signal: AbortSignal.timeout(6000) }); return { configured: true, ok: r.ok, ms: Date.now() - t, user: ps.user ? ps.user.title : '' }; } catch (e) { return { configured: true, ok: false, error: e.name === 'TimeoutError' ? 'No answer in 6 seconds' : e.message }; } })();
|
||||
try { out.mail = await mail.health(); } catch { out.mail = []; }
|
||||
// the other services the hub leans on: reachable at all? (any answer under 500 counts, since most want a sign-in)
|
||||
const probe = async (name, url) => { const t = Date.now(); try { const r = await fetch(url, { redirect: 'manual', signal: AbortSignal.timeout(4000) }); return r.status < 500 ? { name, ok: true, ms: Date.now() - t } : { name, ok: false, error: 'Answered with an error (' + r.status + ')' }; } catch (e) { return { name, ok: false, error: e.name === 'TimeoutError' ? 'No answer within 4 seconds' : 'Cannot be reached' }; } };
|
||||
const checks = [probe('Seafile (Files)', SEAFILE_URL + '/api2/ping/'), probe('OnlyOffice', ONLYOFFICE_URL + '/healthcheck'), probe('Audiobooks', (process.env.OPENAUDIBLE_URL || AUDIOBOOKS_URL).replace(/\/$/, '') + '/'), probe('Podcasts (Podgrab)', PODCAST_URL)];
|
||||
if (recipes.enabled()) checks.push(probe('Recipes (Mealie)', (process.env.MEALIE_URL || 'https://mealie.necloud.us').replace(/\/+$/, '') + '/api/app/about'));
|
||||
out.services = await Promise.all(checks);
|
||||
out.lyrics = lyrics.enabled(); out.signal = signal.enabled(); out.transcripts = transcripts.status();
|
||||
res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' }); return res.end(JSON.stringify(out));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
// The browser side, without a browser: every script the page loads is run in order in one shared scope (as the browser does), against a stand-in for the page.
|
||||
// This catches what only showed up in the interface before: a syntax error, a name declared twice by two files (which stops the second file and everything after it),
|
||||
// a script that throws as it loads, a missing file, and a script listed in index.html twice or not at all.
|
||||
const fs = require('fs'), vm = require('vm'), path = require('path'), assert = require('assert');
|
||||
const PUB = path.join(__dirname, '..', 'public');
|
||||
const html = fs.readFileSync(path.join(PUB, 'index.html'), 'utf8');
|
||||
const listed = [...html.matchAll(/<script[^>]*\ssrc="(\/[\w.-]+\.js)"/g)].map(m => m[1]);
|
||||
assert.ok(listed.length > 40, 'index.html lists the scripts');
|
||||
assert.equal(new Set(listed).size, listed.length, 'no script is listed twice');
|
||||
for (const f of listed) assert.ok(fs.existsSync(path.join(PUB, f)), f + ' is listed but missing');
|
||||
const unlisted = fs.readdirSync(PUB).filter(f => f.endsWith('.js') && !listed.includes('/' + f) && !/^(sw|login|board|device-ui|webauthn)\.js$/.test(f)); // (the service worker and the other pages have their own)
|
||||
assert.deepEqual(unlisted, [], 'every script in public/ is loaded by the page (or is one of the known others)');
|
||||
|
||||
// a stand-in for the browser that answers anything: any property is another stand-in, calling it gives one, awaiting it gives it back
|
||||
const stub = () => { const f = function () {}; const p = new Proxy(f, { get: (t, k) => k === Symbol.toPrimitive ? () => '' : k === 'then' ? undefined : k === 'length' ? 0 : k === Symbol.iterator ? function* () {} : p, apply: () => p, construct: () => p, set: () => true, has: () => true }); return p; };
|
||||
const store = {}, win = {};
|
||||
const ctx = Object.assign(win, {
|
||||
console: { log() {}, warn() {}, error() {}, info() {}, debug() {} }, document: stub(), navigator: Object.assign(stub(), { onLine: true, userAgent: 'test' }), location: { hash: '', href: 'http://x/', origin: 'http://x', pathname: '/', search: '', reload() {}, replace() {} }, history: stub(),
|
||||
localStorage: { getItem: k => store[k] ?? null, setItem: (k, v) => { store[k] = String(v); }, removeItem: k => { delete store[k]; } }, sessionStorage: stub(), matchMedia: () => ({ matches: false, addEventListener() {}, addListener() {} }),
|
||||
fetch: async () => ({ ok: true, status: 200, json: async () => ({}), text: async () => '', headers: { get: () => '' } }), addEventListener() {}, removeEventListener() {}, setTimeout: () => 0, clearTimeout() {}, setInterval: () => 0, clearInterval() {}, requestAnimationFrame: () => 0,
|
||||
Audio: function () { return stub(); }, Image: function () { return stub(); }, MutationObserver: function () { return { observe() {}, disconnect() {} }; }, IntersectionObserver: function () { return { observe() {}, disconnect() {}, unobserve() {} }; }, ResizeObserver: function () { return { observe() {}, disconnect() {} }; },
|
||||
URL, URLSearchParams, Blob, TextEncoder, TextDecoder, AbortController, Intl, Date, JSON, Math, Promise, Set, Map, WeakMap, Array, Object, String, Number, RegExp, Error, Symbol, Uint8Array, ArrayBuffer, DataView, encodeURIComponent, decodeURIComponent, parseInt, parseFloat, isNaN, structuredClone, atob, btoa,
|
||||
screen: {}, innerWidth: 1000, innerHeight: 800, devicePixelRatio: 1, performance: { now: () => 0 }, crypto: stub(), indexedDB: undefined, caches: stub(), Notification: stub(), alert() {}, confirm: () => false, prompt: () => null, getComputedStyle: () => stub(), CSS: stub(),
|
||||
});
|
||||
ctx.window = ctx; ctx.self = ctx; ctx.globalThis = ctx; vm.createContext(ctx);
|
||||
process.on('unhandledRejection', () => {}); // (boot.js starts the app against the stand-in; what it does after loading is not under test)
|
||||
const failures = [];
|
||||
for (const f of listed) {
|
||||
const file = path.join(PUB, f), src = fs.readFileSync(file, 'utf8');
|
||||
try { new vm.Script(src, { filename: f }); } catch (e) { failures.push(`${f}: syntax error: ${e.message}`); continue; }
|
||||
try { vm.runInContext(src, ctx, { filename: f }); } catch (e) { failures.push(`${f}: ${e.name}: ${e.message}`); }
|
||||
}
|
||||
assert.deepEqual(failures, [], 'every script loads:\n ' + failures.join('\n '));
|
||||
// the pieces the app calls by name across files are there
|
||||
for (const name of ['route', 'viewHome', 'viewSettings', 'viewTasks', 'viewCalendar', 'viewBible', 'viewMail', 'viewRecipes', 'viewVideo', 'themeApply', 'autoSettings', 'bbToday', 'homeListening', 'saveToFiles', 'downloadText']) assert.equal(typeof vm.runInContext(`typeof ${name}`, ctx), 'string') && assert.equal(vm.runInContext(`typeof ${name}`, ctx), 'function', name + ' is defined');
|
||||
console.log('frontend test ok (' + listed.length + ' scripts)'); process.exit(0);
|
||||
Reference in new issue
Block a user