276 lines
22 KiB
Python
276 lines
22 KiB
Python
#!/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)
|