132 lines
6.8 KiB
Python
132 lines
6.8 KiB
Python
#!/usr/bin/env python3
|
||||
|
|
"""验证已授权浏览器产生的截图与元数据,再运行像素对比;本脚本不启动浏览器。"""
|
|||
|
|
import argparse
|
|||
|
|
import json
|
|||
|
|
from pathlib import Path
|
|||
|
|
import subprocess
|
|||
|
|
import sys
|
|||
|
|
from urllib.parse import urldefrag, urlparse
|
|||
|
|
from urllib.request import urlopen
|
|||
|
|
from html.parser import HTMLParser
|
|||
|
|
from PIL import Image
|
|||
|
|
from prepare_capture import compare_geometry, write_geometry_markdown
|
|||
|
|
|
|||
|
|
|
|||
|
|
def verify(manifest_path, out_dir, previous=None):
|
|||
|
|
if out_dir.exists():
|
|||
|
|
raise ValueError('输出目录已存在;使用新的轮次目录,拒绝覆盖')
|
|||
|
|
data = json.loads(manifest_path.read_text(encoding='utf-8'))
|
|||
|
|
base = manifest_path.parent
|
|||
|
|
expected = data['expected']
|
|||
|
|
capture = json.loads((base / data['capture_metadata']).read_text(encoding='utf-8'))
|
|||
|
|
candidate = base / data['candidate']
|
|||
|
|
reference = base / data['reference']
|
|||
|
|
failures = []
|
|||
|
|
def check(condition, message):
|
|||
|
|
if not condition:
|
|||
|
|
failures.append(message)
|
|||
|
|
for key in ('url', 'title', 'viewport', 'dpr'):
|
|||
|
|
if key not in capture or key not in expected:
|
|||
|
|
raise ValueError(f'缺少必填字段:{key}')
|
|||
|
|
check(bool(expected['title']) and capture['title'] == expected['title'], '页面标题不匹配')
|
|||
|
|
check(bool(expected['url']) and urldefrag(capture['url'])[0] == urldefrag(expected['url'])[0], '页面URL不匹配')
|
|||
|
|
check(capture['viewport'] == expected['viewport'], '实际视口不匹配')
|
|||
|
|
check(capture['dpr'] == expected['dpr'] and capture['dpr'] > 0, '实际DPR不匹配')
|
|||
|
|
check(capture.get('fonts_ready') is True, '字体尚未加载完成')
|
|||
|
|
check(capture.get('images_ready') is True, '图片尚未加载完成')
|
|||
|
|
check(capture.get('full_page') == expected['full_page'], '截图范围与约定不一致')
|
|||
|
|
scale_mode = capture['screenshot_scale']
|
|||
|
|
check(scale_mode == expected['screenshot_scale'] and scale_mode in ('css', 'device'), '截图像素缩放方式不匹配')
|
|||
|
|
scale = capture['dpr'] if scale_mode == 'device' else 1
|
|||
|
|
target_height = capture['document_height'] if capture['full_page'] else capture['viewport']['height']
|
|||
|
|
with Image.open(candidate) as image:
|
|||
|
|
check(abs(image.width - capture['viewport']['width']*scale) < 1, '截图宽度与元数据不符')
|
|||
|
|
check(abs(image.height - target_height*scale) < 1, '截图高度与元数据不符')
|
|||
|
|
for name, asset in data.get('expected_assets', {}).items():
|
|||
|
|
actual = capture.get('assets', {}).get(name, {})
|
|||
|
|
check(bool(asset) and all(actual.get(key) == value for key, value in asset.items()), f'资源版本或尺寸不匹配:{name}')
|
|||
|
|
if failures:
|
|||
|
|
raise ValueError(';'.join(failures))
|
|||
|
|
geometry = None
|
|||
|
|
if 'measurement_baseline' in data:
|
|||
|
|
geometry = compare_geometry(data, capture, base, previous)
|
|||
|
|
elif previous is not None:
|
|||
|
|
raise ValueError('前后比较需要由测量基线生成的验收配置')
|
|||
|
|
command = [sys.executable, str(Path(__file__).with_name('compare_mockup.py')),
|
|||
|
|
'--reference', str(reference), '--candidate', str(candidate), '--out-dir', str(out_dir), '--prefix', 'verify']
|
|||
|
|
if data.get('allow_height_difference'):
|
|||
|
|
command.append('--allow-height-difference')
|
|||
|
|
for clip in data.get('clips', []):
|
|||
|
|
command.extend(['--clip', clip])
|
|||
|
|
out_dir.mkdir(parents=True, exist_ok=False)
|
|||
|
|
result = subprocess.run(command, capture_output=True, text=True)
|
|||
|
|
if result.returncode:
|
|||
|
|
raise ValueError(result.stderr.strip())
|
|||
|
|
actual_sections = {section['name']: section for section in capture.get('sections', [])}
|
|||
|
|
positions = []
|
|||
|
|
for name, target_y in data.get('section_positions', {}).items():
|
|||
|
|
actual = actual_sections.get(name)
|
|||
|
|
positions.append({'name': name, 'expected_y': target_y, 'actual_y': actual['y'] if actual else None,
|
|||
|
|
'delta_y': round(actual['y']-target_y, 3) if actual else None})
|
|||
|
|
report = {'status': '截图身份与渲染前提通过;视觉仍需复核', 'manifest': str(manifest_path.resolve()),
|
|||
|
|
'capture': capture, 'section_positions': positions,
|
|||
|
|
'comparison': str(out_dir / 'verify-metrics.json')}
|
|||
|
|
if geometry is not None:
|
|||
|
|
report['geometry'] = geometry
|
|||
|
|
write_geometry_markdown(out_dir, geometry)
|
|||
|
|
if not geometry['complete']:
|
|||
|
|
report['status'] = '截图前提通过,但元素采集不完整,需处理报告中的缺项'
|
|||
|
|
(out_dir / 'verification.json').write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding='utf-8')
|
|||
|
|
print(json.dumps(report, ensure_ascii=False, indent=2))
|
|||
|
|
if geometry is not None and not geometry['complete']:
|
|||
|
|
raise ValueError('元素采集不完整;诊断报告已保存')
|
|||
|
|
|
|||
|
|
|
|||
|
|
def preflight(manifest_path):
|
|||
|
|
expected = json.loads(manifest_path.read_text(encoding='utf-8'))['expected']
|
|||
|
|
url = expected['url']
|
|||
|
|
if urlparse(url).hostname not in ('127.0.0.1', 'localhost', '::1'):
|
|||
|
|
raise ValueError('启动前检查仅用于本地页面')
|
|||
|
|
class TitleParser(HTMLParser):
|
|||
|
|
def __init__(self):
|
|||
|
|
super().__init__()
|
|||
|
|
self.inside = False
|
|||
|
|
self.parts = []
|
|||
|
|
def handle_starttag(self, tag, attrs):
|
|||
|
|
if tag == 'title': self.inside = True
|
|||
|
|
def handle_endtag(self, tag):
|
|||
|
|
if tag == 'title': self.inside = False
|
|||
|
|
def handle_data(self, text):
|
|||
|
|
if self.inside: self.parts.append(text)
|
|||
|
|
with urlopen(url, timeout=8) as response:
|
|||
|
|
if urldefrag(response.geturl())[0] != urldefrag(url)[0]:
|
|||
|
|
raise ValueError('页面重定向到其他URL')
|
|||
|
|
html = response.read(2_000_000).decode('utf-8')
|
|||
|
|
parser = TitleParser()
|
|||
|
|
parser.feed(html)
|
|||
|
|
actual = ''.join(parser.parts).strip()
|
|||
|
|
if not expected['title'] or actual != expected['title']:
|
|||
|
|
raise ValueError('本地HTML标题不匹配,禁止继续浏览器验证')
|
|||
|
|
print(json.dumps({'status': '本地服务与页面身份检查通过', 'url': url, 'title': actual}, ensure_ascii=False))
|
|||
|
|
|
|||
|
|
|
|||
|
|
def main():
|
|||
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|||
|
|
parser.add_argument('--manifest', type=Path, required=True)
|
|||
|
|
parser.add_argument('--out-dir', type=Path)
|
|||
|
|
parser.add_argument('--previous', type=Path, help='同一测量基线下前轮 verification.json,用于报告改善与退步')
|
|||
|
|
parser.add_argument('--preflight', action='store_true', help='仅核对本地HTML服务与标题;成功后才能导航')
|
|||
|
|
args = parser.parse_args()
|
|||
|
|
try:
|
|||
|
|
if args.preflight:
|
|||
|
|
preflight(args.manifest)
|
|||
|
|
elif args.out_dir is None:
|
|||
|
|
parser.error('截图验收需要 --out-dir')
|
|||
|
|
else:
|
|||
|
|
verify(args.manifest, args.out_dir, args.previous)
|
|||
|
|
except (ValueError, KeyError, OSError, TypeError) as error:
|
|||
|
|
parser.exit(2, f'验证失败:{error}\n')
|
|||
|
|
|
|||
|
|
|
|||
|
|
if __name__ == '__main__':
|
|||
|
|
main()
|