143 lines
9.3 KiB
JavaScript
143 lines
9.3 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();
|
|
// 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 {
|
|
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');
|
|
$('#settings').html(window.settingsHTML);
|
|
// Repeated updates must keep one label and one menu item per option.
|
|
$('.selectpicker').selectpicker();
|
|
await wait(200);
|
|
for (const select of document.querySelectorAll('#settings select.selectpicker')) {
|
|
const $select = $(select);
|
|
$select.append(new Option('Alternativa', '__alternative'), new Option('Prueba dinámica', '__smoke'));
|
|
actualizarSelects($select);
|
|
$select.val('__smoke');
|
|
for (let i = 0; i < 3; i++) actualizarSelects($select);
|
|
let wrapper = select.closest('.bootstrap-select');
|
|
check(wrapper.querySelector('.filter-option').textContent.trim() === 'Prueba dinámica', 'Render failed: ' + select.id);
|
|
wrapper.querySelector('button').click();
|
|
await wait(50);
|
|
check(wrapper.querySelector('.dropdown-menu').classList.contains('show'), 'Select did not open: ' + select.id);
|
|
check([...wrapper.querySelectorAll('[role="option"] .text')].filter(item => item.textContent === 'Prueba dinámica').length === 1, 'Duplicated menu option: ' + select.id);
|
|
const first = wrapper.querySelector('[role="option"]');
|
|
first.click();
|
|
check(select.value !== '__smoke', 'Option selection failed: ' + select.id);
|
|
$select.prop('disabled', true);
|
|
actualizarSelects($select);
|
|
wrapper = select.closest('.bootstrap-select');
|
|
check(wrapper.querySelector('button').getAttribute('aria-disabled') === 'true', 'Disabled state failed: ' + select.id);
|
|
wrapper.querySelector('button').click();
|
|
check(!wrapper.querySelector('.dropdown-menu').classList.contains('show'), 'Disabled select opened: ' + select.id);
|
|
$select.selectpicker('destroy');
|
|
}
|
|
tests.push('configuration selects: refresh, value, open, select, disable, destroy');
|
|
$('#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({dom: 'Bfrtip', buttons: ['copy', 'csv'], pageLength: 1,
|
|
language: {url: window.languageURL}, initComplete() { resolve(this.api()); }});
|
|
});
|
|
check(table.rows().count() === 2, 'Missing table rows');
|
|
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); }
|
|
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(mode + '/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;
|
|
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"><ul class="nav nav-tabs">${tabs.join('')}</ul>${panes}<div id="settings"></div>
|
|
<button id="tip" title="Prueba" class="btn btn-primary">Tooltip</button>
|
|
<table id="table" class="table"><thead><tr><th>Nombre</th></tr></thead><tbody><tr><td>Alpha</td></tr><tr><td>Beta</td></tr></tbody></table>
|
|
<script>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] || '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;});
|