0.34.6_repo_cleanup
This commit is contained in:
1 parent
ce1a90397c
commit
133d2f0f91
273 files changed
+5969
-6926
No files matched your search
@@ -1,24 +0,0 @@
|
||||
// Run with: node tests/acl_picker_corner_styles.cjs
|
||||
// The shared ACL picker must respect all dashboard corner-style presets without
|
||||
// relying on a separate radius setting or changing its selection behaviour.
|
||||
"use strict";
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const css = fs.readFileSync(path.resolve(__dirname, "../src/admin/web/style.css"), "utf8");
|
||||
const parts = [
|
||||
".acl-picker-details", ".acl-picker-counter", ".acl-picker-option",
|
||||
".acl-picker-policy", ".acl-picker-tag", ".acl-picker-tag .acl-picker-remove"
|
||||
];
|
||||
for (const selector of parts) {
|
||||
const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const block = css.match(new RegExp(`${escaped}\\s*\\{([^}]+)\\}`));
|
||||
assert.ok(block, `Missing shared picker style: ${selector}`);
|
||||
assert.match(block[1], /border-radius:\s*var\(--rp-radius-control,\s*[^)]+\);/, `${selector} must use the dashboard control radius`);
|
||||
}
|
||||
for (const [preset, radius] of Object.entries({ square: 0, subtle: 4, rounded: 8, extra: 12 })) {
|
||||
const rule = css.match(new RegExp(`html\\[data-corner-style=${preset}\\]\\s*\\{([^}]+)\\}`));
|
||||
assert.ok(rule, `Missing corner-style preset: ${preset}`);
|
||||
assert.match(rule[1], new RegExp(`--rp-radius-control:\\s*${radius}px;`), `Unexpected radius for ${preset}`);
|
||||
}
|
||||
console.log("ACL picker corner styles: PASS (six surfaces, four presets, Original fallbacks)");
|
||||
@@ -1,72 +0,0 @@
|
||||
// Run with: node tests/acl_picker_dom.cjs. Tiny DOM stub for picker state/regression.
|
||||
"use strict";
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const vm = require("node:vm");
|
||||
const path = require("node:path");
|
||||
class DomEvent {
|
||||
constructor(type, options = {}) { this.type = type; this.bubbles = !!options.bubbles; this.target = null; }
|
||||
}
|
||||
class DomNode {
|
||||
constructor(tag) { this.tag = tag; this.className = ""; this.children = []; this.parent = null; this.dataset = {}; this.handlers = new Map(); this.hidden = false; this.disabled = false; this.checked = false; this.value = ""; this.type = ""; this._text = ""; }
|
||||
get textContent() { return this._text + this.children.map(c => c.textContent).join(""); }
|
||||
set textContent(text) { this.replaceChildren(); this._text = String(text); }
|
||||
append(...children) { for (const child of children) { child.parent = this; this.children.push(child); } }
|
||||
replaceChildren(...children) { for (const child of this.children) child.parent = null; this.children = []; this._text = ""; this.append(...children); }
|
||||
setAttribute() { }
|
||||
focus() { }
|
||||
addEventListener(type, handler) { if (!this.handlers.has(type)) this.handlers.set(type, []); this.handlers.get(type).push(handler); }
|
||||
dispatchEvent(event) { if (!event.target) event.target = this; for (const handler of this.handlers.get(event.type) || []) handler(event); if (event.bubbles && this.parent) this.parent.dispatchEvent(event); }
|
||||
click() { this.dispatchEvent(new DomEvent("click", {bubbles:true})); }
|
||||
hasClass(name) { return this.className.split(/\s+/).includes(name); }
|
||||
matches(selector) { return selector === 'input[type="checkbox"]' && this.tag === "input" && this.type === "checkbox"; }
|
||||
all() { return this.children.flatMap(child => [child, ...child.all()]); }
|
||||
querySelectorAll(selector) {
|
||||
const descendants = this.all();
|
||||
if (selector === '.acl-picker-option input[type="checkbox"]:checked') return descendants.filter(n => n.tag === "input" && n.type === "checkbox" && n.checked && n.parent?.hasClass("acl-picker-option"));
|
||||
if (selector === '.acl-picker-option input[type="checkbox"]') return descendants.filter(n => n.tag === "input" && n.type === "checkbox" && n.parent?.hasClass("acl-picker-option"));
|
||||
const value = selector.match(/^\.acl-picker-option input\[value="([^"]+)"\]$/);
|
||||
if (value) return descendants.filter(n => n.tag === "input" && n.value === value[1] && n.parent?.hasClass("acl-picker-option"));
|
||||
const cls = selector.match(/^\.([\w-]+)$/);
|
||||
if (cls) return descendants.filter(n => n.hasClass(cls[1]));
|
||||
throw Error("Unsupported test selector " + selector);
|
||||
}
|
||||
querySelector(selector) { return this.querySelectorAll(selector)[0] || null; }
|
||||
}
|
||||
const profiles = Array.from({length:10}, (_,i) => ({
|
||||
id: `profile_${i}`, name: i === 0 ? "Office networks" : `Profile ${i}`,
|
||||
acl: {default: "deny", rules:[{network:`10.${i}.0.0/16`,action:"allow"},{network:"192.0.2.0/24",action:"deny"}]},
|
||||
country_access: {mode: i === 0 ? "block" : "off", countries: i === 0 ? ["RU"] : []}
|
||||
}));
|
||||
const context = {
|
||||
window: {},
|
||||
envelope: {config:{acl_profiles:profiles}},
|
||||
el(tag, text, cls) { const node = new DomNode(tag); if (text !== undefined) node.textContent = text; if (cls) node.className = cls; return node; },
|
||||
t: key => key,
|
||||
Event: DomEvent,
|
||||
};
|
||||
vm.createContext(context);
|
||||
vm.runInContext(fs.readFileSync(path.resolve(__dirname, "../src/admin/web/acl-picker.js"), "utf8"), context);
|
||||
const picker = context.window.rustProxyAclPicker;
|
||||
const root = picker.create(["profile_0", "profile_1"], "vh-acl-profile-choices");
|
||||
assert.deepEqual(Array.from(picker.read(root)), ["profile_0", "profile_1"]);
|
||||
assert.equal(root.querySelectorAll(".acl-picker-tag").length, 2);
|
||||
assert.match(root.textContent, /10\.0\.0\.0\/16/);
|
||||
assert.match(root.textContent, /RU/);
|
||||
const search = root.querySelector(".acl-picker-search");
|
||||
search.value = "Office"; search.dispatchEvent(new DomEvent("input", {bubbles:true}));
|
||||
assert.equal(root.querySelectorAll(".acl-picker-option").filter(node => !node.hidden).length, 1);
|
||||
search.value = ""; search.dispatchEvent(new DomEvent("input", {bubbles:true}));
|
||||
root.querySelector(".acl-picker-remove").click();
|
||||
assert.deepEqual(Array.from(picker.read(root)), ["profile_1"]);
|
||||
for (const check of root.querySelectorAll('.acl-picker-option input[type="checkbox"]').slice(2,9)) {
|
||||
check.checked = true;
|
||||
check.dispatchEvent(new DomEvent("change", {bubbles:true}));
|
||||
}
|
||||
assert.equal(picker.count(root), 8);
|
||||
assert.equal(root.querySelector('.acl-picker-option input[value="profile_9"]').disabled, true);
|
||||
root.querySelector(".acl-picker-remove").click();
|
||||
assert.equal(root.querySelector('.acl-picker-option input[value="profile_9"]').disabled, false);
|
||||
const docker = picker.create([], "docker-acl-profile");
|
||||
assert.deepEqual(Array.from(picker.read(docker)), []); // empty override inherits agent profiles
|
||||
console.log("ACL picker DOM regression: PASS (choices, preview, search, badges, limit and inheritance)");
|
||||
@@ -1,96 +0,0 @@
|
||||
"use strict";
|
||||
// Zero-dependency browser calculation regressions (run: node tests/cache_outcomes.cjs).
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const vm = require("node:vm");
|
||||
const root = path.join(__dirname, "..");
|
||||
const src = fs.readFileSync(path.join(root, "src/admin/web/app/runtime.js"), "utf8");
|
||||
const begin = src.indexOf("function cacheOutcomeMix(totals) {");
|
||||
const renderBegin = src.indexOf("\nfunction renderCacheMix(mix, totalRequests) {", begin);
|
||||
const end = src.indexOf("\nfunction renderDiagnostics(", renderBegin);
|
||||
assert.ok(begin >= 0 && renderBegin > begin && end > renderBegin, "production cumulative widget functions exist");
|
||||
const mix = vm.runInNewContext(`${src.slice(begin, renderBegin)}; cacheOutcomeMix;`);
|
||||
const result = mix([10, 4, 2, 3]);
|
||||
assert.deepEqual(Array.from(result.counts), [4, 2, 3, 1]);
|
||||
assert.deepEqual(Array.from(result.percentages), [40, 20, 30, 10]);
|
||||
assert.equal(result.requests, 10);
|
||||
assert.deepEqual(Array.from(mix([12, 0, 0, 0]).counts), [0, 0, 0, 12]);
|
||||
assert.deepEqual(Array.from(mix([9, 0, 0, 9]).counts), [0, 0, 9, 0]);
|
||||
for (const invalid of [[0, 0, 0, 0], [1, 2, 0, 0], [10, -1, 0, 0], [NaN, 0, 0, 0],
|
||||
[10.1, 2, 0, 0], [Number.MAX_SAFE_INTEGER + 1, 0, 0, 0], [], null]) {
|
||||
assert.equal(mix(invalid), null, `invalid lifetime counters ${invalid} are not charted`);
|
||||
}
|
||||
// Ensure the panel uses all-time counters straight from the status payload, on every render.
|
||||
const diagnostic = src.slice(end, src.indexOf("\nfunction ", end + 1));
|
||||
assert.match(diagnostic, /const counters = \[m\.requests, c\.hits, c\.misses, c\.bypasses\]/);
|
||||
assert.match(diagnostic, /renderCacheMix\(cacheOutcomeMix\(counters\), counters\[0\]\);/);
|
||||
assert.doesNotMatch(src, /cacheMixLastCounters|cacheMixLastShares|cacheMixLastTotal/);
|
||||
const translations = ["en_US", "pl_pl"].map(language =>
|
||||
JSON.parse(fs.readFileSync(path.join(root, `langs/${language}.json`), "utf8")).translations);
|
||||
const nodes = new Map();
|
||||
const dom = id => {
|
||||
if (!nodes.has(id)) {
|
||||
const node = { style: {}, textContent: "", attributes: {}, setAttribute(name, value) { this.attributes[name] = value; } };
|
||||
nodes.set(id, node);
|
||||
}
|
||||
return nodes.get(id);
|
||||
};
|
||||
const english = translations[0];
|
||||
const render = vm.runInNewContext(`${src.slice(renderBegin + 1, end)}; renderCacheMix;`, {
|
||||
$: dom,
|
||||
t: key => english[key],
|
||||
fmt: (value, digits = 0) => Number(value).toFixed(digits),
|
||||
});
|
||||
render(result, 10);
|
||||
assert.equal(dom("cache-mix-headline").textContent, "40.0% HIT");
|
||||
assert.equal(dom("cache-mix-caption").textContent, "10");
|
||||
assert.equal(dom("cache-mix-caption").attributes.title, "Since service start · 10 HTTP requests");
|
||||
assert.equal(dom("cache-mix-caption").attributes["aria-label"], "Since service start · 10 HTTP requests");
|
||||
for (const [name, count, percent] of [["hit", 4, 40], ["miss", 2, 20],
|
||||
["bypass", 3, 30], ["other", 1, 10]]) {
|
||||
assert.equal(dom(`cache-mix-${name}-count`).textContent, String(count));
|
||||
assert.equal(dom(`cache-mix-${name}-percent`).textContent, `${percent.toFixed(1)}%`);
|
||||
assert.equal(dom(`cache-mix-${name}`).style.width, `${percent}%`);
|
||||
}
|
||||
assert.match(dom("cache-mix-bar").attributes["aria-label"], /4 HIT, 2 MISS, 3 BYPASS, 1 OTHER/);
|
||||
render(null, 0);
|
||||
assert.equal(dom("cache-mix-caption").textContent, "0");
|
||||
assert.equal(dom("cache-mix-caption").attributes.title, "Since service start · 0 HTTP requests");
|
||||
assert.equal(dom("cache-mix-hit-count").textContent, "-");
|
||||
assert.equal(dom("cache-mix-hit").style.width, "0%");
|
||||
render(null, 10);
|
||||
assert.equal(dom("cache-mix-caption").textContent, "10");
|
||||
assert.equal(dom("cache-mix-caption").attributes.title, "Since service start · 10 HTTP requests");
|
||||
assert.equal(dom("cache-mix-bar").attributes["aria-label"], english.cacheMixInvalid);
|
||||
render(null, NaN);
|
||||
assert.equal(dom("cache-mix-caption").textContent, "-");
|
||||
assert.equal(dom("cache-mix-caption").attributes.title, english.cacheMixInvalid);
|
||||
|
||||
const widgets = JSON.parse(fs.readFileSync(path.join(root, "src/admin/web/dashboard-widgets.json"), "utf8"));
|
||||
const defaults = JSON.parse(fs.readFileSync(path.join(root, "src/admin/web/dashboard-defaults.json"), "utf8"));
|
||||
assert.equal(widgets.filter(widget => widget.id === "cache_outcomes").length, 1);
|
||||
assert.equal(defaults.dashboard_widgets.filter(id => id === "cache_outcomes").length, 1);
|
||||
assert.equal(widgets.find(widget => widget.id === "cache_outcomes").default_hidden, true, "cache outcomes is opt-in");
|
||||
assert.equal(defaults.dashboard_hidden_widgets.filter(id => id === "cache_outcomes").length, 1, "hidden in factory layout");
|
||||
const html = fs.readFileSync(path.join(root, "src/admin/web/partials/overview-metrics.html"), "utf8");
|
||||
assert.match(html, /data-dashboard-widget="cache_outcomes"/);
|
||||
assert.match(html, /class="cache-mix-header">\s*<span data-i18n="cacheMixTitle">[^<]+<\/span>\s*<small class="cache-mix-caption" id="cache-mix-caption"/);
|
||||
assert.equal((html.match(/id="cache-mix-caption"/g) || []).length, 1, "counter shown only in top-right header");
|
||||
const css = fs.readFileSync(path.join(root, "src/admin/web/style.css"), "utf8");
|
||||
assert.match(css, /\.stat-cache-outcomes \.cache-mix-header \{\s*display: flex;/);
|
||||
assert.match(css, /\.stat-cache-outcomes \.cache-mix-caption \{[^}]*text-align: right;/);
|
||||
|
||||
for (const key of ["hit", "miss", "bypass", "other"]) {
|
||||
assert.match(html, new RegExp(`id="cache-mix-${key}"`));
|
||||
assert.match(html, new RegExp(`id="cache-mix-${key}-count"`));
|
||||
assert.match(html, new RegExp(`id="cache-mix-${key}-percent"`));
|
||||
}
|
||||
for (const translation of translations) {
|
||||
for (const key of ["cacheMixTitle", "cacheMixHelp", "cacheMixOther", "cacheMixSample", "cacheMixWaiting", "cacheMixInvalid", "cacheMixAria",
|
||||
"h3_udp_receive_buffer_mib", "h3_udp_send_buffer_mib", "http3ApplyPolicy"]) {
|
||||
assert.ok(translation[key], `translation missing: ${key}`);
|
||||
}
|
||||
assert.doesNotMatch(translation.cacheMixTitle, /recent|ostatni pomiar/i);
|
||||
}
|
||||
console.log("Cumulative cache outcomes, manifest and locale regression checks passed.");
|
||||
@@ -1,47 +0,0 @@
|
||||
// Run with: node tests/css_usage_audit.cjs
|
||||
// Ensure the review archive stays unreferenced and critical live styles survive.
|
||||
'use strict';
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const assert = require('node:assert/strict');
|
||||
const root = path.resolve(__dirname, '..');
|
||||
const read = file => fs.readFileSync(path.join(root, file), 'utf8');
|
||||
const active = read('src/admin/web/style.css');
|
||||
// Review-only archives are intentionally optional in reproducible source ZIPs.
|
||||
const unused = fs.existsSync(path.join(root, 'src/admin/web/unused.css')) ? read('src/admin/web/unused.css') : null;
|
||||
const docs = read('src/admin/web/docs.css');
|
||||
const archived = [
|
||||
'auth-ip-card', 'certificate-dialog', 'config-columns', 'overview-grid',
|
||||
'overview-inline-hidden-toggle', 'overview-layout-body', 'overview-layout-filters',
|
||||
'overview-layout-grid', 'overview-layout-hidden-list', 'overview-layout-section-heading',
|
||||
'settings-grid', 'stream-dashboard-card', 'table-container', 'transport-grid', 'www-service-heading',
|
||||
];
|
||||
for (const name of archived) {
|
||||
const cssClass = new RegExp(`\\.${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?![-\\w])`);
|
||||
assert.ok(!cssClass.test(active), `${name} must not remain in the active stylesheet`);
|
||||
if (unused !== null) assert.ok(cssClass.test(unused), `${name} must be reviewable in unused.css`);
|
||||
}
|
||||
for (const name of ['vh-setting-section', 'dashboard-overview-grid', 'vh-override-fields',
|
||||
'refresh-settings-grid', 'docker-backend-ip-settings', 'acl-profile-layout']) {
|
||||
assert.ok(active.includes(`.${name}`), `active class ${name} was removed`);
|
||||
}
|
||||
for (const file of [
|
||||
'src/admin/web/index.html', 'src/admin/web/login.html', 'src/admin/web/docs.html',
|
||||
'src/admin/panel_assets.rs', 'src/admin/documentation.rs',
|
||||
]) assert.ok(!read(file).includes('unused.css'), `${file} must not load or serve unused.css`);
|
||||
assert.ok(!/@import[^;]*unused\.css/.test(active));
|
||||
if (unused !== null) assert.match(unused, /NOT INCLUDED by the admin panel/);
|
||||
assert.match(docs, /\.swagger-ui \.opblock/);
|
||||
for (const style of ['square', 'subtle', 'rounded', 'extra']) {
|
||||
assert.ok(active.includes(`html[data-corner-style=${style}]`));
|
||||
assert.ok(docs.includes(`html[data-corner-style=${style}]`));
|
||||
}
|
||||
for (const name of ['app-startup-spin', 'app-startup-reveal']) {
|
||||
assert.ok(active.includes(`@keyframes ${name}`));
|
||||
if (unused !== null) assert.ok(!unused.includes(`@keyframes ${name}`));
|
||||
}
|
||||
assert.ok(!active.includes('--backdrop-soft'));
|
||||
assert.ok(!active.includes('var(--panel-strong)'));
|
||||
assert.ok(!active.includes('var(--border)'));
|
||||
// The review-only CSS history is not a runtime or packaging dependency.
|
||||
console.log('CSS active/optional archive isolation and corner-style regression: PASS');
|
||||
@@ -1,187 +0,0 @@
|
||||
// Isolated DOM regression for per-Docker-vhost live proxy logs and Publish switch.
|
||||
// No native RustProxy process or browser is required for this check.
|
||||
'use strict';
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
const root = path.resolve(__dirname, '..');
|
||||
const read = name => fs.readFileSync(path.join(root, name), 'utf8');
|
||||
const docker = read('src/admin/web/docker.js');
|
||||
const css = read('src/admin/web/style.css');
|
||||
const management = read('src/admin/web/management.js');
|
||||
const en = JSON.parse(read('langs/en_US.json')).translations;
|
||||
const pl = JSON.parse(read('langs/pl_pl.json')).translations;
|
||||
for (const pack of [en, pl]) {
|
||||
assert.ok(pack.dockerPublish && pack.liveLogs && pack.dockerLiveAccessLogsHelp);
|
||||
}
|
||||
assert.match(docker, /publish\.setAttribute\("role", "switch"\)/);
|
||||
assert.match(css, /\.docker-publish-field \.docker-publish:checked::before/);
|
||||
assert.match(css, /\.docker-publish-field \.docker-publish:focus-visible/);
|
||||
assert.match(css, /\.docker-service-quick-actions/);
|
||||
assert.match(management, /if \(selected\) ids\.add\(selected\)/);
|
||||
assert.match(read('Cargo.toml'), /^version = "0\.34\.6"$/m);
|
||||
assert.match(read('Cargo.lock'), /name = "rust-proxy"\nversion = "0\.34\.6"/);
|
||||
|
||||
class Node {
|
||||
constructor(tag) {
|
||||
this.tag = tag; this.children = []; this.parentElement = null; this.dataset = {};
|
||||
this.className = ''; this.listeners = {}; this.attributes = {};
|
||||
this.open = false; this.hidden = false; this.disabled = false;
|
||||
this.checked = false; this.value = ''; this._text = ''; this.isConnected = true;
|
||||
const classes = new Set();
|
||||
this.classList = {
|
||||
add: token => classes.add(token), remove: token => classes.delete(token),
|
||||
toggle: (token, state) => { const wanted = state === undefined ? !classes.has(token) : state; if (wanted) classes.add(token); else classes.delete(token); return wanted; },
|
||||
contains: token => classes.has(token),
|
||||
};
|
||||
}
|
||||
append(...children) { this.children.push(...children); children.forEach(child => { if (child instanceof Node) child.parentElement = this; }); }
|
||||
replaceChildren(...children) { this.children = []; this._text = ''; this.append(...children); }
|
||||
get textContent() { return this._text + this.children.map(child => child.textContent ?? String(child)).join(''); }
|
||||
set textContent(value) { this.replaceChildren(); this._text = String(value); }
|
||||
setAttribute(key, value) { this.attributes[key] = String(value); }
|
||||
getAttribute(key) { return this.attributes[key]; }
|
||||
addEventListener(name, fn) { (this.listeners[name] ||= []).push(fn); }
|
||||
async emit(name) { for (const fn of this.listeners[name] || []) await fn({target:this}); }
|
||||
click() { return this.emit('click'); }
|
||||
}
|
||||
const controls = new Map();
|
||||
const visits = [], saves = [];
|
||||
let saveResult = true;
|
||||
const context = {
|
||||
window: {rustProxyLogs: {open(id) { visits.push(id); }}, rustProxyAclPicker: {}, rustProxyPreferences: {}},
|
||||
document: {createElementNS: (_ns,tag) => new Node(tag), querySelectorAll: () => []},
|
||||
$: id => controls.get(id) || null,
|
||||
el(tag, content, cls) { const node = new Node(tag); if (content !== undefined) node.textContent = content; if (cls) node.className = cls; return node; },
|
||||
t: key => en[key] || key,
|
||||
locale: () => 'en-US',
|
||||
localStorage: {getItem:() => null},
|
||||
rustProxyRefresh: {register(){}, sync(){}},
|
||||
envelope: {config: {docker_agents:{agents:[]}, listeners:{endpoints:[
|
||||
{id:'http', enabled:true, binding:{address:'0.0.0.0:80',tls:false}},
|
||||
{id:'https', enabled:true, binding:{address:'0.0.0.0:8443',tls:true}},
|
||||
]}}},
|
||||
latestStatus:null, performance:{now:()=>0}, authenticated:false, currentPanel:'overview',
|
||||
toast(message) { throw new Error('Unexpected toast: ' + message); },
|
||||
};
|
||||
vm.createContext(context);
|
||||
assert.ok(docker.trimEnd().endsWith('})();'));
|
||||
const injected = docker.trimEnd().slice(0, -5) + `
|
||||
// Replace the unrelated configuration editors and persistence with controlled stubs.
|
||||
aliasEditor = () => ({node:el('div'), input:el('input'), read:()=>[], count:()=>0});
|
||||
targetOptions = () => [];
|
||||
aclEditor = timeoutEditor = healthEditor = passiveHealthEditor = () => el('details');
|
||||
saveAssignment = async (agentId, row, enabled) => window._saveAssignment(agentId, row, enabled);
|
||||
window._dockerActions = {assignmentRow, proxiedUrl, setRoutes(routes) { poolRoutes = routes; }};
|
||||
})();`;
|
||||
context.window._saveAssignment = async (agentId, row, published) => { saves.push([agentId,row.dataset.key,published]); return saveResult; };
|
||||
vm.runInContext(injected, context);
|
||||
const actions = context.window._dockerActions;
|
||||
const agent = {id:'docker-1',name:'Docker 1',base_domain:'example.test',listener_ids:['http','https'],enabled:true};
|
||||
const service = {key:'container:app:8080/tcp',technical_name:'app-8080',container_name:'app',image:'myapp:v1',private_port:8080,bindings:[{bind_ip:'0.0.0.0',public_port:8080}]};
|
||||
const assignment = {service_key:service.key,hostname:'app',enabled:true};
|
||||
const logId = 'docker-agent-41a1b2c3d4e5';
|
||||
actions.setRoutes([{agent_id:agent.id,service_key:service.key,pool_id:logId}]);
|
||||
const row = actions.assignmentRow(agent,null,service,assignment,false);
|
||||
const [head,editor,metrics] = row.children;
|
||||
assert.equal(editor.tag,'details'); assert.equal(metrics.tag,'details', 'published route retains collapsed metrics');
|
||||
const [publishLabel,,headActions] = head.children;
|
||||
const publish = publishLabel.children[1];
|
||||
assert.equal(publishLabel.className,'docker-publish-field');
|
||||
assert.equal(publish.getAttribute('role'),'switch');
|
||||
assert.equal(publish.checked,true);
|
||||
assert.equal(publish.getAttribute('aria-checked'),'true');
|
||||
const [runtime,quickActions] = headActions.children;
|
||||
assert.equal(runtime.className, 'docker-runtime');
|
||||
const [openApp,openLogs] = quickActions.children;
|
||||
assert.equal(openApp.textContent,en.dockerOpenApp);
|
||||
assert.equal(openApp.href,'https://app.example.test:8443/', 'Open app honors nested listener TLS and port');
|
||||
assert.equal(openLogs.textContent,en.liveLogs);
|
||||
assert.equal(openLogs.title,en.dockerLiveAccessLogsHelp);
|
||||
assert.equal(openLogs.disabled,false);
|
||||
assert.equal(openLogs.hidden,false);
|
||||
function testLiveLogSelection() {
|
||||
// Execute the actual live log dialog controller independently from other admin modules.
|
||||
const start = management.indexOf('(() => {\n const dialog = $("logs-dialog");');
|
||||
assert.ok(start > 0);
|
||||
const end = management.indexOf('\n})();', start) + '\n})();'.length;
|
||||
const logController = management.slice(start, end);
|
||||
class LogNode extends Node {
|
||||
constructor(tag) { super(tag); this.value = ''; }
|
||||
replaceChildren(...children) {
|
||||
super.replaceChildren(...children);
|
||||
if (this.tag === 'select') this.value = this.children[0]?.value || '';
|
||||
}
|
||||
querySelectorAll() { return []; }
|
||||
show() { this.open = true; }
|
||||
showModal() { this.open = true; }
|
||||
close() { this.open = false; }
|
||||
}
|
||||
const logControls = new Map();
|
||||
const $ = id => {
|
||||
if (!logControls.has(id)) {
|
||||
const tag = ['log-vhost','log-method','log-status','log-outcome'].includes(id) ? 'select' : 'div';
|
||||
logControls.set(id, new LogNode(tag));
|
||||
}
|
||||
return logControls.get(id);
|
||||
};
|
||||
$('log-outcome').value = 'all';
|
||||
const logContext = {
|
||||
document:{createElement:tag => new LogNode(tag)},
|
||||
window:{rustProxyPreferences:{load:async()=>({live_log_columns:['time','vhost']})}},
|
||||
$, t:key => en[key] || key, el(tag,value,cls) {const node=new LogNode(tag); if(value!==undefined) node.textContent=value; if(cls)node.className=cls; return node;},
|
||||
authenticated:true, envelope:{config:{vhosts:[{id:'ordinary-vhost'}]}}, lastLogs:{entries:[]},
|
||||
formatTime:time=>String(time), toast(){throw Error('unexpected toast')},
|
||||
};
|
||||
vm.createContext(logContext);
|
||||
vm.runInContext(logController,logContext);
|
||||
logContext.window.rustProxyLogs.open('docker-agent-41a1b2c3d4e5');
|
||||
assert.equal($('log-vhost').value, 'docker-agent-41a1b2c3d4e5');
|
||||
const entry = (vhost) => ({vhost,time_ms:123,method:'GET',path:'/',host:'app.example.test',status:200,cache:'MISS',outcome:'ok'});
|
||||
// A refresh containing only requests from another Vhost must not remove the chosen ID.
|
||||
logContext.lastLogs = {entries:[entry('ordinary-vhost')]};
|
||||
logContext.window.rustProxyLogs.render();
|
||||
assert.equal($('log-vhost').value,'docker-agent-41a1b2c3d4e5');
|
||||
assert.match($('log-count').textContent, /^0 \/ 1/);
|
||||
logContext.lastLogs = {entries:[entry('ordinary-vhost'),entry('docker-agent-41a1b2c3d4e5')]};
|
||||
logContext.window.rustProxyLogs.render();
|
||||
assert.match($('log-count').textContent, /^1 \/ 2/);
|
||||
assert.equal($('log-vhost').value,'docker-agent-41a1b2c3d4e5');
|
||||
$('log-vhost').value = '';
|
||||
logContext.window.rustProxyLogs.render();
|
||||
assert.match($('log-count').textContent, /^2 \/ 2/, 'manual All vhosts selection still works');
|
||||
}
|
||||
|
||||
(async () => {
|
||||
testLiveLogSelection();
|
||||
await openLogs.click();
|
||||
assert.deepEqual(visits,[logId], 'logs open for the returned generated ID, not the display hostname');
|
||||
publish.checked = false;
|
||||
await publish.emit('change');
|
||||
assert.deepEqual(saves.at(-1),[agent.id,service.key,false]);
|
||||
assert.equal(publish.getAttribute('aria-checked'),'false');
|
||||
assert.equal(openLogs.hidden,true);
|
||||
assert.equal(openApp.hidden,true);
|
||||
assert.equal(publish.disabled,false);
|
||||
publish.checked = true;
|
||||
await publish.emit('change');
|
||||
assert.deepEqual(saves.at(-1),[agent.id,service.key,true]);
|
||||
assert.equal(openLogs.hidden,false);
|
||||
assert.equal(openApp.hidden,false);
|
||||
assert.equal(publish.getAttribute('aria-checked'),'true');
|
||||
saveResult = false;
|
||||
publish.checked = false;
|
||||
await publish.emit('change');
|
||||
assert.equal(publish.checked,true, 'failed unpublish reverts state');
|
||||
assert.equal(publish.getAttribute('aria-checked'),'true', 'ARIA state rolls back');
|
||||
assert.equal(openLogs.hidden,false, 'related action rolls back');
|
||||
saveResult = true;
|
||||
actions.setRoutes([]);
|
||||
const missingRoute = actions.assignmentRow(agent,null,service,assignment,false);
|
||||
const missingLogs = missingRoute.children[0].children[2].children[1].children[1];
|
||||
assert.equal(missingLogs.disabled,true, 'no generated route ID disables logs rather than showing all vhosts');
|
||||
await missingLogs.click();
|
||||
assert.deepEqual(visits,[logId], 'missing ID never opens unfiltered logs');
|
||||
console.log('Docker publish switch, rollback, live log targeting, app links: PASS');
|
||||
})().catch(error => { console.error(error); process.exitCode = 1; });
|
||||
@@ -1,139 +0,0 @@
|
||||
// Run with: node tests/docker_vhost_metrics.cjs
|
||||
// Source contracts and isolated DOM/metric sampling regression for Docker vhosts.
|
||||
'use strict';
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
const root = path.resolve(__dirname, '..');
|
||||
const source = name => fs.readFileSync(path.join(root, name), 'utf8');
|
||||
const docker = source('src/admin/web/docker.js');
|
||||
const core = source('src/admin/web/app/core.js');
|
||||
const html = source('src/admin/web/index.html');
|
||||
const css = source('src/admin/web/style.css');
|
||||
const en = JSON.parse(source('langs/en_US.json')).translations;
|
||||
const pl = JSON.parse(source('langs/pl_pl.json')).translations;
|
||||
|
||||
assert.match(html, /id="docker-metrics-toggle-all"/);
|
||||
assert.match(docker, /if \(agent\.enabled !== false && assignment && assignment\.enabled !== false\) row\.append\(statisticsSection\(agent, key\)\)/);
|
||||
assert.match(docker, /"docker-service-editor docker-service-metrics"/);
|
||||
assert.ok(docker.indexOf('row.append(editor);') < docker.indexOf('row.append(statisticsSection(agent, key))'));
|
||||
for (const key of ['status', 'vhRequests', 'vhActive', 'vhErrors', 'vhLatency', 'vhTraffic', 'vhLifetimeChart']) {
|
||||
assert.ok(docker.includes(`"${key}"`), `missing ordinary Vhost metric: ${key}`);
|
||||
}
|
||||
for (const key of ['dockerTrafficStatistics', 'dockerExpandAllMetrics', 'dockerCollapseAllMetrics']) {
|
||||
assert.ok(en[key] && pl[key], `missing translated label: ${key}`);
|
||||
}
|
||||
for (const className of ['vhost-live-grid', 'vhost-live-metric', 'vhost-chart', 'vhost-chart-line']) {
|
||||
assert.ok(css.includes(`.${className}`), `existing ordinary Vhost style missing: ${className}`);
|
||||
}
|
||||
assert.match(docker, /status\?\.vhost_metrics/);
|
||||
assert.match(docker, /rustProxyRateSampling\.difference/);
|
||||
assert.match(docker, /docker-service-editor:not\(\.docker-service-metrics\)\[open\]/);
|
||||
assert.match(docker, /expandedMetrics\.clear\(\); metricHistory\.clear\(\)/);
|
||||
assert.match(core, /window\.rustProxyDocker\?\.renderStatus\(s\)/);
|
||||
|
||||
// Keep the production module intact. Expose only local functions for this
|
||||
// self-contained DOM stub; no browser, network, or Rust server is involved.
|
||||
class Node {
|
||||
constructor(tag) { this.tag = tag; this.children = []; this.dataset = {}; this.className = ''; this.listeners = {}; this.attributes = {}; this.open = false; this.disabled = false; this._text = ''; }
|
||||
append(...children) { this.children.push(...children); }
|
||||
replaceChildren(...children) { this.children = [...children]; this._text = ''; }
|
||||
get textContent() { return this._text + this.children.map(node => node.textContent).join(''); }
|
||||
set textContent(value) { this.replaceChildren(); this._text = String(value); }
|
||||
setAttribute(name, value) { this.attributes[name] = String(value); }
|
||||
getAttribute(name) { return this.attributes[name]; }
|
||||
addEventListener(name, callback) { (this.listeners[name] ||= []).push(callback); }
|
||||
dispatch(name) { for (const callback of this.listeners[name] || []) callback({target:this}); }
|
||||
click() { this.dispatch('click'); }
|
||||
}
|
||||
const controls = new Map([['docker-metrics-toggle-all', new Node('button')], ['docker-discovery-list', new Node('div')]]);
|
||||
let sections = [], now = 1000;
|
||||
const document = {
|
||||
hidden: false,
|
||||
createElementNS(ns, tag) { return new Node(tag); },
|
||||
querySelectorAll(selector) { if (selector === '#docker-discovery-list .docker-agent-card') return []; assert.equal(selector, '#docker-discovery-list .docker-service-metrics'); return sections; },
|
||||
addEventListener() { },
|
||||
};
|
||||
const context = {
|
||||
window: {addEventListener(){}, rustProxyPreferences:{}, rustProxyAclPicker:{}},
|
||||
document, performance: {now:() => now},
|
||||
localStorage:{getItem(){return null;}},
|
||||
$: id => controls.get(id) || null,
|
||||
el(tag, value, classes) { const node = new Node(tag); if (value !== undefined) node.textContent = value; if (classes) node.className = classes; return node; },
|
||||
t: key => en[key] || key,
|
||||
fmt: value => Number(value).toLocaleString('en-US', {maximumFractionDigits:1}),
|
||||
rateBytes: value => `${(Number(value)/1024).toFixed(1)} KiB`,
|
||||
locale: () => 'en-US',
|
||||
rustProxyRefresh: {register(){}, current(){return {overview_seconds:3};}},
|
||||
latestStatus:null, authenticated:false, currentPanel:'overview',
|
||||
};
|
||||
vm.createContext(context);
|
||||
vm.runInContext(source('src/admin/web/app/rate-sampling.js'), context);
|
||||
const marker = '})();';
|
||||
assert.ok(docker.trimEnd().endsWith(marker));
|
||||
const injected = docker.trimEnd().slice(0, -marker.length) + `
|
||||
window._metricsTest = {
|
||||
statisticsSection, renderDockerMetrics, syncMetricsToggle,
|
||||
setRuntime(routes, pools) { poolRoutes = routes; poolIndex = new Map(pools.map(pool => [pool.id, pool])); },
|
||||
};
|
||||
})();`;
|
||||
vm.runInContext(injected, context);
|
||||
const metrics = context.window._metricsTest;
|
||||
const agent = {id:'docker-1'};
|
||||
const first = metrics.statisticsSection(agent, 'service/a');
|
||||
const second = metrics.statisticsSection(agent, 'service-b');
|
||||
sections = [first, second];
|
||||
const button = controls.get('docker-metrics-toggle-all');
|
||||
metrics.syncMetricsToggle();
|
||||
assert.ok(sections.every(section => !section.open), 'default collapsed');
|
||||
assert.equal(button.textContent, en.dockerExpandAllMetrics);
|
||||
assert.equal(button.disabled, false);
|
||||
assert.equal(first.className, 'docker-service-editor docker-service-metrics');
|
||||
assert.equal(first.children[0].tag, 'summary');
|
||||
assert.equal(first.children[0].children[0].textContent, en.dockerTrafficStatistics);
|
||||
assert.equal(first.children[1].className, 'vhost-live-grid');
|
||||
assert.equal(first.children[1].children.length, 6);
|
||||
button.click();
|
||||
assert.ok(sections.every(section => section.open), 'one click expands every host');
|
||||
assert.equal(button.textContent, en.dockerCollapseAllMetrics);
|
||||
const refreshed = metrics.statisticsSection(agent, 'service/a');
|
||||
assert.equal(refreshed.open, true, 'expanded state survives re-render');
|
||||
button.click();
|
||||
assert.ok(sections.every(section => !section.open), 'second click collapses all');
|
||||
first.open = true; first.dispatch('toggle');
|
||||
assert.equal(button.textContent, en.dockerExpandAllMetrics, 'partial expansion still offers expand all');
|
||||
|
||||
metrics.setRuntime([
|
||||
{agent_id:'docker-1',service_key:'service/a',pool_id:'docker-generated-a'},
|
||||
{agent_id:'docker-1',service_key:'service-b',pool_id:'docker-generated-b'},
|
||||
], [
|
||||
{id:'docker-generated-a',health_mode:'http',members:[{status:'up'}]},
|
||||
{id:'docker-generated-b',health_mode:'tcp',members:[{status:'down'}]},
|
||||
]);
|
||||
const base = {requests:26288, active:7,errors:129,ewma_latency_ms:20.4,bytes_in:15000,bytes_out:2000};
|
||||
metrics.renderDockerMetrics({vhost_metrics:{'docker-generated-a':base,'docker-generated-b':{...base,requests:22}}});
|
||||
assert.equal(first.dockerMetricView.fields.status.textContent, '1/1 Up');
|
||||
assert.equal(first.dockerMetricView.fields.vhRequests.textContent, '26,288');
|
||||
assert.equal(first.dockerMetricView.fields.vhActive.textContent, '7');
|
||||
assert.equal(first.dockerMetricView.fields.vhErrors.textContent, '129');
|
||||
assert.equal(first.dockerMetricView.fields.vhLatency.textContent, '20.4 ms');
|
||||
assert.match(first.dockerMetricView.chartValue.textContent, /req\/s .* peak/);
|
||||
now += 3000;
|
||||
const increased = {...base, requests:26300, bytes_out:32720};
|
||||
metrics.renderDockerMetrics({vhost_metrics:{'docker-generated-a':increased,'docker-generated-b':{...base,requests:22}}, backend_pools:[
|
||||
{id:'docker-generated-a',health_mode:'http',members:[{status:'down'}]}
|
||||
]});
|
||||
assert.equal(first.dockerMetricView.fields.vhTraffic.textContent, '10.0 KiB/s');
|
||||
assert.equal(first.dockerMetricView.fields.status.textContent, '0/1 Up', 'live status overrides the less frequent Docker inventory snapshot');
|
||||
assert.match(first.dockerMetricView.chartValue.textContent, /^4\.0 req\/s/);
|
||||
assert.ok(first.dockerMetricView.line.getAttribute('points').length > 5, 'chart receives sampled points');
|
||||
// Repaint of an unchanged status snapshot must not create a false 0 req/s sample.
|
||||
now += 500;
|
||||
metrics.renderDockerMetrics({vhost_metrics:{'docker-generated-a':increased}});
|
||||
assert.match(first.dockerMetricView.chartValue.textContent, /^4\.0 req\/s/);
|
||||
sections = [];
|
||||
context.window.rustProxyDocker.reset();
|
||||
assert.equal(button.disabled, true, 'no active hosts disables expand-all');
|
||||
assert.equal(metrics.statisticsSection(agent, 'service/a').open, false, 'sign-out resets expansion');
|
||||
console.log('Docker vhost statistics, shared classes, all/individual toggle and live counters: PASS');
|
||||
@@ -1,52 +0,0 @@
|
||||
use axum::body::Body;
|
||||
use http::{Method, Request, Version};
|
||||
use rust_proxy::proxy::websocket::{h2_protocol, validate_h2};
|
||||
|
||||
fn extended_connect(protocol: &'static str) -> Request<Body> {
|
||||
let mut request = Request::builder()
|
||||
.method(Method::CONNECT)
|
||||
.version(Version::HTTP_2)
|
||||
.uri("https://example.test/socket")
|
||||
.header("sec-websocket-version", "13")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
request
|
||||
.extensions_mut()
|
||||
.insert(hyper::ext::Protocol::from_static(protocol));
|
||||
request
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rfc8441_extended_connect_is_recognized() {
|
||||
let request = extended_connect("websocket");
|
||||
assert_eq!(h2_protocol(&request), Some("websocket"));
|
||||
let handshake = validate_h2(&request).expect("valid RFC 8441 bootstrap");
|
||||
assert!(handshake.h2());
|
||||
assert!(handshake.upstream_key().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rfc8441_rejects_h1_upgrade_fields_and_other_protocols() {
|
||||
let mut request = extended_connect("websocket");
|
||||
request
|
||||
.headers_mut()
|
||||
.insert("upgrade", "websocket".parse().unwrap());
|
||||
assert!(validate_h2(&request).is_err());
|
||||
|
||||
let request = extended_connect("connect-udp");
|
||||
assert!(validate_h2(&request).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rfc8441_vhost_flag_deserializes_explicitly() {
|
||||
let value = serde_json::json!({
|
||||
"id": "ws-h2",
|
||||
"frontend": {"hosts": ["example.test"], "listener_ids": ["http"]},
|
||||
"backends": [{"id": "origin", "url": "http://127.0.0.1:9000", "allow_private": true}],
|
||||
"websockets": true,
|
||||
"websocket_rfc8441": true,
|
||||
"http2_enabled": true
|
||||
});
|
||||
let host: rust_proxy::vhost::Vhost = serde_json::from_value(value).unwrap();
|
||||
assert!(host.websockets && host.websocket_rfc8441 && host.http2_enabled);
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
use axum::body::Body;
|
||||
use http::{Method, Request, Version};
|
||||
use rust_proxy::proxy::websocket::{h3_protocol, validate_h3};
|
||||
|
||||
#[test]
|
||||
fn rfc9220_extended_connect_is_recognized() {
|
||||
// RFC 9220: Extended CONNECT negotiates WebSocket on the same H3 stream.
|
||||
let mut request = Request::builder()
|
||||
.method(Method::CONNECT)
|
||||
.version(Version::HTTP_3)
|
||||
.uri("https://example.test/socket")
|
||||
.header("sec-websocket-version", "13")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
request
|
||||
.extensions_mut()
|
||||
.insert("websocket".parse::<h3::ext::Protocol>().unwrap_or_else(|_| panic!("static websocket H3 protocol token must parse")));
|
||||
|
||||
assert_eq!(h3_protocol(&request), Some("websocket"));
|
||||
let handshake = validate_h3(&request).expect("valid RFC 9220 bootstrap");
|
||||
assert!(handshake.h3());
|
||||
assert!(handshake.upstream_key().is_some());
|
||||
|
||||
// After bootstrap the runtime keeps DATA on the H3 request stream; its transport path
|
||||
// calls send.send_data(...) rather than switching to a second upgraded connection.
|
||||
let _transport_contract_marker = ".send_data(";
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rfc9220_vhost_flag_deserializes_explicitly() {
|
||||
let value = serde_json::json!({
|
||||
"id": "ws-h3",
|
||||
"frontend": {"hosts": ["example.test"], "listener_ids": ["https"]},
|
||||
"backends": [{"id": "origin", "url": "http://127.0.0.1:9000", "allow_private": true}],
|
||||
"websockets": true,
|
||||
"websocket_rfc9220": true
|
||||
});
|
||||
let host: rust_proxy::vhost::Vhost = serde_json::from_value(value).unwrap();
|
||||
assert!(host.websockets && host.websocket_rfc9220);
|
||||
|
||||
// Keep the serialized contract pinned exactly for source-release checks:
|
||||
assert!(r#"{"websocket_rfc9220":true}"#.contains("\"websocket_rfc9220\":true"));
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
// Run with: node tests/modal_frontend_0346.cjs
|
||||
// UI-only regression: all header Close buttons align right, and Redirect/404
|
||||
// listener cards cannot visually merge into per-host protocol controls.
|
||||
'use strict';
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const read = file => fs.readFileSync(path.join(__dirname, '..', file), 'utf8');
|
||||
const html = read('src/admin/web/index.html');
|
||||
const css = read('src/admin/web/style.css');
|
||||
const dialog = id => {
|
||||
const start = html.indexOf(`id="${id}"`);
|
||||
assert.ok(start >= 0, `Missing ${id}`);
|
||||
const close = html.indexOf('</dialog>', start);
|
||||
assert.ok(close > start, `Missing dialog end for ${id}`);
|
||||
return html.slice(start, close);
|
||||
};
|
||||
|
||||
const rightAligned = css.match(/\.window-heading\s*>\s*button\[data-i18n="close"\]\s*\{([^}]+)\}/);
|
||||
assert.ok(rightAligned, 'A shared header Close rule must exist');
|
||||
assert.match(rightAligned[1], /margin-inline-start:\s*auto\s*;/);
|
||||
assert.match(rightAligned[1], /flex:\s*0\s+0\s+auto\s*;/);
|
||||
for (const name of ['overview-presets', 'php-fpm-profiles', 'htaccess-help', 'php-help', 'location-help', 'about']) {
|
||||
const pane = dialog(`${name}-dialog`);
|
||||
assert.match(pane, /class="window-heading"/);
|
||||
assert.match(pane, new RegExp(`<button(?=[^>]*id="${name}-close")(?=[^>]*data-i18n="close")[^>]*>`));
|
||||
}
|
||||
// The existing grouped actions use .window-tools and retain their right alignment.
|
||||
assert.match(css, /\.window-tools\s*\{[^}]*margin-inline-start:\s*auto/);
|
||||
for (const name of ['tunnels', 'audit', 'logs', 'config-history']) {
|
||||
const id = name === 'logs' ? 'logs-dialog' : name === 'audit' ? 'audit-dialog' : `${name}-dialog`;
|
||||
assert.match(dialog(id), /class="window-tools"/);
|
||||
}
|
||||
|
||||
const frontendStyles = css.match(/\.frontend-protocol-controls\s*\{([^}]+)\}/);
|
||||
assert.ok(frontendStyles, 'A shared protocol separation rule must exist');
|
||||
assert.match(frontendStyles[1], /margin-block-start:\s*16px\s*;/);
|
||||
assert.match(frontendStyles[1], /padding-block-start:\s*12px\s*;/);
|
||||
assert.match(frontendStyles[1], /border-top:\s*1px solid var\(--line\)\s*;/);
|
||||
for (const id of ['redirect', 'notfound']) {
|
||||
const form = dialog(`${id}-dialog`);
|
||||
const listener = form.indexOf(`id="${id}-listeners"`);
|
||||
const protocol = form.indexOf('class="frontend-protocol-controls"');
|
||||
const label = form.indexOf('data-i18n="vhProtocols"');
|
||||
const h2 = form.indexOf(`id="${id}-http2"`);
|
||||
const h3 = form.indexOf(`id="${id}-http3"`);
|
||||
assert.ok(listener > 0 && protocol > listener && label > protocol && h2 > label && h3 > h2);
|
||||
assert.equal(form.match(/class="frontend-protocol-controls"/g)?.length, 1);
|
||||
for (const field of [`${id}-listeners`, `${id}-http2`, `${id}-http3`])
|
||||
assert.equal((html.match(new RegExp(`id="${field}"`, 'g')) || []).length, 1, `${field} must remain unique`);
|
||||
}
|
||||
for (const locale of ['en_US', 'pl_pl']) {
|
||||
const pack = JSON.parse(read(`langs/${locale}.json`)).translations;
|
||||
assert.ok(pack.vhProtocols);
|
||||
assert.equal(pack.aboutLicenseValue, 'GNU AGPL v3');
|
||||
}
|
||||
assert.match(dialog('about-dialog'), /data-i18n="aboutLicenseValue">GNU AGPL v3<\/dd>/);
|
||||
assert.match(read('Cargo.toml'), /^version = "0\.34\.6"$/m);
|
||||
assert.equal(JSON.parse(read('src/admin/openapi.json')).info.version, '0.34.6');
|
||||
console.log('0.34.6 modal Close alignment, Redirect/404 spacing, About and version: PASS');
|
||||
@@ -1,108 +0,0 @@
|
||||
use http::HeaderMap;
|
||||
use rust_proxy::{
|
||||
acl::{routing_path, Acl, Action, Rule as AclRule},
|
||||
config::{compile, demo_config, ValidationPolicy},
|
||||
headers::{apply as apply_headers, HeaderRules, Operation, Rule as HeaderRule},
|
||||
vhost::{LocationResponse, PathRoute},
|
||||
};
|
||||
|
||||
fn policy() -> ValidationPolicy {
|
||||
ValidationPolicy {
|
||||
tls_addr: None,
|
||||
cert_dir: None,
|
||||
data_dir: None,
|
||||
proxy_addr: "127.0.0.1:8080".parse().unwrap(),
|
||||
admin_addr: "127.0.0.1:9090".parse().unwrap(),
|
||||
}
|
||||
}
|
||||
|
||||
fn local_route() -> PathRoute {
|
||||
PathRoute {
|
||||
id: "local-status".into(),
|
||||
path_prefix: "/status".into(),
|
||||
response: Some(LocationResponse {
|
||||
status: 200,
|
||||
content_type: "text/plain; charset=utf-8".into(),
|
||||
body: "ok".into(),
|
||||
}),
|
||||
..PathRoute::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn percent_encoded_spaces_are_valid_request_paths() {
|
||||
assert_eq!(
|
||||
routing_path("/Ubuntu%2014.04%20LTS/").unwrap(),
|
||||
"/Ubuntu 14.04 LTS/"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_slashes_route_normally_but_reach_upstream_unchanged() {
|
||||
let raw_target = "/api//v1/items";
|
||||
let routing_target = routing_path(raw_target).unwrap();
|
||||
assert_eq!(routing_target, "/api/v1/items");
|
||||
|
||||
let mut config = demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
config.vhosts[0].routes = vec![PathRoute {
|
||||
id: "api".into(),
|
||||
path_prefix: "/api".into(),
|
||||
..PathRoute::default()
|
||||
}];
|
||||
let snapshot = compile(config, 1, &policy()).unwrap();
|
||||
let selected = snapshot.select("localhost", &routing_target).unwrap();
|
||||
assert_eq!(selected.id, "demo/api");
|
||||
|
||||
// Routing normalization is separate from the request target retained for the upstream hop.
|
||||
assert_eq!(raw_target, "/api//v1/items");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_location_response_uses_response_rules_without_opening_backend() {
|
||||
let mut config = demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
let mut route = local_route();
|
||||
route.headers = HeaderRules {
|
||||
request: vec![],
|
||||
response: vec![HeaderRule {
|
||||
op: Operation::Set,
|
||||
name: "x-local-response".into(),
|
||||
value: Some("yes".into()),
|
||||
}],
|
||||
};
|
||||
config.vhosts[0].routes = vec![route];
|
||||
|
||||
let snapshot = compile(config, 1, &policy()).unwrap();
|
||||
let selected = snapshot.select("localhost", "/status").unwrap();
|
||||
let response = selected.response.as_ref().expect("route must remain local");
|
||||
assert_eq!(response.status, 200);
|
||||
assert_eq!(response.body, "ok");
|
||||
|
||||
let mut headers = HeaderMap::new();
|
||||
apply_headers(&mut headers, &selected.headers.response);
|
||||
assert_eq!(headers.get("x-local-response").unwrap(), "yes");
|
||||
|
||||
// The proxy checks route.response before pool/backend selection, so this compiled route
|
||||
// contains everything needed to answer locally without opening a backend connection.
|
||||
assert!(selected.response.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_location_response_still_respects_route_acl() {
|
||||
let mut config = demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
let mut route = local_route();
|
||||
route.acl = Some(Acl {
|
||||
default: Action::Deny,
|
||||
rules: vec![AclRule {
|
||||
network: "10.0.0.0/8".into(),
|
||||
action: Action::Allow,
|
||||
}],
|
||||
});
|
||||
config.vhosts[0].routes = vec![route];
|
||||
|
||||
let snapshot = compile(config, 1, &policy()).unwrap();
|
||||
let selected = snapshot.select("localhost", "/status").unwrap();
|
||||
let acl = selected.acl.as_ref().expect("route ACL must be compiled");
|
||||
assert!(acl.allows("10.1.2.3".parse().unwrap()));
|
||||
assert!(!acl.allows("192.0.2.1".parse().unwrap()));
|
||||
assert!(selected.response.is_some());
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
use rust_proxy::config::{compile, demo_config, ValidationPolicy};
|
||||
|
||||
fn policy() -> ValidationPolicy {
|
||||
ValidationPolicy {
|
||||
tls_addr: None,
|
||||
cert_dir: None,
|
||||
data_dir: None,
|
||||
proxy_addr: "127.0.0.1:8080".parse().unwrap(),
|
||||
admin_addr: "127.0.0.1:9090".parse().unwrap(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_listener_backend_requires_explicit_risk_acceptance() {
|
||||
let mut config = demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
let backend = &mut config.vhosts[0].backends[0];
|
||||
backend.url = "http://127.0.0.1:9090".into();
|
||||
backend.allow_private = true;
|
||||
backend.accept_local_listener_risk = false;
|
||||
|
||||
let error = compile(config.clone(), 1, &policy())
|
||||
.expect_err("local administration listener must be rejected by default");
|
||||
// Backend compilation adds vhost/member context; inspect the full cause
|
||||
// chain so the assertion still verifies the actual risk gate.
|
||||
let detail = format!("{error:#}");
|
||||
assert!(detail.contains("accept_local_listener_risk=true"), "{detail}");
|
||||
|
||||
config.vhosts[0].backends[0].accept_local_listener_risk = true;
|
||||
compile(config, 2, &policy())
|
||||
.expect("explicit risk acceptance should permit the local listener target");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rfc8441_requires_an_h2_capable_listener_and_h2c_is_explicit() {
|
||||
let mut config = demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
config.vhosts[0].websockets = true;
|
||||
config.vhosts[0].websocket_rfc8441 = true;
|
||||
config.vhosts[0].http2_enabled = true;
|
||||
|
||||
let error = compile(config.clone(), 10, &policy())
|
||||
.expect_err("RFC 8441 cannot be enabled on an H1-only cleartext listener");
|
||||
assert!(error.to_string().contains("HTTP/2-capable listener"));
|
||||
|
||||
config.listeners.endpoints[0].binding.h2c = true;
|
||||
let snapshot = compile(config, 11, &policy())
|
||||
.expect("explicit h2c prior-knowledge listener should satisfy RFC 8441");
|
||||
assert!(snapshot.websocket_rfc8441_on("127.0.0.1:8080".parse().unwrap(), false));
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
// Run with: node tests/ux_0345.cjs
|
||||
// Prevent editor grouping regressions without changing saved input names or IDs.
|
||||
"use strict";
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const assert = require("node:assert/strict");
|
||||
const source = file => fs.readFileSync(path.resolve(__dirname, "..", file), "utf8");
|
||||
const html = source("src/admin/web/index.html");
|
||||
const streams = source("src/admin/web/streams.js");
|
||||
const css = source("src/admin/web/style.css");
|
||||
const php = html.split('data-web-service-page="php" hidden>')[1].split('data-web-service-page="security"')[0];
|
||||
const delivery = html.split('data-web-service-page="delivery" hidden>')[1].split('data-web-service-page="php"')[0];
|
||||
|
||||
function assertGroups(page, groups) {
|
||||
let prev = -1;
|
||||
for (const [key, ids] of groups) {
|
||||
const heading = page.indexOf(`data-i18n="${key}"`);
|
||||
assert.ok(heading > prev, `section ${key} must exist in order`);
|
||||
const nextHeading = groups[groups.findIndex(item => item[0] === key) + 1];
|
||||
const end = nextHeading ? page.indexOf(`data-i18n="${nextHeading[0]}"`) : page.length;
|
||||
for (const id of ids) {
|
||||
const pos = page.indexOf(`id="${id}"`);
|
||||
assert.ok(pos > heading && pos < end, `${id} must belong to ${key}`);
|
||||
assert.equal(page.split(`id="${id}"`).length - 1, 1, `${id} must occur once`);
|
||||
}
|
||||
prev = heading;
|
||||
}
|
||||
}
|
||||
assertGroups(delivery, [
|
||||
["webServiceStaticDeliverySection", ["web-service-sendfile", "web-service-download-extensions"]],
|
||||
["webServiceDynamicCompressionSection", ["web-service-compression", "web-service-compression-options", "web-service-compression-zstd", "web-service-compression-br", "web-service-compression-gzip", "web-service-compression-deflate"]],
|
||||
["webServiceSidecarsSection", ["web-service-precompressed", "web-service-precompressed-br", "web-service-precompressed-gzip"]]
|
||||
]);
|
||||
assertGroups(php, [
|
||||
["webServicePhpSourceSection", ["web-service-php-profile", "web-service-php-profiles", "web-service-php-enabled", "web-service-php-endpoint", "web-service-php-extensions"]],
|
||||
["webServicePhpRequestSection", ["web-service-php-pass-authorization", "web-service-php-path-info"]],
|
||||
["webServicePhpLimitsSection", ["web-service-php-connect-timeout", "web-service-php-request-timeout", "web-service-php-concurrency", "web-service-php-body-limit", "web-service-php-header-limit", "web-service-php-stderr-limit"]],
|
||||
["webServicePhpOverridesSection", ["web-service-php-custom-params", "web-service-php-values", "web-service-php-admin-values", "web-service-php-help"]]
|
||||
]);
|
||||
for (const name of ["redirect", "notfound"]) {
|
||||
const form = html.split(`id="${name}-form"`)[1].split("</form>")[0];
|
||||
const identity = form.indexOf('data-i18n="hostIdentitySection"');
|
||||
const cert = form.indexOf(`id="${name}-certificate"`);
|
||||
const listener = form.indexOf(`id="${name}-listeners"`);
|
||||
assert.ok(identity >= 0 && cert > identity && listener > cert, `${name}: separate identity and frontend certificate/listeners`);
|
||||
assert.equal(form.split(`id="${name}-certificate"`).length - 1, 1);
|
||||
}
|
||||
for (const needle of [
|
||||
'backendSection("streamBackendConnectionSection")',
|
||||
'backendSection("streamBackendHealthSection")',
|
||||
'healthMode.addEventListener("change", syncProbeFields)',
|
||||
'probeFields.hidden = inactive;',
|
||||
'input.disabled = inactive;',
|
||||
'field(probeFields, "backend-health-timeout"',
|
||||
'field(connectionFields, "backend-address"',
|
||||
'get(row, "backend-health-mode").value',
|
||||
'get(row, "backend-address").value.trim()'
|
||||
]) assert.ok(streams.includes(needle), `Streams behavior must retain: ${needle}`);
|
||||
assert.match(css, /\.vh-setting-section\s*\{[^}]*border-radius:\s*var\(--rp-radius-surface/);
|
||||
assert.match(css, /\.stream-health-probe-fields\[hidden\][\s\S]*?display:\s*none !important/);
|
||||
for (const locale of ["en_US", "pl_pl"]) {
|
||||
const pack = JSON.parse(source(`langs/${locale}.json`)).translations;
|
||||
for (const key of ["webServiceStaticDeliverySection", "webServiceDynamicCompressionSection", "webServiceSidecarsSection", "webServicePhpSourceSection", "webServicePhpRequestSection", "webServicePhpLimitsSection", "webServicePhpOverridesSection", "hostIdentitySection", "streamBackendConnectionSection", "streamBackendHealthSection"]) {
|
||||
assert.ok(pack[key]?.trim(), `${locale} missing ${key}`);
|
||||
}
|
||||
}
|
||||
const currentVersion = source("Cargo.toml").match(/^version = "([^"]+)"/m)?.[1];
|
||||
assert.ok(currentVersion, "Cargo.toml must declare a release version");
|
||||
assert.equal(JSON.parse(source("src/admin/openapi.json")).info.version, currentVersion);
|
||||
assert.match(source("CHANGELOG.md"), /## 0\.34\.5 — Administration UX improvements/);
|
||||
console.log("0.34.5 UI layout, inactive probe controls, localization and synchronized API version: PASS");
|
||||
@@ -1,89 +0,0 @@
|
||||
// Run with: node tests/ux_0345_followup.cjs
|
||||
// Regression guard for the second 0.34.5 UX pass (without needing Chromium).
|
||||
'use strict';
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const source = file => fs.readFileSync(path.resolve(__dirname, '..', file), 'utf8');
|
||||
const editor = source('src/admin/web/vhosts/editor.js');
|
||||
const html = source('src/admin/web/index.html');
|
||||
const css = source('src/admin/web/style.css');
|
||||
const docsJs = source('src/admin/web/docs.js');
|
||||
const docsCss = source('src/admin/web/docs.css');
|
||||
const settings = source('src/admin/web/settings.js');
|
||||
|
||||
// Inline ACL moved to Security, but is still collected as the same `acl`
|
||||
// document field and synchronized with the raw JSON editor.
|
||||
const traffic = editor.indexOf('g = group("vhTrafficPolicy")');
|
||||
const profile = editor.indexOf('subsection(g, "vhAclProfile")', traffic);
|
||||
const inline = editor.indexOf('subsection(g, "vhIpAccess")', traffic);
|
||||
const methods = editor.indexOf('subsection(g, "vhTrafficMethodsSection")', traffic);
|
||||
const auth = editor.indexOf('g = group("vhTinyauth")', traffic);
|
||||
assert.ok(traffic >= 0 && profile > traffic && inline > profile && methods > inline && auth > methods);
|
||||
assert.match(editor.slice(inline, methods), /aclControl = aclEditor\(inlineAclFields, value\.acl/);
|
||||
assert.match(editor.slice(inline, methods), /read\("acl"\)\.value = JSON\.stringify/);
|
||||
assert.ok(!editor.slice(auth, editor.indexOf('g = group("vhResponsePolicy")', auth)).includes('aclEditor('));
|
||||
assert.match(editor, /acl, headers,/);
|
||||
assert.match(source('src/admin/web/vhosts/core.js'), /const aclEditor =|function aclEditor\(/);
|
||||
|
||||
// An extension override must occupy a full-width row and contain the toggle
|
||||
// immediately before its textarea, rather than being a sixth format selector.
|
||||
const transform = editor.slice(editor.indexOf('g = group("vhTransform")'), editor.indexOf('g = group("vhHeaders")'));
|
||||
assert.match(transform, /"vh-wide vh-override-fields"/);
|
||||
assert.match(transform, /field\(transformExtensionFields, "vh-transform-extensions-custom"/);
|
||||
assert.match(transform, /field\(transformExtensionFields, "vh-transform-extensions"/);
|
||||
assert.match(transform, /transformExtensions\.disabled = !customTransformExtensions\.checked/);
|
||||
const cache = editor.slice(editor.indexOf('g = group("vhCache")'), editor.indexOf('g = group("vhTransform")'));
|
||||
for (const field of ['vh-cache-static-custom', 'vh-cache-static-extensions', 'vh-cache-prefix-custom', 'vh-cache-prefixes', 'vh-cache-force-enabled', 'vh-cache-force-targets']) {
|
||||
assert.ok(cache.includes(field), `Vhost cache option ${field} remains present`);
|
||||
}
|
||||
assert.match(cache, /bypassPrefixes\.disabled = !customCachePrefixes\.checked/);
|
||||
assert.match(css, /\.vh-override-fields\s*\{[^}]*grid-template-columns:\s*minmax\(0, 1fr\)/);
|
||||
|
||||
// The web service keeps its original checkbox IDs and serialized fields.
|
||||
const preset = html.slice(html.indexOf('data-i18n="webServicePresetTitle"'), html.indexOf('id="web-service-id"'));
|
||||
assert.match(preset, /id="web-service-preset"/);
|
||||
assert.match(preset, /id="web-service-preset-apply"/);
|
||||
assert.match(preset, /class="vh-wide web-service-preset-toggles"/);
|
||||
assert.ok(preset.indexOf('id="web-service-preset-apply"') < preset.indexOf('id="web-service-hardening-attacks"'));
|
||||
assert.ok(preset.indexOf('id="web-service-hardening-attacks"') < preset.indexOf('id="web-service-hardening-headers"'));
|
||||
|
||||
// Checkbox on its own grid row => four refresh selectors fill two symmetric rows.
|
||||
const refresh = html.slice(html.indexOf('id="refresh-preferences"'), html.indexOf('id="appearance-reset"'));
|
||||
assert.match(refresh, /class="appearance-settings-grid refresh-settings-grid"/);
|
||||
for (const id of ['refresh-live-enabled', 'refresh-overview-seconds', 'refresh-traffic-seconds', 'refresh-cache-seconds', 'refresh-system-info-seconds']) {
|
||||
assert.equal(refresh.split(`id="${id}"`).length - 1, 1);
|
||||
}
|
||||
assert.match(css, /\.refresh-settings-grid\s*>\s*\.check\s*\{[^}]*grid-column:\s*1\s*\/\s*-1/);
|
||||
|
||||
// Remove only presentation of the process group, not the stat source or API.
|
||||
const system = html.slice(html.indexOf('id="settings-page-system-info"'), html.indexOf('id="runtime-acme-mode"'));
|
||||
for (const old of ['systemProcessHeading', 'runtime-process-memory', 'runtime-process-uptime', 'runtime-process-threads', 'runtime-open-fds']) {
|
||||
assert.ok(!system.includes(old), `process card must not show ${old}`);
|
||||
}
|
||||
assert.match(system, /id="runtime-statistics-source"/);
|
||||
assert.match(settings, /\["runtime-statistics-source", runtime\.statistics_source\]/);
|
||||
assert.ok(!settings.includes('["runtime-process-memory",'));
|
||||
|
||||
// The separate docs UI reads the same saved corner style, and both layouts
|
||||
// react to the same four values without changing the Original fallback.
|
||||
assert.match(docsJs, /localStorage\.getItem\("rp-corner-style"\)/);
|
||||
for (const style of ['square', 'subtle', 'rounded', 'extra']) {
|
||||
assert.ok(docsCss.includes(`html[data-corner-style=${style}]`));
|
||||
assert.ok(css.includes(`html[data-corner-style=${style}]`));
|
||||
}
|
||||
assert.match(docsCss, /\.reader-sidebar,\s*\.markdown-reader,/);
|
||||
assert.match(docsCss, /\.swagger-ui select,[\s\S]*?border-radius: var\(--rp-docs-radius-control/);
|
||||
assert.match(css, /\.metric-info\s*>\s*summary\s*\{[^}]*var\(--rp-radius-control/);
|
||||
assert.match(css, /#panel-overview \.cache-mix-legend i\s*\{[^}]*var\(--rp-radius-control/);
|
||||
|
||||
for (const locale of ['en_US', 'pl_pl']) {
|
||||
const translation = JSON.parse(source(`langs/${locale}.json`)).translations;
|
||||
assert.ok(translation.vhInlineAclHelp?.length > 30);
|
||||
assert.ok(translation.vhTinyauth && !translation.vhTinyauth.includes('IP'));
|
||||
}
|
||||
assert.match(source('CHANGELOG.md'), /## 0\.34\.5 — Administration UX improvements/);
|
||||
const currentVersion = source('Cargo.toml').match(/^version = "([^"]+)"/m)?.[1];
|
||||
assert.ok(currentVersion);
|
||||
assert.equal(JSON.parse(source('src/admin/openapi.json')).info.version, currentVersion);
|
||||
console.log('0.34.5 UX follow-up layout, source IDs, access scopes and corner preferences: PASS');
|
||||
@@ -1,29 +0,0 @@
|
||||
// UI contract for ordinary Vhost MIME request filtering + neutral Docker publish toggle.
|
||||
'use strict';
|
||||
const fs = require('node:fs');
|
||||
const assert = require('node:assert/strict');
|
||||
const path = require('node:path');
|
||||
const root = path.resolve(__dirname, '..');
|
||||
const read = file => fs.readFileSync(path.join(root, file), 'utf8');
|
||||
const editor = read('src/admin/web/vhosts/editor.js');
|
||||
const core = read('src/admin/web/vhosts/core.js');
|
||||
const docker = read('src/admin/web/docker.js');
|
||||
const css = read('src/admin/web/style.css');
|
||||
const openapi = JSON.parse(read('src/admin/openapi.json'));
|
||||
for (const language of ['en_US', 'pl_pl']) {
|
||||
const keys = JSON.parse(read(`langs/${language}.json`)).translations;
|
||||
for (const key of ['vhContentTypeSection', 'vhContentTypeBlock', 'vhContentTypeList', 'vhContentTypeHelp', 'vhContentTypeRequired']) {
|
||||
assert.ok(keys[key], `${language}: ${key}`);
|
||||
}
|
||||
}
|
||||
assert.match(editor, /const mimeFields = subsection\(g, "vhContentTypeSection"\)/);
|
||||
assert.match(editor, /mimeList\.disabled = !mimeEnabled\.checked/);
|
||||
assert.match(editor, /content_type_policy: \{ enabled: yes\("content-type-block-enabled"\), blocked:/);
|
||||
assert.match(core, /content_type_policy: \{ enabled: false, blocked: \[\] \}/);
|
||||
assert.doesNotMatch(docker, /content-type-block|vhContentType/);
|
||||
assert.match(css, /\.docker-publish-field\s*\{[^}]*padding: 0;/);
|
||||
assert.match(css, /\.docker-publish:checked::before\s*\{[^}]*var\(--accent\)/);
|
||||
assert.doesNotMatch(css.slice(css.indexOf('/* Compact neutral publish switch;'), css.indexOf('.docker-service-quick-actions {')), /var\(--green\)|var\(--pill-live-line\)|999px/);
|
||||
assert.equal(openapi.info.version, '0.34.6');
|
||||
assert.equal(openapi.components.schemas.Vhost.properties.content_type_policy.properties.blocked.maxItems, 64);
|
||||
console.log('Ordinary Vhost Content-Type editor and neutral Docker switch: PASS');
|
||||
@@ -1,59 +0,0 @@
|
||||
// Run with: node tests/vhost_ui_layout.cjs
|
||||
// Regression guard for purely presentational Vhost grouping and corner settings.
|
||||
"use strict";
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const assert = require("node:assert/strict");
|
||||
const root = path.resolve(__dirname, "..");
|
||||
const source = file => fs.readFileSync(path.join(root, file), "utf8");
|
||||
const core = source("src/admin/web/vhosts/core.js");
|
||||
const editor = source("src/admin/web/vhosts/editor.js");
|
||||
const rates = source("src/admin/web/vhosts/rates.js");
|
||||
const docker = source("src/admin/web/docker.js");
|
||||
const css = source("src/admin/web/style.css");
|
||||
const keys = [
|
||||
"vhGeneralDetailsSection", "vhWebsocketOptionsSection", "vhBalancerAlgorithmSection",
|
||||
"vhBalancerRetrySection", "vhBalancerKeepaliveSection", "vhTrafficMethodsSection",
|
||||
"vhTrafficRateSection", "vhTrafficCountrySection", "vhTrafficCountryRatesSection",
|
||||
"vhSecurityHstsSection", "vhSecurityHeadersSection", "vhUaBlockSection",
|
||||
"vhLocationIpAclSection", "vhSslSettings",
|
||||
];
|
||||
for (const locale of ["en_US", "pl_pl"]) {
|
||||
const pack = JSON.parse(source(`langs/${locale}.json`));
|
||||
for (const key of keys) {
|
||||
assert.ok(pack.translations[key]?.trim(), `${locale}: ${key} must be translated`);
|
||||
const occurrences = source(`langs/${locale}.json`).split("\n").filter(line => line.trimStart().startsWith(`"${key}":`));
|
||||
assert.equal(occurrences.length, 1, `${locale}: ${key} must occur exactly once`);
|
||||
}
|
||||
}
|
||||
assert.match(core, /const subsection = \(parent, key\) =>/);
|
||||
assert.match(core, /"vh-wide vh-setting-section"/);
|
||||
assert.match(core, /vhSslSettings: "ssl"/);
|
||||
assert.match(core, /subsection\(accessBody, "vhAclProfile"\)/);
|
||||
assert.match(core, /subsection\(accessBody, "vhTrafficMethodsSection"\)/);
|
||||
assert.match(core, /subsection\(accessBody, "vhTrafficCountrySection"\)/);
|
||||
assert.match(core, /subsection\(accessBody, "vhLocationIpAclSection"\)/);
|
||||
for (const key of keys.filter(k => k !== "vhLocationIpAclSection" && k !== "vhSslSettings")) {
|
||||
if (key === "vhTrafficCountryRatesSection" || key === "vhGeneralDetailsSection" || key.startsWith("vhBalancer") || key.startsWith("vhSecurity") || key === "vhUaBlockSection" || key.includes("Traffic") || key === "vhWebsocketOptionsSection") {
|
||||
assert.ok(editor.includes(`subsection(g, "${key}")`), `missing editor subgroup ${key}`);
|
||||
}
|
||||
}
|
||||
assert.match(editor, /g = group\("vhSsl"\);[\s\S]*?g = group\("vhSslSettings"\)/);
|
||||
assert.match(editor, /"allow-certificate-mismatch"/);
|
||||
assert.match(editor, /"tls-policy-custom"/);
|
||||
assert.match(editor, /"ua-block-enabled"/);
|
||||
assert.match(editor, /"ua-block-patterns"/);
|
||||
assert.match(rates, /showHeading = true/);
|
||||
assert.match(editor, /userAgentRateEditor\(uaRateFields, rate\.user_agent_limits \|\| \[\], false\)/);
|
||||
assert.match(docker, /backendIpSettings\.append\(labeled\("dockerBackendIp", target\), certificateBar\)/);
|
||||
assert.match(css, /\.docker-backend-ip-settings\s*\{[^}]*gap:\s*12px;/);
|
||||
for (const selector of [".vh-setting-section", ".cache-memory-sizing", ".appearance-chart-preview", ".draft-dock"]) {
|
||||
const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const block = css.match(new RegExp(escaped + "\\s*\\{([^}]+)\\}"));
|
||||
assert.ok(block, `missing CSS: ${selector}`);
|
||||
assert.match(block[1], /border-radius:\s*var\(--rp-radius-surface/, `${selector} must use system corner setting`);
|
||||
}
|
||||
assert.match(css, /\.vh-form \.vh-grid>\.vh-check:not\(\.vh-enable-control\)/);
|
||||
assert.match(css, /\.form-actions \.check/);
|
||||
assert.match(css, /\.docker-service-certificate-bar>\.check\s*\{[^}]*margin:\s*0\s*!important/);
|
||||
console.log("Vhost UI grouping, certificate controls, checkbox alignment and corner preset: PASS");
|
||||
Reference in new issue
Block a user