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(/',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,'1 sec ago');
assert.equal(app.querySelector('summary .last-log').textContent,'1 sec ago : Fresh log');
dom.window.Date.now=()=>nextTime+65000;
await new Promise(resolve=>setTimeout(resolve,1100));
assert.equal(app.querySelector('.last-age').textContent,'1 min ago');
assert.equal(app.querySelector('summary .last-log').textContent,'1 min ago : Fresh log');
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();
});