Last week, we shipped an initial version of , a Google Chrome extension that records your browser interactions and generates a Puppeteer script. Puppeteer Recorder It turns out Chrome extension development is like real web development, but with a weird dash of quasi embedded development mixed in. almost 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 Architecture Google’s documentation does a fairly ok job of talking you through all the moving parts an extension is made of in their . 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. getting started guide So they reflect the components in the extension: manifest.json This file bootstraps your extension and provides meta data like versioning. Without this, you have no extension. background scripts 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 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 popup UI 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. options page UI 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. The chrome global Meet your new best friend 👫, the global. You will be spending a lot of time together! Its primary functions are: chrome on browser navigation and interface clicks. In the example below you can see an abbreviated version of the function that runs when you click ‘Record’ in the popup UI. Registering listeners & handlers start() 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 or store. The store should be synced over Chrome browsers hooked up with Chrome Sync. For more, see below. session sync sync State “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 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. chrome State State is persisted using the native API. No other way around it. This will get/set basically Javascript object you give it, much like the API. Wrapping it in some non-global function helps keeping things sane. Be sure to check values returning from the store, e.g: chrome.storage localStorage 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 script’s state does persist 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’s state depends on many things 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. Storage has pretty tight limits 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 🤙 Messaging Communication between components is done by sending messages and adding listeners on, you guessed it, the global. chrome 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 👍 👍 Coding Except for the 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. chrome 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 for more on this topic. Building 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 block around the connection and discarding the message. Reloading an extension has some quirks try/catch function sendMessage (msg) { console.debug('sending message', msg) try { chrome.runtime.sendMessage(msg) } catch (err) { console.debug('caught err', err) }} Debugging 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. use the current browser windows’ Developer Tools. content scripts uses a custom Developer tools, accessible from your extensions page. background are opened by right-clicking in the UI of the popup / options window and clicking popup and options Inspect 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. Building To be fair, you actually do not 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: need Transpiling ESx Vue.js single file components SASS/SCSS compilation 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 and corresponding on Github and you can see what works for Puppeteer Recorder. webpack config package.json Testing We use for testing. We don’t go for 100% test coverage and tests are being added as we speak. They fall into three categories: Jest Unit tests at the module or function level UI tests rendering Vue.js components and using Jest to validate correctness snapshots End 2 end tests that build and install the extension. For 1 and 2, there is no specific magic except that you have to… Mock out calls to the global chrome its 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 on Github. App.spec.js 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() }) }) Check build & install with Puppeteer 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 flag. --runInBand 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)}) Distributing Getting your extension on the web store is a three part process. 1. Package your code into a zip file 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 repo, check it out in the . Kudos to Kocal and Kudos to Google for keeping the distribution format as simple as a zip 👌. Kocal’s vue-web-extension scripts directory 2. Create a Chrome Web Store Developer account 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. 3. Provide images and a video Don’t skimp this! 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. Het oog wil ook wat Originally published at checklyhq.com . P.S. If you liked this article, please show your appreciation by 👏 below and But wait, there’s more! clapping follow me on Twitter !