generated from coulomb/repo-seed
Add console visual checks
This commit is contained in:
8
Makefile
8
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
|
UV ?= /home/worsch/.local/bin/uv
|
||||||
PYTHONPATH ?= src
|
PYTHONPATH ?= src
|
||||||
@@ -21,3 +21,9 @@ openapi:
|
|||||||
|
|
||||||
migrate-validate:
|
migrate-validate:
|
||||||
PYTHONPATH=$(PYTHONPATH) $(UV) run --extra dev python scripts/core_hub_migrate.py validate contracts/fixtures/migration/interhub-minimal.bundle.json
|
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
|
||||||
|
|||||||
@@ -7,7 +7,23 @@
|
|||||||
- contract tests from fixtures
|
- contract tests from fixtures
|
||||||
- migration tests for Alembic revisions and imports
|
- migration tests for Alembic revisions and imports
|
||||||
- consumer smokes for ops-hub and activity-core
|
- 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
|
## Release Gates
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ dev = [
|
|||||||
"pytest-asyncio>=0.23",
|
"pytest-asyncio>=0.23",
|
||||||
"ruff>=0.8",
|
"ruff>=0.8",
|
||||||
"aiosqlite>=0.20",
|
"aiosqlite>=0.20",
|
||||||
|
"playwright>=1.60.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
|
|||||||
379
scripts/check_console_visual.py
Executable file
379
scripts/check_console_visual.py
Executable 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
33
uv.lock
generated
@@ -142,6 +142,7 @@ dependencies = [
|
|||||||
[package.optional-dependencies]
|
[package.optional-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
{ name = "aiosqlite" },
|
{ name = "aiosqlite" },
|
||||||
|
{ name = "playwright" },
|
||||||
{ name = "pytest" },
|
{ name = "pytest" },
|
||||||
{ name = "pytest-asyncio" },
|
{ name = "pytest-asyncio" },
|
||||||
{ name = "ruff" },
|
{ name = "ruff" },
|
||||||
@@ -154,6 +155,7 @@ requires-dist = [
|
|||||||
{ name = "asyncpg", specifier = ">=0.29" },
|
{ name = "asyncpg", specifier = ">=0.29" },
|
||||||
{ name = "fastapi", specifier = ">=0.115" },
|
{ name = "fastapi", specifier = ">=0.115" },
|
||||||
{ name = "httpx", specifier = ">=0.27" },
|
{ name = "httpx", specifier = ">=0.27" },
|
||||||
|
{ name = "playwright", marker = "extra == 'dev'", specifier = ">=1.60.0" },
|
||||||
{ name = "pydantic", specifier = ">=2.8" },
|
{ name = "pydantic", specifier = ">=2.8" },
|
||||||
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=7.4" },
|
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=7.4" },
|
||||||
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23" },
|
{ 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 },
|
{ 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]]
|
[[package]]
|
||||||
name = "pluggy"
|
name = "pluggy"
|
||||||
version = "1.6.0"
|
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 },
|
{ 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]]
|
[[package]]
|
||||||
name = "pygments"
|
name = "pygments"
|
||||||
version = "2.20.0"
|
version = "2.20.0"
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ type: workplan
|
|||||||
title: "Design operator console and whynot UI adapters"
|
title: "Design operator console and whynot UI adapters"
|
||||||
domain: infotech
|
domain: infotech
|
||||||
repo: core-hub
|
repo: core-hub
|
||||||
status: active
|
status: finished
|
||||||
owner: codex
|
owner: codex
|
||||||
topic_slug: custodian
|
topic_slug: custodian
|
||||||
created: "2026-06-27"
|
created: "2026-06-27"
|
||||||
@@ -53,9 +53,9 @@ Result 2026-06-27: Added protected `/console` prototype in FastAPI with whynot-a
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: CORE-WP-0006-T04
|
id: CORE-WP-0006-T04
|
||||||
status: wait
|
status: done
|
||||||
priority: low
|
priority: low
|
||||||
state_hub_task_id: "12628328-6699-4abf-aa95-c0a07542f3e9"
|
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/`.
|
||||||
|
|||||||
Reference in New Issue
Block a user