| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244 |
- """Real-browser acceptance for the product modal, navigation and responsive reader."""
- 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-product-modal-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()
- def panel_ready(panel):
- if panel not in ['overview','source']:
- child_ready(panel)
- else:
- 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()})')
- settle()
- def click_child(selector):
- 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}')
- 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']:
- call('Input.dispatchKeyEvent',{'type':kind,'key':name,'code':code,'windowsVirtualKeyCode':vk,'nativeVirtualKeyCode':vk})
- settle()
- def closed():
- ready();settle()
- assert action('return !p.recordId&&!p.renderRoot.querySelector(".product-detail-dialog").open')
- assert ev('document.documentElement.style.overflow')!='hidden'
- def modal_state():
- 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}')
- call('Runtime.enable');call('Page.enable');call('Network.enable')
- call('Emulation.setFocusEmulationEnabled', {'enabled':True})
- call('Browser.grantPermissions',{'origin':args.url,'permissions':['clipboardReadWrite','clipboardSanitizedWrite']})
- call('Emulation.setDeviceMetricsOverride',{'width':1440,'height':900,'deviceScaleFactor':1,'mobile':False})
- catalog='/library?type=products&quality=audit&category=banmian&platform=jd&brand=ksf&page=2'
- navigate(catalog)
- mouse_click('[data-record-id]:nth-of-type(1)')
- ready();panel_ready('overview')
- first=modal_state()
- assert first['open'] and first['modal'] and first['background']==24 and first['panels']==6,first
- product_id=first['id']
- assert action('return history.state.kfLibraryOverlay.returnTo')==catalog
- assert ev('document.documentElement.style.overflow')=='hidden'
- check('real-card-click-opens-native-modal-over-the-same-24-card-catalogue')
- click_child('[data-product-panel="reviews"]');panel_ready('reviews')
- click_child('[data-product-panel="source"]');panel_ready('source')
- call('Runtime.evaluate',{'expression':'history.back()'});settle();closed()
- assert action('return p.routeQuery.page')=='2'
- assert action('return p.routeQuery.category')=='banmian'
- call('Runtime.evaluate',{'expression':'history.forward()'});settle();ready();panel_ready('source')
- assert modal_state()['modal']
- mouse_click('[data-close-product]');closed()
- check('sidebar-replaces-one-history-entry-back-closes-forward-reopens-with-filters')
- # Open from a scrolled card and verify native focus and scroll restoration.
- mouse_click('.product-choice:nth-child(12) [data-record-id]')
- ready();panel_ready('overview')
- return_scroll=action('return history.state.kfLibraryOverlay.scrollY')
- assert return_scroll>0
- mouse_click('[data-close-product]');closed()
- assert abs(ev('scrollY')-return_scroll)<2
- assert action('return p.renderRoot.activeElement?.dataset.recordId')==action('return p._data.records[11].record_id')
- mouse_click('.product-choice:nth-child(12) [data-record-id]');ready();panel_ready('overview')
- key('Escape','Escape',27);closed()
- mouse_click('.product-choice:nth-child(12) [data-record-id]');ready();panel_ready('overview')
- click_point(2,2);closed()
- check('close-button-escape-and-backdrop-restore-page-scroll-and-card-focus')
- mouse_click('[data-selection-mode]')
- before=action('return p._selected.length')
- mouse_click('.product-choice:nth-child(1) h4')
- assert action('return p._selected.length')==before+1
- assert not action('return p.renderRoot.querySelector(".product-detail-dialog").open')
- mouse_click('[data-selection-mode]')
- check('selection-mode-continues-to-select-the-whole-card-without-opening-details')
- navigate('/library/products/jd%3A100000348106?panel=reviews&relatedQuality=audit')
- panel_ready('reviews')
- assert modal_state()['modal']
- assert action('return p.renderRoot.querySelector("library-product-detail")._data.records.length')>0
- click_child('[data-select-child]')
- assert action('return p._selected.some(x=>x.entity==="reviews"&&x.productId==="jd:100000348106")')
- mouse_click('.product-detail-dialog [data-preview-materials]');copied_ready();settle()
- assert action('return p.renderRoot.querySelector(".material-dialog").matches(":modal")&&p.renderRoot.querySelector(".product-detail-dialog").matches(":modal")')
- key('Escape','Escape',27)
- assert action('return !p.renderRoot.querySelector(".material-dialog").open&&p.renderRoot.querySelector(".product-detail-dialog").open')
- mouse_click('.product-detail-dialog [data-copy-materials]');copied_ready()
- copied=ev('navigator.clipboard.readText()').replace('\r\n','\n')
- assert copied==action('return p._copyText')
- rows=[json.loads(line) for line in copied.splitlines() if line.startswith('{')]
- assert any(row.get('entity')=='reviews' for row in rows)
- assert not any(term in copied for term in ['员工','请返回','请你'])
- check('child-selection-and-real-clipboard-work-with-copy-preview-stacked-above-details')
- # The selected review's original data must remain unchanged.
- review=action('return p._selected.find(x=>x.entity==="reviews")')
- 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})()')
- assert exact==review['id']
- report['verifiedReviewId']=exact
- for pid,quality,count in [('100017664018','audit',10),('100005702440','analysis',18),('100001720753','analysis',0)]:
- navigate('/library/products/jd%3A'+pid+'?panel=reviews');panel_ready('reviews')
- state=action('const c=p.renderRoot.querySelector("library-product-detail");return {quality:c.reviewQuality,count:c._data.records.length,text:c.renderRoot.textContent}')
- assert state['quality']==quality and state['count']==count,(pid,state)
- if not count:assert '不代表平台上没有评论' in state['text']
- check('review-audit-fallback-analysis-default-and-honest-no-review-message-remain-correct')
- navigate('/library/products/jd%3A100000348106?panel=reviews&relatedQuality=audit');panel_ready('reviews')
- call('Page.reload');settle();ready();panel_ready('reviews')
- assert modal_state()['modal']
- mouse_click('[data-close-product]');closed()
- assert ev('location.pathname')=='/library'
- assert not action('return Object.hasOwn(p.routeQuery,"panel")')
- check('direct-link-refresh-preserves-panel-and-close-returns-to-catalogue')
- navigate('/library/products/jd%3A100000348106');panel_ready('overview')
- action('p.renderRoot.querySelector("library-product-detail").renderRoot.querySelector("[data-product-panel=overview]").focus();return true')
- key('ArrowDown','ArrowDown',40);panel_ready('specs')
- key('End','End',35);panel_ready('source')
- key('Home','Home',36);panel_ready('overview')
- assert action('return p.renderRoot.querySelector("library-product-detail").renderRoot.activeElement.dataset.productPanel')=='overview'
- check('vertical-sidebar-supports-arrow-keys-home-end-and-visible-tab-focus')
- # Check the actual top-layer reader, not merely the inert page underneath it.
- for panel in ['overview','specs','prices','reviews','rankings','source']:
- click_child('[data-product-panel="'+panel+'"]');panel_ready(panel)
- for width,height in [(1440,900),(1024,900),(768,900),(390,844),(320,568)]:
- call('Emulation.setDeviceMetricsOverride',{'width':width,'height':height,'deviceScaleFactor':1,'mobile':False});settle()
- 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}')
- assert metrics['innerWidth']==width and 0<=metrics['left'] and metrics['right']<=width and metrics['bottom']<=height,metrics
- assert metrics['dialogWidth']==metrics['dialogScrollWidth'] and metrics['contentWidth']==metrics['contentScrollWidth'],(panel,width,metrics)
- assert metrics['sidebarRight']<=metrics['contentLeft']+1 and metrics['closeHit'] and metrics['closeBottom']<=height,metrics
- assert not metrics['overflow'],(panel,width,metrics['overflow'])
- report['layouts'].append({'panel':panel,'width':width,'height':height,**metrics})
- if width in (1440,390,320):shot(panel+'-'+str(width)+'.png')
- check('all-six-panels-fit-five-real-viewports-with-left-menu-and-hit-testable-close')
- click_child('[data-product-panel="overview"]');panel_ready('overview')
- call('Emulation.setDeviceMetricsOverride',{'width':1440,'height':900,'deviceScaleFactor':1,'mobile':False});settle()
- 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}})')
- assert images and all(i['complete'] and i['width']>0 for i in images),images
- report['decodedImages']=images
- before_scroll=ev('scrollY')
- 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}')
- call('Input.dispatchMouseEvent',{'type':'mouseWheel','x':point['x'],'y':point['y'],'deltaX':0,'deltaY':600});settle()
- assert action('return p.renderRoot.querySelector("library-product-detail").renderRoot.querySelector(".product-content").scrollTop')>0
- assert ev('scrollY')==before_scroll
- assert action('return p.renderRoot.querySelector("[data-close-product]").getBoundingClientRect().bottom')<900
- mouse_click('.product-detail-dialog .detail-actions a[data-link]');ready();settle()
- assert not action('return p.recordId')
- assert action('return !!p.routeQuery.shopId')
- assert ev('document.documentElement.style.overflow')!='hidden'
- check('real-images-decode-internal-scroll-keeps-header-fixed-and-shop-link-unlocks-page')
- 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,recordId:p?.recordId}')
- 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']),'consoleExceptions':len(report.get('consoleExceptions',[])),'receipt':str(out/'verification.json')},ensure_ascii=False,indent=2))
- if __name__=='__main__':main()
|