verify-library-product-modal.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244
  1. """Real-browser acceptance for the product modal, navigation and responsive reader."""
  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-product-modal-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. def panel_ready(panel):
  71. if panel not in ['overview','source']:
  72. child_ready(panel)
  73. else:
  74. 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?.panel===' + json.dumps(panel) + '&&!c._loading){c.updateComplete.then(()=>resolve(true));return}if(Date.now()>end)reject(Error("panel timeout"));else setTimeout(tick,60)}tick()})')
  75. settle()
  76. def click_child(selector):
  77. point=action('const b=p.renderRoot.querySelector("library-product-detail").renderRoot.querySelector('+json.dumps(selector)+');b.scrollIntoView({block:"nearest"});const r=b.getBoundingClientRect();return {x:r.x+r.width/2,y:r.y+r.height/2}')
  78. click_point(point['x'],point['y'])
  79. def click_point(x,y):
  80. for kind in ['mousePressed','mouseReleased']:
  81. call('Input.dispatchMouseEvent',{'type':kind,'x':x,'y':y,'button':'left','clickCount':1})
  82. settle()
  83. def key(name,code,vk):
  84. for kind in ['rawKeyDown','keyUp']:
  85. call('Input.dispatchKeyEvent',{'type':kind,'key':name,'code':code,'windowsVirtualKeyCode':vk,'nativeVirtualKeyCode':vk})
  86. settle()
  87. def closed():
  88. ready();settle()
  89. assert action('return !p.recordId&&!p.renderRoot.querySelector(".product-detail-dialog").open')
  90. assert ev('document.documentElement.style.overflow')!='hidden'
  91. def modal_state():
  92. return action('const d=p.renderRoot.querySelector(".product-detail-dialog"),c=d.querySelector("library-product-detail");return {open:d.open,modal:d.matches(":modal"),id:p.recordId,panels:c?.renderRoot.querySelectorAll("[data-product-panel]").length,background:p.renderRoot.querySelectorAll("[data-record-id]").length}')
  93. call('Runtime.enable');call('Page.enable');call('Network.enable')
  94. call('Emulation.setFocusEmulationEnabled', {'enabled':True})
  95. call('Browser.grantPermissions',{'origin':args.url,'permissions':['clipboardReadWrite','clipboardSanitizedWrite']})
  96. call('Emulation.setDeviceMetricsOverride',{'width':1440,'height':900,'deviceScaleFactor':1,'mobile':False})
  97. catalog='/library?type=products&quality=audit&category=banmian&platform=jd&brand=ksf&page=2'
  98. navigate(catalog)
  99. mouse_click('[data-record-id]:nth-of-type(1)')
  100. ready();panel_ready('overview')
  101. first=modal_state()
  102. assert first['open'] and first['modal'] and first['background']==24 and first['panels']==6,first
  103. product_id=first['id']
  104. assert action('return history.state.kfLibraryOverlay.returnTo')==catalog
  105. assert ev('document.documentElement.style.overflow')=='hidden'
  106. check('real-card-click-opens-native-modal-over-the-same-24-card-catalogue')
  107. click_child('[data-product-panel="reviews"]');panel_ready('reviews')
  108. click_child('[data-product-panel="source"]');panel_ready('source')
  109. call('Runtime.evaluate',{'expression':'history.back()'});settle();closed()
  110. assert action('return p.routeQuery.page')=='2'
  111. assert action('return p.routeQuery.category')=='banmian'
  112. call('Runtime.evaluate',{'expression':'history.forward()'});settle();ready();panel_ready('source')
  113. assert modal_state()['modal']
  114. mouse_click('[data-close-product]');closed()
  115. check('sidebar-replaces-one-history-entry-back-closes-forward-reopens-with-filters')
  116. # Open from a scrolled card and verify native focus and scroll restoration.
  117. mouse_click('.product-choice:nth-child(12) [data-record-id]')
  118. ready();panel_ready('overview')
  119. return_scroll=action('return history.state.kfLibraryOverlay.scrollY')
  120. assert return_scroll>0
  121. mouse_click('[data-close-product]');closed()
  122. assert abs(ev('scrollY')-return_scroll)<2
  123. assert action('return p.renderRoot.activeElement?.dataset.recordId')==action('return p._data.records[11].record_id')
  124. mouse_click('.product-choice:nth-child(12) [data-record-id]');ready();panel_ready('overview')
  125. key('Escape','Escape',27);closed()
  126. mouse_click('.product-choice:nth-child(12) [data-record-id]');ready();panel_ready('overview')
  127. click_point(2,2);closed()
  128. check('close-button-escape-and-backdrop-restore-page-scroll-and-card-focus')
  129. mouse_click('[data-selection-mode]')
  130. before=action('return p._selected.length')
  131. mouse_click('.product-choice:nth-child(1) h4')
  132. assert action('return p._selected.length')==before+1
  133. assert not action('return p.renderRoot.querySelector(".product-detail-dialog").open')
  134. mouse_click('[data-selection-mode]')
  135. check('selection-mode-continues-to-select-the-whole-card-without-opening-details')
  136. navigate('/library/products/jd%3A100000348106?panel=reviews&relatedQuality=audit')
  137. panel_ready('reviews')
  138. assert modal_state()['modal']
  139. assert action('return p.renderRoot.querySelector("library-product-detail")._data.records.length')>0
  140. click_child('[data-select-child]')
  141. assert action('return p._selected.some(x=>x.entity==="reviews"&&x.productId==="jd:100000348106")')
  142. mouse_click('.product-detail-dialog [data-preview-materials]');copied_ready();settle()
  143. assert action('return p.renderRoot.querySelector(".material-dialog").matches(":modal")&&p.renderRoot.querySelector(".product-detail-dialog").matches(":modal")')
  144. key('Escape','Escape',27)
  145. assert action('return !p.renderRoot.querySelector(".material-dialog").open&&p.renderRoot.querySelector(".product-detail-dialog").open')
  146. mouse_click('.product-detail-dialog [data-copy-materials]');copied_ready()
  147. copied=ev('navigator.clipboard.readText()').replace('\r\n','\n')
  148. assert copied==action('return p._copyText')
  149. rows=[json.loads(line) for line in copied.splitlines() if line.startswith('{')]
  150. assert any(row.get('entity')=='reviews' for row in rows)
  151. assert not any(term in copied for term in ['员工','请返回','请你'])
  152. check('child-selection-and-real-clipboard-work-with-copy-preview-stacked-above-details')
  153. # The selected review's original data must remain unchanged.
  154. review=action('return p._selected.find(x=>x.entity==="reviews")')
  155. exact=ev('(async()=>{const {readLibrary}=await import("/src/services/library-service.js");return (await readLibrary({op:"detail",entity:"reviews",id:'+json.dumps(review['id'])+',generation:'+json.dumps(action('return p._summary.generation'))+'})).record.id})()')
  156. assert exact==review['id']
  157. report['verifiedReviewId']=exact
  158. for pid,quality,count in [('100017664018','audit',10),('100005702440','analysis',18),('100001720753','analysis',0)]:
  159. navigate('/library/products/jd%3A'+pid+'?panel=reviews');panel_ready('reviews')
  160. state=action('const c=p.renderRoot.querySelector("library-product-detail");return {quality:c.reviewQuality,count:c._data.records.length,text:c.renderRoot.textContent}')
  161. assert state['quality']==quality and state['count']==count,(pid,state)
  162. if not count:assert '不代表平台上没有评论' in state['text']
  163. check('review-audit-fallback-analysis-default-and-honest-no-review-message-remain-correct')
  164. navigate('/library/products/jd%3A100000348106?panel=reviews&relatedQuality=audit');panel_ready('reviews')
  165. call('Page.reload');settle();ready();panel_ready('reviews')
  166. assert modal_state()['modal']
  167. mouse_click('[data-close-product]');closed()
  168. assert ev('location.pathname')=='/library'
  169. assert not action('return Object.hasOwn(p.routeQuery,"panel")')
  170. check('direct-link-refresh-preserves-panel-and-close-returns-to-catalogue')
  171. navigate('/library/products/jd%3A100000348106');panel_ready('overview')
  172. action('p.renderRoot.querySelector("library-product-detail").renderRoot.querySelector("[data-product-panel=overview]").focus();return true')
  173. key('ArrowDown','ArrowDown',40);panel_ready('specs')
  174. key('End','End',35);panel_ready('source')
  175. key('Home','Home',36);panel_ready('overview')
  176. assert action('return p.renderRoot.querySelector("library-product-detail").renderRoot.activeElement.dataset.productPanel')=='overview'
  177. check('vertical-sidebar-supports-arrow-keys-home-end-and-visible-tab-focus')
  178. # Check the actual top-layer reader, not merely the inert page underneath it.
  179. for panel in ['overview','specs','prices','reviews','rankings','source']:
  180. click_child('[data-product-panel="'+panel+'"]');panel_ready(panel)
  181. for width,height in [(1440,900),(1024,900),(768,900),(390,844),(320,568)]:
  182. call('Emulation.setDeviceMetricsOverride',{'width':width,'height':height,'deviceScaleFactor':1,'mobile':False});settle()
  183. metrics=action('const d=p.renderRoot.querySelector(".product-detail-dialog"),c=d.querySelector("library-product-detail"),s=c.renderRoot.querySelector(".product-sidebar"),m=c.renderRoot.querySelector(".product-content"),b=d.querySelector("[data-close-product]"),r=d.getBoundingClientRect(),q=b.getBoundingClientRect();const nodes=[...c.renderRoot.querySelectorAll("*"),...d.querySelectorAll("*")];const overflow=nodes.filter(n=>{const r=n.getBoundingClientRect(),st=getComputedStyle(n);return r.width>0&&r.height>0&&st.display!=="none"&&n.scrollWidth>n.clientWidth+1&&["visible","clip"].includes(st.overflowX)&&!(n instanceof SVGElement)&&n.tagName!=="SLOT"}).map(n=>({tag:n.tagName,cls:n.className,cw:n.clientWidth,sw:n.scrollWidth}));return {innerWidth,clientWidth:document.documentElement.clientWidth,left:r.left,right:r.right,top:r.top,bottom:r.bottom,dialogWidth:d.clientWidth,dialogScrollWidth:d.scrollWidth,contentWidth:m.clientWidth,contentScrollWidth:m.scrollWidth,sidebarRight:s.getBoundingClientRect().right,contentLeft:m.getBoundingClientRect().left,closeRight:q.right,closeBottom:q.bottom,closeHit:b.contains(p.renderRoot.elementFromPoint(q.left+q.width/2,q.top+q.height/2)),overflow}')
  184. assert metrics['innerWidth']==width and 0<=metrics['left'] and metrics['right']<=width and metrics['bottom']<=height,metrics
  185. assert metrics['dialogWidth']==metrics['dialogScrollWidth'] and metrics['contentWidth']==metrics['contentScrollWidth'],(panel,width,metrics)
  186. assert metrics['sidebarRight']<=metrics['contentLeft']+1 and metrics['closeHit'] and metrics['closeBottom']<=height,metrics
  187. assert not metrics['overflow'],(panel,width,metrics['overflow'])
  188. report['layouts'].append({'panel':panel,'width':width,'height':height,**metrics})
  189. if width in (1440,390,320):shot(panel+'-'+str(width)+'.png')
  190. check('all-six-panels-fit-five-real-viewports-with-left-menu-and-hit-testable-close')
  191. click_child('[data-product-panel="overview"]');panel_ready('overview')
  192. call('Emulation.setDeviceMetricsOverride',{'width':1440,'height':900,'deviceScaleFactor':1,'mobile':False});settle()
  193. images=action('return [...p.renderRoot.querySelectorAll(".product-detail-dialog library-product-image")].map(c=>{const i=c.renderRoot.querySelector("img");return {complete:i?.complete,width:i?.naturalWidth,height:i?.naturalHeight}})')
  194. assert images and all(i['complete'] and i['width']>0 for i in images),images
  195. report['decodedImages']=images
  196. before_scroll=ev('scrollY')
  197. point=action('const r=p.renderRoot.querySelector("library-product-detail").renderRoot.querySelector(".product-content").getBoundingClientRect();return {x:r.x+r.width/2,y:r.y+r.height/2}')
  198. call('Input.dispatchMouseEvent',{'type':'mouseWheel','x':point['x'],'y':point['y'],'deltaX':0,'deltaY':600});settle()
  199. assert action('return p.renderRoot.querySelector("library-product-detail").renderRoot.querySelector(".product-content").scrollTop')>0
  200. assert ev('scrollY')==before_scroll
  201. assert action('return p.renderRoot.querySelector("[data-close-product]").getBoundingClientRect().bottom')<900
  202. mouse_click('.product-detail-dialog .detail-actions a[data-link]');ready();settle()
  203. assert not action('return p.recordId')
  204. assert action('return !!p.routeQuery.shopId')
  205. assert ev('document.documentElement.style.overflow')!='hidden'
  206. check('real-images-decode-internal-scroll-keeps-header-fixed-and-shop-link-unlocks-page')
  207. report['consoleExceptions']=[e for e in events if e['method']=='Runtime.exceptionThrown']
  208. assert not report['consoleExceptions'],report['consoleExceptions']
  209. report['generation']=action('return p._summary.generation')
  210. report['ok']=True
  211. except Exception:
  212. if ws:
  213. report['failureState']=action('return {url:location.href,loading:p?._loading,error:p?._error,recordId:p?.recordId}')
  214. shot('failure.png')
  215. raise
  216. finally:
  217. if ws:ws.close()
  218. browser.terminate()
  219. try:browser.wait(timeout=10)
  220. except subprocess.TimeoutExpired:browser.kill();browser.wait(timeout=10)
  221. (out/'verification.json').write_text(json.dumps(report,ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
  222. print(json.dumps({'ok':report['ok'],'checks':report['checks'],'layouts':len(report['layouts']),'consoleExceptions':len(report.get('consoleExceptions',[])),'receipt':str(out/'verification.json')},ensure_ascii=False,indent=2))
  223. if __name__=='__main__':main()