verify-library-sidebar.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249
  1. """Real-browser acceptance for the library submenu and catalogue navigation."""
  2. import argparse
  3. import base64
  4. from datetime import datetime, timezone
  5. import json
  6. from pathlib import Path
  7. import subprocess
  8. import tempfile
  9. import time
  10. from urllib.request import Request, urlopen
  11. import websocket
  12. ROOT = Path(__file__).resolve().parents[1]
  13. def main():
  14. parser = argparse.ArgumentParser(description=__doc__)
  15. parser.add_argument('--url', default='http://127.0.0.1:4183')
  16. parser.add_argument('--out', default='work/library-sidebar-20261107-r1/browser')
  17. args = parser.parse_args()
  18. out = (ROOT / args.out).resolve()
  19. out.mkdir(parents=True, exist_ok=True)
  20. profile = Path(tempfile.mkdtemp(prefix='library-material-edge-', dir=ROOT / 'work'))
  21. browser = subprocess.Popen(['C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe', '--headless=new',
  22. '--disable-gpu', '--disable-background-networking', '--disable-component-update', '--disable-extensions',
  23. '--no-first-run', '--remote-debugging-port=0', '--user-data-dir=' + str(profile), 'about:blank'],
  24. stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, creationflags=subprocess.CREATE_NO_WINDOW)
  25. serial, ws, events = 0, None, []
  26. report = {'ok': False, 'checkedAt': datetime.now(timezone.utc).isoformat(), 'url': args.url,
  27. 'checks': [], 'layouts': [], 'responseOverrides': 0, 'paidCalls': 0, 'productionWrites': 0}
  28. try:
  29. end = time.monotonic() + 20
  30. while not (profile / 'DevToolsActivePort').exists():
  31. assert time.monotonic() < end and browser.poll() is None
  32. time.sleep(.1)
  33. port = (profile / 'DevToolsActivePort').read_text().splitlines()[0]
  34. page = next(p for p in json.load(urlopen('http://127.0.0.1:' + port + '/json/list')) if p['type'] == 'page')
  35. ws = websocket.create_connection(page['webSocketDebuggerUrl'], timeout=45, suppress_origin=True, http_no_proxy=['127.0.0.1'])
  36. def call(method, params=None):
  37. nonlocal serial
  38. serial += 1
  39. ws.send(json.dumps({'id': serial, 'method': method, 'params': params or {}}))
  40. while True:
  41. d = json.loads(ws.recv())
  42. if d.get('id') == serial:
  43. assert 'error' not in d, d
  44. return d.get('result', {})
  45. events.append(d)
  46. def ev(code):
  47. d = call('Runtime.evaluate', {'expression': code, 'returnByValue': True, 'awaitPromise': True})
  48. assert 'exceptionDetails' not in d, d
  49. return d['result'].get('value')
  50. view = 'document.querySelector("kf-app")?.renderRoot?.querySelector("view-library")'
  51. def action(code): return ev('(()=>{const p=' + view + ';' + code + '})()')
  52. def ready():
  53. ev('new Promise((resolve,reject)=>{let end=Date.now()+30000;function tick(){const p=' + view +
  54. ';if(p && !p._loading && p._summary && (p._data || p._detail || p._type==="reports")){p.updateComplete.then(()=>resolve(true));return}'
  55. 'if(p?._error)reject(Error(p._error));else if(Date.now()>end)reject(Error("timeout"));else setTimeout(tick,70)}tick()})')
  56. def navigate(path):
  57. call('Page.navigate', {'url': args.url + path}); ready()
  58. def settle(): ev('new Promise(r=>setTimeout(r,120))')
  59. def child_ready(panel):
  60. 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()})')
  61. def copied_ready():
  62. 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()})')
  63. def shot(name):
  64. (out / name).write_bytes(base64.b64decode(call('Page.captureScreenshot', {'format': 'png', 'captureBeyondViewport': False})['data']))
  65. def check(name): report['checks'].append(name)
  66. def mouse_click(selector):
  67. 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}')
  68. call('Input.dispatchMouseEvent',{'type':'mousePressed','x':point['x'],'y':point['y'],'button':'left','clickCount':1})
  69. call('Input.dispatchMouseEvent',{'type':'mouseReleased','x':point['x'],'y':point['y'],'button':'left','clickCount':1});settle()
  70. nav='document.querySelector("kf-app")?.renderRoot?.querySelector("kf-nav")'
  71. def nav_action(code):return ev('(()=>{const n='+nav+';'+code+'})()')
  72. def click_nav(selector):
  73. 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}')
  74. click_point(point['x'],point['y'])
  75. def click_point(x,y):
  76. for kind in ['mousePressed','mouseReleased']:
  77. call('Input.dispatchMouseEvent',{'type':kind,'x':x,'y':y,'button':'left','clickCount':1})
  78. settle()
  79. def key(name,code,vk):
  80. for kind in ['rawKeyDown','keyUp']:
  81. params={'type':kind,'key':name,'code':code,'windowsVirtualKeyCode':vk}
  82. if name=='Enter' and kind=='rawKeyDown':params.update(type='keyDown',text='\r',unmodifiedText='\r')
  83. call('Input.dispatchKeyEvent',params)
  84. settle()
  85. def selected_type():return nav_action('return n.renderRoot.querySelector(".library-children [aria-current=page]")?.dataset.librarySection')
  86. def catalog_is(type,label):
  87. ready();settle()
  88. assert action('return p._type')==type
  89. assert action('return p.renderRoot.querySelector(".p-head h2").textContent')==label
  90. assert selected_type()==type
  91. assert not action('return p.renderRoot.querySelector(".directory,.directory-links,.mobile-type,[aria-label=资料分类]")')
  92. def menu_open():return nav_action('return n.renderRoot.querySelector("#mobile-library-menu").matches(":popover-open")')
  93. sections={'products':'商品与竞品','social':'社媒内容','documents':'政策文件','reports':'已有研究成果'}
  94. call('Runtime.enable');call('Page.enable');call('Network.enable')
  95. call('Emulation.setFocusEmulationEnabled',{'enabled':True})
  96. call('Emulation.setDeviceMetricsOverride',{'width':1440,'height':900,'deviceScaleFactor':1,'mobile':False})
  97. navigate('/library');settle()
  98. assert nav_action('return n.renderRoot.querySelector("[data-library-toggle]").getAttribute("aria-expanded")')=='true'
  99. assert nav_action('return [...n.renderRoot.querySelectorAll(".library-children a")].map(a=>a.textContent.trim())')==list(sections.values())
  100. catalog_is('products',sections['products'])
  101. shot('products-1440.png')
  102. before=ev('location.href')
  103. calls_before=len([e for e in events if e['method']=='Network.requestWillBeSent' and '/api/library' in e['params']['request']['url']])
  104. click_nav('[data-library-toggle]')
  105. assert nav_action('return n.renderRoot.querySelector("#library-subnav").hidden')
  106. assert ev('location.href')==before
  107. click_nav('[data-library-toggle]')
  108. assert not nav_action('return n.renderRoot.querySelector("#library-subnav").hidden')
  109. assert len([e for e in events if e['method']=='Network.requestWillBeSent' and '/api/library' in e['params']['request']['url']])==calls_before
  110. check('four-secondary-links-live-under-the-collapsible-library-parent-without-duplicate-page-tabs')
  111. # Build a cross-category selection through actual sidebar links.
  112. mouse_click('[data-selection-mode]')
  113. mouse_click('.product-choice:nth-child(1) [data-record-id]')
  114. selected_product=action('return p._selected[0].id')
  115. for type,label in sections.items():
  116. click_nav('.library-children [data-library-section="'+type+'"]')
  117. catalog_is(type,label)
  118. assert ev('location.search')=='?type='+type
  119. if type=='social':mouse_click('[data-record-id]')
  120. if type=='documents':mouse_click('[data-record-id]')
  121. if type=='reports':
  122. 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()})')
  123. mouse_click('[data-record-id]')
  124. shot(type+'-1440.png')
  125. assert action('return p._selected.map(x=>x.entity)')==['products','social','documents','reports']
  126. check('sidebar-navigation-resets-category-filters-and-preserves-cross-category-selected-materials')
  127. call('Page.reload');ready();settle()
  128. assert selected_type()=='reports'
  129. assert action('return p._selected[0].id')==selected_product
  130. assert nav_action('return !n.renderRoot.querySelector("#library-subnav").hidden')
  131. check('refresh-restores-current-subpage-highlight-expanded-parent-and-existing-selection')
  132. # Cross-page navigation starts at the top; browser Back restores the old page and scroll.
  133. navigate('/library?type=products&quality=audit&category=banmian&platform=jd&brand=ksf&page=2');settle()
  134. mouse_click('.product-choice:nth-child(12) [data-record-id]')
  135. old_scroll=ev('scrollY');assert old_scroll>100
  136. click_nav('.library-children [data-library-section=social]');catalog_is('social',sections['social'])
  137. report['scrollRestore']={'expected':old_scroll}
  138. assert ev('scrollY')==0
  139. ev('history.back()');settle();catalog_is('products',sections['products'])
  140. assert action('return p.routeQuery.page')=='2'
  141. report['scrollRestore']['actual']=ev('scrollY')
  142. report['scrollRestore']['saved']=ev('history.state?.kfScrollY')
  143. assert abs(ev('scrollY')-old_scroll)<2,report['scrollRestore']
  144. ev('history.forward()');settle();catalog_is('social',sections['social'])
  145. assert not action('return p.routeQuery.brand||p.routeQuery.category||p.routeQuery.page')
  146. check('subpage-links-reset-scroll-and-old-filters-while-back-restores-the-previous-page')
  147. # Detail URLs use the record entity for the group highlight.
  148. navigate('/library?type=social&quality=audit');settle()
  149. social_id=action('return p._data.records[0].record_id')
  150. navigate('/library/social/'+__import__('urllib.parse',fromlist=['quote']).quote(social_id));settle()
  151. assert selected_type()=='social'
  152. navigate('/library/products/jd%3A100000348106?panel=reviews&relatedQuality=audit');child_ready('reviews');settle()
  153. assert selected_type()=='products'
  154. assert action('return p.renderRoot.querySelector(".product-detail-dialog").matches(":modal")')
  155. mouse_click('[data-close-product]');ready();settle()
  156. assert selected_type()=='products'
  157. navigate('/library?type=clauses');settle()
  158. assert selected_type()=='documents'
  159. check('product-modal-social-details-and-legacy-clause-links-highlight-their-own-library-section')
  160. # Keyboard access to the desktop parent and its real links.
  161. nav_action('n.renderRoot.querySelector("[data-library-toggle]").focus();return true')
  162. key(' ','Space',32)
  163. assert nav_action('return n.renderRoot.querySelector("#library-subnav").hidden')
  164. key('Enter','Enter',13)
  165. assert not nav_action('return n.renderRoot.querySelector("#library-subnav").hidden')
  166. key('Tab','Tab',9)
  167. assert nav_action('return n.renderRoot.activeElement.dataset.librarySection')=='products'
  168. key('Enter','Enter',13);catalog_is('products',sections['products'])
  169. check('desktop-parent-and-subpage-links-support-native-keyboard-navigation')
  170. # Actual desktop sidebar geometry and all four page bodies, including a short tablet.
  171. for type,label in sections.items():
  172. navigate('/library?type='+type);catalog_is(type,label)
  173. for width,height in [(1440,900),(1024,900),(768,568),(390,844),(320,568)]:
  174. call('Emulation.setDeviceMetricsOverride',{'width':width,'height':height,'deviceScaleFactor':1,'mobile':False});settle()
  175. metrics=ev('({innerWidth,clientWidth:document.documentElement.clientWidth,scrollWidth:document.documentElement.scrollWidth})')
  176. assert metrics['innerWidth']==width and metrics['scrollWidth']<=metrics['clientWidth'],(type,width,metrics)
  177. if width>=768:
  178. 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}')
  179. assert side['bottom']<=height and side['scrollWidth']<=side['clientWidth'],side
  180. assert side['selectedLeft']>=side['left'] and side['selectedRight']<=side['right'],side
  181. metrics['sidebar']=side
  182. report['layouts'].append({'section':type,'width':width,'height':height,**metrics})
  183. if width in [1440,390,320]:shot(type+'-'+str(width)+'.png')
  184. check('all-four-subpages-fit-five-real-viewports-and-short-tablet-sidebar-scrolls')
  185. for width,height in [(390,844),(320,568)]:
  186. call('Emulation.setDeviceMetricsOverride',{'width':width,'height':height,'deviceScaleFactor':1,'mobile':False});settle()
  187. for type,label in sections.items():
  188. click_nav('[data-library-mobile]')
  189. assert menu_open()
  190. assert nav_action('return n.renderRoot.querySelector("[data-library-mobile]").getAttribute("aria-expanded")')=='true'
  191. 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}')
  192. assert 0<=metrics['left'] and metrics['right']<=width and 0<=metrics['top'] and metrics['bottom']<height-60,metrics
  193. assert metrics['scrollWidth']<=metrics['clientWidth'],metrics
  194. report.setdefault('mobileMenus',[]).append({'width':width,'destination':type,**metrics})
  195. if type=='social':shot('menu-'+str(width)+'.png')
  196. click_nav('#mobile-library-menu [data-library-section='+type+']');catalog_is(type,label)
  197. assert not menu_open()
  198. click_nav('[data-library-mobile]');key('Escape','Escape',27);assert not menu_open()
  199. click_nav('[data-library-mobile]');click_point(2,100);assert not menu_open()
  200. click_nav('[data-library-mobile]');click_nav('#mobile-library-menu [popovertargetaction=hide]');assert not menu_open()
  201. click_nav('[data-library-mobile]');click_nav('#mobile-library-menu [data-library-section=reports]');assert not menu_open()
  202. click_nav('[data-library-mobile]')
  203. call('Emulation.setDeviceMetricsOverride',{'width':1024,'height':900,'deviceScaleFactor':1,'mobile':False});settle()
  204. assert not menu_open()
  205. check('phone-library-tab-opens-four-subpages-with-native-escape-outside-and-close-dismissal')
  206. # The same menu is available when entering from a different top-level page.
  207. call('Emulation.setDeviceMetricsOverride',{'width':390,'height':844,'deviceScaleFactor':1,'mobile':False});settle()
  208. ev('(async()=>{const {navigate}=await import("/src/utils/router.js");navigate("/")})()');settle()
  209. assert nav_action('return n.current')=='/'
  210. click_nav('[data-library-mobile]');assert menu_open()
  211. click_nav('#mobile-library-menu [data-library-section=documents]');catalog_is('documents',sections['documents'])
  212. assert not menu_open()
  213. check('phone-library-submenu-is-accessible-from-other-main-pages')
  214. report['consoleExceptions']=[e for e in events if e['method']=='Runtime.exceptionThrown']
  215. assert not report['consoleExceptions'],report['consoleExceptions']
  216. report['generation']=action('return p._summary.generation')
  217. report['ok']=True
  218. except Exception:
  219. if ws:
  220. report['failureState']=action('return {url:location.href,loading:p?._loading,error:p?._error,type:p?._type}')
  221. shot('failure.png')
  222. raise
  223. finally:
  224. if ws:ws.close()
  225. browser.terminate()
  226. try:browser.wait(timeout=10)
  227. except subprocess.TimeoutExpired:browser.kill();browser.wait(timeout=10)
  228. (out/'verification.json').write_text(json.dumps(report,ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
  229. 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))
  230. if __name__=='__main__':main()