"""Audit measurable accessibility, responsive, and result-state UI behavior.""" 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 Page, sync_playwright ROOT = Path(__file__).resolve().parents[1] DEFAULT_AUDIT_OUTPUT_DIR = ROOT / "outputs" / "ui-audit" AUDIT_OUTPUT_DIR = Path( os.environ.get("UI_TEST_OUTPUT_DIR", DEFAULT_AUDIT_OUTPUT_DIR) ) REPORT_PATH = AUDIT_OUTPUT_DIR / "ui-quality-audit.json" URL = os.environ.get("UI_TEST_URL", "http://127.0.0.1:7862") 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 result audit at a real provider-backed app.""" 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 _dom_scan(page: Page) -> dict: return page.evaluate( """() => { const visible = element => { const style = getComputedStyle(element); const box = element.getBoundingClientRect(); return style.visibility !== "hidden" && style.display !== "none" && box.width > 0 && box.height > 0; }; const nameOf = element => { const labelledBy = element.getAttribute("aria-labelledby"); if (labelledBy) { return labelledBy.split(/\\s+/) .map(id => document.getElementById(id)?.textContent || "") .join(" ") .trim(); } const labels = element.labels ? [...element.labels].map(label => label.textContent || "").join(" ") : ""; return ( element.getAttribute("aria-label") || labels || element.getAttribute("title") || element.textContent || element.getAttribute("value") || "" ).trim().replace(/\\s+/g, " "); }; const interactiveSelector = [ "a[href]", "button", "input:not([type=hidden])", "select", "textarea", "[role=button]", "[role=checkbox]", "[role=combobox]", "[role=tab]" ].join(","); const interactive = [...document.querySelectorAll(interactiveSelector)] .filter(visible) .map(element => { const target = element instanceof HTMLInputElement && element.type === "checkbox" && element.labels?.length ? element.labels[0] : element; const box = target.getBoundingClientRect(); return { tag: element.tagName.toLowerCase(), role: element.getAttribute("role") || "", name: nameOf(element), className: String(element.className || ""), parentClasses: [ element.parentElement?.className || "", element.parentElement?.parentElement?.className || "" ].map(value => String(value)), ariaLabel: element.getAttribute("aria-label") || "", width: Math.round(box.width), height: Math.round(box.height), html: element.outerHTML.slice(0, 500) }; }); const ids = [...document.querySelectorAll("[id]")] .map(element => element.id) .filter(Boolean); const duplicates = [...new Set(ids.filter( (id, index) => ids.indexOf(id) !== index ))]; const headings = [...document.querySelectorAll("h1,h2,h3,h4,h5,h6")] .filter(visible) .map(element => ({ level: Number(element.tagName.slice(1)), text: (element.textContent || "").trim().replace(/\\s+/g, " ") })); const headingSkips = headings.slice(1).filter( (heading, index) => heading.level > headings[index].level + 1 ); return { title: document.title, lang: document.documentElement.lang, mainLandmarks: document.querySelectorAll("main,[role=main]").length, headings, headingSkips, duplicateIds: duplicates, interactiveCount: interactive.length, unnamedInteractive: interactive.filter(item => !item.name), undersizedInteractive: interactive.filter( item => item.width < 44 || item.height < 44 ) }; }""" ) def _focus_scan(page: Page, limit: int = 24) -> list[dict]: page.evaluate( """() => { if (document.activeElement instanceof HTMLElement) { document.activeElement.blur(); } }""" ) items = [] seen: set[tuple[str, str, str]] = set() for _ in range(limit): page.keyboard.press("Tab") item = page.evaluate( """() => { const element = document.activeElement; if (!(element instanceof HTMLElement)) return null; const style = getComputedStyle(element); const box = element.getBoundingClientRect(); const labels = element.labels ? [...element.labels].map(label => label.textContent || "").join(" ") : ""; const name = ( element.getAttribute("aria-label") || labels || element.getAttribute("title") || element.textContent || element.getAttribute("value") || "" ).trim().replace(/\\s+/g, " "); const outlineVisible = style.outlineStyle !== "none" && parseFloat(style.outlineWidth || "0") > 0; const shadowVisible = style.boxShadow !== "none"; return { tag: element.tagName.toLowerCase(), role: element.getAttribute("role") || "", name, className: String(element.className || ""), top: Math.round(box.top), outline: style.outline, boxShadow: style.boxShadow, focusVisible: outlineVisible || shadowVisible }; }""" ) if not item or item["tag"] == "body": break signature = (item["tag"], item["role"], item["name"]) if signature in seen: break seen.add(signature) items.append(item) return items def _performance_scan(page: Page) -> dict: return page.evaluate( """() => { const navigation = performance.getEntriesByType("navigation")[0]; const resources = performance.getEntriesByType("resource"); return { domContentLoadedMs: Math.round( navigation.domContentLoadedEventEnd - navigation.startTime ), loadMs: Math.round(navigation.loadEventEnd - navigation.startTime), resourceCount: resources.length, transferBytes: Math.round(resources.reduce( (total, entry) => total + (entry.transferSize || 0), 0 )) }; }""" ) def _result_case(browser, key: str, expected_level: str) -> dict: context = browser.new_context( viewport={"width": 1440, "height": 1000}, color_scheme="light", ) page = context.new_page() console_errors: list[str] = [] page_errors: list[str] = [] 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) demo_path = DEMO_DIR / f"{key}.png" page.locator('#document-upload input[type="file"]').set_input_files( demo_path ) page.get_by_text(demo_path.name, exact=True).wait_for(state="visible") page.locator("#cloud-consent input").check() page.locator(".primary-btn").click() status = page.locator(f".status-card.status-{expected_level}") status.wait_for(state="visible", timeout=30_000) action_plan = page.locator("#action-plan") action_plan_text = action_plan.inner_text() result = { "level": expected_level, "statusText": status.inner_text(), "actionPlanText": action_plan_text, "hasNextStep": "下一步" in status.inner_text(), "hasActionInstruction": ( "怎麼處理" in action_plan_text if expected_level != "green" else "人工確認" in action_plan_text ), "machinePathVisible": any( marker in action_plan_text for marker in ("applicants.", "line_items.", "required.", "amount.") ), "consoleErrors": console_errors, "pageErrors": page_errors, } context.close() return result 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", ) REPORT_PATH.parent.mkdir(parents=True, exist_ok=True) with sync_playwright() as playwright: browser = playwright.chromium.launch(headless=True) desktop_context = browser.new_context( viewport={"width": 1440, "height": 1000}, color_scheme="light", ) desktop_page = desktop_context.new_page() open_app(desktop_page) desktop = _dom_scan(desktop_page) focus = _focus_scan(desktop_page) performance = _performance_scan(desktop_page) desktop_context.close() mobile_context = browser.new_context( viewport={"width": 390, "height": 844}, device_scale_factor=2, has_touch=True, color_scheme="light", ) mobile_page = mobile_context.new_page() open_app(mobile_page) mobile = _dom_scan(mobile_page) mobile_overflow = mobile_page.evaluate( "document.documentElement.scrollWidth > window.innerWidth" ) mobile_context.close() result_cases = { "subsidy_green": _result_case(browser, "subsidy_green", "green"), "subsidy_yellow": _result_case(browser, "subsidy_yellow", "yellow"), "subsidy_red": _result_case(browser, "subsidy_red", "red"), "receipt_green": _result_case(browser, "receipt_green", "green"), } browser.close() report = { "url": URL, "desktop": desktop, "mobile": mobile, "mobileHorizontalOverflow": mobile_overflow, "focusSequence": focus, "focusWithoutVisibleIndicator": [ item for item in focus if not item["focusVisible"] ], "performance": performance, "resultCases": result_cases, } REPORT_PATH.write_text( json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8", ) print(json.dumps(report, ensure_ascii=False)) if __name__ == "__main__": main()