Files
hub/tools/smoke-test.js
T
nmemmertandClaude Sonnet 5.5 489ea0aaf0
build-container / build (push) Successful in 1m55s
Fix: the server crashed on every Search; Search now covers tasks and events and explains itself
- Restore ddgAnswer, which an earlier edit deleted: /api/ddg threw a ReferenceError and killed the
  whole server process whenever Search ran
- One failing request can no longer take the server down (errors are logged and answered with a 500)
- tools/smoke-test.js starts the real server and calls every endpoint; the Gitea workflow runs it before
  building the image, so a missing function or a crash stops the build
- Search: also searches NE Hub tasks and calendar events; Seafile file search falls back to scanning file
  names when the search index returns nothing; Plex search falls back to the library's artists/albums;
  a status line shows what was searched, per-source counts and errors, with sign-in links

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 16:18:12 -04:00

55 lines
5.1 KiB
JavaScript

#!/usr/bin/env node
// Starts the real server on a spare port with a temporary data folder and calls every endpoint while signed in.
// Fails if any request gets a 500 (502/504 for an unreachable Seafile/Plex is a normal answer), if the process dies, or if a route points at a function that does not exist.
// Run: node tools/smoke-test.js (also run by the Gitea workflow before the container is built)
const { spawn } = require('child_process');
const fs = require('fs'), os = require('os'), path = require('path'), crypto = require('crypto');
const root = path.join(__dirname, '..');
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'nehub-smoke-'));
const port = 20000 + Math.floor(Math.random() * 20000), base = `http://127.0.0.1:${port}`;
let failed = 0;
const ok = (name, cond, extra) => { console.log((cond ? 'PASS ' : 'FAIL ') + name + (cond ? '' : ' -> ' + extra)); if (!cond) failed++; };
// 1) every function the router calls must exist
const src = fs.readFileSync(path.join(root, 'server.js'), 'utf8');
const targets = [...new Set([...src.matchAll(/return (\w+)\(req, res\)/g)].map(m => m[1]))];
const missing = targets.filter(n => !new RegExp(`(async )?function ${n}\\b|const ${n} =`).test(src));
ok('every function the router calls is defined', !missing.length, missing);
const child = spawn(process.execPath, ['server.js'], { cwd: root, env: { ...process.env, PORT: String(port), DATA_DIR: dir, COOKIE_SECRET: 'smoke-'.repeat(8), HUB_SETUP_CODE: 'smoke', SEAFILE_URL: 'http://127.0.0.1:9' } });
let alive = true, log = ''; child.on('exit', () => { alive = false; }); child.stdout.on('data', d => log += d); child.stderr.on('data', d => log += d);
const wait = ms => new Promise(r => setTimeout(r, ms));
(async () => {
for (let i = 0; i < 50; i++) { try { await fetch(base + '/login'); break; } catch { await wait(100); } }
const exp = Date.now() + 864e5, sig = crypto.createHmac('sha256', 'smoke-'.repeat(8)).update('s' + exp).digest('base64url');
const cookie = `hub_session=${exp}.${sig}`;
const get = async (p, method = 'GET', body) => { try { const r = await fetch(base + p, { method, redirect: 'manual', headers: { cookie, 'content-type': 'application/json' }, body: body && JSON.stringify(body) }); await r.text(); return r.status; } catch { return -1; } };
const pages = ['/', '/login', '/app.js', '/style.css', '/sw.js', '/manifest.webmanifest', '/icons/icon-192.png', '/icons/apple-touch-icon.png'];
const api = ['/api/config', '/api/system', '/api/settings', '/api/auth/state', '/api/auth/passkeys', '/api/tasks/lists', '/api/tasks?list=local:inbox', '/api/tasks/summary',
'/api/cal/list', '/api/cal/icloud', '/api/cal/feed', '/api/search?q=ab', '/api/search?q=a', '/api/ddg?q=einstein', '/api/ddg?q=a', '/api/plex/status',
`/api/cal/events?from=${Date.now()}&to=${Date.now() + 864e5}&fd=2026-01-01&td=2026-01-02`];
const bad = [];
for (const p of [...pages, ...api]) { const st = await get(p); if (st === 500 || st === -1 || !alive) bad.push(`${p} -> ${alive ? st : 'server died'}`); }
ok(`${pages.length + api.length} pages and endpoints answer without a server error`, !bad.length, bad);
// a few writes, including bad input, which must produce 4xx and never a crash
const writes = [['/api/tasks', { list: 'local:inbox', fields: { title: 'smoke', notes: '', due: '', done: false } }], ['/api/tasks', { list: 'local:inbox', fields: { title: '' } }], ['/api/tasks', { list: 'nope', fields: {} }],
['/api/settings', { key: 'cal_view', value: 'month' }, 'PUT'], ['/api/settings', { key: 'evil', value: 1 }, 'PUT'], ['/api/cal/event', { cal: 'local', fields: { title: 'x' } }], ['/api/cal/event', { cal: 'icloud:0000000000', fields: {} }],
['/api/cal/add', { url: 'http://localhost/x' }], ['/api/cal/icloud/connect', { appleId: '', password: '' }], ['/api/plex/server', { id: 'x' }], ['/api/oo/config', {}], ['/api/plex/pin', {}]];
const wbad = [];
for (const [p, b, m] of writes) { const st = await get(p, m || 'POST', b); if (st === 500 || st === -1 || !alive) wbad.push(`${m || 'POST'} ${p} -> ${alive ? st : 'server died'}`); }
ok(`${writes.length} writes with good and bad input never crash the server`, !wbad.length, wbad);
// malformed requests
const rawSt = await get('/api/tasks', 'POST', undefined).catch(() => -1);
const raw = await fetch(base + '/api/tasks', { method: 'POST', headers: { cookie, 'content-type': 'application/json' }, body: '{not json' }).then(async r => { await r.text(); return r; }).catch(() => ({ status: -1 }));
ok('malformed JSON gets a clean error, not a crash', raw.status !== 500 && raw.status !== -1 && alive, raw.status);
ok('signed-out requests are refused', await fetch(base + '/api/tasks/lists').then(r => r.status === 401).catch(() => false));
ok('the server is still running after all of that', alive);
child.kill(); fs.rmSync(dir, { recursive: true, force: true });
if (failed) { console.log('\n--- server log ---\n' + log.split('\n').filter(l => /error|Error|not defined/.test(l)).slice(0, 12).join('\n')); }
console.log(failed ? `\n${failed} check(s) failed` : '\nAll smoke checks passed');
process.exit(failed ? 1 : 0);
})();