verify-product-images.py 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105
  1. """Real browser checks for product images; does not write business data."""
  2. import base64
  3. import json
  4. from pathlib import Path
  5. import subprocess
  6. import tempfile
  7. import time
  8. from urllib.request import urlopen
  9. import websocket
  10. ROOT = Path(__file__).resolve().parents[1]
  11. OUT = ROOT / 'docs/industry-product-images-20261106'
  12. SHOTS = OUT / 'screenshots'
  13. SHOTS.mkdir(parents=True, exist_ok=True)
  14. profile = Path(tempfile.mkdtemp(prefix='images-browser-', dir=ROOT / 'work/library-preview'))
  15. process = subprocess.Popen(['C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe', '--headless=new', '--disable-gpu',
  16. '--remote-debugging-port=0', '--disable-background-networking', '--disable-component-update', '--disable-sync', '--disable-extensions',
  17. '--no-first-run', '--no-default-browser-check', '--user-data-dir=' + str(profile), 'about:blank'],
  18. stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, creationflags=subprocess.CREATE_NO_WINDOW)
  19. ws = None
  20. serial = 0
  21. events = []
  22. try:
  23. deadline = time.monotonic() + 20
  24. while not (profile / 'DevToolsActivePort').exists():
  25. assert time.monotonic() < deadline and process.poll() is None
  26. time.sleep(.1)
  27. port = (profile / 'DevToolsActivePort').read_text().splitlines()[0]
  28. page = next(p for p in json.load(urlopen(f'http://127.0.0.1:{port}/json/list', timeout=5)) if p['type'] == 'page')
  29. ws = websocket.create_connection(page['webSocketDebuggerUrl'], timeout=40, suppress_origin=True, http_no_proxy=['127.0.0.1', 'localhost'])
  30. def call(method, params=None):
  31. global serial
  32. serial += 1
  33. rid = serial
  34. ws.send(json.dumps(dict(id=rid, method=method, params=params or {})))
  35. while True:
  36. response = json.loads(ws.recv())
  37. if response.get('id') == rid:
  38. assert 'error' not in response, response
  39. return response.get('result', {})
  40. events.append(response)
  41. def evaluate(expression):
  42. result = call('Runtime.evaluate', dict(expression=expression, returnByValue=True, awaitPromise=True))
  43. assert 'exceptionDetails' not in result, result
  44. return result['result'].get('value')
  45. def wait(expression):
  46. evaluate('new Promise((resolve,reject)=>{const end=Date.now()+30000;const tick=()=>{try{if(' + expression + '){resolve(true);return;}}catch{}if(Date.now()>end)reject(new Error("Timed out: "+' + json.dumps(expression) + '));else setTimeout(tick,70)};tick()})')
  47. V = 'document.querySelector("kf-app")?.renderRoot.querySelector("view-library")'
  48. def action(body): return evaluate('(()=>{const v=' + V + ';' + body + '})()')
  49. def ready(): wait(V + '?._summary && !' + V + '._loading && (' + V + '._data || ' + V + '._detail)')
  50. def go(path):
  51. call('Page.navigate', {'url': 'http://127.0.0.1:4181' + path})
  52. ready()
  53. def shot(name):
  54. data = call('Page.captureScreenshot', dict(format='png', captureBeyondViewport=False))
  55. (SHOTS / (name + '.png')).write_bytes(base64.b64decode(data['data']))
  56. call('Page.enable'); call('Runtime.enable'); call('Network.enable')
  57. layouts = []
  58. for width in [1440, 1024, 390, 320]:
  59. call('Emulation.setDeviceMetricsOverride', dict(width=width, height=1000, deviceScaleFactor=1, mobile=width < 600))
  60. go('/library?type=products&platform=jd&shopId=1000084104&sort=reviews_desc')
  61. wait(V + '.renderRoot.querySelector("library-product-image")?.renderRoot.querySelector("img")?.complete')
  62. layout = action('return {width:innerWidth,documentWidth:document.documentElement.scrollWidth,loaded:v._data.records.filter(r=>r.image).length,cards:v.renderRoot.querySelectorAll(".product-choice").length,columns:getComputedStyle(v.renderRoot.querySelector(".record-list")).gridTemplateColumns.split(" ").length};')
  63. assert layout['documentWidth'] <= width, layout
  64. assert layout['loaded'] >= 10, layout
  65. assert not action('return !!v.renderRoot.querySelector("a a,a button");')
  66. layouts.append(layout)
  67. shot('catalog-' + str(width))
  68. go('/library/products/jd%3A100005702440')
  69. wait(V + '._detail.record.image && ' + V + '.renderRoot.querySelector("library-product-image")?.renderRoot.querySelector("img")?.naturalWidth > 0')
  70. assert evaluate('document.documentElement.scrollWidth <= innerWidth')
  71. shot('detail-' + str(width))
  72. call('Emulation.setDeviceMetricsOverride', dict(width=1440, height=1000, deviceScaleFactor=1, mobile=False))
  73. go('/library?compare=%5B%22jd%3A100005702440%22%2C%22jd%3A2848752%22%5D&comparing=1')
  74. C = V + '.renderRoot.querySelector("library-product-compare")'
  75. wait(C + ' && !' + C + '._loading && ' + C + '._items.every(i=>i.detail?.record.image)')
  76. wait(C + '.renderRoot.querySelector("library-product-image")?.renderRoot.querySelector("img")?.naturalWidth > 0')
  77. shot('compare-1440')
  78. call('Emulation.setDeviceMetricsOverride', dict(width=390, height=1000, deviceScaleFactor=1, mobile=True))
  79. assert evaluate('document.documentElement.scrollWidth <= innerWidth')
  80. shot('compare-390')
  81. # Explicitly injected read error: product text remains available.
  82. go('/library?type=products')
  83. evaluate('window.__imageFetch=window.fetch;window.fetch=(url,options)=>String(url).includes("/classes/KsfProductImage")?Promise.reject(new Error("image fixture offline")):window.__imageFetch(url,options);')
  84. action('v._data.records.forEach(r=>r.image=null);v.requestUpdate();')
  85. wait(V + '.renderRoot.querySelector("library-product-image")?.renderRoot.querySelector(".placeholder")')
  86. assert action('return v._data.records.length > 0 && !!v.renderRoot.querySelector("a[data-record-id] h4").textContent;')
  87. shot('missing-placeholder')
  88. action('v._data={...v._data,imagesUnavailable:true};v.requestUpdate();')
  89. wait(V + '.renderRoot.querySelector("library-product-image")?.renderRoot.textContent.includes("图片暂时无法加载")')
  90. shot('image-read-error')
  91. errors = [e for e in events if e.get('method') == 'Runtime.exceptionThrown']
  92. assert not errors, errors
  93. report = {'ok': True, 'layouts': layouts, 'consoleExceptions': 0, 'businessWrites': 0,
  94. 'checks': ['production-image-bytes-rendered', 'four-widths-no-document-overflow', 'normal-product-navigation',
  95. 'full-size-detail-image', 'comparison-images', 'valid-card-html', 'missing-image-placeholder-keeps-text'],
  96. 'fixtures': ['missing-image-placeholder']}
  97. (OUT / 'browser-acceptance.json').write_text(json.dumps(report, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
  98. print(json.dumps(report, ensure_ascii=False, indent=2))
  99. finally:
  100. if ws: ws.close()
  101. process.terminate()
  102. try: process.wait(timeout=8)
  103. except subprocess.TimeoutExpired: process.kill()