verify-library-browser.py 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344
  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 action('return v.renderRoot.getElementById("advanced-filters").hidden;')
  77. assert action('return v.renderRoot.getElementById("coverage-insights").hidden;')
  78. assert action('return v.renderRoot.querySelector("a[data-record-id]").getBoundingClientRect().top;')<350
  79. assert action('return getComputedStyle(v.renderRoot.querySelector(".stats")).display;')=='none'
  80. assert action('return getComputedStyle(v.renderRoot.querySelector(".record-list")).gridTemplateColumns.split(" ").length;')>=3
  81. # Display preferences reveal existing data without changing the query or fetching again.
  82. resources=evaluate('performance.getEntriesByType("resource").length')
  83. action('v.renderRoot.querySelector("button[aria-label=列表视图]").click();')
  84. wait(V+'.renderRoot.querySelector(".record-list.list")')
  85. assert action('return v._data.records.length;')==20
  86. shot('library-list-view')
  87. action('v.renderRoot.querySelector("button[aria-label=卡片视图]").click();v.renderRoot.querySelector(".insights-toggle").click();')
  88. wait('!'+V+'.renderRoot.getElementById("coverage-insights").hidden')
  89. assert action('return v.renderRoot.querySelectorAll(".bar").length;')>0
  90. assert evaluate('performance.getEntriesByType("resource").length')==resources
  91. action('v.renderRoot.querySelector(".insights-toggle").click();v.renderRoot.querySelector(".advanced-toggle").click();')
  92. wait('!'+V+'.renderRoot.getElementById("advanced-filters").hidden')
  93. action('const i=v.renderRoot.querySelector("input[aria-label=日期起]");i.value="2026-11-01";i.dispatchEvent(new Event("change",{bubbles:true}));')
  94. wait(V+'.routeQuery.from==="2026-11-01" && !'+V+'._loading')
  95. action('v.renderRoot.querySelector(".advanced-toggle").click();')
  96. wait(V+'.renderRoot.getElementById("advanced-filters").hidden')
  97. assert action('return !!v.renderRoot.querySelector("button[aria-label^=移除日期起]");')
  98. action('v.renderRoot.querySelector("button[aria-label^=移除日期起]").click();')
  99. wait('!'+V+'.routeQuery.from && !'+V+'._loading')
  100. def select(label,value):
  101. selector=json.dumps('select[aria-label="'+label+'"]',ensure_ascii=False)
  102. action('const s=v.renderRoot.querySelector('+selector+');s.value='+json.dumps(value)+';s.dispatchEvent(new Event("change",{bubbles:true}));')
  103. wait(V+'._loading===true || '+V+'._data?.effectiveQuery.filters['+json.dumps({'平台':'platform','品牌':'brand','品类':'category'}.get(label,label))+']')
  104. ready()
  105. select('平台','jd')
  106. wait(V+'.routeQuery.platform==="jd" && !'+V+'._loading')
  107. select('品牌','ksf')
  108. wait(V+'.routeQuery.brand==="ksf" && !'+V+'._loading')
  109. select('品类','banmian')
  110. wait(V+'.routeQuery.category==="banmian" && !'+V+'._loading')
  111. scope=action('return {total:v._data.totalMatched,rows:v._data.records.map(r=>[r.platform,r.brand,r.category])};')
  112. assert scope['total']>0 and all(r==['jd','ksf','banmian'] for r in scope['rows']),scope
  113. assert action('return v.renderRoot.querySelectorAll(".filter-chip").length;')==3
  114. action('v.renderRoot.querySelector("button[aria-label^=移除品牌]").click();')
  115. wait('!'+V+'.routeQuery.brand && !'+V+'._loading')
  116. assert action('return [v.routeQuery.platform,v.routeQuery.category];')==['jd','banmian']
  117. select('品牌','ksf')
  118. wait(V+'.routeQuery.brand==="ksf" && !'+V+'._loading')
  119. shot('library-filtered-desktop')
  120. action('v.renderRoot.querySelector("a[data-record-id]").click();')
  121. wait(V+'._detail && !'+V+'._loading')
  122. assert action('return v._detail.record.source.archiveSha256.length;')==64
  123. assert action('return v._detail.relations.length;')==5
  124. product_ready()
  125. assert product_action('return p.product.id;')==action('return v._detail.record.id;')
  126. assert product_action('return p.renderRoot.querySelectorAll(".product-resources button").length;')==4
  127. product_tab('prices')
  128. assert product_action('return p._data.records.every(row=>row.product_id===p.product.id);')
  129. assert action('return v.routeQuery.brand;')=='ksf'
  130. product_tab('overview')
  131. reader_surface()
  132. action('navigator.clipboard.writeText=async text=>{window.__libraryCitation=text;};v.renderRoot.querySelector(".detail-actions .primary").click();')
  133. wait('!!window.__libraryCitation')
  134. citation=evaluate('window.__libraryCitation')
  135. assert '资料链接:' in citation and '原始页面:https://' in citation,citation
  136. assert not any(x in citation for x in ['SHA256','来源编号','来源文件','JSONL','rule_title_']),citation
  137. shot('library-product-detail')
  138. detail_url=evaluate('location.href')
  139. call('Page.reload');ready();assert evaluate('location.href')==detail_url
  140. assert action('return v._detail.record.id.startsWith("jd:");')
  141. action('v.renderRoot.querySelector(".breadcrumb a").click();')
  142. wait(V+'._data && !'+V+'._loading && !'+V+'.recordId')
  143. assert action('return v.routeQuery.brand;')=='ksf'
  144. # Category navigation and cards operate on URL state, not a local mock dataset.
  145. def go_type(entity):
  146. if entity in ['products','social','documents','reports']:
  147. action('Array.from(v.renderRoot.querySelectorAll(".tree-link")).find(a=>new URL(a.href).searchParams.get("type")==='+json.dumps(entity)+').click();')
  148. else:
  149. # Old bookmarked entity URLs remain readable, but have no standalone navigation.
  150. call('Page.navigate',dict(url=args.url+'?type='+entity))
  151. wait(V+'._type==='+json.dumps(entity)+' && !'+V+'._loading && ('+V+'._data || '+V+'._type==="reports")')
  152. # A product with many reviews validates isolated search, complete text, pagination and deep URLs.
  153. product_id='taobao:1040511334739'
  154. call('Page.navigate',dict(url=args.url+'/products/'+product_id+'?type=products&brand=ksf&panel=reviews&relatedQuality=audit'))
  155. ready();product_ready()
  156. assert product_action('return p._data.totalMatched;')==1000
  157. assert product_action('return p._data.records.every(row=>row.product_id===p.product.id);')
  158. assert product_action('return p.renderRoot.querySelectorAll(".review").length;')==20
  159. assert product_action('return !/SHA256|分类依据|rule_title_|source_attribute/.test(p.renderRoot.textContent);')
  160. shot('library-product-reviews')
  161. review_first=product_action('return p._data.records[0].record_id;')
  162. product_action('Array.from(p.renderRoot.querySelectorAll(".pagination button")).find(b=>b.textContent.includes("下一页")).click();')
  163. wait(P+'.query.relatedCursor && !'+P+'._loading && '+P+'._data')
  164. assert product_action('return p._data.records[0].record_id;')!=review_first
  165. assert product_action('return p._data.records.every(row=>row.product_id===p.product.id);')
  166. review_page_url=evaluate('location.href')
  167. call('Page.reload');ready();product_ready()
  168. assert evaluate('location.href')==review_page_url
  169. assert product_action('return p.query.relatedQuality;')=='audit'
  170. product_action('Array.from(p.renderRoot.querySelectorAll(".pagination button")).find(b=>b.textContent.includes("第一页")).click();')
  171. wait('!'+P+'.query.relatedCursor && !'+P+'._loading && '+P+'._data')
  172. assert product_action('return p._data.records[0].record_id;')==review_first
  173. assert product_action('return !p.renderRoot.querySelector("input:not([type=date]),form");')
  174. product_action('const i=p.renderRoot.querySelector("input[aria-label=商品资料日期起]");i.value="2099-01-01";i.dispatchEvent(new Event("change",{bubbles:true}));')
  175. wait(P+'.query.relatedFrom==="2099-01-01" && !'+P+'._loading && '+P+'._data?.totalMatched===0')
  176. assert product_action('return p.product.id;')==product_id
  177. product_action('Array.from(p.renderRoot.querySelectorAll("button")).find(b=>b.textContent.includes("清空当前筛选")).click();')
  178. wait('!'+P+'.query.relatedFrom && !'+P+'._loading && '+P+'._data')
  179. product_tab('prices')
  180. assert product_action('return p._data.records.every(row=>row.product_id===p.product.id);')
  181. assert product_action('return !!p.renderRoot.querySelector(".price-heading strong");')
  182. shot('library-product-prices')
  183. product_tab('specs')
  184. assert product_action('return p._data.records.every(row=>row.product_id===p.product.id);')
  185. product_tab('rankings')
  186. assert product_action('return p._data.records.every(row=>row.product_id===p.product.id);')
  187. # Failed tab reads stay in the product and must never be called empty data.
  188. call('Network.setBlockedURLs',dict(urls=['*/api/library']))
  189. product_action('p.renderRoot.querySelector("#tab-reviews").click();')
  190. wait(P+'._error && !'+P+'._loading')
  191. assert product_action('return !!p.renderRoot.querySelector("[role=alert]");')
  192. call('Network.setBlockedURLs',dict(urls=[]))
  193. product_action('p.renderRoot.querySelector(".error button").click();');product_ready()
  194. assert product_action('return p._data.records.every(row=>row.product_id===p.product.id);')
  195. action('v.renderRoot.querySelector(".breadcrumb a").click();')
  196. wait(V+'._data && !'+V+'._loading && !'+V+'.recordId')
  197. assert action('return v.routeQuery.brand;')=='ksf'
  198. assert not evaluate('new URL(location.href).searchParams.has("relatedQ") || new URL(location.href).searchParams.has("panel")')
  199. # Legacy per-product links now land inside the same product instead of a detached list.
  200. call('Page.navigate',dict(url=args.url+'?type=prices&productId='+product_id))
  201. wait(V+'.recordEntity==="products" && '+V+'._detail && !'+V+'._loading');product_ready()
  202. assert product_action('return p.panel;')=='prices'
  203. assert product_action('return p.product.id;')==product_id
  204. # Old search bookmarks shed keyword-bound cursors and preserve explicit filters.
  205. call('Page.navigate',dict(url=args.url+'?type=products&brand=ksf&q=旧搜索&cursor=old-search-cursor'))
  206. ready()
  207. assert action('return v.routeQuery.brand==="ksf" && !v.routeQuery.q && !v.routeQuery.cursor && !v._error;')
  208. call('Page.navigate',dict(url=args.url+'/products/'+product_id+'?type=products&panel=reviews&relatedQuality=audit&relatedQ=旧搜索&relatedCursor=old-review-cursor'))
  209. ready();product_ready()
  210. assert product_action('return p.product.id===p._data.records[0].product_id && !p.query.relatedQ && !p.query.relatedCursor;')
  211. call('Page.navigate',dict(url=args.url));ready()
  212. go_type('reviews')
  213. assert action('return v._data.totalMatched;')==771
  214. action('const s=v.renderRoot.querySelector("select[aria-label=查看口径]");s.value="audit";s.dispatchEvent(new Event("change",{bubbles:true}));')
  215. wait(V+'._data?.totalMatched===2360 && !'+V+'._loading')
  216. go_type('documents')
  217. assert action('return v._data.totalMatched;')==25
  218. shot('library-policy-browse')
  219. action('const s=v.renderRoot.querySelector("select[aria-label=政策状态]");s.value=s.options[1].value;s.dispatchEvent(new Event("change",{bubbles:true}));')
  220. wait(V+'.routeQuery.policyStatus && !'+V+'._loading')
  221. assert action('return v._data.totalMatched;')>0
  222. action('v.renderRoot.querySelector("a[data-record-id]").click();')
  223. wait(V+'._detail && !'+V+'._loading')
  224. assert action('return v._detail.record.full_text.length;')>100
  225. assert action('return v.renderRoot.querySelector(".detail-main").firstElementChild.id;')=='full_text'
  226. reader_surface()
  227. assert action('return !!v.renderRoot.querySelector(".source a[target=_blank]");')
  228. shot('library-policy-detail')
  229. call('Page.navigate',dict(url=args.url+'?type=clauses'));ready()
  230. assert action('return v._data.totalMatched;')==31
  231. action('v.renderRoot.querySelector("a[data-record-id]").click();')
  232. wait(V+'._detail && !'+V+'._loading')
  233. assert action('return v.renderRoot.textContent.includes("应用提示(非官方原话)");')
  234. assert action('return v._detail.record.parentDocument.title;')
  235. assert action('return v.renderRoot.querySelector(".source a[target=_blank]").href;').startswith('http')
  236. reader_surface()
  237. call('Page.navigate',dict(url=args.url+'?type=products'));ready()
  238. first=action('return v._data.records[0].record_id;')
  239. action('Array.from(v.renderRoot.querySelectorAll("button")).find(b=>b.textContent.includes("下一页")).click();')
  240. wait(V+'.routeQuery.cursor && !'+V+'._loading')
  241. assert action('return v._data.records[0].record_id;')!=first
  242. second=action('return v._data.records[0].record_id;')
  243. action('Array.from(v.renderRoot.querySelectorAll("button")).find(b=>b.textContent.includes("上一页")).click();')
  244. wait('!'+V+'.routeQuery.cursor && !'+V+'._loading')
  245. assert action('return v._data.records[0].record_id;')==first
  246. action('Array.from(v.renderRoot.querySelectorAll("button")).find(b=>b.textContent.includes("下一页")).click();')
  247. wait(V+'.routeQuery.cursor && !'+V+'._loading')
  248. assert action('return v._data.records[0].record_id;')==second
  249. call('Page.reload');ready();assert action('return !!v.routeQuery.cursor;')
  250. # Read all remaining categories, retaining a real zero-result state for absent evidence.
  251. for entity in ['social','prices','specs','rankings','coverage','reports']:
  252. go_type(entity)
  253. if entity!='reports':assert action('return v._data.totalMatched;')>0
  254. if entity=='prices':
  255. price=action('return {raw:v._data.records[0].price_minor,shown:v.renderRoot.querySelector(".price-fact strong").textContent};')
  256. assert price['shown']=='¥'+format(price['raw']/100,'.2f'),price
  257. assert action('return !v.renderRoot.querySelector(".record").textContent.includes("search_listing_snapshot");')
  258. shot('library-price-browse')
  259. if entity=='specs':
  260. assert action('return v._data.records[0].net_weight_g===null && !v.renderRoot.querySelector(".spec-facts").textContent.includes("净含量");')
  261. if entity=='coverage':
  262. assert action('return !v.renderRoot.querySelector(".record").textContent.includes("not_attempted_service_blocked");')
  263. reader_surface()
  264. if entity=='reports':
  265. action('const s=v.renderRoot.querySelector("select[aria-label=成果阶段]");s.value=s.options[1].value;s.dispatchEvent(new Event("change",{bubbles:true}));')
  266. wait(V+'.routeQuery.reportStage && !'+V+'._loading')
  267. assert action('return v.renderRoot.querySelectorAll(".report-record").length===v.db.artifacts.filter(r=>r.stageObj?.code===v.routeQuery.reportStage).length;')
  268. call('Page.reload');ready()
  269. assert action('return !!v.routeQuery.reportStage && !v.renderRoot.querySelector("input");')
  270. if entity!='reports':
  271. action('v.renderRoot.querySelector("a[data-record-id]").click();')
  272. wait(V+'._detail && !'+V+'._loading')
  273. reader_surface()
  274. action('v.renderRoot.querySelector(".breadcrumb a").click();')
  275. wait(V+'._data && !'+V+'._loading && !'+V+'.recordId')
  276. go_type('products')
  277. action('v.renderRoot.querySelector(".advanced-toggle").click();')
  278. wait('!'+V+'.renderRoot.getElementById("advanced-filters").hidden')
  279. action('const i=v.renderRoot.querySelector("input[aria-label=日期起]");i.value="2099-01-01";i.dispatchEvent(new Event("change",{bubbles:true}));')
  280. wait(V+'.routeQuery.from==="2099-01-01" && !'+V+'._loading')
  281. assert action('return v._data.totalMatched;')==0
  282. action('v.renderRoot.querySelector(".empty button").click();')
  283. wait('!'+V+'.routeQuery.from && !'+V+'._loading && '+V+'._data?.totalMatched===4639')
  284. # A failed connection must stay distinct from a genuine zero-result query.
  285. call('Network.setBlockedURLs',dict(urls=['*/api/library']))
  286. action('v.renderRoot.querySelector(".refresh").click();')
  287. wait(V+'._error && !'+V+'._loading')
  288. assert action('return !!v.renderRoot.querySelector("[role=alert]") && !v.renderRoot.querySelector(".empty");')
  289. call('Network.setBlockedURLs',dict(urls=[]))
  290. action('v.renderRoot.querySelector(".error button").click();')
  291. ready()
  292. call('Page.navigate',dict(url=args.url));ready()
  293. layouts=[]
  294. for width,height in [(1440,1100),(1024,1000),(390,844),(320,740)]:
  295. call('Emulation.setDeviceMetricsOverride',dict(width=width,height=height,deviceScaleFactor=1,mobile=width<700))
  296. time.sleep(.15)
  297. 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};')
  298. assert metrics['documentWidth']<=width,metrics
  299. assert metrics['cards']==4 and metrics['records']==20,metrics
  300. assert metrics['resultTop']<(350 if width>=1000 else 450),metrics
  301. if width==1440:assert metrics['columns']>=3 and metrics['visibleRecords']>=9,metrics
  302. if width==1024:assert metrics['columns']>=2,metrics
  303. shot('library-'+str(width));layouts.append(metrics)
  304. assert action('return getComputedStyle(v.renderRoot.querySelector(".stats")).display;')=='none'
  305. action('v.renderRoot.querySelector(".overview-toggle").click();')
  306. wait(V+'.renderRoot.querySelector(".overview-toggle").getAttribute("aria-expanded")==="true"')
  307. assert action('return getComputedStyle(v.renderRoot.querySelector(".stats")).display;')=='grid'
  308. action('v.renderRoot.querySelector(".overview-toggle").click();')
  309. # Product tabs scroll within their own row; long reviews fit the mobile viewport.
  310. call('Page.navigate',dict(url=args.url+'/products/'+product_id+'?type=products&panel=reviews&relatedQuality=audit'))
  311. ready();product_ready()
  312. assert evaluate('document.documentElement.scrollWidth<=innerWidth')
  313. assert product_action('return p.renderRoot.querySelector(".product-tabs").scrollWidth>p.renderRoot.querySelector(".product-tabs").clientWidth;')
  314. product_action('p.renderRoot.querySelector(".section-tools").scrollIntoView({block:"start"});')
  315. shot('library-product-reviews-mobile')
  316. product_tab('specs')
  317. assert evaluate('document.documentElement.scrollWidth<=innerWidth')
  318. call('Page.navigate',dict(url=args.url));ready()
  319. # New tab remains reachable on mobile, and active state covers detail routes.
  320. assert evaluate('document.querySelector("kf-app").shadowRoot.querySelector("kf-nav").shadowRoot.querySelector(".tabbar a[data-path=\\"/library\\"]").getAttribute("aria-current")')=='page'
  321. action('const s=v.renderRoot.querySelector("select[aria-label=资料分类]");s.value="documents";s.dispatchEvent(new Event("change",{bubbles:true}));')
  322. wait(V+'._type==="documents" && !'+V+'._loading && '+V+'._data?.totalMatched===25')
  323. action('v.renderRoot.querySelector(".filter").scrollIntoView({block:"start"});')
  324. shot('library-mobile-browse')
  325. action('v.renderRoot.querySelector("a[data-record-id]").click();')
  326. wait(V+'._detail && !'+V+'._loading')
  327. assert evaluate('document.documentElement.scrollWidth<=innerWidth')
  328. assert action('return getComputedStyle(v.renderRoot.getElementById("record-facts")).display;')=='none'
  329. action('v.renderRoot.querySelector(".facts-toggle").click();')
  330. wait(V+'.renderRoot.querySelector(".facts-toggle").getAttribute("aria-expanded")==="true"')
  331. assert action('return getComputedStyle(v.renderRoot.getElementById("record-facts")).display;')=='block'
  332. action('v.renderRoot.querySelector(".facts-toggle").click();')
  333. shot('library-mobile-detail')
  334. failures=[x for x in events if x.get('method')=='Runtime.exceptionThrown']
  335. assert not failures,failures
  336. result=dict(ok=True,scope=scope,layouts=layouts,tests=['product-catalog-navigation','real-data-counts','exact-filters','active-filter-removal','advanced-filters','display-mode-no-refetch','coverage-toggle','product-inline-panels','product-review-date-filter','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)
  337. (WORK/'browser-validation.json').write_text(json.dumps(result,ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
  338. print(json.dumps(result,ensure_ascii=False,indent=2))
  339. finally:
  340. if ws:ws.close()
  341. process.terminate()
  342. try:process.wait(timeout=8)
  343. except subprocess.TimeoutExpired:process.kill()