All checks were successful
Deploy server-up (dev) / deploy (push) Successful in 13m11s
- Twintig categorieknoppen en veertien labelknoppen stonden altijd uitgeklapt boven de store; bij 256 apps vulde dat een half scherm. Nu een knop Filter met een popover: categorieen in een raster van twee kolommen met kleur en aantal, labels met een eigen zoekveld, en onderaan het aantal resultaten met een wisknop. Sluit met Escape of door ernaast te klikken - De labelfilter werkte maar half: de rij was afgekapt op veertien terwijl er 297 labels zijn, dus 283 daarvan waren niet te kiezen. Het menu zoekt nu in de volledige lijst; zonder zoekterm de veertig meest gebruikte, en een gekozen label blijft zichtbaar ook als het zeldzaam is - Actieve filters staan als chips naast de knop, met een kruisje per stuk. Het zoekveld heeft een wisknop - Nieuwe test die het hele sjabloon op gebalanceerde HTML-tags controleert. Een verkeerd geplaatste </div> schuift de rest van de pagina in een andere tak en dat merk je anders pas in de browser - Nieuwe JS-tests voor de filterlogica: filteren op categorie en label, de teller, wissen, en zoeken naar een zeldzaam label Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q9eqpADJSRs49SoGGr4NAy
232 lines
13 KiB
JavaScript
232 lines
13 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));
|
|
|
|
// ── 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);
|