verify-industry-innovation.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256
  1. """Real-browser acceptance for evidence-backed product innovation, 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/industry-innovation-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=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-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));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 ('研究范围', '资料版本', 'source_record_id', '"entity":"reviews"'))
  98. assert not any(x in copied for x in ('员工','请先','请返回','ksf-voc-library','判断标准','查库要求'))
  99. assert copied == action('return p._prepareInsights()')
  100. (out / 'copied-agent-analysis.txt').write_text(copied, encoding='utf-8')
  101. check('top-right-real-clipboard-copy-contains-data-locators-without-prompts')
  102. action('p.renderRoot.querySelector("[data-evidence=seasoning]").click();return true')
  103. ev('new Promise(r=>setTimeout(r,80))'); ready()
  104. assert action('return p._data.evidence.theme') == 'seasoning'
  105. assert action('return p.renderRoot.querySelectorAll(".evidence-item").length') == 12
  106. assert action('return p.renderRoot.activeElement?.id') == 'insight-evidence'
  107. shot('innovation-evidence-1440.png')
  108. action('p.renderRoot.querySelector(".evidence-panel .task-paging button:last-child").click();return true')
  109. ev('new Promise(r=>setTimeout(r,80))'); ready()
  110. assert action('return p._data.evidence.page') == 2
  111. action('p.renderRoot.querySelector("[data-close-evidence]").click();return true')
  112. ev('new Promise(r=>setTimeout(r,80))'); ready()
  113. assert action('return p._discussionIds.length') == 1
  114. check('real-theme-evidence-pagination-and-keyboard-focus')
  115. action('p._scrollInsight("directions");p.renderRoot.querySelector("[data-insight-focus=value]").click();return true')
  116. ev('new Promise(r=>setTimeout(r,80))'); ready()
  117. assert action('return p._data.themes.every(t=>t.id==="value")')
  118. assert action('return p._data.scope.products') == 837
  119. assert action('return p._discussionIds.length') == 1
  120. assert 0 <= action('return p.renderRoot.querySelector("#insight-directions").getBoundingClientRect().top') < 100
  121. action('p.renderRoot.querySelector("[data-select-direction=value]").click();return true')
  122. assert action('return p._discussionIds.length') == 2
  123. action('p.renderRoot.querySelector("[data-copy-agent]").click();return true')
  124. copied = ev('navigator.clipboard.readText()').replace('\r\n', '\n')
  125. assert '已选2个方向' in copied
  126. assert '保留风味,让调味更容易按偏好控制' in copied and '把价格接受与实际体验价值一起验证' in copied
  127. (out / 'copied-two-directions.txt').write_text(copied, encoding='utf-8')
  128. call('Page.reload'); ready()
  129. assert action('return p._discussionIds.length') == 2
  130. check('visual-focus-keeps-scope-scroll-position-and-multiple-selected-directions')
  131. 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')
  132. action('p.renderRoot.querySelector("[data-preview-agent]").click();return true')
  133. ev('new Promise(r=>requestAnimationFrame(r))')
  134. assert action('return p.renderRoot.querySelector(".agent-copy-dialog").open')
  135. assert action('return p.renderRoot.querySelector("[data-agent-text]").value===p._prepareInsights()')
  136. assert action('return !p.renderRoot.querySelector("[data-agent-text]").value.includes("方向1:保留风味")')
  137. shot('agent-copy-preview-1440.png')
  138. call('Input.dispatchKeyEvent', {'type': 'rawKeyDown', 'key': 'Escape', 'code': 'Escape', 'windowsVirtualKeyCode': 27, 'nativeVirtualKeyCode': 27})
  139. call('Input.dispatchKeyEvent', {'type': 'keyUp', 'key': 'Escape', 'code': 'Escape', 'windowsVirtualKeyCode': 27, 'nativeVirtualKeyCode': 27})
  140. ev('new Promise(r=>setTimeout(r,80))')
  141. assert action('return !p.renderRoot.querySelector(".agent-copy-dialog").open')
  142. check('copy-scope-preview-and-native-keyboard-dialog')
  143. call('Browser.resetPermissions')
  144. for unsanitized in (False, True):
  145. call('Browser.setPermission', {'origin': args.url, 'permission': {'name': 'clipboard-write', 'allowWithoutSanitization': unsanitized}, 'setting': 'denied'})
  146. action('p.renderRoot.querySelector("[data-copy-agent]").click();return true')
  147. ev('new Promise(r=>setTimeout(r,150))')
  148. assert action('return p.renderRoot.querySelector(".agent-copy-dialog").open')
  149. assert action('const t=p.renderRoot.querySelector("[data-agent-text]");return t.selectionEnd-t.selectionStart===t.value.length')
  150. action('p.renderRoot.querySelector(".agent-copy-dialog").close();return true')
  151. call('Browser.grantPermissions', {'origin': args.url, 'permissions': ['clipboardReadWrite', 'clipboardSanitizedWrite']})
  152. check('clipboard-denial-opens-selected-text-for-manual-copy')
  153. change('category', 'soup_noodle')
  154. assert action('return p._discussionIds.length') == 0
  155. assert action('return p._data.scope.products') == 508
  156. change('brand', 'ksf')
  157. assert action('return p._data.brands.every(b=>b.brand==="ksf")')
  158. change('platform', 'jd')
  159. change('shopId', '1000084104')
  160. assert action('return p._data.representativeProducts.every(r=>r.productId.startsWith("jd:"))')
  161. change('platform', 'tmall')
  162. assert action('return !p.routeQuery.shopId')
  163. check('category-brand-platform-shop-propagate-without-cross-scope-evidence')
  164. navigate('/industry?category=banmian&brand=samyang')
  165. assert action('return p._data.scope.products') == 90
  166. assert action('return p._data.scope.reviewRows') == 0
  167. assert action('return p._data.opportunities.length') == 0
  168. check('real-no-reviews-brand-never-invents-innovation-hypotheses')
  169. navigate('/industry?category=banmian&brand=not-in-library')
  170. assert action('return p._data.scope.products') == 0
  171. assert action('return !!p.renderRoot.querySelector(".insight-empty")')
  172. check('empty-scope-not-confused-with-no-market-demand')
  173. navigate('/industry?category=banmian')
  174. action('const b=p.renderRoot.querySelectorAll(".insight-products button");b[0].click();b[1].click();return true')
  175. assert action('return p._materialProductIds.length') == 2
  176. action('p.renderRoot.querySelector(".section-title.compact a").click();return true')
  177. ev('new Promise((resolve,reject)=>{let end=Date.now()+20000;function tick(){const p=document.querySelector("kf-app")?.renderRoot?.querySelector("view-library");if(p && !p._loading && p._data && p._selected.length===2 && !p.renderRoot.querySelector("library-product-compare")){resolve(true);return}if(Date.now()>end)reject(Error("material selection timeout"));else setTimeout(tick,80)}tick()})')
  178. check('representative-products-transfer-to-exact-version-library-material-selection')
  179. navigate('/industry?category=banmian&insightTheme=seasoning')
  180. for width in [1440, 1024, 768, 390, 320]:
  181. call('Emulation.setDeviceMetricsOverride', {'width': width, 'height': 1050, 'deviceScaleFactor': 1, 'mobile': False})
  182. ev('window.scrollTo(0,0);new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(r)))')
  183. metrics = ev('({requested:' + str(width) + ',innerWidth,clientWidth:document.documentElement.clientWidth,scrollWidth:document.documentElement.scrollWidth})')
  184. assert metrics['requested'] == metrics['innerWidth']
  185. assert metrics['clientWidth'] == metrics['scrollWidth'], metrics
  186. report['layouts'].append(metrics)
  187. if width in (1440, 390, 320):
  188. shot('innovation-' + str(width) + '.png')
  189. action('p._scrollInsight("directions");return true')
  190. shot('innovation-directions-' + str(width) + '.png')
  191. if width in (1440, 390, 320):
  192. action('p._scrollInsight("supply");return true')
  193. shot('innovation-supply-' + str(width) + '.png')
  194. action('p.renderRoot.querySelector(".insight-products").scrollIntoView({block:"start"});return true')
  195. ev('new Promise(r=>setTimeout(r,350))')
  196. decoded = action('return [...p.renderRoot.querySelectorAll("library-product-image")].filter(x=>{const i=x.renderRoot.querySelector("img");return i?.complete && i.naturalWidth>0}).length')
  197. assert decoded == report['representativeImages'], (width, decoded)
  198. shot('innovation-products-' + str(width) + '.png')
  199. action('p._scrollInsight("prices");return true')
  200. shot('innovation-prices-' + str(width) + '.png')
  201. if width <= 390:
  202. assert action('return [...p.renderRoot.querySelectorAll(".price-table td")].every(x=>x.scrollWidth<=x.clientWidth)'), width
  203. if width == 390:
  204. action('p._scrollInsight("evidence");return true')
  205. shot('innovation-evidence-390.png')
  206. check('five-exact-viewports-with-no-page-horizontal-overflow')
  207. for width, height in [(390, 844), (320, 568)]:
  208. call('Emulation.setDeviceMetricsOverride', {'width': width, 'height': height, 'deviceScaleFactor': 1, 'mobile': False})
  209. action('p.renderRoot.querySelector(".copy-menu>summary").click();return true')
  210. box = action('const r=p.renderRoot.querySelector(".copy-options").getBoundingClientRect();return {left:r.left,right:r.right}')
  211. assert box['left'] >= 0 and box['right'] <= width, box
  212. action('p.renderRoot.querySelector("[data-preview-agent]").click();return true')
  213. ev('new Promise(r=>requestAnimationFrame(r))')
  214. metrics = action('const d=p.renderRoot.querySelector(".agent-copy-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}')
  215. assert 0 <= metrics['left'] and metrics['right'] <= width
  216. assert 0 <= metrics['top'] and metrics['bottom'] <= height
  217. assert 0 <= metrics['buttonTop'] and metrics['buttonBottom'] <= height
  218. assert metrics['scrollWidth'] == metrics['clientWidth']
  219. shot('agent-copy-preview-' + str(width) + '.png')
  220. action('p.renderRoot.querySelector(".agent-copy-dialog").close();return true')
  221. check('phone-copy-options-preview-and-copy-button-fit-actual-viewport-heights')
  222. for tab in ['overview', 'listings', 'reviews', 'policies', 'rankings', 'plan', 'contract']:
  223. call('Page.navigate', {'url': args.url + '/industry?tab=' + tab})
  224. ready(tab)
  225. check('seven-existing-research-and-maintenance-tabs-retained')
  226. call('Page.navigate', {'url': args.url + '/industry?insightTheme=seasoning&insightPage=99999'})
  227. 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()})')
  228. action('p.renderRoot.querySelector("[data-reset-insights]").click();return true')
  229. ev('new Promise(r=>setTimeout(r,80))'); ready()
  230. check('invalid-evidence-page-shows-recoverable-error-not-zero')
  231. report['consoleExceptions'] = [e for e in events if e['method'] == 'Runtime.exceptionThrown']
  232. assert not report['consoleExceptions'], report['consoleExceptions']
  233. report['ok'] = True
  234. except Exception:
  235. if ws:
  236. report['failureState'] = action('return {url:location.href,tab:p?._tab,query:p?.routeQuery,loading:p?._loading,error:p?._error,keys:Object.keys(p?._data||{})}')
  237. shot('failure.png')
  238. raise
  239. finally:
  240. if ws: ws.close()
  241. browser.terminate()
  242. try: browser.wait(timeout=10)
  243. except subprocess.TimeoutExpired: browser.kill(); browser.wait(timeout=10)
  244. (out / 'verification.json').write_text(json.dumps(report, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
  245. print(json.dumps(report, ensure_ascii=False, indent=2))
  246. if __name__ == '__main__': main()