// 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('', 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. // De teller volgt de repolijst, want die bepaalt ook welke kaarten er komen. c.repos = [{id:'r', name:'Proefrepo'}]; 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)); // ── Mappen ────────────────────────────────────────────────────────────────── // De poortcontroles hieronder hebben hun eigen antwoord nodig; even bewaren. const poortRpc = c.rpc; c.settings = { LIBRARY_DIR: '/opt/serverup/stacks', DATA_DIR: '/opt/serverup/appdata', BACKUP_DIR: '/opt/serverup/backups' }; c.padenVoorWijziging = Object.assign({}, c.settings); c.autoSave = () => {}; check('niets gewijzigd, niets te verplaatsen', c.teVerplaatsen.length === 0); c.afgeleidePaden = { LIBRARY_DIR: '/srv/serverup/stacks', DATA_DIR: '/srv/serverup/appdata', BACKUP_DIR: '/srv/serverup/backups' }; c.neemAllesOver(); check('alles overnemen vult de drie velden', c.settings.DATA_DIR === '/srv/serverup/appdata'); check('en meldt drie verhuizingen', c.teVerplaatsen.length === 3); check('met oud én nieuw erbij', c.teVerplaatsen[0].oud === '/opt/serverup/stacks' && c.teVerplaatsen[0].nieuw === '/srv/serverup/stacks'); // De voortgangsbalk leest rechtstreeks uit de job; zonder voortgang blijft hij weg. c.job = { status: 'running', lines: [], progress: null }; check('geen balk zonder voortgang', !c.job.progress); c.job.progress = { done: 512, total: 1024, label: 'sonarr/config.xml', pct: 50 }; check('balk staat halverwege', c.job.progress.pct === 50); // De poortcontrole praat met de backend, dus die checks zijn asynchroon. // Top-level await kan niet in een CommonJS-script; vandaar deze afsluiting. (async () => { // ── De hoofdmap ─────────────────────────────────────────────────────────── let gevraagdPad = null; c.rpc = async (url) => { gevraagdPad = url; return { ok: true, afgeleid: { LIBRARY_DIR: '/srv/su/stacks', DATA_DIR: '/srv/su/appdata', BACKUP_DIR: '/srv/su/backups' } }; }; c.hoofdmap = 'srv/su'; await c.checkHoofdmap(); check('een relatief pad gaat niet naar de server', gevraagdPad === null); check('en levert geen voorstellen op', Object.keys(c.afgeleidePaden).length === 0); c.hoofdmap = '/srv/su'; await c.checkHoofdmap(); check('een volledig pad levert drie submappen op', c.afgeleidePaden.DATA_DIR === '/srv/su/appdata'); check('zonder klacht', !c.padCheck.reden); // Buiten de gemounte map schrijft de container naar zijn eigen overlay; dat // moet je zien staan vóór je verhuist, niet erna. c.rpc = async () => ({ ok: false, reden: 'buiten_mount', afgeleid: {}, msg: 'Zet BASE_DIR=/srv in je .env' }); await c.checkHoofdmap(); check('een onbereikbaar pad wordt gemeld', c.padCheck.reden === 'buiten_mount'); check('met de regel voor .env erbij', c.padCheck.msg.includes('BASE_DIR=/srv')); // Verhuizen zonder wijziging hoort geen job te starten. let verstuurd = null; global.confirm = () => true; c.rpc = async (url, body) => { verstuurd = body; return { job_id: 'x1' }; }; c.pollJob = () => {}; c.padenVoorWijziging = Object.assign({}, c.settings); await c.verplaatsPaden(); check('geen verhuizing zonder wijziging', verstuurd === null); c.settings.DATA_DIR = '/srv/su/appdata'; await c.verplaatsPaden(); check('alleen de gewijzigde map wordt verstuurd', verstuurd && Object.keys(verstuurd).join() === 'DATA_DIR'); check('met het nieuwe pad', verstuurd.DATA_DIR === '/srv/su/appdata'); // Afbreken bij de bevestiging hoort niets te doen: dit gooit data weg. verstuurd = null; global.confirm = () => false; await c.verplaatsPaden(); check('afbreken bij de bevestiging start niets', verstuurd === null); // ── De store telt wat hij ook laat zien ─────────────────────────────────── // Het raster liep over `repos`, de teller over `storeStacks`. Liepen die twee // uit elkaar, dan stond er "Nog 106 tonen" onder een leeg scherm — zonder dat // er ergens een fout te zien was. const nepApps = n => Array.from({length: n}, (_, i) => ({dir: 'a' + i, name: 'App' + i, categories: [], tags: []})); const kaarten = () => c.repos.reduce((n, r) => n + c.zichtbareStore(r.id).length, 0); c._storeCache = {sleutel: null, lijsten: {}}; c.storeQuery = ''; c.storeCat = ''; c.storeTag = ''; c.storeLimiet = 60; c.repos = [{id: 'server-up', name: 'Server Up'}]; c.storeStacks = {'server-up': nepApps(106)}; check('de eerste zestig staan in beeld', kaarten() === 60); check('en de rest wacht achter de knop', c.storeVerborgen === 46); c.meerTonen(); c._storeCache = {sleutel: null, lijsten: {}}; check('meer tonen laat de rest zien', kaarten() === 106); check('en de knop verdwijnt', c.storeVerborgen === 0); // Zonder repo's is er niets te tonen — en dus ook niets te beloven. c._storeCache = {sleutel: null, lijsten: {}}; c.storeLimiet = 60; c.repos = []; check('geen repo\'s: geen kaarten', kaarten() === 0); check('en geen knop die apps belooft', c.storeVerborgen === 0); check('de teller is ook nul', c.storeResultaat === 0); c.rpc = poortRpc; // ── Eén lijst voor beheerde en handmatig gestarte apps ──────────────────── // Ze stonden in twee lijsten onder elkaar, waarvan de tweede een kop, een // uitleg en een eigen vormgeving had. Wie zijn apps kwijt was, moest weten // dat er een tweede lijst bestond. c.stacks = [{name: 'sonarr', categories: ['media']}, {name: 'vaultwarden', categories: ['tools']}]; c.hostContainers = {containers: [ {id: '1', name: 'oude-nginx', image: 'nginx:alpine', managed: false}, {id: '2', name: 'sonarr', image: 'lscr.io/sonarr', managed: true}, ]}; c.stackQuery = ''; c.stackCat = ''; check('beheerde apps staan in de lijst', c.filteredStacks.length === 2); check('handmatig gestarte apps ook', c.filteredLos.length === 1); check('en niet dubbel', c.filteredLos[0].name === 'oude-nginx'); c.stackQuery = 'nginx'; check('de zoekterm geldt voor allebei', c.filteredStacks.length === 0 && c.filteredLos.length === 1); c.stackQuery = 'sonarr'; check('een beheerde app blijft over', c.filteredStacks.length === 1); check('en de handmatige valt af', c.filteredLos.length === 0); c.stackQuery = 'lscr'; check('zoeken kan ook op image', c.filteredLos.length === 0); c.stackQuery = 'alpine'; check('van een handmatig gestarte app', c.filteredLos.length === 1); // Een categoriefilter hoort ze te laten vallen: ze hébben geen categorie. c.stackQuery = ''; c.stackCat = 'media'; check('categoriefilter laat handmatige apps vallen', c.filteredLos.length === 0); check('en houdt de beheerde over', c.filteredStacks.length === 1); // 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); })();