Files
RustProxy/tools/checks/vhost_www_ui_test.mjs
T
2026-10-08 15:59:37 +02:00

104 lines
6.4 KiB
JavaScript

#!/usr/bin/env node
// Exercise the real vhost WWW-routing editor code with a small DOM stand-in.
// This is a browser-UI state test, not a Rust request-routing test.
import fs from 'node:fs';
import vm from 'node:vm';
const editor = fs.readFileSync('src/admin/web/vhosts/editor.js', 'utf8');
const first = editor.indexOf(' const wwwPanel = el("section", undefined, "vh-wide vh-www-panel");');
const last = editor.indexOf(' field(g, "forward-client-ip",', first);
if (first === -1 || last === -1) throw new Error('WWW editor component missing');
const source = editor.slice(first, last);
if (editor.indexOf('readHosts = hostTagEditor(g, value.frontend?.hosts') < editor.indexOf('g = group("vhFrontends")')) {
throw new Error('Host chips must be in the same Frontends tab as WWW routing');
}
class ClassList {
constructor() { this.names = new Set(); }
add(name) { this.names.add(name); }
toggle(name, active) { if (active) this.names.add(name); else this.names.delete(name); }
contains(name) { return this.names.has(name); }
}
class Element {
constructor(tag = 'div', text = '', css = '') {
this.tag = tag;
this.textContent = text || '';
this.classList = new ClassList();
if (css) for (const name of css.split(' ')) this.classList.add(name);
this.children = [];
this.hidden = false;
this.checked = false;
this.value = '';
this.attrs = {};
this.listeners = new Map();
}
append(...children) { for (const child of children) { child.parent = this; this.children.push(child); } }
prepend(...children) { for (const child of [...children].reverse()) { child.parent = this; this.children.unshift(child); } }
replaceChildren(...children) { this.children = []; this.append(...children); }
closest(tag) { let node = this.parent; while (node && node.tag !== tag) node = node.parent; return node; }
setAttribute(key, value) { this.attrs[key] = String(value); }
addEventListener(event, listener) { this.listeners.set(event, listener); }
dispatch(event) { this.listeners.get(event)?.(); }
}
const el = (tag, content, css) => new Element(tag, content, css);
const t = key => key === 'vhWwwSharedCount' ? '{count} names / one vhost' : key;
const text = (tag, key, css) => el(tag, t(key), css);
const g = new Element();
const hosts = new Element('textarea');
hosts.value = 'suba.local\nsubb.local\n*.local';
const read = name => { if (name !== 'hosts') throw new Error(name); return hosts; };
const lines = raw => raw.split(/[\n,]+/).map(s => s.trim()).filter(Boolean);
let changes = 0;
const changed = () => { changes++; };
const value = { www_redirect: false, www_redirect_target: 'subb.local' };
const document = { createElement: tag => new Element(tag) };
const field = (parent, name, key, initial, type) => {
const label = new Element('label');
const input = new Element('input');
input.name = name;
input.type = type;
input.checked = Boolean(initial);
label.append(input, text('span', key));
parent.append(label);
return input;
};
let refreshCanonical = () => {};
const context = { el, text, field, g, t, value, read, lines, changed, document, refreshCanonical };
const component = vm.runInNewContext(`(() => { ${source}\nreturn { wwwPanel, wwwRedirect, wwwTarget, wwwChoices, wwwDetails, wwwInactive, wwwOther, wwwNoHosts, wwwHostCount, syncCanonical }; })()`, context);
const check = (condition, message) => { if (!condition) throw new Error(message); };
const choice = (baseIndex, directionIndex) => component.wwwChoices.children[baseIndex].children[1].children[directionIndex];
const route = (baseIndex, directionIndex) => choice(baseIndex, directionIndex).children[1].children[1].textContent;
check(!component.wwwDetails.hidden && !component.wwwInactive.hidden, 'Direction preview should be visible even when redirect is disabled');
check(component.wwwTarget.value === 'subb.local', 'Saved canonical domain was lost');
check(component.wwwChoices.children.length === 2, 'Wildcard must not become a canonical pair');
check(component.wwwHostCount.textContent === '3 names / one vhost', 'Shared host count not updated');
check(route(0, 0) === 'www.suba.local → suba.local', 'Incorrect www -> non-www preview');
check(route(0, 1) === 'suba.local → www.suba.local', 'Incorrect non-www -> www preview');
check(route(1, 0) === 'www.subb.local → subb.local', 'Incorrect second domain preview');
check(choice(1, 0).classList.contains('is-selected'), 'Saved target not visibly selected');
component.wwwRedirect.checked = true;
component.wwwRedirect.dispatch('change');
check(!component.wwwDetails.hidden && component.wwwInactive.hidden, 'Switch did not reveal WWW routing choices');
check(component.wwwRedirect.attrs['aria-expanded'] === 'true', 'Toggle accessibility state not updated');
check(component.wwwOther.children.some(child => child.textContent === 'suba.local'), 'Non-canonical alias must remain available');
check(component.wwwOther.children.some(child => child.textContent === '*.local'), 'Wildcard alias must remain available');
choice(0, 1).children[0].checked = true;
choice(0, 1).children[0].dispatch('change');
check(component.wwwTarget.value === 'www.suba.local', 'WWW destination selected without a separately declared www host was lost');
check(choice(0, 1).classList.contains('is-selected'), 'Selected direction not styled');
check(changes === 2, 'Toggle and radio change did not mark editor dirty');
hosts.value = 'subb.local\nwww.subb.local\n*.local';
context.refreshCanonical();
check(component.wwwTarget.value === 'subb.local', 'Deleting selected domain did not choose a valid fallback');
check(component.wwwChoices.children.length === 1, 'Duplicate base and www domains should be one pair');
check(route(0, 1) === 'subb.local → www.subb.local', 'Wrong preview for canonical www host');
choice(0, 1).children[0].checked = true;
choice(0, 1).children[0].dispatch('change');
check(component.wwwTarget.value === 'www.subb.local', 'Explicit WWW target not serialized');
check(component.wwwOther.children.every(child => child.textContent !== 'www.subb.local'), 'Redirected companion must not appear as a non-redirected alias');
hosts.value = '*.local';
context.refreshCanonical();
check(component.wwwNoHosts.hidden === false && component.wwwTarget.value === '', 'Wildcard-only configurations need a useful empty state');
check(component.wwwRedirect.disabled && !component.wwwRedirect.checked, 'Wildcard-only configuration must disable redirect');
console.log('PASS: WWW-routing UI two-way choices, implicit www target, shared aliases, dynamic host edits, wildcard exclusion and empty state.');