metis-desk/scripts/workstation-uiux-v1.test.py

204 lines
19 KiB
Python
Raw Normal View History

2026-10-24 23:19:17 +00:00
#!/usr/bin/env python3
"""Capturas comparables y recorridos MCP de M01/M02 sobre el preparador desechable."""
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 = [(1600, 1000), (1440, 900), (1024, 768), (768, 1024), (390, 844), (320, 720)]
class WorkstationUiuxV1Tests(unittest.TestCase):
@classmethod
def setUpClass(cls):
value = os.environ.get("METIS_PLAYWRIGHT_STORAGE_STATE")
if not value:
raise unittest.SkipTest("Requiere visual-audit.py y las fixtures de puestos.")
state = (ROOT / 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 pertenecer al preparador.")
report = json.loads((state.parent / "report.json").read_text())
cls.fixture = json.loads((state.parent / "workstation-fixtures.json").read_text())
cls.origin = report["origin"]
if urlparse(cls.origin).hostname != "localhost" or report.get("ordinaryUser") is not True or cls.fixture['origin'] != cls.origin or cls.fixture['companyId'] != report['companyId']:
raise RuntimeError("Requiere la instalación sintética desechable activa.")
cls.phase = os.environ.get("METIS_UIUX_PHASE", "despues")
if cls.phase not in ("antes", "despues"):
raise RuntimeError("Fase desconocida.")
cls.output = ROOT / "docs/auditorias/ficha-puesto-trabajo/implementacion-v1"
(cls.output / cls.phase).mkdir(parents=True, exist_ok=True)
cls.measurements = json.loads((cls.output / "mediciones.json").read_text()) if (cls.output / "mediciones.json").exists() else {}
cls.stack = contextlib.ExitStack()
cls.addClassCleanup(cls.stack.close)
config = tomllib.loads((ROOT / ".codex/config.toml").read_text())["mcp_servers"]["playwright"]
cls.client = cls.stack.enter_context(AUDIT["browser"](config, state))
cls.base = cls.origin + "/companies/" + cls.fixture['companyId']
cls.code("page.__uiuxErrors=[];page.on('pageerror',e=>page.__uiuxErrors.push(e.message));return true;")
@classmethod
def tool(cls, name, args):
return AUDIT["tool"](cls.client, name, args)
@classmethod
def code(cls, body):
return AUDIT["result_json"](cls.tool("browser_run_code_unsafe", {"code": "async(page)=>{" + body + "}"}))
@classmethod
def save(cls, key, value):
cls.measurements.setdefault(cls.phase, {})[key] = value
(cls.output / "mediciones.json").write_text(json.dumps(cls.measurements, ensure_ascii=False, indent=2) + "\n")
def navigate(self, key="main"):
self.tool("browser_navigate", {"url": self.base + "/workstations/" + self.fixture[key]})
self.code("await page.locator('main h1').waitFor();await page.locator('[data-pending-count]').waitFor();return true;")
if key == "main":
self.code("await page.getByRole('link',{name:'Ángela · administración',exact:true}).waitFor();await page.getByText('equipo-adm-01.example.invalid',{exact:false}).first().waitFor();await page.getByText('Autorizada',{exact:true}).waitFor();return true;")
self.code("await page.evaluate(async()=>{await document.fonts.ready;await new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(r)))});return true;")
def capture(self, name):
image = AUDIT["capture"](self.client, self.output / self.phase, name)
geometry = self.code("""return await page.evaluate(()=>{const rect=e=>{const r=e.getBoundingClientRect();return {x:r.x,y:r.y,width:r.width,height:r.height}};const heading=document.querySelector('main h1');const header=heading.closest('header');const section=name=>[...document.querySelectorAll('main section[aria-labelledby]')].find(s=>document.getElementById(s.getAttribute('aria-labelledby'))?.textContent===name);return {viewport:innerWidth,scrollWidth:document.documentElement.scrollWidth,h1Count:document.querySelectorAll('main h1').length,title:heading.textContent,header:rect(header),equipment:rect(section('Equipamiento')),support:rect(section('Soporte y acceso remoto')),profiles:rect(section('Personas y perfiles')),actions:section('Soporte y acceso remoto').querySelectorAll('button').length}});""")
self.assertEqual(geometry['viewport'], geometry['scrollWidth'])
self.assertEqual(geometry['h1Count'], 1)
self.save(name, {'image': image, 'geometry': geometry})
def test_01_comparable_captures(self):
for width, height in SIZES:
self.tool("browser_resize", {"width": width, "height": height})
self.navigate()
self.capture("principal-" + str(width))
self.code("await page.getByRole('region',{name:'Soporte y acceso remoto',exact:true}).scrollIntoViewIfNeeded();return true;")
self.capture("soporte-" + str(width))
for width, height in [(1440, 900), (320, 720)]:
self.tool("browser_resize", {"width": width, "height": height})
self.navigate("empty")
self.capture("sin-principal-" + str(width))
self.save('pageErrors', self.code('return page.__uiuxErrors;'))
self.assertEqual(self.code('return page.__uiuxErrors;'), [])
def test_02_management_keyboard_and_cancel(self):
self.tool('browser_resize', {'width':390,'height':844})
self.navigate()
self.code("await page.getByRole('button',{name:'Gestionar soporte',exact:true}).focus();await page.keyboard.press('Enter');await page.getByRole('dialog',{name:'Gestionar soporte',exact:true}).waitFor();return true;")
self.code("await page.getByRole('dialog',{name:'Gestionar soporte',exact:true}).getByRole('button',{name:'Añadir método',exact:true}).click();await page.getByRole('dialog',{name:'Añadir método',exact:true}).waitFor();return true;")
self.code("await page.getByRole('dialog',{name:'Añadir método',exact:true}).getByRole('button',{name:'Cancelar',exact:true}).click();await page.getByRole('dialog',{name:'Añadir método',exact:true}).waitFor({state:'hidden'});return true;")
self.assertTrue(self.code("return await page.getByRole('dialog',{name:'Gestionar soporte',exact:true}).isVisible();"))
self.code("await page.keyboard.press('Escape');await page.getByRole('dialog').waitFor({state:'hidden'});return true;")
self.assertEqual(self.code("return await page.evaluate(()=>document.activeElement?.textContent);"), 'Gestionar soporte')
self.save('management-keyboard-cancel', 'correcto')
def api(self, path, data=None):
return self.code("const origin="+json.dumps(self.origin)+",path="+json.dumps(path)+",data="+json.dumps(data)+";const {csrfToken}=await(await page.request.get(origin+'/api/auth/session')).json();const r=data===null?await page.request.get(origin+'/api'+path):await page.request.post(origin+'/api'+path,{data,headers:{origin,'x-csrf-token':csrfToken}});if(!r.ok())throw Error('HTTP '+r.status());return r.json();")
def open_management(self):
self.code("await page.getByRole('button',{name:'Gestionar soporte',exact:true}).click();await page.getByRole('dialog',{name:'Gestionar soporte',exact:true}).waitFor();return true;")
def test_03_support_crud_history_retired_and_confirmation(self):
self.tool('browser_resize', {'width':1440,'height':900})
self.navigate()
path='/asset-support/'+self.fixture['companyId']+'/'+self.fixture['host']
created=None
self.open_management()
try:
self.code("await page.getByRole('dialog',{name:'Gestionar soporte',exact:true}).getByRole('button',{name:'Añadir método',exact:true}).click();await page.getByRole('dialog',{name:'Añadir método',exact:true}).getByRole('combobox',{name:'Herramienta',exact:true}).click();await page.getByRole('option',{name:'Herramienta de soporte de ejemplo',exact:true}).click();await page.getByRole('dialog',{name:'Añadir método',exact:true}).getByRole('textbox',{name:'Identificador o destino',exact:true}).fill('UIUX-V1-metodo-temporal');await page.getByRole('dialog',{name:'Añadir método',exact:true}).getByRole('button',{name:'Guardar',exact:true}).click();await page.getByRole('dialog',{name:'Añadir método',exact:true}).waitFor({state:'hidden'});await page.getByText('UIUX-V1-metodo-temporal',{exact:true}).waitFor();return true;")
created=next(x for x in self.api(path)['connections'] if x['values']['destination']=='UIUX-V1-metodo-temporal')
self.code("await page.keyboard.press('Escape');await page.getByRole('dialog').waitFor({state:'hidden'});return true;")
self.assertEqual(self.code("return await page.getByRole('region',{name:'Soporte registrado',exact:true}).locator('[data-slot=compact-list-item]').count();"),2)
self.open_management()
self.code("const row=page.getByRole('dialog',{name:'Gestionar soporte',exact:true}).locator('li').filter({hasText:'UIUX-V1-metodo-temporal'});await row.getByRole('button',{name:'Editar',exact:true}).click();await page.getByRole('dialog',{name:'Editar método',exact:true}).getByRole('textbox',{name:'Identificador o destino',exact:true}).fill('UIUX-V1-metodo-editado');await page.getByRole('dialog',{name:'Editar método',exact:true}).getByRole('combobox',{name:'Estado',exact:true}).click();await page.getByRole('option',{name:'Retirado',exact:true}).click();await page.getByRole('dialog',{name:'Editar método',exact:true}).getByRole('button',{name:'Guardar',exact:true}).click();await page.getByRole('dialog',{name:'Editar método',exact:true}).waitFor({state:'hidden'});return true;")
saved=next(x for x in self.api(path)['connections'] if x['id']==created['id'])
self.assertEqual(saved['values']['state'],'retired')
self.assertEqual(saved['values']['destination'],'UIUX-V1-metodo-editado')
self.assertFalse(self.code("return await page.getByText('UIUX-V1-metodo-editado',{exact:true}).isVisible();"))
self.code("await page.getByRole('checkbox',{name:'Mostrar métodos retirados',exact:true}).check();const row=page.getByRole('dialog',{name:'Gestionar soporte',exact:true}).locator('li').filter({hasText:'UIUX-V1-metodo-editado'});await row.getByRole('button',{name:'Historial',exact:true}).click();await page.getByRole('dialog',{name:'Historial',exact:true}).getByText('UIUX-V1-metodo-temporal',{exact:true}).waitFor();return true;")
self.code("await page.keyboard.press('Escape');await page.getByRole('dialog',{name:'Historial',exact:true}).waitFor({state:'hidden'});const row=page.getByRole('dialog',{name:'Gestionar soporte',exact:true}).locator('li').filter({hasText:'UIUX-V1-metodo-editado'});await row.getByRole('button',{name:'Eliminar registro',exact:true}).click();await page.getByRole('dialog',{name:'Eliminar registro',exact:true}).getByRole('button',{name:'Cancelar',exact:true}).click();return true;")
self.assertTrue(any(x['id']==created['id'] for x in self.api(path)['connections']))
self.code("const row=page.getByRole('dialog',{name:'Gestionar soporte',exact:true}).locator('li').filter({hasText:'UIUX-V1-metodo-editado'});await row.getByRole('button',{name:'Eliminar registro',exact:true}).click();await page.getByRole('dialog',{name:'Eliminar registro',exact:true}).getByRole('button',{name:'Eliminar registro',exact:true}).click();await page.getByRole('dialog',{name:'Eliminar registro',exact:true}).waitFor({state:'hidden'});return true;")
self.assertFalse(any(x['id']==created['id'] for x in self.api(path)['connections']))
self.save('support-crud-history-retired-confirmation','correcto: alta, edición, retirado, historial, cancelar y confirmar borrado')
finally:
if created and any(x['id']==created['id'] for x in self.api(path)['connections']):
self.api(path+'/'+created['id']+'/delete',{})
self.code("for(let i=0;i<3 && await page.getByRole('dialog').count();i++){const dialog=page.getByRole('dialog').last();await page.keyboard.press('Escape');await dialog.waitFor({state:'hidden'});}return true;")
def test_04_support_failures_permissions_and_partial_data(self):
self.tool('browser_resize', {'width':390,'height':844})
route='**/api/asset-support/'+self.fixture['companyId']+'/'+self.fixture['host']
for status, message in [(503,'No se pudo cargar la asistencia remota.'),(403,'No tienes acceso al soporte de este activo.')]:
self.code("await page.route("+json.dumps(route)+",route=>route.fulfill({status:"+str(status)+",contentType:'application/json',body:JSON.stringify({code:"+json.dumps('FORBIDDEN' if status==403 else 'INTERNAL_ERROR')+"})}));return true;")
try:
self.tool('browser_navigate',{'url':self.base+'/workstations/'+self.fixture['main']})
self.code("await page.getByText("+json.dumps(message)+",{exact:false}).waitFor();return true;")
self.assertTrue(self.code("return await page.getByRole('button',{name:'Gestionar soporte',exact:true}).isDisabled();"))
self.assertFalse(self.code("return await page.getByText('equipo-adm-01.example.invalid',{exact:false}).isVisible();"))
self.assertTrue(self.code("return await page.getByRole('region',{name:'Equipamiento',exact:true}).isVisible();"))
finally:
self.code("await page.unroute("+json.dumps(route)+");return true;")
self.code("await page.getByRole('region',{name:'Soporte registrado',exact:true}).getByRole('button',{name:'Reintentar',exact:true}).click();await page.getByText('equipo-adm-01.example.invalid',{exact:false}).first().waitFor();return true;")
self.code("await page.route("+json.dumps(route)+",async route=>{const response=await route.fetch();const data=await response.json();data.tools=[];await route.fulfill({response,json:data});});return true;")
try:
self.tool('browser_navigate',{'url':self.base+'/workstations/'+self.fixture['main']})
self.code("await page.getByText('No se ha podido resolver la herramienta de este método.',{exact:true}).waitFor();return true;")
self.assertTrue(self.code("return await page.getByText('equipo-adm-01.example.invalid',{exact:false}).isVisible();"))
finally:
self.code("await page.unroute("+json.dumps(route)+");return true;")
self.save('support-failure-permission-recovery-partial','correcto')
def test_05_loading_stale_empty_and_no_computer(self):
self.tool('browser_resize', {'width':1440,'height':900})
route='**/api/asset-support/'+self.fixture['companyId']+'/'+self.fixture['host']
self.code("await page.route("+json.dumps(route)+",route=>{page.__heldSupportRoute=route;});return true;")
try:
self.tool('browser_navigate',{'url':self.base+'/workstations/'+self.fixture['main']})
self.code("await page.getByText('Cargando asistencia remota…',{exact:true}).waitFor();return true;")
self.assertTrue(self.code("return await page.getByRole('button',{name:'Gestionar soporte',exact:true}).isDisabled();"))
finally:
self.code("if(page.__heldSupportRoute){await page.__heldSupportRoute.continue();delete page.__heldSupportRoute;}await page.unroute("+json.dumps(route)+");return true;")
self.code("await page.getByText('equipo-adm-01.example.invalid',{exact:false}).first().waitFor();return true;")
self.code("await page.route("+json.dumps(route)+",route=>route.fulfill({status:503,contentType:'application/json',body:JSON.stringify({code:'INTERNAL_ERROR'})}));await page.context().setOffline(true);await page.context().setOffline(false);return true;")
try:
self.code("await page.getByText('No se pudo actualizar el soporte. Se muestran los últimos datos disponibles.',{exact:true}).waitFor();return true;")
self.assertTrue(self.code("return await page.getByText('equipo-adm-01.example.invalid',{exact:false}).first().isVisible();"))
finally:
self.code("await page.unroute("+json.dumps(route)+");return true;")
self.code("await page.getByRole('region',{name:'Soporte registrado',exact:true}).getByRole('button',{name:'Reintentar',exact:true}).click();await page.getByText('No se pudo actualizar el soporte. Se muestran los últimos datos disponibles.',{exact:true}).waitFor({state:'hidden'});return true;")
self.code("await page.route("+json.dumps(route)+",route=>route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({connections:[],tools:[]})}));return true;")
try:
self.tool('browser_navigate',{'url':self.base+'/workstations/'+self.fixture['main']})
self.code("await page.getByText('No hay métodos de asistencia registrados para este activo.',{exact:true}).waitFor();return true;")
self.assertFalse(self.code("return await page.getByRole('button',{name:'Gestionar soporte',exact:true}).isDisabled();"))
finally:
self.code("await page.unroute("+json.dumps(route)+");return true;")
self.navigate('empty')
self.assertEqual(self.code("return await page.getByRole('button',{name:'Gestionar soporte',exact:true}).count();"),0)
self.save('support-loading-stale-empty-no-computer','correcto')
def test_06_header_navigation_lifecycle_and_management_geometry(self):
self.tool('browser_resize', {'width':390,'height':844})
self.navigate()
self.code("await page.getByRole('link',{name:'Volver a puestos de trabajo',exact:true}).focus();await page.keyboard.press('Enter');await page.waitForURL(url=>url.pathname.endsWith('/workstations'));return true;")
self.navigate()
for action in ['Desactivar','Eliminar definitivamente']:
self.code("await page.getByRole('button',{name:'Más acciones del puesto',exact:true}).click();await page.getByRole('menuitem',{name:"+json.dumps(action)+",exact:true}).click();await page.getByRole('dialog',{name:"+json.dumps(action)+",exact:true}).waitFor();await page.getByRole('dialog',{name:"+json.dumps(action)+",exact:true}).getByRole('button',{name:'Cancelar',exact:true}).click();return true;")
for width,height in [(1440,900),(390,844),(320,720)]:
self.tool('browser_resize',{'width':width,'height':height})
self.open_management()
image=AUDIT['capture'](self.client,self.output/self.phase,'gestion-'+str(width))
rect=self.code("return await page.getByRole('dialog',{name:'Gestionar soporte',exact:true}).evaluate(e=>{const r=e.getBoundingClientRect();return {width:r.width,height:r.height,scrollWidth:e.scrollWidth,clientWidth:e.clientWidth};});")
self.assertEqual(rect['scrollWidth'],rect['clientWidth'])
self.save('gestion-'+str(width),{'image':image,'dialog':rect})
self.code("await page.keyboard.press('Escape');await page.getByRole('dialog').waitFor({state:'hidden'});return true;")
self.save('header-return-and-lifecycle-cancel','correcto')
if __name__ == '__main__':
unittest.main()