Add console visual checks

This commit is contained in:
2026-06-27 14:52:38 +02:00
parent 57057c8104
commit 8c035b0b98
6 changed files with 440 additions and 5 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -20,6 +20,7 @@ dev = [
"pytest-asyncio>=0.23",
"ruff>=0.8",
"aiosqlite>=0.20",
"playwright>=1.60.0",
]
[tool.pytest.ini_options]

379
scripts/check_console_visual.py Executable file
View File

@@ -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())

33
uv.lock generated
View File

@@ -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"

View File

@@ -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/`.