verify-product-overview-browser.py 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103
  1. """Verify product insights, exact quote navigation and responsive layout on real data."""
  2. import argparse
  3. import base64
  4. import json
  5. from pathlib import Path
  6. import subprocess
  7. import tempfile
  8. import time
  9. from urllib.request import urlopen
  10. import websocket
  11. ROOT = Path(__file__).resolve().parents[1]
  12. WORK = ROOT / 'work/library-preview'
  13. WORK.mkdir(parents=True, exist_ok=True)
  14. p = argparse.ArgumentParser(description=__doc__)
  15. p.add_argument('--url', default='http://127.0.0.1:4181')
  16. args = p.parse_args()
  17. profile = Path(tempfile.mkdtemp(prefix='overview-browser-', dir=WORK))
  18. process = subprocess.Popen(['C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe', '--headless=new',
  19. '--disable-gpu', '--remote-debugging-port=0', '--disable-background-networking', '--disable-extensions',
  20. '--no-first-run', '--user-data-dir=' + str(profile), 'about:blank'], stdout=subprocess.DEVNULL,
  21. stderr=subprocess.DEVNULL, creationflags=subprocess.CREATE_NO_WINDOW)
  22. ws = None
  23. serial = 0
  24. events = []
  25. try:
  26. end = time.monotonic() + 20
  27. while not (profile / 'DevToolsActivePort').exists():
  28. assert time.monotonic() < end and process.poll() is None
  29. time.sleep(.1)
  30. port = (profile / 'DevToolsActivePort').read_text().splitlines()[0]
  31. page = next(x for x in json.load(urlopen('http://127.0.0.1:' + port + '/json/list')) if x['type'] == 'page')
  32. ws = websocket.create_connection(page['webSocketDebuggerUrl'], timeout=35, suppress_origin=True, http_no_proxy=['127.0.0.1'])
  33. def call(method, params=None):
  34. global serial
  35. serial += 1
  36. ws.send(json.dumps(dict(id=serial, method=method, params=params or {})))
  37. while True:
  38. r = json.loads(ws.recv())
  39. if r.get('id') == serial:
  40. assert 'error' not in r, r
  41. return r.get('result', {})
  42. events.append(r)
  43. def ev(expression, promise=False):
  44. r = call('Runtime.evaluate', dict(expression=expression, returnByValue=True, awaitPromise=promise))
  45. assert 'exceptionDetails' not in r, r
  46. return r['result'].get('value')
  47. def wait(condition):
  48. ev('new Promise((resolve,reject)=>{const end=Date.now()+25000;const tick=()=>{try{if(' + condition + '){resolve(true);return}}catch{}if(Date.now()>end)reject(new Error("timeout"));else setTimeout(tick,60)};tick()})', True)
  49. V = 'document.querySelector("kf-app")?.shadowRoot?.querySelector("view-library")'
  50. P = V + '?.renderRoot.querySelector("library-product-detail")'
  51. def action(body): return ev('(()=>{const p=' + P + ';' + body + '})()')
  52. def ready(): wait(P + '?.product?.overview && !' + P + '._loading')
  53. def shot(name):
  54. data = call('Page.captureScreenshot', dict(format='png', captureBeyondViewport=False))
  55. (WORK / (name + '.png')).write_bytes(base64.b64decode(data['data']))
  56. call('Page.enable'); call('Runtime.enable')
  57. call('Emulation.setDeviceMetricsOverride', dict(width=1440, height=1100, deviceScaleFactor=1, mobile=False))
  58. url = args.url.rstrip('/') + '/library/products/jd%3A100005702440'
  59. call('Page.navigate', dict(url=url)); ready()
  60. stats = action('return p.product.overview.reviews;')
  61. assert (stats['collected'], stats['eligible'], stats['distinctTexts']) == (19, 18, 17), stats
  62. themes = action('return p.product.overview.themes;')
  63. assert [t['distinctTextCount'] for t in themes] == sorted([t['distinctTextCount'] for t in themes], reverse=True)
  64. assert action('return p.renderRoot.querySelector("details.theme-item").open;')
  65. assert action('return Array.from(p.renderRoot.querySelectorAll(".overview-metrics strong")).map(x=>+x.textContent);')[:3] == [19, 18, 17]
  66. shot('product-overview-desktop')
  67. selected = themes[0]['examples'][0]['reviewId']
  68. action('p.renderRoot.querySelector(".theme-quotes .text-button").click();')
  69. wait(P + '?.panel==="reviews" && !' + P + '._loading && ' + P + '._data')
  70. assert action('return p._data.totalMatched;') == 1
  71. assert action('return p._data.records[0].record_id;') == selected
  72. call('Page.reload'); ready()
  73. assert action('return p._data.totalMatched;') == 1
  74. action('p.renderRoot.querySelector(".review-controls .text-button").click();')
  75. wait('!' + P + '.query.relatedReviewId && !' + P + '._loading && ' + P + '._data?.totalMatched===18')
  76. action('p.renderRoot.getElementById("tab-overview").click();'); ready()
  77. layouts = []
  78. for width in (1440, 1024, 390, 320):
  79. call('Emulation.setDeviceMetricsOverride', dict(width=width, height=1100, deviceScaleFactor=1, mobile=width < 600))
  80. ev('new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))', True)
  81. if width == 390: shot('product-overview-mobile')
  82. assert ev('document.documentElement.scrollWidth<=innerWidth'), width
  83. sizes = action('return Array.from(p.renderRoot.querySelectorAll(".insights-grid,.overview-metrics,.theme-item,.aggregate-panel")).map(x=>({class:x.className,scroll:x.scrollWidth,width:x.clientWidth}));')
  84. assert all(x['scroll'] <= x['width'] + 1 for x in sizes), (width, sizes)
  85. layouts.append(dict(width=width, metricColumns=action('return getComputedStyle(p.renderRoot.querySelector(".overview-metrics")).gridTemplateColumns.split(" ").length;')))
  86. call('Emulation.setDeviceMetricsOverride', dict(width=1440, height=1100, deviceScaleFactor=1, mobile=False))
  87. call('Page.navigate', dict(url=args.url.rstrip('/') + '/library?sort=reviews_asc'))
  88. wait(V + '?._data?.records?.length && !' + V + '._loading')
  89. empty = ev(V + '._data.records.find(r=>!r.relatedCounts.reviews).record_id')
  90. call('Page.navigate', dict(url=args.url.rstrip('/') + '/library/products/' + empty.replace(':', '%3A'))); ready()
  91. assert action('return p.product.overview.reviews.collected;') == 0
  92. assert action('return p.renderRoot.querySelector(".insight-empty").textContent.includes("暂未收录评价");')
  93. assert not [e for e in events if e.get('method') == 'Runtime.exceptionThrown']
  94. result = dict(ok=True, stats=stats, layouts=layouts, exactQuoteNavigation=True, refreshPreservesQuote=True,
  95. emptyProductVerified=True, browserVerified=True, productionBrowserVerified=False, databaseWrites=0)
  96. (WORK / 'product-overview-validation.json').write_text(json.dumps(result, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
  97. print(json.dumps(result, ensure_ascii=False, indent=2))
  98. finally:
  99. if ws: ws.close()
  100. process.terminate()
  101. try: process.wait(timeout=8)
  102. except subprocess.TimeoutExpired: process.kill()