Files
RustProxy/tools/checks/docker_startup_0318_ui_test.mjs
T
2026-10-12 14:57:37 +02:00

168 lines
8.7 KiB
JavaScript

#!/usr/bin/env node
// Runs the real Docker VHosts dashboard module against a minimal DOM + API fixture.
// This is a JS-only regression, not a compiled RustProxy or live agent test.
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import vm from 'node:vm';
const dockerSource = readFileSync('src/admin/web/docker.js', 'utf8');
const coreSource = readFileSync('src/admin/web/app/core.js', 'utf8');
const english = JSON.parse(readFileSync('langs/en_US.json', 'utf8')).translations;
const polish = JSON.parse(readFileSync('langs/pl_pl.json', 'utf8')).translations;
for (const translations of [english, polish]) {
assert.ok(translations.dockerNoObservation);
assert.ok(translations.dockerAgentConnectionFailed);
assert.ok(translations.dockerUnknownPollError);
assert.doesNotMatch(translations.dockerNoObservation.toLowerCase(), /refresh|odśwież/);
}
class FakeNode {
constructor(tag, text = '', cls = '') {
this.tagName = tag;
this.content = String(text ?? '');
this.className = cls;
this.children = [];
this.dataset = {};
this.attrs = {};
this.events = {};
this.hidden = false;
this.open = false;
this.classList = {
toggle: (name, value) => {
const parts = new Set(this.className.split(' ').filter(Boolean));
if (value) parts.add(name); else parts.delete(name);
this.className = [...parts].join(' ');
},
add: name => { this.className += ` ${name}`; },
remove: name => { this.className = this.className.split(' ').filter(part => part !== name).join(' '); },
};
}
get textContent() { return this.content + this.children.map(child => child.textContent || '').join(''); }
set textContent(value) { this.content = String(value ?? ''); this.children = []; }
append(...nodes) { this.children.push(...nodes.filter(Boolean)); }
replaceChildren(...nodes) { this.children = nodes.filter(Boolean); }
setAttribute(name, value) { this.attrs[name] = value; }
addEventListener(name, fn) { this.events[name] = fn; }
descendants() { return this.children.flatMap(child => [child, ...child.descendants()]); }
querySelectorAll(selector) {
const part = selector.split(' ').at(-1);
if (part.startsWith('.')) return this.descendants().filter(child => child.className.split(' ').includes(part.slice(1)));
return [];
}
querySelector(selector) { return this.querySelectorAll(selector)[0] || null; }
}
const node = (tag, text, cls) => new FakeNode(tag, text, cls);
const destination = node('div');
const elements = new Map([
['docker-discovery-list', destination],
['overview-refresh', node('button')],
['page-label', node('span')],
['page-title', node('h1')],
['page-description', node('p')]
]);
const agent = {
id: 'docker1', name: 'Docker One', enabled: true,
base_domain: 'apps.example.test', endpoint: 'https://192.0.2.2:9876',
tls_fingerprint_sha256: 'A'.repeat(64), poll_interval_seconds: 30,
assignments: []
};
const config = { docker_agents: { agents: [agent] }, listeners: { endpoints: [] }, tls: { certificates: [] } };
const storage = new Map();
const requests = [];
let nextResult = { observations: [], pools: [], pool_routes: [] };
let refreshSpec;
let syncs = 0, manuals = 0;
const coordinator = {
register(id, spec) { assert.equal(id, 'docker'); refreshSpec = spec; },
sync() { syncs++; },
manual(id) { assert.equal(id, 'docker'); manuals++; return Promise.resolve(); },
setPanel(name) { this.panel = name; },
};
const ctx = {
el: node,
$: name => elements.get(name) || null,
t: name => english[name] || name,
document: {
activeElement: null,
querySelectorAll(selector) {
if (selector === '#docker-discovery-list .docker-agent-card') return destination.children;
return [];
}
},
window: { rustProxyRefresh: coordinator, rustProxySettings: {} },
rustProxyRefresh: coordinator,
localStorage: { getItem: key => storage.get(key) || null, setItem: (key, value) => storage.set(key, value) },
authenticated: true, authEpoch: 1,
currentPanel: 'docker-vhosts', envelope: { config },
settingsDirty: new Set(), busy: false,
api: async (path, args = {}) => { requests.push({ path, args }); return nextResult; },
toast(message) { throw new Error(`Unexpected toast: ${message}`); },
console,
};
vm.createContext(ctx);
vm.runInContext(dockerSource, ctx, { filename: 'src/admin/web/docker.js' });
assert(refreshSpec, 'register Docker refresh with central scheduler');
assert.equal(refreshSpec.interval(), 5);
ctx.window.rustProxyDocker.render();
assert.equal(manuals, 1, 'deep-link bootstrap fetches a snapshot after configuration loads');
assert.equal(syncs, 1, 'deep-link bootstrap syncs waiting-state scheduler');
assert.equal(refreshSpec.active(), true, 'bootstrap retries cached status without requiring local Live view');
assert.match(destination.textContent, /WAITING/);
assert.match(destination.textContent, /Waiting for the first agent check/);
assert.doesNotMatch(destination.textContent, /Last Docker agent check failed/);
assert.doesNotMatch(destination.textContent, /Agent connection failed/);
assert.equal(destination.querySelector('.docker-agent-error'), null, 'pending must not be styled as a failure');
let tlsPill = destination.querySelectorAll('.pill').find(item => item.textContent === 'TLS PINNED');
assert.ok(tlsPill);
assert.ok(!tlsPill.className.split(' ').includes('live'), 'configured fingerprint does not prove successful handshake');
await ctx.window.rustProxyDocker.refresh(false);
assert.equal(requests.at(-1).path, '/api/docker');
assert.notEqual(requests.at(-1).args.method, 'POST', 'automatic status fetch never polls the agent');
assert.equal(refreshSpec.active(), true, 'retry while the initial server-side poll is not complete');
nextResult = { observations: [{ agent_id: 'docker1', ok: false, error: 'connection refused', checked_at_unix_ms: 1, inventory: null }], pools: [], pool_routes: [] };
await ctx.window.rustProxyDocker.refresh(false);
assert.match(destination.textContent, /Last Docker agent check failed/);
assert.match(destination.textContent, /connection refused/);
assert.doesNotMatch(destination.textContent, /TLS certificate SHA-256:/, 'pinned fingerprint is configuration, not the reason for an arbitrary failure');
assert.ok(destination.querySelector('.docker-agent-error'), 'a genuine failed observation must be visible');
assert.equal(refreshSpec.active(), false, 'local Live-off stops automatic refresh after first status, even if it is a failure');
nextResult = { observations: [{ agent_id: 'docker1', ok: true, error: null, checked_at_unix_ms: Date.now(), inventory: {
agent_version: '0.22.3', hostname: 'docker1', addresses: [], services: [], public_ip: '192.0.2.2', certificate_sha256: 'A'.repeat(64)
} }], pools: [], pool_routes: [] };
await ctx.window.rustProxyDocker.refresh(false);
assert.match(destination.textContent, /CONNECTED/);
assert.doesNotMatch(destination.textContent, /Last Docker agent check failed/);
assert.equal(refreshSpec.active(), false, 'local Live-off remains off with a successful observation');
tlsPill = destination.querySelectorAll('.pill').find(item => item.textContent === 'TLS PINNED');
assert.ok(tlsPill.className.split(' ').includes('live'), 'pin may be marked verified after a successful poll');
storage.set('rustproxy.docker.live-view', '1');
assert.equal(refreshSpec.active(), true, 'Live view still polls normally');
storage.delete('rustproxy.docker.live-view');
// Execute the real panel-navigation function with side effects disabled.
const from = coreSource.indexOf('function setPanel(name, navigate = true) {');
const to = coreSource.indexOf('function bytes(value)', from);
assert(from >= 0 && to > from);
ctx.PANEL_PATHS = { overview: '/', 'docker-vhosts': '/docker-vhosts' };
vm.runInContext(coreSource.slice(from, to), ctx);
ctx.currentPanel = 'overview';
ctx.setPanel('docker-vhosts', false);
assert.equal(manuals, 2, 'opening Docker VHosts requests a new cached snapshot even with Live view off');
ctx.setPanel('docker-vhosts', false);
assert.equal(manuals, 2, 'rendering the same panel does not duplicate one-shot requests');
ctx.setPanel('overview', false);
ctx.setPanel('docker-vhosts', false);
assert.equal(manuals, 3, 're-entering the page reads the newest cached status');
ctx.currentPanel = 'overview';
assert.equal(refreshSpec.active(), false, 'no bootstrap activity outside Docker VHosts');
ctx.currentPanel = 'docker-vhosts';
config.docker_agents.agents = [{ ...agent, enabled: false }];
ctx.window.rustProxyDocker.render();
assert.equal(refreshSpec.active(), false, 'disabled agents do not cause polling');
assert.doesNotMatch(destination.textContent, /Last Docker agent check failed/);
console.log('PASS: 0.31.9 Docker startup awaiting/poll failure/success, pinned TLS semantics, Live-off bootstrap and return-navigation refresh (real JS, simulated DOM/API).');