| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249 |
- """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']<height-60,metrics
- assert metrics['scrollWidth']<=metrics['clientWidth'],metrics
- report.setdefault('mobileMenus',[]).append({'width':width,'destination':type,**metrics})
- if type=='social':shot('menu-'+str(width)+'.png')
- click_nav('#mobile-library-menu [data-library-section='+type+']');catalog_is(type,label)
- assert not menu_open()
- click_nav('[data-library-mobile]');key('Escape','Escape',27);assert not menu_open()
- click_nav('[data-library-mobile]');click_point(2,100);assert not menu_open()
- click_nav('[data-library-mobile]');click_nav('#mobile-library-menu [popovertargetaction=hide]');assert not menu_open()
- click_nav('[data-library-mobile]');click_nav('#mobile-library-menu [data-library-section=reports]');assert not menu_open()
- click_nav('[data-library-mobile]')
- call('Emulation.setDeviceMetricsOverride',{'width':1024,'height':900,'deviceScaleFactor':1,'mobile':False});settle()
- assert not menu_open()
- check('phone-library-tab-opens-four-subpages-with-native-escape-outside-and-close-dismissal')
- # The same menu is available when entering from a different top-level page.
- call('Emulation.setDeviceMetricsOverride',{'width':390,'height':844,'deviceScaleFactor':1,'mobile':False});settle()
- ev('(async()=>{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()
|