Files
digitaltwin/scripts/apicheck.mjs
T
2026-08-24 15:35:32 +05:30

188 lines
7.8 KiB
JavaScript

/**
* 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);