ui-regression.test.cjs 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117
  1. const {test} = require('node:test');
  2. const assert = require('node:assert/strict');
  3. const fs = require('node:fs');
  4. const path = require('node:path');
  5. const vm = require('node:vm');
  6. const source = fs.readFileSync(path.join(__dirname,'../internal/web/static/app.js'),'utf8');
  7. function functionSource(name,next) {
  8. return source.slice(source.indexOf('function '+name+'('),source.indexOf('\nfunction '+next+'('));
  9. }
  10. test('bandwidth cursor follows elapsed time across a rolling history and a new session',()=>{
  11. const seen=[];
  12. const context={bwtestState:{lastSampleElapsed:-1},handleBwTestSample:s=>seen.push(s.elapsed_sec),clearInterval(){},setBwTestRunningUI(){},handleBwTestFinish(){}};
  13. vm.createContext(context);
  14. vm.runInContext(functionSource('processBwTestStatus','startBwTestTelemetry'),context);
  15. const window=offset=>Array.from({length:200},(_,i)=>({elapsed_sec:i+offset}));
  16. context.processBwTestStatus({session_id:'one',running:true,recent_samples:window(1)});
  17. context.processBwTestStatus({session_id:'one',running:true,recent_samples:window(2)});
  18. assert.equal(seen.length,201);
  19. assert.equal(seen.at(-1),201);
  20. context.processBwTestStatus({session_id:'two',running:true,recent_samples:[{elapsed_sec:0.5}]});
  21. assert.equal(seen.at(-1),0.5);
  22. });
  23. test('fetch coalesces API reads, copies responses, and keeps bearer tokens on this origin',async()=>{
  24. const requests=[];
  25. const localStorage={getItem:()=> 'test-token',removeItem(){}};
  26. const context={URL,Headers,Request,Response,AbortController,setTimeout,clearTimeout,console,localStorage,document:{getElementById:()=>null}};
  27. context.window={location:{href:'http://router/',origin:'http://router'},fetch:async(resource,options)=>{
  28. requests.push({resource,options});
  29. await new Promise(resolve=>setTimeout(resolve,10));
  30. return new Response('{"ok":true}',{headers:{'content-type':'application/json'}});
  31. }};
  32. vm.createContext(context);
  33. vm.runInContext(source.slice(source.indexOf('const originalFetch'),source.indexOf('\nlet state ='))+'\nlet state = {currentUser:null};',context);
  34. const responses=await Promise.all([context.window.fetch('/api/status'),context.window.fetch('/api/status')]);
  35. assert.equal(requests.length,1);
  36. assert.deepEqual(await responses[0].json(),{ok:true});
  37. assert.deepEqual(await responses[1].json(),{ok:true});
  38. assert.equal(requests[0].options.headers.get('Authorization'),'Bearer test-token');
  39. await context.window.fetch('https://another-origin.example/path');
  40. assert.equal(requests[1].options.headers,undefined);
  41. });
  42. test('HTML contains no duplicate IDs',()=>{
  43. const html=fs.readFileSync(path.join(__dirname,'../internal/web/static/index.html'),'utf8');
  44. const ids=[...html.matchAll(/\bid="([^"]+)"/g)].map(match=>match[1]);
  45. assert.equal(new Set(ids).size,ids.length);
  46. });
  47. test('routing delete keeps selection and reports a rejected change',async()=>{
  48. const messages=[];
  49. let refreshes=0;
  50. const context={API_BASE:'/api',state:{selectedRipRow:{instances:'r1'}},confirm:()=>true,
  51. fetch:async()=>new Response('{"message":"Cannot delete active instance"}',{status:409}),
  52. showToast:(...args)=>messages.push(args),loadDynamicRouting:async()=>refreshes++,Response};
  53. vm.createContext(context);
  54. vm.runInContext(source.slice(source.indexOf('async function responseError('),source.indexOf('\n}',source.indexOf('async function responseError('))+2),context);
  55. vm.runInContext(source.slice(source.indexOf('async function routingMutation('),source.indexOf('\n}',source.indexOf('async function routingMutation('))+2),context);
  56. vm.runInContext(source.slice(source.indexOf('async function removeSelectedRipInstance('),source.indexOf('\n}',source.indexOf('async function removeSelectedRipInstance('))+2),context);
  57. await context.removeSelectedRipInstance();
  58. assert.equal(context.state.selectedRipRow.instances,'r1');
  59. assert.equal(refreshes,0);
  60. assert.match(messages[0][1],/Cannot delete active instance/);
  61. });
  62. test('view health filters unrelated requests and reports failures',()=>{
  63. const element={};
  64. const context={document:{getElementById:()=>element},state:{activeTab:'eigrp'},Date,
  65. telemetryHealth:{failures:new Map([['/api/routing/eigrp/neighbors','HTTP 500'],['/api/firewall/filter','HTTP 500']]),successes:new Map([['/api/routing/eigrp/instances',Date.now()]])}};
  66. vm.createContext(context);
  67. vm.runInContext(source.slice(source.indexOf('function viewUsesEndpoint('),source.indexOf('function renderTelemetryHealth(')),context);
  68. context.renderViewDataHealth();
  69. assert.match(element.textContent,/incomplete/);
  70. assert.match(element.title,/eigrp/);
  71. assert.doesNotMatch(element.title,/firewall/);
  72. context.telemetryHealth.failures.delete('/api/routing/eigrp/neighbors');
  73. context.renderViewDataHealth();
  74. assert.match(element.textContent,/oldest update/);
  75. });
  76. test('network overview displays reported state without treating it as reachability',()=>{
  77. const context={};vm.createContext(context);
  78. vm.runInContext(source.slice(source.indexOf('function formatNetworkOverview('),source.indexOf('\nfunction loadToolsView(')),context);
  79. assert.match(context.formatNetworkOverview('Interfaces',[{name:'eth0',operstate:'up',ipv4:['192.0.2.1/24'],ipv6:[]}]),/eth0: up.*192\.0\.2\.1/);
  80. assert.match(context.formatNetworkOverview('WireGuard peers',[{interface:'wg0',name:'site',disabled:false}]),/last handshake not reported/);
  81. assert.match(context.formatNetworkOverview('AeroTUN',[{name:'a0',running:true,connected:false}]),/not connected/);
  82. assert.equal(context.formatNetworkOverview('Routes',{}),'Unexpected response');
  83. });
  84. test('OSPF rejected delete keeps modal open and reports API error',async()=>{
  85. const events=[];
  86. const context={API_BASE:'/api',document:{getElementById:()=>({value:'r1'})},confirm:()=>true,
  87. fetch:async()=>new Response('{"message":"In use"}',{status:409}),
  88. closeOspfAreaRangeModal:()=>events.push('closed'),showToast:(...x)=>events.push(x),loadDynamicRouting:()=>events.push('refreshed'),Response};
  89. vm.createContext(context);
  90. for(const name of ['responseError','routingMutation','deleteCurrentOspfAreaRange']) {
  91. const start=source.indexOf('async function '+name+'('); const end=source.indexOf('\n}',start)+2;
  92. vm.runInContext(source.slice(start,end),context);
  93. }
  94. await context.deleteCurrentOspfAreaRange();
  95. assert.equal(events.some(e=>e==='closed' || e==='refreshed'),false);
  96. assert.ok(events.some(e=>Array.isArray(e) && e[1]==='In use' && e[2]==='error'));
  97. });
  98. test('OSPF rejected toggle does not alter cached range',async()=>{
  99. const range={id:'r1',disabled:true};const messages=[];
  100. const context={API_BASE:'/api',state:{selectedOspfRow:{'area-ranges':'r1'},ospfAreaRanges:[range]},
  101. fetch:async()=>new Response('Rejected',{status:400}),showToast:(...x)=>messages.push(x),loadDynamicRouting:()=>{throw Error('unexpected refresh')},Response};
  102. vm.createContext(context);
  103. for(const name of ['responseError','routingMutation','enableSelectedOspfAreaRange']) {
  104. const start=source.indexOf('async function '+name+'('); const end=source.indexOf('\n}',start)+2;
  105. vm.runInContext(source.slice(start,end),context);
  106. }
  107. await context.enableSelectedOspfAreaRange();
  108. assert.equal(range.disabled,true);assert.equal(messages[0][1],'Rejected');
  109. });