Spaces:
Running
Running
| """Capture responsive UI evidence and fail on basic layout regressions.""" | |
| from __future__ import annotations | |
| import json | |
| import os | |
| from pathlib import Path | |
| import sys | |
| from tempfile import gettempdir | |
| from urllib.parse import urlsplit | |
| from playwright.sync_api import Locator, Page, sync_playwright | |
| ROOT = Path(__file__).resolve().parents[1] | |
| PUBLIC_ASSET_DIR = ROOT / "docs" / "assets" | |
| DEFAULT_AUDIT_OUTPUT_DIR = ROOT / "outputs" / "ui-audit" | |
| AUDIT_OUTPUT_DIR = Path( | |
| os.environ.get("UI_TEST_OUTPUT_DIR", DEFAULT_AUDIT_OUTPUT_DIR) | |
| ) | |
| URL = os.environ.get("UI_TEST_URL", "http://127.0.0.1:7862") | |
| VERIFY_ACTION_PLAN = os.environ.get("UI_TEST_EXPECT_ACTION_PLAN") == "1" | |
| DEMO_DIR = Path( | |
| os.environ.get( | |
| "UI_TEST_DEMO_DIR", | |
| Path(gettempdir()) / "doc-inspector-ui-fixture-7862" / "demo", | |
| ) | |
| ) | |
| def validate_test_target(url: str, *, allow_non_fixture: bool = False) -> None: | |
| """Refuse to aim an automated UI test at the normal app by accident.""" | |
| if allow_non_fixture: | |
| return | |
| parsed = urlsplit(url) | |
| if parsed.hostname not in {"127.0.0.1", "localhost"} or parsed.port != 7862: | |
| raise RuntimeError( | |
| "UI 稽核預設只允許 127.0.0.1:7862 離線 fixture;" | |
| "若確定要測其他目標,請顯式設定 UI_TEST_ALLOW_NON_FIXTURE=1。" | |
| ) | |
| def open_app(page: Page, url: str = URL) -> None: | |
| """Load the interface and wait for the rendered shell rather than network idle. | |
| The source-verification panel keeps its data in Gradio session state, and | |
| Gradio holds a heartbeat stream open for the life of such a page, so | |
| ``networkidle`` never fires. | |
| """ | |
| page.goto(url, wait_until="domcontentloaded") | |
| page.get_by_text("結果燈號", exact=True).wait_for(state="visible") | |
| def _metrics(locator: Locator) -> dict[str, float | str]: | |
| return locator.evaluate( | |
| """element => { | |
| const box = element.getBoundingClientRect(); | |
| const style = window.getComputedStyle(element); | |
| return { | |
| x: Math.round(box.x), | |
| y: Math.round(box.y), | |
| width: Math.round(box.width), | |
| height: Math.round(box.height), | |
| fontSize: style.fontSize, | |
| fontWeight: style.fontWeight, | |
| lineHeight: style.lineHeight, | |
| borderTopWidth: style.borderTopWidth | |
| }; | |
| }""" | |
| ) | |
| def _all_metrics(locator: Locator) -> list[dict[str, float | str]]: | |
| return locator.evaluate_all( | |
| """elements => elements.map(element => { | |
| const box = element.getBoundingClientRect(); | |
| const style = window.getComputedStyle(element); | |
| return { | |
| x: Math.round(box.x), | |
| y: Math.round(box.y), | |
| width: Math.round(box.width), | |
| height: Math.round(box.height), | |
| fontSize: style.fontSize, | |
| fontWeight: style.fontWeight, | |
| lineHeight: style.lineHeight, | |
| borderTopWidth: style.borderTopWidth | |
| }; | |
| })""" | |
| ) | |
| def _dropdown_metrics(page: Page) -> dict: | |
| combo = page.locator("#demo-selector [role='combobox']").first | |
| combo.click() | |
| listbox = page.locator('#demo-selector [role="listbox"]').first | |
| option = page.locator( | |
| '#demo-selector [role="listbox"] [role="option"]' | |
| ).first | |
| option.wait_for(state="visible") | |
| metrics = { | |
| "control": _metrics(combo), | |
| "listbox": _metrics(listbox), | |
| "option": _metrics(option), | |
| "frontmostRole": option.evaluate( | |
| """element => { | |
| const box = element.getBoundingClientRect(); | |
| return document.elementFromPoint( | |
| box.left + 16, | |
| box.top + box.height / 2 | |
| )?.getAttribute("role") ?? ""; | |
| }""" | |
| ), | |
| } | |
| page.keyboard.press("Escape") | |
| return metrics | |
| def _page_metrics(page: Page) -> dict: | |
| dropdown = _dropdown_metrics(page) | |
| metrics = { | |
| "viewportWidth": page.evaluate("window.innerWidth"), | |
| "documentWidth": page.evaluate("document.documentElement.scrollWidth"), | |
| "documentHeight": page.evaluate("document.documentElement.scrollHeight"), | |
| "header": _metrics(page.locator(".app-header").first), | |
| "h1": _metrics(page.locator(".app-header h1").first), | |
| "bodyText": _metrics(page.locator(".app-header p").first), | |
| "masthead": _metrics(page.locator(".masthead").first), | |
| "resultLegend": _metrics(page.locator(".result-legend").first), | |
| "legendTitle": _metrics(page.locator(".result-legend h2").first), | |
| "legendItem": _metrics(page.locator(".legend-items li").first), | |
| "legendCards": _all_metrics(page.locator(".legend-items li")), | |
| "legendLabel": _metrics(page.locator(".legend-items strong").first), | |
| "legendAction": _metrics(page.locator(".legend-action").first), | |
| "legendDots": _all_metrics(page.locator(".legend-dot")), | |
| "sourceHeading": _metrics(page.locator(".source-brief h3").first), | |
| "sourceText": _metrics(page.locator(".source-brief p").first), | |
| "demoHeading": _metrics(page.locator(".demo-heading h3").first), | |
| "demoText": _metrics(page.locator(".demo-heading p").first), | |
| "demoDropdownControl": dropdown["control"], | |
| "demoDropdownListbox": dropdown["listbox"], | |
| "demoDropdownOption": dropdown["option"], | |
| "demoDropdownFrontmostRole": dropdown["frontmostRole"], | |
| "demoSelectorAccessible": page.get_by_label("範例文件").is_visible(), | |
| "documentPickerLabelVisible": page.locator( | |
| "#document-upload" | |
| ).get_by_text( | |
| "選擇申請表、收據或發票", exact=True | |
| ).is_visible(), | |
| "workbench": _metrics(page.locator(".workbench").first), | |
| "uploadPanel": _metrics(page.locator(".upload-section").first), | |
| "sourceBrief": _metrics(page.locator(".source-brief").first), | |
| "uploadControl": _metrics(page.locator(".upload-control").first), | |
| "uploadPrompt": _metrics( | |
| page.locator(".upload-control > button > .wrap").first | |
| ), | |
| "settingsPanel": _metrics(page.locator(".settings-section").first), | |
| "actionPanel": _metrics(page.locator(".action-section").first), | |
| "taskHeading": _metrics(page.locator(".task-heading").first), | |
| "stepMarkers": _all_metrics(page.locator(".task-step")), | |
| "taskTitle": _metrics(page.locator(".task-heading h2").first), | |
| "taskCopy": _metrics(page.locator(".task-heading p").first), | |
| "fieldLabel": _metrics( | |
| page.locator('#schema-selector [data-testid="block-info"]').first | |
| ), | |
| "uploadGuidance": _metrics(page.locator(".upload-guidance").first), | |
| "noticeGrid": _metrics(page.locator(".notice-grid").first), | |
| "privacyText": _metrics(page.locator(".privacy-note p").first), | |
| "actionGrid": _metrics(page.locator(".action-grid").first), | |
| "primaryButton": _metrics(page.locator(".primary-btn").first), | |
| "statusText": _metrics(page.locator(".status-card").first), | |
| "actionPlan": _metrics(page.locator(".action-plan").first), | |
| "resultTabs": _metrics(page.locator(".result-tabs").first), | |
| "emptyResultHint": page.locator( | |
| "#extraction-table .result-empty" | |
| ).inner_text(), | |
| "layoutAncestors": page.locator(".app-header").first.evaluate( | |
| """element => { | |
| const ancestors = []; | |
| let current = element.parentElement; | |
| while (current && ancestors.length < 10) { | |
| const box = current.getBoundingClientRect(); | |
| const style = window.getComputedStyle(current); | |
| ancestors.push({ | |
| tag: current.tagName, | |
| id: current.id, | |
| className: current.className, | |
| width: Math.round(box.width), | |
| paddingLeft: style.paddingLeft, | |
| paddingRight: style.paddingRight | |
| }); | |
| current = current.parentElement; | |
| } | |
| return ancestors; | |
| }""" | |
| ), | |
| } | |
| upload = metrics["uploadControl"] | |
| prompt = metrics["uploadPrompt"] | |
| metrics["uploadPromptInsetTop"] = prompt["y"] - upload["y"] | |
| metrics["uploadPromptInsetBottom"] = ( | |
| upload["y"] + upload["height"] - prompt["y"] - prompt["height"] | |
| ) | |
| return metrics | |
| def _theme_contrast(browser, color_scheme: str) -> dict: | |
| """Measure rendered text contrast under a browser color preference.""" | |
| context = browser.new_context( | |
| viewport={"width": 1440, "height": 1000}, | |
| color_scheme=color_scheme, | |
| ) | |
| page = context.new_page() | |
| open_app(page) | |
| selectors = ( | |
| ".source-brief p", | |
| ".upload-guidance span", | |
| "#document-upload button .wrap", | |
| "#demo-selector input", | |
| "#schema-selector input", | |
| ".privacy-note p", | |
| ".task-heading p", | |
| ) | |
| samples = {} | |
| for selector in selectors: | |
| samples[selector] = page.locator(selector).first.evaluate( | |
| """element => { | |
| const canvas = document.createElement("canvas"); | |
| canvas.width = 1; | |
| canvas.height = 1; | |
| const context = canvas.getContext("2d", {willReadFrequently: true}); | |
| const parseColor = value => { | |
| context.clearRect(0, 0, 1, 1); | |
| context.fillStyle = "rgba(0, 0, 0, 0)"; | |
| context.fillStyle = value; | |
| context.fillRect(0, 0, 1, 1); | |
| const [red, green, blue, alpha] = context.getImageData( | |
| 0, 0, 1, 1 | |
| ).data; | |
| return [red, green, blue, alpha / 255]; | |
| }; | |
| const composite = (front, back) => { | |
| const alpha = front[3] + back[3] * (1 - front[3]); | |
| if (alpha === 0) return [0, 0, 0, 0]; | |
| return [ | |
| (front[0] * front[3] | |
| + back[0] * back[3] * (1 - front[3])) / alpha, | |
| (front[1] * front[3] | |
| + back[1] * back[3] * (1 - front[3])) / alpha, | |
| (front[2] * front[3] | |
| + back[2] * back[3] * (1 - front[3])) / alpha, | |
| alpha | |
| ]; | |
| }; | |
| const layers = []; | |
| let current = element; | |
| while (current) { | |
| const background = parseColor( | |
| getComputedStyle(current).backgroundColor | |
| ); | |
| if (background[3] > 0) layers.push(background); | |
| current = current.parentElement; | |
| } | |
| let background = [255, 255, 255, 1]; | |
| for (const layer of layers.reverse()) { | |
| background = composite(layer, background); | |
| } | |
| const foreground = composite( | |
| parseColor(getComputedStyle(element).color), | |
| background | |
| ); | |
| const luminance = color => { | |
| const channels = color.slice(0, 3).map(channel => { | |
| const value = channel / 255; | |
| return value <= 0.04045 | |
| ? value / 12.92 | |
| : ((value + 0.055) / 1.055) ** 2.4; | |
| }); | |
| return ( | |
| 0.2126 * channels[0] | |
| + 0.7152 * channels[1] | |
| + 0.0722 * channels[2] | |
| ); | |
| }; | |
| const foregroundLuminance = luminance(foreground); | |
| const backgroundLuminance = luminance(background); | |
| const ratio = ( | |
| Math.max(foregroundLuminance, backgroundLuminance) + 0.05 | |
| ) / ( | |
| Math.min(foregroundLuminance, backgroundLuminance) + 0.05 | |
| ); | |
| return { | |
| color: getComputedStyle(element).color, | |
| backgroundColor: `rgb(${background.slice(0, 3) | |
| .map(Math.round).join(", ")})`, | |
| contrastRatio: Math.round(ratio * 100) / 100 | |
| }; | |
| }""" | |
| ) | |
| container_theme = page.locator(".gradio-container").first.evaluate( | |
| """element => { | |
| const style = getComputedStyle(element); | |
| return { | |
| browserPrefersDark: matchMedia( | |
| "(prefers-color-scheme: dark)" | |
| ).matches, | |
| renderedColorScheme: style.colorScheme, | |
| bodyTextColor: style.getPropertyValue("--body-text-color").trim(), | |
| inputBackground: style.getPropertyValue( | |
| "--input-background-fill" | |
| ).trim() | |
| }; | |
| }""" | |
| ) | |
| result = { | |
| "requestedColorScheme": color_scheme, | |
| "theme": container_theme, | |
| "samples": samples, | |
| "minimumContrastRatio": min( | |
| sample["contrastRatio"] for sample in samples.values() | |
| ), | |
| } | |
| context.close() | |
| return result | |
| def _capture( | |
| browser, | |
| *, | |
| width: int, | |
| height: int, | |
| filename: str, | |
| output_dir: Path, | |
| console_errors: list[str], | |
| page_errors: list[str], | |
| ) -> tuple[dict, bool]: | |
| context = browser.new_context( | |
| viewport={"width": width, "height": height}, | |
| color_scheme="light", | |
| ) | |
| page = context.new_page() | |
| page.on( | |
| "console", | |
| lambda message: console_errors.append(message.text) | |
| if message.type == "error" | |
| else None, | |
| ) | |
| page.on("pageerror", lambda error: page_errors.append(str(error))) | |
| open_app(page) | |
| page.get_by_text( | |
| "完成後照顏色處理", exact=True | |
| ).wait_for(state="visible") | |
| for label in [ | |
| "送件前先修正", | |
| "對照原文件確認", | |
| "目前未發現問題", | |
| ]: | |
| page.get_by_text(label, exact=False).wait_for(state="visible") | |
| for step in [ | |
| "準備要檢查的文件", | |
| "確認預檢設定", | |
| "確認傳送並開始", | |
| "查看預檢結果", | |
| ]: | |
| page.get_by_text(step, exact=True).wait_for(state="visible") | |
| page.get_by_text("文件從哪裡來?", exact=True).wait_for(state="visible") | |
| page.get_by_text("請上傳已填好的申請表", exact=False).wait_for( | |
| state="visible" | |
| ) | |
| page.get_by_text("沒有文件?", exact=True).wait_for(state="visible") | |
| page.get_by_text("尚未產生修正建議", exact=True).wait_for(state="visible") | |
| page.screenshot(path=output_dir / filename, full_page=True) | |
| metrics = _page_metrics(page) | |
| consent_unchecked = not page.locator("#cloud-consent input").is_checked() | |
| context.close() | |
| return metrics, consent_unchecked | |
| def _verify_consent_gate( | |
| browser, | |
| console_errors: list[str], | |
| page_errors: list[str], | |
| ) -> tuple[bool, bool, bool]: | |
| context = browser.new_context( | |
| viewport={"width": 1280, "height": 900}, | |
| color_scheme="light", | |
| ) | |
| page = context.new_page() | |
| page.on( | |
| "console", | |
| lambda message: console_errors.append(message.text) | |
| if message.type == "error" | |
| else None, | |
| ) | |
| page.on("pageerror", lambda error: page_errors.append(str(error))) | |
| open_app(page) | |
| page.locator(".primary-btn").click() | |
| error = page.get_by_text("請先勾選雲端傳送告知", exact=False) | |
| error.wait_for(state="visible") | |
| visible = error.is_visible() | |
| status_near_action = page.locator( | |
| ".action-section .status-output .status-card.status-red" | |
| ).is_visible() | |
| button_reenabled = page.locator(".primary-btn").is_enabled() | |
| context.close() | |
| return visible, status_near_action, button_reenabled | |
| def _verify_demo_loader( | |
| browser, | |
| console_errors: list[str], | |
| page_errors: list[str], | |
| ) -> tuple[bool, bool, bool]: | |
| context = browser.new_context( | |
| viewport={"width": 1280, "height": 1000}, | |
| color_scheme="light", | |
| ) | |
| page = context.new_page() | |
| page.on( | |
| "console", | |
| lambda message: console_errors.append(message.text) | |
| if message.type == "error" | |
| else None, | |
| ) | |
| page.on("pageerror", lambda error: page_errors.append(str(error))) | |
| open_app(page) | |
| page.locator("#load-demo").click() | |
| loaded = page.get_by_text("已載入安全範例:", exact=False) | |
| loaded.wait_for(state="visible", timeout=30_000) | |
| file_visible = page.locator( | |
| '#document-upload [aria-label="receipt_green.png"]' | |
| ).is_visible() | |
| status_visible = loaded.is_visible() | |
| schema_updated = ( | |
| page.locator("#schema-selector input").input_value() == "收據/發票" | |
| ) | |
| context.close() | |
| return file_visible, status_visible, schema_updated | |
| def _verify_actionable_result( | |
| browser, | |
| console_errors: list[str], | |
| page_errors: list[str], | |
| output_dir: Path, | |
| ) -> tuple[bool, bool, bool, bool]: | |
| context = browser.new_context( | |
| viewport={"width": 1440, "height": 1000}, | |
| color_scheme="light", | |
| ) | |
| page = context.new_page() | |
| page.on( | |
| "console", | |
| lambda message: console_errors.append(message.text) | |
| if message.type == "error" | |
| else None, | |
| ) | |
| page.on("pageerror", lambda error: page_errors.append(str(error))) | |
| open_app(page) | |
| red_demo_path = DEMO_DIR / "subsidy_red.png" | |
| page.locator('#document-upload input[type="file"]').set_input_files( | |
| red_demo_path | |
| ) | |
| page.get_by_text("subsidy_red.png", exact=True).wait_for(state="visible") | |
| page.locator("#cloud-consent input").check() | |
| page.locator(".primary-btn").click() | |
| page.get_by_text("先修正 4 項", exact=False).wait_for( | |
| state="visible", | |
| timeout=30_000, | |
| ) | |
| page.get_by_text("查看修正建議 ↓", exact=True).click() | |
| action_plan_visible = page.get_by_text( | |
| "先照這份清單處理", exact=True | |
| ).is_visible() | |
| next_step_visible = page.get_by_text( | |
| "回到上方換上修正後的文件", exact=False | |
| ).is_visible() | |
| plain_language_visible = page.get_by_text( | |
| "逐字核對身分證字號", exact=False | |
| ).is_visible() | |
| machine_paths_hidden = page.locator("#action-plan").get_by_text( | |
| "applicants.0", exact=False | |
| ).count() == 0 | |
| page.screenshot(path=output_dir / "result-red.png", full_page=True) | |
| context.close() | |
| return ( | |
| action_plan_visible, | |
| next_step_visible, | |
| plain_language_visible, | |
| machine_paths_hidden, | |
| ) | |
| def _provenance_snapshot(page: Page) -> dict: | |
| card = page.locator("#provenance-detail .provenance-card").first | |
| card.wait_for(state="visible") | |
| return { | |
| "cardClass": card.get_attribute("class") or "", | |
| "text": card.inner_text(), | |
| "pagePreviewVisible": page.locator("#provenance-preview img").count() > 0, | |
| } | |
| def _select_provenance_field(page: Page, option_text: str) -> dict: | |
| page.locator("#provenance-field [role='combobox']").first.click() | |
| page.locator( | |
| "#provenance-field [role='listbox'] [role='option']" | |
| ).filter(has_text=option_text).first.click() | |
| page.wait_for_function( | |
| """text => { | |
| const card = document.querySelector("#provenance-detail .provenance-card"); | |
| const row = card && [...card.querySelectorAll(".provenance-row dd")][0]; | |
| return Boolean(row && row.textContent.includes(text)); | |
| }""", | |
| arg=option_text, | |
| timeout=30_000, | |
| ) | |
| return _provenance_snapshot(page) | |
| def _verify_evidence_provenance( | |
| browser, | |
| console_errors: list[str], | |
| page_errors: list[str], | |
| output_dir: Path, | |
| ) -> dict: | |
| """Drive the source-verification panel across every verification status.""" | |
| context = browser.new_context( | |
| viewport={"width": 1440, "height": 1000}, | |
| color_scheme="light", | |
| ) | |
| page = context.new_page() | |
| page.on( | |
| "console", | |
| lambda message: console_errors.append(message.text) | |
| if message.type == "error" | |
| else None, | |
| ) | |
| page.on("pageerror", lambda error: page_errors.append(str(error))) | |
| open_app(page) | |
| page.locator('#document-upload input[type="file"]').set_input_files( | |
| DEMO_DIR / "subsidy_provenance.pdf" | |
| ) | |
| page.get_by_text("subsidy_provenance.pdf", exact=True).wait_for(state="visible") | |
| page.locator("#cloud-consent input").check() | |
| page.locator(".primary-btn").click() | |
| page.locator(".status-card.status-green").wait_for(state="visible", timeout=60_000) | |
| page.get_by_role("tab", name="來源核驗").click() | |
| selector = page.locator("#provenance-field [role='combobox']").first | |
| selector_box = selector.bounding_box() or {"width": 0, "height": 0} | |
| selector.focus() | |
| focus_outline = selector.evaluate( | |
| """element => { | |
| const style = getComputedStyle(element); | |
| return { | |
| outlineWidth: parseFloat(style.outlineWidth || "0"), | |
| outlineStyle: style.outlineStyle | |
| }; | |
| }""" | |
| ) | |
| verified = _provenance_snapshot(page) | |
| page.screenshot( | |
| path=PUBLIC_ASSET_DIR / "evidence-provenance.png", | |
| full_page=True, | |
| ) | |
| ambiguous = _select_provenance_field(page, "申報總額") | |
| page_only = _select_provenance_field(page, "附件金額") | |
| unresolved = _select_provenance_field(page, "承辦註記") | |
| page.screenshot(path=output_dir / "provenance-unresolved.png", full_page=True) | |
| context.close() | |
| mobile_context = browser.new_context( | |
| viewport={"width": 390, "height": 844}, | |
| color_scheme="light", | |
| ) | |
| mobile_page = mobile_context.new_page() | |
| mobile_page.on( | |
| "console", | |
| lambda message: console_errors.append(message.text) | |
| if message.type == "error" | |
| else None, | |
| ) | |
| mobile_page.on("pageerror", lambda error: page_errors.append(str(error))) | |
| open_app(mobile_page) | |
| mobile_page.get_by_role("tab", name="來源核驗").click() | |
| mobile_page.locator("#provenance-detail .provenance-card").first.wait_for( | |
| state="visible" | |
| ) | |
| mobile_overflow = mobile_page.evaluate( | |
| "document.documentElement.scrollWidth > window.innerWidth" | |
| ) | |
| mobile_context.close() | |
| return { | |
| "selectorWidth": round(selector_box["width"]), | |
| "selectorHeight": round(selector_box["height"]), | |
| "selectorFocusOutlineWidth": focus_outline["outlineWidth"], | |
| "selectorFocusOutlineStyle": focus_outline["outlineStyle"], | |
| "verified": verified, | |
| "ambiguous": ambiguous, | |
| "pageOnly": page_only, | |
| "unresolved": unresolved, | |
| "mobileHorizontalOverflow": mobile_overflow, | |
| } | |
| def main() -> None: | |
| if hasattr(sys.stdout, "reconfigure"): | |
| sys.stdout.reconfigure(encoding="utf-8") | |
| validate_test_target( | |
| URL, | |
| allow_non_fixture=os.environ.get("UI_TEST_ALLOW_NON_FIXTURE") == "1", | |
| ) | |
| PUBLIC_ASSET_DIR.mkdir(parents=True, exist_ok=True) | |
| AUDIT_OUTPUT_DIR.mkdir(parents=True, exist_ok=True) | |
| console_errors: list[str] = [] | |
| page_errors: list[str] = [] | |
| with sync_playwright() as playwright: | |
| browser = playwright.chromium.launch(headless=True) | |
| wide, wide_unchecked = _capture( | |
| browser, | |
| width=1920, | |
| height=1080, | |
| filename="wide.png", | |
| output_dir=AUDIT_OUTPUT_DIR, | |
| console_errors=console_errors, | |
| page_errors=page_errors, | |
| ) | |
| desktop, desktop_unchecked = _capture( | |
| browser, | |
| width=1440, | |
| height=1000, | |
| filename="desktop.png", | |
| output_dir=PUBLIC_ASSET_DIR, | |
| console_errors=console_errors, | |
| page_errors=page_errors, | |
| ) | |
| mobile, mobile_unchecked = _capture( | |
| browser, | |
| width=390, | |
| height=844, | |
| filename="mobile.png", | |
| output_dir=AUDIT_OUTPUT_DIR, | |
| console_errors=console_errors, | |
| page_errors=page_errors, | |
| ) | |
| light_theme_contrast = _theme_contrast(browser, "light") | |
| dark_preference_contrast = _theme_contrast(browser, "dark") | |
| ( | |
| consent_error_visible, | |
| status_near_action, | |
| button_reenabled_after_error, | |
| ) = _verify_consent_gate( | |
| browser, | |
| console_errors, | |
| page_errors, | |
| ) | |
| demo_file_visible, demo_status_visible, demo_schema_updated = _verify_demo_loader( | |
| browser, | |
| console_errors, | |
| page_errors, | |
| ) | |
| if VERIFY_ACTION_PLAN: | |
| ( | |
| action_plan_visible, | |
| next_step_visible, | |
| plain_language_visible, | |
| machine_paths_hidden, | |
| ) = _verify_actionable_result( | |
| browser, | |
| console_errors, | |
| page_errors, | |
| AUDIT_OUTPUT_DIR, | |
| ) | |
| else: | |
| action_plan_visible = None | |
| next_step_visible = None | |
| plain_language_visible = None | |
| machine_paths_hidden = None | |
| provenance = _verify_evidence_provenance( | |
| browser, | |
| console_errors, | |
| page_errors, | |
| AUDIT_OUTPUT_DIR, | |
| ) | |
| browser.close() | |
| report = { | |
| "url": URL, | |
| "screenshots": [ | |
| "wide.png", | |
| "desktop.png", | |
| "mobile.png", | |
| *(["result-red.png"] if VERIFY_ACTION_PLAN else []), | |
| "evidence-provenance.png", | |
| "provenance-unresolved.png", | |
| ], | |
| "wide_metrics": wide, | |
| "desktop_metrics": desktop, | |
| "mobile_metrics": mobile, | |
| "theme_contrast": { | |
| "light": light_theme_contrast, | |
| "dark_system_preference": dark_preference_contrast, | |
| }, | |
| "consent_control_default_unchecked": ( | |
| wide_unchecked and desktop_unchecked and mobile_unchecked | |
| ), | |
| "consent_error_visible_before_api_call": consent_error_visible, | |
| "persistent_status_near_action": status_near_action, | |
| "button_reenabled_after_error": button_reenabled_after_error, | |
| "demo_file_loaded_without_api": demo_file_visible, | |
| "demo_status_visible": demo_status_visible, | |
| "demo_schema_updated": demo_schema_updated, | |
| "action_plan_checked": VERIFY_ACTION_PLAN, | |
| "action_plan_visible": action_plan_visible, | |
| "action_plan_next_step_visible": next_step_visible, | |
| "action_plan_plain_language_visible": plain_language_visible, | |
| "action_plan_machine_paths_hidden": machine_paths_hidden, | |
| "evidence_provenance": provenance, | |
| "console_errors": console_errors, | |
| "page_errors": page_errors, | |
| } | |
| (AUDIT_OUTPUT_DIR / "browser-report.json").write_text( | |
| json.dumps(report, ensure_ascii=False, indent=2) + "\n", | |
| encoding="utf-8", | |
| ) | |
| desktop_step_x = [marker["x"] for marker in desktop["stepMarkers"]] | |
| mobile_step_x = [marker["x"] for marker in mobile["stepMarkers"]] | |
| desktop_dot_widths = [dot["width"] for dot in desktop["legendDots"]] | |
| desktop_dot_heights = [dot["height"] for dot in desktop["legendDots"]] | |
| mobile_dot_widths = [dot["width"] for dot in mobile["legendDots"]] | |
| mobile_dot_heights = [dot["height"] for dot in mobile["legendDots"]] | |
| for viewport_name, metrics in ( | |
| ("wide", wide), | |
| ("desktop", desktop), | |
| ("mobile", mobile), | |
| ): | |
| legend_cards = metrics["legendCards"] | |
| assert len(legend_cards) == 3, ( | |
| f"{viewport_name} viewport should render exactly three legend cards" | |
| ) | |
| legend_widths = [card["width"] for card in legend_cards] | |
| legend_heights = [card["height"] for card in legend_cards] | |
| assert max(legend_widths) - min(legend_widths) <= 1, ( | |
| f"{viewport_name} legend cards should have equal widths: {legend_widths}" | |
| ) | |
| assert max(legend_heights) - min(legend_heights) <= 1, ( | |
| f"{viewport_name} legend cards should have equal heights: {legend_heights}" | |
| ) | |
| assert desktop["viewportWidth"] == desktop["documentWidth"] | |
| assert mobile["viewportWidth"] == mobile["documentWidth"] | |
| assert float(desktop["bodyText"]["fontSize"].removesuffix("px")) >= 20 | |
| assert float(desktop["legendTitle"]["fontSize"].removesuffix("px")) >= 22 | |
| assert float(desktop["legendItem"]["fontSize"].removesuffix("px")) >= 18 | |
| assert float(desktop["legendLabel"]["fontSize"].removesuffix("px")) >= 22 | |
| assert float(desktop["legendAction"]["fontSize"].removesuffix("px")) >= 20 | |
| assert len(desktop["legendDots"]) == 3 | |
| assert desktop_dot_widths == [18, 18, 18] | |
| assert desktop_dot_heights == [18, 18, 18] | |
| assert float(desktop["sourceText"]["fontSize"].removesuffix("px")) >= 19 | |
| assert float(desktop["demoText"]["fontSize"].removesuffix("px")) >= 18 | |
| assert ( | |
| float(desktop["demoDropdownControl"]["fontSize"].removesuffix("px")) | |
| >= 21 | |
| ) | |
| assert ( | |
| float(desktop["demoDropdownOption"]["fontSize"].removesuffix("px")) | |
| >= 20 | |
| ) | |
| assert desktop["demoDropdownOption"]["height"] >= 44 | |
| assert ( | |
| desktop["demoDropdownListbox"]["y"] | |
| >= desktop["demoDropdownControl"]["y"] | |
| + desktop["demoDropdownControl"]["height"] | |
| ) | |
| assert desktop["demoDropdownFrontmostRole"] == "option" | |
| assert len(desktop["stepMarkers"]) == 4 | |
| assert all(marker["width"] >= 46 for marker in desktop["stepMarkers"]) | |
| assert all(marker["height"] >= 46 for marker in desktop["stepMarkers"]) | |
| assert max(desktop_step_x) - min(desktop_step_x) <= 1 | |
| assert float(desktop["taskTitle"]["fontSize"].removesuffix("px")) >= 28 | |
| assert float(desktop["taskCopy"]["fontSize"].removesuffix("px")) >= 20 | |
| assert float(desktop["fieldLabel"]["fontSize"].removesuffix("px")) >= 18 | |
| assert ( | |
| float(desktop["uploadGuidance"]["fontSize"].removesuffix("px")) >= 18 | |
| ) | |
| assert float(desktop["primaryButton"]["fontSize"].removesuffix("px")) >= 20 | |
| assert float(desktop["statusText"]["fontSize"].removesuffix("px")) >= 19 | |
| assert float(mobile["bodyText"]["fontSize"].removesuffix("px")) >= 19 | |
| assert float(mobile["legendItem"]["fontSize"].removesuffix("px")) >= 18 | |
| assert float(mobile["legendLabel"]["fontSize"].removesuffix("px")) >= 21 | |
| assert float(mobile["legendAction"]["fontSize"].removesuffix("px")) >= 19 | |
| assert len(mobile["legendDots"]) == 3 | |
| assert mobile_dot_widths == [18, 18, 18] | |
| assert mobile_dot_heights == [18, 18, 18] | |
| assert float(mobile["sourceText"]["fontSize"].removesuffix("px")) >= 19 | |
| assert ( | |
| float(mobile["demoDropdownOption"]["fontSize"].removesuffix("px")) | |
| >= 20 | |
| ) | |
| assert mobile["demoDropdownOption"]["height"] >= 44 | |
| assert ( | |
| mobile["demoDropdownListbox"]["y"] | |
| >= mobile["demoDropdownControl"]["y"] | |
| + mobile["demoDropdownControl"]["height"] | |
| ) | |
| assert mobile["demoDropdownFrontmostRole"] == "option" | |
| assert len(mobile["stepMarkers"]) == 4 | |
| assert all(marker["width"] >= 46 for marker in mobile["stepMarkers"]) | |
| assert all(marker["height"] >= 46 for marker in mobile["stepMarkers"]) | |
| assert max(mobile_step_x) - min(mobile_step_x) <= 1 | |
| assert float(mobile["fieldLabel"]["fontSize"].removesuffix("px")) >= 18 | |
| assert desktop["masthead"]["height"] <= 150 | |
| assert desktop["resultLegend"]["height"] <= 140 | |
| assert desktop["uploadPanel"]["height"] <= 480 | |
| assert desktop["noticeGrid"]["height"] <= 180 | |
| assert desktop["actionGrid"]["height"] <= 110 | |
| assert desktop["workbench"]["y"] <= 225 | |
| assert desktop["uploadPromptInsetTop"] >= 6 | |
| assert desktop["uploadPromptInsetBottom"] >= 6 | |
| assert desktop["documentHeight"] <= 1900 | |
| assert ( | |
| float( | |
| desktop["settingsPanel"]["borderTopWidth"].removesuffix("px") | |
| ) | |
| >= 8 | |
| ) | |
| assert ( | |
| float(desktop["actionPanel"]["borderTopWidth"].removesuffix("px")) | |
| >= 8 | |
| ) | |
| assert ( | |
| abs( | |
| desktop["settingsPanel"]["y"] | |
| - ( | |
| desktop["uploadPanel"]["y"] | |
| + desktop["uploadPanel"]["height"] | |
| ) | |
| ) | |
| <= 2 | |
| ) | |
| assert ( | |
| abs( | |
| desktop["actionPanel"]["y"] | |
| - ( | |
| desktop["settingsPanel"]["y"] | |
| + desktop["settingsPanel"]["height"] | |
| ) | |
| ) | |
| <= 2 | |
| ) | |
| assert mobile["resultLegend"]["height"] <= 180 | |
| assert mobile["masthead"]["height"] <= 340 | |
| assert mobile["workbench"]["y"] <= 410 | |
| assert mobile["workbench"]["width"] / mobile["viewportWidth"] >= 0.9 | |
| assert mobile["uploadPromptInsetTop"] >= 6 | |
| assert mobile["uploadPromptInsetBottom"] >= 6 | |
| assert mobile["documentHeight"] <= 2900 | |
| assert "尚未執行預檢" in desktop["emptyResultHint"] | |
| assert desktop["resultTabs"]["height"] >= 130 | |
| assert desktop["demoSelectorAccessible"] | |
| assert mobile["demoSelectorAccessible"] | |
| assert desktop["documentPickerLabelVisible"] | |
| assert mobile["documentPickerLabelVisible"] | |
| assert not light_theme_contrast["theme"]["browserPrefersDark"] | |
| assert dark_preference_contrast["theme"]["browserPrefersDark"] | |
| assert light_theme_contrast["theme"]["renderedColorScheme"] == "light" | |
| assert dark_preference_contrast["theme"]["renderedColorScheme"] == "light" | |
| for preference in (light_theme_contrast, dark_preference_contrast): | |
| assert preference["minimumContrastRatio"] >= 4.5 | |
| assert report["consent_control_default_unchecked"] | |
| assert report["consent_error_visible_before_api_call"] | |
| assert report["persistent_status_near_action"] | |
| assert report["button_reenabled_after_error"] | |
| assert report["demo_file_loaded_without_api"] | |
| assert report["demo_status_visible"] | |
| assert report["demo_schema_updated"] | |
| if VERIFY_ACTION_PLAN: | |
| assert report["action_plan_visible"] | |
| assert report["action_plan_next_step_visible"] | |
| assert report["action_plan_plain_language_visible"] | |
| assert report["action_plan_machine_paths_hidden"] | |
| assert provenance["selectorHeight"] >= 44 | |
| assert provenance["selectorFocusOutlineWidth"] >= 2 | |
| assert provenance["selectorFocusOutlineStyle"] != "none" | |
| assert not provenance["mobileHorizontalOverflow"] | |
| verified = provenance["verified"] | |
| assert "provenance-verified" in verified["cardClass"] | |
| assert "已核驗來源" in verified["text"] | |
| assert "PDF 內建文字層" in verified["text"] | |
| assert verified["pagePreviewVisible"] | |
| ambiguous = provenance["ambiguous"] | |
| assert "provenance-ambiguous" in ambiguous["cardClass"] | |
| assert "provenance-verified" not in ambiguous["cardClass"] | |
| assert "找到多處相同證據" in ambiguous["text"] | |
| page_only = provenance["pageOnly"] | |
| assert "provenance-page_only" in page_only["cardClass"] | |
| assert "provenance-verified" not in page_only["cardClass"] | |
| assert "未經本機驗證" in page_only["text"] | |
| unresolved = provenance["unresolved"] | |
| assert "provenance-unresolved" in unresolved["cardClass"] | |
| assert "provenance-verified" not in unresolved["cardClass"] | |
| assert "找不到這段證據" in unresolved["text"] | |
| assert not unresolved["pagePreviewVisible"] | |
| assert not console_errors | |
| assert not page_errors | |
| print(json.dumps(report, ensure_ascii=False)) | |
| if __name__ == "__main__": | |
| main() | |