build-container / build (push) Successful in 2m55s
The People screens did not appear after the redeploy even after a hard refresh: a caching layer (for example Nginx Proxy Manager's "Cache Assets") was still serving the old app.js. The pages now reference /app.js?v=<build>, /style.css?v=<build> etc., where the build changes with every release, so a stale cached file at the old address is never used. The service worker no longer precaches unversioned app code. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
69 lines
7.2 KiB
JavaScript
69 lines
7.2 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);
|
|
|
|
// Seed one admin account so the test can sign in without a passkey ceremony (the cookie is signed with the test's own secret).
|
|
const SECRET = 'smoke-'.repeat(8), UID = 'usmoke';
|
|
fs.writeFileSync(path.join(dir, 'users.json'), JSON.stringify({ users: [{ id: UID, name: 'Admin', role: 'admin', tabs: [], handle: 'aGFuZGxl', epoch: 1, disabled: false, created: Date.now() }] }));
|
|
const child = spawn(process.execPath, ['server.js'], { cwd: root, env: { ...process.env, PORT: String(port), DATA_DIR: dir, COOKIE_SECRET: SECRET, 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', SECRET).update(`s${UID}.1.${exp}`).digest('base64url');
|
|
const cookie = `hub_session=${UID}.1.${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/me', '/api/users', '/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));
|
|
// browsers must always check for a new version of the app's files
|
|
const a1 = await fetch(base + '/app.js', { headers: { cookie } }); await a1.text(); const etag = a1.headers.get('etag');
|
|
ok('app files are sent with no-cache and an ETag, so a new version is never missed', /no-cache/.test(a1.headers.get('cache-control') || '') && !!etag, [a1.headers.get('cache-control'), etag]);
|
|
const a2 = await fetch(base + '/app.js', { headers: { cookie, 'if-none-match': etag } }); await a2.text();
|
|
ok('and an unchanged file is answered with a cheap 304', a2.status === 304, a2.status);
|
|
const i1 = await fetch(base + '/icons/icon-192.png'); await i1.arrayBuffer(); ok('icons are cached for a week', /max-age=604800/.test(i1.headers.get('cache-control') || ''), i1.headers.get('cache-control'));
|
|
const cfg = await (await fetch(base + '/api/config', { headers: { cookie } })).json(); ok('the server reports a build stamp so open pages can tell a newer version exists', /^[0-9a-f]{12}$/.test(cfg.build || ''), cfg.build);
|
|
const home = await (await fetch(base + '/', { headers: { cookie } })).text(), cfg2 = await (await fetch(base + '/api/config', { headers: { cookie } })).json();
|
|
ok('the app page links its code with the current build number, so a cached old copy can never be used', home.includes(`/app.js?v=${cfg2.build}`) && home.includes(`/style.css?v=${cfg2.build}`), home.match(/app\.js[^"]*/));
|
|
const login = await (await fetch(base + '/login')).text(); ok('so does the sign-in page', login.includes(`/login.js?v=${cfg2.build}`), login.match(/login\.js[^"]*/));
|
|
const va = await fetch(base + `/app.js?v=${cfg2.build}`, { headers: { cookie } }); ok('the versioned address serves the file', va.status === 200 && (await va.text()).includes('addPersonSheet'), va.status);
|
|
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);
|
|
})();
|