metis-desk/scripts/workstation-dashboard-ui.test.py

276 lines
22 KiB
Python
Raw Normal View History

2026-10-22 12:32:31 +00:00
#!/usr/bin/env python3
"""Pruebas MCP del dashboard real sobre fixtures HTTP de la instalación 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"))
class WorkstationDashboardTests(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 workstation-dashboard-fixtures.py.")
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())
if urlparse(report["origin"]).hostname != "localhost" or report.get("ordinaryUser") is not True:
raise RuntimeError("Requiere instalación local sintética de pruebas.")
cls.fixture = json.loads((state.parent / "workstation-fixtures.json").read_text())
if cls.fixture['origin'] != report['origin'] or cls.fixture['companyId'] != report['companyId']:
raise RuntimeError("Los escenarios no pertenecen a la instalación desechable activa.")
cls.origin = cls.fixture["origin"]
cls.base = f"/companies/{cls.fixture['companyId']}"
cls.output = state.parent / "workstation-dashboard"
cls.output.mkdir(exist_ok=True)
cls.evidence = json.loads((cls.output / "measurements.json").read_text()) if (cls.output / "measurements.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.code("page.__workstationErrors=[];page.on('pageerror',error=>page.__workstationErrors.push(error.message));return true;")
@classmethod
def tearDownClass(cls):
if hasattr(cls, 'client'):
errors=cls.code("return page.__workstationErrors;")
cls.record('uncaught-page-errors',errors)
if errors:
raise AssertionError('Errores JavaScript de página: '+str(errors))
@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 record(cls, name, values):
cls.evidence = [entry for entry in cls.evidence if entry["scenario"] != name]
cls.evidence.append({"scenario": name, "values": values})
(cls.output / "measurements.json").write_text(json.dumps(cls.evidence, ensure_ascii=False, indent=2) + "\n")
def navigate(self, key="main", pending=True):
url = self.origin + self.base + "/workstations/" + self.fixture[key]
self.tool("browser_navigate", {"url": url})
self.code("await page.locator('main h1').waitFor();return true;")
if pending:
self.code("await page.locator('[data-pending-count]').waitFor();return true;")
self.settle()
def settle(self):
self.code("await page.evaluate(async()=>{await document.fonts.ready;await new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(r)))});return true;")
def capture(self, name):
self.record(name, AUDIT["capture"](self.client, self.output, name))
def test_01_responsive_and_density(self):
for width, height in [(1600,1000),(1440,900),(1280,900),(1024,768),(768,1024),(390,844)]:
self.tool("browser_resize", {"width":width,"height":height})
self.navigate()
self.code("await page.getByRole('link',{name:'Gestor documental de ejemplo',exact:true}).first().waitFor();return true;")
values = self.code("""return await page.evaluate(()=>({width:innerWidth,documentWidth:document.documentElement.scrollWidth,pending:document.querySelector('[data-pending-count]').textContent,headings:[...document.querySelectorAll('main h2')].map(x=>x.textContent.trim())}));""")
self.record(f"geometry-{width}", values)
self.assertLessEqual(values["documentWidth"], width)
self.assertEqual(values["pending"], "1")
self.assertNotIn("Activos asociados", values["headings"])
self.assertNotIn("Perfiles activos", values["headings"])
self.capture(f"S01-{width}")
if width == 390:
self.code("await page.locator('#workstation-configurations').getByRole('heading',{name:'Configuraciones por perfil',exact:true}).scrollIntoViewIfNeeded();return true;")
self.capture('S01-configurations-390')
self.tool("browser_resize", {"width":1440,"height":900})
self.navigate("dense")
self.code("await page.getByRole('link',{name:/DASH-PROFILE/}).first().waitFor();return true;")
self.capture("S04-density-1440")
self.assertEqual(self.code("return await page.locator('main').evaluate(e=>e.scrollWidth<=e.clientWidth);"), True)
def test_02_profiles_refetch_and_search(self):
self.tool("browser_resize", {"width":1440,"height":900})
self.navigate()
self.code("await page.getByRole('button',{name:'1 perfil archivado',exact:true}).click();await page.getByRole('link',{name:'Ángela · perfil archivado del mismo ordenador',exact:true}).waitFor();return true;")
self.code("await page.getByRole('button',{name:'Ocultar archivados',exact:true}).click();return true;")
self.code("await page.getByRole('combobox',{name:'Seleccionar perfil',exact:true}).click();await page.getByRole('option',{name:'Íñigo · consultas de documentación',exact:true}).click();return true;")
self.assertFalse(self.code("return await page.locator('#workstation-configurations').getByText('Instalación pendiente de completar.',{exact:true}).isVisible();"))
value = self.code("""const selector=page.getByRole('combobox',{name:'Seleccionar perfil',exact:true});await selector.focus();await page.context().setOffline(true);const response=page.waitForResponse(r=>r.url().includes('/api/work-profiles/')&&r.status()===200);await page.context().setOffline(false);await response;return {selection:await selector.textContent(),focused:await selector.evaluate(e=>e===document.activeElement)};""")
self.assertIn("Íñigo", value["selection"])
self.assertTrue(value["focused"])
self.record("S07-refetch", value)
self.code("const region=page.getByRole('region',{name:'Personas y perfiles',exact:true});await region.locator('input[data-quick-search]').fill('SIN-COINCIDENCIAS-XYZ');return true;")
self.assertFalse(self.code("return await page.getByRole('region',{name:'Personas y perfiles',exact:true}).getByRole('link',{name:'Ángela · administración',exact:true}).isVisible();"))
self.capture("S08-no-results")
def test_03_history_edit_software_and_navigation(self):
self.navigate()
self.code("await page.getByRole('button',{name:'Ver historial',exact:true}).click();await page.getByRole('dialog').waitFor();return true;")
self.assertTrue(self.code("return await page.getByRole('dialog').getByText('Administración — puesto inicial',{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.trim());"),"Ver historial")
self.code("await page.getByRole('button',{name:'Editar puesto',exact:true}).first().click();await page.getByRole('dialog').waitFor();return true;")
self.code("await page.keyboard.press('Escape');await page.getByRole('dialog').waitFor({state:'hidden'});return true;")
self.code("await page.getByRole('button',{name:'Gestionar software',exact:true}).click();await page.getByRole('dialog').waitFor();return true;")
self.code("await page.getByRole('dialog').getByRole('button',{name:'Añadir instalación',exact:true}).waitFor();return true;")
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.trim());"),"Gestionar software")
target = self.base + "/assets/" + self.fixture["host"]
self.code("await page.locator('a[href="+json.dumps(target)+"]').first().click();await page.locator('main h1').waitFor();return true;")
self.assertIn(target,self.code("return page.url();"))
self.navigate()
self.code("await page.getByRole('button',{name:'Ver configuraciones pendientes',exact:true}).click();return true;")
self.assertTrue(self.code("return await page.locator('#workstation-configurations').evaluate(e=>e===document.activeElement);"))
self.assertTrue(self.code("return await page.getByRole('checkbox',{name:'Solo pendientes',exact:true}).isChecked();"))
self.capture("S01-configurations")
def test_04_empty_and_dismantled(self):
for key, name in [("withoutResponsible","S02"),("empty","S03"),("retired","S06")]:
self.navigate(key)
self.capture(name)
if key == "withoutResponsible":
self.assertTrue(self.code("return (await page.locator('main').innerText()).includes('Sin responsable');"))
self.assertTrue(self.code("return await page.getByRole('link',{name:'Ángela · laboratorio',exact:true}).isVisible();"))
else:
self.assertTrue(self.code("return await page.getByRole('button',{name:'Nuevo perfil',exact:true}).isDisabled();"))
self.assertFalse(self.code("return await page.getByRole('button',{name:'Gestionar software',exact:true}).isVisible();"))
def test_05_partial_errors_and_retry(self):
company = self.fixture["companyId"]
self.code("await page.route('**/api/work-profiles/"+company+"',route=>route.fulfill({status:503,contentType:'application/json',body:JSON.stringify({code:'INTERNAL_ERROR'})}));await page.route('**/api/folder-mappings/"+company+"',route=>route.fulfill({status:503,contentType:'application/json',body:JSON.stringify({code:'INTERNAL_ERROR'})}));return true;")
try:
self.navigate(pending=False)
self.code("await page.getByText('No se puede confirmar el total de pendientes.',{exact:true}).waitFor();return true;")
self.assertFalse(self.code("return await page.locator('[data-pending-count]').isVisible();"))
self.assertTrue(self.code("return await page.getByRole('heading',{name:'Equipamiento',exact:true}).isVisible();"))
self.code("await page.getByRole('link',{name:'Gestor documental de ejemplo',exact:true}).first().waitFor();return true;")
self.capture("S05-profiles-and-folders-error")
finally:
self.code("await page.unroute('**/api/work-profiles/"+company+"');await page.unroute('**/api/folder-mappings/"+company+"');return true;")
self.code("await page.getByRole('button',{name:'Reintentar',exact:true}).first().click();return true;")
self.navigate()
self.code("await page.route('**/api/folder-mappings/"+company+"',route=>route.fulfill({status:503,contentType:'application/json',body:JSON.stringify({code:'INTERNAL_ERROR'})}));return true;")
try:
self.navigate(pending=False)
self.code("await page.getByText('No se puede confirmar el total de pendientes.',{exact:true}).waitFor();return true;")
self.assertTrue(self.code("return await page.getByRole('link',{name:'Ángela · administración',exact:true}).isVisible();"))
self.assertTrue(self.code("return await page.locator('#workstation-configurations').getByText('Instalación pendiente de completar.',{exact:true}).isVisible();"))
self.capture("S05-folders-only-error")
finally:
self.code("await page.unroute('**/api/folder-mappings/"+company+"');return true;")
def test_06_missing_and_authentication(self):
self.tool("browser_navigate", {"url":self.origin+self.base+"/workstations/019a0a00-0000-7000-8000-000000000999"})
self.code("await page.getByText('Este puesto de trabajo no existe en esta compañía.',{exact:true}).waitFor();return true;")
status = self.code("const context=await page.context().browser().newContext();try{const response=await context.request.get("+json.dumps(self.origin+"/api/assets/"+self.fixture['companyId'])+");return response.status()}finally{await context.close()}")
self.assertEqual(status,401)
self.record("S09-authentication", {"unauthenticatedStatus":status})
def api(self, path, body=None):
return self.code("""const path=__PATH__,body=__BODY__;const origin=__ORIGIN__;
const session=await page.request.get(origin+'/api/auth/session');const {csrfToken}=await session.json();
const response=body===null?await page.request.get(origin+'/api'+path):await page.request.post(origin+'/api'+path,{data:body,headers:{origin,'x-csrf-token':csrfToken}});
if(!response.ok())throw Error('Fixture HTTP '+response.status());return response.json();
""".replace("__PATH__", json.dumps(path)).replace("__BODY__",json.dumps(body)).replace("__ORIGIN__",json.dumps(self.origin)))
def test_07_replace_main_computer(self):
company = self.fixture["companyId"]
inventory = self.api('/assets/'+company)
original = next(x for x in inventory['workstations'] if x['id']==self.fixture['main'])
replacement = next((x for x in inventory['assets'] if x['values']['name']=='DASH-S10-SWAP'),None)
if replacement is None:
catalog = self.api('/asset-catalog/'+company)
template = next(x for x in catalog['items'] if x['values'].get('name')=='Portátil' and x['values']['kind']=='template')
replacement = self.api('/assets/'+company,{'values':{'name':'DASH-S10-SWAP','templateId':template['id'],'siteId':None,'status':'operational','notes':'Fixture S10 desechable.','characteristics':{}}})['asset']
values = dict(original['values'],computerAssetId=replacement['id'],assetIds=[replacement['id']])
self.api('/assets/'+company+'/workstations/'+original['id']+'/update',{'values':values})
try:
self.navigate()
self.code("await page.getByRole('link',{name:'DASH-S10-SWAP',exact:true}).first().waitFor();return true;")
self.assertFalse(self.code("return await page.getByRole('link',{name:'Ángela · administración',exact:true}).isVisible();"))
self.assertEqual(self.code("return await page.locator('[data-pending-count]').textContent();"),'0')
self.capture('S10-replaced-computer')
finally:
self.api('/assets/'+company+'/workstations/'+original['id']+'/update',{'values':original['values']})
def test_08_unknown_documentary_coverage(self):
company = self.fixture['companyId']
mapping = next(x for x in self.api('/folder-mappings/'+company)['mappings'] if x['values']['profileId']==self.fixture['primaryProfile'])
updated = self.api('/folder-mappings/'+company+'/'+mapping['id']+'/update',{'values':dict(mapping['values'],accessAccountId=None),'expectedRevisionId':mapping['history'][-1]['id']})['mapping']
try:
self.assertEqual(updated['accessAccountCoverage'],'unknown')
self.navigate()
self.code("await page.locator('#workstation-configurations').scrollIntoViewIfNeeded();return true;")
label=json.loads((ROOT/'modules/companies/src/common/locales/es.json').read_text())['companies']['folders']['accountCoverage']['unknown']
self.code("await page.locator('#workstation-configurations').getByText("+json.dumps(label)+",{exact:false}).waitFor();return true;")
self.assertEqual(self.code("return await page.locator('[data-pending-count]').textContent();"),'1')
self.capture('S11-unknown-coverage')
finally:
self.api('/folder-mappings/'+company+'/'+mapping['id']+'/update',{'values':mapping['values'],'expectedRevisionId':updated['history'][-1]['id']})
def test_09_stale_profiles_and_retry(self):
self.navigate()
company=self.fixture['companyId']
self.code("await page.route('**/api/work-profiles/"+company+"',route=>route.fulfill({status:503,contentType:'application/json',body:JSON.stringify({code:'INTERNAL_ERROR'})}));return true;")
try:
self.code("await page.context().setOffline(true);await page.context().setOffline(false);await page.getByText('No se puede confirmar el total de pendientes.',{exact:true}).waitFor();return true;")
self.assertTrue(self.code("return await page.getByRole('region',{name:'Personas y perfiles',exact:true}).getByRole('link',{name:'Ángela · administración',exact:true}).isVisible();"))
self.capture('S07-stale-profiles')
finally:
self.code("await page.unroute('**/api/work-profiles/"+company+"');return true;")
self.code("await page.getByRole('button',{name:'Reintentar',exact:true}).first().click();await page.locator('[data-pending-count=\"1\"]').waitFor();return true;")
def test_10_keyboard_contrast_and_pagination(self):
self.tool('browser_resize',{'width':1440,'height':900})
self.navigate()
self.code("await page.getByRole('button',{name:'Ver historial',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.assertTrue(self.code("return await page.getByRole('button',{name:'Ver historial',exact:true}).evaluate(e=>e.matches(':focus-visible'));"))
self.code("await page.getByRole('combobox',{name:'Seleccionar perfil',exact:true}).focus();await page.keyboard.press('ArrowDown');await page.getByRole('option').first().waitFor();await page.keyboard.press('End');await page.keyboard.press('Enter');return true;")
self.assertIn('Íñigo',self.code("return await page.getByRole('combobox',{name:'Seleccionar perfil',exact:true}).textContent();"))
ratios=self.code("""return await page.evaluate(()=>{
const rgb=color=>{const canvas=document.createElement('canvas');canvas.width=canvas.height=1;const context=canvas.getContext('2d');context.fillStyle=color;context.fillRect(0,0,1,1);return [...context.getImageData(0,0,1,1).data].slice(0,3)};
const lum=c=>c.map(v=>v/255).map(v=>v<=0.04045?v/12.92:((v+0.055)/1.055)**2.4).reduce((s,v,i)=>s+v*[0.2126,0.7152,0.0722][i],0);
return [...document.querySelectorAll('main header button,main a,[data-pending-count]')].filter(e=>e.getBoundingClientRect().width>0).map(e=>{const colors=[];for(let n=e;n;n=n.parentElement){colors.unshift(getComputedStyle(n).backgroundColor)}const canvas=document.createElement('canvas');canvas.width=canvas.height=1;const context=canvas.getContext('2d');context.fillStyle='#fff';context.fillRect(0,0,1,1);for(const color of colors){context.fillStyle=color;context.fillRect(0,0,1,1)}const bg=[...context.getImageData(0,0,1,1).data].slice(0,3);const fg=rgb(getComputedStyle(e).color);const values=[lum(bg),lum(fg)].sort((a,b)=>a-b);return {label:e.textContent.trim()||e.getAttribute('aria-label'),ratio:(values[1]+0.05)/(values[0]+0.05)}});
});""")
self.record('text-contrast',ratios)
self.assertTrue(all(x['ratio']>=4.5 for x in ratios),ratios)
self.navigate('dense')
self.code("const region=page.getByRole('region',{name:'Personas y perfiles',exact:true});await region.getByRole('button',{name:'Página siguiente',exact:true}).click();return true;")
self.assertTrue(self.code("return await page.getByRole('region',{name:'Personas y perfiles',exact:true}).getByRole('button',{name:'Página 2',exact:true}).evaluate(e=>e.getAttribute('aria-current')==='page');"))
self.record('S04-pagination',{'profilePage':2})
def test_11_save_editor_and_rename(self):
company=self.fixture['companyId']
original=next(x for x in self.api('/assets/'+company)['workstations'] if x['id']==self.fixture['main'])
renamed='Administración — prueba de edición del dashboard'
self.navigate()
try:
self.code("await page.getByRole('button',{name:'Editar puesto',exact:true}).first().click();const dialog=page.getByRole('dialog');await dialog.getByRole('textbox',{name:'Nombre',exact:true}).fill("+json.dumps(renamed)+");await dialog.getByRole('button',{name:'Guardar',exact:true}).click();await dialog.waitFor({state:'hidden'});await page.getByRole('heading',{level:1,name:"+json.dumps(renamed)+",exact:true}).waitFor();return true;")
saved=next(x for x in self.api('/assets/'+company)['workstations'] if x['id']==original['id'])
self.assertEqual(len(saved['history']),len(original['history'])+1)
label=json.loads((ROOT/'modules/assets/src/common/locales/es.json').read_text())['workstationDetail']['rename']
self.code("await page.getByRole('button',{name:"+json.dumps(label)+",exact:true}).click();const field=page.getByRole('textbox',{name:'Nombre',exact:true});await field.fill("+json.dumps(original['values']['name'])+");await field.press('Enter');await page.getByRole('heading',{level:1,name:"+json.dumps(original['values']['name'])+",exact:true}).waitFor();return true;")
restored=next(x for x in self.api('/assets/'+company)['workstations'] if x['id']==original['id'])
self.assertEqual(restored['values'],original['values'])
self.assertEqual(len(restored['history']),len(original['history'])+2)
self.record('editor-save-and-inline-rename',{'saved':True,'revisionDelta':2})
finally:
current=next(x for x in self.api('/assets/'+company)['workstations'] if x['id']==original['id'])
if current['values'] != original['values']:
self.api('/assets/'+company+'/workstations/'+original['id']+'/update',{'values':original['values']})
if __name__ == "__main__":
unittest.main(verbosity=2)