metis-desk/scripts/catalog-configuration-ui.test.py

370 lines
52 KiB
Python
Raw Normal View History

2026-10-24 23:19:17 +00:00
#!/usr/bin/env python3
"""L04.2: catálogos y configuraciones por el MCP autenticado existente.
Las ampliaciones son GET sintéticos validados; no inicia servicios ni guarda formularios.
"""
import json
import os
from pathlib import Path
import runpy
import subprocess
import unittest
from urllib.parse import urlencode, urlsplit, parse_qs
ROOT = Path(__file__).resolve().parent.parent
ADOPTION = runpy.run_path(str(ROOT / "scripts/adoption-ui.test.py"))
class CatalogConfigurationUiTests(ADOPTION["AdoptionUiTests"]):
artifact_directory = ROOT / ".artifacts/playwright/l04-2/after"
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.code("""const get=async p=>{const r=await page.request.get(page.adoptionOrigin+'/api'+p,{timeout:15000});if(r.status()!==200)throw Error('Fixture GET '+p+': '+r.status());return r.json()};
let sequence=10000;const id=()=> '019a4200-0000-7000-a000-'+String(++sequence).padStart(12,'0'),company=page.adoptionCompany;
const actor=page.adoption.inventory.assets[0].history[0],revision=values=>({id:id(),actorId:actor.actorId,actorName:actor.actorName,recordedAt:'2026-03-01T10:00:00.000Z',values:structuredClone(values)});
const catalogs={};for(const family of ['asset','resource']){const global=await get('/'+family+'-catalog/global'),local=page.adoption.snapshots['/'+family+'-catalog/'+company];const extra=[];
for(const kind of family==='asset'?['field','template','relation']:['field','template']){const sample=global.items.find(x=>x.values.kind===kind);if(!sample)throw Error('Falta prototipo '+family+':'+kind);for(let i=1;i<=50;i++){const x=structuredClone(sample);x.id=id();x.companyId=null;x.builtin=false;x.builtinKey=null;delete x.referenceCatalog;x.active=i%5!==0;x.effectiveActive=x.active;x.overridden=false;x.values.name='L042-'+({field:'CAMPO',template:'PLANTILLA',relation:'RELACION'}[kind])+'-'+String(i).padStart(2,'0')+' · Administración '+(i===2?'Pena':'Peña');
if(kind==='template'){const sections=new Map((x.values.sections??[]).map(s=>[s.id,id()]));x.values.sections=x.values.sections?.map(s=>({...s,id:sections.get(s.id)}));x.values.fields=x.values.fields.map(f=>({...f,...(f.sectionId?{sectionId:sections.get(f.sectionId)}:{})}));x.bindings=x.bindings.map(b=>({...b,id:id()}))}extra.push(x)}}
catalogs['/'+family+'-catalog/global']={items:[...global.items,...extra]};catalogs['/'+family+'-catalog/'+company]={items:[...local.items,...extra.map((x,i)=>i%2||x.values.kind!=='field'?x:{...x,id:id(),companyId:company})]}}
const brands={brands:Array.from({length:50},(_,i)=>({id:id(),name:'L042-MARCA-'+String(i+1).padStart(2,'0')+' · Administración Peña',active:i%5!==0,builtin:false,logoSvg:null}))};
const templates={templates:Array.from({length:50},(_,i)=>{const values={name:'L042-PROCESO-'+String(i+1).padStart(2,'0')+' · Administración Peña',kind:'onboarding',active:i%5!==0,notes:null,instructions:['Comprobar inventario','Confirmar responsabilidades']};return{id:id(),values,history:[revision(values)]}})};
const globalAccess={items:[],reviews:[],coverage:[]};const entry=(values,companyId=null)=>({id:id(),companyId,values,history:[revision(values)]});
for(let i=1;i<=50;i++){globalAccess.items.push(entry({kind:'system',name:'L042-SISTEMA-'+String(i).padStart(2,'0')+' · Administración Peña',active:i%5!==0}));globalAccess.items.push(entry({kind:'resource_type',name:'L042-TIPO-'+String(i).padStart(2,'0')+' · Administración Peña',active:true}))}
const type=globalAccess.items.find(x=>x.values.kind==='resource_type'),system=globalAccess.items.find(x=>x.values.kind==='system'&&x.values.active);
for(let i=1;i<=50;i++){globalAccess.items.push(entry({kind:'permission',name:'L042-PERMISO-'+String(i).padStart(2,'0')+' · Lectura Álvarez',active:true,resourceTypeId:type.id,description:null}));globalAccess.items.push(entry({kind:'template',name:'L042-ACCESO-'+String(i).padStart(2,'0')+' · Administración Peña',active:true,description:null,systemId:system.id}))}
const localAccess=structuredClone(page.adoption.snapshots['/access/'+company]);localAccess.items.push(...globalAccess.items);const environment=entry({kind:'environment',name:'L042-ENTORNO · Administración',active:true,systemId:system.id},company);localAccess.items.push(environment);
for(let i=1;i<=50;i++)localAccess.items.push(entry({kind:'account',systemId:system.id,environmentId:environment.id,login:'L042-CUENTA-'+String(i).padStart(2,'0')+'@administracion.example.test',usage:'personal',active:i%5!==0,expiresOn:null,notes:null},company));
page.catalogConfiguration={catalogs,brands,templates,globalAccess,localAccess};const data={...catalogs,'/asset-brands':brands,'/processes/templates':templates,'/access/global':globalAccess,['/access/'+company]:localAccess};
for(const[path,payload]of Object.entries(data))await page.route(page.adoptionOrigin+'/api'+path,r=>r.request().method()==='GET'?r.fulfill({status:200,contentType:'application/json',json:payload}):r.fallback());return true;""")
fixture = cls.output / ("catalog-fixtures-" + str(os.getpid()) + ".json")
fixture.write_text(json.dumps(cls.code('return page.catalogConfiguration;'), ensure_ascii=False) + '\n')
validation = """import{readFile}from'node:fs/promises';import{CatalogResponseSchema as A,BrandsResponseSchema}from'./modules/assets/src/common/index.ts';import{CatalogResponseSchema as R}from'./modules/resources/src/common/index.ts';import{AccessResponseSchema}from'./modules/companies/src/common/dto/shared/access.dto.ts';import{ProcessTemplatesResponseSchema}from'./modules/companies/src/common/processes.ts';const f=JSON.parse(await readFile(process.argv[1],'utf8'));for(const[k,v]of Object.entries(f.catalogs))(k.startsWith('/asset')?A:R).parse(v);BrandsResponseSchema.parse(f.brands);ProcessTemplatesResponseSchema.parse(f.templates);AccessResponseSchema.parse(f.globalAccess);AccessResponseSchema.parse(f.localAccess);console.log('Schemas de catálogos correctos.');"""
result = subprocess.run(['node', '--import', 'tsx', '--input-type=module', '-e', validation, str(fixture)], cwd=ROOT, capture_output=True, text=True, timeout=60)
(cls.output / ('catalog-validation-' + str(os.getpid()) + '.log')).write_text(result.stdout + result.stderr)
if result.returncode:
raise RuntimeError('Fixtures de catálogo no válidas; revisar evidencia.')
cls.prepare_configurations()
@classmethod
def prepare_configurations(cls):
cls.code(r"""const company=page.adoptionCompany,p=page.adoption.profiles.profiles,people=page.adoption.detail.company.people,access=page.catalogConfiguration.localAccess;
let sequence=0;const id=()=> '019a4201-0000-7000-a000-'+String(++sequence).padStart(12,'0'),actor=page.adoption.inventory.assets[0].history[0];
const revision=(values,recordedAt='2026-03-01T10:00:00.000Z')=>({id:id(),actorId:actor.actorId,actorName:actor.actorName,recordedAt,values:structuredClone(values)});
const entry=values=>({id:id(),companyId:company,values,history:[revision(values)]});
const history=values=>{const states=values.state==='removed'?['pending','configured','removal_pending','removed']:values.state==='configured'?['pending','configured']:[values.state];return states.map((state,i)=>revision({...values,state},'2026-03-0'+(i<2?'1':'2')+'T'+(i%2?'11':'10')+':00:00.000Z'))};
const configuration=(values,extra={})=>({...entry(values),history:history(values),needsReview:false,...extra});
const resourceData=page.adoption.snapshots['/resources/'+company],catalog=page.adoption.snapshots['/resource-catalog/'+company],template=(r,key)=>catalog.items.some(t=>t.id===r.values.templateId&&t.builtinKey==='template:'+key);
const applications=resourceData.resources.filter(r=>template(r,'application'));if(applications.length<50)throw Error('Faltan 50 aplicaciones de recurso para el escenario de software');
const certificate=resourceData.resources.find(r=>template(r,'certificate')&&r.values.active),printer=page.adoption.inventory.assets.find(a=>a.specification.templateName==='Impresora'&&a.values.status==='operational');if(!certificate||!printer)throw Error('Faltan certificado e impresora reales de la fixture');
const type=access.items.find(x=>x.values.kind==='resource_type'&&x.values.name.startsWith('L042')),permission=access.items.find(x=>x.values.kind==='permission'&&x.values.resourceTypeId===type.id),system=access.items.find(x=>x.values.kind==='system'&&x.values.active),accounts=access.items.filter(x=>x.values.kind==='account'&&x.values.login.startsWith('L042'));
const mailboxes={mailboxes:[],uses:[]},mail={configurations:[]},folders={mappings:[]},certificates={configurations:[]},apps={configurations:[]},printers={configurations:[]};
for(let i=0;i<50;i++){const profile=p[i],person=people.find(x=>x.id===profile.values.personId),account=accounts[i],active=profile.values.active,state=active?'configured':'removed';if(!person||!account)throw Error('Perfil sin referencias válidas');
profile.values.accountId=account.id;for(const rev of profile.history)rev.values.accountId=account.id;
const period={startDate:'2026-03-01',endDate:active?null:'2026-03-02'};access.items.push(entry({kind:'account_person',accountId:account.id,personId:person.id,...period,configuration:state}));
const folderResource=entry({kind:'resource',name:'L042-CARPETA-'+String(i+1).padStart(2,'0')+' · Administración Peña',active,resourceTypeId:type.id,systemId:system.id,environmentId:null,reference:'\\L042-NAS\Area-'+(i+1),notes:null});access.items.push(folderResource);
const need=entry({kind:'need',personId:person.id,resourceId:folderResource.id,permissionIds:[permission.id],...period,notes:null});access.items.push(need);access.items.push(entry({kind:'grant',resourceId:folderResource.id,permissionId:permission.id,accountId:account.id,groupId:null,...period,configuration:state}));if(active)access.coverage.push({needId:need.id,covered:true,missingPermissionIds:[]});
const mailbox=entry({address:'l042-mail-'+String(i+1).padStart(2,'0')+'@audit.example.test',kind:'corporate',context:'Administración Peña',accountId:account.id,active,notes:null});mailboxes.mailboxes.push(mailbox);mailboxes.uses.push(entry({mailboxId:mailbox.id,personId:person.id,...period}));
mail.configurations.push(configuration({profileId:profile.id,mailboxId:mailbox.id,address:mailbox.values.address,method:'imap',state,notes:null},{legacy:false}));
folders.mappings.push(configuration({profileId:profile.id,needId:need.id,accessAccountId:account.id,path:folderResource.values.reference,driveLetter:'Z:',state,notes:null},{permissionsCovered:active,accessAccountCoverage:active?'covered':'unknown'}));
apps.configurations.push(configuration({profileId:profile.id,resourceId:applications[0].id,delivery:'installed',version:'2026.1',state,notes:null}));
printers.configurations.push(configuration({profileId:profile.id,printerId:printer.id,connection:'network',isDefault:true,state,notes:null}));
const expiresId=catalog.items.find(x=>x.builtinKey==='field:expires')?.id,expires=expiresId?certificate.values.characteristics[expiresId]:null;
certificates.configurations.push(configuration({profileId:profile.id,resourceId:certificate.id,location:'Almacén personal · Administración Peña',state,notes:null},{expiresOn:typeof expires==='string'?expires:null,expired:false}));
}
const telework={authorizations:[]},remote={connections:[]};for(let i=0;i<50;i++){const person=people[i],effective=person.active;telework.authorizations.push({...entry({personId:person.id,active:effective,startsOn:'2026-03-01',expiresOn:'2030-12-31',notes:null}),status:effective?'authorized':'revoked',effective,needsReview:false});remote.connections.push(configuration({personId:person.id,sourceKind:'personal_computer',sourceAssetId:null,targetKind:'network',targetId:null,siteId:page.adoption.detail.company.sites[i].id,method:'VPN · L042 Administración',requiresVpn:true,state:effective?'configured':'removed',notes:null},{authorizationEffective:effective}))}
const templates=page.catalogConfiguration.templates.templates,processes={processes:templates.map((t,i)=>{const state=i%5===4?'completed':'open',values={personId:p[i].values.personId,templateId:t.id,templateName:t.values.name,kind:t.values.kind,name:'L042-PROCESO-'+String(i+1).padStart(2,'0')+' · Administración Peña',state,notes:null,tasks:t.values.instructions.map(title=>({id:id(),title,status:state==='open'?'pending':'done',notes:null,reference:null}))};return{...entry(values),review:[]}})};
const assetId=p.find(x=>x.values.active).values.assetId,software={configurations:applications.slice(0,50).map(resource=>configuration({assetId,resourceId:resource.id,licenseResourceId:null,version:'2026.1',state:resource.values.active?'configured':'removed',notes:null}))};
const data={['/mailboxes/'+company]:mailboxes,['/mail-configurations/'+company]:mail,['/folder-mappings/'+company]:folders,['/certificate-configurations/'+company]:certificates,['/application-configurations/'+company]:apps,['/printer-configurations/'+company]:printers,['/telework-authorizations/'+company]:telework,['/remote-connections/'+company]:remote,['/processes/'+company]:processes,['/software-installations/'+company]:software,['/access/'+company]:access,['/work-profiles/'+company]:page.adoption.profiles};page.configurationRemoved=null;
page.configurationData={data,assetId,profileId:p.find(x=>x.values.active).id,personId:people.find(x=>x.active).id,companyId:company};for(const[path,payload]of Object.entries(data))await page.route(page.adoptionOrigin+'/api'+path,r=>{if(r.request().method()!=='GET')return r.fallback();const json=path==='/processes/'+company?{processes:payload.processes.filter(x=>x.id!==page.configurationRemoved)}:payload;return r.fulfill({status:200,contentType:'application/json',json})});return true;""")
fixture = cls.output / ("configuration-fixtures-" + str(os.getpid()) + ".json")
fixture.write_text(json.dumps(cls.code('return page.configurationData;'), ensure_ascii=False) + '\n')
validation = """import{readFile}from'node:fs/promises';import{MailboxesResponseSchema}from'./modules/companies/src/common/mailboxes.ts';import{MailConfigurationsResponseSchema}from'./modules/companies/src/common/mail-configurations.ts';import{FolderMappingsResponseSchema}from'./modules/companies/src/common/folder-mappings.ts';import{CertificateConfigurationsResponseSchema}from'./modules/companies/src/common/certificate-configurations.ts';import{ApplicationConfigurationsResponseSchema}from'./modules/companies/src/common/application-configurations.ts';import{PrinterConfigurationsResponseSchema}from'./modules/companies/src/common/printer-configurations.ts';import{TeleworkAuthorizationsResponseSchema}from'./modules/companies/src/common/telework-authorizations.ts';import{RemoteConnectionsResponseSchema}from'./modules/companies/src/common/remote-connections.ts';import{ProcessesResponseSchema}from'./modules/companies/src/common/processes.ts';import{SoftwareInstallationsResponseSchema}from'./modules/companies/src/common/software-installations.ts';import{AccessResponseSchema}from'./modules/companies/src/common/dto/shared/access.dto.ts';import{WorkProfilesResponseSchema}from'./modules/companies/src/common/work-profiles.ts';const f=JSON.parse(await readFile(process.argv[1],'utf8'));const schemas={'mailboxes':MailboxesResponseSchema,'mail-configurations':MailConfigurationsResponseSchema,'folder-mappings':FolderMappingsResponseSchema,'certificate-configurations':CertificateConfigurationsResponseSchema,'application-configurations':ApplicationConfigurationsResponseSchema,'printer-configurations':PrinterConfigurationsResponseSchema,'telework-authorizations':TeleworkAuthorizationsResponseSchema,'remote-connections':RemoteConnectionsResponseSchema,'processes':ProcessesResponseSchema,'software-installations':SoftwareInstallationsResponseSchema,'access':AccessResponseSchema,'work-profiles':WorkProfilesResponseSchema};for(const[path,payload]of Object.entries(f.data)){const key=path.split('/')[1];schemas[key].parse(payload)}console.log('Schemas de configuraciones correctos: 50 registros por familia.');"""
result = subprocess.run(['node', '--import', 'tsx', '--input-type=module', '-e', validation, str(fixture)], cwd=ROOT, capture_output=True, text=True, timeout=60)
(cls.output / ('configuration-validation-' + str(os.getpid()) + '.log')).write_text(result.stdout + result.stderr)
if result.returncode:
raise RuntimeError('Fixtures de configuración no válidas; revisar evidencia.')
def route(self, path, params=None):
url = self.origin + path + ('?' + urlencode(params) if params else '')
self.code('await page.adoptionGoto('+json.dumps(url)+");await page.locator('main [data-slot=entity-table][data-query-state=ready]').waitFor();return true;")
self.settle()
def position_origin(self):
self.code("await page.locator('main tbody tr').first().locator('a, button').first().focus();await page.locator('main [data-slot=entity-table]').evaluate(root=>{root.closest('[data-slot=workspace-scroll]').scrollTop=150;root.querySelector('[data-slot=table-container]').scrollLeft=40});return true;")
self.settle()
return self.values()
def edit_cancel(self, scope='main'):
self.code('const root=page.locator('+json.dumps(scope)+");const dialogs=await page.getByRole('dialog',{includeHidden:true}).count();for(const button of await root.locator('tbody').getByRole('button',{name:'Editar',exact:true}).all()){if(await button.isEnabled()){await button.click();const editor=page.getByRole('dialog',{includeHidden:true}).nth(dialogs);await editor.waitFor();await page.keyboard.press('Escape');await editor.waitFor({state:'hidden'});return true}}throw Error('No hay una fila elegible para editar');")
self.settle()
def test_71_configuration_tables_edit_history_and_clear(self):
self.resize(1440,900)
for section in ['folder-mappings','mail-configurations','certificate-configurations','application-configurations','printer-configurations','remote-connections','telework-authorizations']:
self.route(self.base+'/'+section);default_count=self.values()['count']
if section=='remote-connections':self.code("await page.locator('main input[type=checkbox]').check();return true;")
self.code("await page.locator('main th[aria-sort] button').first().click();await page.getByRole('button',{name:'Página 3',exact:true}).click();return true;");self.settle()
before=self.values();self.assertEqual(before['page'],'3');self.assertEqual(before['sort'],'descending');self.assertEqual(len(before['rows']),10)
self.code("await page.evaluate(()=>window.configurationTable=document.querySelector('main table'));return true;")
self.edit_cancel();self.assertTrue(self.code("return page.evaluate(()=>window.configurationTable===document.querySelector('main table'));"))
self.code("await page.locator('main tbody tr').first().getByRole('button',{name:'Acciones',exact:true}).click();await page.getByRole('menuitem').filter({hasText:/[Hh]istorial/}).first().click();await page.getByRole('dialog').waitFor();await page.keyboard.press('Escape');await page.getByRole('dialog').waitFor({state:'hidden'});return true;")
after=self.values()
for key in ['url','query','page','sort','rows']:self.assertEqual(before[key],after[key],section+':'+key)
self.assertNotEqual(after['focus'],'BODY');self.record(section+'-editor-history',after)
self.code("await page.locator('main input[data-quick-search]').fill('zz-sin-coincidencias');return true;");self.settle();self.assertEqual(len(self.values()['rows']),0)
self.code("await page.getByRole('button',{name:'Limpiar filtros',exact:true}).click();return true;");self.settle();after=self.values()
self.assertEqual(after['query'],'');self.assertEqual(after['page'],'1');self.assertEqual(after['sort'],'descending');self.assertEqual(after['count'],default_count);self.assertTrue(after['focusSearch'])
self.record(section+'-clear',after)
def test_72_required_profile_and_person_scopes(self):
self.resize(390,844)
ids=self.code('return {profile:page.configurationData.profileId,person:page.configurationData.personId};')
for section in ['folder-mappings','mail-configurations','certificate-configurations','application-configurations','printer-configurations','remote-connections','telework-authorizations']:
key='personId' if section in ['remote-connections','telework-authorizations'] else 'profileId';value=ids['person' if key=='personId' else 'profile']
self.route(self.base+'/'+section,{key:value});before=self.values();self.assertGreater(len(before['rows']),0);self.assertLessEqual(before['count'],2)
self.code("await page.locator('main input[data-quick-search]').fill('zz-sin-coincidencias');await page.getByRole('button',{name:'Limpiar filtros',exact:true}).click();return true;");self.settle();after=self.values()
self.assertEqual(parse_qs(urlsplit(after['url']).query).get(key),[value]);self.assertEqual(after['rows'],before['rows']);self.record(section+'-required-scope',after)
self.route(self.base+'/mail-configurations',{'profileId':ids['profile']})
self.code("await page.locator('main input[data-quick-search]').fill('consulta del perfil anterior');return true;");self.settle()
self.code('const selector='+json.dumps('a[href="'+self.base+'/mail-configurations"]:visible')+";if(!(await page.locator(selector).count()))await page.getByRole('button',{name:/^Secciones de/}).first().click();await page.locator(selector).first().click();await page.locator('main [data-query-state=ready]').waitFor();return true;")
self.assertEqual(self.values()['query'],'');self.assertEqual(self.values()['count'],50);self.record('profile-to-company-scope-reset',self.values())
self.route(self.base+'/work-profiles/'+ids['profile']+'/application-configurations')
self.assertEqual(self.values()['count'],1)
self.code("await page.locator('main input[data-quick-search]').fill('ADMINISTRACION');return true;");self.settle();self.assertEqual(self.values()['count'],1)
self.code("await page.getByRole('button',{name:'Volver al entorno',exact:true}).click();await page.getByRole('heading',{name:/Entorno actual/}).waitFor();return true;")
self.assertTrue(self.code("return page.url();").endswith('/work-profiles/'+ids['profile']))
def test_73_configuration_dependencies_refetch_and_permissions(self):
self.resize(320,640)
for section,dependency in [('mail-configurations','mailboxes'),('folder-mappings','access'),('certificate-configurations','assets'),('application-configurations','resources'),('printer-configurations','work-profiles'),('remote-connections','resources'),('telework-authorizations','telework-authorizations')]:
url=self.origin+'/api/'+dependency+'/'+self.company
self.code('page.configurationGateUrl='+json.dumps(url)+";page.configurationGates=[];page.configurationStarted=new Promise(r=>page.configurationStart=r);page.configurationHold=async r=>{page.configurationStart();await new Promise(resolve=>page.configurationGates.push(resolve));await r.fallback()};await page.route(page.configurationGateUrl,page.configurationHold);return true;")
try:
self.code('await page.adoptionGoto('+json.dumps(self.origin+self.base+'/'+section)+");await page.configurationStarted;await page.locator('main [data-query-state=loading]').waitFor();return true;")
self.assertGreater(self.code("return page.locator('main [data-slot=entity-table-skeleton-row]').count();"),0);self.record(section+'-dependency-loading',self.values())
self.code("for(const r of page.configurationGates)r();await page.locator('main [data-query-state=ready]').waitFor();await page.evaluate(()=>window.configurationTable=document.querySelector('main table'));return true;")
finally:self.code("for(const r of page.configurationGates)r();await page.unroute(page.configurationGateUrl,page.configurationHold);return true;")
before=self.values();errors=self.code("return page.adoptionConsole.filter(x=>x.type==='pageerror').length;")
self.code('page.configurationErrorUrl='+json.dumps(url)+";page.configurationErrorStatus=503;page.configurationError=r=>r.fulfill({status:page.configurationErrorStatus,contentType:'application/json',json:{code:page.configurationErrorStatus===403?'FORBIDDEN':'INTERNAL_ERROR',requestId:'019a4201-0000-7000-a000-000000099999'}});await page.route(page.configurationErrorUrl,page.configurationError);await page.evaluate(()=>{window.dispatchEvent(new Event('offline'));window.dispatchEvent(new Event('online'))});await page.locator('main [data-query-state=stale]').waitFor();return true;")
try:
self.assertEqual(before['rows'],self.values()['rows']);self.record(section+'-dependency-stale',self.values())
self.code("await page.unroute(page.configurationErrorUrl,page.configurationError);await page.getByRole('button',{name:'Reintentar',exact:true}).click();await page.locator('main [data-query-state=ready]').waitFor();return true;")
self.assertTrue(self.code("return page.evaluate(()=>window.configurationTable===document.querySelector('main table'));"));self.assertTrue(self.values()['focusSearch'])
self.code("page.configurationErrorStatus=403;await page.route(page.configurationErrorUrl,page.configurationError);await page.evaluate(()=>{window.dispatchEvent(new Event('offline'));window.dispatchEvent(new Event('online'))});await page.locator('main [data-query-state=unavailable]').waitFor();return true;")
self.assertEqual(len(self.values()['rows']),0);self.assertEqual(self.code("return page.getByRole('button',{name:'Reintentar',exact:true}).count();"),0);self.record(section+'-dependency-forbidden',self.values())
self.assertEqual(self.code("return page.adoptionConsole.filter(x=>x.type==='pageerror').length;"),errors)
finally:self.code("await page.unroute(page.configurationErrorUrl,page.configurationError);return true;")
def test_74_initial_error_empty_and_recovery(self):
self.resize(390,844)
for section,key in [('mail-configurations','configurations'),('folder-mappings','mappings'),('remote-connections','connections')]:
url=self.origin+'/api/'+section+'/'+self.company
self.code('page.configurationInitialUrl='+json.dumps(url)+";page.configurationInitial=r=>r.fulfill({status:503,contentType:'application/json',json:{code:'INTERNAL_ERROR',requestId:'019a4201-0000-7000-a000-000000099999'}});await page.route(page.configurationInitialUrl,page.configurationInitial);return true;")
try:
self.code('await page.adoptionGoto('+json.dumps(self.origin+self.base+'/'+section)+");await page.locator('main [data-query-state=unavailable]').waitFor();await page.evaluate(()=>window.configurationTable=document.querySelector('main table'));return true;")
self.assertEqual(len(self.values()['rows']),0);self.record(section+'-initial-error',self.values())
self.code("await page.unroute(page.configurationInitialUrl,page.configurationInitial);await page.getByRole('button',{name:'Reintentar',exact:true}).click();await page.locator('main [data-query-state=ready]').waitFor();return true;")
self.assertTrue(self.code("return page.evaluate(()=>window.configurationTable===document.querySelector('main table'));"));self.assertGreater(len(self.values()['rows']),0)
finally:self.code("await page.unroute(page.configurationInitialUrl,page.configurationInitial);return true;")
self.code('page.configurationEmptyData='+json.dumps({key:[]})+";page.configurationEmpty=r=>r.fulfill({status:200,contentType:'application/json',json:page.configurationEmptyData});await page.route(page.configurationInitialUrl,page.configurationEmpty);return true;")
try:
self.route(self.base+'/'+section);self.assertEqual(self.values()['count'],0);self.assertEqual(len(self.values()['rows']),0);self.assertEqual(self.code("return page.getByRole('button',{name:'Limpiar filtros',exact:true}).count();"),0)
self.code("await page.locator('main input[data-quick-search]').fill('consulta sin datos');return true;");self.settle();self.assertEqual(self.code("return page.getByRole('button',{name:'Limpiar filtros',exact:true}).count();"),1)
self.record(section+'-empty-no-results',self.values())
finally:self.code("await page.unroute(page.configurationInitialUrl,page.configurationEmpty);return true;")
def test_75_process_detail_return_matrix(self):
for i,(width,height) in enumerate(ADOPTION['CONTEXT']['SIZES']):
self.resize(width,height);self.route(self.base+'/processes',{'q':' L042 ','closed':'true','sort':'name','direction':'desc','page':'3'})
before=self.position_origin();self.record('process-before-'+str(width),before)
self.code("await page.locator('main tbody tr a').first().evaluate(e=>e.click());await page.getByRole('button',{name:'Volver a procesos',exact:true}).waitFor();return true;")
if i%2:self.code("await page.getByRole('button',{name:'Volver a procesos',exact:true}).click();return true;")
else:self.code("await page.goBack();return true;")
self.code("await page.locator('main [data-query-state=ready]').waitFor();return true;");self.wait_return_focus(before['focusRow']);self.settle();after=self.values()
for key in ['url','query','sort','page','rows','x','y','focusRow']:self.assertEqual(before[key],after[key],str(width)+':'+key)
self.assertFalse(after['overflow']);self.record('process-return-'+str(width),after)
if width in [1440,390,320]:self.capture('process-return-'+str(width))
def test_76_process_reload_clear_and_unverified_return(self):
self.resize(390,844);path=self.base+'/processes'
self.route(path,{'q':'L042','closed':'true','sort':'name','direction':'desc','page':'3'});before=self.position_origin()
self.code("await page.reload();await page.locator('main [data-query-state=ready]').waitFor();return true;");self.wait_return_focus(before['focusRow']);self.settle()
for key in ['url','rows','page','sort','x','y','focusRow']:self.assertEqual(before[key],self.values()[key])
self.code("await page.locator('main tbody tr a').first().evaluate(e=>e.click());await page.getByRole('button',{name:'Volver a procesos',exact:true}).waitFor();return true;")
self.code('page.configurationRemoved='+json.dumps(before['focusRow'])+";await page.evaluate(()=>{window.dispatchEvent(new Event('offline'));window.dispatchEvent(new Event('online'))});await page.getByRole('link',{name:'Volver a procesos',exact:true}).waitFor();await page.getByRole('link',{name:'Volver a procesos',exact:true}).click();await page.locator('main [data-query-state=ready]').waitFor();await page.waitForFunction(()=>document.activeElement.hasAttribute('data-quick-search'));return true;")
self.assertFalse(any(x['id']==before['focusRow']for x in self.values()['rows']));self.record('process-removed-return',self.values())
self.code("page.configurationRemoved=null;return true;")
self.route(path,{'q':'L042','closed':'true','sort':'name','direction':'desc','page':'3'});before=self.position_origin()
self.code("await page.locator('main tbody tr a').first().evaluate(e=>e.click());page.processGates=[];page.processStarted=new Promise(r=>page.processStart=r);page.processHold=async r=>{page.processStart();await new Promise(resolve=>page.processGates.push(resolve));await r.fallback()};await page.route(page.adoptionOrigin+'/api/processes/'+page.adoptionCompany,page.processHold);await page.evaluate(()=>{window.dispatchEvent(new Event('offline'));window.dispatchEvent(new Event('online'))});await page.goBack();await page.processStarted;await page.locator('main [data-query-state=refreshing]').waitFor();return true;")
try:
self.assertEqual(self.values()['page'],'3');self.code("for(const r of page.processGates)r();await page.locator('main [data-query-state=ready]').waitFor();return true;");self.wait_return_focus(before['focusRow']);self.record('process-refetch-return',self.values())
finally:self.code("for(const r of page.processGates)r();await page.unroute(page.adoptionOrigin+'/api/processes/'+page.adoptionCompany,page.processHold);return true;")
self.code("await page.getByRole('button',{name:'Limpiar filtros',exact:true}).click();return true;");self.settle();after=self.values()
self.assertEqual(after['query'],'');self.assertEqual(after['page'],'1');self.assertEqual(after['sort'],'descending');self.assertEqual(after['count'],40);self.assertTrue(after['focusSearch']);self.record('process-clear',after)
self.route(path,{'sort':'actions','direction':'unknown','page':'-3'});self.code("await page.waitForFunction(()=>!['sort','direction','page'].some(k=>new URL(location.href).searchParams.has(k)));return true;");self.assertEqual(self.values()['sort'],'ascending');self.assertEqual(self.values()['page'],'1')
self.route(path,{'q':'L042-PROCESO-29','sort':'name','direction':'desc'});before=self.position_origin()
self.code("await page.locator('main tbody tr a').first().evaluate(e=>e.click());await page.getByRole('button',{name:'Actualizar seguimiento',exact:true}).click();await page.getByRole('dialog').waitFor();await page.keyboard.press('Escape');await page.getByRole('dialog').waitFor({state:'hidden'});await page.getByRole('button',{name:'Volver a procesos',exact:true}).click();await page.locator('main [data-query-state=ready]').waitFor();return true;");self.wait_return_focus(before['focusRow']);self.assertEqual(before['rows'],self.values()['rows'])
missing='019a4201-0000-7000-a000-999999999999'
self.code('await page.adoptionGoto('+json.dumps(self.origin+path+'/'+missing+'?sort=name&direction=desc&page=3')+");await page.getByRole('link',{name:'Volver a procesos',exact:true}).click();await page.locator('main [data-query-state=ready]').waitFor();return true;");self.assertEqual(self.values()['page'],'3');self.record('process-direct-fallback',self.values())
def test_77_software_modal_and_related_resource(self):
self.resize(1440,900)
target=self.code("const asset=page.configurationData.assetId,station=page.adoption.inventory.workstations.find(w=>w.values.computerAssetId===asset);return {asset,station:station.id};")
self.code('await page.adoptionGoto('+json.dumps(self.origin+self.base+'/workstations/'+target['station'])+");await page.getByRole('button',{name:'Gestionar software',exact:true}).click();await page.getByRole('dialog').waitFor();await page.getByRole('dialog').locator('[data-query-state=ready]').waitFor();return true;")
self.code("const d=page.getByRole('dialog');await d.locator('th[aria-sort] button').first().click();await d.getByRole('button',{name:'Página 3',exact:true}).click();await page.evaluate(()=>window.softwareTable=document.querySelector('[role=dialog] table'));return true;")
before=self.code("return page.getByRole('dialog').locator('[data-slot=entity-table]').evaluate(r=>({url:location.pathname+location.search,rows:[...r.querySelectorAll('tbody tr[data-entity-id]')].map(x=>x.dataset.entityId),page:r.querySelector('[aria-current=page]').textContent}));")
self.edit_cancel('[role=dialog]')
self.assertTrue(self.code("return page.evaluate(()=>window.softwareTable===document.querySelector('[role=dialog] table'));"));self.assertNotEqual(self.code('return page.evaluate(()=>document.activeElement.tagName);'),'BODY')
after=self.code("return page.getByRole('dialog').locator('[data-slot=entity-table]').evaluate(r=>({url:location.pathname+location.search,rows:[...r.querySelectorAll('tbody tr[data-entity-id]')].map(x=>x.dataset.entityId),page:r.querySelector('[aria-current=page]').textContent}));")
self.assertEqual(before,after);self.record('software-editor-cancel',after);self.capture('software-dialog-1440')
href=self.code("return page.getByRole('dialog').locator('tbody a').first().getAttribute('href');")
self.code("await page.getByRole('dialog').locator('tbody a').first().click();return true;")
self.code('await page.waitForURL('+json.dumps(self.origin+href)+");await page.locator('main').getByRole('button',{name:'Ficha e historial',exact:true}).waitFor();return true;")
self.record('software-related-resource',self.code('return {url:page.url(),text:(await page.locator(\'main\').innerText()).slice(0,800)};'))
self.code("await page.goBack();await page.getByRole('button',{name:'Gestionar software',exact:true}).waitFor();return true;");self.assertEqual(self.code("return page.getByRole('dialog').count();"),0)
def test_78_configuration_capacity_and_language(self):
for width,height in ADOPTION['CONTEXT']['SIZES']:
self.resize(width,height);self.route(self.base+'/mail-configurations');self.code("await page.getByRole('button',{name:'Página 3',exact:true}).click();return true;");self.settle();value=self.position_origin()
self.assertFalse(value['overflow']);self.assertEqual(value['page'],'3');self.record('configuration-capacity-'+str(width),value)
if width in [1440,390,320]:self.capture('configuration-capacity-'+str(width))
self.resize(390,844);self.route(self.base+'/mail-configurations',{'profileId':self.code('return page.configurationData.profileId;')})
self.code("await page.locator('main input[data-quick-search]').fill('zz-no-match');return true;");self.settle();before=self.values()
try:
self.code("await page.locator('summary[aria-label=\"Menú de usuario\"]').click();await page.getByRole('combobox',{name:'Idioma',exact:true}).click();await page.getByRole('option',{name:'English',exact:true}).click();await page.locator('summary[aria-label=\"User menu\"]').click();await page.getByRole('button',{name:'Clear filters',exact:true}).focus();await page.keyboard.press('Enter');return true;");self.settle();after=self.values()
self.assertEqual(after['url'],before['url']);self.assertEqual(after['query'],'');self.assertEqual(after['count'],1);self.assertTrue(after['focusSearch']);self.record('configuration-english-clear',after);self.capture('configuration-english-390')
finally:self.code("if(await page.locator('summary[aria-label=\"User menu\"]').count()){await page.locator('summary[aria-label=\"User menu\"]').click();await page.getByRole('combobox',{name:'Language',exact:true}).click();await page.getByRole('option',{name:'Español',exact:true}).click();await page.locator('summary[aria-label=\"Menú de usuario\"]').click()}return true;")
def test_61_catalog_routes_and_reload(self):
self.resize(1440,900)
for path in ['/catalogs/assets',self.base+'/catalogs/assets','/catalogs/resources',self.base+'/catalogs/resources']:
for tab in ['templates','fields'] + (['relations'] if path.endswith('assets') else []):
query={'tab':tab,'q':' L042 ','sort':'name','direction':'desc','page':'3'}
self.route(path,query);before=self.position_origin();self.record(path+'-'+tab+'-before',before);self.record('history-before',self.code('return page.evaluate(()=>history.state);'))
self.assertEqual(before['page'],'3');self.assertEqual(before['sort'],'descending');self.assertEqual(len(before['rows']),10)
self.code("await page.reload();await page.locator('main [data-query-state=ready]').waitFor();return true;")
self.wait_return_focus(before['focusRow']);after=self.values();self.record(path+'-'+tab+'-after',after);self.record('history-after',self.code('return page.evaluate(()=>history.state);'))
for key in ['url','query','page','sort','rows','x','y','focusRow']:self.assertEqual(before[key],after[key],path+':'+tab+':'+key)
self.record(path+'-'+tab+'-reload',after)
def test_62_tabs_clear_dialogs_and_keyboard(self):
self.resize(390,844)
for path in ['/catalogs/assets',self.base+'/catalogs/assets','/catalogs/resources',self.base+'/catalogs/resources']:
self.route(path,{'q':'L042','sort':'origin','direction':'desc','page':'3'})
self.code("await page.getByRole('button',{name:'Campos',exact:true}).click();return true;");self.settle();v=self.values()
params=parse_qs(urlsplit(v['url']).query)
self.assertEqual(params.get('tab'),['fields']);self.assertEqual(v['query'],'');self.assertEqual(v['page'],'1');self.assertEqual(v['sort'],'ascending')
self.assertNotIn('direction',params)
self.code("await page.locator('main input[data-quick-search]').fill(' ADMINISTRACION ');return true;");self.settle();self.assertEqual(self.values()['count'],50)
self.code("await page.locator('main input[data-quick-search]').fill('Pena');return true;");self.settle();self.assertEqual(self.values()['count'],1)
before=self.values();self.code("await page.locator('main tbody tr').first().getByRole('button',{name:'Ver',exact:true}).click();await page.getByRole('dialog').waitFor();await page.keyboard.press('Escape');await page.getByRole('dialog').waitFor({state:'hidden'});return true;")
after=self.values()
for key in ['url','rows','page','sort']:self.assertEqual(before[key],after[key])
self.assertNotEqual(after['focus'],'BODY');self.record(path+'-tabs-modal',after)
self.code("await page.locator('main input[data-quick-search]').fill('L042-CAMPO-01');return true;");self.settle();before=self.values()
self.code("await page.locator('main tbody tr').first().getByRole('button',{name:'Acciones',exact:true}).click();await page.getByRole('menuitem',{name:'Editar',exact:true}).click();await page.getByRole('dialog').waitFor();await page.keyboard.press('Escape');await page.getByRole('dialog').waitFor({state:'hidden'});return true;")
self.assertEqual(before['rows'],self.values()['rows']);self.assertEqual(before['url'],self.values()['url']);self.assertNotEqual(self.values()['focus'],'BODY')
def test_63_access_scopes_and_tabs(self):
self.resize(1440,900)
for path,kind in [('/catalogs/access','system'),(self.base+'/access','account')]:
self.route(path,{'kind':kind,'q':'L042','sort':'name','direction':'desc','page':'3'});before=self.position_origin()
self.code("await page.reload();await page.locator('main [data-query-state=ready]').waitFor();return true;");self.wait_return_focus(before['focusRow'])
self.assertEqual(self.values()['rows'],before['rows']);self.assertEqual(self.values()['page'],'3')
self.code("await page.getByRole('button',{name:'Permisos disponibles',exact:true}).click();return true;");self.settle()
self.assertEqual(self.values()['query'],'');self.assertEqual(self.values()['page'],'1');self.assertEqual(self.values()['sort'],'ascending')
self.record(path+'-kind-switch',self.values())
env=self.code('return page.catalogConfiguration.localAccess.items.find(i=>i.values.kind===\'environment\'&&i.values.name.startsWith(\'L042\')).id;')
self.route(self.base+'/access',{'kind':'account','environmentId':env,'q':'zz-sin-coincidencias','sort':'name','direction':'desc','page':'3'})
self.assertEqual(len(self.values()['rows']),0)
self.code("await page.getByRole('button',{name:'Limpiar filtros',exact:true}).click();return true;");self.settle()
self.assertEqual(parse_qs(urlsplit(self.values()['url']).query).get('environmentId'),[env]);self.assertGreater(len(self.values()['rows']),0)
def test_64_modal_only_catalogs(self):
self.resize(390,844)
for path in ['/catalogs/brands','/catalogs/process-templates']:
self.route(path);self.code("await page.locator('main input[data-quick-search]').fill('ADMINISTRACION');await page.getByRole('columnheader',{name:'Nombre',exact:true}).getByRole('button').click();await page.getByRole('button',{name:'Página 3',exact:true}).click();return true;");self.settle()
before=self.values();history=self.code('return page.evaluate(()=>history.length);')
self.code("await page.locator('main tbody tr').first().getByRole('button',{name:'Editar',exact:true}).click();await page.getByRole('dialog').waitFor();await page.keyboard.press('Escape');await page.getByRole('dialog').waitFor({state:'hidden'});return true;")
after=self.values()
for key in ['url','query','page','sort','rows']:self.assertEqual(before[key],after[key])
self.assertEqual(self.code('return page.evaluate(()=>history.length);'),history);self.assertNotEqual(after['focus'],'BODY');self.record(path+'-cancel',after)
def test_65_catalog_capacity_and_retired_definitions(self):
for width,height in ADOPTION['CONTEXT']['SIZES']:
self.resize(width,height)
self.route(self.base+'/catalogs/assets',{'tab':'fields','q':'L042','sort':'name','direction':'desc','page':'3'})
value=self.position_origin();self.assertFalse(value['overflow']);self.assertEqual(value['page'],'3')
self.record('catalog-capacity-'+str(width),value)
if width in [1440,390,320]:self.capture('catalog-capacity-'+str(width))
self.route(self.base+'/catalogs/assets',{'tab':'fields','q':'L042-CAMPO-05'})
self.assertIn('Inactivo',self.code("return page.locator('main tbody').innerText();"))
self.record('retired-definition',self.values())
def test_66_catalog_refetch_and_permissions(self):
self.resize(320,640)
for path,endpoint in [('/catalogs/assets','/asset-catalog/global'),('/catalogs/resources','/resource-catalog/global'),('/catalogs/brands','/asset-brands'),('/catalogs/process-templates','/processes/templates'),('/catalogs/access','/access/global')]:
self.route(path);before=self.values();errors=self.code("return page.adoptionConsole.filter(x=>x.type==='pageerror').length;")
self.code('page.catalogFailureUrl='+json.dumps(self.origin+'/api'+endpoint)+";page.catalogFailureStatus=503;page.catalogFailure=r=>r.fulfill({status:page.catalogFailureStatus,contentType:'application/json',json:{code:page.catalogFailureStatus===403?'FORBIDDEN':'INTERNAL_ERROR',requestId:'019a4200-0000-7000-a000-000000099999'}});await page.route(page.catalogFailureUrl,page.catalogFailure);await page.evaluate(()=>{window.dispatchEvent(new Event('offline'));window.dispatchEvent(new Event('online'))});await page.locator('main [data-query-state=stale]').waitFor();return true;")
try:
self.assertEqual(before['rows'],self.values()['rows']);self.record(path+'-stale',self.values())
self.code("page.catalogFailureStatus=403;await page.getByRole('button',{name:'Reintentar',exact:true}).click();await page.locator('main [data-query-state=unavailable]').waitFor();return true;")
self.assertEqual(len(self.values()['rows']),0);self.assertEqual(self.code("return page.getByRole('button',{name:'Reintentar',exact:true}).count();"),0)
self.assertEqual(self.code("return page.adoptionConsole.filter(x=>x.type==='pageerror').length;"),errors)
self.record(path+'-forbidden',self.values())
finally:self.code("await page.unroute(page.catalogFailureUrl,page.catalogFailure);return true;")
def test_67_font_arrival_preserves_saved_scroll(self):
self.resize(1440,900)
self.route('/catalogs/assets',{'q':'L042','sort':'name','direction':'desc','page':'3'})
before=self.position_origin()
self.code("page.catalogFonts=[];page.catalogFontStarted=new Promise(r=>page.catalogFontStart=r);page.catalogFontHold=async route=>{page.catalogFontStart();await new Promise(r=>page.catalogFonts.push(r));await route.fallback()};await page.route('**/*.woff2',page.catalogFontHold);return true;")
try:
self.code("await page.reload({waitUntil:'domcontentloaded'});await page.catalogFontStarted;await page.locator('main [data-query-state=ready]').waitFor();return true;")
self.assertEqual(self.code("return page.evaluate(()=>document.fonts.status);"),'loading')
self.assertIsNone(self.values().get('focusRow'))
self.record('font-retained',self.values())
self.code("for(const r of page.catalogFonts)r();await page.evaluate(()=>document.fonts.ready);return true;")
self.wait_return_focus(before['focusRow']);self.settle();after=self.values()
for key in ['url','page','sort','rows','x','y','focusRow']:self.assertEqual(before[key],after[key],key)
self.record('font-arrival-return',after)
finally:self.code("for(const r of page.catalogFonts)r();await page.unroute('**/*.woff2',page.catalogFontHold);return true;")
def test_68_catalog_columns_preferences_and_invalid_parameters(self):
self.resize(1440,900)
for path in ['/catalogs/assets','/catalogs/resources']:
self.route(path,{'q':'L042','sort':'name','direction':'desc','page':'3'})
self.code("await page.getByRole('button',{name:'Columnas',exact:true}).click();await page.getByRole('dialog').getByRole('checkbox',{name:/Origen/}).uncheck();await page.keyboard.press('Escape');await page.reload();await page.locator('main [data-query-state=ready]').waitFor();return true;")
self.assertFalse(any('Origen' in x for x in self.values()['headers']))
self.code("await page.getByRole('button',{name:'Campos',exact:true}).click();return true;");self.settle()
self.assertTrue(any('Origen' in x for x in self.values()['headers']))
self.route(path,{'sort':'actions','direction':'sideways','page':'-3'})
self.code("await page.waitForFunction(()=>!['sort','direction','page'].some(k=>new URL(location.href).searchParams.has(k)));return true;")
self.assertEqual(self.values()['sort'],'ascending');self.assertEqual(self.values()['page'],'1')
self.record(path+'-columns-invalid',self.values())
def test_69_container_query_delays_restore(self):
self.resize(1440,900)
self.route('/catalogs/assets',{'q':'L042','sort':'name','direction':'desc','page':'3'})
before=self.position_origin()
self.code("page.catalogContainerGates=[];page.catalogContainerStarted=new Promise(r=>page.catalogContainerStart=r);page.catalogContainerHold=async route=>{page.catalogContainerStart();await new Promise(r=>page.catalogContainerGates.push(r));await route.fallback()};await page.route(page.adoptionOrigin+'/api/companies',page.catalogContainerHold);return true;")
try:
self.code("await page.reload({waitUntil:'domcontentloaded'});await page.catalogContainerStarted;await page.locator('main [data-query-state=ready]').waitFor();return true;")
self.assertEqual(self.code("return page.locator('[data-slot=workspace-scroll] > p[role=status]').count();"),1)
self.assertIsNone(self.values().get('focusRow'));self.record('container-pending',self.values())
self.code("for(const r of page.catalogContainerGates)r();await page.locator('[data-slot=workspace-scroll] > p[role=status]').waitFor({state:'hidden'});return true;")
self.wait_return_focus(before['focusRow']);self.settle();after=self.values()
for key in ['url','page','sort','rows','x','y','focusRow']:self.assertEqual(before[key],after[key],key)
self.record('container-ready-return',after)
finally:self.code("for(const r of page.catalogContainerGates)r();await page.unroute(page.adoptionOrigin+'/api/companies',page.catalogContainerHold);return true;")
def load_tests(loader, tests, pattern):
return unittest.TestSuite(CatalogConfigurationUiTests(name) for name in sorted(CatalogConfigurationUiTests.__dict__) if name.startswith('test_'))
if __name__ == '__main__':
unittest.main(verbosity=2)