| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212 |
- """Check the offline handoff, responsive layout, unknown states and JSON export."""
- import argparse
- import base64
- from datetime import datetime, timezone
- import hashlib
- from html.parser import HTMLParser
- import json
- from pathlib import Path
- import subprocess
- import tempfile
- import time
- from urllib.parse import unquote, urlparse
- from urllib.request import urlopen
- import websocket
- ROOT = Path(__file__).resolve().parents[1]
- class Links(HTMLParser):
- def __init__(self):
- super().__init__()
- self.hrefs = []
- self.ids = []
- def handle_starttag(self, tag, attrs):
- attrs = dict(attrs)
- if 'id' in attrs:
- self.ids.append(attrs['id'])
- if tag == 'a' and 'href' in attrs:
- self.hrefs.append(attrs['href'])
- def main():
- parser = argparse.ArgumentParser(description=__doc__)
- parser.add_argument('--package', type=Path,
- default=ROOT / 'artifacts/ksf-single-sku-validation-20261107-r1/package')
- args = parser.parse_args()
- package = args.package.resolve()
- parsed = Links()
- page_text = (package / 'index.html').read_text('utf-8')
- parsed.feed(page_text)
- assert len(parsed.ids) == len(set(parsed.ids)), 'DUPLICATE_HTML_IDS'
- for href in parsed.hrefs:
- assert not urlparse(href).scheme, 'EXTERNAL_LINK_IN_OFFLINE_PACKAGE'
- assert (package / unquote(href)).is_file(), href
- evidence = json.loads((package / 'evidence.json').read_text('utf-8'))
- template = json.loads((package / 'current-label-response.template.json').read_text('utf-8'))
- assert all(v is None for v in template['currentLabel'].values())
- assert evidence['historicalPhotoLabel']['preparation']['completeMinutes'] is None
- assert evidence['historicalPhotoLabel']['nutrition']['netWeightG'] is None
- assert evidence['historicalPhotoLabel']['packagingCodeVisible'] == 'WXFC-1A'
- assert evidence['historicalPhotoLabel']['standardCodeVisible'] == 'GB 17400'
- assert len(evidence['otherMissingSkuIds']) == 7
- for photo in evidence['photos']:
- assert hashlib.sha256((package / photo['path']).read_bytes()).hexdigest() == photo['sha256']
- work = ROOT / 'work/single-sku-validation-20261107-r1'
- work.mkdir(exist_ok=False)
- downloads = work / 'downloads'
- downloads.mkdir()
- profile = Path(tempfile.mkdtemp(prefix='edge-', dir=work))
- screenshots = package / 'verification'
- screenshots.mkdir(exist_ok=False)
- browser = Path('C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe')
- assert browser.is_file(), 'EDGE_NOT_FOUND'
- process = subprocess.Popen([
- str(browser), '--headless=new', '--disable-gpu', '--remote-debugging-port=0',
- '--disable-background-networking', '--disable-extensions', '--no-first-run',
- '--user-data-dir=' + str(profile), 'about:blank',
- ], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, creationflags=subprocess.CREATE_NO_WINDOW)
- ws = None
- serial = 0
- events = []
- report = {'ok': False, 'checkedAt': datetime.now(timezone.utc).isoformat(),
- 'scope': 'local offline artifact; no product study performed',
- 'layout': [], 'screenshots': [], 'checks': {},
- 'paidCalls': 0, 'productionWrites': 0, 'participantsObserved': 0}
- try:
- deadline = time.monotonic() + 20
- while not (profile / 'DevToolsActivePort').exists():
- assert time.monotonic() < deadline and process.poll() is None, 'BROWSER_START_TIMEOUT'
- time.sleep(.1)
- port = (profile / 'DevToolsActivePort').read_text().splitlines()[0]
- pages = json.load(urlopen('http://127.0.0.1:' + port + '/json/list', timeout=5))
- page = next(p for p in pages if p['type'] == 'page')
- ws = websocket.create_connection(page['webSocketDebuggerUrl'], timeout=30,
- suppress_origin=True, http_no_proxy=['127.0.0.1', 'localhost'])
- def call(method, params=None):
- nonlocal serial
- serial += 1
- ws.send(json.dumps({'id': serial, 'method': method, 'params': params or {}}))
- while True:
- result = json.loads(ws.recv())
- if result.get('id') == serial:
- assert 'error' not in result, result
- return result.get('result', {})
- events.append(result)
- def evaluate(expression):
- result = call('Runtime.evaluate', {'expression': expression,
- 'returnByValue': True, 'awaitPromise': True})
- assert 'exceptionDetails' not in result, result
- return result['result'].get('value')
- def screenshot(name):
- result = call('Page.captureScreenshot', {'format': 'png', 'captureBeyondViewport': False})
- path = screenshots / name
- path.write_bytes(base64.b64decode(result['data']))
- report['screenshots'].append(path.relative_to(package).as_posix())
- call('Page.enable')
- call('Runtime.enable')
- call('Network.enable')
- call('Log.enable')
- call('Network.emulateNetworkConditions', {'offline': True, 'latency': 0,
- 'downloadThroughput': 0, 'uploadThroughput': 0})
- call('Browser.setDownloadBehavior', {'behavior': 'allow', 'downloadPath': str(downloads)})
- call('Page.navigate', {'url': (package / 'index.html').as_uri()})
- evaluate('new Promise((resolve,reject)=>{const end=Date.now()+10000;'
- 'const tick=()=>{if(document.readyState==="complete" && typeof readResponse==="function")'
- 'resolve(true);else if(Date.now()>end)reject(Error("load timeout"));'
- 'else setTimeout(tick,40)};tick()})')
- evaluate('Promise.all([...document.images].map(img=>img.decode()))')
- images = evaluate('[...document.images].map(i=>({width:i.naturalWidth,height:i.naturalHeight}))')
- assert images == [{'width': 608, 'height': 1080}] * 3, images
- report['checks']['allThreeOriginalImagesDecodedOffline'] = True
- blank = evaluate('readResponse()')
- assert blank == template, 'BLANK_EXPORT_CHANGES_UNKNOWN_STATES'
- assert evaluate('[...document.querySelectorAll("input,textarea")].every(x=>x.closest("label"))')
- report['checks']['emptyFormPreservesAllUnknowns'] = True
- report['checks']['allInputsHaveLabels'] = True
- test_values = {'providedBy': '仅用于离线导出验收', 'providedAt': '2026-11-07',
- 'netWeightG': '96', 'servingSizeG': '96', 'servingsPerPackage': '1',
- 'sodiumMg': '0', 'sodiumNrvPercent': '0',
- 'preparationMethodsVerbatim': '仅用于导出测试\n第二行 <原文> & "引号"'}
- evaluate('(()=>{const values=' + json.dumps(test_values, ensure_ascii=False) +
- ';for(const [name,value] of Object.entries(values))'
- 'document.getElementById("response-form").elements.namedItem(name).value=value;return true})()')
- filled = evaluate('readResponse()')
- assert filled['currentLabel']['sodiumMg'] == 0
- assert filled['currentLabel']['sodiumNrvPercent'] == 0
- assert filled['currentLabel']['netWeightG'] == 96
- assert filled['currentLabel']['preparationMethodsVerbatim'] == test_values['preparationMethodsVerbatim']
- assert filled['currentLabel']['version'] is None
- assert filled['verification']['status'] == 'NOT_REVIEWED'
- assert filled['participantTest']['status'] == 'NOT_STARTED'
- assert filled['participantTest']['participantsObserved'] == 0
- evaluate('document.querySelector("#response-form button[type=submit]").click()')
- download = downloads / 'jd-100297147639-current-label-response.json'
- deadline = time.monotonic() + 10
- while not download.exists():
- assert time.monotonic() < deadline, 'DOWNLOAD_TIMEOUT'
- time.sleep(.05)
- assert json.loads(download.read_text('utf-8')) == filled
- report['checks']['realJsonDownloadPreservesNumbersUnicodeAndNulls'] = True
- report['checks']['exportDoesNotClaimVerificationOrStudyCompletion'] = True
- evaluate('document.getElementById("response-form").reset();'
- 'document.getElementById("export-feedback").textContent="";true')
- assert evaluate('readResponse()') == template
- for width in [1440, 1024, 768, 390, 320]:
- call('Emulation.setDeviceMetricsOverride', {'width': width, 'height': 1100,
- 'deviceScaleFactor': 1, 'mobile': False})
- evaluate('window.scrollTo(0,0);new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(r)))')
- metrics = evaluate('({width:innerWidth,scrollWidth:document.documentElement.scrollWidth,'
- 'overflowing:[...document.body.querySelectorAll("*")].filter(e=>{'
- 'const r=e.getBoundingClientRect();return r.width>0 && '
- '(r.left<-.5 || r.right>innerWidth+.5)}).map(e=>e.tagName+"."+e.className)})')
- assert metrics['scrollWidth'] <= metrics['width'], metrics
- assert not metrics['overflowing'], metrics
- report['layout'].append(metrics)
- if width == 1440:
- screenshot('desktop-1440.png')
- if width == 390:
- screenshot('mobile-390.png')
- evaluate('document.getElementById("response-title").scrollIntoView();true')
- screenshot('response-390.png')
- errors = [e for e in events if e['method'] == 'Runtime.exceptionThrown' or
- e['method'] == 'Log.entryAdded' and e['params']['entry'].get('level') == 'error']
- remote = [e['params']['request']['url'] for e in events
- if e['method'] == 'Network.requestWillBeSent'
- and e['params']['request']['url'].startswith(('http:', 'https:'))]
- assert not errors, errors
- assert not remote, remote
- report['checks']['consoleErrors'] = 0
- report['checks']['httpRequestsByPage'] = 0
- report['checks']['allOfflinePackageLinksResolve'] = True
- report['checks']['historicalFactsAndCurrentUnknownsKeptSeparate'] = True
- report['checks']['sourcePhotoBytesUnchanged'] = True
- report['ok'] = True
- finally:
- if ws is not None:
- ws.close()
- process.terminate()
- try:
- process.wait(timeout=10)
- except subprocess.TimeoutExpired:
- process.kill()
- process.wait(timeout=10)
- (package / 'verification.json').write_text(
- json.dumps(report, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
- print(json.dumps(report, ensure_ascii=False, indent=2))
- if __name__ == '__main__':
- main()
|