// Rendert de echte installatiemodal en appkaart met Alpine in een DOM en klikt
// de bedieningen daadwerkelijk aan. Vangt bindingsfouten die je met losse
// logica niet ziet: een ontbrekende eigenschap in een x-for-item liet hier het
// boolean-attribuut disabled staan. Alleen de gerenderde DOM laat vervolgens
// zien dat de knop geen klikevent meer ontvangt.
const fs = require('fs'), path = require('path');
// jsdom en Alpine zijn geen onderdeel van dit project; zonder die twee slaat
// deze test zichzelf over (afsluitcode 2). Installeren kan met:
// cd tests/js && npm install jsdom alpinejs
let JSDOM, alpinePad;
try {
({ JSDOM } = require('jsdom'));
alpinePad = require.resolve('alpinejs/dist/cdn.js');
} catch (e) {
console.log('overgeslagen: jsdom of alpinejs ontbreekt (npm install in tests/js)');
process.exit(2);
}
const WORTEL = path.resolve(__dirname, '..', '..');
const html = fs.readFileSync(WORTEL + '/server-up/templates/index.html', 'utf8');
const dom = new JSDOM('
',
{ runScripts: 'dangerously', pretendToBeVisual: true, url: 'http://localhost/' });
const w = dom.window;
w.fetch = async () => ({ ok: true, json: async () => ({ ok: true }) });
const start = html.indexOf('function app() {');
w.eval(html.slice(start, html.indexOf('', start)));
// De component vóór Alpine klaarzetten: erna is het een proxy die dit weigert.
const echt = w.app;
w.app = function () {
const c = echt();
c.t = k => k;
c.bpFieldVisible = () => true;
c.toast = () => {};
c.rpc = async () => ({ ok: true, preview_files: { 'compose.yaml': 'services: {}' }, env_preview: '' });
c.connectTargets = () => [];
c.pickIp = () => {};
c.netList = [];
c.sharedNetwork = 'serverup';
c.page = 'stacks';
c.stacks = [{
name: 'mijn-app', has_compose: true, running: true, containers: [],
categories: [], depends_missing: [], editable: true, connected: false
}];
c.modal = 'install';
c.install = {
format: 'boilerplate', loading: false, stack: 'test', repo_id: 'r', instance: 'test',
values: { service_name: 'test', port: 8080 },
fields: [
{name:'service_name',title:'Naam',step:'basis',type:'str',group:'Algemeen',required:true,advanced:false},
{name:'port',title:'Poort',step:'basis',type:'int',group:'Algemeen',required:true,advanced:false},
{name:'db_password',title:'Wachtwoord',step:'geheimen',type:'str',group:'Algemeen',required:true,secret:true,advanced:false},
],
step: 0, preview_files: {}, composeText: '', envText: '', editing: false, tab: 'compose',
mode: 'install', connect_apps: true, network: '', ip: '', envVars: [], images: [],
envOverrides: {}, imageTags: {},
};
return c;
};
const m = html.match(/\n/);
const rm = html.match(/[\s\S]*?/);
const stacks = html.match(/
/);
w.document.body.innerHTML = `${stacks[0]}${m[0]}${rm[0]}
`;
w.eval(fs.readFileSync(alpinePad, 'utf8'));
const wacht = ms => new Promise(r => setTimeout(r, ms));
(async () => {
await wacht(100);
const comp = w.Alpine.$data(w.document.querySelector('[x-data]'));
const knoppen = [...w.document.querySelectorAll('.modal-foot button')];
console.log('knoppen in de voet:');
knoppen.forEach(b => console.log(
` "${b.textContent.trim().slice(0,14)}" zichtbaar=${b.style.display !== 'none'} disabled=${b.disabled}`));
let fouten = 0;
const check = (naam, waar) => { console.log((waar ? ' ✓ ' : ' ✖ ') + naam); if (!waar) fouten++; };
check('velden worden gerenderd', w.document.querySelectorAll('.modal-body input').length > 0);
check('niets ontbreekt op stap 1', comp.stepMissing().length === 0);
const volgende = knoppen.find(b => b.textContent.includes('next'));
check('Volgende staat in de DOM', !!volgende);
if (!volgende) process.exit(1);
// Alpine haalt een boolean-attribuut alleen weg bij null, undefined of false.
// Een lege lijst gaf dus .length === 0, en dat zette disabled juist AAN.
check('Volgende is niet uitgeschakeld met alles ingevuld', volgende.disabled === false);
volgende.click();
await wacht(120);
check('klikken gaat naar de volgende stap', comp.install.step === 1);
// Leeg verplicht veld hoort wél te blokkeren.
comp.install.step = 2;
await wacht(60);
const geheimen = comp.wizardSteps()[2];
check('stap 3 is de geheimenstap', geheimen && geheimen.id === 'geheimen');
check('leeg wachtwoord blokkeert', comp.stepMissing().length === 1);
await wacht(60);
check('knop is dan uitgeschakeld', volgende.disabled === true);
comp.install.values.db_password = 'ietsGeheims';
await wacht(80);
check('knop weer bruikbaar na invullen', volgende.disabled === false);
// De verwijderknop testen we via de echte Alpine @click-binding. De losse
// menu-test roept doRemove() direct aan en mist fouten in de DOM.
let verwijderVerzoek = null;
let verwijderd = false;
comp.rpc = async (url, body) => {
verwijderVerzoek = {url, body};
return {job_id: 'remove-1'};
};
comp.pollJob = (id, klaar) => klaar('done');
comp.loadStacks = async () => { verwijderd = true; };
comp.toast = () => {};
const kaartknop = [...w.document.querySelectorAll('button')].find(
b => b.getAttribute('@click') === 'confirmRemove(s.name)');
check('verwijderknop op de appkaart is gerenderd', !!kaartknop);
if (!kaartknop) {
console.log('stacks:', JSON.stringify(comp.stacks));
console.log('filtered:', JSON.stringify(comp.filteredStacks));
console.log('click-bindings:', [...w.document.querySelectorAll('button')]
.map(b => b.getAttribute('@click')).filter(Boolean).join(' | '));
process.exit(1);
}
kaartknop.click();
await wacht(60);
const verwijderModal = [...w.document.querySelectorAll('.modal')].find(
e => e.getAttribute('x-show') === "modal==='remove'");
check('verwijdermodal opent met de juiste app',
comp.modal === 'remove' && comp.removeTarget === 'mijn-app');
const veilige = [...verwijderModal.querySelectorAll('button')].find(
b => b.getAttribute('@click') === "doRemove('full')");
check('verwijderknop is actief', veilige && !veilige.disabled);
veilige.click();
await wacht(80);
check('klik verstuurt de verwijderaanvraag', verwijderVerzoek &&
verwijderVerzoek.url === '/api/stacks/mijn-app/remove' &&
verwijderVerzoek.body.mode === 'full');
check('voltooide verwijdering herlaadt de apps', verwijderd);
// Op een telefoon loopt verwijderen via een functie in het x-for-actiemenu.
comp.modal = null;
comp.removeTarget = '';
const menuActies = [...w.document.querySelectorAll('button')].filter(
b => b.getAttribute('@click') === "a.doe(); stackMenu = ''");
comp.stackMenu = 'mijn-app';
await wacht(40);
const mobielVerwijderen = menuActies.find(
b => (w.Alpine.$data(b).a || {}).id === 'remove');
check('mobiele verwijderactie is gerenderd', !!mobielVerwijderen);
if (!mobielVerwijderen) process.exit(1);
check('mobiele verwijderactie is actief',
mobielVerwijderen && !mobielVerwijderen.disabled);
mobielVerwijderen.click();
await wacht(60);
if (comp.modal !== 'remove')
console.log('mobiele status:', comp.modal, comp.removeTarget, comp.stackMenu);
check('mobiele verwijderactie opent de modal',
comp.modal === 'remove' && comp.removeTarget === 'mijn-app');
console.log(fouten === 0 ? '\nAlle DOM-controles geslaagd' : `\n${fouten} FOUTEN`);
process.exit(fouten ? 1 : 0);
})();