Last week, we shipped an initial version of Puppeteer Recorder, a Google Chrome extension that records your browser interactions and generates a Puppeteer script.
It turns out Chrome extension development is almost like real web development, but with a weird dash of quasi embedded development mixed in.
This post talks you through the development lifecycle when creating an extension and lists some of the architectural gotcha’s. Source code for the extension in question is on github.
Google’s documentation does a fairly ok job of talking you through all the moving parts an extension is made of in their getting started guide. However, the docs mix explaining secondary concerns / aspects like security, packaging, setting icons etc. with the five core architectural components. You would do well to set up your project’s code structure to reflect these five core components, i.e.
So they reflect the components in the extension:
This file bootstraps your extension and provides meta data like versioning. Without this, you have no extension.
The heart and soul of your extension. This is where you create a listener to actually trigger the popup when users click you icon. All “hard” business logic and native browser interaction should go in here as much as possible.
Content scripts can be injected into the tabs in the browser and access the DOM in the context of a browser session. This is where you can add new DOM elements, add extra listeners etc. Note: content scripts are optional
The little app you see when clicking/activating an extension. Can be build with any framework like React or Vue or just vanilla JS. We used Vue.
A dedicated page for customising settings of your extension. This page should persist any settings to the store, to be fetched again by other parts of your plugin.
Meet your new best friend 👫, the chrome
global. You will be spending a lot of time together! Its primary functions are:
Registering listeners & handlers on browser navigation and interface clicks. In the example below you can see an abbreviated version of the start()
function that runs when you click ‘Record’ in the popup UI.
function start () {
// Inject the content script chrome.tabs.executeScript({file: 'content-script.js'})
// add various handlers to events chrome.runtime.onMessage.addListener(MessageHandler) chrome.webNavigation.onCompleted.addListener(NavigationHandler) chrome.webNavigation.onBeforeNavigate.addListener(WaitHandler)
// update the icon chrome.browserAction.setIcon({ path: './images/icon-green.png' }) chrome.browserAction.setBadgeText({ text: badgeState }) chrome.browserAction.setBadgeBackgroundColor({ color: '#FF0000' })}
Anything you need to persists over navigations and opening/closing the popup UI should go into either the session
or sync
store. The sync
store should be synced over Chrome browsers hooked up with Chrome Sync. For more, see State below.
“Globals are bad ’m kay”. For instance, testing anything that uses them can be a hassle. When global are used over multiple, normally loosely coupled, objects things get hairy, quickly. Having said that, the Chrome team did a good job keeping the chrome
global’s interface fairly minimal. Keeping as much of the calls out of your popup UI, where you will probably use a “modern” web framework will keep things sane.
State is persisted using the native chrome.storage
API. No other way around it. This will get/set basically Javascript object you give it, much like the localStorage
API. Wrapping it in some non-global function helps keeping things sane. Be sure to check values returning from the store, e.g:
function loadState (cb) { this.$chrome.storage.local.get(['controls', 'code'], ({ controls, code }) => { console.debug('loaded controls', controls if (controls) { this.isRecording = controls.isRecording this.isPaused = controls.isPaused } if (code) { this.code = code } cb() })}
function storeState () { this.$chrome.storage.local.set({ code: this.code, controls: { isRecording: this.isRecording, isPaused: this.isPaused } })}
State is handled slightly different by each architectural component:
When opening & closing the extension though clicking on its icon in the toolbar, the popup looses all state. You need to write everything to the session store and reload it on opening.
The background scripts acts a bit like a worker thread, as it is not reloaded unless an explicit reload method is called. This means it should (probably) function as you main source of truth
The content script depends completely on page reloads and how/if/when it is injected. In the specific case of Puppeteer Recorder, the content script attaches message handlers to elements in the current page. These handlers send messages of the events we want to record to the background worker.
Yes, 5,242,880 bytes / 5Mb for local and 102,400 / 0.1Mb for sync. There are also restrictions on individual items in the storage. See the docs
State in browser environments is always tricky, not really an extension issue. Points for the effort 🤙
Communication between components is done by sending messages and adding listeners on, you guessed it, the chrome
global.
chrome.runtime.onMessage.removeListener(handleMsg) chrome.runtime.sendMessage(msg) function handleMsg (msg) { console.log(msg) }
This should be really familiar to all JS developers. No frills, just works. Double thumbs up 👍 👍
Except for the chrome
global and its messaging and state function, there are basically no restrictions to the actual code you write. Any Node.js or front end developer should be pretty comfortable.
We used ES6/7 with Vue.js for the popup and used most of the common parts of the Vue.js eco (and larger) system like Webpack, Vue test utils and single file components. See Building for more on this topic.
Reloading an extension has some quirks though, as your content script is bound to a dynamically assigned port which goes away after a full reload. This is expected, but the console.log is bombarded with errors. I found no workaround other than adding a try/catch
block around the connection and discarding the message.
function sendMessage (msg) { console.debug('sending message', msg) try { chrome.runtime.sendMessage(msg) } catch (err) { console.debug('caught err', err) }}
Chrome’s Developer tools are great for debugging your code and this is no different when building an extension. However, you might need as much as four separate Dev Tools windows open to get the full picture: content script, background, popup UI and options UI all run in separate contexts.
Prepare to do some nice window Tetrissing! Of course, technically this makes sense, but the workflow suffers tremendously, especially when you are used to React, Vue or vanilla JS app development and you have a need timeline of all your debug statements etc. in one console.
To be fair, you actually do not need to build anything. You can just write plain JS, package it up in a .zip and you’re done. However, when you are used to tech like Vue.js and ES6/ES7 syntax, you will enter the land of Babel and Webpack:
Getting the build right was a bit tricky as specifically Webpack examples and corresponding versions seem to be deprecated faster than the speed of light.
So, long story short, have peek at the webpack config and corresponding package.json on Github and you can see what works for Puppeteer Recorder.
We use Jest for testing. We don’t go for 100% test coverage and tests are being added as we speak. They fall into three categories:
For 1 and 2, there is no specific magic except that you have to…
chrome
globalits methods. You can go crazy here, but we managed (for now) to get by with a fairly simple mock. The (edited) example below shows a Vue component being mounted, injected with a mock and asserted. The actual test live at App.spec.js on Github.
import { mount } from '@vue/test-utils'import App from '../App'
const chrome = { storage: { local: { get: jest.fn() } }, extension: { connect: jest.fn() } }
const mocks = { $chrome: chrome }
describe('App.vue', () => { test('it has the correct pristine / empty state', () => { const wrapper = mount(App, { mocks }) expect(wrapper.element).toMatchSnapshot() }) })
In the end you need to deliver a zip file with code to Google to publish on the web store. You probably want to know that that distributable “binary” actually installs. You can test this with Puppeteer. The below example shows how we build the code and install it as an extension in a Chrome instance.
When using Jest, be sure to run these test cases sequentially by using the --runInBand
flag.
import puppeteer from 'puppeteer'import path from 'path'import { scripts } from '../../package.json'const util = require('util') const exec = util.promisify(require('child_process').exec)
const extensionPath = path.join(__dirname, '../../dist')
describe('build & install', () => { // Calls the standard 'npm dist' script used to build thedistributable
test('it builds the extension', async () => { const { stderr } = await exec(scripts.dist) expect(stderr).toBeFalsy() }, 15000) // boots a Chrome instance using Puppeteer and adds the extension we build in the earlier test
test('it installs the extension', async () => { const options = { headless: false, ignoreHTTPSErrors: true, args: [ `--disable-extensions-except=${extensionPath}`, `--load-extension=${extensionPath}`, '--no-sandbox', '--disable-setuid-sandbox' ] } const browser = await puppeteer.launch(options) expect(browser).toBeTruthy() await browser.close() }, 5000)})
Getting your extension on the web store is a three part process.
Your extension needs to be uploaded to Google as zip. You can have your build tool create a zip file for you. We “stole” this script from Kocal’s vue-web-extension repo, check it out in the scripts directory. Kudos to Kocal and Kudos to Google for keeping the distribution format as simple as a zip 👌.
Set up a developer account on the web store at https://chrome.google.com/webstore/developer/dashboard
Note: before publishing, you have to pay a $5 one time fee. Ok, whatever, seems fair.
Don’t skimp this! Het oog wil ook wat we say in Dutch, which translates to “Don’t leave the eye wanting!”. Kudos to Google for giving you many branding and promotion options: icons, promotional images in various sizes, a Youtube link to a demo etc.
Originally published at checklyhq.com.
P.S. If you liked this article, please show your appreciation by clapping 👏 below and follow me on Twitter! But wait, there’s more!