Puppeteer: usare Chrome come un API

La versione 59 di Chrome ha portato un’interessante novità per chi si occupa di sviluppo web. Da questa versione infatti è possibile lanciare il famoso browser targato Google in modalità headless, senza cioè nessuna interfaccia grafica. Inoltre il team Chrome ha anche rilasciato Puppeteer, una API Node che permette di pilotare un’instanza di Chrome. Installando il pacchetto con il comando npm i puppeteer, all’interno della cartella node_modules verrà aggiunta un’installazione di Chromium che verrà utilizzata durante l’esecuzione degli script.

Test end-to-end

L’utilizzo più immediato di Puppeteer è quello di tool per testing end-to-end, simile all’accoppiata Nightwatch + Selenium. Puppeteer ci da infatti una serie di comandi per aprire una pagina, selezionare un elemento del DOM tramite selettore e simulare l’intervento dell’utente. In pratica tutto quello che ci serve per creare una suite di test end-to-end. Qui di seguito un esempio di test, fatto con Jest, per la navigazione di CodingJam.

const URL = 'https://codingjam.it/'
const POST_LINK_SELECTOR = '.entry-title a'
const POST_TITLE_SELECTOR = 'div.post-title'

let browser

describe('navigation', () => {
  test(`navigation should work`, async () => {
    const page = await browser.newPage()
    await page.goto(URL)
    const href = await page.$eval(POST_LINK_SELECTOR, el => el.href)
    await page.click(POST_LINK_SELECTOR)
    await page.waitForSelector(POST_TITLE_SELECTOR)
    expect(page.url()).toBe(href)
    await page.close()
  })
})

beforeAll(async () => {
  browser = await puppeteer.launch()
})

afterAll(() => {
  browser.close()
})

Quello che facciamo con questo test è:

  • Aprire CondingJam attraverso il metodo goto
  • Estrarre l’href del primo link attraverso il metodo $eval
  • Clicchiamo sul pirmo link tramite il metodo click
  • Infine controlliamo che l’url finale sia effettivamente quello del link

Chrome DevTools Protocol

Per quanto Puppeteer sia più immediato da configurare rispetto a Nightwatch e Selenium, le due soluzioni sono molto simili. Ma le funzionalità di Puppeteer non finiscono qui. Un esempio di queste funzionalità nasce dal test scritto qui di seguito.

const getFirstContentfulPaint = async page => {
  const value = await page.evaluate(() => {
    const firstContentfulPaintEntry = window.performance
            .getEntriesByType('paint')
            .find(entry => entry.name === 'first-contentful-paint')
    return firstContentfulPaintEntry && Math.round(firstContentfulPaintEntry.startTime)
  })

  return value
}

describe('performances', () => {
  test('should have the first contentful paint in less than 2 seconds', async () => {
    const FIRST_PAINT_MAX_VALUE = 2000

    const page = await browser.newPage()

    await page.goto(URL)
    await page.waitForSelector('header')

    const firstContentfulPaint = await getFirstContentfulPaint(page)

    expect(firstContentfulPaint).toBeLessThanOrEqual(FIRST_PAINT_MAX_VALUE)
  })
})

Lo scopo di questo test è quello di misurare il First Contentful Paint di un’applicazione web. Questa operazione viene fatta tramite la nuova API Performance. Cosa succede se proviamo a lanciare questo test due volte di fila? Il secondo valore di First Contentful Paint risulterà più basso grazie alla cache del browser. Questo è una palese violazione del principio di ripetibilità del testing.

Test con Cache
Il valore di First Contentful Paint risulta più basso dopo il primo test

Under the hood Puppeteer utilizza il Chrome DevTools Protocol (CDP), un protocollo che permette di effettuare tutte le operazioni presenti all’interno dei tool per sviluppatori di Chrome collegandosi ad un’istanza attiva del browser. Molti delle operazioni possibili grazie a questo protocollo sono esposte in maniera automatica dall’oggetto page di Puppeteer. Ma se c’è bisogno di andare più in profondità è possibile creare una sessione CDP e lanciare i comandi in maniera manuale. Si può riscrivere il test precedente aggiungendo un’istruzione CDP per la pulizia della cache.

test('should have the first contentful paint in less than 2 seconds', async () => {
    const FIRST_PAINT_MAX_VALUE = 2000

    const page = await browser.newPage()

    const client = await page.target().createCDPSession()
    await client.send('Network.clearBrowserCache')

    await page.goto(URL)
    await page.waitForSelector('header')

    const firstContentfulPaint = await getFirstContentfulPaint(page)

    expect(firstContentfulPaint).toBeLessThanOrEqual(FIRST_PAINT_MAX_VALUE)
})

Il prossimo test rende ancora più chiara la potenza messa a disposizione da Puppeteer. Nel test precedente il First Contentful Paint viene calcolato collengadonsi a CodingJam sfruttando la rete della macchina che lancia il test. La quale può essere di sviluppo o di Continous Integration. Tramite una sessione CDP possiamo emulare una velocità di connessione a nostra scelta.

test('should have the first contentful paint in less than 10 seconds on a slow network', async () => {
  const FIRST_PAINT_MAX_VALUE = 10000

  const page = await browser.newPage()

  const client = await page.target().createCDPSession()
  await client.send('Network.clearBrowserCache')
  await client.send('Network.emulateNetworkConditions', {
    offline: false,
    latency: 100,
    downloadThroughput: 750 * 1024,
    uploadThroughput: 200 * 1024,
    connectionType: 'cellular3g'
  })

  await page.goto(URL)
  await page.waitForSelector('header')

  const firstContentfulPaint = await getFirstContentfulPaint(page)

  expect(firstContentfulPaint).toBeLessThanOrEqual(FIRST_PAINT_MAX_VALUE)
})

Questa operazione può essere effettuata anche con i Chrome DevTools in maniera manuale, come per qualsiasi altro metodo del protocollo CDP. Per il CDP non esiste moltissima documentazione, ma Google ha pubblicato il Chrome DevTools Protocol Viewer contenente una lista aggiornata dei metodi esposti e dei parametri necessari per il loro funzionamento.

Chrome DevTools
Ecco l’operazione equivalente nei Chrome DevTools

Device Descriptors

Altra caratteristica che rende i Chrome DevTools molto apprezzati da chiunque abbia lavorato con applicazioni Cordova o in generale con un’applicazione web mobile first, è l’emulazione del viewport dei vari device.

Device Emulation
Emulazione Device in Chrome DevTools

Quelli presenti nello screenshot sono solo un subset dei device che Chrome è in grado di emulare. In realtà nascosti nei settings avanzati ce ne sono ben 53. Questi stessi dispositivi sono presenti anche in Puppeteer e possono essere utilizzati nei nostri test.

const puppeteer = require('puppeteer')
const devices = require('puppeteer/DeviceDescriptors')
const path = require('path')

const URL = 'https://codingjam.it/'

const screenshotsPath = path.join(__dirname, '..', 'screenshosts')

module.exports = async () => {
  const browser = await puppeteer.launch()
  const page = await browser.newPage()
  for (let index = 0; index < devices.length; index++) {
    const device = devices[index]
    try {
      await page.emulate(device)
      await page.goto(URL)
      await page.screenshot({
        path: path.join(screenshotsPath, `${device.name}.png`)
      })
    } catch (e) {
      console.error(`Error in creating screenshot for ${device.name}: ${e.message}`)
    }
  }
  await page.close()
  await browser.close()
}
Lista Screenshot
Screenshots generati con Puppeteer

Questa tenica apre una serie di possibilità, la prima è quella dei Visual Regression Test. In questo tipo di test si fa la comparazione di due screenshot uno considerato valido e l’altro risultato di un test. Se i due differiscono per più di una determinata percentuale di pixel, il test fallisce. Grazie ai DeviceDescriptors possiamo effettuare facilmente questo tipo di test su 53 differenti viewport.

Altri test che si possono fare sono quelli legati al device emulato. Capita spesso che a seconda della tipologia di device (desktop o mobile) la nostra applicazione si debba comportare in maniera leggermente differente. In questi casi si posso scrivere suite di test per Desktop e delle suite specifiche per i nostri target devices principali.

Report Engine

Altro modo di utilizzare Chrome in modalità headless è quello di utilizzare la sua funzione di salvataggio PDF per creare un semplice motore di report.

async () => {
  const browser = await puppeteer.launch()
  const page = await browser.newPage()
  for (let i = 1; i <= TOTAL_PAGES; i++) {
    try {
      const url = `https://codingjam.it/page/${i}/?s=javascript`
      await page.goto(url)
      await page.pdf({
        path: path.join(pdfPath, `page_${i}.pdf`)
      })
    } catch (e) {
      console.error(`Error in converting page ${i}: ${e.message}`)
    }
  }
  await page.close()
  await browser.close()
}

Lo scopo di questo script è quello di creare un PDF per ogni pagina della ricerca di CodingJam per la keyword “JavaScript”. Questa opzione non ha la potenza di un tool quale JasperReports. Ma se il caso d’uso non prevede qualche milione di righe è possibile utilizzare Puppeteer risparmiando il tempo di creazione dei layout dei report ma sfruttando delle pagine web già esistenti.. L’output di questo script sarà equivalente a quello su carta stampata: infatti di default Puppeteer genera un output basato sul media type print. Se volete invece utilizzare il media type screen e ottenere un qualcosa di simile a quello che si vede a schermo basta utilizzare l’istruzione page.emulateMedia('screen').

CodingJam PDF
Se qualcuno dovessero mai servire, ecco un report delle ricerche di CodingJam 😉

Conclusioni

Puppeteer è uno strumento molto versatile. Ci permette di controllare in tutto e per tutto (ed in maniera automatizzata) Chrome che ormai da tempo non è più solo un browser ma un vero proprio strumento per sviluppatori web, proprio grazie ai sui DevTools. Ci sono molti scenari interessanti da approfondire, ma che occuperebbero troppo spazio per questo post introduttivo. Uno di questi è la possibilità di utilizzare Puppeteer per abilitare il Server Side Rendering su una Single Page Application basata su un framework che non la supporta nativamente, come il “vecchio” AngularJS. Se siete curiosi trovate il codice completo degli esempi su GitHub. Alla prossima!

Faccio il Front-end engineer per extrategy dove mi occupo di applicazioni Angular, React e applicazioni mobile. Da sempre sono appassionato di Architetture software e cerco di applicarle in maniera innovativa allo sviluppo frontend in generale.