metis-desk/scripts/picker-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

267 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""Regresión de selectores mediante el MCP/Chromium del preparador sintético existente.
METIS_PLAYWRIGHT_STORAGE_STATE=.artifacts/playwright/authenticated-ID/session.json \
python3 scripts/picker-ui.test.py
No inicia servicios ni escribe entidades. La fixture React solo se importa por Vite en tests.
"""
import contextlib
import json
import os
from pathlib import Path
import runpy
import tomllib
import unittest
from urllib.parse import quote, urlparse
ROOT = Path(__file__).resolve().parent.parent
AUDIT = runpy.run_path(str(ROOT / "scripts/visual-audit.py"))
SIZES = [(320, 640), (390, 844), (768, 1024), (1024, 768), (1366, 768), (1440, 900)]
class PickerUiTests(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 de scripts/visual-audit.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 directorio de auditoría.")
report = json.loads((state.parent / "report.json").read_text())
if urlparse(report['origin']).hostname != 'localhost' or report.get('ordinaryUser') is not True:
raise RuntimeError("Se requiere una sesión sintética local ordinaria.")
cls.origin = report['origin']
cls.company = '/companies/' + report['companyId']
cls.asset_id = report['assetId']
cls.output = ROOT / '.artifacts/playwright/l02-1'
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))
status=cls.code('return (await page.request.get('+json.dumps(cls.origin+'/api/companies')+')).status();')
if status != 200:
raise RuntimeError('La sesión sintética no está activa; preparar otra según docs/playwright-mcp.md.')
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 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 tearDownClass(cls):
(cls.output / 'console.json').write_text(json.dumps(cls.tool('browser_console_messages', {'level': 'error'}), ensure_ascii=False, indent=2)+'\n')
(cls.output / 'blocked-writes.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):
self.tool('browser_resize', {'width': w, 'height': h})
self.settle()
def navigate(self, path):
self.code("await page.evaluate(()=>window.pickerTestRoot?.unmount());return true;")
self.tool('browser_navigate', {'url': self.origin+path})
self.code("await page.locator('[data-slot=workspace]').waitFor();return true;")
self.settle()
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)
result = self.tool('browser_snapshot', {})
(self.output / (name+'.txt')).write_text('\n'.join(v['text'] for v in result['content'] if v.get('type') == 'text'))
def fixture(self, count=12, locale='es', long_names=False, kind='fields'):
self.navigate('/companies')
module = '/@fs/' + quote(str(ROOT / 'packages/rdx-ui/src/picker-browser-fixture.tsx'), safe='/')
options = {'count': count, 'locale': locale, 'longNames': long_names, 'kind': kind}
self.code('const target=await page.evaluateHandle(()=>{const d=document.createElement("div");document.body.append(d);return d});await page.evaluate(async({target,options,module})=>{const {mountPickerFixture}=await import(module);window.pickerTestRoot=mountPickerFixture(target,options)},{target,options:'+json.dumps(options)+',module:'+json.dumps(module)+'});return true;')
self.code("await page.getByRole('button',{name:"+json.dumps('Choose fields' if locale == 'en' else 'Seleccionar campos')+",exact:true}).click();await page.getByRole('dialog').locator('input[data-quick-search]').waitFor();return true;")
self.settle()
def geometry(self):
return self.code("""return await page.locator('[role=dialog]:has([data-slot=form-body])').last().evaluate(d=>{
let c=d.querySelector('[data-slot=table-container]');for(let n=c.parentElement;n&&n!==d;n=n.parentElement){if(getComputedStyle(n).overflowY==='auto'&&n.clientHeight<c.clientHeight)c=n}const b=c.getBoundingClientRect(),head=d.querySelector('thead').getBoundingClientRect();
const body=d.querySelector('[data-slot=form-body]'),br=body.getBoundingClientRect(),f=d.querySelector('[data-slot=dialog-footer]').getBoundingClientRect(),dr=d.getBoundingClientRect();
const bottom=Math.min(b.bottom,br.bottom,f.top),top=Math.max(b.top,br.top,head.bottom);
const rows=[...d.querySelectorAll('tbody tr')].filter(e=>e.querySelector('[role=checkbox],[role=radio]'));
return {viewport:[innerWidth,innerHeight],height:dr.height,tableClient:c.clientHeight,tableScroll:c.scrollHeight,bodyClient:body.clientHeight,bodyScroll:body.scrollHeight,full:rows.filter(e=>{const r=e.getBoundingClientRect();return r.top>=top-.5&&r.bottom<=bottom+.5}).length,rows:rows.length,footerVisible:f.top>=dr.top&&f.bottom<=dr.bottom+.5,focusQuick:document.activeElement.hasAttribute('data-quick-search'),overflow:document.documentElement.scrollWidth>innerWidth};
});""")
def test_01_capacity_counts_and_resize(self):
for count in [0, 1, 2, 12]:
with self.subTest(count=count):
self.resize(320, 640)
self.fixture(count=count)
g=self.geometry();self.record('count-'+str(count),g)
self.assertGreater(g['tableClient'], 0)
self.assertTrue(g['footerVisible'])
self.assertTrue(g['focusQuick'])
self.assertFalse(g['overflow'])
if count < 3:self.assertEqual(g['full'],count)
if count>=2:self.assertGreaterEqual(g['full'],2)
self.code("await page.keyboard.press('Escape');return true;")
for kind in ['fields','options']:
self.fixture(kind=kind)
for w,h in SIZES:
self.resize(w,h);g=self.geometry();self.record(f'{kind}-{w}',g)
self.assertGreater(g['tableClient'],0)
self.assertGreaterEqual(g['full'],2)
self.assertTrue(g['footerVisible'])
self.assertFalse(g['overflow'])
if w in [320,1440]:self.capture(f'after-fixture-{kind}-{w}')
self.code("await page.keyboard.press('Escape');return true;")
def test_02_long_help_and_short_window(self):
self.resize(320,640);self.fixture(long_names=True)
g=self.geometry();self.record('long-names',g);self.assertGreater(g['tableClient'],0)
self.code("await page.getByRole('dialog').locator('[data-slot=table-container]').evaluate(e=>e.scrollTop=e.scrollHeight);return true;")
self.settle();self.capture('after-long-names-scrolled')
self.code("await page.getByRole('button',{name:'Sección y ayuda',exact:true}).click();await page.locator('[data-slot=popover-content]').waitFor();return true;")
self.settle()
help_metrics=self.code("return await page.locator('[data-slot=popover-content]').evaluate(e=>({client:e.clientHeight,scroll:e.scrollHeight,top:e.getBoundingClientRect().top,bottom:e.getBoundingClientRect().bottom,text:e.textContent}));")
self.record('long-help',help_metrics);self.assertGreater(help_metrics['client'],0)
self.assertGreater(help_metrics['scroll'],help_metrics['client'])
self.assertIn('Sección de destino',help_metrics['text'])
self.code("await page.keyboard.press('PageDown');await page.waitForFunction(()=>document.querySelector('[data-slot=popover-content]').scrollTop>0);return true;")
self.record('help-keyboard-scroll',self.code("return await page.locator('[data-slot=popover-content]').evaluate(e=>({scroll:e.scrollTop,focused:e.contains(document.activeElement)}));"))
self.assertEqual(self.geometry()['height'],g['height'])
self.capture('after-help-320')
self.code("await page.keyboard.press('Escape');await page.locator('[data-slot=popover-content]').waitFor({state:'hidden'});return true;")
self.assertEqual(self.code("return await page.getByRole('dialog').count();"),1)
self.resize(320,480);self.record('short-window',self.geometry());self.assertGreater(self.geometry()['tableClient'],0)
self.capture('after-short-window')
self.code("await page.keyboard.press('Escape');return true;")
def test_03_identity_draft_keyboard_and_paging(self):
self.resize(390,844);self.fixture(kind='options')
self.code("await page.getByRole('dialog').getByRole('button',{name:'Columnas',exact:true}).click();return true;")
states=self.code("return await page.getByRole('dialog').last().getByRole('checkbox').evaluateAll(es=>es.map(e=>({label:e.closest('label').textContent,disabled:e.getAttribute('aria-disabled')==='true'||e.hasAttribute('disabled')})));")
self.record('protected-columns',states)
self.assertTrue(self.code("return page.getByRole('dialog').last().getByRole('checkbox',{name:'Campo',exact:true}).isDisabled();"))
# L02.2 excluye el control interno selection del configurador; sigue presente en la tabla.
self.assertFalse(any(x['label']=='Selección' for x in states))
self.code("await page.getByRole('dialog').last().getByRole('checkbox',{name:'Detalles',exact:true}).uncheck();await page.keyboard.press('Escape');await page.getByRole('dialog').getByRole('radio').first().focus();await page.keyboard.press('Space');return true;")
self.assertEqual(self.code("return await page.getByRole('dialog').getByRole('radio').first().getAttribute('aria-checked');"),'true')
self.assertEqual(self.code("return await page.locator('output').textContent();"),'—')
self.capture('after-option-identity')
self.code("await page.getByRole('button',{name:'Cancelar',exact:true}).click();return true;")
self.assertTrue(self.code("return await page.getByRole('button',{name:'Seleccionar campos',exact:true}).evaluate(e=>e===document.activeElement);"))
self.code("await page.getByRole('button',{name:'Seleccionar campos',exact:true}).click();return true;")
self.assertEqual(self.code("return await page.getByRole('dialog').getByRole('radio').first().getAttribute('aria-checked');"),'false')
self.code("await page.getByRole('dialog').getByRole('radio').first().check();await page.getByRole('dialog').getByRole('button',{name:'Página siguiente',exact:true}).click();return true;")
self.assertEqual(self.geometry()['rows'],2)
self.code("await page.getByRole('dialog').getByRole('button',{name:'Seleccionar',exact:true}).click();return true;")
self.assertEqual(self.code("return await page.locator('output').textContent();"),'option-0')
def test_04_catalogs_actual_nested_and_empty(self):
for module in ['assets','resources']:
self.resize(320,640);self.navigate('/catalogs/'+module)
self.code("await page.getByRole('button',{name:'Nueva plantilla',exact:true}).click();await page.getByRole('dialog').getByRole('button',{name:/^Añadir campo/}).click();return true;")
self.settle();initial=self.geometry();self.record('catalog-'+module,initial)
self.assertGreater(initial['tableClient'],0);self.assertTrue(initial['focusQuick'])
self.capture('after-catalog-'+module+'-320')
self.code("await page.getByRole('dialog').last().getByRole('button',{name:'Nuevo campo',exact:true}).click();return true;")
self.settle();self.capture('after-quick-create-'+module);self.assertEqual(self.code("return await page.locator('[role=dialog][data-slot=dialog-content]').count();"),3)
self.code("await page.keyboard.press('Escape');await page.locator('[role=dialog] input[data-quick-search]').waitFor({state:'visible'});return true;")
self.settle();self.capture('after-quick-return-'+module)
self.assertEqual(self.code("return await page.locator('[role=dialog][data-slot=dialog-content]').count();"),2)
self.assertTrue(self.code("return await page.getByRole('button',{name:'Nuevo campo',exact:true}).evaluate(e=>e===document.activeElement);"))
self.code("await page.getByRole('dialog').last().locator('input[data-quick-search]').fill('NO-EXISTE-L02');return true;")
self.settle();self.assertEqual(self.geometry()['height'],initial['height'])
self.assertEqual(self.geometry()['rows'],0)
self.code("await page.keyboard.press('Escape');return true;")
self.assertEqual(self.code("return await page.getByRole('dialog').count();"),1)
self.assertTrue(self.code("return await page.evaluate(()=>document.activeElement.tagName==='BUTTON');"))
def test_05_english(self):
self.resize(320,640);self.fixture(locale='en',kind='options',count=1)
text=self.code("return await page.getByRole('dialog').locator('p[aria-live]').innerText();")
self.assertEqual(text,'1–1 of 1 field')
self.assertNotIn('Choose',text)
self.record('english-range',text);self.capture('after-english-320')
def test_06_remote(self):
self.resize(320,640);self.fixture(kind='remote')
self.assertGreater(self.geometry()['tableClient'],0)
self.code("await page.getByRole('dialog').getByRole('radio').first().check();await page.getByRole('button',{name:'Siguiente',exact:true}).click();return true;")
self.assertEqual(self.geometry()['rows'],2)
self.capture('after-remote-320');self.record('remote',self.geometry())
self.code("await page.getByRole('button',{name:'Cancelar',exact:true}).click();return true;")
self.assertEqual(self.code('return await page.locator(\'output[aria-label="Resultado"]\').textContent();'),'—')
def test_07_actual_assets_profile_and_domain(self):
self.resize(1440,900)
self.code("""const companyId="""+json.dumps(self.company.split('/')[-1])+""";
const siteId='00000000-0000-7000-8000-000000000201';
await page.route('**/api/companies/'+companyId,async r=>{const response=await r.fetch();const data=await response.json();data.company.sites=[{id:siteId,companyId,name:'L02 Sede sintética',active:true,address:null,phone:null}];await r.fulfill({response,json:data})});
await page.route('**/api/assets/'+companyId,async r=>{const response=await r.fetch();const data=await response.json();const base=data.assets[0];data.assets=Array.from({length:12},(_,i)=>{const a=structuredClone(base);if(i)a.id='00000000-0000-7000-8000-'+String(300+i).padStart(12,'0');a.values.siteId=siteId;a.values.name='L02 Equipo '+String(i+1).padStart(2,'0');a.assigned=i===0;a.displayStatus=i===0?'in_use':'available';a.history=[{...a.history[0],id:'00000000-0000-7000-8000-'+String(500+i).padStart(12,'0'),values:structuredClone(a.values)}];return a});const values={name:'L02 Puesto',active:true,siteId,personId:null,computerAssetId:base.id,assetIds:[base.id]};data.workstations=[{id:'00000000-0000-7000-8000-000000000600',companyId,values,history:[{...base.history[0],id:'00000000-0000-7000-8000-000000000601',values}]}];await r.fulfill({response,json:data})});return true;
""")
self.navigate(self.company+'/workstations')
self.code("await page.getByRole('button',{name:'Nuevo puesto',exact:true}).click();await page.getByRole('dialog').getByRole('button',{name:'Siguiente',exact:true}).click();return true;")
label=self.code("return await page.getByRole('button',{name:'Seleccionar activos',exact:true}).evaluate(e=>({text:e.textContent,override:e.getAttribute('aria-label')}));")
self.assertIsNone(label['override']);self.record('actual-asset-label',label)
self.code("await page.getByRole('button',{name:'Seleccionar activos',exact:true}).click();return true;")
self.settle()
for w,h in SIZES:
self.resize(w,h);g=self.geometry();self.record('actual-assets-'+str(w),g)
if w==320:self.assertGreaterEqual(g['full'],2)
self.assertGreater(g['tableClient'],0);self.assertTrue(g['footerVisible']);self.assertFalse(g['overflow'])
if w==320:self.capture('after-actual-assets-320')
self.code("await page.getByRole('dialog').last().locator('[role=checkbox]:not([aria-disabled=true])').first().check();await page.getByRole('dialog').last().getByRole('button',{name:'Cancelar',exact:true}).click();return true;")
self.assertEqual(self.code("return await page.getByRole('dialog').count();"),1)
self.code("await page.getByRole('button',{name:'Seleccionar activos',exact:true}).click();return true;")
self.assertEqual(self.code("return await page.getByRole('dialog').last().locator('[role=checkbox][aria-checked=true]').count();"),0)
self.code("await page.getByRole('dialog').last().locator('[role=checkbox]:not([aria-disabled=true])').first().check();await page.getByRole('dialog').last().locator('[data-slot=dialog-footer] button').last().click();return true;")
self.assertEqual(self.code("return await page.getByRole('dialog').count();"),1)
self.code("await page.keyboard.press('Escape');return true;")
self.resize(1440,900);self.navigate(self.company+'/work-profiles')
self.code("await page.getByRole('button',{name:'Nuevo perfil',exact:true}).click();await page.getByRole('dialog').getByRole('button',{name:'Seleccionar puesto',exact:true}).click();return true;")
self.settle();text=self.code("return await page.getByRole('dialog').last().locator('p[aria-live]').innerText();")
self.assertEqual(text,'1–1 de 1 puesto');self.record('actual-workstation-range',text);self.capture('after-actual-workstation')
self.code("await page.keyboard.press('Escape');await page.keyboard.press('Escape');return true;")
self.navigate(self.company+'/assets/'+self.asset_id)
self.code("await page.getByRole('button',{name:'Asociar a dominio',exact:true}).click();await page.getByRole('dialog').locator('button[id$=\"-domain\"]').click();return true;")
self.settle();self.resize(320,640);self.capture('after-actual-domain-320')
self.assertTrue(self.code("return await page.getByRole('dialog').last().locator('input[data-quick-search]').evaluate(e=>e===document.activeElement);"))
self.code("await page.keyboard.press('Escape');return true;")
self.assertEqual(self.code("return await page.getByRole('dialog').count();"),1)
def test_08_checkboxes_and_effective_text_zoom(self):
self.resize(390,844);self.fixture()
self.code("await page.getByRole('dialog').locator('tbody tr').first().locator('td').nth(1).click();return true;")
self.assertEqual(self.code("return await page.getByRole('dialog').getByRole('checkbox').first().getAttribute('aria-checked');"),'true')
self.code("await page.getByRole('dialog').getByRole('checkbox').first().uncheck();return true;")
self.assertEqual(self.code("return await page.getByRole('dialog').getByRole('checkbox').first().getAttribute('aria-checked');"),'false')
self.code("await page.getByRole('dialog').locator('[data-slot=table-container]').evaluate(e=>e.scrollTop=e.scrollHeight);return true;")
self.assertEqual(self.code("return await page.getByRole('dialog').getByRole('checkbox').nth(9).getAttribute('aria-disabled');"),'true')
self.code("await page.getByRole('dialog').getByRole('button',{name:'Página siguiente',exact:true}).click();return true;")
self.assertEqual(self.geometry()['rows'],2)
self.code("await page.evaluate(()=>document.documentElement.style.fontSize='200%');return true;")
self.settle();g=self.geometry();self.record('text-scale-200',g)
self.assertGreater(g['tableClient'],0);self.assertGreater(g['bodyClient'],0)
self.assertFalse(g['overflow'])
self.code("await page.getByRole('dialog').getByRole('checkbox').first().focus();return true;")
self.settle()
focused=self.code("return await page.getByRole('dialog').getByRole('checkbox').first().evaluate(e=>{const r=e.getBoundingClientRect(),b=e.closest('[data-slot=form-body]').getBoundingClientRect();return {top:r.top,bottom:r.bottom,bodyTop:b.top,bodyBottom:b.bottom}});")
self.record('text-scale-focus',focused)
self.assertGreaterEqual(focused['top'],focused['bodyTop'])
self.assertLessEqual(focused['bottom'],focused['bodyBottom'])
self.capture('after-text-scale-200')
self.code("await page.evaluate(()=>document.documentElement.style.fontSize='');await page.keyboard.press('Escape');return true;")
if __name__=='__main__':
unittest.main(verbosity=2)