#!/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.clientHeighte.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)