Files
RustProxy/tools/checks/backend_health_0300_ui_test.mjs
T
2026-10-10 08:53:55 +02:00

129 lines
8.8 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 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.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(`${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.');