130 lines
9.0 KiB
JavaScript
130 lines
9.0 KiB
JavaScript
#!/usr/bin/env node
|
|
// Simulated vhost DOM: selector eligibility, independent edits and lifecycle.
|
|
// This is not a browser rendering test or a Rust health-probe test.
|
|
import fs from 'node:fs';
|
|
import vm from 'node:vm';
|
|
|
|
const core = fs.readFileSync('src/admin/web/vhosts/core.js', 'utf8');
|
|
const protocol = fs.readFileSync('src/admin/web/vhosts/protocol.js', 'utf8');
|
|
const health = fs.readFileSync('src/admin/web/vhosts/health.js', 'utf8');
|
|
const editor = fs.readFileSync('src/admin/web/vhosts/editor.js', 'utf8');
|
|
const render = fs.readFileSync('src/admin/web/vhosts/render.js', 'utf8');
|
|
const first = core.indexOf('function memberRow(');
|
|
const last = core.indexOf('function countryRateEditor(', first);
|
|
const assert = (condition, message) => { if (!condition) throw new Error(message); };
|
|
assert(first >= 0 && last > first, 'Vhost member editor missing');
|
|
class Element {
|
|
constructor(tag = 'div', value = '', css = '') {
|
|
this.tag = tag; this.value = String(value ?? ''); this.textContent = this.value;
|
|
this.children = []; this.parent = null; this.name = ''; this.checked = false; this.dataset = {};
|
|
this.disabled = false; this.hidden = false; this.listeners = new Map();
|
|
this.classes = new Set(css.split(/\s+/).filter(Boolean));
|
|
}
|
|
append(...children) {
|
|
for (const child of children) {
|
|
if (child.parent) child.remove();
|
|
this.children.push(child); child.parent = this;
|
|
}
|
|
}
|
|
replaceChildren(...children) { for (const child of [...this.children]) child.remove(); this.append(...children); }
|
|
remove() {
|
|
if (!this.parent) return;
|
|
this.parent.children.splice(this.parent.children.indexOf(this), 1); this.parent = null;
|
|
}
|
|
addEventListener(type, callback) { this.listeners.set(type, callback); }
|
|
dispatch(type) { this.listeners.get(type)?.(); }
|
|
querySelectorAll(selector) {
|
|
const result = [];
|
|
const walk = parent => { for (const child of parent.children) {
|
|
if ((selector === 'input,select' && ['input', 'select'].includes(child.tag)) ||
|
|
(selector === '*' || selector === undefined)) result.push(child);
|
|
walk(child);
|
|
}};
|
|
walk(this); return result;
|
|
}
|
|
querySelector(selector) {
|
|
const match = /^\[name="([^"]+)"\]$/.exec(selector);
|
|
return match ? this.querySelectorAll('*').find(node => node.name === match[1]) : null;
|
|
}
|
|
}
|
|
const el = (tag, value, css) => new Element(tag, value, css);
|
|
const text = (tag, key, css) => el(tag, key, css);
|
|
const field = (parent, name, key, value, type = 'text') => {
|
|
const wrapper = el('label');
|
|
const input = el(type === 'select' ? 'select' : 'input', value); input.name = name;
|
|
input.checked = type === 'checkbox' && !!value;
|
|
wrapper.append(input); parent.append(wrapper); return input;
|
|
};
|
|
const button = (key, callback) => { const node = el('button', key); node.addEventListener('click', callback); return node; };
|
|
const note = (parent, key) => { const node = text('p', key); parent.append(node); return node; };
|
|
const context = {
|
|
el, text, field, button, note, t: key => key,
|
|
str: (name, scope) => scope.querySelector(`[name="${name}"]`).value.trim(),
|
|
toast: () => { throw Error('Unexpected error toast'); }, changed: () => {},
|
|
appConfirm: async () => true, adminListenerBackendCandidate: () => false,
|
|
backendHealthEditorControl: null,
|
|
read: (name, scope) => scope ? scope.querySelector(`[name="${name}"]`) :
|
|
({ "health-mode": "http", "health-path": "/shared", "health-host": "shared.example",
|
|
interval: "8500", timeout: "1800", slow: "600", fall: "4", rise: "3",
|
|
"status-min": "201", "status-max": "398" }[name] != null ?
|
|
{ value: ({ "health-mode": "http", "health-path": "/shared", "health-host": "shared.example",
|
|
interval: "8500", timeout: "1800", slow: "600", fall: "4", rise: "3",
|
|
"status-min": "201", "status-max": "398" })[name] } : null),
|
|
};
|
|
const exports = vm.runInNewContext(`${protocol}\n${core.slice(first, last)}\n${health}\n({ memberRow, backendHealthEditor })`, context);
|
|
const members = el('div');
|
|
const probe = (mode, path) => ({ mode, path, host: null, interval_ms: 5000, timeout_ms: 1000, slow_response_ms: 500, fall: 2, rise: 1, expected_status_min: 200, expected_status_max: 399 });
|
|
const a = { id: 'alpha', url: 'http://192.0.2.1:9000', health_check_enabled: true, health_check: probe('http', '/a') };
|
|
const b = { id: 'disabled', url: 'http://192.0.2.2:9000', health_check_enabled: false, health_check: probe('http', '/b') };
|
|
const c = { id: 'charlie', url: 'http://192.0.2.3:9000', health_check_enabled: true, health_check: null };
|
|
for (const backend of [a, b, c]) exports.memberRow(members, { weight: 1, ...backend });
|
|
const [rowA, rowB, rowC] = members.children;
|
|
const wrapper = el('div'); context.backendHealthEditorControl = exports.backendHealthEditor(wrapper, members);
|
|
const select = wrapper.querySelector('[name="health-backend-select"]');
|
|
const control = (row, name) => row.memberHealthPanel.querySelector(`[name="${name}"]`);
|
|
assert(select.children.length === 2 && select.children[0].textContent.includes('alpha') && select.children[1].textContent.includes('charlie'), 'Unmonitored backend listed in selector');
|
|
assert(!rowA.memberHealthPanel.hidden && rowB.memberHealthPanel.hidden && rowC.memberHealthPanel.hidden, 'Selected panel visibility incorrect');
|
|
assert(control(rowB, 'member-health-path').disabled, 'Unmonitored override must be non-editable');
|
|
assert(!control(rowA, 'member-health-path').disabled, 'Monitored override unexpectedly disabled');
|
|
assert(control(rowA, 'member-health-path').value === '/a', 'Existing per-member override not loaded');
|
|
|
|
select.value = '1'; select.dispatch('change');
|
|
assert(rowA.memberHealthPanel.hidden && !rowC.memberHealthPanel.hidden, 'Switch did not change selected panel');
|
|
const customC = control(rowC, 'member-health-custom');
|
|
customC.checked = true; customC.dispatch('change');
|
|
assert(control(rowC, 'member-health-mode').value === 'http' &&
|
|
control(rowC, 'member-health-interval').value === '8500' &&
|
|
control(rowC, 'member-health-path').value === '/shared', 'First override did not inherit shared settings');
|
|
const pathC = control(rowC, 'member-health-path'); pathC.value = '/charlie-ready';
|
|
customC.checked = false; customC.dispatch('change');
|
|
customC.checked = true; customC.dispatch('change');
|
|
assert(pathC.value === '/charlie-ready', 'Toggling custom override discarded edits');
|
|
assert(!pathC.disabled, 'New custom override should become editable');
|
|
select.value = '0'; select.dispatch('change');
|
|
assert(control(rowA, 'member-health-path').value === '/a' && pathC.value === '/charlie-ready', 'Switch discarded unsaved member edits');
|
|
select.value = '1'; select.dispatch('change');
|
|
assert(pathC.value === '/charlie-ready' && !rowC.memberHealthPanel.hidden, 'Returning to backend lost its edits');
|
|
|
|
rowC.querySelector('[name="member-id"]').value = 'renamed';
|
|
rowC.querySelector('[name="member-id"]').dispatch('input');
|
|
assert(select.children[1].textContent.includes('renamed'), 'Backend rename not reflected in selector');
|
|
const monitorA = rowA.querySelector('[name="member-health-enabled"]');
|
|
monitorA.checked = false; monitorA.dispatch('change');
|
|
assert(select.children.length === 1 && select.children[0].textContent.includes('renamed'), 'Disabled monitor remains in selector');
|
|
assert(control(rowA, 'member-health-path').value === '/a' && control(rowA, 'member-health-path').disabled, 'Disabling monitor lost override or left active controls');
|
|
monitorA.checked = true; monitorA.dispatch('change');
|
|
assert(select.children.length === 2 && pathC.value === '/charlie-ready', 'Re-enabling monitor did not restore choice/edits');
|
|
const removeC = rowC.children[0].children[1]; // backend-card header's Remove button
|
|
removeC.dispatch('click');
|
|
assert(members.children.length === 2 && select.children.length === 1 && !rowA.memberHealthPanel.hidden, 'Deleting selected backend did not refresh selector');
|
|
assert(wrapper.querySelectorAll('*').includes(rowA.memberHealthPanel) && !wrapper.querySelectorAll('*').includes(rowC.memberHealthPanel), 'Removed backend panel remained retained in DOM');
|
|
monitorA.checked = false; monitorA.dispatch('change');
|
|
assert(select.disabled && select.children.length === 0 && rowB.memberHealthPanel.hidden, 'Empty monitored backend list not handled');
|
|
|
|
assert(editor.includes('health_check: yes("member-health-custom", row.memberHealthPanel)'), 'Per-member health settings not collected from selected panels');
|
|
assert(editor.includes('backendHealthEditorControl = backendHealthEditor(g, members)'), 'Selector not wired to Health tab');
|
|
assert(render.includes('backendHealthEditorControl?.reveal('), 'Invalid hidden member settings cannot be revealed');
|
|
assert(core.includes('backendHealthEditorControl = state.backendHealthEditorControl || null;') && core.includes('pagesEditor, backendHealthEditorControl,'), 'Draft switching lost selected backend editor');
|
|
assert(fs.readFileSync('src/admin/router.rs','utf8').includes('include_str!("web/vhosts/health.js")'), 'Bundled health editor missing');
|
|
console.log('PASS: 0.30.0 monitored-only backend selector, independent unsaved edits, monitor toggle, rename/removal, empty list and validation routing.');
|