All checks were successful
Deploy server-up (dev) / deploy (push) Successful in 18m31s
- overnemen: een compose-app die buiten Server Up om draait, met een knop erbij trekken. Er wordt niets verplaatst of gekopieerd; de stackmap wordt een verwijzing naar de map die de gebruiker al heeft. Kopieren zou de relatieve paden in zijn compose-bestand breken - kan Server Up niet bij die map (hij ziet alleen wat via BASE_DIR gekoppeld is), dan zegt hij dat met waar de map moet staan - verwijderen van een container die niet bij een app hoort, met in de vraag dat alleen de container weggaat en niet zijn gegevens of image - hoort hij bij een compose-project, dan staat erbij dat hij terugkomt zodra dat project ergens anders start en dat overnemen waarschijnlijk is wat je zoekt. Daarom staat overnemen links van verwijderen - een overgenomen app verwijderen haalt alleen de verwijzing weg; rmtree zou daar bovendien op weigeren en halverwege stranden Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q9eqpADJSRs49SoGGr4NAy
546 lines
28 KiB
JavaScript
546 lines
28 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.
|
|
// 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);
|
|
|
|
// ── Instellingen opslaan ──────────────────────────────────────────────────
|
|
// De uitkomst werd weggegooid: een geweigerde opslag meldde zich als
|
|
// "opgeslagen". En na een padwijziging werd niets herladen, dus je zette je
|
|
// paden goed en zag hetzelfde lege scherm.
|
|
let herladen = 0;
|
|
let getoast = '';
|
|
c.loadStacks = async () => { herladen++; };
|
|
c.loadHostContainers = async () => { herladen++; };
|
|
c.toast = (m) => { getoast = m; };
|
|
c.settings = {LIBRARY_DIR: '/opt/serverup/stacks', DATA_DIR: '/opt/a', BACKUP_DIR: '/opt/b'};
|
|
c._padStand = ['LIBRARY_DIR','DATA_DIR','BACKUP_DIR'].map(k => c.settings[k]).join('|');
|
|
|
|
c.req = async () => ({ok: false, msg: 'Server Up kan niet bij /srv/x'});
|
|
c.settings.LIBRARY_DIR = '/srv/x';
|
|
check('een geweigerde opslag geeft false terug', (await c.saveSettings()) === false);
|
|
check('en meldt waarom', getoast.includes('/srv/x'));
|
|
check('en herlaadt niets', herladen === 0);
|
|
check('en zegt niet dat het opgeslagen is', c.saved !== true);
|
|
|
|
c.req = async () => ({ok: true});
|
|
check('een geslaagde opslag geeft true terug', (await c.saveSettings()) === true);
|
|
check('en haalt de lijst opnieuw op', herladen === 2);
|
|
|
|
herladen = 0;
|
|
check('nog een keer opslaan zonder padwijziging lukt', (await c.saveSettings()) === true);
|
|
check('en herlaadt dan niet opnieuw', herladen === 0);
|
|
|
|
// ── Overnemen en verwijderen ──────────────────────────────────────────────
|
|
// Een container van een compose-project komt na het verwijderen gewoon terug.
|
|
// Dat hoort in de vraag te staan, niet achteraf te blijken.
|
|
let gevraagd2 = '', verstuurd2 = null;
|
|
global.confirm = (v) => { gevraagd2 = v; return true; };
|
|
c.req = async (url, opt) => { verstuurd2 = url + ' ' + (opt || {}).method; return {ok: true, msg: 'gelukt'}; };
|
|
c.loadStacks = async () => {}; c.loadHostContainers = async () => {};
|
|
c.toast = () => {};
|
|
|
|
await c.verwijderContainer({name: 'oude-nginx', project: 'nginx-proj'});
|
|
check('de waarschuwing over het project staat in de vraag',
|
|
gevraagd2.includes('remove_container_project'));
|
|
check('en de container wordt verwijderd',
|
|
verstuurd2 === '/api/containers/oude-nginx/remove POST');
|
|
|
|
gevraagd2 = '';
|
|
await c.verwijderContainer({name: 'los-ding', project: ''});
|
|
check('zonder project geen projectwaarschuwing',
|
|
!gevraagd2.includes('remove_container_project'));
|
|
|
|
await c.overnemen({name: 'homepage', project: 'homepage'});
|
|
check('overnemen roept het juiste eindpunt aan',
|
|
verstuurd2 === '/api/containers/homepage/adopt POST');
|
|
|
|
// Afbreken bij de bevestiging hoort niets te doen.
|
|
verstuurd2 = null;
|
|
global.confirm = () => false;
|
|
await c.verwijderContainer({name: 'oude-nginx', project: ''});
|
|
await c.overnemen({name: 'homepage', project: 'homepage'});
|
|
check('afbreken doet niets', verstuurd2 === null);
|
|
|
|
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);
|
|
})();
|