// 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); })();