kink-discovery / scripts /ui_visual_audit.py
ronheichman's picture
Sync from kink_cli (Docker Space)
0095355 verified
Raw
History Blame Contribute Delete
13.4 kB
from __future__ import annotations
import json
import os
import time
import urllib.request
from dataclasses import dataclass
from pathlib import Path
from playwright.sync_api import Page, expect, sync_playwright
BASE_URL = os.environ.get("KINK_AUDIT_BASE_URL", "http://127.0.0.1:8011")
ARTIFACT_DIR = Path("/tmp/kink_ui_audit")
ARTIFACT_DIR.mkdir(parents=True, exist_ok=True)
BONDAGE_ID = "fetlife_10"
SPANKING_ID = "fetlife_96"
ORAL_ID = "fetlife_71"
@dataclass
class Credentials:
user_id: str
private_token: str
def auth_headers(creds: Credentials) -> dict[str, str]:
return {"x-private-token": creds.private_token}
def post_json(path: str, payload: dict, headers: dict[str, str] | None = None, timeout: int = 20) -> dict:
request_headers = {"Content-Type": "application/json"}
if headers:
request_headers.update(headers)
request = urllib.request.Request(
f"{BASE_URL}{path}",
data=json.dumps(payload).encode("utf-8"),
headers=request_headers,
method="POST",
)
with urllib.request.urlopen(request, timeout=timeout) as response:
return json.loads(response.read().decode("utf-8"))
def create_user() -> Credentials:
payload = post_json("/users", {})
return Credentials(user_id=payload["id"], private_token=payload["private_token"])
def save_play(creds: Credentials, kink_id: str, interest_state: str, directions: list[str]) -> dict:
return post_json(
f"/users/{creds.user_id}/plays",
{"kink_id": kink_id, "interest_state": interest_state, "directions": directions},
headers=auth_headers(creds),
)
def link_partner(creds: Credentials, partner_id: str) -> dict:
return post_json(
f"/users/{creds.user_id}/partners",
{"partner_id": partner_id},
headers=auth_headers(creds),
)
def create_partner_group(creds: Credentials, name: str, member_ids: list[str]) -> dict:
return post_json(
f"/users/{creds.user_id}/partner-groups",
{"name": name, "member_ids": member_ids},
headers=auth_headers(creds),
)
def seed_audit_users() -> tuple[Credentials, Credentials, str]:
owner = create_user()
partner = create_user()
save_play(owner, BONDAGE_ID, "love", ["to_me"])
save_play(partner, BONDAGE_ID, "love", ["by_me"])
save_play(owner, SPANKING_ID, "like", ["together"])
save_play(partner, SPANKING_ID, "love", ["together"])
save_play(owner, ORAL_ID, "love", ["together"])
save_play(partner, ORAL_ID, "love", ["together"])
link_partner(owner, partner.user_id)
group = create_partner_group(owner, "UI Audit Duo", [partner.user_id])
return owner, partner, group["id"]
def capture_locator(locator, label: str, path: Path) -> dict:
if locator.count() == 0:
return {"label": label, "present": False}
target = locator.first
box = target.bounding_box()
if not box:
return {"label": label, "present": True, "box": None}
target.screenshot(path=str(path))
return {
"label": label,
"present": True,
"box": {
"x": round(box["x"]),
"y": round(box["y"]),
"width": round(box["width"]),
"height": round(box["height"]),
},
"path": str(path),
}
def capture_layout_state(page: Page) -> dict:
return page.evaluate(
"""() => ({
viewport: { width: window.innerWidth, height: window.innerHeight },
bodyScrollHeight: document.body.scrollHeight,
headings: [...document.querySelectorAll('h1, h2, h3')].map((el) => ({
text: (el.textContent || '').trim(),
top: Math.round(el.getBoundingClientRect().top),
})).slice(0, 20),
statusText: [...document.querySelectorAll('.status')].map((el) => (el.textContent || '').trim()).filter(Boolean),
authText: document.querySelector('.auth-strip')?.innerText || '',
selectedDetailTitle: document.querySelector('.detail-title')?.textContent || ''
})"""
)
def round_box(box: dict | None) -> dict | None:
if not box:
return None
return {
"x": round(box["x"]),
"y": round(box["y"]),
"width": round(box["width"]),
"height": round(box["height"]),
}
def first_box(locator) -> dict | None:
if locator.count() == 0:
return None
return round_box(locator.first.bounding_box())
def assert_close(label: str, left: int, right: int, tolerance: int = 12) -> None:
if abs(left - right) > tolerance:
raise AssertionError(f"{label} misaligned by {abs(left - right)}px ({left} vs {right})")
def capture_alignment_metrics(page: Page) -> dict:
add_panel = page.locator('.panel').filter(has=page.get_by_role('heading', name='Add Plays')).first
for_you_panel = page.locator('.panel').filter(has=page.get_by_role('heading', name='For You', exact=True)).first
shared_panel = page.locator('.panel').filter(has=page.get_by_role('heading', name='Shared Play List')).first
detail_pane = page.locator('.side-stack').first
board_columns = page.locator('.board-grid .board-column')
add_box = first_box(add_panel)
for_you_box = first_box(for_you_panel)
shared_box = first_box(shared_panel)
detail_box = first_box(detail_pane)
board_boxes = [round_box(board_columns.nth(i).bounding_box()) for i in range(board_columns.count())]
board_boxes = [box for box in board_boxes if box]
add_heading = first_box(add_panel.locator('h2, h3').first) if add_panel.count() else None
for_you_heading = first_box(for_you_panel.locator('h2, h3').first) if for_you_panel.count() else None
shared_heading = first_box(shared_panel.locator('h2, h3').first) if shared_panel.count() else None
return {
"panels": {
"add": add_box,
"for_you": for_you_box,
"shared": shared_box,
"detail": detail_box,
},
"headings": {
"add": add_heading,
"for_you": for_you_heading,
"shared": shared_heading,
},
"board_columns": board_boxes,
}
def assert_shell_alignment(metrics: dict, *, mobile: bool) -> None:
panels = metrics["panels"]
headings = metrics["headings"]
board_columns = metrics["board_columns"]
required_panels = [panels["add"], panels["for_you"], panels["shared"]]
if any(panel is None for panel in required_panels):
raise AssertionError("missing panel box for alignment audit")
if any(heading is None for heading in [headings["add"], headings["for_you"], headings["shared"]]):
raise AssertionError("missing heading box for alignment audit")
assert_close("add vs for_you left edge", panels["add"]["x"], panels["for_you"]["x"])
assert_close("for_you vs shared left edge", panels["for_you"]["x"], panels["shared"]["x"])
assert_close("add vs for_you heading inset", headings["add"]["x"] - panels["add"]["x"], headings["for_you"]["x"] - panels["for_you"]["x"])
assert_close("for_you vs shared heading inset", headings["for_you"]["x"] - panels["for_you"]["x"], headings["shared"]["x"] - panels["shared"]["x"])
if not mobile:
if len(board_columns) != 3:
raise AssertionError(f"expected 3 board columns, got {len(board_columns)}")
widths = [box["width"] for box in board_columns]
heights = [box["height"] for box in board_columns]
left_edges = [box["x"] for box in board_columns]
if max(widths) - min(widths) > 8:
raise AssertionError(f"board column widths diverge too much: {widths}")
if max(heights) - min(heights) > 20:
raise AssertionError(f"board column heights diverge too much: {heights}")
gaps = [left_edges[i + 1] - left_edges[i] for i in range(len(left_edges) - 1)]
if gaps and max(gaps) - min(gaps) > 12:
raise AssertionError(f"board column spacing inconsistent: {left_edges}")
def login_storage_script(creds: Credentials) -> str:
payload = json.dumps({"userId": creds.user_id, "token": creds.private_token})
return f"localStorage.setItem('kink_auth', JSON.stringify({payload}));"
def run_scenario(name: str, viewport: tuple[int, int], init_script: str, action) -> dict:
console_errors: list[str] = []
start = time.perf_counter()
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page(viewport={"width": viewport[0], "height": viewport[1]})
page.add_init_script(init_script)
page.on("console", lambda msg: console_errors.append(f"{msg.type}: {msg.text}") if msg.type == "error" else None)
page.goto(BASE_URL, wait_until="domcontentloaded")
page.wait_for_timeout(1200)
result = {
"name": name,
"viewport": {"width": viewport[0], "height": viewport[1]},
"console_errors": console_errors,
"checkpoints": [],
}
action(page, result)
full_path = ARTIFACT_DIR / f"{name}.png"
page.screenshot(path=str(full_path), full_page=True)
result["full_screenshot"] = str(full_path)
result["elapsed_ms"] = round((time.perf_counter() - start) * 1000, 1)
browser.close()
return result
def action_authed_shell(page: Page, result: dict) -> None:
expect(page.locator('.auth-strip')).to_be_visible(timeout=15000)
expect(page.get_by_role('heading', name='For You', exact=True)).to_be_visible(timeout=15000)
state = capture_layout_state(page)
alignment = capture_alignment_metrics(page)
assert_shell_alignment(alignment, mobile=page.viewport_size['width'] <= 480)
result['checkpoints'].append({"label": "shell", "state": state, "alignment": alignment})
result['clips'] = [
capture_locator(page.locator('.panel').filter(has=page.get_by_role('heading', name='For You', exact=True)), 'for_you_panel', ARTIFACT_DIR / f"{result['name']}_for_you.png"),
capture_locator(page.locator('.board-grid'), 'play_list_board', ARTIFACT_DIR / f"{result['name']}_play_list.png"),
capture_locator(page.locator('.panel').filter(has=page.get_by_role('heading', name='Shared Play List')), 'shared_panel', ARTIFACT_DIR / f"{result['name']}_shared_panel.png"),
]
def action_detail_open(page: Page, result: dict) -> None:
expect(page.locator('.auth-strip')).to_be_visible(timeout=15000)
page.get_by_placeholder('Search plays').fill('bondage')
chip = page.locator('.panel').filter(has=page.get_by_text('Add Plays')).locator('.play-chip').filter(has=page.get_by_role('button', name='Bondage')).first
expect(chip).to_be_visible(timeout=15000)
chip.get_by_role('button', name='Bondage').click()
expect(page.locator('.detail-title')).to_have_text('Bondage', timeout=15000)
result['checkpoints'].append({"label": "detail_open", "state": capture_layout_state(page), "alignment": capture_alignment_metrics(page)})
result['clips'] = [
capture_locator(page.locator('.side-stack'), 'detail_pane', ARTIFACT_DIR / f"{result['name']}_detail.png"),
capture_locator(page.locator('.panel').filter(has=page.get_by_role('heading', name='For You', exact=True)), 'for_you_panel', ARTIFACT_DIR / f"{result['name']}_for_you.png"),
]
def action_shared_group(page: Page, result: dict) -> None:
expect(page.locator('.auth-strip')).to_be_visible(timeout=15000)
shared_panel = page.locator('.panel').filter(has=page.get_by_role('heading', name='Shared Play List'))
shared_panel.get_by_role('button', name='UI Audit Duo (2)').click()
expect(page.locator('.shared-grid')).to_be_visible(timeout=15000)
result['checkpoints'].append({"label": "shared_group", "state": capture_layout_state(page)})
result['clips'] = [
capture_locator(page.locator('.shared-grid'), 'shared_grid', ARTIFACT_DIR / f"{result['name']}_shared.png"),
capture_locator(shared_panel.locator('.chips').first, 'group_picker', ARTIFACT_DIR / f"{result['name']}_group_picker.png"),
]
def main() -> int:
owner, _partner, _group_id = seed_audit_users()
scenarios = [
run_scenario('desktop_authed_shell', (1440, 1000), login_storage_script(owner), action_authed_shell),
run_scenario('desktop_detail_open', (1440, 1000), login_storage_script(owner), action_detail_open),
run_scenario('desktop_shared_group', (1440, 1000), login_storage_script(owner), action_shared_group),
run_scenario('mobile_authed_shell', (390, 844), login_storage_script(owner), action_authed_shell),
run_scenario('mobile_detail_open', (390, 844), login_storage_script(owner), action_detail_open),
]
out = {
'generated_at': time.time(),
'base_url': BASE_URL,
'artifact_dir': str(ARTIFACT_DIR),
'scenarios': scenarios,
}
out_path = ARTIFACT_DIR / 'audit.json'
out_path.write_text(json.dumps(out, indent=2))
print(json.dumps({
'artifact_dir': str(ARTIFACT_DIR),
'audit_json': str(out_path),
'scenarios': [
{
'name': item['name'],
'elapsed_ms': item['elapsed_ms'],
'console_errors': item['console_errors'],
'clip_count': len(item.get('clips', [])),
}
for item in scenarios
],
}, indent=2))
return 0
if __name__ == '__main__':
raise SystemExit(main())