Puppeteer: Automatizando tareas

evolution-3778196_1920

Recientemente, en nuestro proyecto, hemos tenido la oportunidad de invertir algo de tiempo en automatizar una tarea que, si bien era sencilla y necesaria, era monótona y repetitiva.

Consistía en la compilación del código en local y despliegue en el servidor de aplicaciones. De este modo, sin subir los cambios al repositorio de código, podemos desplegar la aplicación. Al realizarlo a mano, siempre podía ocurrir que entre paso y paso nos distrajésemos con otra tarea y el despliegue llevase más tiempo del deseado.

Con la automatización, no seremos nosotros los que tengamos que estar pendientes y conseguiremos que el despliegue se complete de manera rápida y eficiente.

Esta tarea se divide claramente en 2 partes: por un lado la compilación del proyecto y, por otro, el despliegue del fichero generado a través de un interfaz web.

La compilación no deja de ser lanzar un comando, pero la parte del despliegue hace que la elección de tecnología sea limitada. Como plataforma para el script, optamos por NodeJS y para realizar las acciones con el navegador, nos decidimos por Puppeteer. En este punto, pudimos haber optado por Chromeless, pero el primero nos provee de una API para trabajar con frames, una necesidad que viene marcada por el servidor de aplicaciones que divide la ventana de esta manera.

Para el ejemplo que iremos viendo, usaremos la versión v8.12.0 de NodeJS  y la v6.4.1 de NPM. Además usaremos un Tomcat a modo de servidor de aplicaciones y una aplicación generada con Spring Initialzr para ser compilada y desplegada.

No creo que os resulte desconocido NodeJS, pero ¿qué es Puppeteer? En la propia página definen a Puppeteer como una librería que permite controlar Chrome o Chromium sobre el protocolo de DevTools; permitiéndonos, de ésta manera realizar acciones sobre una web como si de una persona se tratase.

Ya con una idea en mente y unas herramientas definidas, empezamos creando una carpeta para guardar el proyecto e inicializándolo con NPM. A continuación, añadimos las dependencias que necesita el proyecto, creamos el fichero de entrada (index.js) y abrimos el editor (en mi caso VS Code).

 

mkdir automated-deploy && cd $_
npm init -yes
npm i puppeteer node-fetch
touch index.js
code .

 

Con estos pasos se nos creará el package.json con las opciones por defecto.

 A continuación, modifico la sección de scripts dentro del mismo package.json para lanzar el script con el comando `npm start`.

Nos quedaría un package.json de la siguiente manera:

 

{
  "name": "tomcat-deploy",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "start": "node index.js"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "node-fetch": "^2.3.0",
    "puppeteer": "^1.12.2"
  }
}


Además, como podéis ver, ya no es necesario el flag --save o -S para que se guarden las dependencias en el package.json.

Abrimos el index.js y le añadimos como primeras líneas las dependencias que vamos a necesitar.


const puppeteer = require('puppeteer')
const fetch = require('node-fetch')
const { exec } = require('child_process')

 

El siguiente paso sería saber qué camino vamos a seguir para completar la tarea, y para ello la podemos realizar de manera manual mientras vamos guardando los selectores de los elementos (botones y demás controles de Tomcat) que vamos a usar para completar la tarea.

Una vez localizados, vamos a guardarlos en el index.js como constantes, de modo que sean fácilmente identificables a la hora de usarlos desde el script.

const MANAGER_APP = ' a[href="/manager/html"]'
const APPLICATION_LINK = 'a[href="/demo-puppeteer/"]'
const INPUT_FILE = 'form[action*="upload"] input[name="deployWar"]'
const DEPLOY_BTN = 'form[action*="upload"] input[value="Deploy"]'
const STOP_BTN = 'form[action*="puppeteer"] input[value="Stop"]'
const UNDEPLOY_BTN = 'form[action*="puppeteer"] input[value="Undeploy"]'

También vamos a guardar en constantes las direcciones que vamos a necesitar durante para el proceso.

const TOMCAT_SERVER = 'http://localhost:8080/'
const TEST_URL = `${TOMCAT_SERVER}/demo-puppeteer/version`
const PROJECT_PATH = '/path/to/project/demo-puppeteer'
const WAR_PATH = `${PROJECT_PATH}/target/demo-puppeteer.war`

En el PROJECT_PATH se encuentra el pom.xml de la aplicación que quiero desplegar.

 Añadimos también usuario y password para Tomcat.

const TOMCAT_USER = 'tomcat'
const TOMCAT_PASSWORD = 'S3cr3t'


Al ser éste un ejemplo, no hay problema, pero deberíamos usar otra forma de modo que no tengamos usuario y password en texto plano. Quizás un módulo como read, para preguntar por ellos en el momento de ejecución.

Y para finalizar con las constantes, vamos a establecer la configuración que vamos a usar con Puppeteer.

const PUPPETEER_OPTS = {
    headless: false,
    slowMo: { default: 300, click: 200, keyup: 10 },
    devtools: false,
}

Headless es la opción que nos permite ocultar o mostrar el navegador. Os recomiendo dejarlo en false para poder ver lo que hace mientras estáis creando el script o realizando pruebas.

La opción slowMo nos permite indicar las velocidades a las que queremos ejecutar las interacciones con el navegador.

Y devtools nos permite mostrar u ocultar las herramientas de desarrollo en el momento de lanzar el navegador.

Éstas son algunas de las opciones, pero hay muchas otras disponibles.

La parte de la compilación es sencilla, tenemos que crear una función que lance la compilación.

const generateWarFile = () => new Promise((resolve, reject) => {
        exec('mvn clean package', {
            maxBuffer: 1024 * 1000, // Avoid Error: stdout maxBuffer exceeded
            cwd: PROJECT_PATH,
          }, 
          (err, stdout, stderr) => {      
            if(err) reject(err)
            if(stdout) console.log(`stdout: ${stdout.slice(-332)}`)
            if(stderr) console.log(`stderr: ${stderr}`)
            resolve()        
          })
    })

Con exec, de NodeJS, podemos lanzar un comando como si de la consola se tratase.

Exec acepta como argumentos el comando como un string, un objeto de opciones y un callback para procesar el resultado. Además, lo estamos envolviendo en una promesa.

Para más detalle, os comento que ‘mvn clean package' es el comando que quiero ejecutar para compilar la aplicación.

maxBuffer lo uso en este caso para poder gestionar el output que genera el comando, ya que es verboso y saturaba el buffer por defecto.

La opción cwd nos permite indicar el path dónde queremos ejecutar el comando.

En el callback mostramos los logs generados y resolvemos la promesa si todo fue bien o la rechazamos si algo fallo.

Podríamos ejecutar en este punto la función y ver como output, que el proyecto se compila como se esperaba.

 

Podemos crear ahora otra función que llevara el flujo en ambas tareas:

const runAutoDeploy = async () => {
	try {
    	// Aquí irá el código que compila el proyecto y lo despliega en el Tomcat
    } catch(error) {
    	console.error(error)
    }
}

Ahora, dentro del try, añadimos la llamada para compilar el proyecto y después de ésto, añadimos la inicialización de Puppeteer.

 

await generateWarFile()
const browser = await puppeteer.launch(PUPPETEER_OPTS)
const page = await browser.newPage()

 

Page será la instancia de la página que usaremos para la navegación e interacción con el navegador.

 A continuación, navegaremos a la consola web del Tomcat, pero deberíamos estar logados. Para ello puppeteer nos provee del método authenticate(credentials) para realizar esto, así pues podemos realizar lo siguiente:

await page.authenticate({ username: TOMCAT_USER, password: TOMCAT_PASSWORD })
await page.goto(TOMCAT_SERVER)

 

Ahora viene algo más sencillo: ir a la gestión de aplicaciones haciendo click en el botón Manager App (algo que podíamos haber hecho directamente con goto, pero así vemos más interacciones).

Para hacer click en un objeto de la página, podemos hacer directamente page.click(selector), siendo el selector el identificador de nuestro botón. Hacemos click y le dejamos un segundo para que cargue, y para ello usaremos waitFor(milliseconds).

 

 

await page.click(MANAGER_APP)
await page.waitFor(1000)

 

En este punto pueden darse los siguientes casos:

  1. La aplicación ya está desplegada.

                              1.1. La aplicación está ejecutándose, debemos pararla

                              1.2. La aplicación esta parada, debemos borrarla

  1. La aplicación no está desplegada.

              

Para el punto 1 deberemos comprobar si ya existe: vamos a usar el link que aparece en la tabla y ver si existe. Puppeteer tiene el método $(selector) que nos devuelve el elemento si es que existe.

const link = await page.$(APPLICATION_LINK)
if(link) {
	// Aquí irá el resto del código para parar y borrar la aplicación
}

 

Ahora dentro del if vamos a buscar el botón de Stop y hacer clic sobre el mismo. Podríamos realizar click como vimos antes, pero, en este caso, vamos a realizarlo de otra manera combinando lo que ya hemos visto.

Además deberíamos esperar a que se parase; para ello voy a comprobar si el botón Stop desaparece. Y finalmente le añadimos un catch a esta cadena para controlar errores como que el botón ya no exista y no rompamos el resto de la ejecución.

const link = await page.$(APPLICATION_LINK)
if(link) {
	await page.$(STOP_BTN)
    .then(btn => btn.click())
    .then(() => page.waitForSelector(STOP_BTN,  { hidden: true })
    .catch(() => console.log('Warn: Unable to stop the app'))
    
	await page.$(UNDEPLOY_BTN)
    .then(btn => btn.click()
    .then(() => page.waitForSelector(UNDEPLOY_BTN, { hidden: true}))
    .catch(() => console.log('Warn: Unable to undeploy the app'))
}

 

Como podéis ver, hacemos lo mismo para el botón Undeploy .

Ya fuera del if, es el punto donde teniendo el entorno limpio debemos subir nuestro fichero .war. Esto también es nuevo, ya que no podemos dar directamente al botón para que salga la ventana de selección de ficheros.

Pero solucionar esto también es sencillo, tenemos el método element.uploadFile(path) en puppeteer que nos permite subir ficheros.

Ya teniendo el input de tipo file que queremos usar para subir el fichero solo tenemos que llamar a la función con la dirección donde se encuentra el .war. Para a continuación seleccionar el botón de Deploy y esperamos a que aparezca la aplicación desplegada en la consola.

await page.$(INPUT_FILE)
.then(element => element.uploadFile(WAR_PATH))
.then(async () => page.$(DEPLOY_BTN)))
.then(btn => btn.click())
.then(async () => page.waitForSelector(APPLICATION_LINK))

 

Si no hubo errores ya debería estar desplegada la aplicación, pero para comprobarlo puedo realizar una llamada a la propia aplicación a ver si obtengo respuesta.

Mi aplicación tiene un endpoint que devuelve un JSON en el que se puede ver la versión. Parece un sitio perfecto para ver si la aplicación se ha levantado correctamente.

Realizamos la llamada, obtenemos el JSON, comprobamos la versión y cerramos el navegador ya que la tarea programada se completó como se esperaba.

await fetch(TEST_URL, {})
.then(response => response.json())
.then(async ({ version }) => {
console.log(`App deployed with version ${version}`)
await browser.close()
})

 

Yo he realizado un pequeño refactor y he creado alguna función de modo que se puedan reusar y me deje un código más legible. Os dejo aquí todo el código para que lo podáis verlo con un solo vistazo.

// node --version 
// v8.12.0

// npm --version
// 6.4.1

// Dependencias externas de proyecto
const puppeteer = require('puppeteer')
const fetch = require('node-fetch')
const { exec } = require('child_process')

// Opciones para la ejecución de Puppeteer
const PUPPETEER_OPTS = {
    headless: false,
    slowMo: { default: 300, click: 200, keyup: 10 },
    devtools: false,
}

// Credenciales del Tomcat
const TOMCAT_USER = 'tomcat'
const TOMCAT_PASSWORD = 'S3cr3t'

// Rutas involucradas en el proceso de despliegue
const TOMCAT_SERVER = 'http://localhost:8080/'
const TEST_URL = `${TOMCAT_SERVER}/demo-puppeteer/version`
const PROJECT_PATH = '/Users/cesar.gonzalez/Desktop/Confidential/lab/puppeteer/demo-puppeteer'
const WAR_PATH = `${PROJECT_PATH}/target/demo-puppeteer.war`

// Elementos usados para el despliegue de aplicaciones en Tomcat
const MANAGER_APP = ' a[href="/manager/html"]'
const APPLICATION_LINK = 'a[href="/demo-puppeteer/"]'
const INPUT_FILE = 'form[action*="upload"] input[name="deployWar"]'
const DEPLOY_BTN = 'form[action*="upload"] input[value="Deploy"]'
const STOP_BTN = 'form[action*="puppeteer"] input[value="Stop"]'
const UNDEPLOY_BTN = 'form[action*="puppeteer"] input[value="Undeploy"]'

// Funciones de navegación
const click = element => element.click()
const waitForSelector = (page, selector) => async () => await page.waitForSelector(selector)
const waitToDissapear = (page, selector) => async () => await page.waitForSelector(selector, { hidden: true })

// Funcion de compilación de proyecto
const generateWarFile = () => new Promise((resolve, reject) => {
        exec('mvn clean package', {
            maxBuffer: 1024 * 1000, // Avoid Error: stdout maxBuffer exceeded
            cwd: PROJECT_PATH,
          }, 
          (err, stdout, stderr) => {      
            if(err) reject(err)
            if(stdout) console.log(`stdout: ${stdout.slice(-332)}`)
            if(stderr) console.log(`stderr: ${stderr}`)
            resolve()        
          })
    })

// Funcion principal que contiene el flujo de compilacion y despliegue de la aplicación
const runAutoDeploy = async () => {
    try {
        // Inicializamos Chromium
        const browser = await puppeteer.launch(PUPPETEER_OPTS)
        const page = await browser.newPage()

        // Compilamos el proyecto
        await generateWarFile()

        // Navegamos a la consola web de Tomcat
        await page.authenticate({ username: TOMCAT_USER, password: TOMCAT_PASSWORD })
        await page.goto(TOMCAT_SERVER)

        // Abrimos la sección de gestión de aplicaciones
        await page.click(MANAGER_APP)
        await page.waitFor(1000)
        
        // Revisamos si la aplicación ya esta desplegada
        const link = await page.$(APPLICATION_LINK)
        if(link) {
            // Esta desplegada, vamos a intentar:

            //  1. Parar la aplicación
            await page.$(STOP_BTN)
            .then(click)
            .then(waitToDissapear(page, STOP_BTN))
            .catch(() => console.log('Warn: Unable to stop the app'))

            await page.waitFor(1000)

            //  2. Eliminarla de la lista de aplicaciones
            await page.$(UNDEPLOY_BTN)
            .then(click)
            .then(waitToDissapear(page, UNDEPLOY_BTN))
            .catch(() => console.log('Warn: Unable to undeploy the app'))
        }

        // Con el entorno limpio, desplegamos la aplicacion
        await waitForSelector(page, INPUT_FILE)()
        .then(element => element.uploadFile(WAR_PATH))
        .then(waitForSelector(page, DEPLOY_BTN))
        .then(click)
        .then(waitForSelector(page, APPLICATION_LINK))

        // Comprobamos que se haya desplegado correctamente
        await fetch(TEST_URL, {})
        .then(response => response.json())
        .then(async ({ version }) => {
            // La aplicacion se desplego correctamente
            console.log(`App deployed with version ${version}`)
            // Cerramos el navegador
	        await browser.close()
        })
    } catch(error) {
        console.log(error)
    }
}

runAutoDeploy()

 

Para ejecutarlo usamos npm start

Con eso ya estaría, hay que decir que Tomcat va rápido, no tiene Frames y mi aplicación no tarda en desplegarse al ser una de prueba. Es un mundo ideal.

Pero en la realidad no resultó tan sencillo. Tuvimos que usar unas funciones para realizar reintentos, de modo que si no iba todo como se esperaba en algún paso lo intentase alguna vez mas.

const TIMEOUT = 5000
const DEFAULT_ATTEMPTS = 3

function delay(millis) {
	return new Promise(resolve => setTimeout(resolve, millis))
}

async function retry(fn, retries = DEFAULT_ATTEMPS) {
	try {
    	return await fn()
    } catch(error) {
    	if(retries === 0) throw error
        return await delay(TIMEOUT).then(() => retry(fn, retries -1))
    }
}

// Y así la llamariamos
await retry(async () => page.$(SOME_BTN)
				.then(element => element.click()))

Hasta 3 veces intentará buscar el selector y hacer click sobre el elemento con un retardo de 5 segundos entre reintentos.

También algo que hizo que fuese más complejo en nuestro caso fue el tener que ir a buscar los elementos entre los frames que iban cambiando, para ello usamos la API que provee Puppeteer para trabajar con frames y creamos alguna función para que se buscasen los selectores en todos ellos, de modo que no tuviésemos que indicar de qué frame se trataba.

Aquí un ejemplo de esas funciones: en la primera buscamos el selector en todos los frames y devolvemos el Frame donde se encuentra y en la segunda buscamos el selector entre los frames y devolvemos el elemento.

 

async function searchFrameForElement(page, selector) {
	return retry(async () => {
    	const frames = await page.frames()
        return Promise.all(frames.map(async frame => {
        	const el = await frame.$(selector)
            return !!el ? frame : undefined
        }))
        .then(frames => frames.reduce((acc, curr) => acc ? acc : curr, undefined))
    })
}

async function searchElementInFrames(page, selector) {
	return retry(async () => {
    	const frame = await searchFrameForElement(page, selector)
        return await frame.$(selector)
    })
}

Añadimos algún log usando Chalk para darle colores y ofrecerles mayor o menor relevancia.

Por último, usamos Promise.all para poder lanzar la compilación del proyecto y el proceso de login de forma paralela para ahorrar tiempo.

await Promise.all([
	// Compilamos el proyecto
    generateWarFile(),
    
    // Al mismo tiempo lanzamos Puppeteer y nos logamos en el Tomcat
    (async () => {
    	browser = await puppeteer.launch(PUPPETEER_OPTS)
        page = await browser.newPage()
        await page.authenticate({ username: TOMCAT_USER, password: TOMCAT_PASSWORD })
        await page.goto(TOMCAT_SERVER)
        await page.click(MANAGER_APP)
    })
])

Una vez que veamos que el script es estable y realiza la tarea de forma adecuada, podemos cambiar la manera de lanzar Puppeteer para que lo haga en modo headless y ni tan si quiera ver la ejecución mientras se realiza la tarea.

 

Os dejo aqui un GIF con la demo. Hay unos instantes en los que parece estar detenida la ejecucion, pero es ahí cuando se está compilando el proyecto. Veremos que, tras esos segundos, aparece el mensaje de compilación satisfactoría y el navegador abre el Tomcat. El resto de la ejecución es realmente rápido, como ya he dicho es un ejemplo y puede que otras tareas no vayan tan rápido.

Peek 2019-02-14 22-28

Espero que os haya gustado y os de alguna idea para aplicarlo en alguna tarea.