selinimum: run the minimum selenium necessary for your commit

What is this?
=============

Selinimum speeds up selenium by only running the specs it needs to for
your commit (iff it can safely determine that). For the purposes of the
initial implementation, that means that if your commit ONLY affects:

1. controllers (but not ApplicationController)
2. views (but not shared / layouts)
3. misc whitelisted stuff (images, .md files, etc.)

then it will only run the selenium specs that actually exercise those.
If your commit touches ANYTHING else (models, lib, etc.), all selenium
specs will run.

But wait, there's more!
=======================

Very soon selinimum will also handle:

1. js/coffee/jsx/hbs (except in the common bundle)
2. scss (except in the common bundle)

We already capture which bundles get used by each spec (see Capture), we
just need to correlate that with the individual files via a dependency
graph (probably using madge and sass-graph)

How does it work?
=================

The new post-merge selenium build will run all the specs with selinimum
capturing enabled. This records any controllers/views/js_bundle/css_bundle
that gets used in the course of each selenium spec, and stores a bunch of
data in S3.

Then when your patchset build runs, it will run Selinimum.minimize (via
corresponding tweak in rspect repo) on the list of spec files. If your
commit's dependents can be fully inferred and synthesized with the spec
dependency data from S3, only the relevant specs will actually be run.

Test Plan
=========

This commit doesn't actually cause selinimum to run on jenkins; that
requires some rspect changes and jenkins config. Refer to the test plan
here: https://gerrit.instructure.com/#/c/58088/

Change-Id: I991574c327a3a580c6fdc3ca3797dcfe0490a096
Reviewed-on: https://gerrit.instructure.com/58085
Tested-by: Jenkins
Reviewed-by: Simon Williams <simon@instructure.com>
Product-Review: Jon Jensen <jon@instructure.com>
QA-Review: Jon Jensen <jon@instructure.com>
This commit is contained in:
Jon Jensen 2015-07-09 13:00:50 -06:00
parent 2ee4731017
commit 4a83f6b6a2
20 changed files with 786 additions and 0 deletions

View File

@ -28,6 +28,7 @@ group :test do
gem 'selenium-webdriver', '2.46.2'
gem 'childprocess', '0.5.0', require: false
gem 'websocket', '1.0.7', require: false
gem 'selinimum', '0.0.1', require: false, path: 'gems/selinimum'
gem 'test_after_commit', '0.4.0'
gem 'test-unit', '~> 3.0', require: false, platform: :ruby_22
gem 'webmock', '1.16.1', require: false

6
gems/selinimum/Gemfile Normal file
View File

@ -0,0 +1,6 @@
source "https://rubygems.org"
gemspec
gem "aws-sdk", "1.63.0" # old cuz canvas
gem "rspec"

60
gems/selinimum/bin/selinimize Executable file
View File

@ -0,0 +1,60 @@
#!/usr/bin/env ruby
require "getoptlong"
require_relative "../lib/selinimum"
opts = GetoptLong.new(
[ "--help", "-h", GetoptLong::NO_ARGUMENT ],
[ "--sha", "-s", GetoptLong::REQUIRED_ARGUMENT ],
[ "--json-path", "-j", GetoptLong::REQUIRED_ARGUMENT ]
)
sha = nil
json_path = nil
opts.each do |opt, arg|
case opt
when "--sha"
sha = arg
when "--json-path"
json_path = arg
when "--help"
puts <<-HELP
selinimize [-h] [-s sha [-j json-path]] file ...
outputs a selinimized file list (or the original list, if selinimization
can't be done)
-h, --help:
show help
-s, --sha <SHA>:
defaults to the nearest ancestor commit that has a post-merge selenium
run with captured spec dependency data. file arguments will be
selenimized relative to <SHA>; any changes between <SHA>..HEAD will be
cross referenced with <SHA>'s spec dependency data to see if
selinimization is possible (and to what extent)
-j, --json-path <PATH>:
load spec dependency json files from this path instead of fetching
from S3; useful for local testing, ignored if -s is not set
HELP
exit
end
end
def fail(error)
$stderr.puts("ERROR: #{error}")
$stderr.puts("usage: selinimize [options] file ...")
exit 1
end
spec_files = ARGV
fail "no files specified" if spec_files.empty?
begin
spec_files = Selinimum.minimize!(spec_files, {sha: sha, json_path: json_path, verbose: true})
rescue Selinimum::SelinimumError
fail $!
end
puts spec_files.join(" ")

View File

@ -0,0 +1,58 @@
module Selinimum
class SelinimumError < StandardError; end
class UnknownDependenciesError < SelinimumError; end
class TooManyDependenciesError < SelinimumError; end
def self.minimize!(spec_files, options = {})
sha = options.delete(:sha)
json_path = options.delete(:json_path)
unless sha && json_path
stats = Selinimum::StatStore.fetch_stats(sha) || raise(SelinimumError, "no stats available")
sha = stats[:sha]
json_path = stats[:json_path]
end
log("selinimizing against #{sha}") if options[:verbose]
commit_files = Selinimum::Git.change_list(sha) ||
raise(SelinimumError, "invalid sha `#{sha}'")
log("commit files: \n #{commit_files.join("\n ")}") if options[:verbose]
dependency_map = Selinimum::StatStore.load_stats(json_path) ||
raise(SelinimumError, "can't load stats from `#{json_path}'")
minimizer = Selinimum::Minimizer.new(dependency_map, detectors, options)
minimizer.filter(commit_files, spec_files)
end
def self.log(message)
$stderr.puts message
end
def self.detectors
[
Selinimum::Detectors::RubyDetector.new,
Selinimum::Detectors::WhitelistDetector.new
# TODO: JSDetector et al once they work
]
end
def self.minimize(spec_files, options = {})
minimize! spec_files, options
rescue SelinimumError => e
$stderr.puts "SELINIMUM: #{e}, testing all the things :("
spec_files
rescue => e
$stderr.puts "SELINIMUM: unexpected error, testing all the things :("
$stderr.puts e.to_s
$stderr.puts e.backtrace.join("\n")
spec_files
end
end
require_relative "selinimum/capture"
require_relative "selinimum/git"
require_relative "selinimum/minimizer"
require_relative "selinimum/stat_store"
require_relative "selinimum/detectors"

View File

@ -0,0 +1,92 @@
module Selinimum
class Capture
# hooks so we know which templates are rendered in each selenium spec
module TemplateExtensions
def render_with_selinimum(template, *args, &block)
Selinimum::Capture.log_template_render inspect
render_without_selinimum(template, *args, &block)
end
def self.included(klass)
klass.alias_method_chain :render, :selinimum
end
end
# hooks so we know which controllers, js and css are used in each
# selenium spec
module ControllerExtensions
def render_with_selinimum(*args)
Selinimum::Capture.log_render self.class
render_without_selinimum(*args)
end
def css_bundle_with_selinimum(*args)
Selinimum::Capture.log_bundle :css, *args
css_bundle_without_selinimum(*args)
end
def js_bundle_with_selinimum(*args)
Selinimum::Capture.log_bundle :js, *args
js_bundle_without_selinimum(*args)
end
def self.included(klass)
klass.alias_method_chain :render, :selinimum
klass.alias_method_chain :css_bundle, :selinimum
klass.alias_method_chain :js_bundle, :selinimum
end
end
class << self
def install!
ActionView::Template.send :include, TemplateExtensions
ApplicationController.send :include, ControllerExtensions
end
def dependencies
@dependencies ||= Hash.new { |h, k| h[k] = Set.new }
end
attr_reader :current_example
def current_example=(example)
@current_file = nil
@current_example = example
end
def current_file
@current_file ||= current_example.metadata[:example_group][:file_path].sub(/\A\.\//, '')
end
def report!(batch_name)
data = Hash[dependencies.map { |k, v| [k, v.to_a] }].to_json
StatStore.save_stats(data, batch_name)
end
def finalize!
StatStore.finalize!
end
def log_render(controller)
classes = controller.ancestors - controller.included_modules
classes = classes.take_while { |klass| klass < ApplicationController }
classes.each do |klass|
dependencies[current_file] << "file:app/controllers/#{klass.name.underscore}.rb"
end
end
def log_template_render(file)
unless file =~ Selinimum::Detectors::RubyDetector::GLOBAL_FILES
dependencies[current_file] << "file:#{file}"
end
end
def log_bundle(type, *args)
args.each do |bundle|
dependencies[current_file] << "#{type}:#{bundle}"
end
end
end
end
end

View File

@ -0,0 +1,3 @@
require_relative "detectors/whitelist_detector"
require_relative "detectors/ruby_detector"
require_relative "detectors/js_detector"

View File

@ -0,0 +1,9 @@
module Selinimum
module Detectors
class GenericDetector
def dependents_for(*)
[]
end
end
end
end

View File

@ -0,0 +1,107 @@
require_relative "generic_detector"
module Selinimum
module Detectors
class JsDetector < GenericDetector
def can_process?(file)
file =~ %r{\Apublic/javascripts/.*\.js\z}
end
def dependents_for(file, type = :js)
bundles_for(file).map { |bundle| "#{type}:#{bundle}" }
end
def bundles_for(file)
mod = module_from(file)
bundles = find_js_bundles(mod)
raise UnknownDependenciesError, file if bundles.empty?
raise TooManyDependenciesError, file if bundles.include?("common")
bundles
end
def module_from(file)
file.sub(%r{\Apublic/javascripts/(.*?)\.js}, "\\1")
end
def find_js_bundles(mod)
RequireJSLite.find_bundles_for(mod).map do |bundle|
bundle.sub(%r{\Aapp/coffeescripts/bundles/(.*).coffee\z}, "\\1")
end
end
def format_route_dependencies(routes)
routes.map { |route| "route:#{route}" }
end
end
class CSSDetector < JsDetector
def can_process?(file)
file =~ %r{\Aapp/stylesheets/.*css\z}
end
def dependents_for(file)
super file, :css
end
def bundles_for(file)
if file =~ %r{/jst/}
file = file.sub("stylesheets", "views").sub(".scss", ".handlebars")
return super file
end
bundles = find_css_bundles(file)
raise TooManyDependenciesError, file if bundles.include?("common")
bundles
end
def find_css_bundles(file)
SASSLite.find_bundles_for(file).map do |bundle|
bundle.sub(%r{\Aapp/coffeescripts/bundles/(.*).coffee\z}, "\\1")
end
end
end
class CoffeeDetector < JsDetector
def can_process?(file)
file =~ %r{\Aapp/coffeescripts/.*\.coffee\z}
end
def module_from(file)
"compiled/" + file.sub(%r{\Aapp/coffeescripts/(.*?)\.coffee}, "\\1")
end
end
class JsxDetector < JsDetector
def can_process?(file)
file =~ %r{/\Aapp/jsx/.*\.jsx\z}
end
def module_from(file)
"jsx/" + file.sub(%r{\Aapp/jsx/(.*?)\.jsx}, "\\1")
end
end
# TODO: partials
class HandlebarsDetector < JsDetector
def can_process?(file)
file =~ %r{app/views/jst/.*\.handlebars\z}
end
def module_from(file)
"jst/" + file.sub(%r{\Aapp/views/jst/(.*?)\.handlebars}, "\\1").sub(/(\A|\/)_/, "")
end
end
end
module SASSLite
def self.find_bundles_for(*)
# TODO: https://github.com/xzyfer/sass-graph
end
end
module RequireJSLite
def self.find_bundles_for(*)
# TODO: https://www.npmjs.com/package/madge
end
end
end

View File

@ -0,0 +1,35 @@
require_relative "generic_detector"
module Selinimum
module Detectors
# we try to map out dependents of any ruby files when we generate the
# spec dependency graph. because ruby and rails are so crazy/magical,
# currently this is only possible for:
# * _spec.rb files
# * views
# * controllers
class RubyDetector < GenericDetector
def can_process?(file)
file =~ %r{\A(
app/views/.*\.erb |
app/controllers/.*\.rb |
spec/.*_spec\.rb
)\z}x && file !~ GLOBAL_FILES
end
# we don't find dependents at this point; we do that during the
# capture phase. so here we just return the file itself
def dependents_for(file)
["file:#{file}"]
end
# stuff not worth tracking, since they are literally used everywhere.
# so if they change, we test all the things
GLOBAL_FILES = %r{\A(
app/views/(layouts|shared)/.*\.erb |
app/controllers/application_controller.rb
)\z}x
end
end
end

View File

@ -0,0 +1,18 @@
require_relative "generic_detector"
module Selinimum
module Detectors
# stuff we ignore that should never affect a build
# TODO: config file, maybe .gitignore style?
class WhitelistDetector < GenericDetector
def can_process?(file)
return false if file =~ %r{\Aspec/fixtures/}
return true if file =~ %r{\.(txt|md|png|jpg|gif|ico|svg|html|yml)\z}
return true if file =~ %r{\A(spec/coffeescripts|doc|guard|bin|script|gems/rubocop-canvas\z)/}
return true if file == "spec/spec.opts"
return true if file == "Gemfile.d/test.rb" # TODO: this is :totes: temporary, just until https://gerrit.instructure.com/#/c/58088/ lands and we can remove testbot from canvas-lms proper
false
end
end
end
end

View File

@ -0,0 +1,24 @@
require "shellwords"
module Selinimum
module Git
class << self
def change_list(sha)
`git diff --name-only #{Shellwords.escape(sha)}`.split(/\n/)
end
def recent_shas
`git log --oneline --first-parent --pretty=format:'%H'|head -n 100`.split(/\n/)
end
def head
recent_shas.first
end
def normalize_sha(sha)
sha = `git show #{Shellwords.escape(sha)} --pretty=format:'%H' --name-only 2>/dev/null|head -n 1`
sha.strip unless sha.empty?
end
end
end
end

View File

@ -0,0 +1,66 @@
require "set"
module Selinimum
class Minimizer
attr_reader :spec_dependency_map, :detectors, :options
def initialize(spec_dependency_map, detectors, options = {})
@spec_dependency_map = spec_dependency_map
@detectors = detectors
@options = options
end
def filter(commit_files, spec_files)
commit_files = Set.new(commit_files)
if commit_files.any? { |file| !can_maybe_find_dependents?(file) }
warn "SELINIMUM: some changed files are too global-y, testing all the things :("
return spec_files
end
begin
commit_dependents = dependents_for(commit_files)
rescue SelinimumError
return spec_files
end
spec_files.select do |spec|
spec_dependencies = spec_dependency_map[spec] || []
spec_dependencies << "file:#{spec}"
spec_dependencies.any? { |dependency| commit_dependents.include?(dependency) }
end
end
# indicates whether or not this file can potentially be scoped to only
# the specs that actually need it. it may not actually be, but it's a
# quick/cheap filter. dependents_for will do a more robust check
def can_maybe_find_dependents?(file)
!detector_for(file).nil?
end
# get the list of things whose behavior depends on these files, so we
# can cross reference them with the specs' recorded dependencies.
# this includes:
#
# * bundles in the case of css/js/hbs
# * the files themselves in the case of recognized ruby stuff (views,
# controllers)
# * nothing in the case of whitelisted/safe stuff
def dependents_for(files)
files.inject(Set.new) do |result, file|
result.merge detector_for(file).dependents_for(file)
end
rescue UnknownDependenciesError => e
warn "SELINIMUM: unable to find dependents of #{e}; testing all the things :(\n" \
"though maybe this file is actually unused? if so, please to delete"
raise
end
def detector_for(file)
detectors.detect { |detector| detector.can_process?(file) }
end
def warn(message)
$stderr.puts(message) if options[:verbose]
end
end
end

View File

@ -0,0 +1,133 @@
require "json"
require "aws-sdk"
require "fileutils"
require "tmpdir"
module Selinimum
module StatStore
class << self
S3_PREFIX = "canvas-lms"
# download stats for the requested sha (and possibly infer the best/
# closest sha if sha is nil)
def fetch_stats(sha)
return unless s3_enabled?
if sha
sha = Git.normalize_sha(sha)
else
sha = closest_sha
end
return unless sha # :(
json_path = Dir.mktmpdir("selinimum")
download_stats(sha, json_path)
{sha: sha, json_path: json_path}
end
# once we've downloaded stat files, load them up into memory and
# merge into a single hash
def load_stats(directory)
Dir["#{directory}/*.json"].inject({}) do |result, file|
result.merge JSON.parse(File.read(file)) do |_, oldval, newval|
oldval.concat newval
end
end
end
# find the closest ancestor commit that has spec stats stored in S3
# (within reason)
def closest_sha
recent_shas = Git.recent_shas
available_shas = Set.new(all_shas)
recent_shas.detect { |sha| available_shas.include?(sha) }
end
# download all the json files stored under canvas-lms/data/<SHA>/
def download_stats(sha, dest)
prefix = S3_PREFIX + "/data/" + sha + "/"
objects = s3.objects
.as_tree(prefix: prefix)
.children
.select(&:leaf?)
.map(&:object)
objects.each do |object|
file_name = object.key.sub(prefix, "")
File.open("#{dest}/#{file_name}", "wb") do |file|
object.read do |chunk|
file.write(chunk)
end
end
end
end
# upload stats generated in a given test run
def save_stats(data, batch_name)
suffix = Time.now.utc.strftime("%Y%m%d%H%M%S")
suffix += "-#{batch_name}" if batch_name
save_file("data/#{Git.head}/stats-#{suffix}.json", data)
# in jenkins land, a given build can have lots of data files (cuz
# parallelization). so we track overall build completion/success
# separately once everything is done. e.g. if one thread has
# failures, the whole dataset is unreliable, so we don't finalize
finalize! unless batch_name
end
def finalize!
save_file("builds/#{Git.head}", "ok")
end
def save_file(filename, data)
save_file_locally(filename, data)
s3.objects["#{S3_PREFIX}/#{filename}"].write data
end
def save_file_locally(filename, data)
filename = "tmp/selinimum/#{filename}"
FileUtils.mkdir_p(File.dirname(filename))
File.open(filename, "w") { |f| f.write data }
end
# get all the SHAs w/ finalized stats
def all_shas
prefix = S3_PREFIX + "/builds/"
s3.objects
.as_tree(prefix: prefix)
.children
.select(&:leaf?)
.map { |obj| obj.key.sub(prefix, "") }
end
def s3_enabled?
s3_config[:access_key_id] && s3_config[:access_key_id] != "access_key"
end
def s3_config
@s3_config ||= begin
config = {
access_key_id: ENV["SELINIMUM_AWS_ID"],
secret_access_key: ENV["SELINIMUM_AWS_SECRET"],
bucket_name: ENV.fetch("SELINIMUM_AWS_BUCKET")
}
# fall back to the canvas s3 creds, if provided
yml_file = "config/amazon_s3.yml"
if File.exist?(yml_file)
yml_config = YAML.load_file(yml_file)[ENV["RAILS_ENV"]] || {}
config[:access_key_id] ||= yml_config["access_key_id"]
config[:secret_access_key] ||= yml_config["secret_access_key"]
end
config
end
end
def s3
@s3 ||= begin
AWS::S3.new(s3_config).buckets[s3_config[:bucket_name]]
end
end
end
end
end

View File

@ -0,0 +1,20 @@
# coding: utf-8
lib = File.expand_path('../lib', __FILE__)
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
Gem::Specification.new do |spec|
spec.name = "selinimum"
spec.version = '0.0.1'
spec.authors = ["Jon Jensen"]
spec.email = ["jon@instructure.com"]
spec.summary = %q{run the minimum selenium necessary for your commit}
spec.files = Dir.glob("{lib,spec,bin}/**/*") + %w(test.sh)
spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
spec.test_files = spec.files.grep(%r{^spec/})
spec.require_paths = ["lib"]
spec.add_dependency "aws-sdk", "~> 1.63.0"
spec.add_development_dependency "rspec"
end

View File

@ -0,0 +1,38 @@
require "spec_helper"
describe Selinimum::Detectors::JsDetector do
before do
allow(Selinimum::RequireJSLite).to receive(:find_bundles_for).and_return(["app/coffeescripts/bundles/bar.coffee"])
end
describe "#can_process?" do
it "processes js files in the right path" do
expect(subject.can_process?("public/javascripts/quizzes.js")).to be_truthy
end
end
describe "#bundles_for" do
it "finds top-level bundle(s)" do
expect(Selinimum::RequireJSLite)
.to receive(:find_bundles_for).with("foo").and_return(["app/coffeescripts/bundles/bar.coffee"])
expect(subject.bundles_for("public/javascripts/foo.js")).to eql(["bar"])
end
it "raises on no bundle" do
allow(Selinimum::RequireJSLite).to receive(:find_bundles_for).with("foo").and_return([])
expect { subject.bundles_for("public/javascripts/foo.js") }.to raise_error(Selinimum::UnknownDependenciesError)
end
it "raises on the common bundle" do
allow(Selinimum::RequireJSLite)
.to receive(:find_bundles_for).with("foo").and_return(["app/coffeescripts/bundles/common.coffee"])
expect { subject.bundles_for("public/javascripts/foo.js") }.to raise_error(Selinimum::TooManyDependenciesError)
end
end
describe "#dependents_for" do
it "formats the bundle dependency" do
expect(subject.dependents_for("public/javascripts/foo.js")).to eql(["js:bar"])
end
end
end

View File

@ -0,0 +1,30 @@
require "spec_helper"
describe Selinimum::Detectors::RubyDetector do
describe "#can_process?" do
it "processes ruby files in the right path" do
expect(subject.can_process?("app/views/users/user.html.erb")).to be_truthy
expect(subject.can_process?("app/views/users/_partial.html.erb")).to be_truthy
expect(subject.can_process?("app/controllers/users_controller.rb")).to be_truthy
expect(subject.can_process?("spec/selenium/users_spec.rb")).to be_truthy
end
it "doesn't process global-y files" do
expect(subject.can_process?("app/views/layouts/application.html.erb")).to be_falsy
expect(subject.can_process?("app/views/shared/_foo.html.erb")).to be_falsy
expect(subject.can_process?("app/controllers/application_controller.rb")).to be_falsy
end
it "doesn't process other ruby files" do
expect(subject.can_process?("app/models/user.rb")).to be_falsy
expect(subject.can_process?("lib/foo.rb")).to be_falsy
end
end
describe "#dependents_for" do
it "returns the file itself" do
expect(subject.dependents_for("app/views/users/user.html.erb")).to eql(["file:app/views/users/user.html.erb"])
end
end
end

View File

@ -0,0 +1,51 @@
require "spec_helper"
describe Selinimum::Minimizer do
describe "#filter" do
class NopeDetector
def can_process?(_)
true
end
def dependents_for(file)
raise Selinimum::TooManyDependenciesError, file
end
end
class FooDetector
def can_process?(file)
file =~ /foo/
end
def dependents_for(file)
["file:#{file}"]
end
end
let(:spec_dependency_map) { { "spec/selenium/foo_spec.rb" => ["file:app/views/foos/show.html.erb"] } }
let(:spec_files) do
[
"spec/selenium/foo_spec.rb",
"spec/selenium/bar_spec.rb"
]
end
it "returns the spec_files if there is no corresponding dependent detector" do
minimizer = Selinimum::Minimizer.new(spec_dependency_map, [])
expect(minimizer.filter(["app/views/foos/show.html.erb"], spec_files)).to eql(spec_files)
end
it "returns the spec_files if a file's dependents can't be inferred" do
minimizer = Selinimum::Minimizer.new(spec_dependency_map, [NopeDetector.new])
expect(minimizer.filter(["app/views/foos/show.html.erb"], spec_files)).to eql(spec_files)
end
it "returns the filtered spec_files if every file's dependents can be inferred" do
minimizer = Selinimum::Minimizer.new(spec_dependency_map, [FooDetector.new])
expect(minimizer.filter(["app/views/foos/show.html.erb"], spec_files)).to eql(["spec/selenium/foo_spec.rb"])
end
end
end

View File

@ -0,0 +1 @@
require "selinimum"

15
gems/selinimum/test.sh Executable file
View File

@ -0,0 +1,15 @@
#!/bin/bash
result=0
echo "################ selinimum ################"
bundle check || bundle install
bundle exec rspec spec
let result=$result+$?
if [ $result -eq 0 ]; then
echo "SUCCESS"
else
echo "FAILURE"
fi
exit $result

View File

@ -412,6 +412,25 @@ RSpec.configure do |config|
$spec_api_tokens = {}
end
# this runs on post-merge builds to capture dependencies of each spec;
# we then use that data to run just the bare minimum subset of selenium
# specs on the patchset builds
if ENV["SELINIMUM_CAPTURE"]
require "selinimum"
config.before :suite do
Selinimum::Capture.install!
end
config.before do |example|
Selinimum::Capture.current_example = example
end
config.after :suite do
Selinimum::Capture.report!(ENV["SELINIMUM_BATCH_NAME"])
end
end
# flush redis before the first spec, and before each spec that comes after
# one that used redis
class << Canvas