Files
VianoBugAnalysis/tests/dashboard-smoke.mjs
T

131 lines
16 KiB
JavaScript

// Real Chromium smoke test; Node built-ins only. No paid model calls.
import {spawn} from 'node:child_process';
import {mkdtemp, mkdir, writeFile, rm} from 'node:fs/promises';
import {tmpdir} from 'node:os';
import path from 'node:path';
import {createServer} from 'node:net';
async function freePort(){const server=createServer();await new Promise(resolve=>server.listen(0,'127.0.0.1',resolve));const port=server.address().port;await new Promise(resolve=>server.close(resolve));return port;}
const port=await freePort(), debugPort=await freePort();
const pause=ms=>new Promise(r=>setTimeout(r,ms));
const output=path.resolve('service-data/ui-preview');await mkdir(output,{recursive:true});
const profile=await mkdtemp(path.join(tmpdir(),'vba-browser-'));
const fixtureRoot=await mkdtemp(path.join(tmpdir(),'vba-ui-data-'));
let browser,server,ws; const errors=[];
async function until(fn, milliseconds=15000){const start=Date.now();while(Date.now()-start<milliseconds){try{const v=await fn();if(v)return v;}catch{}await pause(100);}throw new Error('Timed out waiting for UI');}
try {
server=spawn(path.resolve('.venv/Scripts/python.exe'),['tests/ui_dashboard_server.py'],{windowsHide:true,env:{...process.env,VBA_UI_TEST_PORT:String(port),VBA_UI_TEST_WORKSPACE:fixtureRoot},stdio:'ignore'});
await until(async()=> (await fetch(`http://127.0.0.1:${port}/healthz`)).ok);
browser=spawn(process.env.BROWSER_PATH||'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe',[
'--headless=new','--disable-gpu','--no-first-run','--no-default-browser-check',
`--user-data-dir=${profile}`,`--remote-debugging-port=${debugPort}`,'about:blank'],{windowsHide:true,stdio:'ignore'});
const tabs=await until(async()=>await (await fetch(`http://127.0.0.1:${debugPort}/json`)).json());
ws=new WebSocket(tabs.find(t=>t.type==='page').webSocketDebuggerUrl);
await new Promise((resolve,reject)=>{ws.onopen=resolve;ws.onerror=reject;});
let next=0;const pending=new Map();
ws.onmessage=e=>{const m=JSON.parse(e.data);if(m.id){const p=pending.get(m.id);pending.delete(m.id);if(!p)return;m.error?p.reject(new Error(m.error.message)):p.resolve(m.result);}
if(m.method==='Runtime.exceptionThrown')errors.push(m.params.exceptionDetails.text);
if(m.method==='Log.entryAdded'&&m.params.entry.level==='error'&&!(m.params.entry.url?.endsWith('/v1/auth/session')&&m.params.entry.text.includes('401')))errors.push(m.params.entry.text);};
const send=(method,params={})=>new Promise((resolve,reject)=>{const id=++next;pending.set(id,{resolve,reject});ws.send(JSON.stringify({id,method,params}));});
const evaluate=async expression=>{const r=await send('Runtime.evaluate',{expression,returnByValue:true,awaitPromise:true});if(r.exceptionDetails)throw new Error(r.exceptionDetails.text);return r.result.value;};
await send('Runtime.enable');await send('Log.enable');await send('Page.enable');
await send('Emulation.setDeviceMetricsOverride',{width:1440,height:1200,deviceScaleFactor:1,mobile:false});
await send('Page.navigate',{url:`http://127.0.0.1:${port}/`});
await until(()=>evaluate("document.readyState === 'complete' && typeof render === 'function'"));
await evaluate("document.getElementById('username').value='preview';document.getElementById('password').value='ui-fixture-password';document.getElementById('login-form').dispatchEvent(new Event('submit',{bubbles:true,cancelable:true}));");
await until(()=>evaluate("document.getElementById('token-count').textContent==='35.300'"));
if(await evaluate("document.cookie.includes('vba_session')"))throw new Error('Session cookie is readable by JavaScript');
await send('Page.reload');
await until(()=>evaluate("session===0&&token==='session'&&document.getElementById('token-count').textContent==='35.300'"));
await until(()=>evaluate("document.querySelector('#operation-projects button')"));
await evaluate("document.querySelector('#operation-projects button').click()");
await until(()=>evaluate("document.getElementById('operation-projects').textContent.includes('Neue Fälle pausiert')"));
if(!await evaluate("document.getElementById('operation-events').textContent.includes('preview')"))throw new Error('Operator audit missing');
await evaluate("document.querySelector('#operation-projects button').click()");
await until(()=>evaluate("document.getElementById('operation-projects').textContent.includes('Neue Fälle freigegeben')"));
const state=await evaluate("({workers:document.querySelectorAll('.worker').length,cases:document.querySelectorAll('#case-rows tr').length,active:document.getElementById('active-count').textContent,correction:document.getElementById('correction-count').textContent,overflow:document.documentElement.scrollWidth>innerWidth,stored:localStorage.length,progress:document.querySelector('.worker [role=progressbar]').getAttribute('aria-valuenow')})");
if(state.workers!==1||state.cases!==1||state.active!=='1'||state.stored!==0||state.overflow||state.progress!=='57')throw new Error(JSON.stringify(state));
const categories=await evaluate("Array.from(document.querySelectorAll('#legend li')).map(e=>e.textContent)");
for(const name of ['Fallaufbau','Fallplanung','Hypothesen','Untersuchung','Diagnose','Codingauftrag','Korrekturen']) {
if(!categories.some(c=>c.includes(name)))throw new Error(`Missing phase in pie legend: ${name}`);
}
await writeFile(path.join(output,'desktop.png'),Buffer.from((await send('Page.captureScreenshot',{format:'png'})).data,'base64'));
await send('Emulation.setDeviceMetricsOverride',{width:390,height:1200,deviceScaleFactor:1,mobile:true});await pause(200);
if(await evaluate('document.documentElement.scrollWidth>innerWidth'))throw new Error('Mobile horizontal overflow');
await writeFile(path.join(output,'mobile.png'),Buffer.from((await send('Page.captureScreenshot',{format:'png'})).data,'base64'));
await evaluate("location.hash='#tests'");
await until(()=>evaluate("!document.getElementById('test-page').hidden&&document.querySelector('#test-list button')"));
if(!await evaluate("document.getElementById('test-list').textContent.includes('viano-core')&&document.getElementById('test-list').textContent.includes('BUG-000020')"))throw new Error('Test request project/case missing');
const sorting=await evaluate("(()=>{const original=testData.requests;const row=original[0];testData.requests=[{...row,case_id:'NEW',received_at:'2098-01-01T00:00:00Z'},{...row,project_id:'aaa',case_id:'OLD',received_at:'2000-01-01T00:00:00Z'},row];const first=()=>document.querySelector('#test-list tbody tr').textContent;document.getElementById('test-sort').value='oldest';renderTests();const oldest=first().includes('OLD');document.getElementById('test-sort').value='newest';renderTests();const newest=first().includes('NEW');document.getElementById('test-sort').value='project';renderTests();const project=first().includes('aaa');document.getElementById('test-project').value='viano-core';renderTests();const filtered=document.querySelectorAll('#test-list tbody tr').length===2;testData.requests=original;document.getElementById('test-project').value='';renderTests();return oldest&&newest&&project&&filtered;})()");
if(!sorting)throw new Error('Test request sorting/filtering failed');
await evaluate("document.getElementById('test-sort').value='project';document.getElementById('test-sort').dispatchEvent(new Event('change'));document.querySelector('#test-list button').click()");
if(await evaluate("!!document.querySelector('#test-answer textarea')"))throw new Error('Form editable without a claim');
await evaluate("Array.from(document.querySelectorAll('#test-answer button')).find(b=>b.textContent==='Bearbeiten').click()");
await until(()=>evaluate("document.querySelector('#test-answer textarea')"));
if(!await evaluate("document.getElementById('test-answer').textContent.includes('Sichtbarkeit und Build')"))throw new Error('Human test answer form missing');
if(await evaluate('document.documentElement.scrollWidth>innerWidth'))throw new Error('Test page mobile overflow');
await writeFile(path.join(output,'test-requests-mobile.png'),Buffer.from((await send('Page.captureScreenshot',{format:'png'})).data,'base64'));
await evaluate("location.hash='#overview'");await pause(100);
await evaluate("document.getElementById('project-filter').value='viano-core';document.getElementById('project-filter').dispatchEvent(new Event('change')); ");
if(await evaluate("document.getElementById('token-count').textContent")!=='35.300')throw new Error('Project aggregation differs');
await evaluate("location.hash='#results'");
await until(()=>evaluate("!document.getElementById('results').hidden&&document.querySelector('#result-rows a')"));
if(!await evaluate("document.querySelectorAll('#result-rows .package-icon').length===2&&document.getElementById('result-rows').textContent.includes('Wartet auf Testerantwort')"))throw new Error('Processed list missing blocked case or download controls');
await evaluate("document.getElementById('result-status').value='BLOCKED';document.getElementById('result-status').dispatchEvent(new Event('change'))");
if(!await evaluate("document.querySelectorAll('#result-rows .package-icon').length===1&&document.getElementById('result-rows').textContent.includes('BUG-000020')"))throw new Error('Blocked category filter failed');
await evaluate("document.getElementById('result-status').value='OUTPUT_READY';document.getElementById('result-status').dispatchEvent(new Event('change'));document.getElementById('result-sort').value='category';document.getElementById('result-sort').dispatchEvent(new Event('change'))");
if(!await evaluate("document.querySelectorAll('#result-rows .package-icon').length===1&&document.getElementById('result-rows').textContent.includes('BUG-000017')"))throw new Error('Outbox category filter failed');
await writeFile(path.join(output,'processed-cases.png'),Buffer.from((await send('Page.captureScreenshot',{format:'png'})).data,'base64'));
await evaluate("document.querySelector('#result-rows a').click()");
await until(()=>evaluate("document.getElementById('case-detail').textContent.includes('SQL-Vorlage mit Platzhaltern')"));
if(!await evaluate("document.getElementById('case-detail').textContent.includes('Passende Spawndaten ermitteln')"))throw new Error('Human assignment absent');
if(await evaluate('document.documentElement.scrollWidth>innerWidth'))throw new Error('Mobile detail overflow');
await writeFile(path.join(output,'case-mobile.png'),Buffer.from((await send('Page.captureScreenshot',{format:'png'})).data,'base64'));
await send('Emulation.setDeviceMetricsOverride',{width:1440,height:1200,deviceScaleFactor:1,mobile:false});await pause(200);
await writeFile(path.join(output,'case-desktop.png'),Buffer.from((await send('Page.captureScreenshot',{format:'png'})).data,'base64'));
await evaluate("location.hash='#overview'");await pause(100);
await evaluate("document.getElementById('kind').value='case_return';document.getElementById('kind').dispatchEvent(new Event('change'));");
if(!await evaluate("document.getElementById('new-files').hidden&&!document.getElementById('return-files').hidden"))throw new Error('Return form not selected');
await evaluate("location.hash='#keys'");
await until(()=>evaluate("!document.getElementById('keys-page').hidden&&document.querySelector('input[name=\"key-project\"]')"));
await evaluate("document.getElementById('key-name').value='Smoke integration';document.querySelector('input[name=\"key-project\"]').checked=true;document.querySelector('#key-form button').click()");
await until(()=>evaluate("document.getElementById('key-list').textContent.includes('Smoke integration')"));
await evaluate("Array.from(document.querySelectorAll('#key-list button')).find(b=>b.textContent==='Anzeigen').click()");
await until(()=>evaluate("document.querySelector('#key-list input').value.length>32"));
await writeFile(path.join(output,'integration-keys.png'),Buffer.from((await send('Page.captureScreenshot',{format:'png'})).data,'base64'));
await evaluate("Array.from(document.querySelectorAll('#key-list button')).find(b=>b.textContent==='Verbergen').click()");
if(await evaluate("document.querySelector('#key-list input').value!==''"))throw new Error('Hidden key retained in input');
await evaluate("window.confirm=()=>true;Array.from(document.querySelectorAll('#key-list button')).find(b=>b.textContent==='Widerrufen').click()");
await until(()=>evaluate("document.getElementById('key-list').textContent.includes('Widerrufen')"));
await evaluate("location.hash='#manual'");
await until(()=>evaluate("!document.getElementById('manual-page').hidden&&document.getElementById('manual-content').textContent.includes('So arbeitet BugAnalysis')"));
if(!await evaluate("document.getElementById('manual-document').options.length===9&&document.querySelectorAll('#manual-toc button').length>5"))throw new Error('Manual catalog or table of contents missing');
const safe=await evaluate("(()=>{const rendered=renderManualMarkdown('<script>alert(1)</script> [bad](javascript:alert)');const root=document.createElement('div');root.append(rendered.fragment);return !root.querySelector('script,a')&&root.textContent.includes('<script>');})()");
if(!safe)throw new Error('Manual renderer interpreted unsafe markup');
await writeFile(path.join(output,'user-manual-desktop.png'),Buffer.from((await send('Page.captureScreenshot',{format:'png'})).data,'base64'));
await evaluate("document.getElementById('manual-document').value='linux-deployment';document.getElementById('manual-document').dispatchEvent(new Event('change'))");
await until(()=>evaluate("document.querySelector('#manual-content pre')&&document.getElementById('manual-content').textContent.includes('systemd-run')"));
await evaluate("document.getElementById('manual-document').value='project-configuration';document.getElementById('manual-document').dispatchEvent(new Event('change'))");
await until(()=>evaluate("document.getElementById('manual-content').textContent.includes('password_env')&&document.querySelector('#manual-content table')"));
await send('Emulation.setDeviceMetricsOverride',{width:390,height:1200,deviceScaleFactor:1,mobile:true});await pause(200);
if(await evaluate('document.documentElement.scrollWidth>innerWidth'))throw new Error('Manual mobile overflow');
await writeFile(path.join(output,'user-manual-mobile.png'),Buffer.from((await send('Page.captureScreenshot',{format:'png'})).data,'base64'));
await evaluate("document.getElementById('logout').click()");
if(!await evaluate("document.getElementById('manual-content').childElementCount===0"))throw new Error('Logout retained manual content');
if(!await evaluate("document.getElementById('key-list').childElementCount===0"))throw new Error('Logout retained keys');
if(!await evaluate("document.getElementById('content').hidden&&!document.getElementById('login').hidden&&document.getElementById('token-count').textContent==='—'"))throw new Error('Logout retained visible case data');
if(!await evaluate("document.getElementById('case-detail').childElementCount===0&&document.getElementById('result-rows').childElementCount===0"))throw new Error('Logout retained detail data');
if(!await evaluate("document.getElementById('operation-events').childElementCount===0"))throw new Error('Logout retained audit');
if(!await evaluate("document.getElementById('test-answer').childElementCount===0&&document.getElementById('test-list').childElementCount===0"))throw new Error('Logout retained test request data');
if(errors.filter(e=>!e.includes('404')).length)throw new Error(errors.join('\n'));
console.log(JSON.stringify({status:'PASS',...state,console_errors:errors.filter(e=>!e.includes('404'))}));
} finally {
// Let Chromium close its database handles before removing its own profile.
if(ws?.readyState===WebSocket.OPEN)ws.send(JSON.stringify({id:999999,method:'Browser.close'}));
await pause(1500);ws?.close();browser?.kill();server?.kill();await pause(400);
for(const [folder,prefix] of [[profile,'vba-browser-'],[fixtureRoot,'vba-ui-data-']]) {
const target=path.resolve(folder),relative=path.relative(path.resolve(tmpdir()),target);
if(!relative||relative.startsWith('..')||path.isAbsolute(relative)||!path.basename(target).startsWith(prefix))throw new Error('Unsafe temporary cleanup');
await rm(target,{recursive:true,force:true,maxRetries:15,retryDelay:300});
}
}