- 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.
182 lines
23 KiB
Python
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()
|