diff --git a/Makefile b/Makefile index 7293de9..49b3b25 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: install test lint run openapi migrate-validate +.PHONY: install test lint run openapi migrate-validate playwright-install visual-check UV ?= /home/worsch/.local/bin/uv PYTHONPATH ?= src @@ -21,3 +21,9 @@ openapi: migrate-validate: PYTHONPATH=$(PYTHONPATH) $(UV) run --extra dev python scripts/core_hub_migrate.py validate contracts/fixtures/migration/interhub-minimal.bundle.json + +playwright-install: + $(UV) run --extra dev playwright install chromium + +visual-check: + PYTHONPATH=$(PYTHONPATH) $(UV) run --extra dev python scripts/check_console_visual.py diff --git a/docs/specs/testing-release-and-migration.md b/docs/specs/testing-release-and-migration.md index 6e75d72..1183556 100644 --- a/docs/specs/testing-release-and-migration.md +++ b/docs/specs/testing-release-and-migration.md @@ -7,7 +7,23 @@ - contract tests from fixtures - migration tests for Alembic revisions and imports - consumer smokes for ops-hub and activity-core -- optional Playwright visual checks once UI exists +- Playwright visual checks for protected operator UI desktop and mobile layouts + +## Operator UI Visual Checks + +The protected `/console` route is checked with Playwright using `make visual-check`. +The harness starts a disposable SQLite-backed Core Hub instance, seeds non-secret +operator data, verifies unauthenticated access returns `401`, renders authenticated +desktop and mobile screenshots, checks for horizontal overflow and overlapping +major regions, and asserts full API key material is absent from DOM state and +visual artifacts. Generated screenshots are written under `.local/visual-checks/` +and must not be committed. + +Install the Chromium runtime once per workstation with: + +```bash +make playwright-install +``` ## Release Gates diff --git a/pyproject.toml b/pyproject.toml index a5580c5..5700172 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,6 +20,7 @@ dev = [ "pytest-asyncio>=0.23", "ruff>=0.8", "aiosqlite>=0.20", + "playwright>=1.60.0", ] [tool.pytest.ini_options] diff --git a/scripts/check_console_visual.py b/scripts/check_console_visual.py new file mode 100755 index 0000000..eb61b42 --- /dev/null +++ b/scripts/check_console_visual.py @@ -0,0 +1,379 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import os +import socket +import struct +import sys +import tempfile +import threading +import time +from pathlib import Path +from typing import Any + +import httpx +import uvicorn + +try: + from playwright.sync_api import sync_playwright +except ImportError as exc: # pragma: no cover - exercised only when dev deps are missing + raise SystemExit( + "Playwright is required for visual checks. Run `uv sync --extra dev` and " + "`uv run --extra dev playwright install chromium`." + ) from exc + +OPERATOR_TOKEN = "operator-token" +OPERATOR_HEADERS = {"Authorization": f"Bearer {OPERATOR_TOKEN}"} +DEFAULT_OUTPUT_DIR = Path(".local/visual-checks/console") +VIEWPORTS = { + "desktop": {"width": 1440, "height": 1100}, + "mobile": {"width": 390, "height": 1100}, +} + +LAYOUT_CHECK = """ +({ fullKey, keyPrefix }) => { + const failures = []; + const viewportWidth = window.innerWidth; + const doc = document.documentElement; + + if (doc.scrollWidth > viewportWidth + 1) { + failures.push(`document horizontal overflow: ${doc.scrollWidth}px > ${viewportWidth}px`); + } + + const rectFor = (label, selector) => { + const element = document.querySelector(selector); + if (!element) { + failures.push(`missing element: ${label} (${selector})`); + return null; + } + const rect = element.getBoundingClientRect(); + if (rect.width <= 0 || rect.height <= 0) { + failures.push(`empty box: ${label}`); + return null; + } + return { + label, + selector, + left: rect.left, + right: rect.right, + top: rect.top, + bottom: rect.bottom, + width: rect.width, + height: rect.height, + }; + }; + + const overlaps = (a, b) => ( + a.left < b.right - 1 && a.right > b.left + 1 && + a.top < b.bottom - 1 && a.bottom > b.top + 1 + ); + + const compareNonOverlap = (items) => { + for (let i = 0; i < items.length; i += 1) { + for (let j = i + 1; j < items.length; j += 1) { + if (items[i] && items[j] && overlaps(items[i], items[j])) { + failures.push(`overlap: ${items[i].label} intersects ${items[j].label}`); + } + } + } + }; + + compareNonOverlap([ + rectFor("top navigation", ".wn-topnav"), + rectFor("sidebar", ".wn-sidebar"), + rectFor("main", ".console-main"), + ]); + + compareNonOverlap([ + rectFor("overview", "#overview"), + rectFor("counters", ".kpi-grid"), + rectFor("readiness gates", "section[aria-labelledby='gates-heading']"), + rectFor("registry", "#registry"), + rectFor("migration", "#migration"), + rectFor("access", "#access"), + rectFor("events", "#events"), + ]); + + compareNonOverlap([ + rectFor("top navigation brand", ".wn-topnav__brand"), + rectFor("top navigation state", ".wn-topnav__right"), + ]); + + const viewportBoundedSelectors = [ + ".wn-topnav", + ".wn-sidebar", + ".console-main", + ".wn-page-header", + ".kpi-grid", + ".wn-card", + ".gate", + ".gate-list", + ".table-wrap", + ".section-head", + ".wn-btn", + ]; + for (const selector of viewportBoundedSelectors) { + for (const element of document.querySelectorAll(selector)) { + const rect = element.getBoundingClientRect(); + if (rect.left < -1 || rect.right > viewportWidth + 1) { + const bounds = `${Math.round(rect.left)}..${Math.round(rect.right)}`; + failures.push(`${selector} escapes viewport: ${bounds} of ${viewportWidth}`); + } + } + } + + const intrinsicSelectors = [ + ".wn-btn", + ".wn-tag", + ".wn-sidebar__item", + ".stat-card span", + ".stat-card strong", + ".wn-topnav__brand", + ".wn-topnav__right", + ]; + for (const selector of intrinsicSelectors) { + for (const element of document.querySelectorAll(selector)) { + if (element.scrollWidth > Math.ceil(element.clientWidth) + 1) { + failures.push(`${selector} text overflows its own box`); + } + } + } + + const openApi = document.querySelector(".wn-btn[href='/api/v2/openapi.json']"); + if (!openApi) { + failures.push("missing OpenAPI control"); + } else { + const rect = openApi.getBoundingClientRect(); + if (rect.width < 44 || rect.height < 32) { + failures.push("OpenAPI control is too small to operate comfortably"); + } + } + + const text = document.body.innerText; + if (!text.includes("Operator console") || !text.includes("ops-hub")) { + failures.push("expected console content is not visible"); + } + if (!text.includes(keyPrefix)) { + failures.push("non-secret key prefix is not visible in access table"); + } + if (text.includes(fullKey) || document.documentElement.outerHTML.includes(fullKey)) { + failures.push("full API key leaked into rendered console"); + } + if (document.documentElement.outerHTML.includes("fullKey")) { + failures.push("fullKey field name leaked into rendered console"); + } + + return failures; +} +""" + + +def configure_environment(database_path: Path) -> None: + os.environ["CORE_HUB_DATABASE_URL"] = f"sqlite+aiosqlite:///{database_path}" + os.environ["CORE_HUB_API_TOKEN"] = OPERATOR_TOKEN + os.environ["CORE_HUB_AUTO_CREATE_TABLES"] = "1" + + from core_hub.config import get_settings + from core_hub.db import get_engine, get_sessionmaker + + get_settings.cache_clear() + get_engine.cache_clear() + get_sessionmaker.cache_clear() + + +def reset_environment_caches() -> None: + from core_hub.config import get_settings + from core_hub.db import get_engine, get_sessionmaker + + get_settings.cache_clear() + get_engine.cache_clear() + get_sessionmaker.cache_clear() + + +def start_server() -> tuple[uvicorn.Server, threading.Thread, str]: + from core_hub.app import create_app + + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.bind(("127.0.0.1", 0)) + host, port = sock.getsockname() + config = uvicorn.Config(create_app(), log_level="warning", lifespan="on") + server = uvicorn.Server(config) + thread = threading.Thread(target=server.run, kwargs={"sockets": [sock]}, daemon=True) + thread.start() + + base_url = f"http://{host}:{port}" + deadline = time.monotonic() + 10 + while time.monotonic() < deadline: + try: + response = httpx.get(f"{base_url}/healthz", timeout=0.5) + except httpx.HTTPError: + time.sleep(0.1) + continue + if response.status_code == 200: + return server, thread, base_url + time.sleep(0.1) + + server.should_exit = True + thread.join(timeout=5) + raise RuntimeError("Timed out waiting for the visual check server to start") + + +def stop_server(server: uvicorn.Server, thread: threading.Thread) -> None: + server.should_exit = True + thread.join(timeout=5) + + +def seed_console_data(base_url: str) -> dict[str, Any]: + with httpx.Client(base_url=base_url, headers=OPERATOR_HEADERS, timeout=5) as client: + hub = client.post( + "/api/v2/hubs", + json={ + "slug": "ops-hub", + "name": "Ops Hub", + "domain": "ops.coulomb.social", + "hubKind": "domain", + "hubFamily": "vsm", + "vsmFunction": "OPS", + "vsmSystem": "1", + }, + ).raise_for_status().json() + manifest = client.post( + "/api/v2/hub-capability-manifests", + json={"hubId": hub["id"], "manifestVersion": "1.0"}, + ).raise_for_status().json() + client.post(f"/api/v2/hub-capability-manifests/{manifest['id']}/activate").raise_for_status() + consumer = client.post( + "/api/v2/api-consumers", + json={ + "name": "ops-hub", + "description": "API consumer for the VSM Operations hub", + "hubCapabilityManifestId": manifest["id"], + "rateLimitPerMinute": 120, + }, + ).raise_for_status().json() + key_payload = client.post( + f"/api/v2/api-consumers/{consumer['id']}/api-keys", + json={"scopes": "framework:read hub:ops-hub:write"}, + ).raise_for_status().json() + + runtime_headers = {"Authorization": f"Bearer {key_payload['fullKey']}"} + with httpx.Client(base_url=base_url, headers=runtime_headers, timeout=5) as client: + widget = client.post( + "/api/v2/widgets", + json={ + "hubId": hub["id"], + "name": "Readiness", + "widgetType": "ops-readiness-gate", + "capabilityRef": "ops:readiness", + "viewContext": "ops-hub/readiness", + "policyScope": "ops-registry", + }, + ).raise_for_status().json() + client.post( + "/api/v2/interaction-events", + json={ + "widgetId": widget["id"], + "eventType": "ops-endpoint-verified", + "metadata": {"expectedStatus": 401}, + }, + ).raise_for_status() + + return key_payload + + +def png_width(path: Path) -> int: + with path.open("rb") as handle: + header = handle.read(24) + if len(header) < 24 or header[:8] != b"\x89PNG\r\n\x1a\n": + raise RuntimeError(f"{path} is not a valid PNG screenshot") + return struct.unpack(">II", header[16:24])[0] + + +def check_protected_route(base_url: str) -> None: + with sync_playwright() as playwright: + browser = playwright.chromium.launch() + try: + context = browser.new_context(viewport={"width": 390, "height": 800}) + page = context.new_page() + response = page.goto(f"{base_url}/console", wait_until="domcontentloaded") + status = response.status if response else None + if status != 401: + raise RuntimeError(f"Unauthenticated /console returned {status}, expected 401") + context.close() + finally: + browser.close() + + +def check_viewports(base_url: str, key_payload: dict[str, Any], output_dir: Path) -> list[Path]: + output_dir.mkdir(parents=True, exist_ok=True) + screenshots: list[Path] = [] + with sync_playwright() as playwright: + browser = playwright.chromium.launch() + try: + for name, viewport in VIEWPORTS.items(): + context = browser.new_context( + viewport=viewport, + extra_http_headers=OPERATOR_HEADERS, + device_scale_factor=1, + ) + page = context.new_page() + response = page.goto(f"{base_url}/console", wait_until="networkidle") + status = response.status if response else None + if status != 200: + raise RuntimeError(f"Authenticated /console returned {status}, expected 200") + + failures = page.evaluate( + LAYOUT_CHECK, + { + "fullKey": key_payload["fullKey"], + "keyPrefix": key_payload["apiKey"]["keyPrefix"], + }, + ) + if failures: + joined = "\n - ".join(failures) + raise RuntimeError(f"{name} layout failures:\n - {joined}") + + screenshot = output_dir / f"console-{name}.png" + page.screenshot(path=str(screenshot), full_page=True) + if screenshot.stat().st_size < 1024: + raise RuntimeError(f"{screenshot} looks empty") + width = png_width(screenshot) + if width != viewport["width"]: + raise RuntimeError( + f"{screenshot} width is {width}px, expected {viewport['width']}px" + ) + screenshots.append(screenshot) + context.close() + finally: + browser.close() + return screenshots + + +def main() -> int: + output_dir = Path(sys.argv[1]) if len(sys.argv) > 1 else DEFAULT_OUTPUT_DIR + unauthorized_screenshot = output_dir / "console-unauthorized.png" + if unauthorized_screenshot.exists(): + unauthorized_screenshot.unlink() + + with tempfile.TemporaryDirectory(prefix="core-hub-console-visual-") as tmp: + configure_environment(Path(tmp) / "core-hub.db") + server, thread, base_url = start_server() + try: + key_payload = seed_console_data(base_url) + check_protected_route(base_url) + screenshots = check_viewports(base_url, key_payload, output_dir) + finally: + stop_server(server, thread) + reset_environment_caches() + + if unauthorized_screenshot.exists(): + raise RuntimeError("unauthorized console screenshot was created unexpectedly") + + print("Console visual checks passed.") + for screenshot in screenshots: + print(f"- {screenshot}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/uv.lock b/uv.lock index 55c5325..2559bd8 100644 --- a/uv.lock +++ b/uv.lock @@ -142,6 +142,7 @@ dependencies = [ [package.optional-dependencies] dev = [ { name = "aiosqlite" }, + { name = "playwright" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "ruff" }, @@ -154,6 +155,7 @@ requires-dist = [ { name = "asyncpg", specifier = ">=0.29" }, { name = "fastapi", specifier = ">=0.115" }, { name = "httpx", specifier = ">=0.27" }, + { name = "playwright", marker = "extra == 'dev'", specifier = ">=1.60.0" }, { name = "pydantic", specifier = ">=2.8" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.4" }, { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23" }, @@ -420,6 +422,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195 }, ] +[[package]] +name = "playwright" +version = "1.60.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet" }, + { name = "pyee" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/21/f0/832bd9677194908da118064eef20082f2791e3d18215cc6d9391ee2c5a67/playwright-1.60.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:6a8cd0fec171fb3089e95e898c8bc8a6f35dea0b78b399e12fcc19427e91b1d7", size = 43474635 }, + { url = "https://files.pythonhosted.org/packages/59/7b/e1d32ae8a3ed937ec2be3721c5f728b13d731a0b7c6442e0b3bec5094ac0/playwright-1.60.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:39b5420ba6145045b69ced4c5c47d4d9fe5bddfc8ff816c518913afcb25ec7a5", size = 42261327 }, + { url = "https://files.pythonhosted.org/packages/d7/bc/23de499ded6411c188a20c5a0dea6f0cd4ed5d2b3cc6042a5dbd3ed609aa/playwright-1.60.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:2581d0e6a3392c71f91b27460c7fd093356818dc430f48153896c8aeeaef7705", size = 43474636 }, + { url = "https://files.pythonhosted.org/packages/22/7b/1d679f4fced4ea94efadd17103856d8c565384f68382a1681264e46f5925/playwright-1.60.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:1c2bfae7884fb3fb05b853290eab8f343d524e5016f2f1def702acbbdf14c93e", size = 47467220 }, + { url = "https://files.pythonhosted.org/packages/84/c2/1528d267d4442bd2c6b8eaeab819dd52c2030bf80e89293f0ba1f687473b/playwright-1.60.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:43e66564125ee31b07a58cefb21e256d62d67d8d1713e6858df7a3019d8ed353", size = 47154856 }, + { url = "https://files.pythonhosted.org/packages/bb/4e/b008b6440a7a1624378041da94829956d4b8f7ab9ef5aad22d0dc3f2e26d/playwright-1.60.0-py3-none-win32.whl", hash = "sha256:ec94e416ea320711e0ad4bf185dcbf41833672961e90773e1885255d7db7b7e7", size = 37902157 }, + { url = "https://files.pythonhosted.org/packages/55/f0/0541524133104f9cc20bf900870ff4a736b76a23483f3a55295ddfa58409/playwright-1.60.0-py3-none-win_amd64.whl", hash = "sha256:9566821ce6030a1f9e7146a24e19355ab0d98805fd0f9be50bb3d8fef1750c02", size = 37902159 }, + { url = "https://files.pythonhosted.org/packages/80/c8/210f282d278e4709cdd71b12a31af45a30a22ab3207b387e29b37e478713/playwright-1.60.0-py3-none-win_arm64.whl", hash = "sha256:6e4f6700a4c2250efff8e690a81d66e3855754fb587b6b87cf5c784014f91537", size = 34037981 }, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -523,6 +544,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590 }, ] +[[package]] +name = "pyee" +version = "13.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/04/e7c1fe4dc78a6fdbfd6c337b1c3732ff543b8a397683ab38378447baa331/pyee-13.0.1.tar.gz", hash = "sha256:0b931f7c14535667ed4c7e0d531716368715e860b988770fc7eb8578d1f67fc8", size = 31655 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/c4/b4d4827c93ef43c01f599ef31453ccc1c132b353284fc6c87d535c233129/pyee-13.0.1-py3-none-any.whl", hash = "sha256:af2f8fede4171ef667dfded53f96e2ed0d6e6bd7ee3bb46437f77e3b57689228", size = 15659 }, +] + [[package]] name = "pygments" version = "2.20.0" diff --git a/workplans/CORE-WP-0006-operator-console-and-whynot-ui.md b/workplans/CORE-WP-0006-operator-console-and-whynot-ui.md index f03cde7..c6db5d9 100644 --- a/workplans/CORE-WP-0006-operator-console-and-whynot-ui.md +++ b/workplans/CORE-WP-0006-operator-console-and-whynot-ui.md @@ -4,7 +4,7 @@ type: workplan title: "Design operator console and whynot UI adapters" domain: infotech repo: core-hub -status: active +status: finished owner: codex topic_slug: custodian created: "2026-06-27" @@ -53,9 +53,9 @@ Result 2026-06-27: Added protected `/console` prototype in FastAPI with whynot-a ```task id: CORE-WP-0006-T04 -status: wait +status: done priority: low state_hub_task_id: "12628328-6699-4abf-aa95-c0a07542f3e9" ``` -Add Playwright visual checks for `/console` across desktop and mobile, including non-overlap checks and protected-state screenshot handling. +Result 2026-06-27: Added `make visual-check` backed by `scripts/check_console_visual.py`, with Playwright Chromium desktop/mobile screenshots, protected `/console` auth handling, non-overlap and horizontal-overflow checks, PNG validation, and full-key non-disclosure assertions. Screenshots are written under ignored `.local/visual-checks/`.