verify-single-sku-validation.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212
  1. """Check the offline handoff, responsive layout, unknown states and JSON export."""
  2. import argparse
  3. import base64
  4. from datetime import datetime, timezone
  5. import hashlib
  6. from html.parser import HTMLParser
  7. import json
  8. from pathlib import Path
  9. import subprocess
  10. import tempfile
  11. import time
  12. from urllib.parse import unquote, urlparse
  13. from urllib.request import urlopen
  14. import websocket
  15. ROOT = Path(__file__).resolve().parents[1]
  16. class Links(HTMLParser):
  17. def __init__(self):
  18. super().__init__()
  19. self.hrefs = []
  20. self.ids = []
  21. def handle_starttag(self, tag, attrs):
  22. attrs = dict(attrs)
  23. if 'id' in attrs:
  24. self.ids.append(attrs['id'])
  25. if tag == 'a' and 'href' in attrs:
  26. self.hrefs.append(attrs['href'])
  27. def main():
  28. parser = argparse.ArgumentParser(description=__doc__)
  29. parser.add_argument('--package', type=Path,
  30. default=ROOT / 'artifacts/ksf-single-sku-validation-20261107-r1/package')
  31. args = parser.parse_args()
  32. package = args.package.resolve()
  33. parsed = Links()
  34. page_text = (package / 'index.html').read_text('utf-8')
  35. parsed.feed(page_text)
  36. assert len(parsed.ids) == len(set(parsed.ids)), 'DUPLICATE_HTML_IDS'
  37. for href in parsed.hrefs:
  38. assert not urlparse(href).scheme, 'EXTERNAL_LINK_IN_OFFLINE_PACKAGE'
  39. assert (package / unquote(href)).is_file(), href
  40. evidence = json.loads((package / 'evidence.json').read_text('utf-8'))
  41. template = json.loads((package / 'current-label-response.template.json').read_text('utf-8'))
  42. assert all(v is None for v in template['currentLabel'].values())
  43. assert evidence['historicalPhotoLabel']['preparation']['completeMinutes'] is None
  44. assert evidence['historicalPhotoLabel']['nutrition']['netWeightG'] is None
  45. assert evidence['historicalPhotoLabel']['packagingCodeVisible'] == 'WXFC-1A'
  46. assert evidence['historicalPhotoLabel']['standardCodeVisible'] == 'GB 17400'
  47. assert len(evidence['otherMissingSkuIds']) == 7
  48. for photo in evidence['photos']:
  49. assert hashlib.sha256((package / photo['path']).read_bytes()).hexdigest() == photo['sha256']
  50. work = ROOT / 'work/single-sku-validation-20261107-r1'
  51. work.mkdir(exist_ok=False)
  52. downloads = work / 'downloads'
  53. downloads.mkdir()
  54. profile = Path(tempfile.mkdtemp(prefix='edge-', dir=work))
  55. screenshots = package / 'verification'
  56. screenshots.mkdir(exist_ok=False)
  57. browser = Path('C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe')
  58. assert browser.is_file(), 'EDGE_NOT_FOUND'
  59. process = subprocess.Popen([
  60. str(browser), '--headless=new', '--disable-gpu', '--remote-debugging-port=0',
  61. '--disable-background-networking', '--disable-extensions', '--no-first-run',
  62. '--user-data-dir=' + str(profile), 'about:blank',
  63. ], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, creationflags=subprocess.CREATE_NO_WINDOW)
  64. ws = None
  65. serial = 0
  66. events = []
  67. report = {'ok': False, 'checkedAt': datetime.now(timezone.utc).isoformat(),
  68. 'scope': 'local offline artifact; no product study performed',
  69. 'layout': [], 'screenshots': [], 'checks': {},
  70. 'paidCalls': 0, 'productionWrites': 0, 'participantsObserved': 0}
  71. try:
  72. deadline = time.monotonic() + 20
  73. while not (profile / 'DevToolsActivePort').exists():
  74. assert time.monotonic() < deadline and process.poll() is None, 'BROWSER_START_TIMEOUT'
  75. time.sleep(.1)
  76. port = (profile / 'DevToolsActivePort').read_text().splitlines()[0]
  77. pages = json.load(urlopen('http://127.0.0.1:' + port + '/json/list', timeout=5))
  78. page = next(p for p in pages if p['type'] == 'page')
  79. ws = websocket.create_connection(page['webSocketDebuggerUrl'], timeout=30,
  80. suppress_origin=True, http_no_proxy=['127.0.0.1', 'localhost'])
  81. def call(method, params=None):
  82. nonlocal serial
  83. serial += 1
  84. ws.send(json.dumps({'id': serial, 'method': method, 'params': params or {}}))
  85. while True:
  86. result = json.loads(ws.recv())
  87. if result.get('id') == serial:
  88. assert 'error' not in result, result
  89. return result.get('result', {})
  90. events.append(result)
  91. def evaluate(expression):
  92. result = call('Runtime.evaluate', {'expression': expression,
  93. 'returnByValue': True, 'awaitPromise': True})
  94. assert 'exceptionDetails' not in result, result
  95. return result['result'].get('value')
  96. def screenshot(name):
  97. result = call('Page.captureScreenshot', {'format': 'png', 'captureBeyondViewport': False})
  98. path = screenshots / name
  99. path.write_bytes(base64.b64decode(result['data']))
  100. report['screenshots'].append(path.relative_to(package).as_posix())
  101. call('Page.enable')
  102. call('Runtime.enable')
  103. call('Network.enable')
  104. call('Log.enable')
  105. call('Network.emulateNetworkConditions', {'offline': True, 'latency': 0,
  106. 'downloadThroughput': 0, 'uploadThroughput': 0})
  107. call('Browser.setDownloadBehavior', {'behavior': 'allow', 'downloadPath': str(downloads)})
  108. call('Page.navigate', {'url': (package / 'index.html').as_uri()})
  109. evaluate('new Promise((resolve,reject)=>{const end=Date.now()+10000;'
  110. 'const tick=()=>{if(document.readyState==="complete" && typeof readResponse==="function")'
  111. 'resolve(true);else if(Date.now()>end)reject(Error("load timeout"));'
  112. 'else setTimeout(tick,40)};tick()})')
  113. evaluate('Promise.all([...document.images].map(img=>img.decode()))')
  114. images = evaluate('[...document.images].map(i=>({width:i.naturalWidth,height:i.naturalHeight}))')
  115. assert images == [{'width': 608, 'height': 1080}] * 3, images
  116. report['checks']['allThreeOriginalImagesDecodedOffline'] = True
  117. blank = evaluate('readResponse()')
  118. assert blank == template, 'BLANK_EXPORT_CHANGES_UNKNOWN_STATES'
  119. assert evaluate('[...document.querySelectorAll("input,textarea")].every(x=>x.closest("label"))')
  120. report['checks']['emptyFormPreservesAllUnknowns'] = True
  121. report['checks']['allInputsHaveLabels'] = True
  122. test_values = {'providedBy': '仅用于离线导出验收', 'providedAt': '2026-11-07',
  123. 'netWeightG': '96', 'servingSizeG': '96', 'servingsPerPackage': '1',
  124. 'sodiumMg': '0', 'sodiumNrvPercent': '0',
  125. 'preparationMethodsVerbatim': '仅用于导出测试\n第二行 <原文> & "引号"'}
  126. evaluate('(()=>{const values=' + json.dumps(test_values, ensure_ascii=False) +
  127. ';for(const [name,value] of Object.entries(values))'
  128. 'document.getElementById("response-form").elements.namedItem(name).value=value;return true})()')
  129. filled = evaluate('readResponse()')
  130. assert filled['currentLabel']['sodiumMg'] == 0
  131. assert filled['currentLabel']['sodiumNrvPercent'] == 0
  132. assert filled['currentLabel']['netWeightG'] == 96
  133. assert filled['currentLabel']['preparationMethodsVerbatim'] == test_values['preparationMethodsVerbatim']
  134. assert filled['currentLabel']['version'] is None
  135. assert filled['verification']['status'] == 'NOT_REVIEWED'
  136. assert filled['participantTest']['status'] == 'NOT_STARTED'
  137. assert filled['participantTest']['participantsObserved'] == 0
  138. evaluate('document.querySelector("#response-form button[type=submit]").click()')
  139. download = downloads / 'jd-100297147639-current-label-response.json'
  140. deadline = time.monotonic() + 10
  141. while not download.exists():
  142. assert time.monotonic() < deadline, 'DOWNLOAD_TIMEOUT'
  143. time.sleep(.05)
  144. assert json.loads(download.read_text('utf-8')) == filled
  145. report['checks']['realJsonDownloadPreservesNumbersUnicodeAndNulls'] = True
  146. report['checks']['exportDoesNotClaimVerificationOrStudyCompletion'] = True
  147. evaluate('document.getElementById("response-form").reset();'
  148. 'document.getElementById("export-feedback").textContent="";true')
  149. assert evaluate('readResponse()') == template
  150. for width in [1440, 1024, 768, 390, 320]:
  151. call('Emulation.setDeviceMetricsOverride', {'width': width, 'height': 1100,
  152. 'deviceScaleFactor': 1, 'mobile': False})
  153. evaluate('window.scrollTo(0,0);new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(r)))')
  154. metrics = evaluate('({width:innerWidth,scrollWidth:document.documentElement.scrollWidth,'
  155. 'overflowing:[...document.body.querySelectorAll("*")].filter(e=>{'
  156. 'const r=e.getBoundingClientRect();return r.width>0 && '
  157. '(r.left<-.5 || r.right>innerWidth+.5)}).map(e=>e.tagName+"."+e.className)})')
  158. assert metrics['scrollWidth'] <= metrics['width'], metrics
  159. assert not metrics['overflowing'], metrics
  160. report['layout'].append(metrics)
  161. if width == 1440:
  162. screenshot('desktop-1440.png')
  163. if width == 390:
  164. screenshot('mobile-390.png')
  165. evaluate('document.getElementById("response-title").scrollIntoView();true')
  166. screenshot('response-390.png')
  167. errors = [e for e in events if e['method'] == 'Runtime.exceptionThrown' or
  168. e['method'] == 'Log.entryAdded' and e['params']['entry'].get('level') == 'error']
  169. remote = [e['params']['request']['url'] for e in events
  170. if e['method'] == 'Network.requestWillBeSent'
  171. and e['params']['request']['url'].startswith(('http:', 'https:'))]
  172. assert not errors, errors
  173. assert not remote, remote
  174. report['checks']['consoleErrors'] = 0
  175. report['checks']['httpRequestsByPage'] = 0
  176. report['checks']['allOfflinePackageLinksResolve'] = True
  177. report['checks']['historicalFactsAndCurrentUnknownsKeptSeparate'] = True
  178. report['checks']['sourcePhotoBytesUnchanged'] = True
  179. report['ok'] = True
  180. finally:
  181. if ws is not None:
  182. ws.close()
  183. process.terminate()
  184. try:
  185. process.wait(timeout=10)
  186. except subprocess.TimeoutExpired:
  187. process.kill()
  188. process.wait(timeout=10)
  189. (package / 'verification.json').write_text(
  190. json.dumps(report, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
  191. print(json.dumps(report, ensure_ascii=False, indent=2))
  192. if __name__ == '__main__':
  193. main()