168 lines
8.7 KiB
JavaScript
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).');
|