Spaces:
Runtime error
Runtime error
| 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" | |
| 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()) | |