verify-product-ui-release.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271
  1. """Verify frozen product UI with real production data, before or after deployment."""
  2. import argparse
  3. import base64
  4. import datetime as dt
  5. import hashlib
  6. import json
  7. from pathlib import Path
  8. import subprocess
  9. import tempfile
  10. import time
  11. from urllib.parse import quote
  12. from urllib.request import Request, urlopen
  13. import websocket
  14. ROOT = Path(__file__).resolve().parents[1]
  15. parser = argparse.ArgumentParser(description=__doc__)
  16. parser.add_argument('--package-dir', type=Path)
  17. parser.add_argument('--out', type=Path)
  18. parser.add_argument('--url', default='https://kangshifu.fmode.cn')
  19. parser.add_argument('--production', action='store_true')
  20. args = parser.parse_args()
  21. PACKAGE = (args.package_dir or (ROOT if (ROOT / 'deploy/product-ui-files.json').exists()
  22. else ROOT / 'work/product-images-layout-20261106-r2/package')).resolve()
  23. WORK = (args.out or PACKAGE.parent).resolve()
  24. SHOTS = WORK / 'screenshots'
  25. SHOTS.mkdir(parents=True, exist_ok=True)
  26. manifest = json.loads((PACKAGE / 'deploy/product-ui-files.json').read_text(encoding='utf-8'))
  27. payload = {}
  28. for item in manifest['files']:
  29. raw = (PACKAGE / 'payload' / item['path']).read_bytes()
  30. assert hashlib.sha256(raw).hexdigest() == item['sha256']
  31. payload[item['path']] = raw
  32. BASE = args.url.rstrip('/')
  33. def read_public(relative, data=None):
  34. headers = {'Cache-Control': 'no-cache'}
  35. if data is not None:
  36. headers['Content-Type'] = 'application/json'
  37. with urlopen(Request(BASE + '/' + relative, data, headers), timeout=40) as response:
  38. return response.read(), response.headers.get('Content-Type', '')
  39. def api(request):
  40. raw, _ = read_public('api/library', json.dumps(request).encode())
  41. result = json.loads(raw)
  42. assert result.get('ok'), result.get('error')
  43. return result
  44. summary = api({'op': 'summary'})
  45. generation = summary['generation']
  46. if args.production:
  47. for item in manifest['files']:
  48. raw, mime = read_public(item['path'])
  49. assert 'javascript' in mime and hashlib.sha256(raw).hexdigest() == item['sha256'], item['path']
  50. raw, _ = read_public('index.html')
  51. assert hashlib.sha256(raw).hexdigest() == manifest['homepageSha256']
  52. profile = Path(tempfile.mkdtemp(prefix='integrated-browser-', dir=WORK))
  53. process = subprocess.Popen(['C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe', '--headless=new',
  54. '--disable-gpu', '--remote-debugging-port=0', '--disable-background-networking', '--disable-extensions',
  55. '--no-first-run', '--user-data-dir=' + str(profile), 'about:blank'], stdout=subprocess.DEVNULL,
  56. stderr=subprocess.DEVNULL, creationflags=subprocess.CREATE_NO_WINDOW)
  57. ws = None
  58. serial = 0
  59. events = []
  60. overrides = {}
  61. report = {'ok': False, 'checkedAt': dt.datetime.now(dt.timezone(dt.timedelta(hours=8))).isoformat(),
  62. 'generation': generation, 'productionInstalled': args.production, 'productionWrites': 0, 'paidCalls': 0,
  63. 'verificationMode': 'public-site-no-overrides' if args.production else 'frozen-modules-overridden-in-private-browser',
  64. 'baseUrl': BASE, 'realProductionData': True, 'layouts': [], 'fixtures': []}
  65. try:
  66. deadline = time.monotonic() + 20
  67. while not (profile / 'DevToolsActivePort').exists():
  68. assert time.monotonic() < deadline and process.poll() is None
  69. time.sleep(.1)
  70. port = (profile / 'DevToolsActivePort').read_text().splitlines()[0]
  71. page = next(p for p in json.load(urlopen('http://127.0.0.1:' + port + '/json/list', timeout=5)) if p['type'] == 'page')
  72. ws = websocket.create_connection(page['webSocketDebuggerUrl'], timeout=45, suppress_origin=True,
  73. http_no_proxy=['127.0.0.1', 'localhost'])
  74. def send(method, params=None):
  75. global serial
  76. serial += 1
  77. ws.send(json.dumps({'id': serial, 'method': method, 'params': params or {}}))
  78. return serial
  79. def call(method, params=None):
  80. rid = send(method, params)
  81. while True:
  82. response = json.loads(ws.recv())
  83. if response.get('method') == 'Fetch.requestPaused':
  84. request = response['params']
  85. relative = request['request']['url'].split('?', 1)[0].removeprefix(BASE + '/')
  86. assert relative in payload and not args.production
  87. overrides[relative] = overrides.get(relative, 0) + 1
  88. send('Fetch.fulfillRequest', {'requestId': request['requestId'], 'responseCode': 200,
  89. 'responseHeaders': [{'name': 'Content-Type', 'value': 'application/javascript; charset=utf-8'},
  90. {'name': 'Cache-Control', 'value': 'no-store'}],
  91. 'body': base64.b64encode(payload[relative]).decode()})
  92. elif response.get('id') == rid:
  93. assert 'error' not in response, response
  94. return response.get('result', {})
  95. else:
  96. events.append(response)
  97. def ev(expression):
  98. result = call('Runtime.evaluate', {'expression': expression, 'returnByValue': True, 'awaitPromise': True})
  99. assert 'exceptionDetails' not in result, result
  100. return result['result'].get('value')
  101. def wait(expression):
  102. ev('new Promise((resolve,reject)=>{const end=Date.now()+35000;const tick=()=>{try{if(' + expression +
  103. '){resolve(true);return}}catch{}if(Date.now()>end)reject(new Error("UI wait timed out: "+' + json.dumps(expression) +
  104. '));else setTimeout(tick,70)};tick()})')
  105. V = 'document.querySelector("kf-app")?.renderRoot.querySelector("view-library")'
  106. P = V + '?.renderRoot.querySelector("library-product-detail")'
  107. C = V + '?.renderRoot.querySelector("library-product-compare")'
  108. def action(body):
  109. return ev('(()=>{const v=' + V + ';const p=' + P + ';const c=' + C + ';' + body + '})()')
  110. def ready():
  111. wait(V + '?._summary && !' + V + '._loading && (' + V + '._data || ' + V + '._detail)')
  112. assert ev(V + '._summary.generation') == generation
  113. def go(path):
  114. call('Page.navigate', {'url': BASE + path})
  115. ready()
  116. def shot(name):
  117. raw = call('Page.captureScreenshot', {'format': 'png', 'captureBeyondViewport': False})
  118. (SHOTS / (name + '.png')).write_bytes(base64.b64decode(raw['data']))
  119. def size(width):
  120. call('Emulation.setDeviceMetricsOverride', {'width': width, 'height': 1100, 'deviceScaleFactor': 1, 'mobile': width < 600})
  121. ev('new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))')
  122. def product_ready():
  123. wait(P + '?.product?.overview && !' + P + '._loading')
  124. wait(V + '.renderRoot.querySelector(".product-hero>library-product-image")?.renderRoot.querySelector("img")?.naturalWidth>0')
  125. call('Page.enable')
  126. call('Runtime.enable')
  127. call('Network.enable')
  128. call('Network.setCacheDisabled', {'cacheDisabled': True})
  129. if not args.production:
  130. call('Fetch.enable', {'patterns': [{'urlPattern': BASE + '/' + name + '*', 'resourceType': 'Script',
  131. 'requestStage': 'Request'} for name in payload]})
  132. catalog = '/library?type=products&platform=jd&shopId=1000084104&sort=reviews_desc'
  133. for width in [1440, 1024, 768, 390, 320]:
  134. size(width)
  135. go(catalog)
  136. wait(V + '.renderRoot.querySelector("library-product-image")?.renderRoot.querySelector("img")?.naturalWidth>0')
  137. state = action('return {documentWidth:document.documentElement.scrollWidth,records:v._data.records.length,'
  138. 'images:v._data.records.filter(r=>r.image).length,'
  139. 'columns:getComputedStyle(v.renderRoot.querySelector(".record-list")).gridTemplateColumns.split(" ").length,'
  140. 'exactShop:v._data.records.every(r=>r.shop_id==="1000084104" && r.platform==="jd"),'
  141. 'noNestedControls:!v.renderRoot.querySelector("a button,a input,a a"),'
  142. 'firstImageIsStoredBytes:v.renderRoot.querySelector("library-product-image").image.thumbnail.startsWith("data:image/jpeg;base64,")};')
  143. assert state['documentWidth'] <= width and state['images'] == state['records'] > 0
  144. assert state['exactShop'] and state['noNestedControls'] and state['firstImageIsStoredBytes']
  145. assert state['columns'] == 2 if width < 600 else state['columns'] >= (2 if width >= 1024 else 1)
  146. if width in [1440, 390, 320]:
  147. shot('catalog-' + str(width))
  148. go('/library/products/jd%3A100005702440')
  149. product_ready()
  150. position = action('const image=v.renderRoot.querySelector(".product-hero>library-product-image").getBoundingClientRect();'
  151. 'const info=v.renderRoot.querySelector(".product-hero .product-info").getBoundingClientRect();'
  152. 'return {image:{left:image.left,right:image.right,top:image.top,bottom:image.bottom},'
  153. 'info:{left:info.left,right:info.right,top:info.top,bottom:info.bottom},'
  154. 'facts:v.renderRoot.querySelectorAll(".product-facts>div").length,'
  155. 'noDuplicate:!p.renderRoot.querySelector(".product-info"),documentWidth:document.documentElement.scrollWidth};')
  156. assert position['documentWidth'] <= width and position['facts'] >= 4 and position['noDuplicate']
  157. if width >= 600:
  158. assert position['info']['left'] >= position['image']['right']
  159. else:
  160. assert position['info']['top'] >= position['image']['bottom']
  161. if width in [1440, 390, 320]:
  162. shot('detail-' + str(width))
  163. report['layouts'].append({'width': width, 'catalog': state, 'detail': position})
  164. print('Catalog images and detail facts: ' + str(width) + ' passed', flush=True)
  165. size(1440)
  166. go('/library/products/taobao%3A1040511334739')
  167. product_ready()
  168. report['crossPlatformImageVerified'] = action('return v._detail.record.id==="taobao:1040511334739" && !!v._detail.record.image;')
  169. assert report['crossPlatformImageVerified']
  170. go(catalog)
  171. action('v.renderRoot.querySelector("button[aria-label=列表视图]").click();')
  172. wait(V + '.renderRoot.querySelector(".record-list.list")')
  173. assert action('return !!v.renderRoot.querySelector(".record-list.list library-product-image");')
  174. action('v.renderRoot.querySelector("button[aria-label=卡片视图]").click();'
  175. 'v.renderRoot.querySelectorAll(".compare-toggle")[0].click();')
  176. wait(V + '._compareIds.length===1')
  177. action('v.renderRoot.querySelectorAll(".compare-toggle")[1].click();')
  178. wait(V + '._compareIds.length===2')
  179. action('v.renderRoot.querySelector("#open-compare").click();')
  180. wait(C + ' && !' + C + '._loading && ' + C + '._items.every(i=>i.detail?.record.image)')
  181. assert action('return c._items.every(i=>i.id===i.detail.record.id && i.detail.generation===c._summary.generation);')
  182. wait(C + '.renderRoot.querySelector("library-product-image")?.renderRoot.querySelector("img")?.naturalWidth>0')
  183. shot('compare-1440')
  184. size(390)
  185. assert ev('document.documentElement.scrollWidth<=innerWidth')
  186. assert action('const r=c.renderRoot.querySelector("dialog").getBoundingClientRect();return r.left>=0 && r.right<=innerWidth;')
  187. shot('compare-390')
  188. call('Page.reload', {'ignoreCache': True})
  189. ready()
  190. wait(C + ' && !' + C + '._loading && ' + C + '._items.length===2 && ' + C + '._items.every(i=>i.detail)')
  191. call('Input.dispatchKeyEvent', {'type': 'keyDown', 'key': 'Escape', 'code': 'Escape', 'windowsVirtualKeyCode': 27})
  192. call('Input.dispatchKeyEvent', {'type': 'keyUp', 'key': 'Escape', 'code': 'Escape', 'windowsVirtualKeyCode': 27})
  193. wait('!' + C)
  194. assert action('return v._compareIds.length===2;')
  195. report['comparisonImagesAndRefreshVerified'] = True
  196. report['listViewImageVerified'] = True
  197. assert action('return Array.from(v.renderRoot.querySelector("select[aria-label=平台]").options).some(o=>o.value==="tmall");')
  198. action('const s=v.renderRoot.querySelector("select[aria-label=平台]");s.value="tmall";s.dispatchEvent(new Event("change",{bubbles:true}));')
  199. wait(V + '.routeQuery.platform==="tmall" && !' + V + '._loading && ' + V + '._data')
  200. assert action('return !v.routeQuery.shopId && !v.routeQuery.cursor;')
  201. report['platformChangeClearsShopVerified'] = True
  202. size(1440)
  203. go('/library/products/jd%3A100017664018?panel=reviews')
  204. product_ready()
  205. wait(P + '._data?.totalMatched===10')
  206. assert action('return p.reviewQuality==="audit" && p.renderRoot.querySelectorAll("article.review").length===10;')
  207. action('const s=p.renderRoot.querySelector("select[aria-label=商品评价范围]");s.value="analysis";s.dispatchEvent(new Event("change",{bubbles:true}));')
  208. wait(P + '.query.relatedQuality==="analysis" && !' + P + '._loading && ' + P + '._data?.totalMatched===0')
  209. action('const s=p.renderRoot.querySelector("select[aria-label=商品评价范围]");s.value="audit";s.dispatchEvent(new Event("change",{bubbles:true}));')
  210. wait(P + '.query.relatedQuality==="audit" && !' + P + '._loading && ' + P + '._data?.totalMatched===10')
  211. call('Page.reload', {'ignoreCache': True})
  212. ready()
  213. wait(P + '._data?.totalMatched===10')
  214. go('/library/products/jd%3A100005702440?panel=reviews')
  215. product_ready()
  216. wait(P + '._data?.totalMatched===18')
  217. assert action('return p.reviewQuality==="analysis";')
  218. report['publishedReviewHotfixPreserved'] = True
  219. if not args.production:
  220. # These two presentation states are private fixtures, kept separate from real-data checks.
  221. go('/library/products/jd%3A100005702440')
  222. product_ready()
  223. action('const image=v.renderRoot.querySelector(".product-hero>library-product-image");image.image=null;')
  224. wait(V + '.renderRoot.querySelector(".product-hero>library-product-image")?.renderRoot.querySelector(".placeholder")')
  225. assert action('return !!v.renderRoot.querySelector(".product-facts").textContent && !!v.renderRoot.querySelector("h2").textContent;')
  226. action('v.renderRoot.querySelector(".product-hero>library-product-image").unavailable=true;')
  227. wait(V + '.renderRoot.querySelector(".product-hero>library-product-image")?.renderRoot.textContent.includes("图片暂时无法加载")')
  228. report['fixtures'] = ['missing-image-keeps-facts', 'image-read-error-distinguished-from-missing']
  229. exceptions = [e['params']['exceptionDetails'].get('text') for e in events if e.get('method') == 'Runtime.exceptionThrown']
  230. assert not exceptions, exceptions
  231. assert set(overrides) == set(payload) if not args.production else not overrides
  232. api({'op': 'version', 'generation': generation})
  233. report.update(ok=True, consoleExceptions=0, overriddenModules=overrides, filesVerified=len(payload),
  234. publicBrowserVerified=args.production, upperRightFactsVerified=True,
  235. mobileImageAboveFactsVerified=True)
  236. finally:
  237. if ws:
  238. ws.close()
  239. process.terminate()
  240. try:
  241. process.wait(timeout=8)
  242. except subprocess.TimeoutExpired:
  243. process.kill()
  244. name = 'production-browser-verification.json' if args.production else 'browser-verification.json'
  245. (WORK / name).write_text(json.dumps(report, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
  246. print(json.dumps(report, ensure_ascii=False, indent=2))