verify-library-materials.py 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321
  1. """Real-browser acceptance for multi-select library materials and exact database locators, with no page overrides."""
  2. import argparse
  3. import base64
  4. from datetime import datetime, timezone
  5. import json
  6. from pathlib import Path
  7. import subprocess
  8. import tempfile
  9. import time
  10. from urllib.request import Request, urlopen
  11. import websocket
  12. ROOT = Path(__file__).resolve().parents[1]
  13. def main():
  14. parser = argparse.ArgumentParser(description=__doc__)
  15. parser.add_argument('--url', default='http://127.0.0.1:4183')
  16. parser.add_argument('--out', default='work/library-card-selection-20261107-r1/browser')
  17. args = parser.parse_args()
  18. out = (ROOT / args.out).resolve()
  19. out.mkdir(parents=True, exist_ok=True)
  20. profile = Path(tempfile.mkdtemp(prefix='library-material-edge-', dir=ROOT / 'work'))
  21. browser = subprocess.Popen(['C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe', '--headless=new',
  22. '--disable-gpu', '--disable-background-networking', '--disable-component-update', '--disable-extensions',
  23. '--no-first-run', '--remote-debugging-port=0', '--user-data-dir=' + str(profile), 'about:blank'],
  24. stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, creationflags=subprocess.CREATE_NO_WINDOW)
  25. serial, ws, events = 0, None, []
  26. report = {'ok': False, 'checkedAt': datetime.now(timezone.utc).isoformat(), 'url': args.url,
  27. 'checks': [], 'layouts': [], 'responseOverrides': 0, 'paidCalls': 0, 'productionWrites': 0}
  28. try:
  29. end = time.monotonic() + 20
  30. while not (profile / 'DevToolsActivePort').exists():
  31. assert time.monotonic() < end and browser.poll() is None
  32. time.sleep(.1)
  33. port = (profile / 'DevToolsActivePort').read_text().splitlines()[0]
  34. page = next(p for p in json.load(urlopen('http://127.0.0.1:' + port + '/json/list')) if p['type'] == 'page')
  35. ws = websocket.create_connection(page['webSocketDebuggerUrl'], timeout=45, suppress_origin=True, http_no_proxy=['127.0.0.1'])
  36. def call(method, params=None):
  37. nonlocal serial
  38. serial += 1
  39. ws.send(json.dumps({'id': serial, 'method': method, 'params': params or {}}))
  40. while True:
  41. d = json.loads(ws.recv())
  42. if d.get('id') == serial:
  43. assert 'error' not in d, d
  44. return d.get('result', {})
  45. events.append(d)
  46. def ev(code):
  47. d = call('Runtime.evaluate', {'expression': code, 'returnByValue': True, 'awaitPromise': True})
  48. assert 'exceptionDetails' not in d, d
  49. return d['result'].get('value')
  50. view = 'document.querySelector("kf-app")?.renderRoot?.querySelector("view-library")'
  51. def action(code): return ev('(()=>{const p=' + view + ';' + code + '})()')
  52. def ready():
  53. ev('new Promise((resolve,reject)=>{let end=Date.now()+30000;function tick(){const p=' + view +
  54. ';if(p && !p._loading && p._summary && (p._data || p._detail || p._type==="reports")){p.updateComplete.then(()=>resolve(true));return}'
  55. 'if(p?._error)reject(Error(p._error));else if(Date.now()>end)reject(Error("timeout"));else setTimeout(tick,70)}tick()})')
  56. def navigate(path):
  57. call('Page.navigate', {'url': args.url + path}); ready()
  58. def settle(): ev('new Promise(r=>setTimeout(r,120))')
  59. def child_ready(panel):
  60. ev('new Promise((resolve,reject)=>{let end=Date.now()+20000;function tick(){const p=' + view + ';const c=p?.renderRoot?.querySelector("library-product-detail");if(c && c.panel===' + json.dumps(panel) + ' && !c._loading && c._data){c.updateComplete.then(()=>resolve(true));return}if(c?._error)reject(Error(c._error));else if(Date.now()>end)reject(Error("child timeout"));else setTimeout(tick,70)}tick()})')
  61. def copied_ready():
  62. ev('new Promise((resolve,reject)=>{let end=Date.now()+15000;function tick(){const p=' + view + ';if(!p._copying && p._copyText){resolve(true);return}if(Date.now()>end)reject(Error("copy timeout"));else setTimeout(tick,70)}tick()})')
  63. def shot(name):
  64. (out / name).write_bytes(base64.b64decode(call('Page.captureScreenshot', {'format': 'png', 'captureBeyondViewport': False})['data']))
  65. def check(name): report['checks'].append(name)
  66. def mouse_click(selector):
  67. point=action('const b=p.renderRoot.querySelector('+json.dumps(selector)+');b.scrollIntoView({block:"center"});const r=b.getBoundingClientRect();return {x:r.x+r.width/2,y:r.y+r.height/2}')
  68. call('Input.dispatchMouseEvent',{'type':'mousePressed','x':point['x'],'y':point['y'],'button':'left','clickCount':1})
  69. call('Input.dispatchMouseEvent',{'type':'mouseReleased','x':point['x'],'y':point['y'],'button':'left','clickCount':1});settle()
  70. call('Runtime.enable'); call('Page.enable'); call('Network.enable')
  71. call('Emulation.setFocusEmulationEnabled', {'enabled': True})
  72. call('Browser.grantPermissions', {'origin': args.url, 'permissions': ['clipboardReadWrite', 'clipboardSanitizedWrite']})
  73. call('Emulation.setDeviceMetricsOverride', {'width': 1440, 'height': 1050, 'deviceScaleFactor': 1, 'mobile': False})
  74. navigate('/library?type=products&quality=audit&category=banmian&platform=jd&brand=ksf')
  75. generation = action('return p._summary.generation')
  76. assert action('return p.renderRoot.querySelectorAll("[data-record-id]").length') == 24
  77. assert action('return !p.renderRoot.querySelector("library-product-compare,.compare-toggle,.compare-bar")')
  78. assert not action('return p._isSelecting')
  79. assert action('return !p.renderRoot.querySelector(".material-choice-footer,[data-select-material],.select-page,.selection-bar")')
  80. first_product=action('return p._data.records[0].record_id')
  81. action('p.renderRoot.querySelector("[data-record-id]").click();return true');settle();ready()
  82. assert action('return p.recordId')==first_product
  83. assert action('return p._selected.length')==0
  84. navigate('/library?type=products&quality=audit&category=banmian&platform=jd&brand=ksf')
  85. shot('browse-1440.png')
  86. action('p.renderRoot.querySelector("[data-selection-mode]").click();return true');settle()
  87. assert action('return p._isSelecting')
  88. assert action('return [...p.renderRoot.querySelectorAll("[data-record-id]")].every(e=>e.getAttribute("role")==="checkbox"&&!e.hasAttribute("href")&&!e.hasAttribute("data-link"))')
  89. check('browse-click-opens-detail-and-selection-mode-has-no-card-checkbox-footers')
  90. count_before = len([e for e in events if e['method']=='Network.requestWillBeSent' and '/api/library' in e['params']['request']['url']])
  91. mouse_click('.product-choice:nth-child(1) library-product-image')
  92. mouse_click('.product-choice:nth-child(2) h4')
  93. assert action('return p._selected.length') == 2
  94. count_after = len([e for e in events if e['method']=='Network.requestWillBeSent' and '/api/library' in e['params']['request']['url']])
  95. assert count_after == count_before, (count_before,count_after)
  96. first_ids = action('return p._selected.map(x=>x.id)')
  97. assert not action('return p.recordId')
  98. assert action('return p.renderRoot.querySelectorAll(".card-selection-mark").length')==2
  99. action('p.renderRoot.querySelector("[data-record-id]").focus();return true')
  100. for key,code,vk in [(' ','Space',32),('Enter','Enter',13)]:
  101. call('Input.dispatchKeyEvent',{'type':'rawKeyDown','key':key,'code':code,'windowsVirtualKeyCode':vk});call('Input.dispatchKeyEvent',{'type':'keyUp','key':key,'code':code,'windowsVirtualKeyCode':vk});settle()
  102. assert action('return p._selected.length')==1
  103. call('Input.dispatchKeyEvent',{'type':'rawKeyDown','key':key,'code':code,'windowsVirtualKeyCode':vk});call('Input.dispatchKeyEvent',{'type':'keyUp','key':key,'code':code,'windowsVirtualKeyCode':vk});settle()
  104. assert action('return p._selected.length')==2
  105. call('Input.dispatchKeyEvent',{'type':'rawKeyDown','key':'Escape','code':'Escape','windowsVirtualKeyCode':27});call('Input.dispatchKeyEvent',{'type':'keyUp','key':'Escape','code':'Escape','windowsVirtualKeyCode':27});settle()
  106. assert not action('return p._isSelecting')
  107. assert action('return p._selected.length')==2
  108. assert action('return p.renderRoot.querySelectorAll("[data-record-id][href]").length')==24
  109. action('p.renderRoot.querySelector("[data-selection-mode]").click();return true');settle()
  110. first_ids=action('return p._selected.map(x=>x.id)')
  111. shot('selecting-1440.png')
  112. check('card-click-selection-does-not-fetch-or-navigate-and-keyboard-space-enter-escape-work')
  113. action('p.renderRoot.querySelector("[data-page-next]").click();return true'); settle(); ready()
  114. assert action('return p.routeQuery.page') == '2'
  115. action('p.renderRoot.querySelector("[data-record-id]").click();return true')
  116. assert action('return p._selected.length') == 3
  117. action('p.renderRoot.querySelector(".select-page").click();return true')
  118. assert action('return p._selected.length') == 26
  119. action('p.renderRoot.querySelector(".select-page").click();return true')
  120. assert action('return p._selected.map(x=>x.id)') == first_ids
  121. check('page-select-and-cancel-only-affect-current-page-with-cross-page-selection-preserved')
  122. assert action('return p._data.pageSize')==24
  123. assert '第 2 / ' in action('return p.renderRoot.querySelector(".page-status").textContent')
  124. # Direct input, reload, browser history and true final-page size.
  125. action('const f=p.renderRoot.querySelector(".page-jump");f.querySelector("input").value="3";f.requestSubmit();return true');settle();ready()
  126. page_three=action('return p._data.records.map(x=>x.record_id)')
  127. assert action('return p._data.page')==3
  128. call('Page.reload');ready()
  129. assert action('return p._isSelecting')
  130. assert action('return p._data.records.map(x=>x.record_id)')==page_three
  131. action('p.renderRoot.querySelector("[data-page-prev]").click();return true');settle();ready()
  132. assert action('return p._data.page')==2
  133. ev('history.back();true');settle();ready()
  134. assert action('return p._data.page')==3
  135. pages=action('return p._data.totalPages')
  136. action('const f=p.renderRoot.querySelector(".page-jump");f.querySelector("input").value=String(p._data.totalPages);f.requestSubmit();return true');settle();ready()
  137. assert action('return p._data.page')==pages
  138. assert action('return p._data.records.length')==action('return p._data.totalMatched-(p._data.totalPages-1)*24')
  139. assert action('return p.renderRoot.querySelector("[data-page-next]").disabled')
  140. for invalid in ['0','1.5',str(pages+1)]:
  141. assert not action('const i=p.renderRoot.querySelector(".page-jump input");i.value='+json.dumps(invalid)+';return i.checkValidity()')
  142. action('const f=p.renderRoot.querySelector(".page-jump");f.querySelector("input").value="1";f.requestSubmit();return true');settle();ready()
  143. assert action('return p._selected.map(x=>x.id)')==first_ids
  144. check('24-per-page-direct-input-refresh-history-last-page-and-invalid-page-validation')
  145. action('const f=p.renderRoot.querySelector(".page-jump");f.querySelector("input").value="3";f.requestSubmit();return true');settle();ready()
  146. action('const s=p.renderRoot.querySelector("select[aria-label=价格]");s.value="price_asc";s.dispatchEvent(new Event("change"));return true');settle();ready()
  147. assert action('return p._data.page')==1
  148. assert action('return p.routeQuery.sort')=='price_asc'
  149. assert action('return p._selected.map(x=>x.id)')==first_ids
  150. check('changing-sort-resets-page-with-selected-records-retained')
  151. mouse_click('[data-selection-mode]')
  152. assert not action('return p._isSelecting')
  153. assert action('return p._selected.map(x=>x.id)')==first_ids
  154. assert action('return !p.renderRoot.querySelector(".select-page")')
  155. mouse_click('[data-selection-mode]')
  156. check('finish-selection-restores-browsing-with-selected-materials-retained')
  157. navigate('/library?type=social&quality=audit')
  158. action('p.renderRoot.querySelector("[data-record-id]").click();return true')
  159. navigate('/library?type=documents&quality=audit')
  160. action('p.renderRoot.querySelector("[data-record-id]").click();return true')
  161. assert action('return p._selected.map(x=>x.entity)') == ['products','products','social','documents']
  162. policy = action('return p._selected.find(x=>x.entity==="documents").id')
  163. assert ':' not in policy
  164. call('Page.reload');ready()
  165. assert action('return p._selected.length') == 4
  166. check('mixed-product-social-policy-selection-and-exact-unprefixed-policy-id-survive-refresh')
  167. navigate('/library?type=reports')
  168. ev('new Promise((resolve,reject)=>{let end=Date.now()+20000;function tick(){const p=' + view + ';if(p?.db){resolve(true);return}if(Date.now()>end)reject(Error("workshop database timeout"));else setTimeout(tick,70)}tick()})')
  169. reports = action('return p.db.artifacts.length')
  170. report['availableWorkshopReports'] = reports
  171. if reports:
  172. action('p.renderRoot.querySelector("[data-record-id]").click();return true')
  173. assert action('const s=p._selected.find(x=>x.entity==="reports");return !!p.db.artifactById[s.id]')
  174. check('existing-workshop-reports-use-real-artifact-object-id')
  175. if reports>24:
  176. assert action('return p.renderRoot.querySelectorAll(".report-record").length')==24
  177. action('p.renderRoot.querySelector("[data-page-next]").click();return true');settle();ready()
  178. assert action('return p.routeQuery.page')=='2'
  179. assert action('return p.renderRoot.querySelectorAll(".report-record").length')==min(24,reports-24)
  180. action('p.renderRoot.querySelector(".select-page").click();return true')
  181. selected_reports=action('return p._selected.filter(x=>x.entity==="reports").length')
  182. assert selected_reports==min(24,reports-24)+1
  183. action('p.renderRoot.querySelector(".select-page").click();return true')
  184. assert action('return p._selected.filter(x=>x.entity==="reports").length')==1
  185. check('research-reports-use-24-card-pages-and-page-selection-retains-other-pages')
  186. navigate('/library/products/jd%3A100000348106?panel=reviews&relatedQuality=audit')
  187. child_ready('reviews')
  188. child_rows = action('return p.renderRoot.querySelector("library-product-detail")._data.records.length')
  189. assert child_rows > 0
  190. action('p.renderRoot.querySelector("library-product-detail").renderRoot.querySelector("[data-select-child]").click();return true')
  191. assert action('return p._selected.some(x=>x.entity==="reviews" && x.productId==="jd:100000348106")')
  192. before = action('return p._selected.length')
  193. action('p.renderRoot.querySelector("library-product-detail").renderRoot.querySelector("[data-select-child-page]").click();return true')
  194. assert action('return p._selected.length') == before+child_rows-1
  195. for panel in ['prices','specs']:
  196. product_id = ev('(async()=>{const {readLibrary}=await import("/src/services/library-service.js");const d=await readLibrary({op:"browse",entity:' + json.dumps(panel) + ',quality:"audit",limit:1,generation:' + json.dumps(generation) + '});return d.records[0].product_id})()')
  197. navigate('/library/products/' + __import__('urllib.parse',fromlist=['quote']).quote(product_id) + '?panel=' + panel)
  198. child_ready(panel)
  199. action('p.renderRoot.querySelector("library-product-detail").renderRoot.querySelector("[data-select-child]").click();return true')
  200. assert action('return p._selected.some(x=>x.entity===' + json.dumps(panel) + ')')
  201. check('product-detail-supports-exact-review-price-spec-and-current-page-selection')
  202. mouse_click('.product-detail-dialog [data-copy-materials]');copied_ready()
  203. copied = ev('navigator.clipboard.readText()').replace('\r\n','\n')
  204. assert copied == action('return p._copyText')
  205. (out/'copied-materials.txt').write_text(copied,encoding='utf-8')
  206. selected = action('return p._selected')
  207. rows = [json.loads(line) for line in copied.splitlines() if line.startswith('{')]
  208. assert len(rows)==len(selected)
  209. assert any(row.get('database')=='workshop' for row in rows) == bool(reports)
  210. assert 'generation:'+generation in copied
  211. assert 'jd:jd:' not in copied and 'source_record_id' in copied
  212. assert not any(phrase in copied for phrase in ['员工','请返回','请你','查库要求','ksf-voc-library','workshop-read','selectedData','selectionContext'])
  213. # Independently read every selected VOC record from the same live generation.
  214. verified = ev('(async()=>{const p=' + view + ';const {readLibrary}=await import("/src/services/library-service.js");const out=[];for(const s of p._selected.filter(x=>x.entity!=="reports")){const d=await readLibrary({op:"detail",entity:s.entity,id:s.id,generation:p._summary.generation});out.push({entity:s.entity,id:s.id,actualId:d.record.id,sourceMatches:!s.sourceRecordId || s.sourceRecordId===d.record.source_record_id});}return out})()')
  215. assert all(row['id']==row['actualId'] and row['sourceMatches'] for row in verified)
  216. report['verifiedLocators'] = verified
  217. check('real-clipboard-has-only-data-locators-and-every-voc-record-reads-back')
  218. navigate('/library?type=products&quality=audit&category=banmian&platform=jd&brand=ksf')
  219. action('p.renderRoot.querySelector("[data-preview-materials]").click();return true');copied_ready();settle()
  220. assert action('return p.renderRoot.querySelector(".material-dialog").open')
  221. call('Input.dispatchKeyEvent', {'type':'rawKeyDown','key':'Escape','code':'Escape','windowsVirtualKeyCode':27,'nativeVirtualKeyCode':27})
  222. call('Input.dispatchKeyEvent', {'type':'keyUp','key':'Escape','code':'Escape','windowsVirtualKeyCode':27,'nativeVirtualKeyCode':27})
  223. settle();assert action('return !p.renderRoot.querySelector(".material-dialog").open')
  224. call('Browser.resetPermissions')
  225. for unsanitized in (False,True):
  226. call('Browser.setPermission',{'origin':args.url,'permission':{'name':'clipboard-write','allowWithoutSanitization':unsanitized},'setting':'denied'})
  227. action('p.renderRoot.querySelector("[data-copy-materials]").click();return true');copied_ready();settle()
  228. assert action('return p.renderRoot.querySelector(".material-dialog").open')
  229. assert action('const t=p.renderRoot.querySelector("[data-material-text]");return t.selectionEnd-t.selectionStart===t.value.length')
  230. shot('manual-copy-1440.png')
  231. action('p.renderRoot.querySelector(".material-dialog").close();return true')
  232. call('Browser.grantPermissions', {'origin':args.url,'permissions':['clipboardReadWrite','clipboardSanitizedWrite']})
  233. check('native-dialog-keyboard-and-real-permission-denial-manual-copy-fallback')
  234. for path in ['/library?type=products&quality=audit','/library?type=social&quality=audit','/library?type=documents','/library?type=reports','/library/products/jd%3A100000348106?panel=reviews&relatedQuality=audit']:
  235. navigate(path)
  236. if '/products/' in path: child_ready('reviews')
  237. for width in [1440,1024,768,390,320]:
  238. call('Emulation.setDeviceMetricsOverride',{'width':width,'height':900,'deviceScaleFactor':1,'mobile':False})
  239. ev('window.scrollTo(0,0);new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(r)))')
  240. # Responsive columns can change page size and trigger a real data reload.
  241. ready();settle()
  242. metrics=ev('({innerWidth,clientWidth:document.documentElement.clientWidth,scrollWidth:document.documentElement.scrollWidth})')
  243. assert metrics['innerWidth']==width and metrics['scrollWidth']<=metrics['clientWidth'],(path,width,metrics)
  244. report['layouts'].append({'path':path,'width':width,**metrics})
  245. if width<=390:
  246. title_height=action('return p.renderRoot.querySelector(".result-head h3")?.getBoundingClientRect().height || 0')
  247. assert title_height<=40,(path,width,title_height)
  248. if path=='/library?type=products&quality=audit' and width==1440:
  249. compact=action('const cards=[...p.renderRoot.querySelectorAll(".product-choice")];return {maxHeight:Math.max(...cards.map(x=>x.getBoundingClientRect().height)),fullyVisible:cards.filter(x=>x.getBoundingClientRect().bottom<=innerHeight).length,thumbnailWidth:p.renderRoot.querySelector(".product-record library-product-image").getBoundingClientRect().width}')
  250. assert compact['maxHeight']<=250 and compact['fullyVisible']>=3 and compact['thumbnailWidth']==104,compact
  251. report['readableCards']=compact
  252. assert action('return getComputedStyle(p.renderRoot.querySelector(".record-list.cards")).gridTemplateColumns.split(" ").length')==3
  253. if width in (1440,390,320): shot(('detail' if '/products/' in path else path.split('type=')[1].split('&')[0])+'-'+str(width)+'.png')
  254. if path=='/library?type=products&quality=audit' and width in (1440,390,320):
  255. action('p.renderRoot.querySelector(".pagination").scrollIntoView({block:"center"});return true');settle()
  256. paging=action('const n=p.renderRoot.querySelector(".pagination"),i=n.querySelector("input"),b=n.querySelector("[data-page-go]");const r=n.getBoundingClientRect(),q=b.getBoundingClientRect();return {width:innerWidth,left:r.left,right:r.right,top:r.top,bottom:r.bottom,buttonTop:q.top,buttonBottom:q.bottom,status:n.querySelector(".page-status").textContent,clickable:p.renderRoot.elementFromPoint(q.left+q.width/2,q.top+q.height/2)===b}')
  257. assert 0<=paging['left'] and paging['right']<=width and paging['buttonBottom']<=900 and paging['clickable'],paging
  258. report.setdefault('paginationLayouts',[]).append(paging)
  259. shot('pagination-'+str(width)+'.png')
  260. mouse_click('[data-close-product]');ready();settle()
  261. mouse_click('.selection-list summary')
  262. assert ev('document.documentElement.clientWidth===document.documentElement.scrollWidth')
  263. for width,height in [(320,568),(390,844),(1440,900)]:
  264. call('Emulation.setDeviceMetricsOverride',{'width':width,'height':height,'deviceScaleFactor':1,'mobile':False})
  265. mouse_click('[data-preview-materials]');copied_ready();settle()
  266. metrics=action('const d=p.renderRoot.querySelector(".material-dialog"),b=p.renderRoot.querySelector("[data-copy-preview]");const r=d.getBoundingClientRect(),q=b.getBoundingClientRect();return {left:r.left,right:r.right,top:r.top,bottom:r.bottom,buttonTop:q.top,buttonBottom:q.bottom,scrollWidth:d.scrollWidth,clientWidth:d.clientWidth}')
  267. assert 0 <= metrics['left'] and metrics['right'] <= width and metrics['bottom'] <= height and metrics['buttonBottom'] <= height,metrics
  268. assert metrics['scrollWidth']==metrics['clientWidth'],metrics
  269. shot('copy-dialog-'+str(width)+'.png')
  270. action('p.renderRoot.querySelector(".material-dialog").close();return true')
  271. check('all-five-views-at-five-real-viewport-widths-and-small-phone-copy-dialog-fit')
  272. # An old comparison link becomes a selection without mounting comparison UI.
  273. navigate('/library?type=products&compare='+__import__('urllib.parse',fromlist=['quote']).quote(json.dumps(first_ids))+'&comparing=1')
  274. assert action('return !p.renderRoot.querySelector("library-product-compare") && !location.search.includes("compare")')
  275. assert action('return p._selected.some(x=>x.id===' + json.dumps(first_ids[0]) + ')')
  276. check('legacy-comparison-link-normalizes-to-copyable-material-selection')
  277. old_count=action('return p._selected.length')
  278. navigate('/library?type=products&selectProducts=%5B%22jd%3A100000283767%22%5D&selectionGeneration=outdated')
  279. assert action('return p._selected.length')==old_count
  280. assert '版本已变化' in action('return p._notice')
  281. check('stale-generation-industry-transfer-rejected-with-visible-explanation')
  282. action('const s=JSON.parse(sessionStorage.getItem("ksf-library-materials-v1"));s.generation="previous-generation";sessionStorage.setItem("ksf-library-materials-v1",JSON.stringify(s));return true')
  283. call('Page.reload');ready()
  284. assert action('return p._selected.length')==0
  285. assert '旧版本选择已清空' in action('return p._notice')
  286. assert action('return p.renderRoot.querySelector("[data-copy-materials]").disabled')
  287. check('persisted-selection-from-old-generation-clears-before-copy')
  288. report['generation']=generation
  289. report['consoleExceptions']=[e for e in events if e['method']=='Runtime.exceptionThrown']
  290. assert not report['consoleExceptions'],report['consoleExceptions']
  291. report['ok']=True
  292. except Exception:
  293. if ws:
  294. report['failureState'] = action('return {url:location.href,type:p?._type,query:p?.routeQuery,loading:p?._loading,error:p?._error,keys:Object.keys(p?._data||{})}')
  295. shot('failure.png')
  296. raise
  297. finally:
  298. if ws: ws.close()
  299. browser.terminate()
  300. try: browser.wait(timeout=10)
  301. except subprocess.TimeoutExpired: browser.kill(); browser.wait(timeout=10)
  302. (out / 'verification.json').write_text(json.dumps(report, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
  303. print(json.dumps(report, ensure_ascii=False, indent=2))
  304. if __name__ == '__main__': main()