verify-industry-mainline.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243
  1. """Frozen r8 browser acceptance: innovation evidence, Agent copy and existing research flows."""
  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/industry-mainline-handoff-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='innovation-edge-', dir=out))
  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-industry")'
  51. def action(code): return ev('(()=>{const p=' + view + ';' + code + '})()')
  52. def ready(tab='insights'):
  53. ev('new Promise((resolve,reject)=>{let end=Date.now()+30000;function tick(){const p=' + view +
  54. ';if(p && p._tab===' + json.dumps(tab) + ' && !p._loading && p._data){p.updateComplete.then(()=>resolve(true),reject);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})
  58. ready()
  59. def change(field, value):
  60. action('const s=p.renderRoot.querySelector(' + json.dumps('select[data-filter=' + field + ']') + ');s.value=' + json.dumps(value) + ';s.dispatchEvent(new Event("change"));return true')
  61. ev('new Promise(r=>setTimeout(r,80))')
  62. ready()
  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. call('Runtime.enable'); call('Page.enable')
  67. call('Emulation.setFocusEmulationEnabled', {'enabled': True})
  68. call('Browser.grantPermissions', {'origin': args.url, 'permissions': ['clipboardReadWrite', 'clipboardSanitizedWrite']})
  69. call('Emulation.setDeviceMetricsOverride', {'width': 1440, 'height': 1050, 'deviceScaleFactor': 1, 'mobile': False})
  70. navigate('/industry')
  71. assert action('return p._filters.category') == 'banmian'
  72. scope = action('return p._data.scope')
  73. assert (scope['products'], scope['reviewRows'], scope['distinctReadablePairs'], scope['eligibleRows']) == (837, 693, 429, 49), scope
  74. report['scope'] = scope
  75. check('default-banmian-real-scope-and-independent-quality-counts')
  76. assert action('return !p.renderRoot.querySelector("details.maintenance").open')
  77. assert action('return p.renderRoot.querySelectorAll(".finding-card").length') == 3
  78. check('innovation-first-maintenance-collapsed')
  79. ev('new Promise(r=>setTimeout(r,1000))')
  80. report['representativeImages'] = action('return [...p.renderRoot.querySelectorAll("library-product-image")].filter(x=>x.image?.thumbnail).length')
  81. assert report['representativeImages'] > 0
  82. shot('innovation-1440.png')
  83. for index, target in enumerate(['directions', 'supply', 'voice', 'prices']):
  84. action('p.renderRoot.querySelectorAll(".insight-jumps button")[' + str(index) + '].click();return true')
  85. top = action('return p.renderRoot.querySelector("#insight-' + target + '").getBoundingClientRect().top')
  86. assert 0 <= top <= 40, (target, top)
  87. check('section-navigation-scrolls-to-the-actual-analysis-sections')
  88. assert action('return !p.renderRoot.querySelector("[data-export]") && !!p.renderRoot.querySelector(".p-head [data-copy-agent]")')
  89. action('p.renderRoot.querySelector("[data-select-direction=seasoning]").click();return true')
  90. assert action('return p._discussionIds.length') == 1
  91. call('Page.reload')
  92. ready()
  93. assert action('return p._discussionIds.length') == 1
  94. check('discussion-restores-on-refresh-within-the-same-scope-and-version')
  95. action('p.renderRoot.querySelector("[data-copy-agent]").click();return true')
  96. copied = ev('navigator.clipboard.readText()').replace('\r\n', '\n')
  97. assert all(x in copied for x in ('研究范围', '判断标准', '待核条件', '原文依据', '资料版本', 'ksf-voc-library', 'source_record_id'))
  98. assert copied == action('return p._prepareInsights()')
  99. (out / 'copied-agent-analysis.txt').write_text(copied, encoding='utf-8')
  100. check('top-right-real-clipboard-copy-has-scope-complete-record-ids-and-database-instructions')
  101. action('p.renderRoot.querySelector("[data-evidence=seasoning]").click();return true')
  102. ev('new Promise(r=>setTimeout(r,80))'); ready()
  103. assert action('return p._data.evidence.theme') == 'seasoning'
  104. assert action('return p.renderRoot.querySelectorAll(".evidence-item").length') == 12
  105. assert action('return p.renderRoot.activeElement?.id') == 'insight-evidence'
  106. shot('innovation-evidence-1440.png')
  107. action('p.renderRoot.querySelector(".evidence-panel .task-paging button:last-child").click();return true')
  108. ev('new Promise(r=>setTimeout(r,80))'); ready()
  109. assert action('return p._data.evidence.page') == 2
  110. action('p.renderRoot.querySelector("[data-close-evidence]").click();return true')
  111. ev('new Promise(r=>setTimeout(r,80))'); ready()
  112. assert action('return p._discussionIds.length') == 1
  113. check('real-theme-evidence-pagination-and-keyboard-focus')
  114. action('p._scrollInsight("directions");p.renderRoot.querySelector("[data-insight-focus=value]").click();return true')
  115. ev('new Promise(r=>setTimeout(r,80))'); ready()
  116. assert action('return p._data.themes.every(t=>t.id==="value")')
  117. assert action('return p._data.scope.products') == 837
  118. assert action('return p._discussionIds.length') == 1
  119. assert 0 <= action('return p.renderRoot.querySelector("#insight-directions").getBoundingClientRect().top') < 100
  120. action('p.renderRoot.querySelector("[data-select-direction=value]").click();return true')
  121. assert action('return p._discussionIds.length') == 2
  122. action('p.renderRoot.querySelector("[data-copy-agent]").click();return true')
  123. copied = ev('navigator.clipboard.readText()').replace('\r\n', '\n')
  124. assert '已选2个方向' in copied
  125. assert '保留风味,让调味更容易按偏好控制' in copied and '把价格接受与实际体验价值一起验证' in copied
  126. (out / 'copied-two-directions.txt').write_text(copied, encoding='utf-8')
  127. check('visual-focus-keeps-scope-scroll-position-and-multiple-selected-directions')
  128. action('p.renderRoot.querySelector(".copy-menu>summary").click();const s=p.renderRoot.querySelector("[data-copy-mode]");s.value="current";s.dispatchEvent(new Event("change"));return true')
  129. action('p.renderRoot.querySelector("[data-preview-agent]").click();return true')
  130. ev('new Promise(r=>requestAnimationFrame(r))')
  131. assert action('return p.renderRoot.querySelector(".agent-copy-dialog").open')
  132. assert action('return p.renderRoot.querySelector("[data-agent-text]").value===p._prepareInsights()')
  133. assert action('return !p.renderRoot.querySelector("[data-agent-text]").value.includes("方向1:保留风味")')
  134. shot('agent-copy-preview-1440.png')
  135. call('Input.dispatchKeyEvent', {'type': 'rawKeyDown', 'key': 'Escape', 'code': 'Escape', 'windowsVirtualKeyCode': 27, 'nativeVirtualKeyCode': 27})
  136. call('Input.dispatchKeyEvent', {'type': 'keyUp', 'key': 'Escape', 'code': 'Escape', 'windowsVirtualKeyCode': 27, 'nativeVirtualKeyCode': 27})
  137. ev('new Promise(r=>setTimeout(r,80))')
  138. assert action('return !p.renderRoot.querySelector(".agent-copy-dialog").open')
  139. check('copy-scope-preview-and-native-keyboard-dialog')
  140. call('Browser.grantPermissions', {'origin': args.url, 'permissions': []})
  141. assert ev('navigator.permissions.query({name:"clipboard-write"}).then(p=>p.state)') == 'denied'
  142. action('p.renderRoot.querySelector("[data-copy-agent]").click();return true')
  143. ev('new Promise((resolve,reject)=>{const end=Date.now()+5000;function tick(){const p=' + view + ';if(p.renderRoot.querySelector(".agent-copy-dialog").open){resolve(true);return}if(Date.now()>end)reject(Error("clipboard fallback timeout"));else setTimeout(tick,50)}tick()})')
  144. assert action('return p.renderRoot.querySelector(".agent-copy-dialog").open')
  145. assert action('const t=p.renderRoot.querySelector("[data-agent-text]");return t.selectionEnd-t.selectionStart===t.value.length')
  146. action('p.renderRoot.querySelector(".agent-copy-dialog").close();return true')
  147. call('Browser.grantPermissions', {'origin': args.url, 'permissions': ['clipboardReadWrite', 'clipboardSanitizedWrite']})
  148. check('clipboard-denial-opens-selected-text-for-manual-copy')
  149. action('const s=p.renderRoot.querySelector("[data-copy-mode]");s.value="all";s.dispatchEvent(new Event("change"));return true')
  150. ev('new Promise(r=>requestAnimationFrame(r))')
  151. assert action('return p._prepareInsights().includes("当前范围全部方向")')
  152. assert action('return p._discussionIds.length') == 2
  153. check('all-directions-copy-scope-keeps-existing-selection')
  154. change('category', 'soup_noodle')
  155. assert action('return p._discussionIds.length') == 0
  156. assert action('return p._data.scope.products') == 508
  157. change('brand', 'ksf')
  158. assert action('return p._data.brands.every(b=>b.brand==="ksf")')
  159. change('platform', 'jd')
  160. change('shopId', '1000084104')
  161. assert action('return p._data.representativeProducts.every(r=>r.productId.startsWith("jd:"))')
  162. change('platform', 'tmall')
  163. assert action('return !p.routeQuery.shopId')
  164. check('category-brand-platform-shop-propagate-without-cross-scope-evidence')
  165. navigate('/industry?category=banmian&brand=samyang')
  166. assert action('return p._data.scope.products') == 90
  167. assert action('return p._data.scope.reviewRows') == 0
  168. assert action('return p._data.opportunities.length') == 0
  169. check('real-no-reviews-brand-never-invents-innovation-hypotheses')
  170. navigate('/industry?category=banmian&brand=not-in-library')
  171. assert action('return p._data.scope.products') == 0
  172. assert action('return !!p.renderRoot.querySelector(".insight-empty")')
  173. check('empty-scope-not-confused-with-no-market-demand')
  174. navigate('/industry?category=banmian')
  175. action('const b=p.renderRoot.querySelectorAll(".insight-products button");b[0].click();b[1].click();return true')
  176. assert action('return p._compareIds.length') == 2
  177. action('p.renderRoot.querySelector(".section-title.compact a").click();return true')
  178. ev('new Promise((resolve,reject)=>{let end=Date.now()+20000;function tick(){const p=document.querySelector("kf-app")?.renderRoot?.querySelector("view-library");const c=p?.renderRoot?.querySelector("library-product-compare");if(c && !c._loading && c._items?.length===2 && c._items.every(x=>x.detail)){resolve(true);return}if(Date.now()>end)reject(Error("comparison timeout"));else setTimeout(tick,80)}tick()})')
  179. check('representative-products-open-existing-real-comparison')
  180. navigate('/industry?category=banmian&insightTheme=seasoning')
  181. for width in [1440, 1024, 768, 390, 320]:
  182. call('Emulation.setDeviceMetricsOverride', {'width': width, 'height': 1050, 'deviceScaleFactor': 1, 'mobile': False})
  183. ev('window.scrollTo(0,0);new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(r)))')
  184. metrics = ev('({requested:' + str(width) + ',innerWidth,clientWidth:document.documentElement.clientWidth,scrollWidth:document.documentElement.scrollWidth})')
  185. assert metrics['requested'] == metrics['innerWidth']
  186. assert metrics['clientWidth'] == metrics['scrollWidth'], metrics
  187. report['layouts'].append(metrics)
  188. if width in (1440, 390, 320):
  189. shot('innovation-' + str(width) + '.png')
  190. action('p._scrollInsight("directions");return true')
  191. shot('innovation-directions-' + str(width) + '.png')
  192. if width in (1440, 390, 320):
  193. action('p._scrollInsight("supply");return true')
  194. shot('innovation-supply-' + str(width) + '.png')
  195. action('p.renderRoot.querySelector(".insight-products").scrollIntoView({block:"start"});return true')
  196. ev('new Promise(r=>setTimeout(r,350))')
  197. decoded = action('return [...p.renderRoot.querySelectorAll("library-product-image")].filter(x=>{const i=x.renderRoot.querySelector("img");return i?.complete && i.naturalWidth>0}).length')
  198. assert decoded == report['representativeImages'], (width, decoded)
  199. shot('innovation-products-' + str(width) + '.png')
  200. action('p._scrollInsight("prices");return true')
  201. shot('innovation-prices-' + str(width) + '.png')
  202. if width <= 390:
  203. assert action('return [...p.renderRoot.querySelectorAll(".price-table td")].every(x=>x.scrollWidth<=x.clientWidth)'), width
  204. if width == 390:
  205. action('p._scrollInsight("evidence");return true')
  206. shot('innovation-evidence-390.png')
  207. check('five-exact-viewports-with-no-page-horizontal-overflow')
  208. for tab in ['overview', 'listings', 'reviews', 'policies', 'rankings', 'plan', 'contract']:
  209. call('Page.navigate', {'url': args.url + '/industry?tab=' + tab})
  210. ready(tab)
  211. check('seven-existing-research-and-maintenance-tabs-retained')
  212. call('Page.navigate', {'url': args.url + '/industry?insightTheme=seasoning&insightPage=99999'})
  213. ev('new Promise((resolve,reject)=>{let end=Date.now()+20000;function tick(){const p=' + view + ';if(p?._error){resolve(true);return}if(Date.now()>end)reject(Error("error-state timeout"));else setTimeout(tick,70)}tick()})')
  214. action('p.renderRoot.querySelector("[data-reset-insights]").click();return true')
  215. ev('new Promise(r=>setTimeout(r,80))'); ready()
  216. check('invalid-evidence-page-shows-recoverable-error-not-zero')
  217. report['consoleExceptions'] = [e for e in events if e['method'] == 'Runtime.exceptionThrown']
  218. assert not report['consoleExceptions'], report['consoleExceptions']
  219. report['ok'] = True
  220. except Exception:
  221. if ws:
  222. report['failureState'] = action('return {url:location.href,tab:p?._tab,query:p?.routeQuery,loading:p?._loading,error:p?._error,keys:Object.keys(p?._data||{})}')
  223. shot('failure.png')
  224. raise
  225. finally:
  226. report['consoleExceptions'] = [e for e in events if e['method'] == 'Runtime.exceptionThrown']
  227. if ws: ws.close()
  228. browser.terminate()
  229. try: browser.wait(timeout=10)
  230. except subprocess.TimeoutExpired: browser.kill(); browser.wait(timeout=10)
  231. (out / 'verification.json').write_text(json.dumps(report, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
  232. print(json.dumps(report, ensure_ascii=False, indent=2))
  233. if __name__ == '__main__': main()