const {test}=require('node:test'); const assert=require('node:assert/strict'); const fs=require('fs'); const path=require('path'); const {JSDOM,VirtualConsole}=require('jsdom'); const media=path.join(__dirname,'../media'); function setup() { const errors=[], sent=[]; const virtualConsole=new VirtualConsole();virtualConsole.on('jsdomError',e=>errors.push(e)); const html=fs.readFileSync(path.join(media,'logExplorer.html'),'utf8').replace(/]+>/,'').replace(/]+src="\{\{JS\}\}"[^>]*><\/script>/,''); const dom=new JSDOM(html,{runScripts:'outside-only',virtualConsole}); dom.window.acquireVsCodeApi=()=>({postMessage:message=>sent.push(message),getState:()=>({}),setState() {}}); dom.window.eval(fs.readFileSync(path.join(media,'logExplorer.js'),'utf8')); const entry=(id,app,second,message,extra={})=>{const timestamp=`2026-09-27T08:00:${String(second).padStart(2,'0')}Z`;return {event_id:id,app,time:Date.parse(timestamp),timestamp,message,kind:'log',cause_ids:[],level:'INFO',...extra};}; const entries=[entry('init1','alpha',1,'Preparing sensors',{initialization_id:'init-one'}),entry('init2','alpha',2,'Ready',{initialization_id:'init-one'}),entry('init3','alpha',3,'Restarting',{initialization_id:'init-two'}),entry('a','alpha',10,'Température déjà disponible'),entry('b','beta',20,'Connection ready')]; const send=items=>dom.window.dispatchEvent(new dom.window.MessageEvent('message',{data:{type:'entries',entries:items,warnings:[],limit:10000}})); send(entries); return {dom,d:dom.window.document,entries,sent,errors,send}; } test('English interface, latest timestamps, sorting and separate initialization groups',()=>{ const {dom,d,errors}=setup(); assert.equal(d.documentElement.lang,'en'); assert.equal(d.getElementById('copy').textContent,'Copy selected log'); assert.deepEqual([...d.querySelectorAll('[data-app]')].map(e=>e.dataset.app),['beta','alpha']); assert.match(d.querySelector('[data-app="alpha"] .last-age').title,/10:00:10/); d.getElementById('sort').value='oldest';d.getElementById('sort').dispatchEvent(new dom.window.Event('input')); assert.equal(d.querySelector('[data-app]').dataset.app,'alpha'); const alpha=d.querySelector('[data-app="alpha"]');alpha.open=true;alpha.dispatchEvent(new dom.window.Event('toggle')); assert.equal(alpha.querySelectorAll('.rows>.row').length,3); assert.equal(alpha.querySelectorAll('.event-log-group').length,1); assert.equal(alpha.querySelector('.event-log-group .row-message').textContent,'Preparing sensors\nReady'); assert.equal(alpha.querySelectorAll('.rows details').length,0); assert.equal(d.getElementById('group-init'),null); assert.deepEqual(errors,[]);dom.window.close(); }); test('copies selected logs and entire initialization groups through VS Code transport',()=>{ const {dom,d,sent,errors}=setup();const alpha=d.querySelector('[data-app="alpha"]');alpha.open=true;alpha.dispatchEvent(new dom.window.Event('toggle')); [...alpha.querySelectorAll('.row')].find(e=>e.textContent.includes('Température déjà disponible')).click();d.getElementById('copy').click(); assert.equal(sent.at(-1).type,'copy');assert.match(sent.at(-1).text,/Température déjà disponible/); const group=alpha.querySelector('.event-log-group'); group.click();d.getElementById('copy').click(); assert.match(d.getElementById('detail').textContent,/Preparing sensors\s+Ready/); assert.match(sent.at(-1).text,/Preparing sensors/);assert.match(sent.at(-1).text,/Ready/);assert.doesNotMatch(sent.at(-1).text,/Restarting/); const input=d.getElementById('search'),paste=new dom.window.KeyboardEvent('keydown',{key:'v',ctrlKey:true,bubbles:true,cancelable:true});input.dispatchEvent(paste);assert.equal(paste.defaultPrevented,false); assert.deepEqual(errors,[]);dom.window.close(); }); test('retains selected text during live updates and copies it with Ctrl+C',async()=>{ const {dom,d,entries,sent,errors,send}=setup();const alpha=d.querySelector('[data-app="alpha"]');alpha.open=true;alpha.dispatchEvent(new dom.window.Event('toggle')); const text=alpha.querySelector('.row-message'),range=d.createRange();range.selectNodeContents(text);dom.window.getSelection().addRange(range); const original=text.textContent; send([...entries,{...entries[3],event_id:'new',time:30,message:'New log'}]); assert.equal(dom.window.getSelection().toString(),original);assert.match(d.getElementById('status').textContent,/buffered/); text.dispatchEvent(new dom.window.KeyboardEvent('keydown',{key:'c',ctrlKey:true,bubbles:true,cancelable:true})); assert.equal(sent.at(-1).text,original); dom.window.getSelection().removeAllRanges();d.dispatchEvent(new dom.window.Event('selectionchange'));await new Promise(resolve=>setTimeout(resolve,15)); assert.match(d.querySelector('[data-app="alpha"]').textContent,/New log/); assert.deepEqual(errors,[]);dom.window.close(); }); test('log content is never interpreted as HTML',()=>{ const {dom,d,entries,send,errors}=setup();send([{...entries[0],app:'',initialization_id:undefined,message:''}]); const app=d.querySelector('[data-app]');app.open=true;app.dispatchEvent(new dom.window.Event('toggle')); assert.equal(d.querySelectorAll('#apps img,#apps script').length,0);assert.match(app.textContent,/{ const {dom,d,entries,send,sent}=setup(); const trigger={...entries[3],event_id:'motion-callback',time:entries[3].time+1000,message:'Callback trigger by sensor.kitchen_last_motion(7 => 0)',trace_id:'motion-event'}; const evaluation={...entries[3],event_id:'motion-evaluation',time:entries[3].time+1000,message:'[kitchen_occupancy][Evaluating] Triggered by (sensor.kitchen_last_motion[0] <= 5)[True]',trace_id:'motion-event',cause_ids:['state-event']}; const newer={...entries[3],event_id:'later-log',time:entries[3].time+2000,message:'Later event'}; send([trigger,evaluation,newer].sort((a,b)=>a.time-b.time)); const alpha=d.querySelector('[data-app="alpha"]');alpha.open=true;alpha.dispatchEvent(new dom.window.Event('toggle')); const rows=alpha.querySelector('.rows'),combined=rows.children[0]; assert.ok(combined.classList.contains('event-log-group')); assert.match(combined.querySelector('.row-message').textContent,/Callback trigger by sensor.kitchen_last_motion/); assert.match(combined.querySelector('.row-message').textContent,/Triggered by \(sensor.kitchen_last_motion/); assert.equal(combined.querySelectorAll('.link-hint').length,1); assert.equal(rows.children[1].querySelector('.row-message').textContent,'Later event'); combined.click();d.getElementById('copy').click(); assert.match(sent.at(-1).text,/Callback trigger by sensor.kitchen_last_motion/); assert.match(sent.at(-1).text,/Triggered by \(sensor.kitchen_last_motion/); assert.match(d.getElementById('detail').textContent,/Callback trigger by sensor.kitchen_last_motion/); dom.window.close(); }); test('does not show a mirrored evaluation twice in the cause graph',()=>{ const {dom,d,entries,send}=setup(); const time=entries[3].time+1000,trace='same-run'; const state={...entries[3],event_id:'state-event',kind:'state_changed',message:'sensor.ext_back_humidity.state: 66 → 65',time,trace_id:trace,cause_ids:[]}; const message='[living_room_cant_open_window][Evaluating] No valid conditions'; const evaluation={...entries[3],event_id:'technical-evaluation',kind:'evaluation',message,time,trace_id:trace,cause_ids:['state-event'],values:{'sensor.ext_back_humidity':'65'}}; const callback={...entries[3],event_id:'callback-log',message:'[living_room_cant_open_window] Callback trigger by sensor.ext_back_humidity(66 => 65)',time,trace_id:trace,cause_ids:['state-event']}; const mirroredLog={...entries[3],event_id:'evaluation-log',message,time:time+1,trace_id:trace,cause_ids:['technical-evaluation']}; send([state,evaluation,callback,mirroredLog].sort((a,b)=>a.time-b.time)); const app=d.querySelector('[data-app="alpha"]');app.open=true;app.dispatchEvent(new dom.window.Event('toggle')); const combined=app.querySelector('.event-log-group'); assert.ok(combined); assert.equal(combined.querySelectorAll('.link-hint').length,1); combined.click(); const matchingNodes=[...d.querySelectorAll('#detail .node .text')].filter(item=>item.textContent.includes('No valid conditions')); assert.equal(matchingNodes.length,1); assert.equal(d.querySelectorAll('#detail .values tr').length,1); dom.window.close(); }); test('pulses an app when a new log arrives and refreshes its last-log age',async()=>{ const {dom,d,entries,send}=setup(); const beta=entries.find(e=>e.app==='beta'),nextTime=beta.time+2000; dom.window.Date.now=()=>beta.time+3000; const newer={...beta,event_id:'beta-new',time:nextTime,timestamp:new Date(nextTime).toISOString(),message:'Fresh log'}; send([...entries,newer]); const app=d.querySelector('[data-app="beta"]'); assert.ok(app.classList.contains('new-log-app')); app.open=true;app.dispatchEvent(new dom.window.Event('toggle')); assert.ok(app.querySelector('.recent-log')); assert.equal(app.querySelector('.last-age').textContent,'Last log: 1 second ago'); dom.window.Date.now=()=>nextTime+65000; await new Promise(resolve=>setTimeout(resolve,1100)); assert.equal(app.querySelector('.last-age').textContent,'Last log: 1 minute ago'); dom.window.close(); }); test('distinguishes log lines from supplemental records when configured',()=>{ const {dom,d,entries,send}=setup(); const trace={...entries[3],event_id:'trace-evaluation',kind:'evaluation',message:'Condition was evaluated'}; send([...entries,trace]); assert.match(d.getElementById('status').textContent,/5 log lines \+ 1 trace event/); assert.match(d.getElementById('status').title,/configured history files/); dom.window.close(); }); test('shows and refreshes relative time on each log entry while retaining exact time on hover',async()=>{ const {dom,d,entries}=setup(); const entry=entries.find(e=>e.event_id==='a'); dom.window.Date.now=()=>entry.time+120000; const app=d.querySelector('[data-app="alpha"]'); app.open=true; app.dispatchEvent(new dom.window.Event('toggle')); const row=[...d.querySelectorAll('[data-app="alpha"] .row')].find(item=>item.textContent.includes('Température déjà disponible')); const relativeTime=()=>[...app.querySelectorAll('.row')].find(item=>item.textContent.includes('Température déjà disponible')).querySelector('.relative-time'); assert.equal(relativeTime().textContent,'2 minutes ago'); assert.match(relativeTime().title,/10:00:10/); dom.window.Date.now=()=>entry.time+3600000; await new Promise(resolve=>setTimeout(resolve,1600)); assert.equal(relativeTime().textContent,'1 hour ago'); dom.window.close(); }); test('shows consecutive logs from one explicit group as a single visible row',()=>{ const {dom,d,entries,send}=setup(); const group=[ {...entries[3],event_id:'selector-1',time:entries[3].time+1000,message:'Rejected candidate',group_id:'0123456789ab',group_kind:'value_selector'}, {...entries[3],event_id:'selector-2',time:entries[3].time+1000,message:'Selected candidate',group_id:'0123456789ab',group_kind:'value_selector'} ]; send(group); const app=d.querySelector('[data-app="alpha"]');app.open=true;app.dispatchEvent(new dom.window.Event('toggle')); const rows=[...app.querySelectorAll('.rows>.row')].filter(row=>row.textContent.includes('Rejected candidate')); assert.equal(rows.length,1);assert.ok(rows[0].classList.contains('event-log-group')); assert.match(rows[0].querySelector('.row-message').textContent,/Rejected candidate\nSelected candidate/); assert.doesNotMatch(rows[0].textContent,/\[group=/); dom.window.close(); }); test('global error counter shows all loaded errors and opens them despite active filters',()=>{ const {dom,d,entries,send}=setup(); send([...entries,{...entries[3],event_id:'alpha-error',level:'ERROR',message:'Alpha failed'}, {...entries[4],event_id:'beta-critical',level:'CRITICAL',message:'Beta failed'}, {...entries[4],event_id:'supplement',kind:'evaluation',level:'ERROR',message:'Supplemental'}]); const counter=d.getElementById('error-count'); assert.equal(counter.textContent,'2 errors');assert.ok(counter.classList.contains('has-errors')); d.getElementById('search').value='nothing';d.getElementById('period').value='15'; counter.click(); assert.equal(d.getElementById('search').value,'');assert.equal(d.getElementById('period').value,'0'); assert.equal(d.getElementById('level').value,'ERROR'); assert.equal(d.querySelectorAll('#apps .row').length,2); assert.match(d.getElementById('detail').textContent,/failed/); dom.window.close(); }); test('exits errors with the counter or All levels and restores normal app sorting',()=>{ const {dom,d,entries,send}=setup(); send([...entries,{...entries[3],event_id:'old-alpha-error',time:entries[0].time,level:'ERROR',message:'Old error'}]); const counter=d.getElementById('error-count'); counter.click();assert.equal(counter.getAttribute('aria-pressed'),'true'); assert.equal(d.querySelector('[data-app]').dataset.app,'alpha'); counter.click(); assert.equal(d.getElementById('level').value,'all'); assert.equal(counter.getAttribute('aria-pressed'),'false'); assert.equal(d.querySelector('[data-app]').dataset.app,'beta'); counter.click(); d.getElementById('level').value='all';d.getElementById('level').dispatchEvent(new dom.window.Event('input')); assert.equal(d.querySelector('[data-app]').dataset.app,'beta'); assert.equal(d.querySelectorAll('.row.selected').length,0); assert.equal(counter.getAttribute('aria-pressed'),'false'); dom.window.close(); }); test('Open source in a group targets the parsing error even when the last entry has no source',()=>{ const {dom,d,entries,send,sent}=setup(); send([{...entries[0],event_id:'parse-error',level:'ERROR',source:{path:'/conf/apps/heating.yaml',line:160}}, {...entries[1],event_id:'init-failed',level:'ERROR',message:'Initialization failed'}]); const app=d.querySelector('[data-app="alpha"]');app.open=true;app.dispatchEvent(new dom.window.Event('toggle')); app.querySelector('.row').click(); const source=[...d.querySelectorAll('#detail button')].find(button=>button.textContent==='Open source'); assert.ok(source);source.click();assert.equal(sent.at(-1).type,'source');assert.equal(sent.at(-1).id,'parse-error'); dom.window.close(); }); test('Clear logs sends a request and resets its button after completion',()=>{ const {dom,d,sent}=setup(); const button=d.getElementById('clear');button.click(); assert.equal(sent.at(-1).type,'clear');assert.equal(button.disabled,true); dom.window.dispatchEvent(new dom.window.MessageEvent('message',{data:{type:'clear-result',cleared:2,failures:[]}})); assert.equal(button.disabled,false);assert.equal(button.textContent,'Clear logs'); dom.window.close(); });