server-up/tests/js/modal.dom.test.js
Ramon ea9b1eb668
All checks were successful
Deploy server-up (dev) / deploy (push) Successful in 2m10s
v0.7.80-beta - Volgende-knop, tijdzones, netwerken per groepje, Pangolin
De knop Volgende deed niets. Alpine haalt een boolean-attribuut alleen weg bij
null, undefined of false; het getal 0 zet het juist AAN. Mijn
:disabled="stepMissing().length" gaf bij een ingevuld formulier 0 en zette de
knop dus permanent uit. De logica klopte, de binding niet — met losse logica
niet te vinden, dus er draait nu een test die de echte modal met Alpine in een
DOM rendert en erop klikt.

Tijdzones:
- Nieuwe instelling TIMEZONE, standaard die van de server (TZ, /etc/timezone,
  /etc/localtime). In alle 77 sjablonen stond Europe/Amsterdam hard ingevuld.
- Apps zonder tijdzoneveld krijgen TZ er automatisch bij; die draaiden in UTC.

Poorten:
- _bezette_poorten() telt nu ook mee waar de host zelf op luistert, gelezen uit
  /proc/net/tcp in een hulpcontainer met --network host. Een nginx buiten
  Docker zag Server Up eerder niet.
- GET /api/ports/check geeft vrij/bezet plus een alternatief, en meldt of de
  host echt gecontroleerd kon worden.

Netwerken:
- APP_NETWORKS: meerdere gedeelde bridge-netwerken in plaats van één waar élke
  app aan hing. Per app kies je welke; aanmaken kan vanuit het invulmenu, met
  eigen naam of een voorstel (su-<appnaam>).
- add_shared_network() accepteert een lijst; één naam blijft werken.
- GET /api/networks/<n>/ip-check controleert subnet, bereik, Docker-toewijzing
  en de ARP-tabel van de host (vangt een fysiek apparaat met DHCP-adres) en
  stelt een vrij adres voor.

Pangolin (nieuw, core/pangolin.py):
- Publieke URL per app via een tunnel, zonder poorten open te zetten.
- De API veranderde in 1.9 (resource hing onder een site, staat nu los); beide
  routes worden geprobeerd en bij een fout krijg je beide meldingen.
- API-sleutel is write-only. Getest tegen een nagebouwde API, niet tegen een
  echte server.

Nieuw: docs/pangolin.md; docs/netwerken.md uitgebreid. 2369 tests groen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C7oLCRYzY5ixJ5Sv8Y8EFb
2026-07-28 22:25:38 +02:00

101 lines
4.4 KiB
JavaScript

// Rendert de echte install-modal met Alpine in een DOM en klikt erop.
// Vangt bindingsfouten die je met losse logica niet ziet: :disabled="0"
// schakelt een knop juist UIT, want Alpine haalt een boolean-attribuut
// alleen weg bij null, undefined of false.
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('<!doctype html><html><body></body></html>',
{ 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('</script>', 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.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(/<div x-show="modal==='install'"[\s\S]*?\n <\/div>\n/);
w.document.body.innerHTML = `<div x-data="app()">${m[0]}</div>`;
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);
console.log(fouten === 0 ? '\nAlle DOM-controles geslaagd' : `\n${fouten} FOUTEN`);
process.exit(fouten ? 1 : 0);
})();