104 lines
6.4 KiB
JavaScript
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.');
|