Files
Ansible/scripts/addons/webgui/tests/browser_qa.py
T
2026-09-22 19:23:17 +02:00

192 lines
12 KiB
Python

"""Optional Chromium fixture QA, not deployment/remote/production-HTMX acceptance.
Uses the real supplied Core for discovery against a temporary synthetic inventory.
The external assets are embedded only in about:blank test documents. With no pinned
assets available, --bootstrap (or the local default) is a declared substitute.
No substitutions are written into production static/vendor.
"""
from pathlib import Path
import argparse
import json
import re
import sys
import tempfile
import time
sys.path.insert(0,str(Path(__file__).resolve().parents[1]/'src'))
from fastapi.testclient import TestClient
from playwright.sync_api import sync_playwright
from aim_webgui.app import create_app
from aim_webgui.auth.service import Auth
from conftest import core_root as core_fixture, settings as settings_fixture
from test_read_experience import add_job
ROOT=Path(__file__).resolve().parents[1]
def main():
parser=argparse.ArgumentParser()
parser.add_argument('--out',type=Path,required=True)
parser.add_argument('--bootstrap',type=Path,default=Path('/opt/imagemagick/share/doc/ImageMagick-7/www/assets/bootstrap.min.css'))
args=parser.parse_args();args.out.mkdir(parents=True,exist_ok=True)
results=[];screenshots=[]
with tempfile.TemporaryDirectory(prefix='aim-browser-readonly-') as tmp:
tmp=Path(tmp);core=core_fixture.__wrapped__(tmp);settings=settings_fixture.__wrapped__(tmp,core)
(core/'inventories/example/hosts.yml').write_text('''all:
children:
example:
hosts:
edge-01.example:
ansible_host: 192.0.2.1
children:
linux:
hosts:
api-01.example:
ansible_host: 192.0.2.10
api-02.example:
ansible_host: 192.0.2.11
children:
database:
hosts:
db-01.example:
ansible_host: 192.0.2.12
db-02.example:
ansible_host: 192.0.2.13
windows:
hosts:
desktop-01.example:
ansible_host: 192.0.2.20
children:
servers:
hosts:
mail-01.example:
ansible_host: 192.0.2.21
app-01.example:
ansible_host: 192.0.2.22
staging:
hosts:
api-02.example: {}
''')
print('Fixture core ready',flush=True)
auth=Auth(settings);auth.bootstrap()
print('Fixture auth ready',flush=True)
with auth.store.transaction() as db:
db.execute("UPDATE users SET must_change_password=0 WHERE username='admin'")
uid=db.execute("SELECT id FROM users WHERE username='admin'").fetchone()[0]
now=int(time.time())
hosts=['api-01.example','api-02.example','db-01.example','db-02.example','desktop-01.example','mail-01.example','app-01.example']
for i in range(18):
facts={h:('unreachable' if i%4==0 and h=='db-02.example' else 'successful') for h in hosts}
add_job(auth,owner='admin',book=['checkmk_install_agent','debug_test_connection','maintenance_patch_os'][i%3],
hosts=facts,status='failed' if i%4==0 else 'successful',when=now-3600*(i+1))
active=add_job(auth,owner='admin',status='running',hosts={'api-01.example':'successful'})
token,session=auth.new_session(uid)
paths={'overview':'/','new-run':'/plan?customer=example&playbook=debug_test_connection',
'explorer':'/inventory/example/explore','map':'/inventory/example/explore?view=map',
'branch':'/inventory/example/explore?view=map&branch=%5B%22linux%22%5D',
'activity':'/inventory/example/activity?host=api-01.example',
'insights':'/insights?customer=example','audit':'/audit'}
pages={}
with TestClient(create_app(settings),base_url=settings.public_url) as client:
client.cookies.set(settings.cookie_name,token)
for name,path in paths.items():
print('Render',name,path,flush=True)
response=client.get(path);assert response.status_code==200,(name,response.text)
pages[name]=response.text
# Every SVG host link really points to an authenticated host page, not an action.
import html
links=re.findall(r'<a href="([^"]+)" class="graph-node graph-host"',pages['map'])
assert links
for link in links:
assert client.get(html.unescape(link)).status_code==200
(args.out/'html').mkdir(exist_ok=True)
for name,content in pages.items(): (args.out/'html'/(name+'.html')).write_text(content)
def inline(content):
def css(m):
path=m.group(1).split('?')[0]
file=args.bootstrap if path.endswith('bootstrap.min.css') else ROOT/'src/aim_webgui'/path.lstrip('/')
return '<style>'+file.read_text()+'</style>'
content=re.sub(r'<link rel="stylesheet" href="([^"]+)"[^>]*>',css,content)
content=re.sub(r'<script src="[^"]+"[^>]*></script>','',content)
fixture='''window.fetch=async()=>({ok:true,json:async()=>({saved:true})});
window.__sources=[]; window.EventSource=class {
static CLOSED=2; constructor(url){this.url=url;this.readyState=1;this.handlers={};window.__sources.push(this);}
addEventListener(k,fn){this.handlers[k]=fn;} close(){this.readyState=2;}
emit(k,text){if(this.handlers[k])this.handlers[k]({data:JSON.stringify({text})});}
};'''
script='\n'.join((ROOT/'src/aim_webgui/static/js'/x).read_text() for x in ('theme.js','aim.js','experience.js','credentials.js'))
return content.replace('</body>','<script>'+fixture+script+'</script></body>')
with sync_playwright() as pw:
browser=pw.chromium.launch(executable_path='/usr/bin/chromium',headless=True,args=['--no-sandbox','--disable-dev-shm-usage'])
try:
for width,height in ((320,750),(390,844),(760,900),(1440,1000),(740,390)):
for theme in ('light','dark'):
context=browser.new_context(viewport={'width':width,'height':height},locale='de-DE',timezone_id='Europe/Berlin',reduced_motion='reduce')
errors=[]
for name,content in pages.items():
print('QA',width,height,theme,name,flush=True)
page=context.new_page();page.on('pageerror',lambda e:errors.append(str(e)))
page.set_content(inline(content),wait_until='load')
page.locator('[data-theme-option='+theme+']:visible').click()
page.wait_for_timeout(100)
assert page.evaluate('document.documentElement.scrollWidth<=innerWidth'),(name,width,height,'overflow')
if width<=760:
assert not page.locator('.sidebar').is_visible()
toggle=page.locator('[data-mobile-menu] summary');toggle.focus();page.keyboard.press('Enter')
assert page.locator('[data-mobile-menu]').get_attribute('open') is not None
assert page.locator('#mobile-navigation').get_by_role('link',name='Playbook insights',exact=True).is_visible()
page.keyboard.press('Escape')
assert page.locator('[data-mobile-menu]').get_attribute('open') is None
assert toggle.evaluate('(e)=>e===document.activeElement')
a=page.locator('.mobile-brand').bounding_box();b=page.locator('.mobile-actions .theme-control').bounding_box();c=toggle.bounding_box()
assert abs((a['y']+a['height']/2)-(c['y']+c['height']/2))<2
assert abs((b['y']+b['height']/2)-(c['y']+c['height']/2))<2
assert b['x']+b['width']<=c['x'] and c['x']+c['width']<=width
assert page.locator('.mobile-topbar').bounding_box()['height']<80
assert page.locator('[data-nav-forward]').count()==0
else:
assert page.locator('.sidebar').is_visible() and not page.locator('.mobile-topbar').is_visible()
if name=='explorer':
assert page.locator('.explorer-map').is_visible()==(width>760)
if name=='map':
graph=page.locator('[data-inventory-graph]');old=float(graph.get_attribute('width'))
page.locator('[data-graph-zoom="in"]').click()
assert float(graph.get_attribute('width'))>old
page.locator('[data-graph-zoom="reset"]').click()
assert page.locator('svg .graph-host').count()>0
assert page.locator('.inventory-canvas').evaluate('(e)=>e.scrollHeight>=e.clientHeight')
if name=='insights':
assert page.locator('.matrix-mobile').is_visible()==(width<=760)
if name=='new-run':
page.locator('[data-select-all-hosts]').check()
assert page.locator('[data-host-target]:checked').count()==7
if name=='job':
output=page.locator('[data-console-output]');output.scroll_into_view_if_needed();before=page.evaluate('scrollY')
page.evaluate("()=>{for(let i=0;i<240;i++)window.__sources[0].emit('line','TASK [Synthetic fixture '+i+']');}")
page.wait_for_timeout(100)
assert output.evaluate('(e)=>e.scrollTop>0 && e.scrollHeight>e.clientHeight')
assert abs(page.evaluate('scrollY')-before)<3
output.evaluate('(e)=>e.scrollTop=0');page.wait_for_timeout(100)
assert not page.locator('[data-console-autoscroll]').is_checked()
page.locator('[data-console-autoscroll]').check();page.wait_for_timeout(100)
assert output.evaluate('(e)=>e.scrollTop>0')
if name in ('map','activity','insights','explorer') and width in (390,1440) and theme=='dark':
page.evaluate('scrollTo(0,0)');file=f'{name}-{width}-{theme}.png';page.screenshot(path=str(args.out/file),full_page=True);screenshots.append(file)
if name=='activity':
shot=f'activity-{width}-viewport.png';page.screenshot(path=str(args.out/shot),full_page=False);screenshots.append(shot)
if name=='overview' and width==390 and theme=='dark':
page.locator('[data-mobile-menu] summary').click();page.screenshot(path=str(args.out/'mobile-menu.png'),full_page=False);screenshots.append('mobile-menu.png')
# Formatter includes an HTMX-replaced <time> root itself.
page.evaluate("()=>{let t=document.createElement('time');t.className='js-local-time';t.dateTime='2026-09-19T07:00:00Z';t.textContent='UTC-FALLBACK';document.querySelector('main').append(t);document.dispatchEvent(new CustomEvent('htmx:afterSwap',{detail:{target:t}}));}")
assert 'UTC-FALLBACK' not in page.locator('main > time').last.inner_text()
results.append({'page':name,'width':width,'height':height,'theme':theme,'passed':True});page.close()
assert not errors,errors
context.close()
finally:browser.close()
report={'fixture_only':True,'bootstrap_source':str(args.bootstrap),'htmx_loaded':False,'real_remote_execution':False,
'cases':results,'screenshots':screenshots}
(args.out/'results.json').write_text(json.dumps(report,indent=2))
print(f'{len(results)} browser fixture cases passed. No live HTMX/TLS/remote qualification.')
if __name__=='__main__':
main()