verify-industry-planner.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247
  1. """Real-browser acceptance for the five-step innovation research planner, with real database readback."""
  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-planner-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. call('Network.enable')
  71. def settle(): ev('new Promise(r=>setTimeout(r,140))')
  72. def choose(key, value):
  73. action('p.renderRoot.querySelector('+json.dumps('[data-plan-choice="'+key+':'+value+'"]')+').click();return true')
  74. settle()
  75. def next_step():
  76. action('p.renderRoot.querySelector("[data-plan-next]").click();return true');settle()
  77. def mouse_click(selector):
  78. 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,hit:p.renderRoot.elementFromPoint(r.x+r.width/2,r.y+r.height/2)===b}')
  79. assert point['hit'], point
  80. call('Input.dispatchMouseEvent',{'type':'mousePressed','x':point['x'],'y':point['y'],'button':'left','clickCount':1})
  81. call('Input.dispatchMouseEvent',{'type':'mouseReleased','x':point['x'],'y':point['y'],'button':'left','clickCount':1});settle()
  82. navigate('/industry')
  83. assert action('return p._planProgress.completed')==1
  84. assert action('return p._filters.category')=='banmian'
  85. assert action('return p.renderRoot.querySelector("[data-plan-next]").disabled')
  86. assert action('return p.renderRoot.querySelectorAll("[data-plan-step]")[4].disabled')
  87. assert action('return !p.renderRoot.querySelector(".research-analysis").open && !p.renderRoot.querySelector("details.maintenance").open')
  88. report['initialScope']=action('return p._data.scope')
  89. check('default-category-single-choice-and-guided-step-gating')
  90. shot('planner-initial-1440.png')
  91. choose('category','soup_noodle');ready()
  92. assert action('return p._filters.category')=='soup_noodle'
  93. assert action('return p._data.scope.products')!=report['initialScope']['products']
  94. assert action('return p.renderRoot.querySelectorAll("[data-plan-group=category] input:checked").length')==1
  95. choose('category','banmian');ready()
  96. assert action('return p._data.scope.products')==report['initialScope']['products']
  97. check('category-switch-reads-real-different-data-and-preserves-single-selection')
  98. choose('goal','new');choose('goal','upgrade')
  99. assert action('return p._plan.goal')==['upgrade']
  100. action('p.renderRoot.querySelector(".plan-scope-more").open=true;const e=p.renderRoot.querySelector("[data-plan-filter=platform]");e.value="jd";e.dispatchEvent(new Event("change"));return true');settle();ready()
  101. assert action('return p._filters.platform')=='jd'
  102. action('const e=p.renderRoot.querySelector("[data-plan-filter=brand]");e.value="ksf";e.dispatchEvent(new Event("change"));return true');settle();ready()
  103. assert action('return p._filters.brand')=='ksf'
  104. assert action('return p._plan.goal')==['upgrade']
  105. action('const e=p.renderRoot.querySelector("[data-plan-filter=brand]");e.value="";e.dispatchEvent(new Event("change"));return true');settle();ready()
  106. action('const e=p.renderRoot.querySelector("[data-plan-filter=platform]");e.value="";e.dispatchEvent(new Event("change"));return true');settle();ready()
  107. check('optional-platform-brand-scope-updates-real-counts-with-research-intent-retained')
  108. next_step(); assert action('return p._planStep')==1
  109. choose('audience','tentative');choose('audience','office');choose('audience','student')
  110. assert action('return p._plan.audience')==['office','student']
  111. choose('audience','tentative');assert action('return p._plan.audience')==['tentative']
  112. choose('audience','office');choose('audience','student')
  113. choose('scenario','lunch');choose('scenario','night')
  114. assert action('return p._plan.scenario')==['lunch','night']
  115. shot('planner-audience-1440.png')
  116. check('multiple-audiences-scenarios-and-tentative-exclusivity')
  117. next_step();assert action('return p._planStep')==2
  118. choose('format','bag');choose('format','cup');choose('preparation','soak')
  119. action('p.renderRoot.querySelector('+json.dumps('[data-plan-choice="format:cup"]')+').focus();return true')
  120. call('Input.dispatchKeyEvent',{'type':'rawKeyDown','key':'ArrowLeft','code':'ArrowLeft','windowsVirtualKeyCode':37});call('Input.dispatchKeyEvent',{'type':'keyUp','key':'ArrowLeft','code':'ArrowLeft','windowsVirtualKeyCode':37});settle()
  121. assert action('return p._plan.format')==['bag']
  122. call('Input.dispatchKeyEvent',{'type':'rawKeyDown','key':'ArrowRight','code':'ArrowRight','windowsVirtualKeyCode':39});call('Input.dispatchKeyEvent',{'type':'keyUp','key':'ArrowRight','code':'ArrowRight','windowsVirtualKeyCode':39});settle()
  123. assert action('return p._plan.format')==['cup']
  124. choose('flavor','spicy');choose('flavor','local');choose('position','balanced')
  125. assert action('return p._plan.format')==['cup']
  126. assert action('return p._plan.flavor')==['spicy','local']
  127. check('product-format-preparation-position-are-single-and-flavor-is-multiple')
  128. next_step();assert action('return p._planStep')==3
  129. choose('directions','seasoning');choose('directions','health');choose('directions','tentative')
  130. assert action('return p._plan.directions')==['tentative']
  131. choose('directions','seasoning');choose('directions','value')
  132. assert action('return p._plan.directions')==['seasoning','value']
  133. assert action('return p.renderRoot.querySelectorAll(".plan-evidence-count").length')==7
  134. shot('planner-directions-1440.png')
  135. next_step();assert action('return p._planStep')==4
  136. assert action('return p._planProgress.ready')
  137. assert not action('return p._promptReady')
  138. check('seven-innovation-themes-with-separate-real-evidence-counts-and-review-step')
  139. mouse_click('[data-generate-prompt]')
  140. assert action('return p._promptReady')
  141. expected=action('return p._prepareInsights()')
  142. assert all(x in expected for x in ['目标人群:上班族、在校学生','食用场景:工作日简餐、夜宵加餐','包装形态:杯装','风味偏好:麻辣香辣、地域特色','不是已经证实','database','source_record_id','generation:','"entity":"reviews"','"entity":"prices"'])
  143. assert not any(x in expected for x in ['员工','jd:jd:','undefined'])
  144. mouse_click('[data-copy-result]')
  145. copied=ev('navigator.clipboard.readText()').replace('\r\n','\n')
  146. assert copied==expected
  147. (out/'copied-innovation-prompt.txt').write_text(copied,encoding='utf-8')
  148. report['generatedPlan']=action('return p._plan')
  149. report['generation']=action('return p._data.generation')
  150. check('real-mouse-generation-and-clipboard-match-selected-intent-and-database-locators')
  151. refs=[json.loads(line) for line in copied.splitlines() if line.startswith('{')]
  152. verified=ev('(async()=>{const {readLibrary}=await import("/src/services/library-service.js");const refs='+json.dumps(refs)+';const out=[];for(const s of refs){const d=await readLibrary({op:"detail",entity:s.entity,id:s.id,generation:'+json.dumps(report['generation'])+'});out.push({entity:s.entity,id:s.id,actualId:d.record.id,sourceMatches:!s.source_record_id||s.source_record_id===d.record.source_record_id});}return out})()')
  153. assert all(r['id']==r['actualId'] and r['sourceMatches'] for r in verified)
  154. report['verifiedLocators']=verified
  155. check('every-prompt-database-id-and-provided-source-read-back-in-exact-generation')
  156. # Editing invalidates the generated result; refresh restores explicit choices.
  157. action('p.renderRoot.querySelectorAll("[data-edit-step]")[2].click();return true');settle()
  158. choose('format','bag')
  159. assert not action('return p._promptReady')
  160. assert action('return p._plan.audience')==['office','student']
  161. call('Page.reload');ready()
  162. assert action('return p._plan.format')==['bag']
  163. assert action('return p._planStep')==2
  164. assert not action('return p._promptReady')
  165. next_step();next_step();mouse_click('[data-generate-prompt]')
  166. assert '包装形态:袋装' in action('return p._prepareInsights()')
  167. check('editing-invalidates-prompt-and-refresh-retains-complete-research-choices')
  168. # Inspect every step at exact desktop, tablet and phone widths.
  169. for width,height in [(1440,1050),(1024,900),(768,1024),(390,844),(320,568)]:
  170. call('Emulation.setDeviceMetricsOverride',{'width':width,'height':height,'deviceScaleFactor':1,'mobile':False})
  171. for step in range(5):
  172. action('p._goPlanStep('+str(step)+');return true');settle()
  173. m=ev('({innerWidth,clientWidth:document.documentElement.clientWidth,scrollWidth:document.documentElement.scrollWidth})')
  174. assert m['innerWidth']==width and m['scrollWidth']==m['clientWidth'],(width,step,m)
  175. overflow=action('return [...p.renderRoot.querySelectorAll(".research-planner *")].filter(e=>{const r=e.getBoundingClientRect();return r.width>1&&r.height>1&&(r.left < -1||r.right>document.documentElement.clientWidth+1)}).map(e=>({tag:e.tagName,cls:e.className}))')
  176. assert not overflow,(width,step,overflow)
  177. report['layouts'].append({'width':width,'step':step,**m})
  178. if width in [1440,390,320] and step in [0,3,4]:
  179. if step==0:ev('scrollTo(0,0);true');settle()
  180. shot('planner-step-'+str(step)+'-'+str(width)+'.png')
  181. action('p._goPlanStep(4);return true');settle()
  182. # Clipboard preview is usable at real small-phone height.
  183. action('p.renderRoot.querySelector(".plan-result-actions [data-preview-agent]").click();return true');settle()
  184. m=action('const d=p.renderRoot.querySelector(".agent-copy-dialog"),b=p.renderRoot.querySelector("[data-copy-preview]"),r=d.getBoundingClientRect(),q=b.getBoundingClientRect();return {left:r.left,right:r.right,top:r.top,bottom:r.bottom,buttonBottom:q.bottom,scrollWidth:d.scrollWidth,clientWidth:d.clientWidth}')
  185. assert 0<=m['left'] and m['right']<=width and 0<=m['top'] and m['bottom']<=height and m['buttonBottom']<=height and m['scrollWidth']==m['clientWidth'],(width,m)
  186. if width in [390,320]:shot('planner-prompt-dialog-'+str(width)+'.png')
  187. action('p.renderRoot.querySelector(".agent-copy-dialog").close();return true')
  188. check('five-steps-across-five-exact-viewports-without-overflow-and-visible-copy-actions')
  189. # Native permission denial uses the manual-copy fallback, no runtime patch.
  190. call('Browser.resetPermissions')
  191. for unsanitized in (False,True):
  192. call('Browser.setPermission',{'permission':{'name':'clipboard-write','allowWithoutSanitization':unsanitized},'setting':'denied','origin':args.url})
  193. action('p.renderRoot.querySelector("[data-copy-result]").click();return true');settle()
  194. assert action('return p.renderRoot.querySelector(".agent-copy-dialog").open')
  195. assert action('const t=p.renderRoot.querySelector("[data-agent-text]");return t.selectionStart===0&&t.selectionEnd===t.value.length')
  196. action('p.renderRoot.querySelector(".agent-copy-dialog").close();return true')
  197. call('Browser.grantPermissions',{'origin':args.url,'permissions':['clipboardReadWrite','clipboardSanitizedWrite']})
  198. check('actual-clipboard-denial-opens-full-selected-manual-copy-fallback')
  199. # Real evidence and original maintenance views remain usable.
  200. action('p._analysisOpen=true;return true');settle()
  201. action('p.renderRoot.querySelector("[data-evidence=seasoning]").click();return true');settle();ready()
  202. assert action('return p._data.evidence.theme')=='seasoning'
  203. assert action('return p.renderRoot.querySelectorAll(".evidence-item").length')>0
  204. action('p.renderRoot.querySelector("[data-close-evidence]").click();return true');settle();ready()
  205. check('original-evidence-opens-and-reads-actual-main-and-append-content')
  206. for tab in ['overview','listings','reviews','policies','rankings','plan','contract']:
  207. call('Page.navigate',{'url':args.url+'/industry?tab='+tab});ready(tab)
  208. check('seven-existing-data-and-maintenance-tabs-still-load')
  209. navigate('/industry')
  210. action('p._goPlanStep(0);return true');settle()
  211. choose('category','tentative');ready()
  212. assert not action('return p._filters.category')
  213. assert action('return p._data.scope.products')>report['initialScope']['products']
  214. assert not action('return p._promptReady')
  215. assert action('return p._plan.audience')==['office','student']
  216. for i in range(4):next_step()
  217. mouse_click('[data-generate-prompt]')
  218. assert '研究品类:暂定' in action('return p._prepareInsights()')
  219. assert '商品范围 filters:{}' in action('return p._prepareInsights()')
  220. check('tentative-category-uses-broad-true-scope-and-invalidates-old-generated-result')
  221. report['consoleExceptions']=[e['params']['exceptionDetails'].get('text') for e in events if e['method']=='Runtime.exceptionThrown']
  222. assert not report['consoleExceptions'],report['consoleExceptions']
  223. report['ok']=True
  224. except Exception:
  225. if ws:
  226. report['failureState']=action('return {url:location.href,error:p?._error,step:p?._planStep,plan:p?._plan,progress:p?._planProgress}')
  227. shot('failure.png')
  228. raise
  229. finally:
  230. (out/'verification.json').write_text(json.dumps(report,ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
  231. if ws:ws.close()
  232. browser.terminate()
  233. try:browser.wait(timeout=10)
  234. except subprocess.TimeoutExpired:browser.kill()
  235. if __name__=='__main__':
  236. main()