verify-industry-workshop.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275
  1. """Real-browser acceptance for workshop product analysis, actual filters and original evidence."""
  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 sys
  9. import tempfile
  10. import time
  11. from urllib.request import Request, urlopen
  12. import websocket
  13. ROOT = Path(__file__).resolve().parents[1]
  14. def main():
  15. parser = argparse.ArgumentParser(description=__doc__)
  16. parser.add_argument('--url', default='http://127.0.0.1:4183')
  17. parser.add_argument('--out', default='work/industry-workshop-20261107-r1/browser')
  18. args = parser.parse_args()
  19. out = (ROOT / args.out).resolve()
  20. out.mkdir(parents=True, exist_ok=True)
  21. profile = Path(tempfile.mkdtemp(prefix='innovation-edge-', dir=ROOT / 'work'))
  22. browser = subprocess.Popen(['C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe', '--headless=new',
  23. '--disable-gpu', '--disable-background-networking', '--disable-component-update', '--disable-extensions',
  24. '--no-first-run', '--remote-debugging-port=0', '--user-data-dir=' + str(profile), 'about:blank'],
  25. stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, creationflags=subprocess.CREATE_NO_WINDOW)
  26. serial, ws, events = 0, None, []
  27. report = {'ok': False, 'checkedAt': datetime.now(timezone.utc).isoformat(), 'url': args.url,
  28. 'checks': [], 'layouts': [], 'responseOverrides': 0, 'paidCalls': 0, 'productionWrites': 0}
  29. try:
  30. end = time.monotonic() + 20
  31. while not (profile / 'DevToolsActivePort').exists():
  32. assert time.monotonic() < end and browser.poll() is None
  33. time.sleep(.1)
  34. port = (profile / 'DevToolsActivePort').read_text().splitlines()[0]
  35. page = next(p for p in json.load(urlopen('http://127.0.0.1:' + port + '/json/list')) if p['type'] == 'page')
  36. ws = websocket.create_connection(page['webSocketDebuggerUrl'], timeout=45, suppress_origin=True, http_no_proxy=['127.0.0.1'])
  37. def call(method, params=None):
  38. nonlocal serial
  39. serial += 1
  40. ws.send(json.dumps({'id': serial, 'method': method, 'params': params or {}}))
  41. while True:
  42. d = json.loads(ws.recv())
  43. if d.get('id') == serial:
  44. assert 'error' not in d, d
  45. return d.get('result', {})
  46. events.append(d)
  47. def ev(code):
  48. d = call('Runtime.evaluate', {'expression': code, 'returnByValue': True, 'awaitPromise': True})
  49. assert 'exceptionDetails' not in d, d
  50. return d['result'].get('value')
  51. view = 'document.querySelector("kf-app")?.renderRoot?.querySelector("view-industry")'
  52. def action(code): return ev('(()=>{const p=' + view + ';' + code + '})()')
  53. def ready(tab='insights'):
  54. ev('new Promise((resolve,reject)=>{let end=Date.now()+30000;function tick(){const p=' + view +
  55. ';if(p && p._tab===' + json.dumps(tab) + ' && !p._loading && p._data){p.updateComplete.then(()=>resolve(true));return}'
  56. 'if(p?._error)reject(Error(p._error));else if(Date.now()>end)reject(Error("timeout"));else setTimeout(tick,70)}tick()})')
  57. def navigate(path, tab='insights'):
  58. call('Page.navigate', {'url': args.url + path})
  59. ready(tab)
  60. def change(field, value):
  61. action('const s=p.renderRoot.querySelector(' + json.dumps('select[data-filter=' + field + ']') + ');s.value=' + json.dumps(value) + ';s.dispatchEvent(new Event("change"));return true')
  62. ev('new Promise(r=>setTimeout(r,80))')
  63. ready()
  64. def shot(name):
  65. (out / name).write_bytes(base64.b64decode(call('Page.captureScreenshot', {'format': 'png', 'captureBeyondViewport': False})['data']))
  66. def check(name):
  67. report['checks'].append(name)
  68. (out/'in-progress.json').write_text(json.dumps(report,ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
  69. print('PASS '+name,flush=True)
  70. def settle(): ev('new Promise(r=>setTimeout(r,120))')
  71. def click(selector, reload=True):
  72. point=action('const b=p.renderRoot.querySelector('+json.dumps(selector)+');if(!b)throw Error("missing selector");b.scrollIntoView({block:"center"});const r=b.getBoundingClientRect();return {x:r.x+r.width/2,y:r.y+r.height/2}')
  73. for kind in ['mousePressed','mouseReleased']:
  74. call('Input.dispatchMouseEvent',{'type':kind,**point,'button':'left','clickCount':1})
  75. settle()
  76. if reload:ready()
  77. def data():return action('return p._data')
  78. def dimensions():return action('return p._data.choices')
  79. def search(text):
  80. action('p.renderRoot.querySelector("[data-w-search]").value='+json.dumps(text)+';p.renderRoot.querySelector(".ws-product-search").requestSubmit();return true')
  81. settle();ready()
  82. def layout(section,width,height):
  83. action('p.renderRoot.querySelector("#workshop-'+section+'").scrollIntoView({block:"start"});return true');settle()
  84. metrics=ev('({innerWidth,clientWidth:document.documentElement.clientWidth,scrollWidth:document.documentElement.scrollWidth})')
  85. assert metrics['innerWidth']==width,metrics
  86. assert metrics['scrollWidth']<=metrics['clientWidth'],metrics
  87. bad=action('return [...p.renderRoot.querySelectorAll(".workshop-analysis *")].filter(e=>{const r=e.getBoundingClientRect();return r.width>0&&r.height>0&&(r.left < -1 || r.right>document.documentElement.clientWidth+1)}).slice(0,8).map(e=>({tag:e.tagName,cls:e.className,text:e.textContent.slice(0,80)}))')
  88. assert not bad,bad
  89. report['layouts'].append({'section':section,'width':width,'height':height,**metrics,'outOfBounds':len(bad)})
  90. # Independent readback of snapshot identifiers, raw values and provenance.
  91. snapshot=json.loads((ROOT/'work/industry-monitoring-20261107-r1/snapshot.json').read_text(encoding='utf-8'))
  92. entities=snapshot['entities'];products={p['record_id']:p for p in entities['products']};reviews={r['record_id']:r for r in entities['reviews']}
  93. sys.path.insert(0,str(ROOT/'server/voc-library'))
  94. from industry_research import analysis_eligible
  95. def verify_scope(result, selected):
  96. selected=set(selected);rs=[r for r in reviews.values() if r['product_id'] in selected]
  97. s=result['scope']
  98. assert s['products']==len(selected)
  99. assert s['reviewRows']==len(rs)
  100. assert s['reviewedProducts']==len({r['product_id'] for r in rs})
  101. assert sum(b['products'] for b in result['brands'])==len(selected)
  102. assert sum(b['reviewRows'] for b in result['brands'])==len(rs)
  103. assert s['eligibleRows']==sum(analysis_eligible(r,True) for r in rs)
  104. for p in result['products']['rows']:
  105. assert p['productId'] in selected and p['title']==products[p['productId']]['title']
  106. assert p['reviewRows']==sum(r['product_id']==p['productId'] for r in rs)
  107. for r in result['reviewEvidence']['rows']:
  108. original=reviews[r['reviewId']]
  109. assert r['productId'] in selected and r['productId']==original['product_id']
  110. assert r['content']==original.get('content','') and r['appendContent']==original.get('append_content','')
  111. assert r['sourceRecordId']==original.get('source_record_id')
  112. assert r['analysisEligible']==analysis_eligible(original,True)
  113. for group in [*result['motives'],*result['audiences'],*result['scenarios']]:
  114. for r in group['examples']:
  115. original=reviews[r['reviewId']]
  116. for hit in r['hits']:
  117. assert hit['quote'] in original.get('content' if hit['field']=='main' else 'append_content','')
  118. return result
  119. call('Runtime.enable');call('Page.enable');call('Network.enable')
  120. call('Emulation.setFocusEmulationEnabled',{'enabled':True})
  121. call('Emulation.setDeviceMetricsOverride',{'width':1440,'height':1000,'deviceScaleFactor':1,'mobile':False})
  122. navigate('/industry')
  123. settle()
  124. base={pid for pid,p in products.items() if p['category']=='banmian'}
  125. baseline=verify_scope(data(),base);report['scope']=baseline['scope']
  126. assert baseline['generation']==snapshot['generation']
  127. assert action('return p.renderRoot.querySelectorAll(".ws-product").length')==12
  128. assert action('return p.renderRoot.querySelectorAll(".ws-review").length')==8
  129. requests=[json.loads(e['params']['request']['postData']) for e in events if e['method']=='Network.requestWillBeSent' and '/api/industry' in e['params']['request']['url'] and e['params']['request'].get('postData')]
  130. assert any(r['op']=='workshop' and r['generation']==snapshot['generation'] for r in requests)
  131. check('live-workshop-request-matches-independent-snapshot-products-raw-review-counts-identities-text-and-sources')
  132. click('[data-w-products-next]');second=data()
  133. assert second['products']['page']==2 and second['scope']==baseline['scope']
  134. assert abs(action('return p.renderRoot.querySelector("#workshop-products").getBoundingClientRect().top')-18)<3
  135. assert not set(p['productId'] for p in second['products']['rows']) & set(p['productId'] for p in baseline['products']['rows'])
  136. click('[data-w-products-prev]')
  137. click('[data-w-reviews-next]');second=data()
  138. assert second['reviewEvidence']['page']==2 and second['scope']==baseline['scope']
  139. assert not set(r['reviewId'] for r in second['reviewEvidence']['rows']) & set(r['reviewId'] for r in baseline['reviewEvidence']['rows'])
  140. click('[data-w-reviews-prev]')
  141. check('products-and-deduplicated-reviews-paginate-independently-with-unchanged-aggregate-scope')
  142. click('[data-w-choice="format:bag"]')
  143. import re
  144. bag={pid for pid in base if re.search('袋|连包',products[pid]['title'])}
  145. verify_scope(data(),bag)
  146. click('[data-w-choice="format:bowl"]')
  147. packaging={pid for pid in base if re.search('袋|连包|碗|桶',products[pid]['title'])}
  148. verify_scope(data(),packaging)
  149. assert dimensions()['format']==['bag','bowl']
  150. click('[data-w-choice="flavor:spicy"]')
  151. combined={pid for pid in packaging if re.search('麻辣|香辣|火鸡|超辣|小龙虾|红油',products[pid]['title'])}
  152. verify_scope(data(),combined)
  153. call('Page.reload');ready();settle()
  154. assert dimensions()['format']==['bag','bowl'] and dimensions()['flavor']==['spicy']
  155. check('packaging-and-flavor-really-filter-all-statistics-with-or-within-groups-and-and-across-groups-and-survive-refresh')
  156. click('[data-w-choice="format:all"]');click('[data-w-choice="flavor:all"]')
  157. action('p.renderRoot.querySelector(".ws-more-filters").open=true;return true')
  158. click('[data-w-choice="audience:office"]')
  159. office=data();assert 0<office['scope']['products']<len(base)
  160. for p in office['products']['rows']:
  161. assert p['matchEvidence']['audience']
  162. for hit in p['matchEvidence']['audience']:
  163. assert hit['quote'] in reviews[hit['reviewId']].get('content','')+reviews[hit['reviewId']].get('append_content','')
  164. click('[data-w-choice="scenario:lunch"]')
  165. assert 0<data()['scope']['products']<=office['scope']['products']
  166. assert all('audience' in p['matchEvidence'] and 'scenario' in p['matchEvidence'] for p in data()['products']['rows'])
  167. check('audience-and-scenario-restrict-products-by-exact-source-mentions-and-show-matching-evidence')
  168. click('[data-w-reset]');verify_scope(data(),base)
  169. change('category','all');assert data()['scope']['products']==len(products)
  170. change('category','banmian');change('brand','ksf')
  171. ksf={pid for pid in base if products[pid]['brand']=='ksf'};verify_scope(data(),ksf)
  172. change('shopId','1000084104')
  173. assert action('return p._filters.platform')=='jd'
  174. primary={pid for pid in ksf if products[pid]['shop_id']=='1000084104' and products[pid]['platform']=='jd'}
  175. verify_scope(data(),primary)
  176. change('platform','tmall');assert not action('return p.routeQuery.shopId')
  177. verify_scope(data(),{pid for pid in ksf if products[pid]['platform']=='tmall'})
  178. check('category-platform-brand-and-shop-selects-update-data-and-shop-automatically-binds-its-platform')
  179. click('[data-w-reset]');search('jd:100000348106')
  180. one=verify_scope(data(),['jd:100000348106']);assert one['products']['total']==1
  181. search('根本不存在的商品XYZ');empty_data=data()
  182. assert empty_data['scope']['products']==0 and empty_data['scope']['reviewRows']==0
  183. assert action('return p.renderRoot.querySelector(".ws-empty").textContent.includes("没有匹配商品")')
  184. assert not action('return p.renderRoot.querySelector("#workshop-opportunities")')
  185. click('[data-w-reset]')
  186. check('keyword-search-constrains-aggregate-products-and-reviews-and-empty-scope-does-not-invent-findings')
  187. pid=data()['products']['rows'][0]['productId']
  188. click('[data-w-product-reviews="'+pid+'"]')
  189. assert data()['scope']==baseline['scope']
  190. assert all(r['productId']==pid for r in data()['reviewEvidence']['rows'])
  191. click('.ws-evidence-controls>button')
  192. click('[data-w-pain="seasoning"]')
  193. assert data()['scope']==baseline['scope'] and data()['reviewEvidence']['total']>0
  194. assert action('return p.renderRoot.querySelector("[data-w-review-theme]").value')=='seasoning'
  195. assert action('return p.renderRoot.querySelector("[data-w-review-kind]").value')=='problem'
  196. assert all(any(t['id']=='seasoning' and any(h['kind']=='problem' for h in t['hits']) for t in r['themes']) for r in data()['reviewEvidence']['rows'])
  197. click('[data-w-opportunity="seasoning"]')
  198. assert action('return p.routeQuery.w_theme')=='seasoning' and not action('return p.routeQuery.w_kind')
  199. ev('history.back()');settle();ready();assert action('return p.routeQuery.w_kind')=='problem'
  200. ev('history.forward()');settle();ready();assert not action('return p.routeQuery.w_kind')
  201. check('product-pain-and-opportunity-buttons-open-their-own-original-comments-without-changing-summary-and-history-restores-them')
  202. call('Browser.grantPermissions',{'origin':args.url,'permissions':['clipboardReadWrite','clipboardSanitizedWrite']})
  203. click('[data-copy-agent]',False)
  204. text=ev('navigator.clipboard.readText()')
  205. assert snapshot['generation'] in text and '原文查看限定' in text and 'seasoning' in text
  206. refs=[json.loads(line) for line in text.splitlines() if line.startswith('{') and '"database"' in line]
  207. assert any(r['entity']=='products' for r in refs) and any(r['entity']=='reviews' for r in refs)
  208. for r in refs:
  209. assert any(row['record_id']==r['id'] for row in entities[r['entity']]),r
  210. report['copy']={'bytes':len(text.encode()),'references':len(refs),'exactReferencesVerified':len(refs),'generation':snapshot['generation']}
  211. check('real-clipboard-copy-includes-scope-statistics-exact-originals-and-all-database-identifiers-verified-against-snapshot')
  212. # Source navigation runs the real library generation guard.
  213. click('.ws-review footer a',False)
  214. ev('new Promise((resolve,reject)=>{const end=Date.now()+30000;function tick(){const p=document.querySelector("kf-app")?.renderRoot?.querySelector("view-library");if(p?._detail&&!p._loading)resolve(true);else if(p?._error)reject(Error(p._error));else if(Date.now()>end)reject(Error("library source timeout"));else setTimeout(tick,70)}tick()})')
  215. assert '/library/reviews/' in ev('location.pathname')
  216. ev('history.back()');settle();ready()
  217. assert action('return p.routeQuery.w_theme')=='seasoning'
  218. check('original-review-link-checks-library-generation-and-opens-real-database-detail-with-back-restoring-analysis')
  219. navigate('/industry?tab=reviews&category=banmian', 'reviews')
  220. assert action('return !!p._data.corpus')
  221. navigate('/industry?tab=listings&category=banmian', 'listings')
  222. assert action('return !!p._data.inventory')
  223. check('existing-review-corpus-and-product-inventory-remain-available-under-maintenance')
  224. navigate('/industry');settle()
  225. for width,height in [(1440,1000),(1024,900),(768,1024),(390,844),(320,568)]:
  226. call('Emulation.setDeviceMetricsOverride',{'width':width,'height':height,'deviceScaleFactor':1,'mobile':False});settle()
  227. ev('scrollTo(0,0)');settle();shot('overview-'+str(width)+'.png')
  228. for section in ['products','needs','audience','map','opportunities','trend','evidence']:
  229. layout(section,width,height)
  230. if width in (1440,390,320):shot(section+'-'+str(width)+'.png')
  231. action('p.renderRoot.querySelector(".copy-menu").open=true;return true')
  232. click('[data-preview-agent]',False)
  233. assert action('return p.renderRoot.querySelector(".agent-copy-dialog").matches(":modal")')
  234. d=action('const x=p.renderRoot.querySelector(".agent-copy-dialog"),r=x.getBoundingClientRect();return {width:r.width,right:r.right,bottom:r.bottom,top:r.top,scrollWidth:x.scrollWidth,clientWidth:x.clientWidth}')
  235. assert d['right']<=width and d['top']>=0 and d['bottom']<=height and d['scrollWidth']<=d['clientWidth'],d
  236. report.setdefault('copyDialogs',[]).append({'width':width,'height':height,**d})
  237. if width==390:shot('copy-390.png')
  238. click('.copy-dialog-head>button',False)
  239. check('seven-sections-and-native-copy-dialog-fit-five-real-viewports-including-320px-without-horizontal-overflow')
  240. # Finalize screenshot evidence on the real page, not an error route.
  241. assert action('return p.renderRoot.querySelector(".p-head h2").textContent')=='产品创新洞察'
  242. errors=[e for e in events if e['method']=='Runtime.exceptionThrown']
  243. assert not errors,errors
  244. report['javascriptExceptions']=len(errors)
  245. check('no-uncaught-browser-exceptions-no-response-overrides-and-no-production-or-paid-operations')
  246. report['ok']=True
  247. except Exception:
  248. if ws:shot('failure.png')
  249. raise
  250. finally:
  251. if ws:ws.close()
  252. browser.terminate()
  253. try:browser.wait(timeout=10)
  254. except subprocess.TimeoutExpired:browser.kill();browser.wait(timeout=10)
  255. (out/'verification.json').write_text(json.dumps(report,ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
  256. print(json.dumps({'ok':report['ok'],'checks':report['checks'],'layouts':len(report['layouts']),'copyDialogs':len(report.get('copyDialogs',[])),'receipt':str(out/'verification.json')},ensure_ascii=False,indent=2))
  257. if __name__=='__main__':main()