/** * End-to-end server verification: WebSocket streaming, the control API, and the * copilot (including its offline fallback). * * Start the server first, then: node scripts/apicheck.mjs */ import WebSocket from 'ws'; const BASE = process.env.BASE || 'http://localhost:8787'; let failures = 0; function check(name, ok, detail = '') { if (!ok) failures++; console.log(` [${ok ? 'PASS' : 'FAIL'}] ${name}${detail ? ' ' + detail : ''}`); } async function post(path, body) { const res = await fetch(`${BASE}${path}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body || {}), }); return { status: res.status, json: await res.json().catch(() => null) }; } async function get(path) { const res = await fetch(`${BASE}${path}`); return { status: res.status, json: await res.json().catch(() => null) }; } /** Collect n frames from the websocket. */ function collectFrames(n, timeoutMs = 15000) { return new Promise((resolve, reject) => { const ws = new WebSocket(`${BASE.replace('http', 'ws')}/ws`); const frames = []; const timer = setTimeout(() => { ws.close(); reject(new Error(`timed out after ${frames.length} frames`)); }, timeoutMs); ws.on('message', (raw) => { const msg = JSON.parse(raw.toString()); if (msg.type === 'frame') frames.push(msg.frame); if (frames.length >= n) { clearTimeout(timer); ws.close(); resolve(frames); } }); ws.on('error', (err) => { clearTimeout(timer); reject(err); }); }); } /** Read an SSE copilot response to completion. */ async function askCopilot(question, timeoutMs = 120000) { const ctl = new AbortController(); const timer = setTimeout(() => ctl.abort(), timeoutMs); try { const res = await fetch(`${BASE}/api/copilot/chat`, { method: 'POST', signal: ctl.signal, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ question, history: [] }), }); let text = ''; let meta = null; let notes = []; let buf = ''; const dec = new TextDecoder(); for await (const chunk of res.body) { buf += dec.decode(chunk, { stream: true }); let i; while ((i = buf.indexOf('\n\n')) >= 0) { const line = buf.slice(0, i).trim(); buf = buf.slice(i + 2); if (!line.startsWith('data:')) continue; const obj = JSON.parse(line.slice(5).trim()); if (obj.type === 'token') text += obj.text; if (obj.type === 'meta') meta = obj; if (obj.type === 'note') notes.push(obj.text); } } return { text, meta, notes }; } finally { clearTimeout(timer); } } console.log(`\n=== A. Metadata and health (${BASE}) ===`); const meta = await get('/api/meta'); check('GET /api/meta returns 200', meta.status === 200); check('five stations described', meta.json?.stations?.length === 5, (meta.json?.stations || []).map((s) => s.id).join(',')); check('four fault profiles offered', meta.json?.faults?.length === 4); check('source reports its capabilities', Boolean(meta.json?.source?.capabilities?.faultInjection)); console.log(` copilot: ${meta.json?.copilot?.label} (${meta.json?.copilot?.detail})`); const health = await get('/api/health'); check('GET /api/health ok', health.status === 200 && health.json.ok === true); console.log('\n=== B. WebSocket streaming ==='); { await post('/api/control/speed', { speed: 1 }); const frames = await collectFrames(3); check('received 3 frames', frames.length === 3); const f = frames[frames.length - 1]; check('frame has stations, kpi, analytics', Boolean(f.stations && f.kpi && f.analytics)); check('frame carries analytics arrays', Array.isArray(f.analytics.alarms) && Array.isArray(f.analytics.predictions)); check('simulated time advances between frames', frames[2].t > frames[0].t, `${frames[0].t.toFixed(1)}s -> ${frames[2].t.toFixed(1)}s`); check('every station reports a state', f.stations.every((s) => typeof s.state === 'string')); check('sim block reports speed and tick', f.sim.tickMs > 0 && typeof f.sim.speed === 'number'); } console.log('\n=== C. Control API ==='); { const sp = await post('/api/control/setpoint', { value: 325 }); check('POST setpoint accepted', sp.status === 200 && sp.json.result === 325, JSON.stringify(sp.json)); const clamp = await post('/api/control/setpoint', { value: 9999 }); check('setpoint is clamped, not rejected', clamp.json.result === 360, `got ${clamp.json.result}`); const ls = await post('/api/control/line-speed', { value: 115 }); check('POST line-speed accepted', ls.json.result === 115); const speed = await post('/api/control/speed', { speed: 60 }); check('POST speed 60x accepted', speed.json.result === 60); const badSpeed = await post('/api/control/speed', { speed: 999 }); check('invalid speed rejected with 400', badSpeed.status === 400, badSpeed.json?.error); const badFault = await post('/api/control/fault', { id: 'nope', action: 'inject' }); check('unknown fault rejected with 400', badFault.status === 400, badFault.json?.error); await post('/api/control/setpoint', { value: 305 }); await post('/api/control/line-speed', { value: 100 }); } console.log('\n=== D. Fault injection propagates to the stream ==='); { await post('/api/control/speed', { speed: 60 }); await post('/api/control/fault', { id: 'packer-jam', action: 'inject' }); const frames = await collectFrames(8); const last = frames[frames.length - 1]; const pkg = last.stations.find((s) => s.id === 'PKG-05'); check('packer shows fault state in the stream', pkg.state === 'fault', pkg.state); check('a critical alarm is raised', last.analytics.alarms.some((a) => a.severity === 'critical'), last.analytics.alarms.map((a) => `${a.severity}:${a.station}`).join(', ')); check('fault is listed on the frame', last.faults.some((f) => f.id === 'packer-jam')); await post('/api/control/fault', { id: 'packer-jam', action: 'clear' }); const after = await collectFrames(6); const pkg2 = after[after.length - 1].stations.find((s) => s.id === 'PKG-05'); check('packer recovers after the fault is cleared', pkg2.state !== 'fault', pkg2.state); } console.log('\n=== E. Copilot ==='); { // Build up a real condition so the answers have something to work with. await post('/api/control/fault', { id: 'bearing-degradation', action: 'inject' }); await post('/api/control/speed', { speed: 60 }); await new Promise((r) => setTimeout(r, 12000)); await post('/api/control/speed', { speed: 1 }); const state = await get('/api/state'); const vib = state.json.stations.find((s) => s.id === 'CNC-02').signals.vibration; console.log(` CNC-02 vibration is now ${vib.toFixed(2)} mm/s; alarms: ${state.json.analytics.alarms.length}; predictions: ${state.json.analytics.predictions.length}`); const qs = [ 'What is wrong with CNC-02?', 'Why is OEE down?', 'Draft a maintenance work order.', 'What happens if I raise line speed by 10%?', ]; for (const q of qs) { const ans = await askCopilot(q); const ok = ans.text.trim().length > 40; check(`answered: "${q}"`, ok, ok ? `${ans.text.trim().length} chars via ${ans.meta?.provider}` : JSON.stringify(ans).slice(0, 200)); if (ans.notes.length) console.log(` note: ${ans.notes[0]}`); console.log(` -> ${ans.text.trim().split('\n')[0].slice(0, 160)}`); } // The answer must be grounded in real telemetry, not generic prose. const diag = await askCopilot('What is wrong with CNC-02?'); check('answer cites the actual station', /CNC-02/i.test(diag.text)); check('answer cites a real measurement', /\d+\.\d+/.test(diag.text)); await post('/api/control/fault', { id: 'bearing-degradation', action: 'clear' }); await post('/api/control/reset', {}); } console.log(`\n${failures === 0 ? 'ALL API CHECKS PASSED' : failures + ' CHECK(S) FAILED'}\n`); process.exit(failures === 0 ? 0 : 1);