Initial commit with Dockerfile and demo code
This commit is contained in:
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* 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);
|
||||
Reference in New Issue
Block a user