├── .jshintrc ├── lib ├── rspectacles.rb └── rspectacles │ ├── version.rb │ ├── app │ ├── models │ │ ├── example.rb │ │ └── run.rb │ ├── public │ │ ├── js │ │ │ ├── script.js │ │ │ ├── form.js │ │ │ ├── details.js │ │ │ ├── exampleStream.js │ │ │ ├── pathtree.js │ │ │ ├── zoomable.js │ │ │ ├── chart.js │ │ │ ├── riffle.js │ │ │ ├── mustache.js │ │ │ └── plates.js │ │ └── css │ │ │ └── style.css │ ├── helpers.rb │ └── views │ │ ├── runs.erb │ │ └── index.erb │ ├── formatter │ ├── batched.rb │ └── base.rb │ ├── config │ └── database.yml │ ├── adapter │ ├── batched_logger.rb │ └── logger.rb │ ├── config.rb │ └── app.rb ├── viz.png ├── Gemfile ├── .gitignore ├── bin └── console ├── puma.rb ├── config.ru ├── Procfile ├── Rakefile ├── db ├── migrate │ ├── 20170907205819_create_examples_table.rb │ ├── 20170912153508_create_runs.rb │ └── 20170912175547_associate_runs.rb └── schema.rb ├── spec └── javascripts │ ├── tests │ └── pathtree_spec.js │ ├── test.html │ └── resources │ ├── qunit.css │ └── qunit.js ├── .rubocop.yml ├── LICENSE ├── rspectacles.gemspec ├── Gemfile.lock └── README.md /.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "laxcomma": true 3 | } 4 | -------------------------------------------------------------------------------- /lib/rspectacles.rb: -------------------------------------------------------------------------------- 1 | require 'rspectacles/app.rb' 2 | -------------------------------------------------------------------------------- /viz.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/g2crowd/rspectacles/HEAD/viz.png -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | ruby "2.6.6" 3 | 4 | gemspec 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | pkg/* 2 | .ruby-gemset 3 | .ruby-version 4 | tmp/* 5 | .bundle/* 6 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | bundle exec irb -r rspectacles/app 4 | -------------------------------------------------------------------------------- /lib/rspectacles/version.rb: -------------------------------------------------------------------------------- 1 | module RSpectacles 2 | VERSION = '0.5.0' 3 | end 4 | -------------------------------------------------------------------------------- /puma.rb: -------------------------------------------------------------------------------- 1 | preload_app! 2 | on_worker_boot do 3 | ActiveRecord::Base.establish_connection 4 | end 5 | -------------------------------------------------------------------------------- /config.ru: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.unshift ::File.expand_path(::File.dirname(__FILE__) + '/lib') 2 | require 'rspectacles/app' 3 | 4 | run RSpectacles::App 5 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: bundle exec puma -t ${PUMA_MIN_THREADS:-1}:${PUMA_MAX_THREADS:-1} -w ${PUMA_WORKERS:-1} -p $PORT -e ${RACK_ENV:-development} -C puma.rb 2 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'sinatra/activerecord/rake' 2 | require 'bundler/gem_tasks' 3 | 4 | namespace :db do 5 | task :load_config do 6 | require './lib/rspectacles/app' 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /lib/rspectacles/app/models/example.rb: -------------------------------------------------------------------------------- 1 | class Example < ActiveRecord::Base 2 | belongs_to :run, primary_key: :rspec_run, foreign_key: :rspec_run 3 | serialize :properties, Hash 4 | 5 | def as_json(*_) 6 | properties 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /lib/rspectacles/app/models/run.rb: -------------------------------------------------------------------------------- 1 | class Run < ActiveRecord::Base 2 | has_many :examples, primary_key: :rspec_run, foreign_key: :rspec_run 3 | 4 | def total_count 5 | @total_count ||= examples.size 6 | end 7 | 8 | def runtime 9 | @runtime ||= examples.sum(:duration) 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /db/migrate/20170907205819_create_examples_table.rb: -------------------------------------------------------------------------------- 1 | class CreateExamplesTable < ActiveRecord::Migration[5.1] 2 | def change 3 | create_table :examples do |t| 4 | t.string :rspec_run, null: false 5 | t.text :properties 6 | end 7 | 8 | add_index :examples, :rspec_run 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /lib/rspectacles/app/public/js/script.js: -------------------------------------------------------------------------------- 1 | /*global require: true */ 2 | require(['zoomable', 'exampleStream'], function (chart, examples) { 3 | 'use strict'; 4 | 5 | var 6 | bodyEl = document.querySelector('body') 7 | , ajaxUri = bodyEl.dataset.ajaxUrl 8 | , streams = examples(ajaxUri) 9 | , c = chart() 10 | ; 11 | 12 | streams.example.onOutput(function (data) { 13 | c.push(data); 14 | }); 15 | 16 | c.render(); 17 | window.c = c; 18 | }); 19 | -------------------------------------------------------------------------------- /lib/rspectacles/app/helpers.rb: -------------------------------------------------------------------------------- 1 | RSpectacles::App.helpers do 2 | def versioned_stylesheet(stylesheet) 3 | checksum = File.mtime(File.join(public_dir, 'css', "#{stylesheet}.css")).to_i 4 | url "/css/#{stylesheet}.css?#{checksum}" 5 | end 6 | 7 | def versioned_javascript(js) 8 | checksum = File.mtime(File.join(public_dir, 'js', "#{js}.js")).to_i 9 | url "/js/#{js}.js?#{checksum}" 10 | end 11 | 12 | def public_dir 13 | File.expand_path('../../app/public', __FILE__) 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/rspectacles/app/public/js/form.js: -------------------------------------------------------------------------------- 1 | define(function () { 2 | return function () { 3 | var isCount = false; 4 | var me; 5 | 6 | d3.selectAll("input").on("change", function () { 7 | var newCount = this.value === 'count'; 8 | 9 | if (newCount !== isCount) { 10 | isCount = newCount; 11 | me.onChange(isCount); 12 | } 13 | }); 14 | 15 | return me = { 16 | isCount: function () { 17 | return isCount; 18 | }, 19 | 20 | onChange: function () { 21 | } 22 | } 23 | } 24 | }); 25 | -------------------------------------------------------------------------------- /db/migrate/20170912153508_create_runs.rb: -------------------------------------------------------------------------------- 1 | require 'rspectacles/app/models/example' 2 | require 'rspectacles/app/models/run' 3 | 4 | class CreateRuns < ActiveRecord::Migration[5.1] 5 | def change 6 | create_table :runs do |t| 7 | t.integer :total_time 8 | t.string :rspec_run, null: false 9 | 10 | t.timestamps 11 | end 12 | 13 | add_index :runs, :rspec_run, unique: true 14 | add_reference :examples, :run 15 | 16 | Example.all.distinct.pluck(:rspec_run).each do |run| 17 | Run.where(rspec_run: run).first_or_create 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /db/migrate/20170912175547_associate_runs.rb: -------------------------------------------------------------------------------- 1 | class AssociateRuns < ActiveRecord::Migration[5.1] 2 | def up 3 | add_column :examples, :duration, :numeric, null: false, default: 0 4 | remove_column :runs, :total_time 5 | 6 | Run.all.find_each do |r| 7 | r.update_attributes id: r.rspec_run 8 | end 9 | 10 | count = Example.all.size 11 | Example.all.find_each do |e| 12 | e.update_column :duration, e.properties['duration'].to_f 13 | 14 | count -= 1 15 | puts count 16 | end 17 | end 18 | 19 | def down 20 | remove_column :examples, :duration 21 | add_column :runs, :total_time, :integer 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /spec/javascripts/tests/pathtree_spec.js: -------------------------------------------------------------------------------- 1 | /*global test:true require:true it:true ok:true module:true */ 2 | 3 | require(['pathtree'], function (PathTree) { 4 | 'use strict'; 5 | 6 | test('constructor', function () { 7 | var tree = new PathTree([{ file_path: './file', full_description: 'hi' }]); 8 | equal(tree.nodes.children[0].name, 'file'); 9 | }); 10 | 11 | module('PathTree#add', { 12 | setup: function () { 13 | this.tree = new PathTree(); 14 | } 15 | }); 16 | 17 | test('add', function () { 18 | this.tree.add({ file_path: './file', full_description: 'hi' }); 19 | equal(this.tree.nodes.children[0].name, 'file'); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /spec/javascripts/test.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | QUnit Example 6 | 7 | 8 | 9 |
10 |
11 | 12 | 13 | 14 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /lib/rspectacles/formatter/batched.rb: -------------------------------------------------------------------------------- 1 | require 'rspectacles/adapter/batched_logger' 2 | require 'rspectacles/formatter/base' 3 | 4 | module RSpectacles 5 | module Formatter 6 | class Batched < RSpectacles::Formatter::Base 7 | RSpec::Core::Formatters.register self, 8 | *%i(example_passed 9 | example_failed 10 | start 11 | stop 12 | message) 13 | 14 | def logger 15 | @logger ||= RSpectacles::Adapter::BatchedLogger.new(test_run_key: current_run_key) 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/rspectacles/config/database.yml: -------------------------------------------------------------------------------- 1 | development: 2 | adapter: postgresql 3 | database: rspectacles_dev 4 | username: postgres 5 | pool: 5 6 | host: localhost 7 | timeout: 5000 8 | variables: 9 | statement_timeout: 3600000 10 | 11 | test: &test 12 | adapter: <%= ENV['DB_ADAPTER'] || 'postgresql' %> 13 | database: rspectacles_test 14 | username: postgres 15 | pool: 5 16 | host: <%= ENV['DB_HOST'] || 'localhost' %> 17 | timeout: 5000 18 | variables: 19 | statement_timeout: 3600000 20 | 21 | production: &production 22 | adapter: postgresql 23 | database: rspectacles_prod 24 | username: postgres 25 | pool: 2 26 | host: localhost 27 | timeout: 5000 28 | variables: 29 | statement_timeout: 3600000 30 | -------------------------------------------------------------------------------- /lib/rspectacles/adapter/batched_logger.rb: -------------------------------------------------------------------------------- 1 | require 'rspectacles/adapter/logger' 2 | 3 | module RSpectacles 4 | module Adapter 5 | class BatchedLogger < Logger 6 | def queued_messages 7 | @queued_messages ||= [] 8 | end 9 | 10 | def stop 11 | super 12 | flush_queue 13 | end 14 | 15 | def batch_size 16 | config.batch_size 17 | end 18 | 19 | def queue(message) 20 | return unless active? 21 | queued_messages << message 22 | flush_queue if queued_messages.count > batch_size 23 | end 24 | 25 | def flush_queue 26 | return unless active? 27 | return unless queued_messages.size > 0 28 | 29 | post_results queued_messages 30 | @queued_messages = [] 31 | end 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/rspectacles/app/public/css/style.css: -------------------------------------------------------------------------------- 1 | table { 2 | } 3 | 4 | th, td { 5 | text-align: left; 6 | padding: 8px 16px; 7 | } 8 | 9 | ul { 10 | border: 1px solid #eee; 11 | list-style-type: none; 12 | margin: 0; 13 | padding: 15px; 14 | font-size: 12px; 15 | } 16 | 17 | li { 18 | margin: 0; 19 | padding: 0; 20 | } 21 | 22 | .failed { color: red; } 23 | .passed { color: green; } 24 | 25 | body { 26 | font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; 27 | margin: auto; 28 | position: relative; 29 | width: 960px; 30 | } 31 | 32 | form { 33 | position: absolute; 34 | right: 10px; 35 | top: 10px; 36 | } 37 | 38 | .example-details { 39 | position: fixed; 40 | top: 0; 41 | left: 0; 42 | background: rgba(255, 255, 255, 0.5); 43 | width: 100% 44 | z-index: 100; 45 | overflow: auto; 46 | max-width: 350px; 47 | } 48 | -------------------------------------------------------------------------------- /lib/rspectacles/app/views/runs.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | RSpectacles 6 | ' /> 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | <% @runs.each do |run| %> 17 | 18 | 21 | 24 | 28 | 31 | 32 | <% end %> 33 |
RunExamplesTimeCreated At
19 | Run #<%= run.rspec_run %> 20 | 22 | <%= run.examples.size %> 23 | 25 | <% mm, ss = run.runtime.divmod(60) %> 26 | <%= mm.to_i %>:<%= ss.to_i.to_s.rjust(2, '0') %> 27 | 29 | <%= run.created_at.strftime('%Y-%m-%d %H:%M:%S') %> 30 |
34 | 35 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | AllCops: 2 | Exclude: 3 | - db/schema.rb 4 | - db/migrate/* 5 | 6 | Documentation: 7 | Enabled: false 8 | 9 | LineLength: 10 | Max: 120 11 | 12 | MethodLength: 13 | Max: 15 14 | 15 | ClassLength: 16 | Max: 500 17 | 18 | SpaceBeforeFirstArg: 19 | Enabled: false 20 | 21 | AssignmentInCondition: 22 | Enabled: false 23 | 24 | SingleLineBlockParams: 25 | Enabled: false 26 | 27 | Lambda: 28 | Enabled: false 29 | 30 | NumericLiterals: 31 | Enabled: false 32 | 33 | LambdaCall: 34 | Enabled: false 35 | 36 | AndOr: 37 | Enabled: false 38 | 39 | MultilineOperationIndentation: 40 | Enabled: false 41 | 42 | AbcSize: 43 | Max: 20 44 | 45 | Style/MultilineMethodCallIndentation: 46 | EnforcedStyle: indented_relative_to_receiver 47 | 48 | Bundler/OrderedGems: 49 | Enabled: false 50 | 51 | Metrics/BlockLength: 52 | Enabled: false 53 | 54 | Style/EmptyMethod: 55 | Enabled: false 56 | 57 | Style/FrozenStringLiteralComment: 58 | Enabled: false 59 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2013 Michael Wheeler 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /rspectacles.gemspec: -------------------------------------------------------------------------------- 1 | $:.push File.expand_path('../lib', __FILE__) 2 | require 'rspectacles/version' 3 | 4 | Gem::Specification.new do |s| 5 | s.name = 'rspectacles' 6 | s.version = RSpectacles::VERSION 7 | s.authors = ['Michael Wheeler'] 8 | s.email = ['mwheeler@g2crowd.com'] 9 | s.homepage = 'https://github.com/g2crowd/rspectacles' 10 | s.summary = 'Visualize rspec test running in the browser' 11 | s.description = 'Visualize rspec test running in the browser' 12 | s.license = 'MIT' 13 | 14 | s.required_ruby_version = '>= 2.6.6' 15 | 16 | s.files = `git ls-files`.split("\n") 17 | s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n") 18 | s.executables = `git ls-files -- bin/*`.split("\n").map { |f| File.basename(f) } 19 | s.require_paths = ['lib'] 20 | 21 | # specify any dependencies here; for example: 22 | s.add_development_dependency 'pry' 23 | s.add_development_dependency 'rspec' 24 | s.add_dependency 'httparty' 25 | s.add_dependency 'pg' 26 | s.add_dependency 'puma' 27 | s.add_dependency 'rack', '>= 2.0.6' 28 | s.add_dependency 'rake' 29 | s.add_dependency 'sinatra', '>= 1.4.5' 30 | s.add_dependency 'sinatra-activerecord' 31 | end 32 | -------------------------------------------------------------------------------- /lib/rspectacles/app/public/js/details.js: -------------------------------------------------------------------------------- 1 | define(['jquery', 'mustache'], function($, Mustache) { 2 | var me, 3 | defaults, 4 | current = {}, 5 | tmpl = $('#template').html(), 6 | isCount = false; 7 | 8 | defaults = function() { 9 | return { 10 | name: '', 11 | line_number: '', 12 | status: '', 13 | duration: '', 14 | time_or_count: isCount ? 'Examples' : 'ms', 15 | minutes: '', 16 | value: null 17 | }; 18 | }; 19 | 20 | function secToMin(time) { 21 | var pad = function(val) { 22 | return ('00' + val).slice(-2); 23 | }, 24 | min = parseInt(time / 60), 25 | sec = parseInt(time % 60); 26 | 27 | return pad(min) + ':' + pad(sec); 28 | } 29 | 30 | function render() { 31 | if (current.value) { 32 | if (!isCount) { 33 | current.minutes = secToMin(current.value); 34 | current.value = parseInt(current.value * 1000); 35 | } 36 | } 37 | 38 | $('.example-wrapper').html(Mustache.render(tmpl, current)); 39 | } 40 | 41 | return { 42 | update: function(d) { 43 | current = $.extend({}, defaults(), d); 44 | render(); 45 | }, 46 | 47 | isCount: function(value) { 48 | isCount = value; 49 | }, 50 | }; 51 | }); 52 | -------------------------------------------------------------------------------- /lib/rspectacles/app/public/js/exampleStream.js: -------------------------------------------------------------------------------- 1 | /*global define: true EventSource: true */ 2 | define(['riffle'], function (riffle) { 3 | 'use strict'; 4 | 5 | return function streams(ajaxUri) { 6 | var stream = riffle.stream 7 | , each 8 | , jsonEvents 9 | ; 10 | 11 | function ajaxStream(url, args) { 12 | return stream(function (o) { 13 | $.get(url, args, function (d) { 14 | o(JSON.parse(d)); 15 | }); 16 | }); 17 | } 18 | 19 | each = stream(function (o, i) { 20 | i.forEach(function (item) { o(item); }); 21 | }); 22 | 23 | function batched(delay, maxSize) { 24 | var batch = [] 25 | , timer 26 | ; 27 | 28 | delay = delay || 100; 29 | maxSize = maxSize || 100; 30 | 31 | function clear(o) { 32 | if (batch.length > 0) { 33 | o(batch.splice(0, maxSize)); 34 | } 35 | 36 | if (batch.length < 1) { 37 | clearInterval(timer); 38 | timer = null; 39 | } 40 | } 41 | 42 | return stream(function (o, i) { 43 | batch = batch.concat(i); 44 | 45 | if (!timer) { timer = setInterval(function () { clear(o); }, delay); } 46 | }); 47 | } 48 | 49 | jsonEvents = each.input(ajaxStream(ajaxUri).invoke()); 50 | 51 | return { example: batched(10, 1000).input(jsonEvents) }; 52 | }; 53 | }); 54 | -------------------------------------------------------------------------------- /lib/rspectacles/formatter/base.rb: -------------------------------------------------------------------------------- 1 | require 'rspectacles/adapter/logger' 2 | 3 | module RSpectacles 4 | module Formatter 5 | class Base 6 | RSpec::Core::Formatters.register self, 7 | :example_passed, 8 | :example_failed, 9 | :start, 10 | :stop, 11 | :message 12 | 13 | attr_reader :output 14 | 15 | def initialize(_) 16 | end 17 | 18 | def logger 19 | @logger ||= RSpectacles::Adapter::Logger.new(test_run_key: current_run_key) 20 | end 21 | 22 | def message(_notification) 23 | end 24 | 25 | def start(_) 26 | logger.start 27 | end 28 | 29 | def stop(_) 30 | logger.stop 31 | end 32 | 33 | def example_passed(notification) 34 | logger.log notification.example 35 | end 36 | 37 | def example_pending(notification) 38 | logger.log notification.example 39 | end 40 | 41 | def example_failed(notification) 42 | logger.log notification.example 43 | end 44 | 45 | def current_run_key 46 | ENV['CURRENT_RSPEC_RUN'] || config.last_run_primary_key 47 | end 48 | 49 | def config 50 | RSpectacles.config 51 | end 52 | end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /lib/rspectacles/app/public/js/pathtree.js: -------------------------------------------------------------------------------- 1 | // @format 2 | /*global define: true */ 3 | define(['jquery'], function($) { 4 | 'use strict'; 5 | 6 | function createNodes(path, data, node) { 7 | var nextNode; 8 | 9 | node.children = node.children || []; 10 | 11 | if (path.length === 0) { 12 | nextNode = $.extend( 13 | { 14 | size: data.duration, 15 | name: data.full_description, 16 | }, 17 | data, 18 | ); 19 | 20 | node.children.push(nextNode); 21 | return; 22 | } 23 | 24 | nextNode = node.children.filter(function(child) { 25 | return child.name === path[0]; 26 | })[0]; 27 | 28 | if (!nextNode) { 29 | nextNode = {name: path[0]}; 30 | node.children.push(nextNode); 31 | } 32 | 33 | path.shift(); 34 | createNodes(path, data, nextNode); 35 | } 36 | 37 | function PathTree(data) { 38 | this.nodes = {}; 39 | data && this.add(data); 40 | } 41 | 42 | PathTree.prototype.add = function(data) { 43 | var that = this; 44 | !$.isArray(data) && (data = [data]); 45 | 46 | data.forEach(function(node) { 47 | var path = node.file_path.split('/'); 48 | 49 | ['.'].forEach(function(v) { 50 | if (path[0] === v) { 51 | path.shift(); 52 | } 53 | }); 54 | 55 | createNodes(path, node, that.nodes); 56 | }); 57 | }; 58 | 59 | return PathTree; 60 | }); 61 | -------------------------------------------------------------------------------- /lib/rspectacles/config.rb: -------------------------------------------------------------------------------- 1 | require 'ostruct' 2 | require 'yaml' 3 | require 'erb' 4 | require 'securerandom' 5 | 6 | module RSpectacles 7 | class Config 8 | def initialize 9 | @opts = OpenStruct.new defaults.merge(yml) 10 | end 11 | 12 | def defaults 13 | { 14 | batch_size: (ENV['RSPECTACLES_BATCH_SIZE'] || 1000).to_i, 15 | last_run_primary_key: ENV['RSPECTACLES_RUN_KEY'] || ENV['CIRCLE_BUILD_NUM'] || SecureRandom.hex, 16 | timeout: (ENV['RSPECTACLES_TIMEOUT'] || 15).to_i, 17 | rspectacles_url: ENV['RSPECTACLES_URL'] 18 | } 19 | end 20 | 21 | def timeout 22 | @opts[:timeout] 23 | end 24 | 25 | def method_missing(method, *args) 26 | @opts.public_send method, *args 27 | end 28 | 29 | private 30 | 31 | def yml_path 32 | ::File.expand_path(ENV['RSPECTACLES_CONFIG']) if ENV['RSPECTACLES_CONFIG'] 33 | end 34 | 35 | def yml_exists? 36 | yml_path && ::File.exist?(yml_path) 37 | end 38 | 39 | def yml 40 | res = if yml_exists? 41 | @yml ||= ::YAML.safe_load(::ERB.new(IO.read(yml_path)).result) 42 | else 43 | {} 44 | end 45 | 46 | sanitize(res) 47 | end 48 | 49 | def sanitize(hash) 50 | hash.each_with_object({}) { |(key, value), memo| memo[key.to_sym] = value } 51 | end 52 | end 53 | 54 | def self.configuration 55 | @configuration ||= Config.new 56 | end 57 | 58 | def self.config 59 | yield configuration if block_given? 60 | configuration 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /db/schema.rb: -------------------------------------------------------------------------------- 1 | # This file is auto-generated from the current state of the database. Instead 2 | # of editing this file, please use the migrations feature of Active Record to 3 | # incrementally modify your database, and then regenerate this schema definition. 4 | # 5 | # Note that this schema.rb definition is the authoritative source for your 6 | # database schema. If you need to create the application database on another 7 | # system, you should be using db:schema:load, not running all the migrations 8 | # from scratch. The latter is a flawed and unsustainable approach (the more migrations 9 | # you'll amass, the slower it'll run and the greater likelihood for issues). 10 | # 11 | # It's strongly recommended that you check this file into your version control system. 12 | 13 | ActiveRecord::Schema.define(version: 20170912175547) do 14 | 15 | # These are extensions that must be enabled in order to support this database 16 | enable_extension "plpgsql" 17 | 18 | create_table "examples", force: :cascade do |t| 19 | t.string "rspec_run", null: false 20 | t.text "properties" 21 | t.bigint "run_id" 22 | t.decimal "duration", default: "0.0", null: false 23 | t.index ["rspec_run"], name: "index_examples_on_rspec_run" 24 | t.index ["run_id"], name: "index_examples_on_run_id" 25 | end 26 | 27 | create_table "runs", force: :cascade do |t| 28 | t.string "rspec_run", null: false 29 | t.datetime "created_at", null: false 30 | t.datetime "updated_at", null: false 31 | t.index ["rspec_run"], name: "index_runs_on_rspec_run", unique: true 32 | end 33 | 34 | end 35 | -------------------------------------------------------------------------------- /lib/rspectacles/app/views/index.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | RSpectacles 6 | ' /> 7 | 8 | > 9 |
10 | 11 | 12 |
13 | 14 |
15 |
16 | 17 | 32 | 33 | 34 | 35 | 36 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /lib/rspectacles/app.rb: -------------------------------------------------------------------------------- 1 | require 'rubygems' 2 | require 'sinatra/base' 3 | require 'json' 4 | require 'sinatra/activerecord' 5 | require 'rspectacles/config.rb' 6 | require 'rspectacles/app/models/example' 7 | require 'rspectacles/app/models/run' 8 | require 'puma' 9 | 10 | module RSpectacles 11 | class App < Sinatra::Base 12 | require 'rspectacles/app/helpers' 13 | register Sinatra::ActiveRecordExtension 14 | 15 | set :database_file, 'config/database.yml' 16 | 17 | config = RSpectacles.config 18 | dir = File.dirname(File.expand_path(__FILE__)) 19 | set :app_file, __FILE__ 20 | set :root, dir 21 | set :views, "#{dir}/app/views" 22 | set :static, true 23 | 24 | if respond_to? :public_folder 25 | set :public_folder, "#{dir}/app/public" 26 | else 27 | set :public, "#{dir}/app/public" 28 | end 29 | 30 | # Routes 31 | get '/' do 32 | @runs = Run.all.limit(25).order(created_at: :desc) 33 | erb :runs 34 | end 35 | 36 | get '/watch/:key' do 37 | erb :index 38 | end 39 | 40 | get '/watch' do 41 | params['key'] = config.last_run_primary_key 42 | erb :index 43 | end 44 | 45 | get '/examples/:key' do 46 | Example.where(rspec_run: params['key']).to_json 47 | end 48 | 49 | post '/examples' do 50 | payload = JSON.parse(request.body.read) 51 | 52 | Run.where(rspec_run: payload['examples'].first['rspec_run']).first_or_create 53 | 54 | data = payload['examples'].map do |args| 55 | { rspec_run: args['rspec_run'], duration: args['duration'].to_f, properties: args } 56 | end 57 | 58 | examples = Example.create(data) 59 | 60 | { errors: examples.count { |i| !i.persisted? } }.to_json 61 | end 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /lib/rspectacles/adapter/logger.rb: -------------------------------------------------------------------------------- 1 | require 'rspectacles/config' 2 | require 'uri' 3 | require 'json' 4 | require 'httparty' 5 | 6 | module RSpectacles 7 | module Adapter 8 | class Logger 9 | attr_reader :test_run_key 10 | 11 | def initialize(test_run_key: nil) 12 | @test_run_key = test_run_key || config.last_run_primary_key 13 | end 14 | 15 | def config 16 | RSpectacles.config 17 | end 18 | 19 | def uri 20 | @uri ||= config.rspectacles_url 21 | end 22 | 23 | def stop 24 | end 25 | 26 | def start 27 | end 28 | 29 | def log(example) 30 | message = format_example(example) 31 | queue message 32 | end 33 | 34 | private 35 | 36 | def queue(message) 37 | return unless active? 38 | post_results Array.wrap(message) 39 | end 40 | 41 | def post_results(messages) 42 | HTTParty.post(full_uri, timeout: config.timeout, 43 | body: { examples: messages }.to_json, 44 | headers: { 'Content-Type' => 'application/json' }) 45 | rescue Net::ReadTimeout 46 | puts "RSpectacles Timeout! Failed to send #{messages.size} messages" 47 | end 48 | 49 | def active? 50 | !!uri 51 | end 52 | 53 | def full_uri 54 | "#{uri}/examples" 55 | end 56 | 57 | def format_example(example) 58 | { 59 | rspec_run: test_run_key, 60 | description: example.description, 61 | full_description: example.full_description, 62 | status: example.execution_result.status, 63 | duration: example.execution_result.run_time, 64 | file_path: example.metadata[:file_path], 65 | line_number: example.metadata[:line_number] 66 | } 67 | end 68 | end 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | rspectacles (0.5.0) 5 | httparty 6 | pg 7 | puma 8 | rack (>= 2.0.6) 9 | rake 10 | sinatra (>= 1.4.5) 11 | sinatra-activerecord 12 | 13 | GEM 14 | remote: https://rubygems.org/ 15 | specs: 16 | activemodel (6.1.3) 17 | activesupport (= 6.1.3) 18 | activerecord (6.1.3) 19 | activemodel (= 6.1.3) 20 | activesupport (= 6.1.3) 21 | activesupport (6.1.3) 22 | concurrent-ruby (~> 1.0, >= 1.0.2) 23 | i18n (>= 1.6, < 2) 24 | minitest (>= 5.1) 25 | tzinfo (~> 2.0) 26 | zeitwerk (~> 2.3) 27 | coderay (1.1.1) 28 | concurrent-ruby (1.1.8) 29 | diff-lcs (1.1.3) 30 | httparty (0.16.3) 31 | mime-types (~> 3.0) 32 | multi_xml (>= 0.5.2) 33 | i18n (1.8.9) 34 | concurrent-ruby (~> 1.0) 35 | method_source (0.8.2) 36 | mime-types (3.2.2) 37 | mime-types-data (~> 3.2015) 38 | mime-types-data (3.2018.0812) 39 | minitest (5.14.4) 40 | multi_xml (0.6.0) 41 | mustermann (1.1.1) 42 | ruby2_keywords (~> 0.0.1) 43 | nio4r (2.5.8) 44 | pg (1.1.3) 45 | pry (0.10.4) 46 | coderay (~> 1.1.0) 47 | method_source (~> 0.8.1) 48 | slop (~> 3.4) 49 | puma (5.6.4) 50 | nio4r (~> 2.0) 51 | rack (2.2.3) 52 | rack-protection (2.2.0) 53 | rack 54 | rake (13.0.1) 55 | rspec (2.12.0) 56 | rspec-core (~> 2.12.0) 57 | rspec-expectations (~> 2.12.0) 58 | rspec-mocks (~> 2.12.0) 59 | rspec-core (2.12.2) 60 | rspec-expectations (2.12.1) 61 | diff-lcs (~> 1.1.3) 62 | rspec-mocks (2.12.2) 63 | ruby2_keywords (0.0.5) 64 | sinatra (2.2.0) 65 | mustermann (~> 1.0) 66 | rack (~> 2.2) 67 | rack-protection (= 2.2.0) 68 | tilt (~> 2.0) 69 | sinatra-activerecord (2.0.18) 70 | activerecord (>= 4.1) 71 | sinatra (>= 1.0) 72 | slop (3.6.0) 73 | tilt (2.0.10) 74 | tzinfo (2.0.4) 75 | concurrent-ruby (~> 1.0) 76 | zeitwerk (2.4.2) 77 | 78 | PLATFORMS 79 | ruby 80 | 81 | DEPENDENCIES 82 | pry 83 | rspec 84 | rspectacles! 85 | 86 | RUBY VERSION 87 | ruby 2.6.6p146 88 | 89 | BUNDLED WITH 90 | 2.1.4 91 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Gem Version](https://badge.fury.io/rb/rspectacles.svg)](http://badge.fury.io/rb/rspectacles) 2 | # RSpectacles 3 | 4 | RSpectacles is an in-browser visualizer and profiler for RSpec. It uses d3.js to render a 5 | [partition](http://bl.ocks.org/mbostock/4063423) of your specs based on time to complete, so 6 | that you can tell at a glance where the time is spent in your test suite. 7 | 8 | ![Example Partition](viz.png) 9 | 10 | As a Sinatra app it can be run standalone, or else mounted on another Rack app. 11 | 12 | ## Compatibility 13 | 14 | RSpectacles assumes you are using RSpec 3 or later. 15 | 16 | ## Installation 17 | 18 | gem install rspectacles 19 | 20 | Or in your Gemfile: 21 | 22 | ```ruby 23 | group :test, :development do 24 | gem 'rspectacles' 25 | end 26 | ``` 27 | 28 | Then add the formatter to your `.rspec` file: 29 | 30 | --require rspectacles/formatter/batched 31 | --format RSpectacles::Formatter::Batched 32 | 33 | --format progress # or whatever other formatters you want to use 34 | 35 | The formatter assumes you are using RSpec 3. 36 | 37 | ## Batched Formatter 38 | 39 | The `Batched` formatter is preferred, as it will send fewer web requests and will be less likely to 40 | slow down your specs if the connection to the server is slow. You can change the batch 41 | sizes by changing the `batch_size` in config settings. 42 | 43 | ## Storage 44 | 45 | RSpectacles depends on ActiveRecord for persistence. You 46 | can quickly get an instance up and running by setting the `DATABASE_URL` environment variable, 47 | and running the standard rake commands: 48 | 49 | export DATABASE_URL=postgres://... 50 | rake db:create 51 | rake db:migrate 52 | 53 | Start the server and connect to it in your browser: 54 | 55 | rackup 56 | 57 | Then run your specs and watch the magic happen! 58 | 59 | ## Web Server 60 | 61 | The server uses ActiveRecord and Postgres to store the examples. 62 | 63 | Run migrations: 64 | 65 | # set ENV['DATABASE_URL'] to point to your database, or else database.yml defaults will be used 66 | rake db:create 67 | rake db:migrate 68 | 69 | Start the server: 70 | 71 | puma 72 | 73 | ## Configuration 74 | 75 | Configuration settings can all be set through environment variables: 76 | 77 | | Environment variable | Description | Default value | 78 | | --- | --- | --- | 79 | | RSPECTACLES_URL | Where server is running | | 80 | | RSPECTACLES_RUN_KEY | Set this to log parallel builds to same report | | 81 | | RSPECTACLES_BATCH_SIZE | | 1000 | 82 | | RSPECTACLES_TIMEOUT | | 15 | 83 | 84 | ## Contributing 85 | 86 | 1. Fork it 87 | 2. Create your feature branch (`git checkout -b my-new-feature`) 88 | 3. Commit your changes (`git commit -am 'Added some feature'`) 89 | 4. Push to the branch (`git push origin my-new-feature`) 90 | 5. Create new Pull Request 91 | -------------------------------------------------------------------------------- /lib/rspectacles/app/public/js/zoomable.js: -------------------------------------------------------------------------------- 1 | define(['jquery', 'pathtree', 'details', 'form'], function ($, PathTree, details, countForm) { 2 | return function (options) { 3 | var width = 960, 4 | height = 700, 5 | radius = (Math.min(width, height) / 2) - 10, 6 | form = countForm(), 7 | me; 8 | 9 | function arcTween(a) { 10 | var i = d3.interpolate({ x: a.x0, dx: a.dx0 }, a); 11 | return function tweener(t) { 12 | var b = i(t); 13 | a.x0 = b.x; 14 | a.dx0 = b.dx; 15 | return arc(b); 16 | }; 17 | } 18 | 19 | var x = d3.scale.linear() 20 | .range([0, 2 * Math.PI]); 21 | 22 | var y = d3.scale.sqrt() 23 | .range([0, radius]); 24 | 25 | var color = d3.scale.category20c(); 26 | 27 | var partition = d3.layout.partition() 28 | .sort(function(a, b) { return d3.ascending(a.name, b.name); }) 29 | .value(function(d) { return d.size; }); 30 | 31 | var arc = d3.svg.arc() 32 | .startAngle(function(d) { return Math.max(0, Math.min(2 * Math.PI, x(d.x))); }) 33 | .endAngle(function(d) { return Math.max(0, Math.min(2 * Math.PI, x(d.x + d.dx))); }) 34 | .innerRadius(function(d) { return Math.max(0, y(d.y)); }) 35 | .outerRadius(function(d) { return Math.max(0, y(d.y + d.dy)); }); 36 | 37 | var svg = d3.select("body").append("svg") 38 | .attr("width", width) 39 | .attr("height", height) 40 | .append("g") 41 | .attr("transform", "translate(" + width / 2 + "," + (height / 2) + ")"); 42 | 43 | function getValue() { 44 | return form.isCount() ? 45 | function () { return 1; } : 46 | function (d) { return d.size; }; 47 | } 48 | 49 | function getColor(d) { 50 | if (d.status && d.status === 'failed') { 51 | return '#f00'; 52 | } else { 53 | return color(((d.children ? d : d.parent) || {}).name); 54 | } 55 | } 56 | 57 | function stash(d) { 58 | d.x0 = d.x; 59 | d.dx0 = d.dx; 60 | } 61 | 62 | 63 | function onUpdate(path) { 64 | path 65 | .attr("d", arc) 66 | .each(stash) 67 | .style("fill", getColor); 68 | } 69 | 70 | var render = function () { 71 | var path = svg.datum(me.tree.nodes).selectAll("path") 72 | .data(partition.value(getValue()).nodes); 73 | 74 | onUpdate(path); 75 | path 76 | .enter().append("path") 77 | .on('mouseover', details.update) 78 | .attr("d", arc) 79 | .style("fill", getColor) 80 | .style("stroke", function (d) { return 'rgba(255,255,255,0.3)'; }) 81 | .style("fill-rule", "evenodd") 82 | .on("click", click); 83 | 84 | form.onChange = function (isCount) { 85 | details.isCount(isCount); 86 | 87 | path 88 | .data(partition.value(getValue()).nodes) 89 | .transition() 90 | .duration(1500) 91 | .attrTween("d", arcTween); 92 | }; 93 | 94 | render = function () { 95 | path.datum(me.tree.nodes) 96 | .data(partition.value(getValue()).nodes); 97 | }; 98 | }; 99 | 100 | function click(d) { 101 | svg.transition() 102 | .duration(750) 103 | .tween("scale", function() { 104 | var xd = d3.interpolate(x.domain(), [d.x, d.x + d.dx]), 105 | yd = d3.interpolate(y.domain(), [d.y, 1]), 106 | yr = d3.interpolate(y.range(), [d.y ? 20 : 0, radius]); 107 | return function(t) { x.domain(xd(t)); y.domain(yd(t)).range(yr(t)); }; 108 | }) 109 | .selectAll("path") 110 | .attrTween("d", function(d) { return function() { return arc(d); }; }); 111 | } 112 | 113 | d3.select(self.frameElement).style("height", height + "px"); 114 | 115 | return me = { 116 | tree: new PathTree(), 117 | 118 | render: render, 119 | 120 | reset: function () { 121 | me.tree = new PathTree(); 122 | me.render(); 123 | }, 124 | 125 | push: function (data) { 126 | me.tree.add(data); 127 | me.render(); 128 | } 129 | }; 130 | } 131 | }); 132 | -------------------------------------------------------------------------------- /lib/rspectacles/app/public/js/chart.js: -------------------------------------------------------------------------------- 1 | // @format 2 | /*global define: true d3: true */ 3 | define(['jquery', 'pathtree', 'details'], function( 4 | $, 5 | PathTree, 6 | details, 7 | ) { 8 | 'use strict'; 9 | 10 | function chart(options) { 11 | var svg, 12 | partition, 13 | arc, 14 | me, 15 | tmpl = $('#template').html(), 16 | render; 17 | 18 | options = $.extend( 19 | { 20 | width: 960, 21 | height: 700, 22 | color: d3.scale.category20c(), 23 | isCount: false, 24 | }, 25 | options, 26 | ); 27 | 28 | options.radius = Math.min(options.width, options.height) / 2; 29 | 30 | svg = d3 31 | .select('body') 32 | .append('svg') 33 | .attr('width', options.width) 34 | .attr('height', options.height) 35 | .append('g') 36 | .attr( 37 | 'transform', 38 | 'translate(' + options.width / 2 + ',' + options.height * 0.52 + ')', 39 | ); 40 | 41 | partition = d3.layout 42 | .partition() 43 | .sort(null) 44 | .size([2 * Math.PI, options.radius * options.radius]); 45 | 46 | arc = d3.svg 47 | .arc() 48 | .startAngle(function(d) { 49 | return d.x; 50 | }) 51 | .endAngle(function(d) { 52 | return d.x + d.dx; 53 | }) 54 | .innerRadius(function(d) { 55 | return Math.sqrt(d.y); 56 | }) 57 | .outerRadius(function(d) { 58 | return Math.sqrt(d.y + d.dy); 59 | }); 60 | 61 | // Stash the old values for transition. 62 | function stash(d) { 63 | d.x0 = d.x; 64 | d.dx0 = d.dx; 65 | } 66 | 67 | // Interpolate the arcs in data space. 68 | function arcTween(a) { 69 | var i = d3.interpolate({x: a.x0, dx: a.dx0}, a); 70 | return function tweener(t) { 71 | var b = i(t); 72 | a.x0 = b.x; 73 | a.dx0 = b.dx; 74 | return arc(b); 75 | }; 76 | } 77 | 78 | function secToMin(time) { 79 | var pad = function(val) { 80 | return ('00' + val).slice(-2); 81 | }, 82 | min = parseInt(time / 60), 83 | sec = parseInt(time % 60); 84 | 85 | return pad(min) + ':' + pad(sec); 86 | } 87 | 88 | function getValue() { 89 | return options.isCount 90 | ? function() { 91 | return 1; 92 | } 93 | : function(d) { 94 | return d.size; 95 | }; 96 | } 97 | 98 | function getColor(d) { 99 | if (d.status && d.status === 'failed') { 100 | return '#f00'; 101 | } else { 102 | return options.color(((d.children ? d : d.parent) || {}).name); 103 | } 104 | } 105 | 106 | function onUpdate(path) { 107 | path 108 | .attr('d', arc) 109 | .each(stash) 110 | .style('fill', getColor) 111 | .call(details.update); 112 | } 113 | 114 | function onEnter(path) { 115 | path 116 | .enter() 117 | .append('path') 118 | .attr('display', function(d) { 119 | return d.depth ? null : 'none'; 120 | }) 121 | .attr('d', arc) 122 | .style('stroke', function(d) { 123 | return 'rgba(255,255,255,0.3)'; 124 | }) 125 | .style('fill', getColor) 126 | .style('fill-rule', 'evenodd') 127 | .each(stash) 128 | .on('mouseover', details.update) 129 | .call(details.update); 130 | } 131 | 132 | function onExit(path) { 133 | path.exit().remove(); 134 | } 135 | 136 | function onFormChange(path) { 137 | d3.selectAll('input').on('change', function change() { 138 | options.isCount = this.value === 'count'; 139 | 140 | path 141 | .data(partition.value(getValue()).nodes) 142 | .transition() 143 | .duration(1500) 144 | .attrTween('d', arcTween); 145 | }); 146 | } 147 | 148 | render = function() { 149 | var path = svg 150 | .datum(me.tree.nodes) 151 | .selectAll('path') 152 | .data(partition.value(getValue()).nodes); 153 | 154 | onUpdate(path); 155 | onEnter(path); 156 | onExit(path); 157 | onFormChange(path); 158 | 159 | render = function() { 160 | path.datum(me.tree.nodes).data(partition.value(getValue()).nodes); 161 | }; 162 | }; 163 | 164 | return (me = { 165 | tree: new PathTree(), 166 | 167 | render: render, 168 | 169 | reset: function() { 170 | me.tree = new PathTree(); 171 | me.render(); 172 | }, 173 | 174 | push: function(data) { 175 | me.tree.add(data); 176 | me.render(); 177 | }, 178 | }); 179 | } 180 | 181 | return chart; 182 | }); 183 | -------------------------------------------------------------------------------- /spec/javascripts/resources/qunit.css: -------------------------------------------------------------------------------- 1 | /** 2 | * QUnit v1.12.0 - A JavaScript Unit Testing Framework 3 | * 4 | * http://qunitjs.com 5 | * 6 | * Copyright 2012 jQuery Foundation and other contributors 7 | * Released under the MIT license. 8 | * http://jquery.org/license 9 | */ 10 | 11 | /** Font Family and Sizes */ 12 | 13 | #qunit-tests, #qunit-header, #qunit-banner, #qunit-testrunner-toolbar, #qunit-userAgent, #qunit-testresult { 14 | font-family: "Helvetica Neue Light", "HelveticaNeue-Light", "Helvetica Neue", Calibri, Helvetica, Arial, sans-serif; 15 | } 16 | 17 | #qunit-testrunner-toolbar, #qunit-userAgent, #qunit-testresult, #qunit-tests li { font-size: small; } 18 | #qunit-tests { font-size: smaller; } 19 | 20 | 21 | /** Resets */ 22 | 23 | #qunit-tests, #qunit-header, #qunit-banner, #qunit-userAgent, #qunit-testresult, #qunit-modulefilter { 24 | margin: 0; 25 | padding: 0; 26 | } 27 | 28 | 29 | /** Header */ 30 | 31 | #qunit-header { 32 | padding: 0.5em 0 0.5em 1em; 33 | 34 | color: #8699a4; 35 | background-color: #0d3349; 36 | 37 | font-size: 1.5em; 38 | line-height: 1em; 39 | font-weight: normal; 40 | 41 | border-radius: 5px 5px 0 0; 42 | -moz-border-radius: 5px 5px 0 0; 43 | -webkit-border-top-right-radius: 5px; 44 | -webkit-border-top-left-radius: 5px; 45 | } 46 | 47 | #qunit-header a { 48 | text-decoration: none; 49 | color: #c2ccd1; 50 | } 51 | 52 | #qunit-header a:hover, 53 | #qunit-header a:focus { 54 | color: #fff; 55 | } 56 | 57 | #qunit-testrunner-toolbar label { 58 | display: inline-block; 59 | padding: 0 .5em 0 .1em; 60 | } 61 | 62 | #qunit-banner { 63 | height: 5px; 64 | } 65 | 66 | #qunit-testrunner-toolbar { 67 | padding: 0.5em 0 0.5em 2em; 68 | color: #5E740B; 69 | background-color: #eee; 70 | overflow: hidden; 71 | } 72 | 73 | #qunit-userAgent { 74 | padding: 0.5em 0 0.5em 2.5em; 75 | background-color: #2b81af; 76 | color: #fff; 77 | text-shadow: rgba(0, 0, 0, 0.5) 2px 2px 1px; 78 | } 79 | 80 | #qunit-modulefilter-container { 81 | float: right; 82 | } 83 | 84 | /** Tests: Pass/Fail */ 85 | 86 | #qunit-tests { 87 | list-style-position: inside; 88 | } 89 | 90 | #qunit-tests li { 91 | padding: 0.4em 0.5em 0.4em 2.5em; 92 | border-bottom: 1px solid #fff; 93 | list-style-position: inside; 94 | } 95 | 96 | #qunit-tests.hidepass li.pass, #qunit-tests.hidepass li.running { 97 | display: none; 98 | } 99 | 100 | #qunit-tests li strong { 101 | cursor: pointer; 102 | } 103 | 104 | #qunit-tests li a { 105 | padding: 0.5em; 106 | color: #c2ccd1; 107 | text-decoration: none; 108 | } 109 | #qunit-tests li a:hover, 110 | #qunit-tests li a:focus { 111 | color: #000; 112 | } 113 | 114 | #qunit-tests li .runtime { 115 | float: right; 116 | font-size: smaller; 117 | } 118 | 119 | .qunit-assert-list { 120 | margin-top: 0.5em; 121 | padding: 0.5em; 122 | 123 | background-color: #fff; 124 | 125 | border-radius: 5px; 126 | -moz-border-radius: 5px; 127 | -webkit-border-radius: 5px; 128 | } 129 | 130 | .qunit-collapsed { 131 | display: none; 132 | } 133 | 134 | #qunit-tests table { 135 | border-collapse: collapse; 136 | margin-top: .2em; 137 | } 138 | 139 | #qunit-tests th { 140 | text-align: right; 141 | vertical-align: top; 142 | padding: 0 .5em 0 0; 143 | } 144 | 145 | #qunit-tests td { 146 | vertical-align: top; 147 | } 148 | 149 | #qunit-tests pre { 150 | margin: 0; 151 | white-space: pre-wrap; 152 | word-wrap: break-word; 153 | } 154 | 155 | #qunit-tests del { 156 | background-color: #e0f2be; 157 | color: #374e0c; 158 | text-decoration: none; 159 | } 160 | 161 | #qunit-tests ins { 162 | background-color: #ffcaca; 163 | color: #500; 164 | text-decoration: none; 165 | } 166 | 167 | /*** Test Counts */ 168 | 169 | #qunit-tests b.counts { color: black; } 170 | #qunit-tests b.passed { color: #5E740B; } 171 | #qunit-tests b.failed { color: #710909; } 172 | 173 | #qunit-tests li li { 174 | padding: 5px; 175 | background-color: #fff; 176 | border-bottom: none; 177 | list-style-position: inside; 178 | } 179 | 180 | /*** Passing Styles */ 181 | 182 | #qunit-tests li li.pass { 183 | color: #3c510c; 184 | background-color: #fff; 185 | border-left: 10px solid #C6E746; 186 | } 187 | 188 | #qunit-tests .pass { color: #528CE0; background-color: #D2E0E6; } 189 | #qunit-tests .pass .test-name { color: #366097; } 190 | 191 | #qunit-tests .pass .test-actual, 192 | #qunit-tests .pass .test-expected { color: #999999; } 193 | 194 | #qunit-banner.qunit-pass { background-color: #C6E746; } 195 | 196 | /*** Failing Styles */ 197 | 198 | #qunit-tests li li.fail { 199 | color: #710909; 200 | background-color: #fff; 201 | border-left: 10px solid #EE5757; 202 | white-space: pre; 203 | } 204 | 205 | #qunit-tests > li:last-child { 206 | border-radius: 0 0 5px 5px; 207 | -moz-border-radius: 0 0 5px 5px; 208 | -webkit-border-bottom-right-radius: 5px; 209 | -webkit-border-bottom-left-radius: 5px; 210 | } 211 | 212 | #qunit-tests .fail { color: #000000; background-color: #EE5757; } 213 | #qunit-tests .fail .test-name, 214 | #qunit-tests .fail .module-name { color: #000000; } 215 | 216 | #qunit-tests .fail .test-actual { color: #EE5757; } 217 | #qunit-tests .fail .test-expected { color: green; } 218 | 219 | #qunit-banner.qunit-fail { background-color: #EE5757; } 220 | 221 | 222 | /** Result */ 223 | 224 | #qunit-testresult { 225 | padding: 0.5em 0.5em 0.5em 2.5em; 226 | 227 | color: #2b81af; 228 | background-color: #D2E0E6; 229 | 230 | border-bottom: 1px solid white; 231 | } 232 | #qunit-testresult .module-name { 233 | font-weight: bold; 234 | } 235 | 236 | /** Fixture */ 237 | 238 | #qunit-fixture { 239 | position: absolute; 240 | top: -10000px; 241 | left: -10000px; 242 | width: 1000px; 243 | height: 1000px; 244 | } 245 | -------------------------------------------------------------------------------- /lib/rspectacles/app/public/js/riffle.js: -------------------------------------------------------------------------------- 1 | (function (definition) { 2 | // AMD 3 | if (typeof define === "function") { 4 | define(definition); 5 | // CommonJS 6 | } else if (typeof exports === "object") { 7 | definition(this, exports); 8 | // Browser 9 | } else { 10 | definition(this); 11 | } 12 | }(function (require, exports, module) { 13 | "use strict"; 14 | var old, 15 | _; 16 | 17 | exports = exports || window; 18 | 19 | if (!window.setTimeout) { 20 | return; 21 | } 22 | 23 | function stream(userSuppliedStreamFn) { 24 | var chain = {}; 25 | 26 | function defaultStreamFn(output) { 27 | var inputs = _.argumentsToArray(arguments); 28 | inputs.shift(); 29 | if (inputs.length === 0) { 30 | window.setTimeout(function () { 31 | output(); 32 | }, 0); 33 | } 34 | _.each(inputs, function invokeOutput(input) { 35 | if (!_.isUndefined(input)) { 36 | window.setTimeout(function delayedInvokeOutput() { 37 | output(input); 38 | }, 0); 39 | } 40 | }); 41 | } 42 | 43 | (function () { 44 | var outputFns = [], streamFn; 45 | function outputAllFns() { 46 | var outputs = _.argumentsToArray(arguments); 47 | _.each(outputFns, function applyFunction(f) { 48 | _.applyArgsToFn(f, outputs); 49 | }); 50 | } 51 | streamFn = _.isFunction(userSuppliedStreamFn) ? userSuppliedStreamFn : defaultStreamFn; 52 | chain.invoke = function invoke() { 53 | var outputs = _.argumentsToArray(arguments); 54 | outputs.unshift(outputAllFns); 55 | _.applyArgsToFn(streamFn, outputs); 56 | return chain; 57 | }; 58 | chain.onOutput = function onOutput(f) { 59 | if (!_.isFunction(f)) { 60 | throw new Error('onOutput expecting callback function'); 61 | } 62 | outputFns.push(f); 63 | return chain; 64 | }; 65 | chain.offOutput = function offOutput(f) { 66 | if (!_.isFunction(f)) { 67 | throw new Error('offOutput expecting callback function'); 68 | } 69 | outputFns = _.reject(outputFns, function isSameAsReferenceInScope(x) { 70 | return x === f; 71 | }); 72 | return chain; 73 | }; 74 | }()); 75 | 76 | (function () { 77 | var callbacks = [], 78 | inputStreams = []; 79 | function wait(streams, idx) { 80 | var unbindStreams = inputStreams[idx]; 81 | function invokeWithOneArg(x) { 82 | var outputs = []; 83 | outputs.length = idx + 1; 84 | outputs[idx] = x; 85 | chain.invoke.apply(window, _.isUndefined(x) ? [] : outputs); 86 | } 87 | if (unbindStreams) { 88 | _.each(unbindStreams, function unbindInputs(s) { 89 | s.offOutput(callbacks[idx]); 90 | }); 91 | delete callbacks[idx]; 92 | delete inputStreams[idx]; 93 | } 94 | _.each(streams, function registerOnOutput(stream) { 95 | stream.onOutput(invokeWithOneArg); 96 | }); 97 | callbacks[idx] = invokeWithOneArg; 98 | inputStreams[idx] = streams; 99 | } 100 | chain.input = function input() { 101 | var i, removeStreamIndexes = []; 102 | _.each(arguments, function bindInputs(inputs, inIdx) { 103 | if (stream.isStream(inputs)) { 104 | inputs = [inputs]; 105 | } 106 | if (_.isArray(inputs)) { 107 | inputs = _.reject(inputs, function isNotStream(obj) { 108 | return !stream.isStream(obj); 109 | }); 110 | wait(inputs, inIdx); 111 | } 112 | }); 113 | for (i = arguments.length; i < inputStreams.length; i += 1) { 114 | removeStreamIndexes.push(i); 115 | } 116 | _.each(removeStreamIndexes, function (idx) { 117 | _.each(inputStreams[idx], function (stream) { stream.offOutput(callbacks[idx]); }); 118 | delete callbacks[idx]; 119 | delete inputStreams[idx]; 120 | }); 121 | return chain; 122 | }; 123 | }()); 124 | return chain; 125 | } 126 | 127 | stream.isStream = function isStream(x) { 128 | return !!(x && x.invoke && x.onOutput && x.offOutput && x.input); 129 | }; 130 | 131 | old = exports.stream; 132 | stream.noConflict = function noConflict() { 133 | exports.stream = old; 134 | return stream; 135 | }; 136 | exports.stream = stream; 137 | 138 | _ = { 139 | breaker: {}, 140 | arrayProto: Array.prototype, 141 | objProto: Object.prototype, 142 | isArray: Array.isArray || function isArray(obj) { 143 | return _.objProto.toString.call(obj) === '[object Array]'; 144 | }, 145 | isFunction: function isFunction(obj) { 146 | return _.objProto.toString.call(obj) === '[object Function]'; 147 | }, 148 | isUndefined: function isUndefined(obj) { 149 | return obj === void 0; 150 | }, 151 | argumentsToArray: function argumentsToArray(args) { 152 | return _.arrayProto.slice.call(args); 153 | }, 154 | applyArgsToFn: function applyArgsToFn(fn, args) { 155 | try { 156 | fn.apply(window, args); 157 | } catch (e) { 158 | if (console && console.exception) { 159 | console.exception(e); 160 | } 161 | } 162 | }, 163 | each: function each(obj, iterator, context) { 164 | var i, l, key; 165 | if (obj === null) { 166 | return; 167 | } 168 | if (_.arrayProto.forEach && obj.forEach === _.arrayProto.forEach) { 169 | obj.forEach(iterator, context); 170 | } else if (obj.length === +obj.length) { 171 | for (i = 0, l = obj.length; i < l; i += 1) { 172 | if (obj.hasOwnProperty(i) && iterator.call(context, obj[i], i, obj) === _.breaker) { 173 | return; 174 | } 175 | } 176 | } else { 177 | for (key in obj) { 178 | if (obj.hasOwnProperty(key)) { 179 | if (iterator.call(context, obj[key], key, obj) === _.breaker) { 180 | return; 181 | } 182 | } 183 | } 184 | } 185 | }, 186 | reject: function reject(obj, iterator, context) { 187 | var results = []; 188 | if (obj === null) { 189 | return results; 190 | } 191 | _.each(obj, function exclude(value, index, list) { 192 | if (!iterator.call(context, value, index, list)) { 193 | results[results.length] = value; 194 | } 195 | }); 196 | return results; 197 | } 198 | }; 199 | 200 | })); 201 | -------------------------------------------------------------------------------- /lib/rspectacles/app/public/js/mustache.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * mustache.js - Logic-less {{mustache}} templates with JavaScript 3 | * http://github.com/janl/mustache.js 4 | */ 5 | 6 | /*global define: false*/ 7 | 8 | (function (root, factory) { 9 | if (typeof exports === "object" && exports) { 10 | factory(exports); // CommonJS 11 | } else { 12 | var mustache = {}; 13 | factory(mustache); 14 | if (typeof define === "function" && define.amd) { 15 | define(mustache); // AMD 16 | } else { 17 | root.Mustache = mustache; //