aim-web2.1.0rc9
This commit is contained in:
@@ -0,0 +1,191 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user