metis-desk/scripts/list-states-ui.test.py
David 9fc7d3a2e0 feat: add entity list state management and UI components
- Implemented asset and resource list query state helpers to manage loading, error, and availability states.
- Created EntityListStatus component for displaying status messages and retry options based on entity list state.
- Added EntityTableSkeleton component for rendering loading placeholders in tables.
- Defined EntityListState type and entityListState function for consistent state management across components.
- Developed comprehensive UI tests for list states using Playwright, covering various scenarios including loading, error handling, and responsiveness.
2026-10-22 11:16:59 +02:00

182 lines
23 KiB
Python

#!/usr/bin/env python3
"""L02.3: estados reales de TanStack Query con red interceptada mediante Playwright MCP.
Requiere sesión del preparador y visual-audit-volume.py. No crea datos ni inicia servicios.
Los 503/403 y la reconexión son simulaciones explícitas, no fallos espontáneos del servidor.
"""
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 = [(1920,1080),(1440,900),(1366,768),(1024,768),(768,1024),(390,844),(320,640)]
class ListStatesUiTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
value=os.environ.get('METIS_PLAYWRIGHT_STORAGE_STATE')
if not value: raise unittest.SkipTest('Requiere sesión sintética activa.')
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.')
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 sintética local ordinaria.')
cls.origin=report['origin'];cls.company=report['companyId'];cls.output=ROOT/'.artifacts/playwright/l02-3/after';cls.output.mkdir(parents=True,exist_ok=True)
cls.results=[];cls.measurements=cls.output/('measurements-'+str(os.getpid())+'.json')
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))
if cls.code('return (await page.request.get('+json.dumps(cls.origin+'/api/companies')+')).status();')!=200: raise RuntimeError('La sesión no está activa.')
cls.code("page.blockedWrites=[];await page.route('**/api/**',r=>{if(['GET','HEAD','OPTIONS'].includes(r.request().method()))return r.continue();page.blockedWrites.push(r.request().method());return r.abort()});return true;")
@classmethod
def code(cls, body):
try:
return AUDIT['result_json'](cls.client.tool('browser_run_code_unsafe',{'code':'async(page)=>{'+body+'}'}))
except RuntimeError as error:
(cls.output/('tool-error-'+str(os.getpid())+'.txt')).write_text(str(error))
raise
@classmethod
def tearDownClass(cls):
(cls.output/('console-'+str(os.getpid())+'.json')).write_text(json.dumps(AUDIT['tool'](cls.client,'browser_console_messages',{'level':'warning'}),ensure_ascii=False,indent=2)+'\n')
(cls.output/('blocked-writes-'+str(os.getpid())+'.json')).write_text(json.dumps(cls.code('return page.blockedWrites;'))+'\n')
def settle(self):
self.code("await page.evaluate(async()=>{await document.fonts.ready;await new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(r)));await Promise.all(document.getAnimations().filter(a=>a.effect?.getTiming().iterations!==Infinity).map(a=>a.finished.catch(()=>{})))});return true;")
def resize(self,w,h):
AUDIT['tool'](self.client,'browser_resize',{'width':w,'height':h});self.settle()
def navigate(self,section='assets'):
self.code('await page.goto('+json.dumps(self.origin+'/companies/'+self.company+'/'+section)+",{waitUntil:'domcontentloaded'});await page.locator('[data-slot=inventory-layout]').waitFor();return true;");self.settle()
def network(self,endpoint='assets',mode='hold'):
url=self.origin+'/api/'+endpoint+'/'+self.company
self.code('page.gates=[];page.started=new Promise(resolve=>page.onStart=resolve);page.networkUrl='+json.dumps(url)+';page.networkMode='+json.dumps(mode)+";await page.route(page.networkUrl,async r=>{page.onStart();if(page.networkMode==='hold'){await new Promise(resolve=>page.gates.push(resolve));await r.continue()}else if(page.networkMode==='empty'){const response=await r.fetch();const data=await response.json();if('assets'in data){data.assets=[];data.workstations=[];data.relationships=[]}else data.resources=[];await r.fulfill({response,json:data})}else await r.fulfill({status:page.networkMode==='forbidden'?403:503,contentType:'application/json',body:JSON.stringify({code:page.networkMode==='forbidden'?'FORBIDDEN':'INTERNAL_ERROR'})})});return true;")
def release(self):
self.code("const response=page.waitForResponse(r=>r.url()===page.networkUrl);for(const release of page.gates)release();await response;await page.unroute(page.networkUrl);return true;");self.settle()
def clear_network(self):
self.code('await page.unroute(page.networkUrl);return true;')
def ready(self):
self.code("await page.locator('[data-slot=entity-table][data-query-state=ready]').waitFor();return true;");self.settle()
def refetch(self):
self.code("await page.evaluate(()=>{window.dispatchEvent(new Event('offline'));window.dispatchEvent(new Event('online'))});await page.started;return true;")
def record(self,name,value):
self.results.append({'scenario':name,'value':value});self.measurements.write_text(json.dumps(self.results,ensure_ascii=False,indent=2)+'\n')
def capture(self,name):
AUDIT['capture'](self.client,self.output,name)
snapshot=AUDIT['tool'](self.client,'browser_snapshot',{})
(self.output/(name+'.txt')).write_text('\n'.join(x['text'] for x in snapshot['content'] if x.get('type')=='text'))
def metrics(self):
return self.code("""return page.locator('[data-slot=inventory-layout]').evaluate(root=>{
const table=root.querySelector('table'),container=root.querySelector('[data-slot=entity-table]'),search=root.querySelector('input[data-quick-search]'),main=root.closest('[data-slot=workspace-scroll]')??root.closest('main').parentElement,scroller=table?.closest('[data-slot=table-container]'),r=table?.getBoundingClientRect(),active=document.activeElement;
return {viewport:[innerWidth,innerHeight],state:container?.dataset.queryState,table:!!table,sameTable:!window.stateTable||table===window.stateTable,search:search?.value,searchEnabled:!!search&&!search.disabled,skeletons:root.querySelectorAll('[data-slot=entity-table-skeleton-row]').length,skeletonActions:root.querySelectorAll('[data-slot=entity-table-skeleton-row] button,[data-slot=entity-table-skeleton-row] a,[data-slot=entity-table-skeleton-row] [tabindex]').length,rows:table?.querySelectorAll('tbody tr:not([aria-hidden=true])').length,columnsDisabled:[...root.querySelectorAll('button')].find(b=>b.textContent==='Columnas')?.disabled,nextDisabled:root.querySelector('button[aria-label="Página siguiente"]')?.disabled,tableY:r?.y,headerY:root.querySelector('h2')?.getBoundingClientRect().y,selected:root.dataset.selectedAsset??root.dataset.selectedResource,detailMode:root.dataset.detailMode,detailOpen:root.dataset.detailOpen,sort:table?.querySelector('th[aria-sort]')?.getAttribute('aria-sort'),page:root.querySelector('[aria-current=page]')?.textContent,scrollLeft:scroller?.scrollLeft,scrollTop:main?.scrollTop,focusSame:!window.stateFocus||active===window.stateFocus,focusY:active.getBoundingClientRect().y,focusTag:active.tagName,focusText:active.textContent?.slice(0,60),overflow:document.documentElement.scrollWidth>innerWidth,alerts:[...root.querySelectorAll('[role=alert]')].map(e=>e.textContent),status:[...root.querySelectorAll('[role=status]')].map(e=>e.textContent),text:container?.textContent};
});""")
def test_01_initial_structure_and_arrival(self):
self.network()
self.navigate()
self.code("await page.started;await page.locator('[data-query-state=loading]').waitFor();await page.evaluate(()=>window.stateTable=document.querySelector('main table'));return true;")
for width,height in SIZES:
self.resize(width,height);value=self.metrics();self.record('initial-'+str(width),value)
self.assertTrue(value['table'] and value['searchEnabled']);self.assertGreater(value['skeletons'],0);self.assertEqual(value['skeletonActions'],0);self.assertTrue(value['nextDisabled']);self.assertFalse(value['overflow'])
if width in [320,1440,1920]:self.capture('initial-'+str(width))
self.code("await page.locator('main input[data-quick-search]').fill('AUDIT');await page.getByRole('columnheader',{name:'Nombre',exact:true}).getByRole('button').click();await page.locator('main input[data-quick-search]').focus();await page.evaluate(()=>window.stateFocus=document.activeElement);return true;")
self.release();self.ready();value=self.metrics();self.record('initial-arrived',value)
self.assertTrue(value['sameTable'] and value['focusSame']);self.assertEqual(value['search'],'AUDIT');self.assertEqual(value['sort'],'descending');self.assertEqual(value['skeletons'],0);self.assertGreater(value['rows'],0);self.capture('initial-arrived-320')
def test_02_dependencies(self):
for dependency in ['asset-catalog','asset-infrastructure']:
self.resize(1440,900);self.network(dependency);self.navigate();self.ready();value=self.metrics();self.record('pending-'+dependency,value)
self.assertGreater(value['rows'],0);self.assertTrue(value['searchEnabled']);self.assertEqual(value['columnsDisabled'],dependency=='asset-catalog');self.capture('pending-'+dependency)
self.code("await page.evaluate(()=>window.stateTable=document.querySelector('main table'));return true;")
self.release();self.ready();self.assertTrue(self.metrics()['sameTable'])
self.network('asset-infrastructure','error');self.navigate();self.ready()
self.code("await page.getByRole('button',{name:'Reintentar infraestructura',exact:true}).waitFor({state:'visible'});return true;")
self.assertGreater(self.metrics()['rows'],0);self.capture('accessory-error');self.clear_network()
def test_03_refetch_context_and_error_recovery(self):
self.resize(1920,1080);self.navigate();self.ready()
self.code("await page.locator('main input[data-quick-search]').fill('AUDIT-ASSET');await page.getByRole('columnheader',{name:'Nombre',exact:true}).getByRole('button').click();await page.getByRole('button',{name:'Página 2',exact:true}).click();await page.getByRole('button',{name:'Columnas',exact:true}).click();await page.getByRole('checkbox',{name:'Notas',exact:true}).check();await page.getByRole('checkbox',{name:'Direcciones IP de interfaces',exact:true}).check();await page.getByRole('checkbox',{name:'Direcciones MAC de interfaces',exact:true}).check();await page.keyboard.press('Escape');await page.locator('main tbody tr').first().locator('td').nth(2).click();await page.locator('[data-slot=selection-panel]').waitFor();await page.evaluate(()=>{window.stateTable=document.querySelector('main table');window.stateFocus=window.stateTable.querySelector('tbody a');window.stateFocus.focus({preventScroll:true});document.querySelector('[data-slot=workspace-scroll]').scrollTop=150;window.stateTable.closest('[data-slot=table-container]').scrollLeft=120});return true;")
self.settle();before=self.metrics();self.record('context-before',before);self.assertGreater(before['scrollTop'],0);self.assertGreater(before['scrollLeft'],0)
self.network();self.refetch();self.code("await page.locator('[data-query-state=refreshing]').waitFor();return true;");self.settle();pending=self.metrics();self.record('refetch-pending',pending)
for key in ['sameTable','focusSame']:self.assertTrue(pending[key])
for key in ['selected','detailMode','detailOpen','sort','page','scrollLeft','scrollTop','tableY']:self.assertEqual(before[key],pending[key],key)
self.assertGreater(pending['rows'],0);self.assertEqual(pending['skeletons'],0);self.capture('refetch-pending-1920')
self.release();self.ready();self.network(mode='error');self.refetch();self.code("await page.locator('[data-query-state=stale]').waitFor();await page.locator('[data-slot=entity-table]').getByRole('button',{name:'Reintentar',exact:true}).waitFor();return true;");self.settle()
failed=self.metrics();self.record('refetch-failed',failed);self.assertGreater(failed['rows'],0);self.assertTrue(failed['sameTable'] and failed['focusSame']);self.assertEqual(failed['selected'],before['selected']);self.assertEqual(failed['page'],before['page']);self.capture('refetch-failed-1920')
self.clear_network();self.record('before-retry',self.metrics());self.code("await page.locator('[data-slot=entity-table]').getByRole('button',{name:'Reintentar',exact:true}).click();return true;");self.ready();self.record('retry-recovered',self.metrics());self.assertTrue(self.metrics()['sameTable'])
def test_04_initial_error_empty_filters_permissions(self):
self.resize(320,640);self.network(mode='error');self.navigate();self.code("await page.locator('[data-query-state=unavailable]').waitFor();return true;");value=self.metrics();self.record('initial-503',value);self.assertTrue(value['table'] and value['searchEnabled']);self.assertEqual(value['skeletons'],0);self.assertTrue(value['alerts']);self.capture('initial-503-320')
self.clear_network();self.code("await page.locator('[data-slot=entity-table]').getByRole('button',{name:'Reintentar',exact:true}).focus();await page.keyboard.press('Enter');return true;");self.ready();self.record('initial-retry-focus',self.metrics());self.assertGreater(self.metrics()['rows'],0);self.assertTrue(self.code("return page.locator('main input[data-quick-search]').evaluate(e=>e===document.activeElement);"))
self.network(mode='empty');self.navigate();self.ready();self.record('empty-diagnostic',self.metrics());self.capture('empty-diagnostic');self.assertTrue(self.code("return (await page.getByText('Esta compañía todavía no tiene activos.',{exact:true}).count())>0;"));self.capture('empty-320');self.clear_network()
self.navigate();self.ready();self.code("await page.locator('main input[data-quick-search]').fill('zzzz-no-existe');return true;");self.assertTrue(self.code("return (await page.getByText('No hay resultados para estos criterios.',{exact:true}).count())>0;"));self.capture('filtered-empty-320');self.code("await page.getByRole('button',{name:'Limpiar filtros',exact:true}).click();return true;");self.assertGreater(self.metrics()['rows'],0)
self.network(mode='forbidden');self.navigate();self.code("await page.locator('[data-query-state=unavailable]').waitFor();return true;");self.assertEqual(self.code("return page.locator('[data-slot=entity-table]').getByRole('button',{name:'Reintentar',exact:true}).count();"),0);self.capture('forbidden-320');self.clear_network()
def test_05_resources(self):
self.resize(1024,768);self.network('resources');self.navigate('resources');self.code("await page.locator('[data-query-state=loading]').waitFor();await page.evaluate(()=>window.stateTable=document.querySelector('main table'));return true;");self.capture('resources-initial');self.release();self.ready();self.assertTrue(self.metrics()['sameTable']);self.assertGreater(self.metrics()['rows'],0)
self.network('resources','error');self.refetch();self.code("await page.locator('[data-query-state=stale]').waitFor();return true;");self.assertGreater(self.metrics()['rows'],0);self.capture('resources-stale');self.clear_network();self.code("await page.locator('[data-slot=entity-table]').getByRole('button',{name:'Reintentar',exact:true}).click();return true;");self.ready()
def test_06_catalog_errors_and_joint_loading(self):
self.resize(1440,900)
urls=[self.origin+'/api/'+kind+'/'+self.company for kind in ['assets','asset-catalog']]
self.code('page.jointUrls='+json.dumps(urls)+";page.jointGates=[];page.jointStarted=new Promise(resolve=>page.jointOnStart=resolve);for(const url of page.jointUrls)await page.route(url,async r=>{await new Promise(resolve=>{page.jointGates.push(resolve);if(page.jointGates.length===2)page.jointOnStart()});await r.continue()});return true;")
self.navigate();self.code("await page.jointStarted;return true;")
value=self.metrics();self.record('joint-loading',value);self.assertEqual(value['state'],'loading');self.assertTrue(value['columnsDisabled']);self.capture('joint-loading')
self.code("const responses=page.jointUrls.map(url=>page.waitForResponse(url));for(const release of page.jointGates)release();await Promise.all(responses);for(const url of page.jointUrls)await page.unroute(url);return true;");self.ready()
self.network('asset-catalog','error');self.navigate();self.ready()
self.code("await page.getByRole('button',{name:'Reintentar catálogo',exact:true}).waitFor();return true;")
value=self.metrics();self.record('catalog-initial-503',value);self.assertGreater(value['rows'],0);self.assertTrue(value['columnsDisabled']);self.assertTrue(self.code("return page.getByRole('button',{name:'Nuevo activo',exact:true}).isDisabled();"));self.capture('catalog-initial-503')
self.clear_network();self.code("await page.getByRole('button',{name:'Reintentar catálogo',exact:true}).click();await page.getByRole('button',{name:'Columnas',exact:true}).waitFor();await page.waitForFunction(()=>![...document.querySelectorAll('main button')].find(e=>e.textContent==='Columnas').disabled);return true;")
self.network('asset-catalog','error');self.refetch();self.code("await page.getByRole('button',{name:'Reintentar catálogo',exact:true}).waitFor();return true;")
value=self.metrics();self.record('catalog-stale-503',value);self.assertFalse(value['columnsDisabled']);self.assertGreater(value['rows'],0);self.capture('catalog-stale-503');self.clear_network()
def test_07_editor_focus_reduced_motion_and_english(self):
self.resize(1440,900);self.navigate();self.ready()
self.code("await page.getByRole('button',{name:'Editar',exact:true}).first().click();await page.getByRole('dialog').waitFor();return true;")
before=self.code("return page.getByRole('dialog').evaluate(e=>({title:e.querySelector('h2')?.textContent,inputs:[...e.querySelectorAll('input')].map(i=>i.value)}));")
self.network(mode='error');self.refetch();self.code("await page.locator('[data-query-state=stale]').waitFor();return true;")
after=self.code("return page.getByRole('dialog').evaluate(e=>({title:e.querySelector('h2')?.textContent,inputs:[...e.querySelectorAll('input')].map(i=>i.value)}));")
self.assertEqual(before,after);self.capture('editor-during-stale');self.clear_network();self.code("await page.keyboard.press('Escape');return true;")
self.network();self.code("await page.emulateMedia({reducedMotion:'reduce'});return true;");self.navigate();self.code("await page.locator('[data-query-state=loading]').waitFor();return true;")
motion=self.code("return page.locator('[data-slot=entity-table-skeleton-row] [data-slot=skeleton]').first().evaluate(e=>getComputedStyle(e).animationName);");self.assertEqual(motion,'none');self.record('reduced-motion',motion);self.release();self.ready()
self.code("""await page.emulateMedia({reducedMotion:'no-preference'});await page.locator('summary[aria-label="Menú de usuario"]').click();await page.getByRole('combobox',{name:'Idioma',exact:true}).click();await page.getByRole('option',{name:'English',exact:true}).click();await page.locator('summary[aria-label="User menu"]').click();return true;""")
try:
self.network(mode='error');self.refetch();self.code("await page.locator('[data-query-state=stale]').waitFor();return true;")
text=self.metrics()['text'];self.assertIn('last available',text);self.assertTrue(self.code("return page.locator('[data-slot=entity-table]').getByRole('button',{name:'Retry',exact:true}).isVisible();"));self.capture('english-stale')
self.record('english-stale',self.metrics());self.clear_network()
finally:
self.code("""await page.locator('summary[aria-label="User menu"]').click();await page.getByRole('combobox',{name:'Language',exact:true}).click();await page.getByRole('option',{name:'Español',exact:true}).click();await page.locator('summary[aria-label="Menú de usuario"]').click();return true;""")
def test_08_template_and_status_contrast(self):
self.resize(1440,900);self.network();self.navigate();self.code("await page.locator('[data-query-state=loading]').waitFor();return true;")
contrast="""const rgb=color=>{const c=document.createElement('canvas').getContext('2d');c.fillStyle=color;c.fillRect(0,0,1,1);return [...c.getImageData(0,0,1,1).data].slice(0,3)};const canvas=document.createElement('canvas').getContext('2d');canvas.fillStyle='#fff';canvas.fillRect(0,0,1,1);const chain=[];for(let n=e;n;n=n.parentElement)chain.unshift(n);for(const n of chain){canvas.fillStyle=getComputedStyle(n).backgroundColor;canvas.fillRect(0,0,1,1)}const lum=values=>values.map(v=>v/255).map(v=>v<=.04045?v/12.92:((v+.055)/1.055)**2.4).reduce((sum,v,i)=>sum+v*[.2126,.7152,.0722][i],0);const fg=rgb(getComputedStyle(e).color),bg=[...canvas.getImageData(0,0,1,1).data].slice(0,3),l=[lum(fg),lum(bg)].sort((a,b)=>a-b);return {fg,bg,ratio:(l[1]+.05)/(l[0]+.05)};"""
loading=self.code("return page.locator('main [role=status]').first().evaluate(e=>{"+contrast+"});");self.record('loading-contrast',loading);self.assertGreaterEqual(loading['ratio'],4.5);self.release();self.ready()
self.code("await page.getByRole('tab',{name:'Impresora',exact:true}).click();await page.locator('main input[data-quick-search]').fill('AUDIT');return true;")
before=self.metrics();self.assertGreater(before['rows'],0);self.network(mode='error');self.refetch();self.code("await page.locator('[data-query-state=stale]').waitFor();return true;")
after=self.metrics();self.assertEqual(before['search'],after['search']);self.assertEqual(before['rows'],after['rows']);self.assertEqual(self.code("return page.getByRole('tab',{name:'Impresora',exact:true}).getAttribute('aria-selected');"),'true');self.record('template-stale',after)
error=self.code("return page.locator('[data-slot=entity-table] [role=alert] p').evaluate(e=>{"+contrast+"});");self.record('error-contrast',error);self.assertGreaterEqual(error['ratio'],4.5)
self.resize(320,640);self.code("await page.locator('[data-slot=entity-table]').getByRole('button',{name:'Reintentar',exact:true}).scrollIntoViewIfNeeded();return true;")
rect=self.code("return page.locator('[data-slot=entity-table]').getByRole('button',{name:'Reintentar',exact:true}).evaluate(e=>{const r=e.getBoundingClientRect();return {x:r.x,right:r.right,y:r.y,bottom:r.bottom,width:innerWidth,height:innerHeight}});");self.record('retry-mobile-rect',rect);self.assertGreaterEqual(rect['x'],0);self.assertLessEqual(rect['right'],320);self.assertLessEqual(rect['bottom'],640);self.capture('stale-retry-visible-320');self.clear_network()
if __name__=='__main__':unittest.main()