verify-library-compare.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235
  1. """Read-only acceptance: real local data plus explicitly labelled browser response fixtures."""
  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. from urllib.parse import quote
  11. import websocket
  12. ROOT = Path(__file__).resolve().parents[1]
  13. WORK = ROOT / 'docs/industry-voc-ui-20261106'
  14. SHOTS = WORK / 'screenshots'
  15. SHOTS.mkdir(parents=True, exist_ok=True)
  16. parser = argparse.ArgumentParser()
  17. parser.add_argument('--url', default='http://127.0.0.1:4181')
  18. args = parser.parse_args()
  19. profile = Path(tempfile.mkdtemp(prefix='compare-browser-', dir=ROOT / 'work/library-preview'))
  20. process = subprocess.Popen(['C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe', '--headless=new', '--disable-gpu',
  21. '--remote-debugging-port=0', '--disable-background-networking', '--disable-component-update', '--disable-sync', '--disable-extensions',
  22. '--no-first-run', '--no-default-browser-check', '--user-data-dir=' + str(profile), 'about:blank'],
  23. stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, creationflags=subprocess.CREATE_NO_WINDOW)
  24. ws = None
  25. serial = 0
  26. events = []
  27. try:
  28. deadline = time.monotonic() + 20
  29. while not (profile / 'DevToolsActivePort').exists():
  30. assert time.monotonic() < deadline and process.poll() is None
  31. time.sleep(.1)
  32. port = (profile / 'DevToolsActivePort').read_text().splitlines()[0]
  33. page = next(p for p in json.load(urlopen(f'http://127.0.0.1:{port}/json/list', timeout=5)) if p['type'] == 'page')
  34. ws = websocket.create_connection(page['webSocketDebuggerUrl'], timeout=35, suppress_origin=True, http_no_proxy=['127.0.0.1', 'localhost'])
  35. def call(method, params=None):
  36. global serial
  37. serial += 1
  38. rid = serial
  39. ws.send(json.dumps(dict(id=rid, method=method, params=params or {})))
  40. while True:
  41. response = json.loads(ws.recv())
  42. if response.get('id') == rid:
  43. assert 'error' not in response, response
  44. return response.get('result', {})
  45. events.append(response)
  46. def evaluate(expression):
  47. result = call('Runtime.evaluate', dict(expression=expression, returnByValue=True, awaitPromise=True))
  48. assert 'exceptionDetails' not in result, result
  49. return result['result'].get('value')
  50. def wait(expression):
  51. evaluate('new Promise((resolve,reject)=>{const end=Date.now()+25000;const tick=()=>{try{if(' + expression + '){resolve(true);return;}}catch{}if(Date.now()>end)reject(new Error("Timed out: "+' + json.dumps(expression) + '));else setTimeout(tick,60)};tick()})')
  52. V = 'document.querySelector("kf-app")?.renderRoot.querySelector("view-library")'
  53. C = V + '?.renderRoot.querySelector("library-product-compare")'
  54. def action(body): return evaluate('(()=>{const v=' + V + ';' + body + '})()')
  55. def compare(body): return evaluate('(()=>{const c=' + C + ';' + body + '})()')
  56. def ready(): wait(V + '?._summary && !' + V + '._loading && (' + V + '._data || ' + V + '._detail || ' + V + '._type==="reports")')
  57. def compare_ready(): wait(C + ' && !' + C + '._loading')
  58. def click(selector): action('v.renderRoot.querySelector(' + json.dumps(selector) + ').click();')
  59. def select(label, value):
  60. action('const s=[...v.renderRoot.querySelectorAll("select")].find(s=>s.getAttribute("aria-label")===' + json.dumps(label) + ');s.value=' + json.dumps(value) + ';s.dispatchEvent(new Event("change",{bubbles:true}));')
  61. ready()
  62. def go(path):
  63. call('Page.navigate', dict(url=args.url + path))
  64. ready()
  65. def shot(name):
  66. data = call('Page.captureScreenshot', dict(format='png', captureBeyondViewport=False))
  67. (SHOTS / (name + '.png')).write_bytes(base64.b64decode(data['data']))
  68. def escape():
  69. call('Input.dispatchKeyEvent', dict(type='keyDown', key='Escape', code='Escape', windowsVirtualKeyCode=27))
  70. call('Input.dispatchKeyEvent', dict(type='keyUp', key='Escape', code='Escape', windowsVirtualKeyCode=27))
  71. wait('!' + C)
  72. def open_compare():
  73. click('#open-compare')
  74. compare_ready()
  75. def selected_count(n): wait(V + '._compareIds.length===' + str(n))
  76. call('Page.enable'); call('Runtime.enable'); call('Network.enable')
  77. call('Emulation.setDeviceMetricsOverride', dict(width=1440, height=1100, deviceScaleFactor=1, mobile=False))
  78. go('/library')
  79. info = evaluate('fetch("/__dev/info").then(r=>r.json())')
  80. assert Path(info['root']).resolve() == ROOT
  81. assert action('return v.renderRoot.querySelector("select[aria-label=店铺]").disabled;')
  82. select('平台', 'jd')
  83. assert action('return !v.renderRoot.querySelector("select[aria-label=店铺]").disabled;')
  84. assert action('return v._data.facets.shopId.truncated && v.renderRoot.querySelector(".product-filters").textContent.includes("前100家");')
  85. select('店铺', '1000084104')
  86. scope = action('return {total:v._data.totalMatched,platform:v.routeQuery.platform,shop:v.routeQuery.shopId,brand:v.routeQuery.brand||null,rows:v._data.records.map(r=>({id:r.record_id,platform:r.platform,shop:r.shop_id}))};')
  87. assert scope['total'] > 0 and all(r['platform'] == 'jd' and r['shop'] == scope['shop'] for r in scope['rows']), scope
  88. assert scope['brand'] is None
  89. select('价格', 'price_asc')
  90. prices = action('return v._data.records.flatMap(r=>r.priceRange?[r.priceRange.minMinor]:[]);')
  91. assert prices == sorted(prices)
  92. click('.pagination button:last-child'); ready()
  93. assert action('return !!v.routeQuery.cursor && v.routeQuery.shopId==="1000084104" && v.routeQuery.sort==="price_asc";')
  94. select('平台', 'tmall')
  95. assert action('return !v.routeQuery.shopId && !v.routeQuery.cursor;')
  96. go('/library?type=products&platform=jd&shopId=1000084104&sort=reviews_desc')
  97. assert action('return v.renderRoot.querySelector("select[aria-label=店铺]").value;') == '1000084104'
  98. assert action('return !v.renderRoot.querySelector("a button,a input");')
  99. # Selection does not reload the list or fetch full details for unselected cards.
  100. evaluate('window.__compareCalls=[];window.__compareFetch=window.fetch;window.fetch=async function(url,options){if(String(url).includes("/api/library")&&options?.body)window.__compareCalls.push(JSON.parse(options.body));return window.__compareFetch.call(this,url,options);};')
  101. click('.compare-toggle'); selected_count(1)
  102. assert action('return v.renderRoot.querySelector("#open-compare").disabled;')
  103. action('v.renderRoot.querySelectorAll(".compare-toggle")[1].click();'); selected_count(2)
  104. assert evaluate('window.__compareCalls.length') == 0
  105. assert not action('return !!v.recordId;')
  106. open_compare()
  107. assert compare('return c._items.every(i=>i.detail && i.detail.generation===c._summary.generation);')
  108. assert compare('return c.renderRoot.activeElement?.classList.contains("close");')
  109. escape()
  110. wait(V + '.renderRoot.activeElement?.id==="open-compare"')
  111. action('v.renderRoot.querySelectorAll(".compare-toggle")[2].click();'); selected_count(3)
  112. click('.pagination button:last-child'); ready()
  113. click('.compare-toggle'); selected_count(4)
  114. action('v.renderRoot.querySelectorAll(".compare-toggle")[1].click();')
  115. assert action('return v._compareIds.length===4 && v._notice.includes("最多对比 4 件");')
  116. click('[aria-label=关闭提示]')
  117. ids = action('return v._compareIds;')
  118. checks = ['shop-platform-context', 'shop-truncation-disclosed', 'shop-filter-real-readback', 'no-inferred-brand', 'sort-and-page-preserve-shop', 'platform-change-clears-shop-and-cursor', 'shop-deep-link', 'valid-card-html', 'selection-no-detail-prefetch', 'two-product-comparison', 'esc-and-focus-return', 'four-cross-page-products', 'fifth-rejected']
  119. # Desktop and phone: compare table scrolls inside dialog, never at document level.
  120. layouts = []
  121. for width, height in [(1440, 1100), (1024, 1000), (390, 844), (320, 740)]:
  122. call('Emulation.setDeviceMetricsOverride', dict(width=width, height=height, deviceScaleFactor=1, mobile=width<768))
  123. evaluate('scrollTo(0,0);new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(r)))')
  124. assert evaluate('document.documentElement.scrollWidth<=innerWidth')
  125. shot('shop-list-' + str(width))
  126. open_compare()
  127. layout = compare('const d=c.renderRoot.querySelector("dialog"),s=c.renderRoot.querySelector(".scroll"),r=d.getBoundingClientRect();return {width:innerWidth,documentWidth:document.documentElement.scrollWidth,left:r.left,right:r.right,dialogWidth:d.clientWidth,dialogScroll:d.scrollWidth,tableWidth:s.scrollWidth,tableViewport:s.clientWidth,columns:c._items.length};')
  128. assert layout['columns'] == 4 and layout['documentWidth'] <= width and layout['dialogScroll'] <= layout['dialogWidth'], layout
  129. assert layout['left'] >= 0 and layout['right'] <= width, layout
  130. if width < 768: assert layout['tableWidth'] > layout['tableViewport'], layout
  131. shot('compare-' + str(width))
  132. for _ in range(8):
  133. call('Input.dispatchKeyEvent', dict(type='keyDown', key='Tab', code='Tab', windowsVirtualKeyCode=9))
  134. call('Input.dispatchKeyEvent', dict(type='keyUp', key='Tab', code='Tab', windowsVirtualKeyCode=9))
  135. assert compare('return c.renderRoot.querySelector("dialog").contains(c.renderRoot.activeElement);')
  136. layouts.append(layout)
  137. escape()
  138. checks.extend(['four-responsive-widths', 'local-table-horizontal-scroll', 'dialog-keyboard-focus'])
  139. call('Emulation.setDeviceMetricsOverride', dict(width=1440, height=1100, deviceScaleFactor=1, mobile=False))
  140. open_compare()
  141. deep_url = evaluate('location.href')
  142. call('Page.reload'); ready(); compare_ready()
  143. assert action('return v._compareIds;') == ids
  144. assert compare('return c._items.every(i=>i.detail);')
  145. # Drill down to an exact original review and keep the catalog context.
  146. compare('c.renderRoot.querySelector(".themes summary").click();')
  147. evidence = compare('return c.renderRoot.querySelector(".themes details[open] a").href;')
  148. compare('c.renderRoot.querySelector(".themes details[open] a").click();')
  149. ready()
  150. P = V + '.renderRoot.querySelector("library-product-detail")'
  151. wait(P + '?.panel==="reviews" && !' + P + '._loading && ' + P + '._data')
  152. assert evaluate('location.href') == evidence
  153. assert evaluate(P + '._data.records.length') == 1
  154. assert evaluate(P + '._data.records[0].record_id===' + P + '.query.relatedReviewId')
  155. click('.breadcrumb a'); ready()
  156. assert action('return v._compareIds;') == ids
  157. click('[aria-label="列表视图"]')
  158. assert action('return v.renderRoot.querySelectorAll(".list .product-choice").length;') > 0
  159. open_compare()
  160. compare('c.renderRoot.querySelector("thead .icon-button").click();'); selected_count(3); compare_ready()
  161. assert compare('return c._items.length;') == 3
  162. escape()
  163. click('.compare-actions .text-button'); selected_count(0)
  164. assert not action('return !!v.renderRoot.querySelector(".compare-bar");')
  165. checks.extend(['refresh-open-comparison', 'precise-original-review-link', 'return-catalog-retains-selection', 'list-view-selection', 'remove-column', 'clear-selection'])
  166. # Isolated fixtures alter browser responses only, leaving the real database untouched.
  167. go('/library?type=products&compare=' + quote(json.dumps(ids[:2])))
  168. evaluate('''window.__fixture=null;window.__originalFetch=window.fetch;window.fetch=async function(url,options){
  169. const q=String(url).includes('/api/library')&&options?.body?JSON.parse(options.body):null,f=window.__fixture;
  170. if(q?.op==='detail'&&q.entity==='products'&&f){
  171. if(f.mode==='loading')await new Promise(r=>setTimeout(r,700));
  172. if(q.id===f.id&&['partial','stale'].includes(f.mode))return new Response(JSON.stringify({ok:false,error:{code:f.mode==='stale'?'LIBRARY_VERSION_CHANGED':'LIBRARY_READ_FAILED'}}),{status:503,headers:{'content-type':'application/json'}});
  173. if(f.mode==='empty'){
  174. const r=await window.__originalFetch(url,options),d=await r.json();d.record.specs=[];d.record.overview.prices=[];d.record.overview.themes=[];
  175. d.record.overview.reviews={collected:q.id===f.id?4:0,eligible:0,distinctTexts:0};return new Response(JSON.stringify(d),{status:200,headers:{'content-type':'application/json'}});
  176. }
  177. }return window.__originalFetch(url,options);
  178. };''')
  179. evaluate('window.__fixture=' + json.dumps(dict(mode='partial', id=ids[1])))
  180. open_compare()
  181. assert compare('return !!c._items[0].detail && !!c._items[1].error && !c._items[1].detail;')
  182. shot('fixture-partial-failure')
  183. evaluate('window.__fixture=null')
  184. compare('c._load();'); compare_ready()
  185. assert compare('return c._items.every(i=>i.detail);')
  186. evaluate('window.__fixture=' + json.dumps(dict(mode='stale', id=ids[1])))
  187. compare('c._load();'); compare_ready()
  188. assert compare('return c._stale && !c._items.some(i=>i.detail) && !!c.renderRoot.querySelector("[role=alert]");')
  189. evaluate('window.__fixture=null')
  190. compare('c.renderRoot.querySelector(".state button").click();'); compare_ready()
  191. assert compare('return !c._stale && c._items.every(i=>i.detail);')
  192. evaluate('window.__fixture=' + json.dumps(dict(mode='empty', id=ids[1])))
  193. compare('c._load();'); compare_ready()
  194. assert compare('return c.renderRoot.textContent.includes("暂未收录评价") && c.renderRoot.textContent.includes("尚无可分析评价") && c.renderRoot.textContent.includes("暂未收录已知金额的价格") && c.renderRoot.textContent.includes("暂未收录规格原文");')
  195. shot('fixture-empty-fields')
  196. evaluate('window.__fixture={mode:"loading"}')
  197. compare('c._load();')
  198. assert compare('return c._loading && !!c.renderRoot.querySelector("[aria-busy=true]");')
  199. compare_ready()
  200. evaluate('window.__fixture=null')
  201. escape()
  202. fixtures = ['partial-failure-preserves-success', 'retry-recovers-real-data', 'generation-conflict-invalidates-all', 'fresh-summary-recovers-all', 'zero-reviews-vs-no-eligible-reviews', 'missing-price-and-spec', 'loading-state']
  203. # Real cross-platform identities keep their own details, irrespective of catalog filters.
  204. cross_ids = ['jd:2848752', 'taobao:1040511334739']
  205. go('/library?type=products&platform=jd&compare=' + quote(json.dumps(cross_ids)))
  206. open_compare()
  207. assert compare('return c._items.map(i=>i.detail.record.id);') == cross_ids
  208. assert compare('return c._items[1].detail.record.marketplace;') == 'tmall'
  209. escape()
  210. select('平台', 'tmall')
  211. assert action('return v._compareIds;') == cross_ids
  212. open_compare()
  213. assert compare('return c._items.map(i=>i.detail.record.id);') == cross_ids
  214. escape()
  215. checks.append('real-cross-platform-products-retain-composite-identities')
  216. # Regressions: other library areas, ordinary product links, orphan shop URL.
  217. for entity in ['social', 'documents', 'reports']:
  218. go('/library?type=' + entity)
  219. assert action('return v._type;') == entity
  220. go('/library?shopId=unscoped&cursor=old')
  221. assert action('return !v.routeQuery.shopId && !v.routeQuery.cursor;')
  222. click('a[data-record-id]'); ready()
  223. assert action('return v.recordEntity==="products" && !!v._detail.record;')
  224. checks.extend(['other-library-areas', 'orphan-shop-link-normalized', 'normal-product-link'])
  225. exceptions = [e for e in events if e.get('method') == 'Runtime.exceptionThrown']
  226. assert not exceptions, exceptions
  227. result = dict(ok=True,base=args.url,checks=checks,fixtures=fixtures,scope=scope,selectedIds=ids,layouts=layouts,comparisonDeepLink=deep_url,consoleExceptions=0,productionWrites=0)
  228. (WORK / 'browser-acceptance.json').write_text(json.dumps(result, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
  229. print(json.dumps(result, ensure_ascii=False, indent=2))
  230. finally:
  231. if ws: ws.close()
  232. process.terminate()
  233. try: process.wait(timeout=8)
  234. except subprocess.TimeoutExpired: process.kill()