#!/usr/bin/env python3 """Capturas comparables y recorridos MCP de M03/M04/M06 sobre el preparador desechable.""" 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 = [(1600, 1000), (1440, 900), (1280, 900), (1024, 768), (768, 1024), (390, 844), (320, 720)] class WorkstationUiuxV21Tests(unittest.TestCase): @classmethod def setUpClass(cls): value = os.environ.get("METIS_PLAYWRIGHT_STORAGE_STATE") if not value: raise unittest.SkipTest("Requiere visual-audit.py y las fixtures de puestos.") 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 preparador.") report = json.loads((state.parent / "report.json").read_text()) cls.fixture = json.loads((state.parent / "workstation-fixtures.json").read_text()) cls.origin = report["origin"] if urlparse(cls.origin).hostname != "localhost" or report.get("ordinaryUser") is not True or cls.fixture['origin'] != cls.origin or cls.fixture['companyId'] != report['companyId']: raise RuntimeError("Requiere la instalación sintética desechable activa.") cls.phase = os.environ.get("METIS_UIUX_PHASE", "despues") if cls.phase not in ("antes", "despues"): raise RuntimeError("Fase desconocida.") cls.output = ROOT / "docs/auditorias/ficha-puesto-trabajo/implementacion-v2.1" (cls.output / cls.phase).mkdir(parents=True, exist_ok=True) cls.measurements = json.loads((cls.output / "mediciones.json").read_text()) if (cls.output / "mediciones.json").exists() else {} 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)) cls.base = cls.origin + "/companies/" + cls.fixture['companyId'] cls.code("page.__uiuxErrors=[];page.on('pageerror',e=>page.__uiuxErrors.push(e.message));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 save(cls, key, value): cls.measurements = json.loads((cls.output / "mediciones.json").read_text()) if (cls.output / "mediciones.json").exists() else {} cls.measurements.setdefault(cls.phase, {})[key] = value (cls.output / "mediciones.json").write_text(json.dumps(cls.measurements, ensure_ascii=False, indent=2) + "\n") def navigate(self, key="main"): self.tool("browser_navigate", {"url": self.base + "/workstations/" + self.fixture[key]}) self.code("await page.locator('main h1').waitFor();await page.locator('[data-pending-count]').waitFor();return true;") if key == "main": self.code("await page.getByRole('link',{name:'Ángela · administración',exact:true}).waitFor();await page.getByText('equipo-adm-01.example.invalid',{exact:false}).first().waitFor();await page.getByText('Autorizada',{exact:true}).waitFor();return true;") if key == 'main': login='angela.munoz.documentacion.administracion.'*3+'example.invalid' self.code("await page.getByRole('region',{name:/^Personas y perfiles/}).getByRole('link',{name:"+json.dumps(login)+",exact:true}).waitFor();return true;") self.code("await page.getByRole('region',{name:/^Personas y perfiles/}).locator('table').waitFor();return true;") if key == 'dense': self.code("await page.getByRole('region',{name:/^Personas y perfiles/}).locator('tbody tr[data-entity-id]').first().waitFor();return true;") self.code("await page.evaluate(async()=>{await document.fonts.ready;await new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(r)))});return true;") def api(self, path, data=None): return self.code("const origin="+json.dumps(self.origin)+",path="+json.dumps(path)+",data="+json.dumps(data)+";const {csrfToken}=await(await page.request.get(origin+'/api/auth/session')).json();const r=data===null?await page.request.get(origin+'/api'+path):await page.request.post(origin+'/api'+path,{data,headers:{origin,'x-csrf-token':csrfToken}});if(!r.ok())throw Error('HTTP '+r.status());return r.json();") def capture(self, name): image = AUDIT["capture"](self.client, self.output / self.phase, name) geometry = self.code("""return await page.evaluate(()=>{const rect=e=>{const r=e.getBoundingClientRect();return {x:r.x,y:r.y,width:r.width,height:r.height}};const section=name=>[...document.querySelectorAll('main section[aria-labelledby]')].find(s=>document.getElementById(s.getAttribute('aria-labelledby'))?.textContent.startsWith(name));const panel=section('Personas y perfiles');const table=panel.querySelector('table');return {viewport:innerWidth,scrollWidth:document.documentElement.scrollWidth,profiles:rect(panel),equipment:rect(section('Equipamiento')),table:rect(table),rows:panel.querySelectorAll('tbody tr[data-entity-id]').length,headers:[...table.querySelectorAll('th')].filter(e=>e.getBoundingClientRect().width>0).map(e=>e.textContent),firstRowHeight:table.querySelector('tbody tr')?.getBoundingClientRect().height,firstRowLinks:[...table.querySelectorAll('tbody tr:first-child a')].filter(e=>e.getBoundingClientRect().width>0).map(e=>e.textContent),configurationHeight:document.querySelector('#workstation-configurations')?.getBoundingClientRect().height??null};});""") self.assertLessEqual(geometry['scrollWidth'],geometry['viewport']) self.save(name, {'image': image, 'geometry': geometry}) print(self.phase+': '+name,flush=True) def expand_primary(self): self.code("await page.getByRole('button',{name:'Mostrar configuraciones de Ángela · administración',exact:true}).click();await page.locator('#workstation-configurations').waitFor();return true;") def test_00_prepare_long_data_and_volume(self): if self.phase != 'antes': self.skipTest('Las fixtures se preparan antes de modificar la UI.') company=self.fixture['companyId'] profile=next(p for p in self.api('/work-profiles/'+company)['profiles'] if p['id']==self.fixture['primaryProfile']) account=next(p for p in self.api('/access/'+company)['items'] if p['id']==profile['values']['accountId']) login='angela.munoz.documentacion.administracion.'*3+'example.invalid' self.api('/access/'+company+'/'+account['id']+'/update',{'values':dict(account['values'],login=login)}) mapping=next(p for p in self.api('/folder-mappings/'+company)['mappings'] if p['values']['profileId']==profile['id']) path='\\\\srv-documentacion.example.invalid\\administracion\\documentacion-e-infraestructura-documentacion-e-infraestructura-documentacion-e-infraestructura-documentacion-e-infraestructura-documentacion-e-infraestructura-consulta' need=next(x for x in self.api('/access/'+company)['items'] if x['id']==mapping['values']['needId']) resource=next(x for x in self.api('/resources/'+company)['resources'] if x['id']==need['values']['resourceId']) values={key:resource['values'][key] for key in ('name','templateId','active','notes','characteristics','integration')} values['characteristics']={key:path if value==mapping['values']['path'] else value for key,value in values['characteristics'].items()} self.api('/resources/'+company+'/'+resource['id']+'/update',{'values':values}) self.api('/folder-mappings/'+company+'/'+mapping['id']+'/update',{'values':dict(mapping['values'],path=path),'expectedRevisionId':mapping['history'][-1]['id']}) self.code('const origin='+json.dumps(self.origin)+',id='+json.dumps(company)+";const {csrfToken}=await(await page.request.get(origin+'/api/auth/session')).json();const before=await(await page.request.get(origin+'/api/assets/'+id)).json();for(let i=0;i<50;i++){const name='UIUX-V21-PUESTO-'+String(i).padStart(2,'0');if(!before.workstations.some(s=>s.values.name===name)){const r=await page.request.post(origin+'/api/assets/'+id+'/workstations',{headers:{origin,'x-csrf-token':csrfToken},data:{values:{name,active:true,siteId:null,personId:null,computerAssetId:null,assetIds:[]}}});if(r.status()!==201)throw Error('Fixture no creada')}}return true;") self.save('fixtures',{'longLogin':login,'longPath':path,'counts':self.fixture['counts'],'workstations':len(self.api('/assets/'+company)['workstations'])}) def test_01_comparable_captures(self): for width,height in SIZES: self.tool('browser_resize',{'width':width,'height':height}) self.navigate() self.capture('principal-'+str(width)) self.code("await page.getByRole('region',{name:/^Personas y perfiles/}).scrollIntoViewIfNeeded();return true;") self.capture('perfiles-'+str(width)) for width,height in [(1440,900),(390,844)]: self.tool('browser_resize',{'width':width,'height':height}) self.navigate();self.expand_primary() self.code("await page.locator('#workstation-configurations').scrollIntoViewIfNeeded();return true;") self.capture('configuraciones-'+str(width)) self.code("await page.getByRole('button',{name:'Ver configuraciones pendientes',exact:true}).click();return true;") self.capture('pendientes-'+str(width)) self.tool('browser_resize',{'width':1440,'height':900});self.navigate('dense') self.capture('denso-resumen') self.code("await page.getByRole('region',{name:/^Personas y perfiles/}).getByRole('button',{name:/^Mostrar todos/}).click();return true;") self.capture('denso-50') self.code("await page.getByRole('button',{name:'10 perfiles archivados',exact:true}).click();return true;") self.capture('denso-60-con-archivados') self.code("await page.getByRole('region',{name:/^Equipamiento/}).getByRole('button',{name:/^Mostrar todos/}).click();return true;") self.capture('equipamiento-80') self.tool('browser_resize',{'width':390,'height':844});self.navigate('dense') self.code("await page.getByRole('region',{name:/^Personas y perfiles/}).scrollIntoViewIfNeeded();return true;") self.capture('denso-390') for width,height in [(1440,900),(320,720)]: self.tool('browser_resize',{'width':width,'height':height});self.navigate('empty');self.capture('vacio-'+str(width)) self.save('pageErrors',self.code('return page.__uiuxErrors;')) self.assertEqual(self.code('return page.__uiuxErrors;'),[]) def test_02_container_thresholds(self): self.tool('browser_resize',{'width':1600,'height':1000});self.navigate() results=[] for width in [599,600,601,839,840,841]: result=self.code("const panel=page.getByRole('region',{name:/^Personas y perfiles/});await panel.locator('[data-slot=card]').evaluate((e,w)=>{e.style.width=w+'px'},"+str(width)+");await page.evaluate(()=>new Promise(requestAnimationFrame));return await panel.evaluate(e=>{const card=e.querySelector('[data-slot=card]'),table=e.querySelector('table'),visible=x=>x.getBoundingClientRect().width>0;return {container:card.getBoundingClientRect().width,table:table.getBoundingClientRect().width,scrollWidth:table.parentElement.scrollWidth,headers:[...table.querySelectorAll('th')].filter(visible).map(x=>x.textContent),links:[...table.querySelectorAll('tbody tr:first-child a')].filter(visible).map(x=>x.textContent)}});") results.append(result) if self.phase=='despues': self.assertLessEqual(result['scrollWidth'],result['table']+1) self.assertEqual(len(result['headers']),2 if width<600 else 4 if width<840 else 5) self.assertEqual(len(result['links']),3) self.save('thresholds',results) def test_03_dense_scope_and_keyboard(self): self.tool('browser_resize',{'width':1440,'height':900});self.navigate('dense') result=self.code("""const p=page.getByRole('region',{name:/^Personas y perfiles/}),e=page.getByRole('region',{name:/^Equipamiento/});const count=async x=>(await x.locator('h2').innerText()).trim();const initial={profiles:await count(p),equipment:await count(e),scope:await p.getByRole('status').innerText(),equipmentScope:await e.getByRole('status').innerText()};await p.getByRole('button',{name:'Mostrar todos',exact:true}).click();const rows50=await p.locator('tbody tr[data-entity-id]').count();await p.getByRole('button',{name:'10 perfiles archivados',exact:true}).click();const rows60=await p.locator('tbody tr[data-entity-id]').count();const archivedHeader=await count(p);const expanded=p.getByRole('button',{name:/^Mostrar configuraciones de/}).last();const name=await expanded.getAttribute('aria-label');const initialButton=p.getByRole('button',{name,exact:true});await initialButton.focus();await initialButton.press('Enter');const selected=p.getByRole('button',{name:name.replace('Mostrar','Ocultar'),exact:true});await selected.waitFor();const controls=await selected.getAttribute('aria-controls');const focus=await selected.evaluate(x=>x===document.activeElement);await p.getByRole('button',{name:'Mostrar menos',exact:true}).click();const pinned={rows:await p.locator('tbody tr[data-entity-id]').count(),scope:await p.getByRole('status').innerText(),selectedVisible:await p.getByRole('button',{name:name.replace('Mostrar','Ocultar'),exact:true}).isVisible()};await e.getByRole('button',{name:'Mostrar todos',exact:true}).click();const equipment80=await e.locator('[data-slot=compact-list-item]').count();await e.getByRole('button',{name:'Mostrar menos',exact:true}).click();return {initial,rows50,rows60,archivedHeader,pinned,equipment80,equipment5:await e.locator('[data-slot=compact-list-item]').count(),focus,controlled:await page.locator('[id="'+controls+'"]').isVisible(),principal:await e.locator('[data-slot=compact-list-item]').first().innerText()};""") self.save('denseInteractions',result) self.assertTrue(result['initial']['profiles'].endswith('50'));self.assertTrue(result['initial']['equipment'].endswith('80')) self.assertEqual(result['initial']['scope'],'5 de 50 perfiles activos');self.assertEqual(result['initial']['equipmentScope'],'5 de 80 elementos de equipamiento') self.assertEqual((result['rows50'],result['rows60'],result['equipment80'],result['equipment5']),(50,60,80,5)) self.assertTrue(result['archivedHeader'].endswith('60')) self.assertEqual(result['pinned']['rows'],6);self.assertEqual(result['pinned']['scope'],'6 de 60 perfiles, activos y archivados') self.assertTrue(result['pinned']['selectedVisible']);self.assertTrue(result['focus']);self.assertTrue(result['controlled']);self.assertIn('Principal',result['principal']) self.save('denseInteractions',result) def test_04_technical_disclosure_and_pending(self): self.tool('browser_resize',{'width':390,'height':844});self.navigate();self.expand_primary() result=self.code("""const configurations=page.locator('#workstation-configurations'),folder=configurations.getByRole('region',{name:'Carpetas del perfil',exact:true});const trigger=folder.getByRole('button',{name:/^Ver detalles técnicos:/});await trigger.waitFor();const closed=await trigger.getAttribute('aria-expanded');await trigger.focus();await trigger.press('Space');const open=folder.getByRole('button',{name:/^Ocultar detalles técnicos:/});const id=await open.getAttribute('aria-controls');const details=page.locator('[id="'+id+'"]');const text=await details.innerText();const focus=await open.evaluate(e=>document.activeElement===e);await open.press('Enter');const closedAgain=await trigger.getAttribute('aria-expanded');const links=await configurations.getByRole('link',{name:'Gestionar',exact:true}).evaluateAll(es=>es.map(e=>e.getAttribute('href')));await page.getByRole('button',{name:'Ver configuraciones pendientes',exact:true}).click();return {closed,openText:text,focus,closedAgain,links,pending:await configurations.innerText(),overflow:await page.evaluate(()=>document.documentElement.scrollWidth>innerWidth)};""") self.assertEqual(result['closed'],'false');self.assertEqual(result['closedAgain'],'false');self.assertTrue(result['focus']);self.assertFalse(result['overflow']) self.assertIn('documentacion-e-infraestructura',result['openText']);self.assertEqual(len(result['links']),5) for link in result['links']:self.assertIn('/'+self.fixture['companyId']+'/work-profiles/'+self.fixture['primaryProfile']+'/',link) self.assertIn('Instalación pendiente de completar.',result['pending']);self.save('technicalInteractions',result) def test_05_zoom_reflow_and_dark(self): results=[] for zoom in [2,4]: self.tool('browser_resize',{'width':1440,'height':900});self.navigate();self.expand_primary() result=self.code("const cdp=await page.context().newCDPSession(page);page.__zoomCdp=cdp;await cdp.send('Emulation.setDeviceMetricsOverride',{width:"+str(1440//zoom)+",height:"+str(900//zoom)+",deviceScaleFactor:"+str(zoom)+",mobile:false});await page.evaluate(()=>new Promise(requestAnimationFrame));return await page.evaluate(()=>({dpr:devicePixelRatio,viewport:innerWidth,scrollWidth:document.documentElement.scrollWidth}));") self.assertLessEqual(result['scrollWidth'],result['viewport']);self.capture('reflow-'+str(zoom*100));results.append(result) self.code("await page.__zoomCdp.send('Emulation.clearDeviceMetricsOverride');await page.__zoomCdp.detach();delete page.__zoomCdp;return true;") self.tool('browser_resize',{'width':390,'height':844});self.navigate();self.expand_primary() self.code("await page.evaluate(()=>document.documentElement.classList.add('dark'));return true;") self.capture('oscuro-390') self.code("await page.evaluate(()=>document.documentElement.classList.remove('dark'));return true;") self.save('zoomEmulation',results) def test_06_source_loading_refetch_error_and_recovery(self): route='**/api/work-profiles/'+self.fixture['companyId'] self.code("await page.route("+json.dumps(route)+",r=>{page.__heldProfiles=r});return true;") try: self.tool('browser_navigate',{'url':self.base+'/workstations/'+self.fixture['main']}) result=self.code("const p=page.getByRole('region',{name:/^Personas y perfiles/});await p.locator('table[aria-busy=true]').waitFor();return {countBadges:await p.locator('h2 span.tabular-nums').count(),loading:true};") self.assertEqual(result['countBadges'],0) finally: self.code("if(page.__heldProfiles){await page.__heldProfiles.continue();delete page.__heldProfiles}await page.unroute("+json.dumps(route)+");return true;") self.code("await page.getByRole('link',{name:'Ángela · administración',exact:true}).waitFor();return true;") self.code("await page.route("+json.dumps(route)+",r=>r.fulfill({status:503,contentType:'application/json',body:JSON.stringify({code:'INTERNAL_ERROR'})}));await page.context().setOffline(true);await page.context().setOffline(false);return true;") try: stale=self.code("const p=page.getByRole('region',{name:/^Personas y perfiles/});await p.getByRole('alert').waitFor();return {badges:await p.locator('h2 span.tabular-nums').count(),rows:await p.locator('tbody tr[data-entity-id]').count(),message:await p.getByRole('alert').innerText()};") self.assertEqual(stale['badges'],0);self.assertEqual(stale['rows'],2) finally:self.code("await page.unroute("+json.dumps(route)+");return true;") self.code("const p=page.getByRole('region',{name:/^Personas y perfiles/});await p.getByRole('button',{name:'Reintentar',exact:true}).click();await p.getByRole('alert').waitFor({state:'hidden'});await p.locator('h2 span.tabular-nums').waitFor();return true;") self.save('sourceStates',{'loading':result,'stale':stale,'recovered':True}) def test_07_unknown_review_partial_error_and_empty(self): route='**/api/folder-mappings/'+self.fixture['companyId'] profile=self.fixture['primaryProfile'] self.code("await page.route("+json.dumps(route)+",async route=>{const r=await route.fetch();const json=await r.json();for(const m of json.mappings){if(m.values.profileId==="+json.dumps(profile)+"){m.values.state='pending';m.needsReview=true;m.accessAccountCoverage='unknown';m.values.notes='Revisar cobertura documental; no comprueba permisos SMB efectivos.'}}await route.fulfill({response:r,json})});return true;") try: self.navigate();self.expand_primary() result=self.code("const f=page.locator('#workstation-configurations').getByRole('region',{name:'Carpetas del perfil',exact:true});const button=f.getByRole('button',{name:/^Ver detalles técnicos:/});await button.waitFor();const closed=await button.getAttribute('aria-expanded');const text=await f.innerText();await page.getByRole('button',{name:'Ver configuraciones pendientes',exact:true}).click();return {closed,text,pendingText:await f.innerText(),focused:await page.locator('#workstation-configurations').evaluate(e=>e===document.activeElement)};") self.assertEqual(result['closed'],'false');self.assertIn('Cuenta no indicada',result['text']);self.assertIn('Requiere revisión',result['text']);self.assertIn('no comprueba permisos SMB efectivos',result['pendingText']);self.assertTrue(result['focused']) self.capture('cobertura-desconocida-pendiente') finally:self.code("await page.unroute("+json.dumps(route)+");return true;") self.code("await page.route("+json.dumps(route)+",r=>r.fulfill({status:503,contentType:'application/json',body:JSON.stringify({code:'INTERNAL_ERROR'})}));return true;") try: self.tool('browser_navigate',{'url':self.base+'/workstations/'+self.fixture['main']}) self.code("await page.getByText('No se puede confirmar el total de pendientes.',{exact:true}).waitFor();return true;") self.expand_primary() self.code("await page.locator('#workstation-configurations').getByText('Instalación pendiente de completar.',{exact:true}).waitFor();return true;") self.capture('error-parcial-carpetas') finally:self.code("await page.unroute("+json.dumps(route)+");return true;") self.navigate('empty') counts=self.code("return {profiles:await page.getByRole('region',{name:/^Personas y perfiles/}).locator('h2 span.tabular-nums').innerText(),equipment:await page.getByRole('region',{name:/^Equipamiento/}).locator('h2 span.tabular-nums').innerText()};") self.assertEqual(counts,{'profiles':'0','equipment':'0'});self.save('unknownAndPartial',{'unknown':result,'partialOtherFamiliesAvailable':True,'empty':counts}) def test_08_v1_regressions(self): # Reutiliza los recorridos de v1, preservando íntegros su código y evidencias. source=ROOT/'scripts/workstation-uiux-v1.test.py' code=source.read_text().replace('implementacion-v1"','implementacion-v2.1/regresiones-v1"') code=code.replace("name:'Equipamiento',exact:true", "name:/^Equipamiento/") namespace={'__file__':str(source),'__name__':'uiux_v1_regressions'} exec(compile(code,str(source),'exec'),namespace) case=namespace['WorkstationUiuxV1Tests'] names=[name for name in unittest.defaultTestLoader.getTestCaseNames(case) if not name.startswith('test_01')] result=unittest.TextTestRunner(verbosity=2).run(unittest.TestSuite(case(name) for name in names)) self.save('v1Regressions',{'run':result.testsRun,'failures':len(result.failures),'errors':len(result.errors),'selectorsAdapted':'Equipamiento ahora incluye el contador en su nombre accesible.'}) self.assertTrue(result.wasSuccessful()) def test_09_totals_change_during_refetch(self): company=self.fixture['companyId'];station=next(w for w in self.api('/assets/'+company)['workstations'] if w['id']==self.fixture['dense']);asset=station['values']['computerAssetId'] route='**/api/work-profiles/'+company self.code("page.__v21Limit=6;await page.route("+json.dumps(route)+",async route=>{const r=await route.fetch();const json=await r.json();json.profiles=json.profiles.filter(p=>p.values.active&&p.values.assetId==="+json.dumps(asset)+").slice(0,page.__v21Limit);await route.fulfill({response:r,json})});return true;") results=[] try: self.navigate('dense') self.code("await page.getByRole('region',{name:/^Personas y perfiles/}).getByRole('button',{name:'Mostrar todos',exact:true}).click();return true;") for total in [5,1,0,50]: result=self.code("page.__v21Limit="+str(total)+";await page.context().setOffline(true);await page.context().setOffline(false);const p=page.getByRole('region',{name:/^Personas y perfiles/});await p.locator('h2 span.tabular-nums').filter({hasText:/^"+str(total)+"$/}).waitFor();return {total:"+str(total)+",rows:await p.locator('tbody tr[data-entity-id]').count(),buttons:await p.getByRole('button',{name:/^Mostrar (todos|menos)/}).count()};") self.assertEqual(result['rows'],total);self.assertEqual(result['buttons'],1 if total>5 else 0);results.append(result) finally:self.code("await page.unroute("+json.dumps(route)+");delete page.__v21Limit;return true;") self.save('refetchTotals',results) def test_10_profile_space_full_values_and_refetch(self): self.tool('browser_resize',{'width':390,'height':844});self.navigate() result=self.code("""const name='Ángela · administración';const button=page.getByRole('button',{name:'Mostrar configuraciones de '+name,exact:true});await button.focus();await button.press('Space');const selected=page.getByRole('button',{name:'Ocultar configuraciones de '+name,exact:true});await selected.waitFor();const profileFocused=await selected.evaluate(e=>e===document.activeElement);const focusVisible=await selected.evaluate(e=>e.matches(':focus-visible'));await selected.press('Tab');const next=await page.evaluate(()=>document.activeElement?.getAttribute('href'));const folder=page.locator('#workstation-configurations').getByRole('region',{name:'Carpetas del perfil',exact:true});await folder.getByRole('button',{name:/^Ver detalles técnicos:/}).press('Space');const technical=folder.getByRole('button',{name:/^Ocultar detalles técnicos:/});const values=await folder.locator('dd').allTextContents();const response=page.waitForResponse(r=>r.url().includes('/api/work-profiles/')&&r.status()===200);await page.context().setOffline(true);await page.context().setOffline(false);await response;await page.evaluate(()=>new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(r))));return {profileFocused,focusVisible,next,values,profileExpanded:await selected.getAttribute('aria-expanded'),technicalExpanded:await technical.getAttribute('aria-expanded'),technicalFocused:await technical.evaluate(e=>e===document.activeElement)};""") self.assertTrue(result['profileFocused']);self.assertTrue(result['focusVisible']);self.assertTrue(result['technicalFocused']);self.assertEqual(result['profileExpanded'],'true');self.assertEqual(result['technicalExpanded'],'true') self.assertIn('/work-profiles/'+self.fixture['secondaryProfile'],result['next']) self.assertIn(self.measurements['antes']['fixtures']['longPath'],result['values']) login=self.measurements['antes']['fixtures']['longLogin'];self.assertTrue(any(login in value for value in result['values'])) self.save('spaceFullValuesAndRefetch',result) self.code("await page.locator('#workstation-configurations').scrollIntoViewIfNeeded();return true;") self.capture('detalles-tecnicos-390') self.code("await page.evaluate(()=>document.documentElement.classList.add('dark'));return true;") self.capture('configuraciones-oscuras-390') self.code("await page.evaluate(()=>document.documentElement.classList.remove('dark'));return true;") self.tool('browser_resize',{'width':1440,'height':900}) self.code("await page.locator('#workstation-configurations').scrollIntoViewIfNeeded();return true;") self.capture('detalles-tecnicos-1440') def test_11_entity_family_navigation_and_isolation(self): self.tool('browser_resize',{'width':1440,'height':900});self.navigate() targets=[self.base+'/assets/'+self.fixture['host'],self.base+'/work-profiles/'+self.fixture['primaryProfile']] person=self.code("""return await page.getByRole('region',{name:/^Personas y perfiles/}).locator('tbody tr').first().locator('a[href*="/people/"]:visible').getAttribute('href');""") targets.append(self.origin+person if person.startswith('/') else person) visited=[] for target in targets: self.code("await page.locator('main a[href="+json.dumps(target.replace(self.origin,''))+ "]:visible').first().click();await page.waitForURL("+json.dumps(target)+");await page.locator('main h1, main h2').filter({hasText:/PORT-ADM-01|Ángela/}).first().waitFor();return true;") visited.append(target.replace(self.origin,'')) self.code("await page.goBack();await page.waitForURL("+json.dumps(self.base+'/workstations/'+self.fixture['main'])+");return true;") titles={'folder-mappings':'Carpetas del perfil','mail-configurations':'Correos en dispositivos','application-configurations':'Aplicaciones del perfil','printer-configurations':'Impresoras del perfil','certificate-configurations':'Certificados del perfil'} for family,title in titles.items(): self.navigate();self.expand_primary();target=self.base+'/work-profiles/'+self.fixture['primaryProfile']+'/'+family self.code("await page.locator('#workstation-configurations').locator('a[href="+json.dumps(target.replace(self.origin,''))+"]').click();await page.waitForURL("+json.dumps(target)+");await page.locator('#workstation-configurations').waitFor({state:'hidden'});await page.getByRole('heading',{name:"+json.dumps(title)+",exact:true}).waitFor();return true;") visited.append(target.replace(self.origin,'')) other=self.origin+'/companies/'+self.fixture['otherCompany']+'/workstations/'+self.fixture['main'] self.tool('browser_navigate',{'url':other}) self.code("await page.getByText('Este puesto de trabajo no existe en esta compañía.',{exact:true}).waitFor();return true;") self.assertFalse(self.code("return await page.getByRole('link',{name:'Ángela · administración',exact:true}).isVisible();")) status=self.code("const c=await page.context().browser().newContext();try{const r=await c.request.get("+json.dumps(self.origin+'/api/assets/'+self.fixture['companyId'])+");return r.status()}finally{await c.close()}") self.assertEqual(status,401);self.save('navigationAndIsolation',{'visited':visited,'wrongCompanyWorkstationAbsent':True,'unauthenticatedStatus':status}) def test_12_narrow_header_actions(self): results=[] for width,height in [(320,720),(390,844)]: self.tool('browser_resize',{'width':width,'height':height});self.navigate('dense') value=self.code("const p=page.getByRole('region',{name:/^Personas y perfiles/});return await p.evaluate(e=>{const card=e.querySelector('[data-slot=card]').getBoundingClientRect();return {viewport:innerWidth,right:card.right-16,actions:[...e.querySelectorAll('[data-slot=card-header] button')].map(b=>{const r=b.getBoundingClientRect();return {name:b.textContent,x:r.x,right:r.right,y:r.y,width:r.width}})}});") for action in value['actions']:self.assertLessEqual(action['right'],value['right']+1) results.append(value) self.save('narrowHeaderActions',results) def test_13_configuration_state_priority(self): results=[] for width,height in [(1440,900),(390,844)]: self.tool('browser_resize',{'width':width,'height':height});self.navigate();self.expand_primary() self.code("await page.locator('#workstation-configurations').scrollIntoViewIfNeeded();return true;") self.capture('configuraciones-'+str(width)) value=self.code("const item=page.locator('#workstation-configurations').getByRole('region',{name:'Carpetas del perfil',exact:true}).locator('[data-slot=compact-list-item]');const trigger=item.getByRole('button',{name:/^Ver detalles técnicos:/});await trigger.click();return await item.evaluate(e=>{const title=e.firstElementChild.firstElementChild,badge=e.querySelector('[data-slot=badge]'),details=e.querySelector('dl');return {viewport:innerWidth,titleY:title.getBoundingClientRect().top,badgeY:badge.getBoundingClientRect().top,detailsY:details.getBoundingClientRect().top}});") self.assertAlmostEqual(value['titleY'],value['badgeY'],delta=2);self.assertLess(value['badgeY'],value['detailsY']);results.append(value) self.code("await page.getByRole('button',{name:'Ver configuraciones pendientes',exact:true}).click();return true;") self.capture('pendientes-'+str(width)) self.save('configurationStatePriority',results) if __name__ == '__main__': unittest.main()