#!/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()