/* Browser regression check. Run: node tests/ui_windows.cjs [path-to-playwright] Uses a simulated Python bridge; never opens a COM port or sends MAVLink. */ const assert=require('node:assert/strict'); const fs=require('node:fs'); const path=require('node:path'); const http=require('node:http'); const {chromium}=require(process.argv[2]||'playwright'); const root=path.resolve(__dirname,'..'),output=path.join(root,'runtime','ui-check'); fs.mkdirSync(output,{recursive:true}); const server=http.createServer((req,res)=>{ const url=decodeURIComponent(new URL(req.url,'http://localhost').pathname); let file=path.resolve(root,'.'+url); if(url==='/model.stl'||url==='/model')file=path.join(root,'runtime','vehicle_model.stl'); if(!file.startsWith(root+path.sep)||!fs.existsSync(file)||!fs.statSync(file).isFile()){ res.writeHead(404);res.end();return; } const types={'.html':'text/html','.js':'text/javascript','.css':'text/css','.stl':'model/stl'}; res.setHeader('Content-Type',types[path.extname(file)]||'application/octet-stream'); fs.createReadStream(file).pipe(res); }); async function main(){ await new Promise(resolve=>server.listen(0,'127.0.0.1',resolve)); const browser=await chromium.launch({headless:true}); try{ const page=await browser.newPage({viewport:{width:1080,height:720}}); const errors=[];page.on('pageerror',error=>errors.push(error.message)); await page.addInitScript(()=>{ const clone=value=>JSON.parse(JSON.stringify(value)); const servos={};for(let i=1;i<=16;i++)servos[String(i)]={function:i<=3?32+i:0,motor:i<=3?i:null,direction:i<=3?1:null}; const state={connected:false,armed:false,armed_known:false,mode:null,app_state:'disconnected',status:'Ready',servos,link_quality:null}; const battery={cells:4,capacity_mah:5500,c_rating:35,full_v_per_cell:4.2,empty_v_per_cell:3.3,warning_percent:20}; const link={baud:57600,control_hz:20,ping_hz:1,system_hz:1,servo_hz:2,rc_hz:2,esc_hz:1}; const calls={battery:0,link:0,heavy:0,reverse:0,console:0,connect:[]}; const sleep=ms=>new Promise(resolve=>setTimeout(resolve,ms)); window.testBridge={state,battery,link,calls,failBattery:false,failMotor:false}; window.pywebview={api:{ initial_state:async()=>({ports:[{device:'COM5',label:'COM5'}],state:clone(state),battery_config:clone(battery),link_config:clone(link)}), refresh_ports:async()=>[{device:'COM5',label:'COM5'}],poll:async()=>clone(state), get_saved_model_info:async()=>null,set_gain:async()=>true,stop_controls:async()=>true,set_axes:async()=>true, save_battery:async data=>{calls.battery++;await sleep(200);if(window.testBridge.failBattery)throw Error('Battery save failed');Object.keys(battery).forEach(k=>battery[k]=+data[k]);return clone(battery)}, save_link_settings:async data=>{calls.link++;await sleep(250);Object.keys(link).forEach(k=>link[k]=+data[k]);return clone(link)}, connect:async(port,baud,rate)=>{calls.connect.push({port,baud,rate});Object.assign(state,{connected:true,armed_known:true,app_state:'connected_disarmed',mode:'MANUAL',link:'LINK OK',link_quality:{grade:'excellent',probe_samples:20,rx_packets_30s:100,rtt_median_ms:24,rtt_p95_ms:40,probe_loss_percent:0,rx_sequence_loss_percent:0,heartbeat_age_s:.2}});return clone(state)}, disconnect:async()=>{Object.assign(state,{connected:false,armed_known:false,app_state:'disconnected'});return true}, reverse_motor:async servo=>{calls.reverse++;if(window.testBridge.failMotor)throw Error('Motor write failed');setTimeout(()=>state.servos[servo].direction*=-1,650);return true}, set_servo_function:async(servo,motor)=>{if(window.testBridge.failMotor)throw Error('Motor write failed');setTimeout(()=>Object.assign(state.servos[servo],{motor:motor||null,function:motor===0?0:motor<=8?32+motor:73+motor,direction:motor?1:null}),650);return true}, start_heavy_quality_test:async()=>{calls.heavy++;await sleep(250);state.heavy_quality={running:true,status:'Stage 1 of 3: 1 Hz',sent:4,replies:4,results:[]};return clone(state.heavy_quality)}, cancel_heavy_quality_test:async()=>{state.heavy_quality={running:false,status:'Cancelled',results:[]};return clone(state.heavy_quality)}, read_console:async()=>{calls.console++;return {rx:['RX sample '+calls.console],tx:['TX sample '+calls.console]}}, minimize_window:async()=>true,toggle_maximize_window:async()=>false,close_window:async()=>true }}; }); await page.goto('http://127.0.0.1:'+server.address().port+'/web/index.html'); await page.waitForFunction(()=>bridgeReady); const openLink=()=>page.locator('[data-open="link-modal"]').click(); const close=async id=>{await page.locator('#'+id+' [data-close]').first().click();await page.waitForFunction(id=>!document.getElementById(id).open,id)}; await openLink(); await page.locator('#baud').selectOption('115200'); await page.locator('#link-modal').getByRole('button',{name:'Cancel',exact:true}).click(); await page.waitForFunction(()=>!document.getElementById('link-modal').open); await openLink();assert.equal(await page.locator('#baud').inputValue(),'57600'); await page.locator('#custom-port-input').fill('COM42'); await page.locator('#custom-port-btn').click(); assert(await page.locator('#link-modal').evaluate(d=>d.open)); assert.equal(await page.locator('#port').inputValue(),'COM42'); await page.locator('#baud').selectOption('115200'); assert(await page.locator('#link-form [type="submit"]').evaluate(el=>{el.click();return el.disabled})); await page.waitForFunction(()=>!document.getElementById('link-modal').open); await page.locator('#refresh').click();assert.equal(await page.locator('#port').inputValue(),'COM42'); await page.locator('#connect').click(); await page.waitForFunction(()=>testBridge.calls.connect.length===1); assert.equal(await page.evaluate(()=>testBridge.calls.connect[0].baud),115200); await page.locator('[data-open="battery-modal"]').click(); await page.locator('#battery-form [name="cells"]').fill('5'); await page.keyboard.press('Escape');await page.waitForFunction(()=>!document.getElementById('battery-modal').open); await page.locator('[data-open="battery-modal"]').click(); assert.equal(await page.locator('#battery-form [name="cells"]').inputValue(),'4'); await page.evaluate(()=>testBridge.failBattery=true); await page.locator('#battery-form').getByRole('button',{name:'Save battery'}).click(); await page.locator('#battery-form .form-feedback').waitFor({state:'visible'}); assert(await page.locator('#battery-modal').evaluate(d=>d.open)); assert.equal(await page.locator('#battery-form .form-feedback').textContent(),'Battery save failed'); await page.evaluate(()=>testBridge.failBattery=false); await page.locator('#battery-form [name="cells"]').fill('5'); await page.locator('#battery-form').getByRole('button',{name:'Save battery'}).click(); await page.waitForFunction(()=>!document.getElementById('battery-modal').open); await page.locator('[data-open="battery-modal"]').click(); assert.equal(await page.locator('#battery-form [name="cells"]').inputValue(),'5'); await page.screenshot({path:path.join(output,'battery.png')});await close('battery-modal'); await page.locator('[data-open="motors-modal"]').click(); assert.equal(await page.locator('#motor-list > .motor-row').count(),3); await page.locator('#motor-list > .motor-row').first().getByRole('button').click(); await page.waitForFunction(()=>document.querySelector('#motor-list > .motor-row > span').textContent==='Confirming…'); assert(await page.locator('#motor-list > .motor-row').first().getByRole('button').isDisabled()); await page.waitForFunction(()=>document.querySelector('#motor-list > .motor-row > span').textContent==='Reversed'); await page.evaluate(()=>testBridge.failMotor=true); await page.locator('#motor-list > .motor-row').first().locator('select').selectOption('2'); await page.locator('#motor-feedback').waitFor({state:'visible'}); assert.equal(await page.locator('#motor-list > .motor-row').first().locator('select').inputValue(),'1'); await page.screenshot({path:path.join(output,'motors.png')});await close('motors-modal'); await page.locator('#link-quality').click(); assert.equal(await page.locator('#quality-detail-rtt').textContent(),'24 ms'); await page.locator('#heavy-quality-run').click(); assert(await page.locator('#heavy-quality-run').isDisabled()); await page.locator('#heavy-quality-cancel').waitFor({state:'visible'}); assert.equal(await page.evaluate(()=>testBridge.calls.heavy),1); await page.locator('#heavy-quality-cancel').click(); await page.waitForFunction(()=>!testBridge.state.heavy_quality.running); await page.screenshot({path:path.join(output,'quality.png')});await close('link-quality-modal'); await page.locator('#console').click(); await page.waitForFunction(()=>document.getElementById('console-rx-output').textContent.includes('RX sample')); await page.locator('#console-pause').click(); const paused=await page.locator('#console-rx-output').textContent(); await page.waitForTimeout(850);assert.equal(await page.locator('#console-rx-output').textContent(),paused); await page.locator('#console-pause').click(); await page.waitForFunction(old=>document.getElementById('console-rx-output').textContent!==old,paused); await page.screenshot({path:path.join(output,'console.png')});await close('console-modal'); for(const viewport of [{width:900,height:560},{width:480,height:720}]){ await page.setViewportSize(viewport); for(const id of ['link-modal','battery-modal','link-quality-modal','motors-modal','console-modal']){ await page.evaluate(id=>openModal(id),id); const box=await page.locator('#'+id).boundingBox(); assert(box.x>=0&&box.y>=0&&box.width<=viewport.width&&box.height<=viewport.height,id+' must fit viewport'); assert.equal(await page.evaluate(()=>canDrive()),false); await close(id); } } await page.setViewportSize({width:1080,height:720});await openLink(); await page.screenshot({path:path.join(output,'link.png')});await close('link-modal'); assert.deepEqual(errors,[]); console.log('PASS: settings cancel/save/failure, saved baud, custom port rescan, motor confirmation/failure, heavy test, console pause/resume, keyboard gating, and five dialogs at two window sizes.'); }catch(error){ const page=browser.contexts()[0]?.pages()[0]; if(page){ await page.screenshot({path:path.join(output,'failure.png')}); console.error(await page.evaluate(()=>[...document.querySelectorAll('input')].filter(el=>!el.checkValidity()).map(el=>({name:el.name,value:el.value,error:el.validationMessage})))); } throw error; }finally{await browser.close();await new Promise(resolve=>server.close(resolve))} } main().catch(error=>{console.error(error);server.close();process.exitCode=1});