All checks were successful
Deploy server-up (dev) / deploy (push) Successful in 17m15s
- filteredStore() werd per repo drie keer per reactieve tick aangeroepen (in de x-show, de x-for en de teller in het filtermenu) en liep elke keer alle apps langs met een toLowerCase() per veld. Bij elke toetsaanslag. De catalogus ging deze reeks van 142 naar 286 apps en dat begon te merken; het filteren gebeurt nu een keer per filterstand - Nog maar zestig kaarten tegelijk in de DOM, met een knop Nog N tonen eronder. De limiet valt terug zodra je het filter of de zoekterm wijzigt - Het zoekveld wacht 200 ms voordat het filtert - 28 apps in de dunste hoeken: 2FAuth, Passbolt, OpenBao, Trivy, Verdaccio, Docker Registry, MkDocs Material, LiteLLM, Flowise, Speaches, Pelican Panel, Gameyfin, EmulatorJS, Mainsail, Fluidd, Kanboard, Wekan, Filestash, Linkwarden, Slash, RSSHub, Apprise, Mailpit, Dashy, Plausible, NetBird, LibreSpeed en PinePods - Alle 318 images opnieuw tegen hun registry gecontroleerd Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q9eqpADJSRs49SoGGr4NAy
291 lines
16 KiB
JavaScript
291 lines
16 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);
|
|
|
|
// 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));
|
|
|
|
console.log(fouten === 0 ? '\nAlle menucontroles geslaagd' : `\n${fouten} FOUTEN`);
|
|
process.exit(fouten ? 1 : 0);
|