204 lines
19 KiB
Python
204 lines
19 KiB
Python
|
|
#!/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()
|