server-up/tests/js/menu.test.js
Ramon ef945fcd71
All checks were successful
Deploy server-up (dev) / deploy (push) Successful in 15m55s
v0.8.80-beta - Poortcontrole in het formulier, en installeren op een telefoon
- /api/ports/check bestond al en kijkt naar wat Docker publiceert en naar wat
  er rechtstreeks op de host luistert, maar het installatieformulier riep hem
  nergens aan. Er stond hooguit een regeltje als de standaardwaarde bezet was;
  typte je zelf iets, dan hoorde je niets
- Een poortveld heeft nu een min- en plusknop, een vergrootglas om te
  controleren, en een melding: vrij, of bezet en door welke container, met een
  knop om naar de eerstvolgende vrije poort te springen. De controle loopt ook
  bij het openen van een stap
- Kan de host niet uitgelezen worden, dan zegt de melding dat "vrij" een
  aanname is in plaats van dat te verzwijgen
- De knoppen zitten alleen op velden die ook echt een poort zijn; puid en pgid
  zijn ook getallen
- De installatiemodal is op een telefoon schermvullend, met de kop en de
  knoppenbalk vast en alleen de inhoud die scrollt. Vorige en Volgende vullen
  samen de breedte
- Sluitknop in de kop van de modal: schermvullend betekent geen achtergrond meer
  om op te tikken, en die was er nog niet

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q9eqpADJSRs49SoGGr4NAy
2026-08-02 07:57:05 +02:00

352 lines
19 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();
// De echte t() vult {plaatshouders} in. Dat nabootsen scheelt niet alleen
// realisme: zonder invulling kun je niet toetsen of de containernaam in de
// melding belandt.
c.t = (k, v) => v ? k + ':' + Object.values(v).join(',') : k;
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));
// ── Poortveld ───────────────────────────────────────────────────────────────
// De backend kon al controleren of een poort vrij is, maar het formulier vroeg
// het nooit. En je kon alleen typen — geen manier om er snel eentje bij te doen.
check('een poortveld herkennen', c.isPoortVeld({name: 'port'})
&& c.isPoortVeld({name: 'port_ui'}) && c.isPoortVeld({name: 'poort'}));
check('puid is geen poort', !c.isPoortVeld({name: 'puid'})
&& !c.isPoortVeld({name: 'pgid'}));
c.install = {format:'boilerplate', loading:false, fields:[], values:{port: 8080}, step:0};
c.poortCheck = {};
let gevraagd = [];
c.rpc = async (u) => {
gevraagd.push(u);
const p = Number(new URL('http://x' + u).searchParams.get('port'));
return {ok:true, port:p, free:p !== 8080, suggested:8081,
used_by:p === 8080 ? 'nginx' : '', host_checked:true};
};
c.stapPoort({name:'port'}, 1);
check('plus verhoogt', c.install.values.port === 8081);
c.stapPoort({name:'port'}, -1);
check('min verlaagt', c.install.values.port === 8080);
c.install.values.port = 1;
c.stapPoort({name:'port'}, -1);
check('niet onder 1', c.install.values.port === 1);
c.install.values.port = 65535;
c.stapPoort({name:'port'}, 1);
check('niet boven 65535', c.install.values.port === 65535);
// ── 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);
// De cache: dezelfde filterstand hoort niet opnieuw gerekend te worden. Dit
// werd per repo drie keer per tick aangeroepen, met een toLowerCase() per veld
// per app — bij elke toetsaanslag.
const eerste = c.filteredStore('r');
check('zelfde filterstand geeft hetzelfde resultaat', c.filteredStore('r') === eerste);
c.storeQuery = 'son';
check('andere zoekterm geeft een nieuwe lijst', c.filteredStore('r') !== eerste);
check('en die klopt', c.filteredStore('r').length === 1);
c.storeQuery = '';
// De limiet: niet alles tegelijk in de DOM.
c.storeLimiet = 2;
check('niet meer dan de limiet', c.zichtbareStore('r').length === 2);
check('de rest wacht achter de knop', c.storeVerborgen === 2);
c.meerTonen();
check('meer tonen verhoogt de limiet', c.storeLimiet === 62);
check('en dan staat alles er', c.zichtbareStore('r').length === 4);
check('niets meer verborgen', c.storeVerborgen === 0);
c.storeLimiet = 2;
c.wisFilters();
check('filters wissen zet de limiet terug', c.storeLimiet === 60);
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));
// De poortcontrole praat met de backend, dus die checks zijn asynchroon.
// Top-level await kan niet in een CommonJS-script; vandaar deze afsluiting.
(async () => {
// Een leeg of onzinnig veld hoort geen verzoek te sturen.
c.install.values.port = 0;
gevraagd = [];
await c.checkPoort({name:'port'});
check('geen controle bij een lege poort', gevraagd.length === 0);
check('en geen status', !c.poortCheck.port);
c.install.values.port = 8080;
await c.checkPoort({name:'port'});
check('bezette poort wordt gemeld', c.poortCheck.port.free === false);
check('met de container erbij',
c.poortStatus({name:'port'}).includes('nginx'));
check('en een voorstel', c.poortCheck.port.suggested === 8081);
c.install.values.port = 8081;
await c.checkPoort({name:'port'});
check('vrije poort wordt gemeld', c.poortCheck.port.free === true);
// Zonder de hulpcontainer is "vrij" een aanname; dat hoort de melding te zeggen.
c.rpc = async () => ({ok:true, port:8081, free:true, suggested:8081,
used_by:'', host_checked:false});
await c.checkPoort({name:'port'});
check('onzekerheid wordt benoemd',
c.poortStatus({name:'port'}) !== c.t('port_free'));
console.log(fouten === 0 ? '\nAlle menucontroles geslaagd' : `\n${fouten} FOUTEN`);
process.exit(fouten ? 1 : 0);
})();