metis-desk/scripts/picker-ui.test.py
David db57346c24 feat: add PickerHelp component and browser fixture for picker UI
- Introduced PickerHelp component for contextual help in pickers.
- Created PickerBrowserFixture for testing picker components in a browser-like environment.
- Implemented a comprehensive regression test suite for picker UI using Playwright.
- Added support for localization in picker components and tests.
2026-10-22 09:35:02 +02:00

266 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(next(x for x in states if x['label']=='Campo')['disabled'])
self.assertTrue(next(x for x in states if x['label']=='Selección')['disabled'])
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)