// 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; });