diff --git a/.github/workflows/site-measurement.yml b/.github/workflows/site-measurement.yml index 8c51228b6..fbf459080 100644 --- a/.github/workflows/site-measurement.yml +++ b/.github/workflows/site-measurement.yml @@ -22,6 +22,7 @@ on: - "scripts/inject-site-measurement.py" - "scripts/validate-site-measurement.py" - "scripts/check-site-health.py" + - "scripts/test-public-browser-qa.py" - "scripts/check-measurement-readiness.py" - "scripts/build-site-measurement-report.py" - "scripts/validate-measurement-report.py" @@ -38,6 +39,7 @@ on: - "scripts/inject-site-measurement.py" - "scripts/validate-site-measurement.py" - "scripts/check-site-health.py" + - "scripts/test-public-browser-qa.py" - "scripts/check-measurement-readiness.py" - "scripts/build-site-measurement-report.py" - "scripts/validate-measurement-report.py" @@ -69,8 +71,10 @@ jobs: with: python-version: "3.12" - - name: Install offline report dependency - run: python -m pip install --disable-pip-version-check --quiet requests + - name: Install measurement and browser QA dependencies + run: | + python -m pip install --disable-pip-version-check --quiet requests "playwright==1.56.0" + python -m playwright install --with-deps chromium - name: Prepare deterministic stable release evidence shell: bash @@ -110,6 +114,9 @@ jobs: continue-on-error: true run: python scripts/check-site-health.py --site _site --output _measurement + - name: Exercise real Chromium bilingual and mobile navigation QA + run: python scripts/test-public-browser-qa.py --site _site --output _measurement/browser-qa + - name: Exercise disabled readiness state env: GA4_MEASUREMENT_ID: "" diff --git a/landing/adoption.css b/landing/adoption.css index 6b29f24db..4a0329ab3 100644 --- a/landing/adoption.css +++ b/landing/adoption.css @@ -206,6 +206,8 @@ gap: 1rem; } .supply-chain-note > * { + min-width: 0; + overflow-wrap: anywhere; padding: 1rem; border: 1px solid var(--line); border-radius: var(--radius-sm); @@ -217,7 +219,7 @@ .quick-start-grid, .evidence-grid, .faq-grid, - .supply-chain-note { grid-template-columns: 1fr; } + .supply-chain-note { grid-template-columns: minmax(0, 1fr); } .demo-shell { grid-template-columns: 1fr; } .demo-controls { flex-direction: row; } .demo-step-button { width: auto; } diff --git a/landing/download.css b/landing/download.css index f19557c58..773921d2b 100644 --- a/landing/download.css +++ b/landing/download.css @@ -1 +1,3 @@ .download-hero{padding-bottom:2.5rem}.release-status{display:inline-flex;align-items:center;gap:.62rem;margin-top:1.35rem;padding:.62rem .78rem;border:1px solid rgba(56,189,248,.2);border-radius:13px;color:var(--muted);background:rgba(56,189,248,.055);font-size:.88rem}.download-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:1rem}.download-card{display:flex;flex-direction:column;min-height:410px;padding:1.5rem;border:1px solid var(--line);border-radius:var(--radius);background:linear-gradient(180deg,rgba(255,255,255,.065),rgba(255,255,255,.032));box-shadow:0 18px 55px rgba(0,0,0,.18)}.download-card.recommended{border-color:rgba(56,189,248,.28);background:radial-gradient(circle at 10% 0%,rgba(56,189,248,.13),transparent 18rem),linear-gradient(180deg,rgba(59,130,246,.09),rgba(255,255,255,.03))}.download-card h2{margin-top:.65rem;font-size:clamp(1.75rem,3vw,2.45rem)}.download-card p{color:var(--muted)}.download-meta{margin-top:auto;padding:.85rem 0;color:var(--subtle);font-size:.86rem;overflow-wrap:anywhere}.download-action{width:100%;margin-top:auto}.download-action[aria-disabled="true"]{opacity:.62;pointer-events:none}.download-card .kicker{align-self:flex-start}.release-facts{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:.8rem}.release-fact{padding:1rem 1.05rem;border:1px solid var(--line);border-radius:15px;background:rgba(255,255,255,.035)}.release-fact span{display:block;color:var(--subtle);font-size:.78rem;text-transform:uppercase;letter-spacing:.06em}.release-fact strong{display:block;margin-top:.35rem;font-size:1.05rem}.package-meta{display:grid;gap:.48rem;margin:1.1rem 0 1.25rem;padding:1rem 0;border-top:1px solid var(--line);border-bottom:1px solid var(--line)}.package-meta div{display:grid;grid-template-columns:5.5rem minmax(0,1fr);gap:.75rem}.package-meta dt{color:var(--subtle);font-size:.82rem}.package-meta dd{margin:0;color:var(--text);font-size:.85rem;overflow-wrap:anywhere}.package-comparison{overflow:hidden;border:1px solid var(--line);border-radius:var(--radius);background:rgba(255,255,255,.025)}.comparison-row{display:grid;grid-template-columns:minmax(8rem,.8fr) repeat(2,minmax(0,1.3fr));gap:1rem;padding:.92rem 1.05rem;border-top:1px solid var(--line)}.comparison-row:first-child{border-top:0}.comparison-row>span:first-child{color:var(--subtle);font-weight:680}.comparison-head{background:rgba(56,189,248,.075)}.checksum-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:1rem}.checksum-card{padding:1.25rem;border:1px solid var(--line);border-radius:var(--radius-sm);background:rgba(255,255,255,.035)}.checksum-value{display:block;margin:.85rem 0;padding:.85rem;border:1px solid rgba(125,211,252,.16);background:rgba(3,10,20,.58);font-size:.82rem;line-height:1.55;overflow-wrap:anywhere;user-select:all}.copy-button{cursor:pointer}.verification-command{display:grid;grid-template-columns:auto minmax(0,1fr);align-items:center;gap:.9rem;margin-top:1rem;padding:1rem 1.05rem;border:1px solid var(--line);border-radius:14px;background:rgba(255,255,255,.025)}.verification-command span{color:var(--subtle);font-size:.83rem}.verification-command code{overflow-wrap:anywhere}.release-list{margin:.8rem 0 0;padding-left:1.15rem;color:var(--muted)}.release-list li+li{margin-top:.55rem}.warning-panel{border-color:rgba(251,191,36,.25)!important;background:linear-gradient(145deg,rgba(245,158,11,.07),rgba(255,255,255,.025))!important}.trust-badge{display:inline-flex;margin-top:1rem;padding:.48rem .68rem;border-radius:999px;font-size:.78rem;font-weight:750;letter-spacing:.025em}.trust-badge-warning{border:1px solid rgba(251,191,36,.3);color:#fde68a;background:rgba(245,158,11,.1)}.small-note{margin-top:1rem!important;color:var(--subtle)!important;font-size:.82rem}.text-link{display:inline-flex;margin-top:.7rem;color:#bae6fd;font-weight:650}.text-link:hover{text-decoration:underline}@media(max-width:900px){.release-facts{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:760px){.download-grid,.checksum-grid{grid-template-columns:1fr}.download-card{min-height:auto}.comparison-row{grid-template-columns:1fr;gap:.35rem;padding:1rem}.comparison-head{display:none}.comparison-row>span:first-child{margin-bottom:.25rem}.comparison-row>span:nth-child(2):before{content:"Installer: ";font-weight:700;color:var(--text)}.comparison-row>span:nth-child(3):before{content:"Portable: ";font-weight:700;color:var(--text)}.verification-command{grid-template-columns:1fr}}@media(max-width:520px){.release-facts{grid-template-columns:1fr}.package-meta div{grid-template-columns:1fr;gap:.2rem}} +/* Prevent full source commit hashes from expanding mobile release-trust cards. */ +body[data-page="download"] main code { overflow-wrap: anywhere; word-break: break-word; } diff --git a/scripts/test-public-browser-qa.py b/scripts/test-public-browser-qa.py new file mode 100644 index 000000000..4c8b54698 --- /dev/null +++ b/scripts/test-public-browser-qa.py @@ -0,0 +1,177 @@ +#!/usr/bin/env python3 +"""Real Chromium smoke checks of the fully-rendered ARSAS product website. + +The run is explicitly lab/headless evidence, NOT real mobile hardware, WCAG +certification, NVDA sign-off, or CrUX field Core Web Vitals. +""" +from __future__ import annotations + +import argparse +import functools +import json +import threading +from datetime import datetime, timezone +from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from urllib.parse import urljoin + +from playwright.sync_api import sync_playwright + +PAIRS = ( + ("index.html", "id.html"), + ("download.html", "unduh.html"), + ("quick-start.html", "panduan-mulai-arsas.html"), + ("demo.html", "demo-arsas.html"), + ("faq.html", "faq-arsas.html"), + ("compatibility.html", "bukti-kompatibilitas.html"), +) +WIDTHS = (360, 390, 768, 1280, 1920) + + +class QuietHandler(SimpleHTTPRequestHandler): + def log_message(self, *args: object) -> None: + pass + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--site", type=Path, default=Path("_site")) + parser.add_argument("--output", type=Path, default=Path("_browser-qa")) + args = parser.parse_args() + site = args.site.resolve() + if not site.is_dir(): + parser.error(f"Site directory missing: {site}") + output = args.output.resolve() + output.mkdir(parents=True, exist_ok=True) + server = ThreadingHTTPServer( + ("127.0.0.1", 0), + functools.partial(QuietHandler, directory=str(site)), + ) + server.daemon_threads = True + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + root = f"http://127.0.0.1:{server.server_port}/" + failures: list[str] = [] + rows: list[dict[str, object]] = [] + checked = 0 + try: + with sync_playwright() as playwright: + browser = playwright.chromium.launch(headless=True) + try: + for width in WIDTHS: + pages = PAIRS if width in (390, 1280) else PAIRS[:2] + for english, indonesian in pages: + for lang, path in (("en", english), ("id", indonesian)): + checked += 1 + context = browser.new_context( + viewport={"width": width, "height": 850}, + device_scale_factor=1, + reduced_motion="reduce", + ) + page = context.new_page() + errors: list[str] = [] + page.on("pageerror", lambda exc, error_list=errors: error_list.append(str(exc))) + try: + response = page.goto(urljoin(root, path), wait_until="load", timeout=30000) + if response is None or response.status != 200: + raise AssertionError(f"HTTP {response.status if response else 'none'}") + actual_lang = page.locator("html").get_attribute("lang") + if actual_lang != lang: + errors.append(f"html lang={actual_lang}, expected {lang}") + if page.locator("main#main").count() != 1: + errors.append("missing main#main") + h1 = page.locator("main h1").count() + if h1 != 1: + errors.append(f"expected one main h1, got {h1}") + if page.locator('a.skip-link[href="#main"]').count() != 1: + errors.append("broken skip-to-content link") + overflow = page.evaluate( + "() => ({page: document.documentElement.scrollWidth, viewport: innerWidth})" + ) + if overflow["page"] > overflow["viewport"] + 2: + offenders = page.evaluate("""() => [...document.querySelectorAll('body *')] + .filter(el => el.getBoundingClientRect().right > innerWidth + 2) + .slice(0, 8).map(el => ({ + tag: el.tagName.toLowerCase(), + cls: (el.getAttribute('class') || '').slice(0, 70), + right: Math.round(el.getBoundingClientRect().right), + width: Math.round(el.getBoundingClientRect().width), + label: (el.textContent || '').trim().slice(0, 55) + }))""") + errors.append(f"horizontal overflow {overflow['page']}>{overflow['viewport']}; offenders={offenders}") + if path in ("download.html", "unduh.html") and width in (360, 390): + page.screenshot( + path=str(output / f"overflow-{path}-{width}px.png"), + full_page=True, animations="disabled" + ) + if width < 1081: + toggle = page.locator("button[data-menu-toggle]") + if not toggle.is_visible(): + errors.append("mobile navigation toggle hidden") + else: + expected_open = "Buka navigasi" if lang == "id" else "Open navigation" + expected_close = "Tutup navigasi" if lang == "id" else "Close navigation" + if toggle.get_attribute("aria-label") != expected_open: + errors.append("incorrect mobile menu accessible open name") + toggle.click() + if toggle.get_attribute("aria-expanded") != "true": + errors.append("mobile menu did not open") + if toggle.get_attribute("aria-label") != expected_close: + errors.append("incorrect mobile menu accessible close name") + page.keyboard.press("Escape") + if toggle.get_attribute("aria-expanded") != "false": + errors.append("Escape did not close menu") + toggle.click() + page.locator("nav[data-nav-links] a[data-nav-page='capabilities'] span[data-lang='" + lang + "']").click() + if page.url.split("?")[0].split("/")[-1] != "features.html": + errors.append("nested localized navigation click failed") + if page.locator("button[data-menu-toggle]").get_attribute("aria-expanded") != "false": + errors.append("nested link did not close menu") + else: + if not page.locator("nav[data-nav-links]").is_visible(): + errors.append("desktop primary navigation hidden") + if width in (390, 1280) and path in ("index.html", "id.html"): + screenshot_path = output / f"homepage-{lang}-{width}px.png" + page.goto(urljoin(root, path), wait_until="load") + page.screenshot(path=str(screenshot_path), full_page=True, animations="disabled") + except Exception as exc: + errors.append(f"{type(exc).__name__}: {exc}") + finally: + rows.append({"page": path, "width": width, "lang": lang, "errors": errors}) + failures.extend(f"{path} at {width}px: {msg}" for msg in errors) + page.close() + context.close() + finally: + browser.close() + finally: + server.shutdown() + server.server_close() + report = { + "scope": "headless Chromium lab browser smoke; not physical-mobile, NVDA, or field CWV", + "timestamp_utc": datetime.now(timezone.utc).isoformat(), + "checks": checked, + "failures": len(failures), + "cases": rows, + } + (output / "browser-qa.json").write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8") + summary = [ + "# ARSAS public-site browser QA", + "", + f"- Browser: Playwright Chromium (headless)", + f"- Widths: {', '.join(str(x) for x in WIDTHS)} px", + f"- Page/viewport tests: **{checked}**", + f"- Findings: **{len(failures)}**", + "- Scope: viewport overflow, live JS, EN/ID header, headings/skip link, mobile menu Escape and nested-label clicks", + "- Limitations: not NVDA, WCAG certification, physical device, Lighthouse or field Core Web Vitals", + "", + ] + summary += ["## Findings", "", *[f"- {finding}" for finding in failures]] if failures else [ + "No test failures observed in this headless Chromium run." + ] + (output / "browser-qa.md").write_text("\n".join(summary) + "\n", encoding="utf-8") + print("\n".join(summary)) + return 1 if failures else 0 + + +if __name__ == "__main__": + raise SystemExit(main())