#!/usr/bin/env python3 """Regresión UI real sobre la sesión sintética del preparador, mediante su cliente MCP. METIS_PLAYWRIGHT_STORAGE_STATE=.artifacts/playwright/authenticated-…/session.json \ METIS_UI_AUDIT_PHASE=after python3 scripts/visual-audit-ui.test.py No prepara datos, no inicia servicios y bloquea escrituras HTTP en el navegador. """ import contextlib import json import os from pathlib import Path import runpy import tomllib import unittest from urllib.parse import urlparse ROOT = Path(__file__).resolve().parent.parent AUDIT = runpy.run_path(str(ROOT / "scripts/visual-audit.py")) SIZES = [(1440, 900), (1366, 768), (390, 844), (320, 640)] class VisualUiTests(unittest.TestCase): @classmethod def setUpClass(cls): state_value = os.environ.get("METIS_PLAYWRIGHT_STORAGE_STATE") if not state_value: raise unittest.SkipTest("Requiere una sesión sintética activa de scripts/visual-audit.py.") state = (ROOT / state_value).resolve() if not state.is_relative_to(ROOT / ".artifacts/playwright") or state.stat().st_mode & 0o077: raise RuntimeError("La sesión debe ser privada y estar dentro de .artifacts/playwright.") report = json.loads((state.parent / "report.json").read_text()) if urlparse(report["origin"]).hostname != "localhost" or report.get("ordinaryUser") is not True: raise RuntimeError("Requiere el informe del entorno sintético local.") phase = os.environ.get("METIS_UI_AUDIT_PHASE", "after") if phase not in ("before", "after"): raise ValueError("Fase inválida.") cls.output = ROOT / ".artifacts/playwright/ui-sprint-1" / phase cls.output.mkdir(parents=True, exist_ok=True) cls.origin = report["origin"] cls.company = "/companies/" + report["companyId"] cls.results = json.loads((cls.output / "measurements.json").read_text()) if (cls.output / "measurements.json").exists() else [] config = tomllib.loads((ROOT / ".codex/config.toml").read_text())["mcp_servers"]["playwright"] cls.stack = contextlib.ExitStack() cls.addClassCleanup(cls.stack.close) cls.client = cls.stack.enter_context(AUDIT["browser"](config, state)) cls.tool("browser_resize", {"width": 1440, "height": 900}) cls.code("await page.route('**/api/**', route => ['GET','HEAD','OPTIONS'].includes(route.request().method()) ? route.continue() : route.abort()); return true;") @classmethod def tool(cls, name, arguments): return AUDIT["tool"](cls.client, name, arguments) @classmethod def code(cls, body): return AUDIT["result_json"](cls.tool("browser_run_code_unsafe", {"code": "async (page) => {" + body + "}"})) @classmethod def record(cls, name, value): cls.results.append({"scenario": name, "value": value}) (cls.output / "measurements.json").write_text(json.dumps(cls.results, ensure_ascii=False, indent=2) + "\n") def navigate(self, section): self.tool("browser_navigate", {"url": self.origin + self.company + "/" + section}) self.code("await page.locator('[data-slot=workspace]').waitFor(); return true;") def capture(self, name): AUDIT["capture"](self.client, self.output, name) result = self.tool("browser_snapshot", {}) (self.output / (name + ".txt")).write_text("\n".join(p["text"] for p in result["content"] if p.get("type") == "text")) def resize(self, width, height): self.tool("browser_resize", {"width": width, "height": height}) self.settle() def settle(self): self.code("await page.evaluate(async()=>{await document.fonts.ready; await Promise.all(document.getAnimations().filter(a=>a.effect?.getTiming().iterations!==Infinity).map(a=>a.finished.catch(()=>{})))}); return true;") def test_selected_row_contrast(self): self.resize(1440, 900) self.navigate("assets") self.code("await page.getByRole('link',{name:'AUDIT-PORT-01',exact:true}).waitFor();return true;") values = [] for state in ("normal", "normal-hover", "normal-focus", "selected", "hover", "focus"): if state == "selected": self.code("await page.getByRole('row').filter({has:page.getByRole('link',{name:'AUDIT-PORT-01',exact:true})}).locator('td').nth(2).click();return true;") if state.endswith("hover"): self.code("await page.getByRole('link',{name:'AUDIT-PORT-01',exact:true}).hover(); return true;") if state.endswith("focus"): self.code("await page.mouse.move(0,0); await page.getByRole('link',{name:'AUDIT-PORT-01',exact:true}).focus(); await page.keyboard.press('Tab'); await page.keyboard.press('Shift+Tab'); return true;") self.settle() measurements = self.code("""return await page.evaluate(()=>{ const rgb=c=>{const ctx=document.createElement('canvas').getContext('2d');ctx.fillStyle=c;ctx.fillRect(0,0,1,1);return [...ctx.getImageData(0,0,1,1).data].slice(0,3)}; const background=e=>{const colors=[];for(let n=e;n;n=n.parentElement)colors.unshift(getComputedStyle(n).backgroundColor);const ctx=document.createElement('canvas').getContext('2d');ctx.fillStyle='#fff';ctx.fillRect(0,0,1,1);for(const color of colors){ctx.fillStyle=color;ctx.fillRect(0,0,1,1)}return [...ctx.getImageData(0,0,1,1).data].slice(0,3)}; const lum=c=>c.map(v=>v/255).map(v=>v<=.04045?v/12.92:((v+.055)/1.055)**2.4).reduce((s,v,i)=>s+v*[.2126,.7152,.0722][i],0); const row=[...document.querySelectorAll('tr')].find(e=>e.textContent.includes('AUDIT-PORT-01')); return [...row.querySelectorAll('a,button,[class*=text-muted-foreground]')].map(e=>{const fg=rgb(getComputedStyle(e).color);const bg=background(e);const l=[lum(fg),lum(bg)].sort((a,b)=>a-b);return {label:e.textContent.trim()||e.getAttribute('aria-label'),fg,bg,ratio:(l[1]+.05)/(l[0]+.05)};}); }); """) if state.endswith("focus"): self.assertTrue(self.code("return await page.getByRole('link',{name:'AUDIT-PORT-01',exact:true}).evaluate(e=>e.matches(':focus-visible'));")) values.extend(measurements) self.record("contrast-" + state, measurements) self.capture("contrast-" + state + "-1440") self.assertTrue(all(x["ratio"] >= 4.5 for x in values), values) def test_wizard_geometry(self): failures = [] for section, title, field, template in [("assets", "Nuevo activo", "Plantilla", "Portátil"), ("resources", "Nuevo recurso", "Plantilla de recurso", "Aplicación")]: for width, height in SIZES: self.resize(width, height) self.navigate(section) self.code("await page.getByRole('button',{name:" + json.dumps(title) + ",exact:true}).click(); await page.getByRole('dialog').getByRole('combobox',{name:" + json.dumps(field) + ",exact:true}).click(); await page.getByRole('option',{name:" + json.dumps(template) + ",exact:true}).click(); return true;") # Tras operar el desplegable, inspecciona el inicio del cuerpo desplazable. self.code("await page.locator('[role=dialog] [data-slot=form-body]').evaluate(e=>e.scrollTo({top:0}));return true;") geometry = [] for step in (1, 2, 3): self.settle() value = self.code("""return await page.evaluate(()=>{const d=document.querySelector('[role=dialog]');const r=d.getBoundingClientRect();const f=d.querySelector('[data-slot=dialog-footer]').getBoundingClientRect();const b=d.querySelector('[data-slot=form-body]');return {y:r.y,height:r.height,bottom:r.bottom,footerY:f.y,footerBottom:f.bottom,progressY:b.querySelector('ol').getBoundingClientRect().y,bodyClient:b.clientHeight,bodyScroll:b.scrollHeight};});""") geometry.append(value) self.capture(f"{section}-{width}-step-{step}") if step < 3: self.code("await page.getByRole('dialog').getByRole('button',{name:'Siguiente',exact:true}).click(); return true;") self.record(f"geometry-{section}-{width}", geometry) if max(x["height"] for x in geometry)-min(x["height"] for x in geometry)>1 or max(x["footerY"] for x in geometry)-min(x["footerY"] for x in geometry)>1: failures.append(f"{section} {width}: altura o pie variables") if max(x["progressY"] for x in geometry)-min(x["progressY"] for x in geometry)>1: failures.append(f"{section} {width}: progreso desplazado") if any(x["y"] < 0 or x["bottom"] > height+1 or x["footerBottom"] > height+1 for x in geometry): failures.append(f"{section} {width}: fuera de viewport") self.code("await page.keyboard.press('Escape'); await page.getByRole('dialog').waitFor({state:'hidden'}); return true;") self.assertEqual(failures, []) def test_focus_return(self): self.resize(1440, 900) failures = [] for origin, opening, closing in [("header", "mouse", "Escape"), ("header", "keyboard", "Escape"), ("header", "mouse", "Cancelar"), ("row", "keyboard", "Escape"), ("row", "mouse", "Cancelar"), ("missing", "keyboard", "Escape"), ("disabled", "mouse", "Escape")]: self.navigate("assets") label = "Editar" if origin == "row" else "Nuevo activo" self.code("const trigger=page.getByRole('button',{name:" + json.dumps(label) + ",exact:true}); await trigger.waitFor();" + ("await trigger.focus();await page.keyboard.press('Enter');" if opening == "keyboard" else "await trigger.click();") + "await page.getByRole('dialog').waitFor();return true;") if origin == "missing": self.code("await page.locator('[data-slot=inventory-layout] button').filter({hasText:'Nuevo activo'}).evaluate(e=>e.remove());return true;") if origin == "disabled": self.code("await page.locator('[data-slot=inventory-layout] button').filter({hasText:'Nuevo activo'}).evaluate(e=>e.disabled=true);return true;") self.code(("await page.keyboard.press('Escape');" if closing == "Escape" else "await page.getByRole('dialog').getByRole('button',{name:'Cancelar',exact:true}).click();") + "await page.getByRole('dialog').waitFor({state:'hidden'});return true;") self.settle() result = self.code("await page.waitForFunction(()=>document.activeElement!==document.body,{},{timeout:2000}).catch(()=>{});return await page.evaluate(()=>({tag:document.activeElement.tagName,label:document.activeElement.getAttribute('aria-label')||document.activeElement.textContent.trim().slice(0,80),search:document.activeElement.hasAttribute('data-quick-search')}));") self.record(f"focus-{origin}-{opening}-{closing}", result) self.capture(f"focus-{origin}-{opening}-{closing}") if (origin in ("missing", "disabled") and not result["search"]) or (origin not in ("missing", "disabled") and result["label"] != label): failures.append([origin, opening, closing, result]) for width, height in SIZES[1:]: self.resize(width, height) self.navigate("assets") self.code("await page.getByRole('button',{name:'Nuevo activo',exact:true}).focus();await page.keyboard.press('Enter');await page.getByRole('dialog').waitFor();await page.keyboard.press('Escape');await page.getByRole('dialog').waitFor({state:'hidden'});return true;") self.settle() target = self.code("return await page.evaluate(()=>document.activeElement.textContent.trim());") self.record(f"focus-header-{width}", target) self.capture(f"focus-header-{width}") self.assertEqual(target, "Nuevo activo") self.resize(1440, 900) self.navigate("work-profiles") self.code("await page.getByRole('button',{name:'Nuevo perfil',exact:true}).click(); await page.getByRole('button',{name:'Seleccionar puesto',exact:true}).click();return true;") self.assertTrue(self.code("return await page.evaluate(()=>document.activeElement.hasAttribute('data-quick-search'));")) self.code("await page.keyboard.press('Escape');return true;") self.settle() self.assertEqual(self.code("return await page.evaluate(()=>document.activeElement.textContent.trim());"), "Seleccionar puesto") self.code("await page.keyboard.press('Escape');return true;") self.assertEqual(failures, []) def test_review_headers(self): failures = [] self.resize(1440, 900) for section in ("work-profiles", "folder-mappings", "application-configurations", "certificate-configurations", "printer-configurations", "mail-configurations"): self.navigate(section) self.code("await page.getByRole('table').waitFor();return true;") headers = self.code("return await page.locator('th').allTextContents();") self.record("headers-" + section, headers) self.code("const header=page.getByRole('columnheader',{name:/Revisión/});if(await header.count())await header.scrollIntoViewIfNeeded();return true;") self.capture("headers-" + section + "-1440") if not any(x.strip().startswith("Revisión") for x in headers) or any("Revisar:" in x for x in headers): failures.append(section) for width, height in SIZES[1:]: self.resize(width, height) self.navigate("work-profiles") self.code("await page.getByRole('table').waitFor();return true;") headers = self.code("return await page.locator('th').allTextContents();") self.record(f"headers-work-profiles-{width}", headers) self.code("await page.getByRole('columnheader',{name:/Revisión/}).scrollIntoViewIfNeeded();return true;") self.capture(f"headers-work-profiles-{width}") self.assertTrue(any(x.strip().startswith("Revisión") for x in headers)) self.assertEqual(failures, []) if __name__ == "__main__": unittest.main()