188 lines
10 KiB
JavaScript
188 lines
10 KiB
JavaScript
// 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; });
|