server-up/tests/js/menu.test.js
Ramon 1db3d3e8a0
All checks were successful
Deploy server-up (dev) / deploy (push) Successful in 14m6s
v0.8.50-beta - Leesbare namen, zichtbaar wat al draait, en bruikbaar op een telefoon
- Het auditlog toonde ruwe id's als 'store', 'reconfigure' en
  'credentials_view'. Die hebben nu namen, ook in de filterlijsten, en de
  kolomkoppen zijn vertaald in plaats van hardgecodeerd Nederlands. Onbekende
  waarden vallen terug op de ruwe tekst zodat een module niets kapotmaakt
- Nieuwe test die de broncode afloopt en elke audit.log() zonder vertaling
  aanwijst
- Een modulepagina heette in de titelbalk letterlijk 'mod:mijnmodule'
- De store herkende een installatie aan de mapnaam: een stack telde alleen mee
  als hij 'sonarr' of 'sonarr-...' heette. Wie zijn stack 'media-tv' noemde
  kreeg te zien dat Sonarr niet geinstalleerd was. Nu via de source uit
  .serverup.json, met een duidelijk Geinstalleerd-label per installatie waar je
  op kunt klikken om ernaartoe te gaan
- Twaalf icoonknoppen per stack werden op een telefoon drie rijen plaatjes
  zonder naam. Daar blijven starten, herstarten en logs staan, plus een menu met
  de rest, mét namen. Op een breed scherm verandert er niets
- De rij van twintig categorieknoppen boven de stacks is hetzelfde compacte menu
  geworden als in de app store

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q9eqpADJSRs49SoGGr4NAy
2026-08-01 14:20:28 +02:00

268 lines
15 KiB
JavaScript

// Draait de echte Alpine-component uit index.html en toetst de logica van
// het invulmenu. Wordt aangeroepen door tests/test_ui_logica.py; los draaien
// kan met: node tests/js/menu.test.js
const fs = require('fs');
const html = fs.readFileSync(__dirname + '/../../server-up/templates/index.html', 'utf8');
// Alleen de component-functie uitsnijden en uitvoeren.
const start = html.indexOf('function app() {');
const eind = html.indexOf('</script>', start);
const bron = html.slice(start, eind);
global.localStorage = { getItem: () => null, setItem: () => {} };
global.location = { hash: '', search: '' };
global.window = { matchMedia: () => ({matches:false, addEventListener(){}}),
addEventListener(){}, innerWidth: 1200 };
global.document = { documentElement: {classList:{add(){},remove(){}}},
addEventListener(){}, querySelector: () => null };
global.navigator = { clipboard: { writeText: async () => {} }, language: 'nl' };
global.crypto = require('crypto').webcrypto;
eval(bron);
const c = app();
c.t = (k, v) => k; // vertaling niet nodig voor de logica
c.bpFieldVisible = () => true;
c.rpc = async () => ({ok: true, preview_files: {}, env_preview: ''});
c.toast = () => {};
function veld(naam, stap, extra = {}) {
return Object.assign({name: naam, title: naam, step: stap, type: 'str',
group: 'Algemeen', required: false, advanced: false}, extra);
}
let fouten = 0;
const check = (naam, waar) => { if (!waar) { fouten++; console.log(' ✖ ' + naam); }
else console.log(' ✓ ' + naam); };
// ── Eenvoudige app: alleen basisvelden ──────────────────────────────────────
c.install = {format:'boilerplate', loading:false, fields:[
veld('service_name','basis'), veld('port','basis'), veld('appdata_dir','basis'),
], values:{}, step:0};
c.onePage = false; c.showAdvanced = false;
let s = c.wizardSteps().map(x => x.id);
check('eenvoudige app: basis, verbinden, controleren',
JSON.stringify(s) === '["basis","verbinden","controleren"]');
// ── App met geheimen ────────────────────────────────────────────────────────
c.install.fields.push(veld('db_password','geheimen',{required:true, secret:true}));
s = c.wizardSteps().map(x => x.id);
check('geheimenstap verschijnt', s.includes('geheimen'));
// ── Lege stap wordt overgeslagen ────────────────────────────────────────────
check('geen lege instellingenstap', !s.includes('instellingen'));
// ── Geavanceerde velden ─────────────────────────────────────────────────────
c.install.fields.push(veld('retention','instellingen',{advanced:true}));
check('geavanceerd veld verborgen', c.stepFields('instellingen').length === 0);
check('stap zonder zichtbare velden verdwijnt',
!c.wizardSteps().map(x=>x.id).includes('instellingen'));
c.showAdvanced = true;
check('geavanceerd veld zichtbaar na aanzetten', c.stepFields('instellingen').length === 1);
check('stap komt terug', c.wizardSteps().map(x=>x.id).includes('instellingen'));
check('teller klopt', c.advancedCount() === 1);
// ── Verplicht veld blokkeert Volgende ───────────────────────────────────────
c.install.step = c.wizardSteps().findIndex(x => x.id === 'geheimen');
check('leeg verplicht veld wordt gemeld', c.stepMissing().length === 1);
c.install.values.db_password = 'ietsGeheims';
check('ingevuld: niets meer te melden', c.stepMissing().length === 0);
// ── Groepsschakelaar uit: veld telt niet mee ────────────────────────────────
c.install.fields.push(veld('opt_pass','geheimen',
{required:true, secret:true, group:'Extra', group_toggle:'gebruik_extra'}));
c.install.values.gebruik_extra = false;
check('uitgeschakelde groep blokkeert niet', c.stepMissing().length === 0);
c.install.values.gebruik_extra = true;
check('ingeschakelde groep blokkeert wel', c.stepMissing().length === 1);
// ── Navigatie ───────────────────────────────────────────────────────────────
c.install.values.opt_pass = 'x';
c.install.step = 0;
check('niet op de laatste stap', !c.opLaatsteStap());
c.install.step = c.wizardSteps().length - 1;
check('wel op de laatste stap', c.opLaatsteStap());
check('laatste stap is controleren',
c.wizardSteps()[c.install.step].id === 'controleren');
// ── Alles op één pagina ─────────────────────────────────────────────────────
c.onePage = true;
check('wizard uit bij één pagina', !c.wizardActief());
// ── Groepskoppen ────────────────────────────────────────────────────────────
c.onePage = false;
c.install.fields = [veld('a','basis'), veld('b','basis')];
check('één groep: geen kop', c.stepGroups('basis')[0].showHeader === false);
c.install.fields.push(veld('c','basis',{group:'Tweede'}));
check('twee groepen: wel koppen', c.stepGroups('basis').every(g => g.showHeader));
// ── Leesbare namen ──────────────────────────────────────────────────────────
// Het auditlog bewaart ruwe id's; die horen niet in beeld te staan.
c.T = {audit_cat_store: 'App Store', audit_act_reconfigure: 'Opnieuw ingesteld'};
check('categorie wordt vertaald', c.auditNaam('cat', 'store') === 'App Store');
check('actie wordt vertaald', c.auditNaam('act', 'reconfigure') === 'Opnieuw ingesteld');
check('onbekende waarde blijft leesbaar zoals hij is',
c.auditNaam('cat', 'eigenmodule') === 'eigenmodule');
check('lege waarde geeft niets', c.auditNaam('act', '') === '');
c.modules = [{id: 'mijnmodule', name: 'Mijn Module'}];
c.page = 'mod:mijnmodule';
check('modulepagina toont de naam, niet mod:id', c.paginaTitel === 'Mijn Module');
c.page = 'mod:onbekend';
check('onbekende module valt terug op de id zonder voorvoegsel',
c.paginaTitel === 'onbekend');
c.T = {}; c.page = 'stacks';
// ── Menu per stack op de telefoon ───────────────────────────────────────────
const acties = c.stackMenuItems({name: 's', editable: false, connected: true});
check('het menu heeft alle acties', acties.length === 9);
check('elke actie heeft een naam', acties.every(a => a.label && a.label.length));
check('instellingen uit als de stack niet bewerkbaar is',
acties.find(a => a.id === 'settings').uit === true);
check('koppelen uit als hij al gekoppeld is',
acties.find(a => a.id === 'connect').uit === true);
check('verwijderen is als gevaarlijk gemarkeerd',
acties.find(a => a.id === 'remove').gevaar === true);
// ── Vanuit de store naar een geïnstalleerde stack ───────────────────────────
c.stackQuery = 'iets anders'; c.stackCat = 'media'; c.page = 'app_store';
c.goto = (p) => { c.page = p; };
c.gaNaarStack('mijn-sonarr');
check('doorklikken zet de zoekterm', c.stackQuery === 'mijn-sonarr');
check('doorklikken wist het categoriefilter', c.stackCat === '');
check('doorklikken opent de stackspagina', c.page === 'stacks');
// ── Filtermenu van de app store ─────────────────────────────────────────────
// De labelrij was afgekapt op veertien, waardoor je de rest niet kon kiezen. Het
// menu zoekt nu in de volledige lijst.
c.storeStacks = { r: [
{dir:'sonarr', name:'Sonarr', categories:['media'], tags:['media','series']},
{dir:'radarr', name:'Radarr', categories:['media'], tags:['media','films']},
{dir:'loki', name:'Loki', categories:['monitoring'], tags:['logs']},
{dir:'zeldzaam',name:'Zeldzaam',categories:['overig'], tags:['heelzeldzaam']},
]};
c.categories = [
{id:'media', name:'Media', color:'#8b5cf6', icon:'mdi-play'},
{id:'monitoring', name:'Monitoring', color:'#f97316', icon:'mdi-chart-line'},
{id:'overig', name:'Overig', color:'#94a3b8', icon:'mdi-shape'},
];
c.storeQuery = ''; c.storeCat = ''; c.storeTag = ''; c.filterTagQuery = '';
check('geen filter: alles telt mee', c.storeResultaat === 4);
check('geen actieve filters', c.filterCount === 0);
check('labels hebben een aantal',
c.storeTags[0].tag === 'media' && c.storeTags[0].count === 2);
check('alle labels blijven beschikbaar', c.storeTags.length === 5);
c.storeCat = 'media';
check('categorie filtert', c.storeResultaat === 2);
check('teller telt de categorie', c.filterCount === 1);
c.storeTag = 'films';
check('categorie en label samen', c.storeResultaat === 1);
check('teller telt allebei', c.filterCount === 2);
c.wisFilters();
check('wissen zet alles terug', c.storeResultaat === 4 && c.filterCount === 0);
c.filterTagQuery = 'zeld';
check('zoeken vindt een zeldzaam label',
c.zichtbareTags.length === 1 && c.zichtbareTags[0].tag === 'heelzeldzaam');
check('bij zoeken niets verborgen', c.verborgenTags === 0);
c.filterTagQuery = 'bestaatniet';
check('geen treffers geeft een lege lijst', c.zichtbareTags.length === 0);
// Een gekozen label mag niet uit beeld vallen omdat het zeldzaam is.
c.filterTagQuery = '';
c.storeTag = 'heelzeldzaam';
check('gekozen label blijft zichtbaar',
c.zichtbareTags.some(t => t.tag === 'heelzeldzaam'));
// ── Afhankelijkheden ────────────────────────────────────────────────────────
// De melding hoort alleen te verschijnen voor wat er níét draait, en te
// verdwijnen zodra het er wel is.
c.install = {format:'boilerplate', loading:false, fields:[], values:{}, step:0,
depends_on: [
{app:'mosquitto', reason:'MQTT-broker', installed:false},
{app:'loki', reason:'logopslag', installed:true, stacks:['loki']},
]};
check('alleen het ontbrekende wordt gemeld', c.ontbrekendeAfhankelijkheden().length === 1);
check('en dat is mosquitto', c.ontbrekendeAfhankelijkheden()[0].app === 'mosquitto');
c.install.depends_on[0].installed = true;
check('niets meer te melden zodra alles draait',
c.ontbrekendeAfhankelijkheden().length === 0);
c.install.depends_on = [];
check('een app zonder afhankelijkheden meldt niets',
c.ontbrekendeAfhankelijkheden().length === 0);
// ── Sleutelformaten ─────────────────────────────────────────────────────────
// Een willekeurige string is niet genoeg: Laravel wil `base64:` ervoor, Homarr
// en LibreChat willen hexadecimaal van een vaste lengte. In het verkeerde
// formaat start de container niet, met een foutmelding die nergens naar de
// sleutel wijst.
const standaard = c.veldSecret({});
check('standaard is url-veilig', /^[A-Za-z0-9_-]+$/.test(standaard));
check('standaard is 32 tekens', standaard.length === 32);
const hex = c.veldSecret({secret_format: 'hex', secret_bytes: 32});
check('hex is 64 tekens', hex.length === 64);
check('hex bevat alleen hexcijfers', /^[0-9a-f]{64}$/.test(hex));
check('hex van 16 bytes is 32 tekens',
c.veldSecret({secret_format: 'hex', secret_bytes: 16}).length === 32);
const lar = c.veldSecret({secret_format: 'laravel', secret_bytes: 32});
check('laravel begint met base64:', lar.startsWith('base64:'));
check('laravel is standaard-base64 van 32 bytes',
Buffer.from(lar.slice(7), 'base64').length === 32);
check('twee sleutels zijn niet gelijk', c.veldSecret({}) !== c.veldSecret({}));
// ── Stap Onderdelen ─────────────────────────────────────────────────────────
// Een stack als de ARR-stack heeft vijftig schakelbare onderdelen. Die horen in
// één eigen stap, gegroepeerd per kopje — en nergens anders nóg een keer.
function schakelaar(naam, sectie, aan = false) {
return veld('enable_' + naam, 'onderdelen', {
type: 'bool', is_group_toggle: true, group_toggle: 'enable_' + naam,
group: naam, section: sectie, default: aan});
}
c.install = {format:'boilerplate', loading:false, values:{}, step:0, ips:{}, services:[],
fields:[
veld('service_name','basis'),
schakelaar('sonarr','Bibliotheken'), schakelaar('radarr','Bibliotheken'),
schakelaar('prowlarr','Indexers'),
veld('port_sonarr','instellingen',{type:'int', group:'Sonarr',
group_toggle:'enable_sonarr', advanced:true}),
veld('port_radarr','instellingen',{type:'int', group:'Radarr',
group_toggle:'enable_radarr', advanced:true}),
]};
c.showAdvanced = true;
s = c.wizardSteps().map(x => x.id);
check('onderdelen is de eerste stap', s[0] === 'onderdelen');
check('drie schakelaars in de stap', c.stepFields('onderdelen').length === 3);
const secties = c.stepSections('onderdelen');
check('twee kopjes', secties.length === 2);
check('kopjes kloppen',
secties.map(x => x.name).join() === 'Bibliotheken,Indexers');
check('nog niets gekozen', c.gekozenOnderdelen() === 0);
c.zetSectie(secties[0], true);
check('sectie in één keer aan', c.gekozenOnderdelen() === 2);
c.zetSectie(secties[0], false);
check('en weer uit', c.gekozenOnderdelen() === 0);
// De schakelaar hoorde nergens anders op te duiken. Stond hij ook in de kop van
// zijn eigen groep, dan zag je hem twee keer in twee verschillende stappen.
c.install.values.enable_sonarr = true;
const groepen = c.stepGroups('instellingen');
check('alleen ingeschakelde onderdelen krijgen een kaart', groepen.length === 1);
check('kaart is van Sonarr', groepen[0].name === 'Sonarr');
check('geen tweede schakelaar in de groepskop', groepen[0].toggle === null);
// ── Eigen IP per container ──────────────────────────────────────────────────
check('zonder voorvertoning geen containers', c.ipContainers().length === 0);
c.install.services = ['arr-sonarr', 'arr-radarr'];
check('containers komen uit de voorvertoning', c.ipContainers().length === 2);
c.zetContainerIp('arr-sonarr', true);
check('container aangezet', 'arr-sonarr' in c.install.ips);
check('andere container blijft op de host', !('arr-radarr' in c.install.ips));
c.install.ips['arr-sonarr'] = '192.168.1.240';
c.zetContainerIp('arr-sonarr', false);
check('en weer uitgezet', !('arr-sonarr' in c.install.ips));
console.log(fouten === 0 ? '\nAlle menucontroles geslaagd' : `\n${fouten} FOUTEN`);
process.exit(fouten ? 1 : 0);