Carpincho/test/ui-smoke.cjs
2026-09-30 00:20:44 -03:00

244 lines
18 KiB
JavaScript

// Run with Node >= 20 and an NW.js SDK binary: node test/ui-smoke.cjs /path/to/nw
// Uses production library order, views and static mounts without starting the collector.
const fs = require('node:fs');
const path = require('node:path');
const os = require('node:os');
const { pathToFileURL } = require('node:url');
const { spawn } = require('node:child_process');
const assert = require('node:assert/strict');
const root = path.resolve(__dirname, '..');
const src = path.join(root, 'src');
const express = require(path.join(src, 'node_modules/express'));
const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'carpincho-ui-'));
const read = name => fs.readFileSync(path.join(src, name), 'utf8');
const clean = html => html.replace(/<!--[\s\S]*?-->/g, '');
const app = express();
app.use(express.json());
const testConfigFile = path.join(temp, 'web-config.js');
const {guardarConfiguracion} = require('../src/core/configuracion-store.js');
require('../src/gui_web/configuracion-api.js').registrarConfiguracion(app, {guardar: config => guardarConfiguracion(config, testConfigFile), reconfigurar() {}});
// Read actual mounts so a stale web route also fails this test.
const routes = [...read('gui_web/express.js').matchAll(/app.use\('([^']+)', express.static\(__dirname \+ '([^']+)'\)\)/g)];
app.use(express.static(path.join(src, 'gui_web')));
for (const [, url, dir] of routes) app.use(url, express.static(path.join(src, 'gui_web', dir)));
async function probe() {
const wait = ms => new Promise(resolve => setTimeout(resolve, ms));
const check = (value, message) => { if (!value) throw new Error(message); };
const tests = [];
try {
nw.Window.get().show();
await wait(200);
check($.fn.jquery.startsWith('3.'), 'Expected jQuery 3');
check(bootstrap.Tooltip.VERSION.startsWith('5.'), 'Expected Bootstrap 5');
for (const tab of document.querySelectorAll('[data-bs-toggle="tab"]')) {
tab.click();
await wait(200);
check(document.querySelector(tab.dataset.bsTarget)?.classList.contains('active'), 'Tab failed: ' + tab.id);
}
tests.push('all navigation tabs');
const home = document.getElementById('inicio');
home.innerHTML = window.homeHTML;
document.getElementById('inicio-tab').click();
const live = {state:[true,''], system:'SI', record:{fecha:'30/09/2026 12:00:00', temperatura_externa:0, humedad_externa:65, viento_velocidad:10, lluvia_acu_diaria:2.5}, publications:[]};
EstadoUI.render(live);
check(home.querySelectorAll('.ema-measurement').length === 4, 'Missing measurement cards');
const temperature = home.querySelector('[data-medicion="temperatura_externa"]');
check(temperature.textContent.includes('0°C'), 'Zero temperature treated as missing');
temperature.focus(); EstadoUI.render(live);
check(document.activeElement === temperature, 'Refresh lost focused measurement');
EstadoUI.render({...live, system:'SA'});
check(temperature.textContent.includes('32°F'), 'Home imperial conversion failed');
EstadoUI.render({...live, record:false});
check(temperature.textContent.includes('—'), 'Missing measurement shown as zero');
EstadoUI.render(live);
temperature.click();
check(document.getElementById('exterior').classList.contains('active'), 'Card detail navigation failed');
document.getElementById('inicio-tab').click();
await wait(250);
check(getComputedStyle(document.querySelector('#list-tab .nav-link.active')).backgroundColor === 'rgb(26, 96, 111)', 'Active navigation palette missing: ' + getComputedStyle(document.querySelector('#list-tab .nav-link.active')).backgroundColor);
check(getComputedStyle(document.getElementById('estado-estacion')).position === 'sticky', 'Status is not sticky');
for (const id of ['botoniniciar','botondetener','inombre','imodo','estado','iusb','mensaje','mensajedetalle','tamanio_diario','tamanio_pendiente','tamanio_debug','audiomenu','autoactivado','iso','nwjs_v','node_pid','node_ppid']) check(home.querySelector('#'+id), 'Missing existing control: '+id);
nw.Window.get().show(); await wait(350);
const homeCapture = await new Promise(resolve => nw.Window.get().capturePage(resolve, {format:'png', datatype:'buffer'}));
require('fs').writeFileSync(window.resultFile + '.inicio.png', homeCapture);
tests.push('home live cards, units, missing data, stable focus, detail links, active navigation and sticky status');
const statsRoot = document.getElementById('estadisticas');
statsRoot.innerHTML = window.statisticsHTML;
const records = [
{fecha:'29/09/2026 12:00:00', temperatura_externa:0, cambio:false},
{fecha:'29/09/2026 12:02:00', temperatura_externa:10, cambio:true}
];
await EstadisticasUI.mount(statsRoot, {mode:'Recolector_IP', load:async () => JSON.stringify(records)});
check(statsRoot.querySelectorAll('tbody tr').length === 12, 'Statistics summary missing');
check(statsRoot.querySelector('svg path').getAttribute('d').includes('L'), 'History line missing');
document.getElementById('estadisticas-tab').click();
nw.Window.get().show();
statsRoot.classList.add('ema-panel');
statsRoot.style.padding = '16px';
await wait(400);
check(statsRoot.getBoundingClientRect().height > 300, 'Statistics panel not visible');
const capture = await new Promise(resolve => nw.Window.get().capturePage(resolve, {format:'png', datatype:'buffer'}));
require('fs').writeFileSync(window.resultFile + '.estadisticas.png', capture);
const variable = statsRoot.querySelector('select');
variable.value = 'humedad_externa'; variable.dispatchEvent(new Event('change'));
check(!statsRoot.querySelector('svg'), 'Missing values plotted as zeros');
await EstadisticasUI.mount(statsRoot, {mode:'Recolector_USB', load:async () => [records[0]]});
check(statsRoot.querySelectorAll('svg circle').length === 1, 'Single unchanged record not plotted');
await EstadisticasUI.mount(statsRoot, {mode:'Recolector', load:async () => []});
check(statsRoot.textContent.includes('Todavía no hay registros'), 'Empty state missing');
await EstadisticasUI.mount(statsRoot, {mode:'Recolector', load:async () => {throw Error('fixture failure');}});
check(statsRoot.querySelector('.alert-danger') && !statsRoot.querySelector('button').disabled, 'Error/retry state missing');
await EstadisticasUI.mount(statsRoot, {mode:'Visor', load:async () => {throw Error('Visor must not load');}});
check(statsRoot.textContent.includes('modo recolector'), 'Visor message missing');
let resolveOld;
const old = EstadisticasUI.mount(statsRoot, {mode:'Recolector', load:() => new Promise(resolve => {resolveOld = resolve;})});
await EstadisticasUI.mount(statsRoot, {mode:'Recolector', load:async () => []});
resolveOld(records); await old;
check(!statsRoot.querySelector('svg'), 'Stale response overwrote newer statistics');
tests.push('statistics summary, chart, selection, single record, empty/error/visor states and stale requests');
$('#settings').html(window.settingsHTML);
const fixture = window.configFixture;
const interfaces = {eth0: [{family:'IPv4',internal:false,address:'127.0.0.1'}]};
for (let i = 0; i < 3; i++) {
ConfigUI.mount(fixture, interfaces, [{name:'Voz de prueba'}]);
check(!ConfigUI.dirty(), 'Mount marked the form dirty');
check(document.querySelectorAll('#r_ip option').length === 1, 'Duplicated IP options');
check(document.querySelectorAll('#vocesAudio option').length === 1, 'Duplicated voice options');
}
check(!document.querySelector('.bootstrap-select'), 'Plugin is still used');
check(document.getElementById('inicio_auto').selectedOptions[0].textContent === 'Desactivado', 'Duplicated selected text');
const set = (id, value) => {const e=document.getElementById(id); e.value=value; e.dispatchEvent(new Event('change',{bubbles:true}));};
set('Modo','Visor');
check(!document.getElementById('LlaveR').hidden && document.getElementById('bloque_por_IP_WIFI').hidden, 'Visor visibility failed');
let writes = 0;
check(await ConfigUI.save(async () => {writes++;}) === false && writes === 0, 'Invalid visor config saved');
check(document.getElementById('Llave_R').getAttribute('aria-invalid') === 'true', 'No field validation message');
set('Llave_R','llave-prueba'); set('Modo','Recolector_IP');
check(!document.getElementById('bloque_por_IP_WIFI').hidden && document.getElementById('LlaveR').hidden, 'IP visibility failed');
set('r_puerto','65536');
check(await ConfigUI.save(async () => {writes++;}) === false && writes === 0, 'Invalid port saved');
set('r_puerto','5000'); set('Modo','Recolector_USB'); set('NombreEstacion','Nombre nuevo');
const confirm = window.confirm;
window.confirm = () => false;
check(!ConfigUI.canLeave(), 'Unsaved changes did not block navigation');
const active = document.querySelector('.nav-link.active');
document.getElementById('inicio-tab').click();
check(document.querySelector('.nav-link.active') === active, 'Tab changed despite cancel');
check(await ConfigUI.save(async () => {throw new Error('Fallo simulado');}) === false, 'Save failure not reported');
check(ConfigUI.dirty() && document.getElementById('config-status').textContent.includes('Fallo simulado'), 'Failed save lost dirty state');
window.confirm = confirm;
const saved = await ConfigUI.save(window.persistConfiguration);
check(saved && !ConfigUI.dirty(), 'Successful save did not clear dirty state');
const loaded = window.readConfiguration();
check(loaded.ConfGeneral.NombreEstacion === 'Nombre nuevo', 'Saved config was not persisted');
check(loaded.ConfGeneral.Llave_R === 'llave-prueba', 'Mode switch discarded entered values');
ConfigUI.mount(loaded, interfaces, [{name:'Voz de prueba'}]);
check(document.getElementById('NombreEstacion').value === 'Nombre nuevo', 'Reload lost saved value');
set('NombreEstacion','Cambio descartado'); window.confirm = () => true;
check(ConfigUI.canLeave(), 'Discard did not permit navigation'); window.confirm = confirm;
ConfigUI.mount(loaded, interfaces, []);
tests.push('native selects, repeated mount, mode changes, validation, unsaved changes, failed save, disk save and reload');
EstadoUI.render({state:[false,'Sin conexión USB'], mode:'Recolector_USB',record:false,publications:[]});
check(document.getElementById('estado-estacion').textContent.includes('Sin conexión USB'), 'Missing connection reason');
EstadoUI.render({state:[true,''],record:{fecha:'01/01/2000 00:00:00'},publications:[{red:'Prueba',fecha:'01/01/2000 00:00:00',estado:false,error:'Llave inválida'}]});
check(document.getElementById('estado-estacion').textContent.includes('no es reciente'), 'Stale reading not flagged');
check(document.getElementById('estado-estacion').textContent.includes('Llave inválida'), 'Publication failure missing');
tests.push('separate connection, stale reading and publication error');
$('#tip').tooltip('show');
check(!!document.querySelector('.tooltip'), 'Tooltip did not open');
$('#tip').tooltip('dispose');
tests.push('tooltip show/dispose');
const table = await new Promise(resolve => {
$('#table').DataTable({...TablaUI.options(), pageLength: 1,
language: {url: window.languageURL}, initComplete() { resolve(this.api()); }});
});
check(table.rows().count() === 2, 'Missing table rows');
check(document.querySelector('#table tbody .ema-number'), 'Numeric cells not aligned');
const selectedRow = document.querySelector('#table tbody tr'); selectedRow.click();
check(selectedRow.classList.contains('ema-selected'), 'Row selection missing');
check(!!document.querySelector('.dataTables_scrollHead'), 'Fixed table header missing');
table.search('Beta').draw();
check(table.rows({search: 'applied'}).count() === 1, 'Table search failed');
check(table.buttons.exportData().body[0][0] === 'Beta', 'CSV/copy data failed');
check(document.querySelectorAll('.dt-buttons .btn').length === 2, 'Missing export buttons');
check(!document.querySelector('.dt-buttons .btn-default'), 'Legacy button class');
check(document.querySelector('.dataTables_info').textContent.includes('Mostrando'), 'Spanish language failed');
table.destroy();
tests.push('table initialization, filtering, export data, buttons, translation, destroy');
check(window.failures.length === 0, window.failures.join('\n'));
} catch (error) { window.failures.push(error.stack); }
document.getElementById('settings').replaceChildren();
require('fs').writeFileSync(window.resultFile, JSON.stringify({versions: process.versions, tests, failures: window.failures}, null, 2));
nw.App.quit();
}
async function run(mode, origin) {
const folder = path.join(temp, mode);
fs.mkdirSync(folder);
const html = clean(read(mode + '/index.html'));
const base = mode === 'gui' ? pathToFileURL(path.join(src, 'gui/index.html')).href : origin + '/';
// Verify every resource in the entry point and dynamic HTML against its document base.
const pages = ['index.html', ...(mode === 'gui' ? ['index_actualizar.html'] : []), ...fs.readdirSync(path.join(src, mode, 'view')).filter(n => n.endsWith('.html')).map(n => 'view/' + n)];
for (const file of pages) {
for (const [, resource] of clean(read(mode + '/' + file)).matchAll(/(?:src|href)=["']([^"']+)["']/g)) {
if (/^(#|https?:|mailto:|javascript:)/.test(resource)) continue;
const url = new URL(resource, base);
if (url.protocol === 'file:') assert(fs.existsSync(url), `${file}: missing ${url}`);
else assert((await fetch(url)).ok, `${file}: missing ${url}`);
}
}
const styleURL = new URL(mode === 'gui' ? '../gui_common/css/style.css' : 'gui_common/css/style.css', base);
for (const [, resource] of read('gui_common/css/style.css').matchAll(/url\(['"]?([^)'"]+)/g)) {
const url = new URL(resource, styleURL);
if (url.protocol === 'file:') assert(fs.existsSync(url), 'Missing CSS image: ' + url);
else assert((await fetch(url)).ok, 'Missing CSS image: ' + url);
}
const head = [...html.matchAll(/<link\b[^>]*>|<script\b[^>]*src="([^"]+)"[^>]*><\/script>/g)]
.filter(m => !m[1] || !m[1].includes('controlador')).map(m => m[0]).join('\n');
const tabs = [...html.matchAll(/<a\b[^>]*data-bs-toggle="tab"[^>]*>[\s\S]*?<\/a>/g)].map(m => m[0]);
const panes = tabs.map(tab => {
const id = tab.match(/data-bs-target="#([^"]+)"/)[1];
assert(html.includes('id="' + id + '"'), 'Missing production tab pane: ' + id);
return `<div class="tab-pane ${id === 'inicio' ? 'active show' : ''}" id="${id}"></div>`;
}).join('');
const resultFile = path.join(folder, 'result.json');
const settings = read('gui_common/view/modelo_configuracion.html').replace(/<script\b[\s\S]*?<\/script>/g, '').replace(/\son\w+="[^"]*"/g, '');
const language = new URL(mode === 'gui' ? '../gui_common/css/Spanish.json' : 'css/Spanish.json', base).href;
const configFile = mode === 'gui' ? path.join(folder,'config.js') : testConfigFile;
const fixture = require('./fixtures/configuracion.cjs')();
fs.writeFileSync(path.join(folder, 'index.html'), `<!doctype html><meta charset="utf-8"><base href="${base}">
<script>window.failures=[];addEventListener('error',e=>failures.push(e.message||'Resource: '+e.target.src),true);addEventListener('unhandledrejection',e=>failures.push(String(e.reason)));</script>
${head}<body class="dia"><section id="estado-estacion" class="estado-estacion"></section><ul id="list-tab" class="nav nav-tabs nav-pills nav-fill">${tabs.join('')}</ul><div class="tab-content ema-panel">${panes}</div><div id="settings"></div>
<button id="tip" title="Prueba" class="btn btn-primary">Tooltip</button>
<table id="table" class="table table-bordered"><thead><tr><th>Nombre</th><th>Valor</th></tr></thead><tbody><tr><td>Alpha</td><td>12.5</td></tr><tr><td>Beta</td><td>0</td></tr></tbody></table>
<script>
window.configFixture=${JSON.stringify(fixture)};
window.persistConfiguration=async draft => {
if (${JSON.stringify(mode)} === 'gui') await require(${JSON.stringify(path.join(src,'core/configuracion-store.js'))}).guardarConfiguracion(draft, ${JSON.stringify(configFile)});
else {const response=await $.ajax({url:'/GuardarConf',method:'POST',contentType:'application/json',data:JSON.stringify({configuracion:draft})});if(!response.ok)throw new Error('Save failed');}
};
window.readConfiguration=()=>{const sandbox={module:{exports:{}}};require('vm').runInNewContext(require('fs').readFileSync(${JSON.stringify(configFile)},'utf8'),sandbox);return sandbox.module.exports;};
window.homeHTML=${JSON.stringify(read(mode + '/view/modelo_inicio.html'))};window.statisticsHTML=${JSON.stringify(read(mode + '/view/modelo_estadisticas.html'))};window.settingsHTML=${JSON.stringify(settings)};window.languageURL=${JSON.stringify(language)};window.resultFile=${JSON.stringify(resultFile)};addEventListener('load',${probe.toString()});</script>`);
const args = JSON.parse(read('package.json'))['chromium-args'].replace('--disable-logging', '');
fs.writeFileSync(path.join(folder, 'package.json'), JSON.stringify({name:'carpincho-ui-test',main:'index.html','chromium-args':args,window:{show:false,width:1200,height:900}}));
await new Promise((resolve, reject) => {
const child = spawn(process.argv[2] || process.env.NW_BINARY || 'nw', [folder, '--user-data-dir=' + path.join(folder, 'profile')], {stdio: ['ignore', 'ignore', 'pipe']});
let log = '';
child.stderr.on('data', data => {log += data;});
const timer = setTimeout(() => {child.kill(); reject(new Error('NW.js timed out: ' + folder));}, 30000);
child.on('error', error => {clearTimeout(timer); reject(error);});
child.on('exit', () => {clearTimeout(timer); fs.writeFileSync(path.join(folder,'stderr.log'),log); resolve();});
});
const result = JSON.parse(fs.readFileSync(resultFile));
assert.deepEqual(result.failures, [], JSON.stringify(result, null, 2));
console.log(`${mode}: ${result.tests.length} checks passed (NW ${result.versions.nw}, Chromium ${result.versions.chromium})`);
}
(async () => {
const server = await new Promise(resolve => {const s=app.listen(0,'127.0.0.1',()=>resolve(s));});
try {for (const mode of ['gui','gui_web']) await run(mode, 'http://127.0.0.1:' + server.address().port);}
finally {server.close(); console.log('Results: ' + temp);}
})().catch(error => {console.error(error);process.exitCode=1;});