← 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);
  }
})();