269 lines
15 KiB
Python
Executable File
269 lines
15 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Simulated Chromium Vhost backend quick-view, without a Rust server.
|
|
|
|
Verify both backend triggers, the shared click-to-open upstream H2 protocol
|
|
popover without native title flicker, and the two Cache card margins.
|
|
Exercise live updates, safe text, keyboard/outside dismissal and mobile sizing.
|
|
"""
|
|
from pathlib import Path
|
|
from panel_html import render_admin_index
|
|
from playwright.sync_api import sync_playwright
|
|
import json
|
|
import re
|
|
import time
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
HTML = render_admin_index(ROOT)
|
|
CSS = (ROOT / 'src/admin/web/style.css').read_text()
|
|
ROUTER = (ROOT / 'src/admin/router.rs').read_text()
|
|
PACKS = {lang: {'id': lang, **json.loads((ROOT / 'langs' / f'{lang}.json').read_text())}
|
|
for lang in ('en_US', 'pl_pl')}
|
|
|
|
|
|
def app_bundle():
|
|
found = re.search(r'fn admin_app_javascript\([^)]*\)[^{]*\{', ROUTER)
|
|
assert found
|
|
code = ROUTER[found.end():]
|
|
code = code[:re.search(r'\]\s*\.join\("\\n"\)', code).start()]
|
|
fragments = []
|
|
for line in code.splitlines():
|
|
line = line.strip()
|
|
include = re.search(r'include_str!\("([^"]+)"\)', line)
|
|
if include:
|
|
fragments.append((ROOT / 'src/admin' / include.group(1)).read_text())
|
|
elif line == 'admin_vhosts_javascript(),':
|
|
fragments.append('(() => {\n' + '\n'.join(
|
|
(ROOT / 'src/admin/web/vhosts' / f'{name}.js').read_text()
|
|
for name in ('core', 'protocol', 'health', 'rates', 'editor', 'render')) + '\n})();')
|
|
elif line.startswith('"') and line.endswith('",'):
|
|
fragments.append(json.loads(line[:-1]))
|
|
return '\n'.join(fragments)
|
|
|
|
|
|
CONFIG = {
|
|
'vhosts': [{
|
|
'id': 'api-one', 'enabled': True, 'source': {'type': 'reverse_proxy'},
|
|
'frontend': {'hosts': ['api.example.test'], 'listener_ids': []},
|
|
'backends': [
|
|
{'id': 'origin-http', 'url': 'http://10.12.0.5:8080/api', 'protocol': 'http2', 'enabled': True},
|
|
{'id': '<origin-dns>', 'url': 'https://hiddenuser:secret@backend.example.test:8443/path', 'protocol': 'auto', 'enabled': True},
|
|
]
|
|
}, {'id': 'static-only', 'enabled': True,
|
|
'source': {'type': 'web_server', 'web_service_id': 'static-test'},
|
|
'frontend': {'hosts': ['static.example.test'], 'listener_ids': []}, 'backends': []}],
|
|
'listeners': {'endpoints': []}, 'web_services': [], 'acl_profiles': [],
|
|
}
|
|
|
|
|
|
def snapshot(first=200):
|
|
return {'backend_pools': [{
|
|
'id': 'api-one', 'health_mode': 'on',
|
|
'members': [
|
|
{'id': 'origin-http', 'status': 'up', 'last_status': first,
|
|
'requests': 12, 'active': 0, 'errors': 0, 'bytes_in': 0, 'bytes_out': 0},
|
|
{'id': '<origin-dns>', 'status': 'down', 'last_status': 0,
|
|
'requests': 0, 'active': 0, 'errors': 0, 'bytes_in': 0, 'bytes_out': 0},
|
|
]
|
|
}], 'vhost_metrics': {}}
|
|
|
|
|
|
def protocol_snapshot(h2_count):
|
|
status = snapshot()
|
|
status['backend_pools'][0]['members'][0].update({
|
|
'upstream_protocol_observed': 'h2',
|
|
'upstream_protocol_last_seen_ms': int(time.time() * 1000),
|
|
'upstream_protocol_http1_total': 3,
|
|
'upstream_protocol_http2_total': h2_count,
|
|
})
|
|
return status
|
|
|
|
|
|
with sync_playwright() as playwright:
|
|
browser = playwright.chromium.launch(headless=True, executable_path='/usr/bin/chromium', args=['--no-sandbox'])
|
|
for width in (320, 375, 768, 1440):
|
|
page = browser.new_page(viewport={'width': width, 'height': 820})
|
|
errors = []
|
|
page.on('pageerror', lambda error: errors.append(str(error)))
|
|
page.set_content(HTML.replace('/assets/app___RUSTPROXY_ASSET_ID__.js', '')
|
|
.replace('/assets/style___RUSTPROXY_ASSET_ID__.css', ''))
|
|
page.add_style_tag(content=CSS)
|
|
page.add_script_tag(content=app_bundle())
|
|
page.evaluate('''([config, status, packs]) => {
|
|
languagePacks = new Map(Object.entries(packs));
|
|
defaultLanguage = 'en_US'; language = 'pl_pl';
|
|
document.documentElement.lang = 'pl-PL';
|
|
document.documentElement.classList.remove('app-loading', 'app-startup-failed');
|
|
document.getElementById('login').hidden = true;
|
|
document.getElementById('workspace').hidden = false;
|
|
document.getElementById('panel-vhosts').hidden = false;
|
|
currentPanel = 'vhosts';
|
|
envelope = {revision: 12, config};
|
|
latestStatus = status;
|
|
window.rustProxyVhosts.render();
|
|
}''', [CONFIG, snapshot(), PACKS])
|
|
vhost = page.locator('[data-vhost-card="api-one"]')
|
|
backend = vhost.locator('.vhost-detail.vhost-backend-trigger')
|
|
health = vhost.locator('.vhost-live-metric.vhost-backend-trigger')
|
|
assert backend.count() == health.count() == 1, width
|
|
assert backend.get_attribute('aria-expanded') == 'false'
|
|
assert backend.get_attribute('aria-haspopup') == 'dialog'
|
|
# Clickable backend/health fields must match their non-button peers,
|
|
# including for user-selected interface corner styles.
|
|
shapes = page.evaluate('''() => {
|
|
const root = document.querySelector('[data-vhost-card="api-one"]');
|
|
const peers = [
|
|
[root.querySelector('.vhost-detail.vhost-backend-trigger'), root.querySelector('.vhost-detail:not(.vhost-backend-trigger)')],
|
|
[root.querySelector('.vhost-live-metric.vhost-backend-trigger'), root.querySelector('.vhost-live-metric:not(.vhost-backend-trigger)')]
|
|
];
|
|
const checks = ['square', 'subtle', 'rounded', 'extra'].map(corner => {
|
|
document.documentElement.dataset.cornerStyle = corner;
|
|
return {corner, tiles: peers.map(([trigger, peer]) => {
|
|
const left = getComputedStyle(trigger), right = getComputedStyle(peer);
|
|
return {radius: left.borderRadius, peerRadius: right.borderRadius,
|
|
padding: left.padding, peerPadding: right.padding,
|
|
font: left.fontFamily, peerFont: right.fontFamily};
|
|
})};
|
|
});
|
|
document.documentElement.removeAttribute('data-corner-style');
|
|
return checks;
|
|
}''')
|
|
for shape in shapes:
|
|
for tile in shape['tiles']:
|
|
assert tile['radius'] == tile['peerRadius'] == '0px', (width, shape)
|
|
assert tile['padding'] == tile['peerPadding'], (width, shape)
|
|
assert tile['font'] == tile['peerFont'], (width, shape)
|
|
backend.click()
|
|
popup = page.locator('#vhost-backend-popup')
|
|
assert popup.count() == 1, width
|
|
assert backend.get_attribute('aria-expanded') == 'true'
|
|
assert popup.get_attribute('role') == 'dialog'
|
|
# The anchored popup is a surface, not a permanently rounded tooltip.
|
|
# It must follow live interface corner preference changes without
|
|
# dismissing/recreating the open backend (or shared H2) popup.
|
|
corners = page.evaluate('''() => {
|
|
const node = document.getElementById('vhost-backend-popup');
|
|
const saved = document.documentElement.getAttribute('data-corner-style');
|
|
const out = {};
|
|
for (const style of ['default', 'square', 'subtle', 'rounded', 'extra']) {
|
|
document.documentElement.dataset.cornerStyle = style;
|
|
out[style] = {
|
|
popup: getComputedStyle(node).borderRadius,
|
|
row: getComputedStyle(node.querySelector('.vhost-backend-popup-row')).borderRadius,
|
|
close: getComputedStyle(node.querySelector('.vhost-backend-popup-close')).borderRadius,
|
|
reference: getComputedStyle(document.querySelector('[data-vhost-card="api-one"]')).borderRadius,
|
|
sameNode: node === document.getElementById('vhost-backend-popup'),
|
|
};
|
|
}
|
|
if (saved === null) document.documentElement.removeAttribute('data-corner-style');
|
|
else document.documentElement.dataset.cornerStyle = saved;
|
|
return out;
|
|
}''')
|
|
expected_radii = {'default': ('0px', '0px'), 'square': ('0px', '0px'),
|
|
'subtle': ('5px', '4px'), 'rounded': ('12px', '8px'),
|
|
'extra': ('18px', '12px')}
|
|
for style, (surface, control) in expected_radii.items():
|
|
actual = corners[style]
|
|
assert actual == {'popup': surface, 'row': surface, 'close': control,
|
|
'reference': surface, 'sameNode': True}, (width, style, actual)
|
|
values = popup.evaluate('''node => ({
|
|
cards: node.querySelectorAll('.vhost-backend-popup-row').length,
|
|
text: node.textContent, html: node.innerHTML,
|
|
box: (() => { const r = node.getBoundingClientRect();
|
|
return {left: r.left, right: r.right, top:r.top, bottom:r.bottom}; })(),
|
|
})''')
|
|
assert values['cards'] == 2, (width, values)
|
|
assert '10.12.0.5:8080' in values['text'] and 'HTTP 200' in values['text'], (width, values)
|
|
assert 'backend.example.test:8443' in values['text'], width
|
|
assert 'secret' not in values['text'], width
|
|
assert '<origin-dns>' in values['text'] and '<origin-dns>' in values['html'], width
|
|
assert 'Brak odpowiedzi HTTP' in values['text'], width
|
|
assert 'HTTPS · AUTO' in values['text'], width
|
|
assert values['box']['left'] >= -1 and values['box']['right'] <= width + 1, (width, values['box'])
|
|
assert values['box']['top'] >= -1 and values['box']['bottom'] <= 821, (width, values['box'])
|
|
assert page.evaluate('document.documentElement.scrollWidth - innerWidth') <= 2, width
|
|
page.evaluate('''s => window.rustProxyVhosts.renderStatus(s)''', snapshot(503))
|
|
assert 'HTTP 503' in popup.inner_text(), width
|
|
assert popup.locator('.has-error').count() == 1, width
|
|
health.click()
|
|
assert popup.count() == 1 and health.get_attribute('aria-expanded') == 'true', width
|
|
assert backend.get_attribute('aria-expanded') == 'false', width
|
|
page.keyboard.press('Escape')
|
|
assert popup.count() == 0 and health.get_attribute('aria-expanded') == 'false', width
|
|
assert health.evaluate('(node) => document.activeElement === node'), width
|
|
backend.click()
|
|
page.locator('#vhost-search').click(force=True)
|
|
assert popup.count() == 0, width
|
|
# A static-only vhost presents a translated empty state, not stale pool values.
|
|
page.locator('[data-vhost-card="static-only"] .vhost-detail.vhost-backend-trigger').click()
|
|
assert 'Brak skonfigurowanych backendów.' in popup.inner_text(), width
|
|
popup.locator('.vhost-backend-popup-close').click()
|
|
assert popup.count() == 0, width
|
|
backend.click()
|
|
assert popup.count() == 1, width
|
|
|
|
# The existing backend popover is also used by the H2 policy badge.
|
|
protocol = vhost.locator('[data-upstream-protocol-backend="origin-http"]')
|
|
assert protocol.count() == 1 and protocol.evaluate('n => n.tagName') == 'BUTTON', width
|
|
assert protocol.get_attribute('title') is None, width
|
|
protocol.click()
|
|
assert backend.get_attribute('aria-expanded') == 'false', width
|
|
assert protocol.get_attribute('aria-expanded') == 'true', width
|
|
assert 'Brak odpowiedzi backendu' in popup.inner_text(), width
|
|
page.evaluate('''s => window.rustProxyVhosts.renderStatus(s)''', protocol_snapshot(8))
|
|
assert 'origin-http · H2 compatible → H2' in protocol.inner_text(), width
|
|
assert 'Ostatnia wersja HTTP' in popup.inner_text(), width
|
|
assert popup.locator('.vhost-backend-popup-field > span:last-child').all_inner_texts() == ['3', '8'], width
|
|
page.evaluate('''() => {
|
|
window.__popupReference = document.getElementById('vhost-backend-popup');
|
|
window.__headingReference = window.__popupReference.querySelector('h4');
|
|
window.__popupRemoved = 0;
|
|
window.__popupObserver = new MutationObserver(records => {
|
|
for (const record of records) for (const item of record.removedNodes) {
|
|
if (item === window.__popupReference) window.__popupRemoved++;
|
|
}
|
|
});
|
|
window.__popupObserver.observe(document.body, {childList: true});
|
|
}''')
|
|
for count in range(9, 19):
|
|
page.evaluate('''s => window.rustProxyVhosts.renderStatus(s)''', protocol_snapshot(count))
|
|
unchanged = page.evaluate('''() => ({
|
|
samePopup: document.getElementById('vhost-backend-popup') === window.__popupReference,
|
|
sameHeading: window.__popupReference.querySelector('h4') === window.__headingReference,
|
|
removed: window.__popupRemoved,
|
|
expanded: document.querySelector('[data-upstream-protocol-backend="origin-http"]').getAttribute('aria-expanded'),
|
|
})''')
|
|
assert unchanged == {'samePopup': True, 'sameHeading': True, 'removed': 0, 'expanded': 'true'}, (width, unchanged)
|
|
assert popup.locator('.vhost-backend-popup-field > span:last-child').all_inner_texts() == ['3', '18'], width
|
|
page.evaluate('window.__popupObserver.disconnect()')
|
|
assert protocol.get_attribute('title') is None, width
|
|
# A stale observation should change the displayed information in
|
|
# place, not dismiss/recreate the click popover.
|
|
stale = protocol_snapshot(18)
|
|
stale['backend_pools'][0]['members'][0]['upstream_protocol_last_seen_ms'] -= 600000
|
|
page.evaluate('''s => window.rustProxyVhosts.renderStatus(s)''', stale)
|
|
assert 'H2 compatible → ?' in protocol.inner_text(), width
|
|
assert popup.locator('.vhost-backend-popup-list').is_hidden(), width
|
|
assert page.evaluate('document.getElementById("vhost-backend-popup") === window.__popupReference'), width
|
|
page.keyboard.press('Escape')
|
|
assert popup.count() == 0 and protocol.get_attribute('aria-expanded') == 'false', width
|
|
assert protocol.evaluate('node => document.activeElement === node'), width
|
|
|
|
# Cache Domains and Stored objects are the only directly adjacent
|
|
# table cards and need a visible, small gap at any viewport width.
|
|
cache_gap = page.evaluate('''() => {
|
|
const panel = document.getElementById('panel-cache');
|
|
panel.hidden = false;
|
|
const [domains, stored] = panel.querySelectorAll(':scope > article.card');
|
|
const gap = stored.getBoundingClientRect().top - domains.getBoundingClientRect().bottom;
|
|
panel.hidden = true;
|
|
return gap;
|
|
}''')
|
|
assert 9.5 <= cache_gap <= 10.5, (width, cache_gap)
|
|
page.evaluate('window.rustProxyVhosts.reset()')
|
|
assert popup.count() == 0, width
|
|
assert not errors, (width, errors)
|
|
print(f'PASS Vhosts shared backend/H2 popover and Cache gap at {width}px')
|
|
page.close()
|
|
browser.close()
|
|
print('PASS Vhost quick-view with simulated runtime status (not live Rust)')
|