178 lines
17 KiB
Python
178 lines
17 KiB
Python
"""Recorrido R2.3 con HTTP real y Playwright MCP del procedimiento visual aislado."""
|
|
import json
|
|
import secrets
|
|
import signal
|
|
import shutil
|
|
|
|
|
|
def run(context, verifier=None):
|
|
api = context['api']
|
|
if context['options'].global_software or context['options'].global_profiles:
|
|
api.resource_contract = '2'
|
|
environment = {**context['environment'], 'RESOURCE_GLOBAL_CATALOG_ENABLED': 'false'}
|
|
directory = context['directory']
|
|
child = context['child_process']
|
|
wait = context['wait_for']
|
|
command = context['command']
|
|
command(['pnpm', 'build'], cwd=context['ROOT'], env=environment)
|
|
build_directory = directory / 'web-build'
|
|
shutil.copytree(context['ROOT'] / 'apps/web/dist', build_directory)
|
|
admin_password = secrets.token_urlsafe(32)
|
|
with child(['node', '--import', 'tsx', 'apps/server/src/index.ts'], environment, directory / 'api-legacy.log') as backend:
|
|
wait(api.ready, backend)
|
|
fixture = context['prepare_fixture'](api, admin_password)
|
|
api.request('/auth/login', {'email': 'setup@audit.example.test', 'password': admin_password})
|
|
company = fixture['company']
|
|
fixture["otherCompanies"] = []
|
|
for index in range(14):
|
|
created_company = api.request('/companies', {'name': f'Compañía sintética aislada {index + 2} — Peña', 'active': True}, expected=201)
|
|
fixture['otherCompanies'].append(created_company['company']['id'])
|
|
catalog = api.request('/asset-catalog/' + company)['items']
|
|
laptop = next(item for item in catalog if item['values']['kind'] == 'template' and item['values']['name'] == 'Portátil')
|
|
resource_catalog = api.request('/resource-catalog/' + company)['items']
|
|
application = next(item for item in resource_catalog if item.get('builtinKey') == 'template:application')
|
|
fixture["configurationProfiles"] = []
|
|
fixture["softwareAssets"] = []
|
|
fixture["softwareWorkstations"] = []
|
|
for index in range(50):
|
|
person = api.request('/companies/' + company + '/people', {'name': f'Persona Álvarez {index+1}', 'position': None, 'email': None, 'phone': None, 'active': True, 'isContact': False}, expected=201)['company']['people'][-1]
|
|
asset = api.request('/assets/' + company, {'values': {'name': f'AUDIT-R23-PORT-{index+1:02}', 'templateId': laptop['id'], 'siteId': None, 'registeredOn': '2026-01-15', 'status': 'operational', 'notes': '', 'characteristics': {}}}, expected=201)['asset']
|
|
workstation = api.request('/assets/' + company + '/workstations', {'values': {'name': f'Puesto Álvarez {index+1}', 'active': True, 'siteId': None, 'personId': person['id'], 'computerAssetId': asset['id'], 'assetIds': [asset['id']]}}, expected=201)
|
|
fixture['softwareAssets'].append(asset['id'])
|
|
fixture['softwareWorkstations'].append(workstation['workstation']['id'])
|
|
created_profile = api.request('/work-profiles/' + company, {'values': {'name': f'Perfil Álvarez {index+1}', 'personId': person['id'], 'assetId': asset['id'], 'accountId': None, 'active': True, 'notes': None}}, expected=201)
|
|
fixture['configurationProfiles'].append(created_profile['profile']['id'])
|
|
api.request('/resources/' + company, {'values': {'name': f'Aplicación particular Ágil {index+1}', 'templateId': application['id'], 'active': True, 'notes': 'Solo compañía sintética', 'characteristics': {}}}, expected=201)
|
|
api.request('/auth/logout', {})
|
|
command(['node', '--import', 'tsx', 'apps/server/src/migrate-resource-scope.ts', '--apply', '--writes-paused'], cwd=context['ROOT'], env=environment)
|
|
environment['RESOURCE_GLOBAL_CATALOG_ENABLED'] = 'true'
|
|
if context['options'].global_software:
|
|
environment['RESOURCE_GLOBAL_SOFTWARE_ENABLED'] = 'true'
|
|
environment['RESOURCE_GLOBAL_SOFTWARE_CANDIDATES_ENABLED'] = 'true'
|
|
if context['options'].application_coincidences:
|
|
environment['RESOURCE_GLOBAL_PROFILE_ENABLED'] = 'true'
|
|
environment['RESOURCE_GLOBAL_SOFTWARE_ENABLED'] = 'true'
|
|
environment['RESOURCE_GLOBAL_SOFTWARE_CANDIDATES_ENABLED'] = 'true'
|
|
if context['options'].global_profiles:
|
|
environment['RESOURCE_GLOBAL_PROFILE_ENABLED'] = 'true'
|
|
environment['RESOURCE_GLOBAL_SOFTWARE_ENABLED'] = 'false'
|
|
environment['RESOURCE_GLOBAL_SOFTWARE_CANDIDATES_ENABLED'] = 'false'
|
|
with child(['node', '--import', 'tsx', 'apps/server/src/index.ts'], environment, directory / 'api.log') as backend:
|
|
wait(api.ready, backend)
|
|
with child(['pnpm', '--config.verify-deps-before-run=false', '--filter', '@metis/web', 'exec', 'vite', 'preview', '--outDir', str(build_directory), '--host', '127.0.0.1', '--port', str(context['web_port']), '--strictPort'], environment, directory / 'vite.log') as frontend:
|
|
web = context['TestApi'](context['origin'], context['origin'])
|
|
wait(web.ready, frontend)
|
|
(verifier or verify)({**context, "backend": backend, "active_environment": environment}, fixture, admin_password)
|
|
if not context['options'].check:
|
|
print('Auditoría R2.3 abierta: ' + context['origin'] + '/catalogs/global-applications', flush=True)
|
|
signal.pause()
|
|
|
|
|
|
def verify(context, fixture, admin_password):
|
|
tool = context['tool']
|
|
browser = context['browser']
|
|
capture = context['capture']
|
|
result_json = context['result_json']
|
|
directory = context['directory']
|
|
origin = context['origin']
|
|
base = '/catalogs/global-applications'
|
|
screenshots = []
|
|
checks = []
|
|
|
|
def code(client, source):
|
|
print(' Comprobación MCP: ' + source[:90], flush=True)
|
|
try:
|
|
return tool(client, 'browser_run_code_unsafe', {'code': 'async (page) => { ' + source + ' }'})
|
|
except RuntimeError:
|
|
capture(client, directory, 'r23-failure')
|
|
raise
|
|
|
|
def login(client, email, password):
|
|
print(' Navegación al login real', flush=True)
|
|
try:
|
|
client.tool('browser_navigate', {'url': origin + '/login'})
|
|
except RuntimeError as error:
|
|
# Esta página todavía no tiene credenciales ni sesión autenticada.
|
|
print('Diagnóstico de navegación previa al login: ' + str(error)[:1400], flush=True)
|
|
raise RuntimeError('La navegación inicial al login falló.') from None
|
|
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': email},
|
|
{'name': 'Contraseña', 'type': 'textbox', 'target': 'input[type=password]', 'value': password}]})
|
|
tool(client, 'browser_click', {'target': 'button[type=submit]'})
|
|
code(client, "await page.locator('[data-slot=workspace]').waitFor({timeout:30000});")
|
|
|
|
with browser(context['config']) as client:
|
|
print('R2.3 navegador: acceso real de administrador y catálogo vacío…', flush=True)
|
|
tool(client, 'browser_resize', {'width': 1440, 'height': 1000})
|
|
login(client, 'setup@audit.example.test', admin_password)
|
|
tool(client, 'browser_navigate', {'url': origin + base})
|
|
code(client, "await page.getByText('Todavía no hay aplicaciones globales.',{exact:false}).waitFor();")
|
|
screenshots.append(capture(client, directory, 'r23-01-empty'))
|
|
checks.append('empty')
|
|
code(client, "await page.getByRole('button',{name:'Nueva aplicación global',exact:true}).click(); await page.getByRole('dialog').waitFor();")
|
|
code(client, "if(await page.getByRole('dialog').getByRole('textbox',{name:'Nombre',exact:true}).evaluate(e=>e.checkValidity()))throw Error('Nombre obligatorio'); await page.getByRole('dialog').getByRole('textbox',{name:'Nombre',exact:true}).fill('Ágil — Producto de pruebas'); await page.getByRole('dialog').getByRole('checkbox').check();")
|
|
screenshots.append(capture(client, directory, 'r23-02-create'))
|
|
code(client, "await page.getByRole('dialog').getByRole('button',{name:'Guardar',exact:true}).click(); await page.getByRole('heading',{name:'Ágil — Producto de pruebas',exact:true}).waitFor(); await page.getByRole('heading',{name:'Historial',exact:true}).waitFor();")
|
|
product_url = result_json(code(client, 'return page.url();'))
|
|
checks.extend(['create', 'required-name', 'detail', 'history'])
|
|
screenshots.append(capture(client, directory, 'r23-03-detail'))
|
|
code(client, "await page.getByRole('button',{name:'Editar aplicación global',exact:true}).click(); await page.getByRole('dialog').getByRole('textbox',{name:'Nombre',exact:true}).fill('Ágil — Producto actualizado'); await page.getByRole('dialog').getByRole('button',{name:'Guardar',exact:true}).click(); await page.getByRole('heading',{name:'Ágil — Producto actualizado',exact:true}).waitFor();")
|
|
code(client, "await page.getByRole('button',{name:'Desactivar',exact:true}).click(); await page.getByRole('button',{name:'Reactivar',exact:true}).waitFor();")
|
|
checks.extend(['update', 'deactivate'])
|
|
screenshots.append(capture(client, directory, 'r23-04-inactive'))
|
|
# Los demás productos se preparan por el mismo HTTP autorizado, en la BD desechable.
|
|
api = context['api']
|
|
api.request('/auth/login', {'email': 'setup@audit.example.test', 'password': admin_password})
|
|
specification = api.request('/resource-global-applications/template')['specification']
|
|
# UUID v7 sin dependencias: reloj de milisegundos y entropía de plataforma.
|
|
import time
|
|
import uuid
|
|
for index in range(50):
|
|
identity = f'{int(time.time()*1000):012x}' + '7' + secrets.token_hex(2)[1:] + 'a' + secrets.token_hex(4)[1:] + secrets.token_hex(4)
|
|
api.request('/resource-global-applications', {'creationId': str(uuid.UUID(identity)), 'values': {'name': f'Aplicación global Álvarez {index+1:02} — Producto sintético de nombre largo', 'templateId': specification['templateId'], 'active': index % 5 != 0, 'notes': 'Nota administrativa privada', 'characteristics': {}}}, expected=201)
|
|
api.request('/auth/logout', {})
|
|
tool(client, 'browser_navigate', {'url': origin + base})
|
|
code(client, "await page.getByRole('button',{name:'Siguiente',exact:true}).waitFor(); await page.getByRole('button',{name:'Siguiente',exact:true}).click(); await page.getByText('Página 2',{exact:true}).waitFor(); await page.getByRole('button',{name:'Anterior',exact:true}).click(); await page.getByText('Página 1',{exact:true}).waitFor();")
|
|
code(client, "await page.getByRole('textbox',{name:'Buscar por nombre o proveedor',exact:true}).fill('Producto actualizado'); await page.getByRole('link',{name:'Ágil — Producto actualizado',exact:true}).waitFor(); await page.getByRole('link',{name:'Ágil — Producto actualizado',exact:true}).click(); await page.getByRole('heading',{name:'Ágil — Producto actualizado',exact:true}).waitFor(); await page.getByRole('link',{name:'Catálogo global de aplicaciones',exact:true}).last().click(); await page.getByRole('textbox',{name:'Buscar por nombre o proveedor',exact:true}).waitFor(); if(await page.getByRole('textbox',{name:'Buscar por nombre o proveedor',exact:true}).inputValue()!=='Producto actualizado')throw Error('Contexto perdido');")
|
|
checks.extend(['volume-51-globals', 'server-pagination', 'search', 'return-context'])
|
|
code(client, "await page.getByRole('combobox',{name:'Estado',exact:true}).click(); await page.getByRole('option',{name:'Inactivas',exact:true}).click(); await page.waitForURL(u=>u.searchParams.get('active')==='false'); await page.getByRole('link',{name:'Ágil — Producto actualizado',exact:true}).waitFor();")
|
|
checks.append('inactive-filter')
|
|
code(client, "await page.getByRole('button',{name:'Limpiar filtros',exact:true}).click(); await page.getByRole('button',{name:'Nueva aplicación global',exact:true}).click(); await page.getByRole('dialog').waitFor(); await page.keyboard.press('Escape'); await page.getByRole('dialog').waitFor({state:'hidden'}); if(await page.evaluate(()=>document.activeElement?.textContent)!=='Nueva aplicación global')throw Error('Foco no restaurado'); await page.keyboard.press('Tab'); if(!await page.evaluate(()=>document.activeElement!==document.body))throw Error('Teclado');")
|
|
checks.extend(['escape', 'focus-return', 'keyboard'])
|
|
for width in [1600, 1440, 1280, 1024, 768, 390]:
|
|
tool(client, 'browser_resize', {'width': width, 'height': 1000 if width > 768 else 844})
|
|
code(client, "if(await page.evaluate(()=>document.documentElement.scrollWidth>innerWidth+1))throw Error('Desbordamiento horizontal');")
|
|
screenshots.append(capture(client, directory, f'r23-list-{width}'))
|
|
tool(client, 'browser_navigate', {'url': product_url})
|
|
code(client, "await page.getByRole('heading',{name:'Ágil — Producto actualizado',exact:true}).waitFor();")
|
|
screenshots.append(capture(client, directory, 'r23-detail-390'))
|
|
tool(client, 'browser_resize', {'width': 1440, 'height': 1000})
|
|
code(client, "await page.getByRole('button',{name:'Reactivar',exact:true}).click(); await page.getByRole('button',{name:'Desactivar',exact:true}).waitFor();")
|
|
checks.append('reactivate')
|
|
# Estados de transporte sintéticos; las escrituras anteriores usan API/SQL reales.
|
|
tool(client, 'browser_navigate', {'url': origin + base})
|
|
code(client, "await page.getByRole('textbox',{name:'Buscar por nombre o proveedor',exact:true}).fill('INEXISTENTE-PRUEBA'); await page.getByText('No hay aplicaciones que coincidan.',{exact:false}).waitFor();")
|
|
screenshots.append(capture(client, directory, 'r23-no-matches'))
|
|
checks.append('no-matches')
|
|
code(client, "page.r23Pattern='**/api/resource-global-applications?*'; await page.route(page.r23Pattern, r=>r.fulfill({status:500,contentType:'application/json',json:{code:'INTERNAL_ERROR'}})); await page.goto('" + origin + base + "'); await page.getByText('No se han podido cargar las aplicaciones.',{exact:true}).waitFor();")
|
|
screenshots.append(capture(client, directory, 'r23-error'))
|
|
code(client, "await page.unroute(page.r23Pattern); await page.getByRole('button',{name:'Reintentar',exact:true}).click(); await page.getByRole('link',{name:'Ágil — Producto actualizado',exact:true}).waitFor();")
|
|
checks.extend(['transport-error', 'retry'])
|
|
code(client, "page.r23Releases=[]; await page.route(page.r23Pattern, async r=>{await new Promise(resolve=>page.r23Releases.push(resolve)); await r.continue()}); await page.goto('" + origin + base + "',{waitUntil:'domcontentloaded'}); await page.getByText('Cargando aplicaciones…',{exact:true}).waitFor();")
|
|
screenshots.append(capture(client, directory, 'r23-loading'))
|
|
code(client, "for(const release of page.r23Releases)release(); await page.unroute(page.r23Pattern,{behavior:'wait'}); await page.getByRole('link',{name:'Ágil — Producto actualizado',exact:true}).waitFor();")
|
|
checks.append('loading')
|
|
with browser(context['config']) as client:
|
|
print('R2.3 navegador: usuario ordinario y proyección mínima…', flush=True)
|
|
login(client, fixture['email'], fixture['password'])
|
|
tool(client, 'browser_navigate', {'url': origin + base})
|
|
code(client, "await page.getByRole('textbox',{name:'Buscar por nombre o proveedor',exact:true}).waitFor(); if(await page.getByRole('button',{name:'Nueva aplicación global',exact:true}).count())throw Error('Alta visible a ordinario'); if(await page.getByRole('button',{name:'Acciones',exact:true}).count())throw Error('Menú administrativo vacío para ordinario');")
|
|
tool(client, 'browser_navigate', {'url': product_url})
|
|
code(client, "await page.getByRole('heading',{name:'Ágil — Producto actualizado',exact:true}).waitFor(); if(await page.getByRole('button',{name:'Editar aplicación global',exact:true}).count())throw Error('Edición visible a ordinario'); if(await page.getByRole('heading',{name:'Historial',exact:true}).count())throw Error('Historia visible a ordinario');")
|
|
screenshots.append(capture(client, directory, 'r23-ordinary-detail'))
|
|
checks.extend(['ordinary-read', 'ordinary-no-administration', 'ordinary-no-history'])
|
|
report = {'checks': checks, 'screenshots': screenshots, 'companies': 15, 'principal': {'people': 50, 'assets': 51, 'profiles': 50, 'workstations': 50, 'localApplications': 50}, 'globalApplications': 51, 'database': 'metis_desk_test', 'mcp': 'Docker stdio fijado en .codex/config.toml', 'credentialsSaved': False}
|
|
(directory / 'r23-report.json').write_text(json.dumps(report, ensure_ascii=False, indent=2) + '\n')
|
|
print('Recorrido R2.3 verificado. Evidencias: ' + str(directory.relative_to(context['ROOT'])), flush=True)
|