"""Real-browser acceptance for the library submenu and catalogue navigation.""" import argparse import base64 from datetime import datetime, timezone import json from pathlib import Path import subprocess import tempfile import time from urllib.request import Request, urlopen import websocket ROOT = Path(__file__).resolve().parents[1] def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--url', default='http://127.0.0.1:4183') parser.add_argument('--out', default='work/library-sidebar-20261107-r1/browser') args = parser.parse_args() out = (ROOT / args.out).resolve() out.mkdir(parents=True, exist_ok=True) profile = Path(tempfile.mkdtemp(prefix='library-material-edge-', dir=ROOT / 'work')) browser = subprocess.Popen(['C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe', '--headless=new', '--disable-gpu', '--disable-background-networking', '--disable-component-update', '--disable-extensions', '--no-first-run', '--remote-debugging-port=0', '--user-data-dir=' + str(profile), 'about:blank'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, creationflags=subprocess.CREATE_NO_WINDOW) serial, ws, events = 0, None, [] report = {'ok': False, 'checkedAt': datetime.now(timezone.utc).isoformat(), 'url': args.url, 'checks': [], 'layouts': [], 'responseOverrides': 0, 'paidCalls': 0, 'productionWrites': 0} try: end = time.monotonic() + 20 while not (profile / 'DevToolsActivePort').exists(): assert time.monotonic() < end and browser.poll() is None time.sleep(.1) port = (profile / 'DevToolsActivePort').read_text().splitlines()[0] page = next(p for p in json.load(urlopen('http://127.0.0.1:' + port + '/json/list')) if p['type'] == 'page') ws = websocket.create_connection(page['webSocketDebuggerUrl'], timeout=45, suppress_origin=True, http_no_proxy=['127.0.0.1']) def call(method, params=None): nonlocal serial serial += 1 ws.send(json.dumps({'id': serial, 'method': method, 'params': params or {}})) while True: d = json.loads(ws.recv()) if d.get('id') == serial: assert 'error' not in d, d return d.get('result', {}) events.append(d) def ev(code): d = call('Runtime.evaluate', {'expression': code, 'returnByValue': True, 'awaitPromise': True}) assert 'exceptionDetails' not in d, d return d['result'].get('value') view = 'document.querySelector("kf-app")?.renderRoot?.querySelector("view-library")' def action(code): return ev('(()=>{const p=' + view + ';' + code + '})()') def ready(): ev('new Promise((resolve,reject)=>{let end=Date.now()+30000;function tick(){const p=' + view + ';if(p && !p._loading && p._summary && (p._data || p._detail || p._type==="reports")){p.updateComplete.then(()=>resolve(true));return}' 'if(p?._error)reject(Error(p._error));else if(Date.now()>end)reject(Error("timeout"));else setTimeout(tick,70)}tick()})') def navigate(path): call('Page.navigate', {'url': args.url + path}); ready() def settle(): ev('new Promise(r=>setTimeout(r,120))') def child_ready(panel): ev('new Promise((resolve,reject)=>{let end=Date.now()+20000;function tick(){const p=' + view + ';const c=p?.renderRoot?.querySelector("library-product-detail");if(c && c.panel===' + json.dumps(panel) + ' && !c._loading && c._data){c.updateComplete.then(()=>resolve(true));return}if(c?._error)reject(Error(c._error));else if(Date.now()>end)reject(Error("child timeout"));else setTimeout(tick,70)}tick()})') def copied_ready(): ev('new Promise((resolve,reject)=>{let end=Date.now()+15000;function tick(){const p=' + view + ';if(!p._copying && p._copyText){resolve(true);return}if(Date.now()>end)reject(Error("copy timeout"));else setTimeout(tick,70)}tick()})') def shot(name): (out / name).write_bytes(base64.b64decode(call('Page.captureScreenshot', {'format': 'png', 'captureBeyondViewport': False})['data'])) def check(name): report['checks'].append(name) def mouse_click(selector): point=action('const b=p.renderRoot.querySelector('+json.dumps(selector)+');b.scrollIntoView({block:"center"});const r=b.getBoundingClientRect();return {x:r.x+r.width/2,y:r.y+r.height/2}') call('Input.dispatchMouseEvent',{'type':'mousePressed','x':point['x'],'y':point['y'],'button':'left','clickCount':1}) call('Input.dispatchMouseEvent',{'type':'mouseReleased','x':point['x'],'y':point['y'],'button':'left','clickCount':1});settle() nav='document.querySelector("kf-app")?.renderRoot?.querySelector("kf-nav")' def nav_action(code):return ev('(()=>{const n='+nav+';'+code+'})()') def click_nav(selector): point=nav_action('const b=n.renderRoot.querySelector('+json.dumps(selector)+');let r=b.getBoundingClientRect();if(r.top<0||r.bottom>innerHeight){b.scrollIntoView({block:"nearest"});r=b.getBoundingClientRect()}return {x:r.x+r.width/2,y:r.y+r.height/2}') click_point(point['x'],point['y']) def click_point(x,y): for kind in ['mousePressed','mouseReleased']: call('Input.dispatchMouseEvent',{'type':kind,'x':x,'y':y,'button':'left','clickCount':1}) settle() def key(name,code,vk): for kind in ['rawKeyDown','keyUp']: params={'type':kind,'key':name,'code':code,'windowsVirtualKeyCode':vk} if name=='Enter' and kind=='rawKeyDown':params.update(type='keyDown',text='\r',unmodifiedText='\r') call('Input.dispatchKeyEvent',params) settle() def selected_type():return nav_action('return n.renderRoot.querySelector(".library-children [aria-current=page]")?.dataset.librarySection') def catalog_is(type,label): ready();settle() assert action('return p._type')==type assert action('return p.renderRoot.querySelector(".p-head h2").textContent')==label assert selected_type()==type assert not action('return p.renderRoot.querySelector(".directory,.directory-links,.mobile-type,[aria-label=资料分类]")') def menu_open():return nav_action('return n.renderRoot.querySelector("#mobile-library-menu").matches(":popover-open")') sections={'products':'商品与竞品','social':'社媒内容','documents':'政策文件','reports':'已有研究成果'} call('Runtime.enable');call('Page.enable');call('Network.enable') call('Emulation.setFocusEmulationEnabled',{'enabled':True}) call('Emulation.setDeviceMetricsOverride',{'width':1440,'height':900,'deviceScaleFactor':1,'mobile':False}) navigate('/library');settle() assert nav_action('return n.renderRoot.querySelector("[data-library-toggle]").getAttribute("aria-expanded")')=='true' assert nav_action('return [...n.renderRoot.querySelectorAll(".library-children a")].map(a=>a.textContent.trim())')==list(sections.values()) catalog_is('products',sections['products']) shot('products-1440.png') before=ev('location.href') calls_before=len([e for e in events if e['method']=='Network.requestWillBeSent' and '/api/library' in e['params']['request']['url']]) click_nav('[data-library-toggle]') assert nav_action('return n.renderRoot.querySelector("#library-subnav").hidden') assert ev('location.href')==before click_nav('[data-library-toggle]') assert not nav_action('return n.renderRoot.querySelector("#library-subnav").hidden') assert len([e for e in events if e['method']=='Network.requestWillBeSent' and '/api/library' in e['params']['request']['url']])==calls_before check('four-secondary-links-live-under-the-collapsible-library-parent-without-duplicate-page-tabs') # Build a cross-category selection through actual sidebar links. mouse_click('[data-selection-mode]') mouse_click('.product-choice:nth-child(1) [data-record-id]') selected_product=action('return p._selected[0].id') for type,label in sections.items(): click_nav('.library-children [data-library-section="'+type+'"]') catalog_is(type,label) assert ev('location.search')=='?type='+type if type=='social':mouse_click('[data-record-id]') if type=='documents':mouse_click('[data-record-id]') if type=='reports': ev('new Promise((resolve,reject)=>{let end=Date.now()+20000;function tick(){const p='+view+';if(p?.db){resolve(true);return}if(Date.now()>end)reject(Error("workshop timeout"));else setTimeout(tick,70)}tick()})') mouse_click('[data-record-id]') shot(type+'-1440.png') assert action('return p._selected.map(x=>x.entity)')==['products','social','documents','reports'] check('sidebar-navigation-resets-category-filters-and-preserves-cross-category-selected-materials') call('Page.reload');ready();settle() assert selected_type()=='reports' assert action('return p._selected[0].id')==selected_product assert nav_action('return !n.renderRoot.querySelector("#library-subnav").hidden') check('refresh-restores-current-subpage-highlight-expanded-parent-and-existing-selection') # Cross-page navigation starts at the top; browser Back restores the old page and scroll. navigate('/library?type=products&quality=audit&category=banmian&platform=jd&brand=ksf&page=2');settle() mouse_click('.product-choice:nth-child(12) [data-record-id]') old_scroll=ev('scrollY');assert old_scroll>100 click_nav('.library-children [data-library-section=social]');catalog_is('social',sections['social']) report['scrollRestore']={'expected':old_scroll} assert ev('scrollY')==0 ev('history.back()');settle();catalog_is('products',sections['products']) assert action('return p.routeQuery.page')=='2' report['scrollRestore']['actual']=ev('scrollY') report['scrollRestore']['saved']=ev('history.state?.kfScrollY') assert abs(ev('scrollY')-old_scroll)<2,report['scrollRestore'] ev('history.forward()');settle();catalog_is('social',sections['social']) assert not action('return p.routeQuery.brand||p.routeQuery.category||p.routeQuery.page') check('subpage-links-reset-scroll-and-old-filters-while-back-restores-the-previous-page') # Detail URLs use the record entity for the group highlight. navigate('/library?type=social&quality=audit');settle() social_id=action('return p._data.records[0].record_id') navigate('/library/social/'+__import__('urllib.parse',fromlist=['quote']).quote(social_id));settle() assert selected_type()=='social' navigate('/library/products/jd%3A100000348106?panel=reviews&relatedQuality=audit');child_ready('reviews');settle() assert selected_type()=='products' assert action('return p.renderRoot.querySelector(".product-detail-dialog").matches(":modal")') mouse_click('[data-close-product]');ready();settle() assert selected_type()=='products' navigate('/library?type=clauses');settle() assert selected_type()=='documents' check('product-modal-social-details-and-legacy-clause-links-highlight-their-own-library-section') # Keyboard access to the desktop parent and its real links. nav_action('n.renderRoot.querySelector("[data-library-toggle]").focus();return true') key(' ','Space',32) assert nav_action('return n.renderRoot.querySelector("#library-subnav").hidden') key('Enter','Enter',13) assert not nav_action('return n.renderRoot.querySelector("#library-subnav").hidden') key('Tab','Tab',9) assert nav_action('return n.renderRoot.activeElement.dataset.librarySection')=='products' key('Enter','Enter',13);catalog_is('products',sections['products']) check('desktop-parent-and-subpage-links-support-native-keyboard-navigation') # Actual desktop sidebar geometry and all four page bodies, including a short tablet. for type,label in sections.items(): navigate('/library?type='+type);catalog_is(type,label) for width,height in [(1440,900),(1024,900),(768,568),(390,844),(320,568)]: call('Emulation.setDeviceMetricsOverride',{'width':width,'height':height,'deviceScaleFactor':1,'mobile':False});settle() metrics=ev('({innerWidth,clientWidth:document.documentElement.clientWidth,scrollWidth:document.documentElement.scrollWidth})') assert metrics['innerWidth']==width and metrics['scrollWidth']<=metrics['clientWidth'],(type,width,metrics) if width>=768: side=nav_action('const s=n.renderRoot.querySelector(".side"),v=s.querySelector("nav"),a=s.querySelector("[aria-current=page]"),r=s.getBoundingClientRect(),q=a.getBoundingClientRect();return {left:r.left,right:r.right,bottom:r.bottom,clientWidth:v.clientWidth,scrollWidth:v.scrollWidth,selectedLeft:q.left,selectedRight:q.right}') assert side['bottom']<=height and side['scrollWidth']<=side['clientWidth'],side assert side['selectedLeft']>=side['left'] and side['selectedRight']<=side['right'],side metrics['sidebar']=side report['layouts'].append({'section':type,'width':width,'height':height,**metrics}) if width in [1440,390,320]:shot(type+'-'+str(width)+'.png') check('all-four-subpages-fit-five-real-viewports-and-short-tablet-sidebar-scrolls') for width,height in [(390,844),(320,568)]: call('Emulation.setDeviceMetricsOverride',{'width':width,'height':height,'deviceScaleFactor':1,'mobile':False});settle() for type,label in sections.items(): click_nav('[data-library-mobile]') assert menu_open() assert nav_action('return n.renderRoot.querySelector("[data-library-mobile]").getAttribute("aria-expanded")')=='true' metrics=nav_action('const d=n.renderRoot.querySelector("#mobile-library-menu"),r=d.getBoundingClientRect();return {left:r.left,right:r.right,top:r.top,bottom:r.bottom,clientWidth:d.clientWidth,scrollWidth:d.scrollWidth}') assert 0<=metrics['left'] and metrics['right']<=width and 0<=metrics['top'] and metrics['bottom']{const {navigate}=await import("/src/utils/router.js");navigate("/")})()');settle() assert nav_action('return n.current')=='/' click_nav('[data-library-mobile]');assert menu_open() click_nav('#mobile-library-menu [data-library-section=documents]');catalog_is('documents',sections['documents']) assert not menu_open() check('phone-library-submenu-is-accessible-from-other-main-pages') report['consoleExceptions']=[e for e in events if e['method']=='Runtime.exceptionThrown'] assert not report['consoleExceptions'],report['consoleExceptions'] report['generation']=action('return p._summary.generation') report['ok']=True except Exception: if ws: report['failureState']=action('return {url:location.href,loading:p?._loading,error:p?._error,type:p?._type}') shot('failure.png') raise finally: if ws:ws.close() browser.terminate() try:browser.wait(timeout=10) except subprocess.TimeoutExpired:browser.kill();browser.wait(timeout=10) (out/'verification.json').write_text(json.dumps(report,ensure_ascii=False,indent=2)+'\n',encoding='utf-8') print(json.dumps({'ok':report['ok'],'checks':report['checks'],'layouts':len(report['layouts']),'mobileMenus':len(report.get('mobileMenus',[])),'receipt':str(out/'verification.json')},ensure_ascii=False,indent=2)) if __name__=='__main__':main()