Running headfull chrome with Selenium and Nodejs in Docker

I spent some time bleeding attempting to use nodejs to run Chrome with Selenium. Our usecase is to run a browser with a loaded extension that performs specific operations. Beacouse extensions  cannot yet be launched in headless mode, I opted to try using Xvfb because that’s what I remembered Cypress using for its test launcher. I decided to leave a trail in case anyone else experienced similar developments in the future.


This docker repository is heavily used in the solution. However, it did not appear to function, so I decided to write down my observations below; in the future, I may post the image to Docker Hub.

First we need to install couple of things into the container, like nodejs, yarn, chrome etc.

Dockerfile:

########################################
#Build application and its documentation
########################################

FROM ubuntu:20.04
ARG DEBIAN_FRONTEND=noninteractive
# Set debconf to run non-interactively
# RUN echo 'debconf debconf/frontend select Noninteractive' | debconf-set-selections
#USER root

#
# BASE PACKAGES
#
RUN apt-get -qqy update \
    && apt-get -qqy --no-install-recommends install \
    bzip2 \
    ca-certificates \
    unzip \
    wget \
    curl \
    git \
    jq \
    zip \
    xvfb \
    pulseaudio \
    dbus \
    dbus-x11 \
    upower \
    build-essential && \
    rm -rf /var/lib/apt/lists/* /var/cache/apt/*

#
# NODEJS
#
RUN curl -sL https://deb.nodesource.com/setup_16.x | bash - && \
    apt-get update -qqy && apt-get -qqy install -y nodejs && \
    rm -rf /var/lib/apt/lists/* /var/cache/apt/*

#
# CHROME
#
ARG CHROME_VERSION="google-chrome-stable"
RUN wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add - && \
    echo "deb http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google-chrome.list && \
    apt-get update -qqy && apt-get -qqy install ${CHROME_VERSION:-google-chrome-stable} && \
    rm /etc/apt/sources.list.d/google-chrome.list && \
    rm -rf /var/lib/apt/lists/* /var/cache/apt/* && \
    ln -s /usr/bin/google-chrome /usr/bin/chromium-browser

#
# YARN
#
RUN wget -q -O - https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - && \
    echo "deb https://dl.yarnpkg.com/debian/ stable main" > /etc/apt/sources.list.d/yarn.list && \
    apt-get update -qqy && apt-get -qqy install yarn && \
    rm -rf /var/lib/apt/lists/* /var/cache/apt/*

#
# INSTALL AND CONFIGURE
#
COPY docker-entrypoint.sh /opt/docker-entrypoint.sh
RUN chmod u+rx,g+rx,o+rx,a-w /opt/docker-entrypoint.sh && \
    addgroup --gid 10777 worker && \
    adduser --gecos "" --disabled-login --disabled-password --gid 10777 --uid 10777 worker && \
    mkdir /work/ && \
    mkdir /work-private/ && \
    mkdir /work-bin/ && \
    mkdir /tmp/application/ && \
    mkdir /data/ && \
    mkdir /tmp/.X11-unix && \
    chown -R root:root /tmp/.X11-unix && \
    chmod 1777 /tmp/.X11-unix && \
    chown -R worker:worker /work/ && \
    chmod -R u+rwx,g+rwx,o-rwx /work/ && \
    chown -R worker:worker /work-private/ && \
    chown -R worker:worker /tmp/application/ && \
    chown -R worker:worker /work-bin/ && \
    chown -R worker:worker /data/ && \
    chmod -R u+rwx,g+rwx,o-rwx /work-private/

#
# DBUS
#
COPY dbus-system.conf /work-bin/dbus-system.conf
RUN mkdir /var/run/dbus/ && \
    chown -R worker:worker /var/run/dbus/

# Replace shell with bash so we can source files
#SHELL ["/bin/bash", "--login", "-c"]

# Install base dependencies


#Until package json, or yarn lock are not cached, we can benefit from docker layers caching. No new npm install needed
COPY ./selenium/package.json /tmp/application/


WORKDIR /tmp/application

RUN npm install

#Now we can build application, keep in mind that CRX file is your bundle with extension that is packed by Chrome browser
COPY ./selenium /tmp/application
COPY ./extensions.crx /tmp/application/extensions.crx
ENV EXTENSION_PATH=/tmp/application/extensions.crx
RUN chown -R worker:worker /tmp/application/extensions.crx

RUN npm run build

USER worker

EXPOSE 3000

ENTRYPOINT ["/opt/docker-entrypoint.sh"]
CMD ["node", "dist/main.js"] #This is to be set to your application start command

dbus-system.conf

<!DOCTYPE busconfig PUBLIC "-//freedesktop//DTD D-Bus Bus Configuration 1.0//EN"
 "http://www.freedesktop.org/standards/dbus/1.0/busconfig.dtd">
<busconfig>
  <type>system</type>
  <user>worker</user>
  <fork/>
  <listen>unix:path=/var/run/dbus/system_bus_socket</listen>
  <policy context="default">
    <allow user="*"/>
    <allow own="*"/>
    <allow send_type="method_call"/>
    <allow send_type="signal"/>
    <allow send_requested_reply="true" send_type="method_return"/>
    <allow send_requested_reply="true" send_type="error"/>
    <allow receive_type="method_call"/>
    <allow receive_type="method_return"/>
    <allow receive_type="error"/>
    <allow receive_type="signal"/>
    <allow send_destination="org.freedesktop.DBus"/>
    <allow send_destination="org.freedesktop.UPower"/>
    <allow send_destination="org.PulseAudio1"/>
    <deny send_destination="org.freedesktop.DBus"
          send_interface="org.freedesktop.DBus"
          send_member="UpdateActivationEnvironment"/>
  </policy>
</busconfig>

docker-entrypoint.sh

#!/bin/bash

set -e

umask u+rxw,g+rwx,o-rwx

##
## XVFB
##
#Xvfb :99 -ac -screen 0 1280x1024x16 -nolisten tcp &
#xvfb=$!
export DISPLAY=:99

#
# DBUS
#
eval $(dbus-launch --sh-syntax --config-file=/work-bin/dbus-system.conf)

#
# PULSEAUDIO
#
pulseaudio --daemonize

#
# CHROME
#
export CHROME_BIN="/usr/bin/google-chrome"

#
# EXEC
#
exec "$@"

XVFB needs to expose a port it will allocate for other apps to run, 99 is arbitrary number here. Now we can move to Nodejs part. First install these in your project.

yarn add selenium-webdriver @cypress/xvfb

Next, when you bootstrap your application, you need to launch xvfb. I run a Nestjs app, hence I wrap bootstrap function like this:

import * as Xvfb from '@cypress/xvfb';
const xvfb = new Xvfb({
  displayNum: 99, //Note, that the port is set to 99 (most likely we can extract integer from env variable, I was just in a rush)
});
async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.use(json({ limit: '1000mb' }));
  app.use(urlencoded({ extended: true, limit: '100mb' }));
  await app.listen(3000);
}

xvfb.start((err, xvfbProcess) => {
  // code that uses the virtual frame buffer here
  if (err) {
    console.error(err);
  }
  bootstrap();
});

function exitHandler(options, exitCode) {
  console.log('Stopping XVFB due to app exit');
  xvfb.stop((err) => {
    if (err) {
      console.error(err);
    }
  });
}

//do something when app is closing
process.on('exit', exitHandler.bind(null, { cleanup: true }));

//catches ctrl+c event
process.on('SIGINT', exitHandler.bind(null, { exit: true }));

// catches "kill pid" (for example: nodemon restart)
process.on('SIGUSR1', exitHandler.bind(null, { exit: true }));
process.on('SIGUSR2', exitHandler.bind(null, { exit: true }));

//catches uncaught exceptions
process.on('uncaughtException', exitHandler.bind(null, { exit: true }));

After this, I built docker image with ‘test’ tag and run it with docker-compose like follows:

version: '3.7'
services:
  ###############################################################
  #    Selenium
  ###############################################################
  selenium:
    image: test:latest
    ports:
      - "3000:3000"
    restart: always
    environment:
      DISPLAY: :99
      DEBUG: xvfb,xvfb-process

Running process like this starts selenium chrome browser in headful fachion, with extension running like a charm.  

Adrian Jutrowski

Software Developer