Using Turbo and Rails to develop a browser extension
This article will cover how to develop a browser extension in Chrome while employing Rails to do almost all of the heavy lifting. At Clinch Talent, we try to minimise Javascript and maximise Ruby and HTML. Through careful usage of Hotwire, we now have less than 2% javascript in our code base and this number continues to drop over time.
Below, we will demonstrate how to
- Minimise handwritten client side Javascript and maximise server side Rails code
- Test the browser extension with Capybara
- Prepare the browser extension for shipping
While this article uses Rails as the server side framework, the same technique should in theory work with any framework that supports Turbo.
Iteration #1: Hello browser extension
Make a new Rails application
rails new browser_extension
cd browser_extension
mkdir client
The following files are needed to make the basic browser extension
mkdir -p client/html
mkdir -p client/js
mkdir -p client/css
touch client/manifest.json
touch client/html/popup.html
touch client/js/popup.js
touch client/css/popup.css
wget https://cdn.jsdelivr.net/npm/@hotwired/turbo@7.2.0/dist/turbo.es2017-esm.js -O client/js/turbo.js
// manifest.json
{
"name": "Browser Extension",
"description": "A Turbo powered browser extension",
"version": "0.1",
"manifest_version": 3,
"host_permissions": ["http://localhost:3000/*", "http://127.0.0.1/*"],
"action": {
"default_popup": "html/popup.html"
}
}
Note the host permissions have been set to the url of the Rails development server, and the url of the test server. If this is causing problems, just set the host_permissions to ["http://*/*"]
and deal with it later
//popup.js
import './turbo.js'
//Any other client side javascript here
<!-- popup.html -->
<html>
<head>
<script src="../js/popup.js" type="module"></script>
<script src="../css/popup.css" type="module"></script>
</head>
<body>
<turbo-frame id="content" src="http://localhost:3000/candidates">
Hello from client
</turbo-frame>
</body>
</html>
/* css/popup.css */
body {
width: 20rem;
height: 20rem;
}
input {
margin-bottom: 1rem;
}
Open the browser extension
- Visit
chrome://extensions
- Enable developer mode
- Click
Load unpacked
and select the location of your browser extension
To refresh changes, click the reload button on the Chrome extensions page.
Observations:
- Opening the Chrome Web inspector is annoying as it will disappear when the popup closes
- If you close and open the popup, the popup resets
- We have no tests
Iteration #2: Hello Turbo
Controllers, routes, and views
Scaffold up a Rails application and make a controller with an index action. Since Clinch Talent is in the business of Recruitment, we’re going to use Candidate as the resource.
rails g controller candidates index
rake db:create
rails s
Add the routes
# routes.rb
resources :candidates
Set the layout
We don’t need a layout file served from the server, because the browser extension client code will act as the layout
# app/controllers/application_controller.rb
class ApplicationController
layout false # just send back turbo frames
end
Define the controller action and view
# app/controllers/candidates_controller.rb
class CandidatesController < ApplicationController
def index
end
end
<!-- index.html.erb -->
<%= turbo_frame_tag 'content' do %>
Hello from Rails
<% end %>
What just happened?
On load of the browser extension, the Turbo library running in the browser located a <turbo-frame id="content">
tag, and examined the src
attribute.
The src
attribute points to a Rails server url, and Turbo called fetch
on this url.
The Rails server responded with html containing a <turbo-frame>
with the matching id content
and Turbo replaced the contents of the client with the server turbo-frame
html.
A quicker feedback loop
Observe the updates to your browser extension by opening the popup. One click of the mouse is enough to get a tight feedback loop. There is now no need to reload the browser extension from the Chrome extensions page. Experiment by changing the message inside the turbo-frame and then reopening the popup window.
Iteration #3: Show some candidate data
Set up the models
Create an ActiveRecord model called Candidate which has a name and email address. Add some sample records.
rails g model candidate name:string email:string
rake db:migrate
rails runner 'Candidate.create!(name: "Bill", email: "bill@example.com") ; Candidate.create!(name: "Tom", email: "tom@example.com")'
Fetch the candidates
def index
@candidates = Candidate.order(created_at: :desc)
end
Render the candidates inside the turbo frame
# candidates#index.html.erb
<%= turbo_frame_tag 'content' do %>
<table>
<tbody>
<% @candidates.each do |candidate| %>
<tr>
<td><%= candidate.name %></td>
<td><%= candidate.email %></td>
</tr>
<% end %>
</tbody>
</table>
<% end %>
Iteration #4: Submit some data
Add new controller actions
# candidates_controller.rb
class CandidatesController < ApplicationRecord
def index
@candidates = Candidate.order(created_at: :desc)
end
def new
@candidate = Candidate.new
end
def create
@candidate = Candidate.create!(params.require(:candidate).permit(:name, :email))
redirect_to candidates_path
end
end
Create new view
Make sure to use the absolute url for any links as the browser extension client code will not intrinsically know about the location of the server side code.
<!-- candidates#new.html.erb -->
<turbo-frame id="content">
<%= form_with(model: @candidate, url: candidates_url) do |f| %>
<div>
<%= f.label :name %>
<%= f.text_field :name %>
</div>
<div>
<%= f.label :email %>
<%= f.email :email %>
</div>
<div>
<%= f.submit 'Save' %>
</div>
<% end %>
</turbo-frame>
Add a link to the new candidate form
<!-- candidates#index.html.erb -->
<%= turbo_frame_tag 'content' do %>
<p><%= link_to "Add candidate", new_candidate_url %></p>
<table>
<tbody>
<% @candidates.each do |candidate| %>
....
<% end %>
Configure the turbo-root value in the browser client popup.html file
Warning: Without this, Turbo POST requests will not work
<!-- client/html/popup.html -->
<html>
<head>
<meta name="turbo-root" value="http://localhost:3000">
Disable the forgery protection origin check
# application.rb
module BrowserExtension
class Application < Rails::Application
#....
config.action_controller.forgery_protection_origin_check = false
end
Recap
We have built a browser extension and added support for
- viewing a table of candidates
- viewing the candidate form
- saving the candidate
- redirecting urls
This has been achieved with minimal change to the popup.html, and no handcrafted javascript
Controller testing
Rails controller tests are a cheap and reliable way to test the browser extension. While they won’t test anything in the client itself, they will test the majority of the browser extension.
require "test_helper"
class CandidatesControllerTest < ActionDispatch::IntegrationTest
test 'index' do
get candidates_url
assert_response :success
assert_select 'turbo-frame#content' do
assert_select "a[href='http://www.example.com/candidates/new']"
assert_select 'table'
assert_select 'tr', count: 2
assert_select 'tr:nth-child(1) td:nth-child(1)', 'Bill'
assert_select 'tr:nth-child(1) td:nth-child(2)', 'bill@example.com'
assert_select 'tr:nth-child(2) td:nth-child(1)', 'Tom'
assert_select 'tr:nth-child(2) td:nth-child(2)', 'tom@example.com'
end
end
test 'new' do
get new_candidate_url
assert_response :success
assert_select 'turbo-frame#content' do
assert_select "form[action='http://www.example.com/candidates']" do
assert_select "input[type='text'][name='candidate[name]']"
assert_select "input[type='email'][name='candidate[email]']"
assert_select "input[type='submit']"
end
end
end
test 'create' do
post candidates_url, params: {candidate: {name: 'Bill', email: 'bill@example.com'}}
assert_redirected_to candidates_url
end
end
Browser testing
To test the browser extension client code, use Capybara.
Modify the Capybara set up to open the browser extension
Chrome will install the unpacked browser extension via the command line argument --load-extension=<path/to/browser/extension>
. By adding this to the Capybara driven_by
options, we can make the browser extension client code available to Chrome.
class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
driven_by :selenium, using: :chrome, screen_size: [1400, 1400] do |driver_option|
driver_option.add_argument("--load-extension=#{Rails.root.join('client')}")
end
end
Create a capybara file
After the Capybara browser has been opened with the test extension, the chrome:://extensions
page is visited and developer mode is enabled.
By visiting chrome-extensions://<extension-id>/html/popup.html
, the browser extension client code is available to the test.
However, the client is still fetching all of its turbo frames from http://localhost:3000
. To fix that replace the <meta name="turbo-root"
and the content
turbo frame src
attribute with the url of the test. This test url changes with each test: http://#{Capybara.current_session.server.host}:#{Capybara.current_session.server.port}
class BrowserExtensionTest < ApplicationSystemTestCase
test 'add a candidate' do
visit 'chrome://extensions'
enable_developer_mode
visit "chrome-extension://#{find_chrome_extension_id}/html/popup.html"
# now check the table
assert_selector 'tr:nth-child(1)', text: /Bill/
assert_selector 'tr:nth-child(2)', text: /Tom/
# add a candidate
click_link 'Add candidate'
fill_in 'candidate_name', with: 'Tim Flimpson'
fill_in 'candidate_email', with: 'tim@example.com'
click_button 'Save'
# check the save redirect worked
assert_selector 'tr:nth-child(1)', text: /Bill/
assert_selector 'tr:nth-child(2)', text: /Tom/
assert_selector 'tr:nth-child(3)', text: /Tim Flimpson/
end
private
def enable_developer_mode
execute_script "document.querySelector('extensions-manager').shadowRoot.querySelector('extensions-toolbar').shadowRoot.querySelector('#devMode').click()"
end
def find_chrome_extension_id
execute_script("return document.querySelector('extensions-manager').shadowRoot.querySelector('#items-list').shadowRoot.querySelector('.items-container extensions-item').id")
end
def replace_base_url
test_url = "http://#{Capybara.current_session.server.host}:#{Capybara.current_session.server.port}"
execute_script "document.querySelector('meta[name=\"turbo-root\"]').value = '#{test_url}'"
execute_script "const content = document.querySelector('#content'); content.src = content.src.replace('http://localhost:3000', '#{test_url}')"
end
end
Shipping
Shipping the browser extension involves:
- Replacing references to the development server url
http://localhost:3000
with the production url. In this case it will behttps://acme.example
- Making a CRX file using the unpackaged folder and a certificate
Prepare a rake task that will
- Copy the client files to a build folder
- Replace references to
http://localhost:3000
withhttps://acme.example
# lib/tasks/browser_extension.rake
#
# Note: this rake task uses the jq command line utility
desc "Build the browser extension"
namespace :browser_extension do
localhost_url = 'http://localhost:3000'
production_url = 'https://acme.example'
task :build_production do
`mkdir -p build/production`
`cp -r client\/ build/production`
`sed -i '' "s|#{localhost_url}|#{production_url}|g" build/production/html/popup.html`
`cat client/manifest.json | jq '.host_permissions=(["#{production_url}"])' > build/production/manifest.json`
end
end
Creating the CRX file manually
- Visit
chrome://extensions
and clickPack extension
. - Point the
extension root directory
at thebuild
folder - Fill out the private key
- Click
pack extension
Conclusion
Using Turbo to develop the Clinch Talent browser extensions was an enormous time saver for us. We found improvements in:
- Development pace. It is cheaper to develop a browser extension that sends turbo frames back and forth between the browser extension and the server, than to manipulate the dom with javascript, which is the method most encouraged.
- Testing: By focusing on server side generated HTML, we can spend most of our testing effort in Rails controller tests, and minimise Capybara tests, which are expensive to run and more prone to random failures.
- Updates: If most of the code is coming from the server side, then most updates can come without the involvement of the Chrome Web Store review process.