← back to Qwen38 Viewer
test.js
132 lines
'use strict';
/**
* Regression tests for qwen38-viewer — fast + deterministic.
* Spins up a STUB Ollama (canned NDJSON, no real model) + the app pointed at it,
* then asserts auth, static serving, model metadata, and the streaming proxy
* (incl. the bugs fixed in the debug pass: no-colon auth, upstream-error handling).
*
* Run: npm test (node test.js)
*/
const http = require('http');
const { spawn } = require('child_process');
const APP_PORT = 19821;
let passed = 0, failed = 0;
const b64 = s => Buffer.from(s).toString('base64');
const ok = (name, cond, extra='') => { (cond ? passed++ : failed++);
console.log(` ${cond ? '\x1b[32m✓\x1b[0m' : '\x1b[31m✗\x1b[0m'} ${name}${extra && !cond ? ' → '+extra : ''}`); };
// ---- stub Ollama --------------------------------------------------------
function startStub() {
return new Promise(resolve => {
const srv = http.createServer((req, res) => {
if (req.url === '/api/tags') {
return res.end(JSON.stringify({ models: [{ name: 'qwen3.8-27b-heretic:latest', size: 17176647521 }] }));
}
if (req.url === '/api/chat') {
let body = '';
req.on('data', d => body += d);
req.on('end', () => {
const j = JSON.parse(body || '{}');
if (j.fail || (j.messages && j.messages[0] && j.messages[0].content === '__FAIL__')) {
res.statusCode = 500; return res.end('boom');
}
res.setHeader('Content-Type', 'application/x-ndjson');
// echo forwarded think + keep_alive so the test can assert passthrough
res.write(JSON.stringify({ message: { content: `think=${j.think};ka=${j.keep_alive};` } }) + '\n');
res.write(JSON.stringify({ message: { content: 'hello' } }) + '\n');
res.write(JSON.stringify({ done: true, done_reason: 'stop' }) + '\n');
res.end();
});
return;
}
res.statusCode = 404; res.end('nope');
});
srv.listen(0, '127.0.0.1', () => resolve(srv));
});
}
const waitFor = async (url, tries = 50) => {
for (let i = 0; i < tries; i++) {
try { const r = await fetch(url); if (r.ok) return true; } catch {}
await new Promise(r => setTimeout(r, 100));
}
throw new Error('app did not come up: ' + url);
};
(async () => {
const stub = await startStub();
const stubPort = stub.address().port;
const base = `http://127.0.0.1:${APP_PORT}`;
const app = spawn('node', ['server.js'], {
cwd: __dirname,
env: { ...process.env, PORT: APP_PORT, OLLAMA_URL: `http://127.0.0.1:${stubPort}`,
USERS: 'admin:DW2024!,Dave:Claudia911', CODES: 'Dust2026', KEEP_ALIVE: '-1' },
stdio: 'ignore',
});
let code = 1;
try {
await waitFor(`${base}/health`);
const auth = u => ({ headers: { Authorization: 'Basic ' + b64(u) } });
// --- health (open) ---
const h = await (await fetch(`${base}/health`)).json();
ok('health returns PASS', h.status === 'PASS', JSON.stringify(h));
// --- auth matrix ---
ok('no auth → 401', (await fetch(`${base}/`)).status === 401);
ok('valid user:pass → 200', (await fetch(`${base}/`, auth('admin:DW2024!'))).status === 200);
ok('2nd user → 200', (await fetch(`${base}/`, auth('Dave:Claudia911'))).status === 200);
ok('wrong pass → 401', (await fetch(`${base}/`, auth('admin:nope'))).status === 401);
ok('code Dust2026 → 200', (await fetch(`${base}/`, auth('anyone:Dust2026'))).status === 200);
ok('code case-insensitive → 200',(await fetch(`${base}/`, auth('x:DUST2026'))).status === 200);
ok('NO-COLON garbage → 401 (regression)', (await fetch(`${base}/`, auth('nocolonhere'))).status === 401);
ok('empty-user :pass → 401', (await fetch(`${base}/`, auth(':DW2024!'))).status === 401);
// --- static serving ---
ok('/ serves index.html', (await (await fetch(`${base}/`, auth('admin:DW2024!'))).text()).includes('<title>'));
ok('/vendor/three.min.js 200', (await fetch(`${base}/vendor/three.min.js`, auth('admin:DW2024!'))).status === 200);
// --- model metadata ---
const m = await (await fetch(`${base}/api/model`, auth('admin:DW2024!'))).json();
ok('/api/model present:true', m.present === true && m.size > 0, JSON.stringify(m));
// --- streaming chat proxy (think:false default forwarded, content concatenates) ---
const chat = async (payload) => {
const r = await fetch(`${base}/api/chat`, { method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: 'Basic ' + b64('admin:DW2024!') },
body: JSON.stringify(payload) });
const txt = await r.text();
let content = '';
for (const line of txt.split('\n')) { if (!line.trim()) continue;
try { const j = JSON.parse(line); if (j.message?.content) content += j.message.content; } catch {} }
return { status: r.status, content };
};
const c1 = await chat({ messages: [{ role: 'user', content: 'hi' }] });
ok('chat streams content', c1.content.includes('hello'), c1.content);
ok('think defaults to false', c1.content.includes('think=false'), c1.content);
ok('keep_alive -1 forwarded', c1.content.includes('ka=-1'), c1.content);
const c2 = await chat({ messages: [{ role: 'user', content: 'hi' }], think: true });
ok('think:true forwarded', c2.content.includes('think=true'), c2.content);
// --- upstream error handling (no hang, returns error line) ---
const bad = await chat({ messages: [{ role: 'user', content: '__FAIL__' }] });
ok('upstream 500 → error line, no hang', bad.status === 200 && !bad.content.includes('hello'));
// --- bad request ---
ok('empty messages → 400', (await fetch(`${base}/api/chat`, { method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: 'Basic ' + b64('admin:DW2024!') },
body: '{}' })).status === 400);
code = failed === 0 ? 0 : 1;
} catch (e) {
console.error(' \x1b[31mFATAL\x1b[0m', e.message); code = 1;
} finally {
app.kill(); stub.close();
console.log(`\n${failed === 0 ? '\x1b[32mPASS\x1b[0m' : '\x1b[31mFAIL\x1b[0m'} — ${passed} passed, ${failed} failed`);
process.exit(code);
}
})();