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:
parent
2ee4731017
commit
4a83f6b6a2
|
@ -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
|
||||
|
|
|
@ -0,0 +1,6 @@
|
|||
source "https://rubygems.org"
|
||||
|
||||
gemspec
|
||||
|
||||
gem "aws-sdk", "1.63.0" # old cuz canvas
|
||||
gem "rspec"
|
|
@ -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(" ")
|
|
@ -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"
|
|
@ -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
|
|
@ -0,0 +1,3 @@
|
|||
require_relative "detectors/whitelist_detector"
|
||||
require_relative "detectors/ruby_detector"
|
||||
require_relative "detectors/js_detector"
|
|
@ -0,0 +1,9 @@
|
|||
module Selinimum
|
||||
module Detectors
|
||||
class GenericDetector
|
||||
def dependents_for(*)
|
||||
[]
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -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
|
|
@ -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
|
||||
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
||||
|
|
@ -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
|
|
@ -0,0 +1 @@
|
|||
require "selinimum"
|
|
@ -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
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue