Automating BrowserStack screenshot generation with CircleCI
Automating BrowserStack screenshot generation with CircleCI
The brief
As part of our continuous integration strategy using CircleCI, we decided to automate our cross-browser testing as part of our CircleCI build workflow. With so much of our traffic — over 90% — coming from mobile devices, BrowserStack’s cloud-based mobile browser testing service was ideal for our needs. So we planned to automatically trigger generation of screenshots from various devices using the
The process
There are four parts to the process:
1. When a commit to a branch is pushed, the CircleCI workflow is triggered, which builds the branch first, then triggers the script to generate the screenshots based on that branch.
2. The script makes a POST request to
3. Once that job is running, the script makes a GET request to `https://www.browserstack.com/screenshots/{job_id}.json`, which returns another JSON object containing URLs to the generated screenshots.
4. Finally, the script downloads the images from those URLs into a CircleCI artifacts directory, for us to view once this stage of the workflow is completed.
The code
Dependencies
We’re only using two dependencies for this script: `fs` for piping the generated images into the artifacts directory, and `isomorphic-fetch` to handle our HTTP requests.
const fs = require(‘fs’)
const fetch = require(‘isomorphic-fetch’)
Step 1: Start the job and get its ID
const branchName = process.env.CIRCLE_BRANCH === ‘master’ ? ‘staging’ : process.env.CIRCLE_BRANCH;
const username = process.env.BSTACK_USERNAME;
const accessKey = process.env.BSTACK_ACCESS_KEY;
const branchUrl = `http://honeybee-${branchName}.ladbible.com`;
const basicAuth = Buffer.from(`${username}:${accessKey}`).toString(‘base64’);
const browserList = [ … ]
const getShots = async () => {
if (!branchUrl) {
throw new Error(‘No URL set for this branch’);
}
const response = await fetch(‘https://www.browserstack.com/screenshots', {
method: ‘POST’,
body: JSON.stringify({
url: branchUrl,
wait_time: 5,
orientation: ‘portrait’,
browsers: browserList
}),
headers: {
‘Content-Type’: ‘application/json’,
Accept: ‘application/json’,
Authorization: `Basic ${basicAuth}`
}
});
const screenShots = await response.json()
if (screenShots.message === ‘Parallel limit reached’) {
throw new Error(‘Parallel limit reached! Try again later!’);
}
const jobID = screenShots.job_id;
}
Using the `isomorphic-fetch` library and ES6 `async/await` syntax, our POST request is fairly straightforward. `fetch()` takes two arguments: the URL we’re posting to (`https://www.browserstack.com/screenshots`), and an object with `method`, `body` and `headers` attributes. By default, `method` is set to the string `GET`, which is why we’ve needed to set it to `POST` here. `headers` is another object which tells the server what type of data to expect from us, what type of data we expect to receive back, and our authorisation credentials.
Our `body` in this request is a stringified object, containing some parameters for the screenshots we want BrowserStack to generate. The `browserList` array that the `browsers` property refers to will be an array of JSON objects that each refers to a particular device/browser combination. The BrowserStack Screenshots API docs can tell you how to grab a full list of their available devices.
Depending on your BrowerStack plan, you’ll be limited as to how many parallel tests you can run simultaneously. If you’re trying to exceed that limit, the response you’ll receive from your POST request will only contain a message warning you of this.
At the end of this function, we grab the `job_id` value from the POST request’s response, and assign it to `const jobID`, ready for use in step 2.
Step 2: Generate the screenshots
This is the most involved step, so let’s break it down into smaller chunks.
2.1: the GET request
Once again, the `fetch()` function makes this nice and straightforward:
const getJob = async (id) => {
const jobResponse = await fetch(`https://www.browserstack.com/screenshots/${id}.json`, {
headers: {
‘Content-Type’: ‘application/json’,
Accept: ‘application/json’,
Authorization: `Basic ${basicAuth}`
}
});
return jobResponse.json();
};
Note that we don’t need to set a `method` here; it’s `GET` by default. We also don’t need a `body` for a `GET` request, so this time we only need to send the same `headers` as we did in our `POST` request.
2.2: monitoring the job’s progress
By now, BrowserStack will have started a job on their end that generates our screenshots. Our `getJob()` function will return an object that tells us the `state` of this job. But this job takes a while to finish, and we can’t move past step 2 until `state: ‘done’`. So we need to keep calling this function, or polling it, until we know that BrowserStack’s job is complete. So we need a `pollJob()` function:
const pollJob = async () => {
const job = await getJob(jobID);
if (job.state !== ‘done’) {
return await setTimeout(async () => await pollJob(), 3000);
}
}
`pollJob()` recursively calls `getJob()` until `job.state: ‘done’`. However, as it stands, this doesn’t tell us how the BrowserStack job is progressing, or any errors that might be cropping up. So let’s extend `pollJob()` to include some logging:
const pollJob = async () => {
const job = await getJob(jobID)
const awaitingDevices = job.screenshots.filter(x => x.state !== ‘done’).map(x => x.device);
if (job.state !== ‘done’) {
console.log(`\nAwaiting ${awaitingDevices.length} devices:\n${awaitingDevices}`);
return await setTimeout(async () => await pollJob(), 3000)
}
}
Now, every time the BrowserStack job is polled, we get a `console.log` of how many screenshots we’re waiting on, and what devices the pending screenshots are of. The recursion is wrapped in a `setTimeout()` so that we’re not hammering the job with requests and running the risk of a `FetchError`. Of course, this might be *too* verbose for your needs, so go ahead and tweak the logs and timings to suit you.
Once `job.state === ‘done’`, `const job` is going to be an object containing all the URLs of the screenshot images BrowserStack has so kindly generated for us. Time to catch ’em all.
Step 3: Download the images into CircleCI artifacts
const writeImages = async (imageUrl, filename) => {
const res = await fetch(imageUrl);
await new Promise((resolve, reject) => {
const fileStream = fs.createWriteStream(`/tmp/screenshots/${filename}`);
res.body.pipe(fileStream);
res.body.on(‘error’, (err) => {
reject(err);
throw new Error(‘ERROR! writeImages failed!’, err);
});
fileStream.on(‘finish’, () => {
resolve();
});
});
};
const downloadImages = async (screenshots) => {
const downloads = await screenshots.forEach(async (shot) => {
if (shot.state === ‘done’) {
const urlParts = shot.image_url.split(‘/’);
const filename = urlParts[urlParts.length — 1];
await writeImages(shot.image_url, filename);
console.log(`\nDownloaded ${shot.device}: /tmp/screenshots/${filename}`);
} else {
console.log(`\nScreenshot timed out for ${shot.device}`);
}
});
return downloads;
};
Extend out `pollJob()` and add an `await` for it as the final line in the `getShots` function:
const pollJob = async () => {
const job = await getJob(jobID);
const awaitingDevices = job.screenshots.filter(x => x.state !== ‘done’).map(x => x.device);
if (job.state !== ‘done’) {
console.log(`\nAwaiting ${awaitingDevices.length} devices:\n${awaitingDevices}`);
return await setTimeout(async () => await pollJob(), 3000);
}
await downloadImages(job.screenshots);
};
await pollJob();
So your entire file will look like:
const fs = require(‘fs’);
const fetch = require(‘isomorphic-fetch’);
const branchName = process.env.CIRCLE_BRANCH === ‘master’ ? ‘staging’ : process.env.CIRCLE_BRANCH;
const username = process.env.BSTACK_USERNAME;
const accessKey = process.env.BSTACK_ACCESS_KEY;
const branchUrl = `http://honeybee-${branchName}.ladbible.com`;
const basicAuth = Buffer.from(`${username}:${accessKey}`).toString(‘base64’);
const browserList = [ … ];
const getShots = async () => {
if (!branchUrl) {
throw new Error(‘No URL set for this branch’);
}
const response = await fetch(‘https://www.browserstack.com/screenshots', {
method: ‘POST’,
body: JSON.stringify({
url: branchUrl,
wait_time: 5,
orientation: ‘portrait’,
browsers: browserList
}),
headers: {
‘Content-Type’: ‘application/json’,
Accept: ‘application/json’,
Authorization: `Basic ${basicAuth}`
}
});
const screenShots = await response.json();
if (screenShots.message === ‘Parallel limit reached’) {
throw new Error(‘Parallel limit reached! Try again later!’);
}
const jobID = screenShots.job_id;
const getJob = async (id) => {
const jobResponse = await fetch(`https://www.browserstack.com/screenshots/${id}.json`, {
headers: {
‘Content-Type’: ‘application/json’,
Accept: ‘application/json’,
Authorization: `Basic ${basicAuth}`
}
});
return jobResponse.json();
};
const writeImages = async (imageUrl, filename) => {
const res = await fetch(imageUrl);
await new Promise((resolve, reject) => {
const fileStream = fs.createWriteStream(`/tmp/screenshots/${filename}`);
res.body.pipe(fileStream);
res.body.on(‘error’, (err) => {
reject(err);
throw new Error(‘ERROR! writeImages failed!’, err);
});
fileStream.on(‘finish’, () => {
resolve();
});
});
};
const downloadImages = async (screenshots) => {
const downloads = await screenshots.forEach(async (shot) => {
if (shot.state === ‘done’) {
const urlParts = shot.image_url.split(‘/’);
const filename = urlParts[urlParts.length — 1];
await writeImages(shot.image_url, filename);
console.log(`\nDownloaded ${shot.device}: /tmp/screenshots/${filename}`);
} else {
console.log(`\nScreenshot timed out for ${shot.device}`);
}
});
return downloads;
};
const pollJob = async () => {
const job = await getJob(jobID);
const awaitingDevices = job.screenshots.filter(x => x.state !== ‘done’).map(x => x.device);
if (job.state !== ‘done’) {
console.log(`\nAwaiting ${awaitingDevices.length} devices:\n${awaitingDevices}`);
return await setTimeout(async () => await pollJob(), 3000);
}
await downloadImages(job.screenshots);
};
await pollJob();
};
getShots().catch((err) => {
console.log(err);
});
Step 4: Automate the script in the CircleCI workflow
Time to add the complete script to our npm scripts in `package.json`:
“build-screenshots”: “node path/to/build-screenshots.js”,
Finally, to ensure that this script runs on every build, we added a `browserstack-screenshots` job to our CircleCI `config.yml`…
browserstack-screenshots:
executor: build
steps:
- setup
- run:
name: yarn
command: yarn install
- run:
name: build screenshots
command: |
mkdir /tmp/screenshots
yarn run build-screenshots
- store_artifacts:
path: /tmp/screenshots
…and inserted it into our workflow after deploying the branch.
Future implementations
With this up and running as part of our CI process, we’re now thinking of ways to extend this functionality to be even more useful to us. Our ideas include posting the screenshots back to the pull request page in Github, or even sending them into our Slack channel once the build process completes.
Having an automated cross-browser testing tool is a huge help to our development process here at LADbible, and we hope we’ll have more to share with you soon!