metis-desk/scripts/dialog-help-ui-audit.py

134 lines
20 KiB
Python
Raw Normal View History

"""Ayuda contextual: formularios reales, volumen sintético y navegador MCP fijado."""
import json
def verify(context, fixture, password):
tool, browser, capture = context['tool'], context['browser'], context['capture']
origin, directory = context['origin'], context['directory']
checks, screenshots, measurements = [], [], []
def code(client, source):
print('Ayuda MCP: ' + source[:95], flush=True)
try:
result = context['result_json'](tool(client, 'browser_run_code_unsafe', {
'code': 'async (page) => { page.setDefaultTimeout(15000); try { ' + source + ' return true; } catch(error) { return {helpCheckFailure: String(error.message).split(String.fromCharCode(10))[0]}; } }'}))
if isinstance(result, dict) and 'helpCheckFailure' in result:
raise RuntimeError('Comprobación de ayuda: ' + result['helpCheckFailure'])
return result
except RuntimeError:
capture(client, directory, 'dialog-help-failure')
raise
with browser(context['config']) as client:
tool(client, 'browser_resize', {'width': 1440, 'height': 1000})
tool(client, 'browser_navigate', {'url': origin + '/login'})
code(client, "await page.getByRole('button',{name:'Entrar',exact:true}).waitFor();")
tool(client, 'browser_fill_form', {'fields': [
{'name': 'Correo electrónico', 'type': 'textbox', 'target': 'input[type=email]', 'value': fixture['email']},
{'name': 'Contraseña', 'type': 'textbox', 'target': 'input[type=password]', 'value': fixture['password']}]})
tool(client, 'browser_click', {'target': 'button[type=submit]'})
code(client, "await page.locator('[data-slot=workspace]').waitFor({timeout:30000});")
code(client, "const response=await page.request.get(" + json.dumps(origin + '/api/assets/' + fixture['otherCompanies'][0]) + "); if(!response.ok()||(await response.json()).assets.length!==0)throw Error('Aislamiento de compañía');")
checks.append('other-company-inventory-isolated')
tool(client, 'browser_navigate', {'url': origin + f'/companies/{fixture["company"]}/assets/{fixture["asset"]}'})
code(client, "await page.getByRole('button',{name:'Editar activo',exact:true}).click(); await page.getByRole('dialog',{name:'Editar activo',exact:true}).waitFor();")
code(client, "const d=page.getByRole('dialog'); if(await d.getByRole('button',{name:'Ayuda',exact:true}).count()!==1 || await d.getByRole('button',{name:'Cerrar',exact:true}).count()!==1)throw Error('Cabecera duplicada'); if(await d.locator('aside').count())throw Error('Ayuda permanente'); await d.getByRole('combobox',{name:'Estado técnico',exact:true}).click(); await page.getByRole('option',{name:'Averiado',exact:true}).click(); await d.getByRole('textbox',{name:'Nombre',exact:true}).fill(' '); await d.getByRole('button',{name:'Guardar',exact:true}).click(); await d.getByRole('alert').first().waitFor(); await d.getByRole('textbox',{name:'Notas',exact:true}).fill('Borrador Álvarez conservado'); page.helpError=await d.getByRole('alert').first().textContent(); page.helpStatus=await d.getByRole('combobox',{name:'Estado técnico',exact:true}).textContent(); page.helpInput=await d.getByRole('textbox',{name:'Notas',exact:true}).elementHandle(); await d.getByRole('tab',{name:'Hardware',exact:true}).click();")
checks.extend(['ordinary-user', 'one-help-one-close', 'help-hidden-initially', 'real-invalid-form'])
for width in [320, 375, 390, 768, 1024, 1280, 1440]:
tool(client, 'browser_resize', {'width': width, 'height': 900})
code(client, "const b=page.getByRole('button',{name:'Ayuda',exact:true}); await b.focus(); await b.press('Enter'); await page.locator('[data-slot=dialog-help]').waitFor();")
value = code(client, "return await page.evaluate(()=>{const d=document.querySelector('[data-slot=dialog-content]');const h=d.querySelector('button[aria-label=Ayuda]');const x=d.querySelector('button[aria-label=Cerrar]');const main=d.querySelector('[data-slot=form-dialog-content]');const help=d.querySelector('[data-slot=dialog-help]');const r=d.getBoundingClientRect();return {width:innerWidth,dialogWidth:r.width,mode:d.querySelector('[data-help-mode]').dataset.helpMode,hidden:main.inert,focus:document.activeElement===help.querySelector('h2'),overflow:d.scrollWidth>d.clientWidth+1||document.documentElement.scrollWidth>innerWidth+1,aligned:h.getBoundingClientRect().right<=x.getBoundingClientRect().left,formWidth:main.getBoundingClientRect().width}});")
if value['overflow'] or not value['aligned'] or not value['focus']:
raise RuntimeError('Geometría o foco incorrectos: ' + json.dumps(value))
expected = 'panel' if value['dialogWidth'] >= 960 else 'view'
if value['mode'] != expected or (expected == 'view') != value['hidden']:
raise RuntimeError('Modo incorrecto: ' + json.dumps(value))
if expected == 'panel' and value['formWidth'] < 640:
raise RuntimeError('Formulario excesivamente estrecho')
measurements.append(value)
screenshots.append(capture(client, directory, 'dialog-help-' + str(width)))
code(client, "await page.keyboard.press('Escape'); const d=page.getByRole('dialog'); await d.getByRole('textbox',{name:'Notas',exact:true}).waitFor(); if(await d.getByRole('textbox',{name:'Notas',exact:true}).inputValue()!=='Borrador Álvarez conservado'||await d.getByRole('textbox',{name:'Nombre',exact:true}).inputValue()!==' ')throw Error('Campos perdidos'); if(await d.getByRole('alert').first().textContent()!==page.helpError||await d.getByRole('combobox',{name:'Estado técnico',exact:true}).textContent()!==page.helpStatus)throw Error('Validación o selector perdidos'); if(!await d.getByRole('button',{name:'Ayuda',exact:true}).evaluate(e=>e===document.activeElement))throw Error('Foco no restaurado'); if(!await d.getByRole('textbox',{name:'Notas',exact:true}).evaluate((e,previous)=>e===previous,page.helpInput))throw Error('Formulario remontado'); if(await d.getByRole('tab',{name:'Hardware',exact:true}).getAttribute('aria-selected')!=='true')throw Error('Estado local de pestaña perdido');")
checks.extend(['seven-widths', 'panel-and-view', 'keyboard-enter-escape', 'focus-return', 'same-input-node', 'draft-errors-select-preserved', 'no-horizontal-overflow'])
# Cambiar de modo con ayuda abierta; comprobar retorno explícito y reflow equivalente al zoom.
motion = code(client, "await page.getByRole('button',{name:'Ayuda',exact:true}).click(); await page.locator('[data-help-mode=panel]').waitFor(); return await page.locator('[data-slot=dialog-help]').evaluate(help=>{const host=help.parentElement;const animation=host.getAnimations().find(a=>a.animationName==='enter');if(!animation)throw Error('Panel sin animación CSS');const duration=getComputedStyle(host).animationDuration;animation.pause();animation.currentTime=0;const start=Number(getComputedStyle(host).opacity);animation.currentTime=100;const middle=Number(getComputedStyle(host).opacity);animation.finish();const end=Number(getComputedStyle(host).opacity);if(duration!=='0.2s'||start!==0||middle<=0||middle>=1||end!==1)throw Error('Progreso de animación incorrecto');return {duration,start,middle,end}});")
measurements.append({'panelAnimation': motion})
code(client, "await page.emulateMedia({reducedMotion:'reduce'}); if(await page.locator('[data-slot=dialog-help]').evaluate(e=>getComputedStyle(e.parentElement).animationName)!=='none')throw Error('Panel animado con movimiento reducido'); await page.emulateMedia({reducedMotion:'no-preference'});")
checks.extend(['panel-css-animation', 'panel-reduced-motion'])
tool(client, 'browser_resize', {'width': 390, 'height': 844})
code(client, "await page.locator('[data-help-mode=view]').waitFor(); await page.getByRole('button',{name:'Volver al formulario',exact:true}).click(); await page.getByRole('textbox',{name:'Notas',exact:true}).waitFor();")
checks.append('resize-panel-to-view-back-action')
tool(client, 'browser_resize', {'width': 1440, 'height': 1000})
code(client, "await page.getByRole('button',{name:'Ayuda',exact:true}).click(); await page.locator('[data-help-mode=panel]').waitFor(); await page.evaluate(()=>document.documentElement.classList.add('dark'));")
screenshots.append(capture(client, directory, 'dialog-help-dark'))
code(client, "await page.evaluate(()=>document.documentElement.classList.remove('dark')); await page.getByRole('button',{name:'Cerrar ayuda',exact:true}).click();")
checks.append('dark-mode-capture')
code(client, "await page.getByRole('button',{name:'Cerrar',exact:true}).click(); await page.getByRole('dialog').waitFor({state:'hidden'});")
checks.append('close-x')
# Selector real: la ayuda breve usa el Popover de Base UI dentro del diálogo modal.
tool(client, 'browser_navigate', {'url': origin + f'/companies/{fixture["company"]}/workstations/{fixture["softwareWorkstations"][0]}'})
code(client, "await page.getByRole('button',{name:'Añadir activo al puesto',exact:true}).click(); await page.getByRole('dialog',{name:'Añadir activo al puesto',exact:true}).waitFor(); await page.locator('[data-quick-search]').fill('AUDIT-PORT-01'); await page.getByRole('radio',{name:'Seleccionar AUDIT-PORT-01',exact:true}).check(); await page.getByRole('button',{name:'Ayuda',exact:true}).click(); await page.locator('[data-slot=popover-content]').waitFor();")
code(client, "await page.emulateMedia({reducedMotion:'reduce'}); if(await page.locator('[data-slot=popover-content]').evaluate(e=>getComputedStyle(e).animationName)!=='none')throw Error('Animación con movimiento reducido'); await page.keyboard.press('Tab'); if(!await page.locator('[data-slot=dialog-content]').evaluate(e=>e.contains(document.activeElement))&&!await page.locator('[data-slot=popover-content]').evaluate(e=>e.contains(document.activeElement)))throw Error('Foco fuera de modal'); await page.emulateMedia({reducedMotion:'no-preference'}); if(!await page.locator('[data-slot=popover-content]').isVisible()){await page.getByRole('button',{name:'Ayuda',exact:true}).click(); await page.locator('[data-slot=popover-content]').waitFor();}")
checks.append('reduced-motion-and-popover-tab')
screenshots.append(capture(client, directory, 'dialog-help-popover'))
code(client, "await page.keyboard.press('Escape'); await page.locator('[data-slot=popover-content]').waitFor({state:'hidden'}); if(await page.getByRole('dialog').count()!==1)throw Error('Escape cerró selector'); if(await page.locator('[data-quick-search]').inputValue()!=='AUDIT-PORT-01')throw Error('Búsqueda perdida'); await page.getByRole('button',{name:'Ayuda',exact:true}).click(); await page.locator('[data-slot=popover-content]').waitFor();")
tool(client, 'browser_resize', {'width': 375, 'height': 844})
code(client, "await page.locator('[data-help-mode=view]').waitFor(); await page.getByRole('button',{name:'Volver al formulario',exact:true}).click(); if(await page.locator('[data-quick-search]').inputValue()!=='AUDIT-PORT-01')throw Error('Búsqueda móvil perdida'); if(!await page.getByRole('button',{name:'Ayuda',exact:true}).evaluate(e=>e===document.activeElement))throw Error('Foco del selector móvil perdido'); if(!await page.getByRole('radio',{name:'Seleccionar AUDIT-PORT-01',exact:true}).isChecked())throw Error('Selección perdida'); await page.keyboard.press('Escape'); await page.getByRole('dialog').waitFor({state:'hidden'});")
checks.extend(['compact-popover', 'popover-escape-keeps-dialog', 'resize-popover-to-view', 'picker-search-preserved', 'picker-selection-preserved'])
tool(client, 'browser_resize', {'width': 1440, 'height': 1000})
tool(client, 'browser_navigate', {'url': origin + f'/companies/{fixture["company"]}/assets'})
code(client, "await page.getByRole('button',{name:'Columnas',exact:true}).click(); const d=page.getByRole('dialog'); await d.waitFor(); if(await d.getByRole('button',{name:'Ayuda',exact:true}).count())throw Error('Ayuda en diálogo sin ayuda'); if(await d.locator('[data-slot=dialog-header]').getByRole('button',{name:'Cerrar',exact:true}).count()!==1)throw Error('Cierre duplicado sin ayuda'); await page.keyboard.press('Escape'); await d.waitFor({state:'hidden'});")
checks.append('dialog-without-help')
tool(client, 'browser_navigate', {'url': origin + f'/companies/{fixture["company"]}/assets/{fixture["asset"]}'})
code(client, "await page.getByRole('button',{name:'Editar activo',exact:true}).click(); await page.getByRole('dialog').waitFor(); await page.getByRole('button',{name:'Ayuda',exact:true}).click(); await page.locator('[data-help-mode=panel]').waitFor(); await page.locator('[data-slot=dialog-help] summary').click(); await page.locator('[data-slot=dialog-help] details[open]').waitFor();")
# Colores realmente calculados, convertidos mediante canvas; no se deduce contraste de tokens.
contrast_source = """return await page.evaluate(()=>{
const surface=document.querySelector('[data-slot=dialog-content]');
const canvas=document.createElement('canvas');canvas.width=canvas.height=1;const ctx=canvas.getContext('2d',{willReadFrequently:true});
const paint=color=>{ctx.fillStyle=color;ctx.fillRect(0,0,1,1)};
const lum=channels=>channels.slice(0,3).map(v=>{v/=255;return v<=.04045?v/12.92:((v+.055)/1.055)**2.4}).reduce((v,c,i)=>v+c*[.2126,.7152,.0722][i],0);
return [...surface.querySelectorAll('[data-slot=dialog-help] p,[data-slot=dialog-help] h2,[data-slot=dialog-help] summary')].map(e=>{
ctx.clearRect(0,0,1,1);paint(getComputedStyle(surface).backgroundColor);
const ancestors=[];let current=e;while(current!==surface){ancestors.push(current);current=current.parentElement}
for(const parent of ancestors.reverse())paint(getComputedStyle(parent).backgroundColor);
const b=lum([...ctx.getImageData(0,0,1,1).data]);paint(getComputedStyle(e).color);
const a=lum([...ctx.getImageData(0,0,1,1).data]);return {tag:e.tagName,ratio:(Math.max(a,b)+.05)/(Math.min(a,b)+.05)};
});
});"""
contrast = {'light': code(client, contrast_source)}
code(client, "await page.evaluate(()=>document.documentElement.classList.add('dark'));")
contrast['dark'] = code(client, contrast_source)
for theme, items in contrast.items():
if any(item['ratio'] < 4.5 for item in items):
raise RuntimeError('Contraste insuficiente ' + theme + ': ' + json.dumps(items))
measurements.append({'contrast': contrast})
code(client, "await page.evaluate(()=>document.documentElement.classList.remove('dark')); await page.getByRole('button',{name:'Cerrar ayuda',exact:true}).click();")
checks.append('computed-text-contrast-light-dark')
for scale, width in [(2, 720), (4, 360)]:
tool(client, 'browser_resize', {'width': width, 'height': 900})
code(client, "await page.getByRole('button',{name:'Ayuda',exact:true}).click(); await page.locator('[data-help-mode=view]').waitFor(); if(await page.evaluate(()=>{const d=document.querySelector('[data-slot=dialog-content]');return d.scrollWidth>d.clientWidth+1}))throw Error('Reflow de zoom equivalente');")
screenshots.append(capture(client, directory, 'dialog-help-reflow-' + str(scale * 100)))
code(client, "await page.getByRole('button',{name:'Volver al formulario',exact:true}).click();")
tool(client, 'browser_resize', {'width': 1440, 'height': 1000})
for scale in [2, 4]:
code(client, "await page.evaluate(scale=>document.documentElement.style.fontSize=(scale*100)+'%'," + str(scale) + "); await page.getByRole('button',{name:'Ayuda',exact:true}).click(); await page.locator('[data-help-mode=view]').waitFor(); if(await page.evaluate(()=>{const d=document.querySelector('[data-slot=dialog-content]');return d.scrollWidth>d.clientWidth+1}))throw Error('Texto ampliado'); if(await page.locator('[data-slot=dialog-help]>div').evaluate(e=>e.clientHeight<parseFloat(getComputedStyle(e).lineHeight)*2))throw Error('Ayuda sin altura legible');")
screenshots.append(capture(client, directory, 'dialog-help-text-' + str(scale * 100)))
code(client, "await page.getByRole('button',{name:'Volver al formulario',exact:true}).click(); await page.evaluate(()=>document.documentElement.style.fontSize='');")
checks.append('reflow-and-text-200-400-not-browser-zoom')
tool(client, 'browser_resize', {'width': 390, 'height': 844})
code(client, "await page.getByRole('dialog').locator('[data-slot=dialog-title]').evaluate(e=>e.textContent='Edición de un activo con un título muy largo, tildes y una descripción contextual para comprobar la cabecera'); await page.getByRole('button',{name:'Ayuda',exact:true}).click(); await page.locator('[data-slot=dialog-help]').waitFor(); if(await page.evaluate(()=>{const d=document.querySelector('[data-slot=dialog-content]');return d.scrollWidth>d.clientWidth+1}))throw Error('Título largo desborda');")
screenshots.append(capture(client, directory, 'dialog-help-long-title'))
code(client, "await page.keyboard.press('Escape'); await page.keyboard.press('Escape'); await page.getByRole('dialog').waitFor({state:'hidden'});")
checks.append('synthetic-long-title-stress')
tool(client, 'browser_resize', {'width': 1440, 'height': 1000})
code(client, "await page.getByRole('button',{name:'Editar activo',exact:true}).click(); await page.getByRole('dialog').waitFor(); await page.getByRole('textbox',{name:'Notas',exact:true}).fill('Nota guardada tras consultar ayuda'); await page.getByRole('button',{name:'Ayuda',exact:true}).click(); await page.locator('[data-slot=dialog-help]').waitFor(); await page.getByRole('button',{name:'Cerrar ayuda',exact:true}).click(); await page.getByRole('button',{name:'Guardar',exact:true}).click(); await page.getByRole('dialog').waitFor({state:'hidden'}); await page.getByRole('button',{name:'Editar activo',exact:true}).click(); if(await page.getByRole('textbox',{name:'Notas',exact:true}).inputValue()!=='Nota guardada tras consultar ayuda')throw Error('Guardado tras ayuda'); await page.getByRole('textbox',{name:'Notas',exact:true}).fill('Cambio cancelado'); await page.getByRole('button',{name:'Cancelar',exact:true}).click(); await page.getByRole('dialog').waitFor({state:'hidden'}); await page.getByRole('button',{name:'Editar activo',exact:true}).click(); if(await page.getByRole('textbox',{name:'Notas',exact:true}).inputValue()!=='Nota guardada tras consultar ayuda')throw Error('Cancelar persistió'); await page.keyboard.press('Escape');")
checks.extend(['save-after-help', 'cancel-does-not-save'])
code(client, "await page.getByRole('button',{name:'Configuración',exact:true}).click(); await page.getByRole('combobox',{name:'Idioma',exact:true}).click(); await page.getByRole('option',{name:'English',exact:true}).click(); await page.getByRole('dialog').getByRole('button',{name:'Cancel',exact:true}).click(); await page.getByRole('button',{name:'Edit asset',exact:true}).click(); await page.getByRole('button',{name:'Help',exact:true}).click(); await page.getByRole('button',{name:'Close help',exact:true}).click(); await page.getByRole('dialog').getByRole('button',{name:'Close',exact:true}).click();")
checks.append('english-help-labels')
console = tool(client, 'browser_console_messages', {'level': 'warning'})
(directory / 'dialog-help-console.json').write_text(json.dumps(console, ensure_ascii=False, indent=2) + '\n')
report = {'checks': checks, 'screenshots': screenshots, 'measurements': measurements,
'database': 'metis_desk_test', 'companies': 15,
'principal': {'people': 50, 'assets': 51, 'resources': 66, 'profiles': 50, 'workstations': 50}}
(directory / 'dialog-help-report.json').write_text(json.dumps(report, ensure_ascii=False, indent=2) + '\n')
print('Ayuda contextual verificada. Evidencias: ' + str(directory.relative_to(context['ROOT'])), flush=True)