verify-library-browser.py 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390
  1. """Read-only browser acceptance against the real local industry library."""
  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. parser=argparse.ArgumentParser();parser.add_argument('--url',default='http://127.0.0.1:4181/library');args=parser.parse_args()
  15. profile=Path(tempfile.mkdtemp(prefix='browser-',dir=WORK))
  16. process=subprocess.Popen(['C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe','--headless=new','--disable-gpu',
  17. '--remote-debugging-port=0','--disable-background-networking','--disable-component-update','--disable-sync','--disable-extensions',
  18. '--no-first-run','--no-default-browser-check','--user-data-dir='+str(profile),'about:blank'],
  19. stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,creationflags=subprocess.CREATE_NO_WINDOW)
  20. serial=0;ws=None;events=[]
  21. try:
  22. deadline=time.monotonic()+20
  23. while not (profile/'DevToolsActivePort').exists():
  24. assert time.monotonic()<deadline and process.poll() is None
  25. time.sleep(.1)
  26. port=(profile/'DevToolsActivePort').read_text().splitlines()[0]
  27. page=next(x for x in json.load(urlopen('http://127.0.0.1:'+port+'/json/list',timeout=5)) if x['type']=='page')
  28. ws=websocket.create_connection(page['webSocketDebuggerUrl'],timeout=35,suppress_origin=True,http_no_proxy=['127.0.0.1','localhost'])
  29. def call(method,params=None):
  30. global serial
  31. serial+=1;rid=serial;ws.send(json.dumps(dict(id=rid,method=method,params=params or {})))
  32. while True:
  33. response=json.loads(ws.recv())
  34. if response.get('id')==rid:
  35. assert 'error' not in response,response
  36. return response.get('result',{})
  37. events.append(response)
  38. def evaluate(expression,promise=False):
  39. response=call('Runtime.evaluate',dict(expression=expression,returnByValue=True,awaitPromise=promise))
  40. assert 'exceptionDetails' not in response,response
  41. return response['result'].get('value')
  42. def wait(expression,timeout=25):
  43. evaluate('new Promise((resolve,reject)=>{const end=Date.now()+'+str(timeout*1000)+';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()})',True)
  44. def action(body):
  45. return evaluate('(()=>{const v=document.querySelector("kf-app").shadowRoot.querySelector("view-library");'+body+'})()')
  46. V='document.querySelector("kf-app")?.shadowRoot?.querySelector("view-library")'
  47. P=V+'.renderRoot.querySelector("library-product-detail")'
  48. def product_action(body):
  49. return evaluate('(()=>{const p='+P+';'+body+'})()')
  50. def product_ready():wait(P+' && !'+P+'._loading && ('+P+'.panel==="overview" || '+P+'._data)')
  51. def product_tab(panel):
  52. product_action('p.renderRoot.querySelector("#tab-'+panel+'").click();')
  53. wait(P+'.panel==='+json.dumps(panel)+' && !'+P+'._loading && ('+P+'.panel==="overview" || '+P+'._data)')
  54. def ready():wait(V+'?._summary && !'+V+'?._loading && ('+V+'?._data || '+V+'?._detail || '+V+'?._type==="reports")')
  55. def shot(name):
  56. image=call('Page.captureScreenshot',dict(format='png',captureBeyondViewport=False))
  57. (WORK/(name+'.png')).write_bytes(base64.b64decode(image['data']))
  58. def reader_surface():
  59. text=action('return v.renderRoot.textContent+"\\n"+Array.from(v.renderRoot.querySelectorAll("[title]")).map(el=>el.title).join("\\n");')
  60. forbidden=['本地资料库','资料库已连接','资料更新','更新记录','已入库','业务库','元数据','分类依据','品牌依据','品类依据','分类状态','待核对规则','SHA256','校验指纹','平台接口族','来源编号','来源文件','JSONL','数据版本','rule_title_','source_attribute']
  61. assert not any(token in text for token in forbidden),text[:1500]
  62. assert not action('return !!v.renderRoot.querySelector(".version,.updates,.fingerprints,.record-number");')
  63. assert not action('return !!v.renderRoot.querySelector("input:not([type=date]),form.search");')
  64. # Facts belong to the information panel; source actions must not repeat them.
  65. assert not action('return !!v.renderRoot.querySelector(".source .metadata,.source .source-title");')
  66. labels=action('return Array.from(v.renderRoot.querySelectorAll(".detail-grid .metadata dt")).map(el=>el.textContent);')
  67. assert len(labels)==len(set(labels)),labels
  68. call('Page.enable');call('Runtime.enable');call('Network.enable')
  69. call('Emulation.setDeviceMetricsOverride',dict(width=1440,height=1100,deviceScaleFactor=1,mobile=False))
  70. call('Page.navigate',dict(url=args.url));ready()
  71. assert action('return v._summary.counts.products;')==4639
  72. assert action('return v._data.totalMatched;')==4639
  73. assert action('return Array.from(v.renderRoot.querySelectorAll(".tree-link")).map(a=>new URL(a.href).searchParams.get("type"));')==['products','social','documents','reports']
  74. assert action('return Array.from(v.renderRoot.querySelectorAll(".stat")).map(a=>new URL(a.href).searchParams.get("type"));')==['products','social','documents','reports']
  75. reader_surface()
  76. assert not action('return !!v.renderRoot.querySelector(".advanced-toggle,#advanced-filters");')
  77. assert action('return !!v.renderRoot.querySelector("select[aria-label=评论数量]") && !!v.renderRoot.querySelector("select[aria-label=价格]");')
  78. assert action('return v.renderRoot.getElementById("coverage-insights").hidden;')
  79. assert action('return v.renderRoot.querySelector("a[data-record-id]").getBoundingClientRect().top;')<350
  80. assert action('return getComputedStyle(v.renderRoot.querySelector(".stats")).display;')=='none'
  81. assert action('return getComputedStyle(v.renderRoot.querySelector(".record-list")).gridTemplateColumns.split(" ").length;')>=3
  82. # Display preferences reveal existing data without changing the query or fetching again.
  83. resources=evaluate('performance.getEntriesByType("resource").length')
  84. action('v.renderRoot.querySelector("button[aria-label=列表视图]").click();')
  85. wait(V+'.renderRoot.querySelector(".record-list.list")')
  86. assert action('return v._data.records.length;')==20
  87. shot('library-list-view')
  88. action('v.renderRoot.querySelector("button[aria-label=卡片视图]").click();v.renderRoot.querySelector(".insights-toggle").click();')
  89. wait('!'+V+'.renderRoot.getElementById("coverage-insights").hidden')
  90. assert action('return v.renderRoot.querySelectorAll(".bar").length;')>0
  91. assert evaluate('performance.getEntriesByType("resource").length')==resources
  92. action('v.renderRoot.querySelector(".insights-toggle").click();')
  93. def select(label,value):
  94. selector=json.dumps('select[aria-label="'+label+'"]',ensure_ascii=False)
  95. action('const s=v.renderRoot.querySelector('+selector+');s.value='+json.dumps(value)+';s.dispatchEvent(new Event("change",{bubbles:true}));')
  96. key={'平台':'platform','品牌':'brand','品类':'category','评论数量':'sort','价格':'sort'}.get(label,label)
  97. wait('('+V+'.routeQuery['+json.dumps(key)+'] || "")==='+json.dumps(value)+' && !'+V+'._loading && '+V+'._data')
  98. ready()
  99. select('评论数量','reviews_desc')
  100. assert action('return v._data.totalMatched;')==4639
  101. assert action('return v._data.records[0].relatedCounts.reviews;')==1000
  102. assert action('return !v._data.effectiveQuery.filters.reviewCount && !v._data.effectiveQuery.filters.observedPriceMinor;')
  103. first_counts=action('return v._data.records.map(r=>r.relatedCounts.reviews || 0);')
  104. first_ids=action('return v._data.records.map(r=>r.record_id);')
  105. action('Array.from(v.renderRoot.querySelectorAll(".pagination button")).find(b=>b.textContent.includes("下一页")).click();')
  106. wait(V+'.routeQuery.cursor && !'+V+'._loading')
  107. second_counts=action('return v._data.records.map(r=>r.relatedCounts.reviews || 0);')
  108. assert first_counts+second_counts==sorted(first_counts+second_counts,reverse=True)
  109. assert not set(first_ids)&set(action('return v._data.records.map(r=>r.record_id);'))
  110. select('价格','price_asc')
  111. assert not action('return !!v.routeQuery.cursor;')
  112. assert action('return v._data.totalMatched;')==4639
  113. assert action('return v.renderRoot.querySelector("select[aria-label=评论数量]").value;')==''
  114. amounts=action('return v._data.records.map(r=>r.priceRange.minMinor);')
  115. assert amounts==sorted(amounts)
  116. select('价格','price_desc')
  117. amounts=action('return v._data.records.map(r=>r.priceRange.minMinor);')
  118. assert amounts==sorted(amounts,reverse=True)
  119. assert action('return Array.from(v.renderRoot.querySelectorAll(".product-price")).every(el=>el.textContent.includes("¥") && el.textContent.includes("历史观察价"));')
  120. sorted_url=evaluate('location.href');call('Page.reload');ready()
  121. assert evaluate('location.href')==sorted_url
  122. assert action('return v.routeQuery.sort;')=='price_desc'
  123. action('v.renderRoot.querySelector("a[data-record-id]").click();');ready();product_ready()
  124. product_tab('reviews')
  125. assert product_action('return Object.keys(p._data.effectiveQuery.filters).join(",");')=='productId'
  126. assert product_action('return p._data.effectiveQuery.sort;') is None
  127. action('v.renderRoot.querySelector(".breadcrumb a").click();');ready()
  128. assert action('return v.routeQuery.sort;')=='price_desc'
  129. select('评论数量','reviews_asc')
  130. assert action('return v._data.totalMatched;')==4639
  131. assert action('return v.renderRoot.querySelector("select[aria-label=价格]").value;')==''
  132. assert action('return v._data.records.every(r=>!r.relatedCounts.reviews);')
  133. action('v.renderRoot.querySelector("a[data-record-id]").click();');ready();product_ready();product_tab('reviews')
  134. assert product_action('return p.renderRoot.querySelector(".empty").textContent.includes("不代表平台上没有评论");')
  135. call('Page.navigate',dict(url=args.url+'?type=products&brand=ksf&reviewCount=has&price=10-20&cursor=retired-range-cursor'));ready()
  136. assert action('return v.routeQuery.brand==="ksf" && !v.routeQuery.reviewCount && !v.routeQuery.price && !v.routeQuery.cursor && !v._error;')
  137. call('Page.navigate',dict(url=args.url));ready()
  138. select('平台','jd')
  139. wait(V+'.routeQuery.platform==="jd" && !'+V+'._loading')
  140. select('品牌','ksf')
  141. wait(V+'.routeQuery.brand==="ksf" && !'+V+'._loading')
  142. select('品类','banmian')
  143. wait(V+'.routeQuery.category==="banmian" && !'+V+'._loading')
  144. scope=action('return {total:v._data.totalMatched,rows:v._data.records.map(r=>[r.platform,r.brand,r.category])};')
  145. assert scope['total']>0 and all(r==['jd','ksf','banmian'] for r in scope['rows']),scope
  146. assert action('return v.renderRoot.querySelectorAll(".filter-chip").length;')==3
  147. action('v.renderRoot.querySelector("button[aria-label^=移除品牌]").click();')
  148. wait('!'+V+'.routeQuery.brand && !'+V+'._loading')
  149. assert action('return [v.routeQuery.platform,v.routeQuery.category];')==['jd','banmian']
  150. select('品牌','ksf')
  151. wait(V+'.routeQuery.brand==="ksf" && !'+V+'._loading')
  152. shot('library-filtered-desktop')
  153. action('v.renderRoot.querySelector("a[data-record-id]").click();')
  154. wait(V+'._detail && !'+V+'._loading')
  155. assert action('return v._detail.record.source.archiveSha256.length;')==64
  156. assert action('return v._detail.relations.length;')==5
  157. product_ready()
  158. assert product_action('return p.product.id;')==action('return v._detail.record.id;')
  159. assert product_action('return p.renderRoot.querySelectorAll(".product-resources button").length;')==4
  160. # The overview now includes review themes; source actions remain in the compact header.
  161. assert action('return v.renderRoot.querySelector(".detail-actions").getBoundingClientRect().bottom;')<300
  162. product_tab('prices')
  163. assert product_action('return p._data.records.every(row=>row.product_id===p.product.id);')
  164. assert action('return v.routeQuery.brand;')=='ksf'
  165. product_tab('overview')
  166. reader_surface()
  167. action('navigator.clipboard.writeText=async text=>{window.__libraryCitation=text;};v.renderRoot.querySelector(".detail-actions .primary").click();')
  168. wait('!!window.__libraryCitation')
  169. citation=evaluate('window.__libraryCitation')
  170. assert '资料链接:' in citation and '原始页面:https://' in citation,citation
  171. assert not any(x in citation for x in ['SHA256','来源编号','来源文件','JSONL','rule_title_']),citation
  172. shot('library-product-detail')
  173. detail_url=evaluate('location.href')
  174. call('Page.reload');ready();assert evaluate('location.href')==detail_url
  175. assert action('return v._detail.record.id.startsWith("jd:");')
  176. action('v.renderRoot.querySelector(".breadcrumb a").click();')
  177. wait(V+'._data && !'+V+'._loading && !'+V+'.recordId')
  178. assert action('return v.routeQuery.brand;')=='ksf'
  179. # Category navigation and cards operate on URL state, not a local mock dataset.
  180. def go_type(entity):
  181. if entity in ['products','social','documents','reports']:
  182. action('Array.from(v.renderRoot.querySelectorAll(".tree-link")).find(a=>new URL(a.href).searchParams.get("type")==='+json.dumps(entity)+').click();')
  183. else:
  184. # Old bookmarked entity URLs remain readable, but have no standalone navigation.
  185. call('Page.navigate',dict(url=args.url+'?type='+entity))
  186. wait(V+'._type==='+json.dumps(entity)+' && !'+V+'._loading && ('+V+'._data || '+V+'._type==="reports")')
  187. # A product with many reviews validates isolated search, complete text, pagination and deep URLs.
  188. product_id='taobao:1040511334739'
  189. call('Page.navigate',dict(url=args.url+'/products/'+product_id+'?type=products&brand=ksf&panel=reviews&relatedQuality=audit'))
  190. ready();product_ready()
  191. assert product_action('return p._data.totalMatched;')==1000
  192. assert product_action('return p._data.records.every(row=>row.product_id===p.product.id);')
  193. assert product_action('return p.renderRoot.querySelectorAll(".review").length;')==20
  194. density=product_action('const rows=Array.from(p.renderRoot.querySelectorAll(".review"));return {firstTop:rows[0].getBoundingClientRect().top,firstHeight:rows[0].getBoundingClientRect().height,visible:rows.filter(r=>r.getBoundingClientRect().bottom<innerHeight).length};')
  195. assert density['firstTop']<320 and density['firstHeight']<110 and density['visible']>=7,density
  196. assert product_action('return p.renderRoot.querySelector(".review .full-review").textContent===p._data.records[0].content;')
  197. assert product_action('return !/SHA256|分类依据|rule_title_|source_attribute/.test(p.renderRoot.textContent);')
  198. shot('library-product-reviews')
  199. review_first=product_action('return p._data.records[0].record_id;')
  200. product_action('Array.from(p.renderRoot.querySelectorAll(".pagination button")).find(b=>b.textContent.includes("下一页")).click();')
  201. wait(P+'.query.relatedCursor && !'+P+'._loading && '+P+'._data')
  202. assert product_action('return p._data.records[0].record_id;')!=review_first
  203. assert product_action('return p._data.records.every(row=>row.product_id===p.product.id);')
  204. review_page_url=evaluate('location.href')
  205. call('Page.reload');ready();product_ready()
  206. assert evaluate('location.href')==review_page_url
  207. assert product_action('return p.query.relatedQuality;')=='audit'
  208. product_action('Array.from(p.renderRoot.querySelectorAll(".pagination button")).find(b=>b.textContent.includes("第一页")).click();')
  209. wait('!'+P+'.query.relatedCursor && !'+P+'._loading && '+P+'._data')
  210. assert product_action('return p._data.records[0].record_id;')==review_first
  211. assert product_action('return !p.renderRoot.querySelector("input:not([type=date]),form");')
  212. assert product_action('return !p.renderRoot.querySelector("input[type=date],.date-filters");')
  213. call('Page.navigate',dict(url=args.url+'/products/'+product_id+'?type=products&brand=ksf&sort=reviews_desc&panel=reviews&relatedQuality=audit&relatedFrom=2099-01-01&relatedTo=2099-12-31&relatedCursor=old-date-cursor'))
  214. ready();product_ready()
  215. assert product_action('return !p.query.relatedFrom && !p.query.relatedTo && !p.query.relatedCursor && p._data.totalMatched===1000;')
  216. assert action('return v.routeQuery.brand=== "ksf" && v.routeQuery.sort=== "reviews_desc";')
  217. assert product_action('return !!p.renderRoot.querySelector(".review-meta") && !p.renderRoot.querySelector("input[type=date]");')
  218. shot('library-product-reviews')
  219. product_tab('prices')
  220. assert product_action('return p._data.records.every(row=>row.product_id===p.product.id);')
  221. assert product_action('return !!p.renderRoot.querySelector(".price-heading strong");')
  222. assert product_action('return !p.renderRoot.querySelector("input[type=date],.date-filters");')
  223. price_total=product_action('return p._data.totalMatched;')
  224. call('Page.navigate',dict(url=args.url+'/products/'+product_id+'?type=products&brand=ksf&sort=price_asc&panel=prices&relatedQuality=audit&relatedFrom=2099-01-01&relatedTo=2099-12-31&relatedCursor=old-price-date-cursor'))
  225. ready();product_ready()
  226. assert product_action('return !p.query.relatedFrom && !p.query.relatedTo && !p.query.relatedCursor;')
  227. assert product_action('return p._data.totalMatched;')==price_total
  228. assert product_action('return p._data.records.every(row=>row.product_id===p.product.id) && !p._data.effectiveQuery.filters.sourceDate;')
  229. assert product_action('return p.renderRoot.querySelector(".price").textContent.includes("观察时间") && !p.renderRoot.querySelector("input[type=date],.date-filters");')
  230. assert action('return v.routeQuery.brand=== "ksf" && v.routeQuery.sort=== "price_asc";')
  231. shot('library-product-prices')
  232. product_tab('specs')
  233. assert product_action('return p._data.records.every(row=>row.product_id===p.product.id);')
  234. product_tab('rankings')
  235. assert product_action('return p._data.records.every(row=>row.product_id===p.product.id);')
  236. # Failed tab reads stay in the product and must never be called empty data.
  237. call('Network.setBlockedURLs',dict(urls=['*/api/library']))
  238. product_action('p.renderRoot.querySelector("#tab-reviews").click();')
  239. wait(P+'._error && !'+P+'._loading')
  240. assert product_action('return !!p.renderRoot.querySelector("[role=alert]");')
  241. call('Network.setBlockedURLs',dict(urls=[]))
  242. product_action('p.renderRoot.querySelector(".error button").click();');product_ready()
  243. assert product_action('return p._data.records.every(row=>row.product_id===p.product.id);')
  244. action('v.renderRoot.querySelector(".breadcrumb a").click();')
  245. wait(V+'._data && !'+V+'._loading && !'+V+'.recordId')
  246. assert action('return v.routeQuery.brand;')=='ksf'
  247. assert not evaluate('new URL(location.href).searchParams.has("relatedQ") || new URL(location.href).searchParams.has("panel")')
  248. # Legacy per-product links now land inside the same product instead of a detached list.
  249. call('Page.navigate',dict(url=args.url+'?type=prices&productId='+product_id))
  250. wait(V+'.recordEntity==="products" && '+V+'._detail && !'+V+'._loading');product_ready()
  251. assert product_action('return p.panel;')=='prices'
  252. assert product_action('return p.product.id;')==product_id
  253. # Old search bookmarks shed keyword-bound cursors and preserve explicit filters.
  254. call('Page.navigate',dict(url=args.url+'?type=products&brand=ksf&q=旧搜索&cursor=old-search-cursor'))
  255. ready()
  256. assert action('return v.routeQuery.brand==="ksf" && !v.routeQuery.q && !v.routeQuery.cursor && !v._error;')
  257. call('Page.navigate',dict(url=args.url+'/products/'+product_id+'?type=products&panel=reviews&relatedQuality=audit&relatedQ=旧搜索&relatedCursor=old-review-cursor'))
  258. ready();product_ready()
  259. assert product_action('return p.product.id===p._data.records[0].product_id && !p.query.relatedQ && !p.query.relatedCursor;')
  260. call('Page.navigate',dict(url=args.url));ready()
  261. go_type('reviews')
  262. assert action('return v._data.totalMatched;')==771
  263. action('const s=v.renderRoot.querySelector("select[aria-label=查看口径]");s.value="audit";s.dispatchEvent(new Event("change",{bubbles:true}));')
  264. wait(V+'._data?.totalMatched===2360 && !'+V+'._loading')
  265. go_type('documents')
  266. assert action('return v._data.totalMatched;')==25
  267. shot('library-policy-browse')
  268. action('const s=v.renderRoot.querySelector("select[aria-label=政策状态]");s.value=s.options[1].value;s.dispatchEvent(new Event("change",{bubbles:true}));')
  269. wait(V+'.routeQuery.policyStatus && !'+V+'._loading')
  270. assert action('return v._data.totalMatched;')>0
  271. action('v.renderRoot.querySelector("a[data-record-id]").click();')
  272. wait(V+'._detail && !'+V+'._loading')
  273. assert action('return v._detail.record.full_text.length;')>100
  274. assert action('return v.renderRoot.querySelector(".detail-main").firstElementChild.id;')=='full_text'
  275. reader_surface()
  276. assert action('return !!v.renderRoot.querySelector(".source a[target=_blank]");')
  277. shot('library-policy-detail')
  278. call('Page.navigate',dict(url=args.url+'?type=clauses'));ready()
  279. assert action('return v._data.totalMatched;')==31
  280. action('v.renderRoot.querySelector("a[data-record-id]").click();')
  281. wait(V+'._detail && !'+V+'._loading')
  282. assert action('return v.renderRoot.textContent.includes("应用提示(非官方原话)");')
  283. assert action('return v._detail.record.parentDocument.title;')
  284. assert action('return v.renderRoot.querySelector(".source a[target=_blank]").href;').startswith('http')
  285. reader_surface()
  286. call('Page.navigate',dict(url=args.url+'?type=products'));ready()
  287. first=action('return v._data.records[0].record_id;')
  288. action('Array.from(v.renderRoot.querySelectorAll("button")).find(b=>b.textContent.includes("下一页")).click();')
  289. wait(V+'.routeQuery.cursor && !'+V+'._loading')
  290. assert action('return v._data.records[0].record_id;')!=first
  291. second=action('return v._data.records[0].record_id;')
  292. action('Array.from(v.renderRoot.querySelectorAll("button")).find(b=>b.textContent.includes("上一页")).click();')
  293. wait('!'+V+'.routeQuery.cursor && !'+V+'._loading')
  294. assert action('return v._data.records[0].record_id;')==first
  295. action('Array.from(v.renderRoot.querySelectorAll("button")).find(b=>b.textContent.includes("下一页")).click();')
  296. wait(V+'.routeQuery.cursor && !'+V+'._loading')
  297. assert action('return v._data.records[0].record_id;')==second
  298. call('Page.reload');ready();assert action('return !!v.routeQuery.cursor;')
  299. # Read all remaining categories, retaining a real zero-result state for absent evidence.
  300. for entity in ['social','prices','specs','rankings','coverage','reports']:
  301. go_type(entity)
  302. if entity!='reports':assert action('return v._data.totalMatched;')>0
  303. if entity=='prices':
  304. price=action('return {raw:v._data.records[0].price_minor,shown:v.renderRoot.querySelector(".price-fact strong").textContent};')
  305. assert price['shown']=='¥'+format(price['raw']/100,'.2f'),price
  306. assert action('return !v.renderRoot.querySelector(".record").textContent.includes("search_listing_snapshot");')
  307. shot('library-price-browse')
  308. if entity=='specs':
  309. assert action('return v._data.records[0].net_weight_g===null && !v.renderRoot.querySelector(".spec-facts").textContent.includes("净含量");')
  310. if entity=='coverage':
  311. assert action('return !v.renderRoot.querySelector(".record").textContent.includes("not_attempted_service_blocked");')
  312. reader_surface()
  313. if entity=='reports':
  314. action('const s=v.renderRoot.querySelector("select[aria-label=成果阶段]");s.value=s.options[1].value;s.dispatchEvent(new Event("change",{bubbles:true}));')
  315. wait(V+'.routeQuery.reportStage && !'+V+'._loading')
  316. assert action('return v.renderRoot.querySelectorAll(".report-record").length===v.db.artifacts.filter(r=>r.stageObj?.code===v.routeQuery.reportStage).length;')
  317. call('Page.reload');ready()
  318. assert action('return !!v.routeQuery.reportStage && !v.renderRoot.querySelector("input");')
  319. if entity!='reports':
  320. action('v.renderRoot.querySelector("a[data-record-id]").click();')
  321. wait(V+'._detail && !'+V+'._loading')
  322. reader_surface()
  323. action('v.renderRoot.querySelector(".breadcrumb a").click();')
  324. wait(V+'._data && !'+V+'._loading && !'+V+'.recordId')
  325. go_type('products')
  326. call('Page.navigate',dict(url=args.url+'?type=products&from=2099-01-01'));ready()
  327. assert action('return v._data.totalMatched;')==0
  328. action('v.renderRoot.querySelector(".empty button").click();')
  329. wait('!'+V+'.routeQuery.from && !'+V+'._loading && '+V+'._data?.totalMatched===4639')
  330. # A failed connection must stay distinct from a genuine zero-result query.
  331. call('Network.setBlockedURLs',dict(urls=['*/api/library']))
  332. action('v.renderRoot.querySelector(".refresh").click();')
  333. wait(V+'._error && !'+V+'._loading')
  334. assert action('return !!v.renderRoot.querySelector("[role=alert]") && !v.renderRoot.querySelector(".empty");')
  335. call('Network.setBlockedURLs',dict(urls=[]))
  336. action('v.renderRoot.querySelector(".error button").click();')
  337. ready()
  338. call('Page.navigate',dict(url=args.url));ready()
  339. layouts=[]
  340. for width,height in [(1440,1100),(1024,1000),(390,844),(320,740)]:
  341. call('Emulation.setDeviceMetricsOverride',dict(width=width,height=height,deviceScaleFactor=1,mobile=width<700))
  342. time.sleep(.15)
  343. metrics=action('return {width:innerWidth,documentWidth:document.documentElement.scrollWidth,viewWidth:v.clientWidth,cards:v.renderRoot.querySelectorAll(".stat").length,records:v.renderRoot.querySelectorAll("a[data-record-id]").length,columns:getComputedStyle(v.renderRoot.querySelector(".record-list")).gridTemplateColumns.split(" ").length,resultTop:Math.round(v.renderRoot.querySelector("a[data-record-id]").getBoundingClientRect().top),visibleRecords:Array.from(v.renderRoot.querySelectorAll("a[data-record-id]")).filter(r=>r.getBoundingClientRect().bottom<innerHeight-70).length};')
  344. assert metrics['documentWidth']<=width,metrics
  345. assert metrics['cards']==4 and metrics['records']==20,metrics
  346. assert metrics['resultTop']<(350 if width>=1000 else 450),metrics
  347. if width==1440:assert metrics['columns']>=3 and metrics['visibleRecords']>=9,metrics
  348. if width==1024:assert metrics['columns']>=2,metrics
  349. shot('library-'+str(width));layouts.append(metrics)
  350. assert action('return getComputedStyle(v.renderRoot.querySelector(".stats")).display;')=='none'
  351. action('v.renderRoot.querySelector(".overview-toggle").click();')
  352. wait(V+'.renderRoot.querySelector(".overview-toggle").getAttribute("aria-expanded")==="true"')
  353. assert action('return getComputedStyle(v.renderRoot.querySelector(".stats")).display;')=='grid'
  354. action('v.renderRoot.querySelector(".overview-toggle").click();')
  355. # Product tabs scroll within their own row; long reviews fit the mobile viewport.
  356. call('Page.navigate',dict(url=args.url+'/products/'+product_id+'?type=products&panel=reviews&relatedQuality=audit'))
  357. ready();product_ready()
  358. assert evaluate('document.documentElement.scrollWidth<=innerWidth')
  359. assert product_action('return p.renderRoot.querySelector(".product-tabs").scrollWidth>p.renderRoot.querySelector(".product-tabs").clientWidth;')
  360. product_action('p.renderRoot.querySelector(".section-tools").scrollIntoView({block:"start"});')
  361. shot('library-product-reviews-mobile')
  362. product_tab('specs')
  363. assert evaluate('document.documentElement.scrollWidth<=innerWidth')
  364. call('Page.navigate',dict(url=args.url));ready()
  365. # New tab remains reachable on mobile, and active state covers detail routes.
  366. assert evaluate('document.querySelector("kf-app").shadowRoot.querySelector("kf-nav").shadowRoot.querySelector(".tabbar a[data-path=\\"/library\\"]").getAttribute("aria-current")')=='page'
  367. action('const s=v.renderRoot.querySelector("select[aria-label=资料分类]");s.value="documents";s.dispatchEvent(new Event("change",{bubbles:true}));')
  368. wait(V+'._type==="documents" && !'+V+'._loading && '+V+'._data?.totalMatched===25')
  369. action('v.renderRoot.querySelector(".filter").scrollIntoView({block:"start"});')
  370. shot('library-mobile-browse')
  371. action('v.renderRoot.querySelector("a[data-record-id]").click();')
  372. wait(V+'._detail && !'+V+'._loading')
  373. assert evaluate('document.documentElement.scrollWidth<=innerWidth')
  374. assert action('return getComputedStyle(v.renderRoot.getElementById("record-facts")).display;')=='none'
  375. action('v.renderRoot.querySelector(".facts-toggle").click();')
  376. wait(V+'.renderRoot.querySelector(".facts-toggle").getAttribute("aria-expanded")==="true"')
  377. assert action('return getComputedStyle(v.renderRoot.getElementById("record-facts")).display;')=='block'
  378. action('v.renderRoot.querySelector(".facts-toggle").click();')
  379. shot('library-mobile-detail')
  380. failures=[x for x in events if x.get('method')=='Runtime.exceptionThrown']
  381. assert not failures,failures
  382. result=dict(ok=True,scope=scope,layouts=layouts,productReviewDensity=density,tests=['product-catalog-navigation','real-data-counts','exact-filters','active-filter-removal','review-count-sorting','historical-price-sorting','sort-keeps-all-results','uncaptured-reviews','sorted-pagination-refresh','retired-range-link-normalization','display-mode-no-refetch','coverage-toggle','product-inline-panels','compact-product-overview','compact-review-density','review-original-text-preserved','review-date-filter-removed','legacy-review-date-link-normalization','no-search-controls','legacy-search-link-normalization','report-stage-filter','compact-layout','product-review-pagination-refresh','product-isolation','product-panel-error-recovery','legacy-product-link','deep-refresh','back-preserves-filters','review-quality','policy-prose-first','reader-source-without-internal-fields','reader-citation','clause-original-source','previous-next-pagination','zero-result-reset','connection-error-recovery','typed-price-spec-coverage-cards','legacy-category-details','mobile-overview','mobile-product-tabs','mobile-navigation','mobile-facts'],productionWrites=0,consoleExceptions=0)
  383. (WORK/'browser-validation.json').write_text(json.dumps(result,ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
  384. print(json.dumps(result,ensure_ascii=False,indent=2))
  385. finally:
  386. if ws:ws.close()
  387. process.terminate()
  388. try:process.wait(timeout=8)
  389. except subprocess.TimeoutExpired:process.kill()