"""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.clientHeightdocument.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)