├── lib ├── blade │ ├── version.rb │ ├── component.rb │ ├── config.rb │ ├── session.rb │ ├── cli.rb │ ├── model.rb │ ├── rack │ │ ├── router.rb │ │ └── adapter.rb │ ├── server.rb │ ├── test_results.rb │ ├── interface │ │ ├── ci.rb │ │ ├── runner.rb │ │ └── runner │ │ │ └── tab.rb │ ├── assets │ │ └── builder.rb │ └── assets.rb └── blade.rb ├── exe └── blade ├── Gemfile ├── .gitignore ├── bin ├── setup ├── console └── rake ├── test ├── blade_test.rb └── test_helper.rb ├── Rakefile ├── assets └── blade │ ├── index.html.erb │ └── index.coffee ├── LICENSE.txt ├── blade.gemspec └── README.md /lib/blade/version.rb: -------------------------------------------------------------------------------- 1 | module Blade 2 | VERSION = "0.7.3" 3 | end 4 | -------------------------------------------------------------------------------- /exe/blade: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "blade" 4 | Blade::CLI.start(ARGV) 5 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Specify your gem's dependencies in blade.gemspec 4 | gemspec 5 | -------------------------------------------------------------------------------- /lib/blade/component.rb: -------------------------------------------------------------------------------- 1 | module Blade::Component 2 | def self.included(base) 3 | Blade.register_component(base) 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /Gemfile.lock 4 | /_yardoc/ 5 | /coverage/ 6 | /doc/ 7 | /pkg/ 8 | /spec/reports/ 9 | /tmp/ 10 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | 5 | bundle install 6 | 7 | # Do any other automated setup that you need to do here 8 | -------------------------------------------------------------------------------- /test/blade_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class BladeTest < Blade::TestCase 4 | test "initialize" do 5 | assert Blade.initialize! 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | require "blade" 2 | require "minitest/autorun" 3 | 4 | ActiveSupport.test_order = :random 5 | 6 | class Blade::TestCase < ActiveSupport::TestCase 7 | end 8 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "rake" 2 | require "rake/testtask" 3 | require "bundler/gem_tasks" 4 | 5 | task default: :test 6 | 7 | Rake::TestTask.new(:test) do |t| 8 | t.libs << "test" 9 | t.pattern = "test/*_test.rb" 10 | t.verbose = true 11 | end 12 | Rake::Task[:test].comment = "Run tests" 13 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "bundler/setup" 4 | require "blade" 5 | 6 | # You can add fixtures and/or initialization code here to make experimenting 7 | # with your gem easier. You can also use a different console, if you like. 8 | 9 | # (If you use this, don't forget to add pry to your Gemfile!) 10 | # require "pry" 11 | # Pry.start 12 | 13 | require "irb" 14 | IRB.start 15 | -------------------------------------------------------------------------------- /bin/rake: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # 3 | # This file was generated by Bundler. 4 | # 5 | # The application 'rake' is installed as part of a gem, and 6 | # this file is here to facilitate running it. 7 | # 8 | 9 | require "pathname" 10 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile", 11 | Pathname.new(__FILE__).realpath) 12 | 13 | require "rubygems" 14 | require "bundler/setup" 15 | 16 | load Gem.bin_path("rake", "rake") 17 | -------------------------------------------------------------------------------- /lib/blade/config.rb: -------------------------------------------------------------------------------- 1 | class Blade::Config < ActiveSupport::HashWithIndifferentAccess 2 | def method_missing(method, *args) 3 | case method 4 | when /=$/ 5 | self[$`] = args.first 6 | when /\?$/ 7 | self[$`].present? 8 | else 9 | if self[method].is_a?(Hash) && !self[method].is_a?(self.class) 10 | self[method] = self.class.new(self[method]) 11 | end 12 | self[method] 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/blade/session.rb: -------------------------------------------------------------------------------- 1 | class Blade::Session < Blade::Model 2 | KEY = "blade_session" 3 | 4 | class << self 5 | def create(attributes) 6 | model = super 7 | model.test_results = Blade::TestResults.new(model.id) 8 | model 9 | end 10 | 11 | def combined_test_results 12 | Blade::CombinedTestResults.new(all) 13 | end 14 | end 15 | 16 | def to_s 17 | @to_s ||= "#{ua.browser} #{ua.version} #{ua.platform}" 18 | end 19 | 20 | private 21 | def ua 22 | user_agent 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/blade/cli.rb: -------------------------------------------------------------------------------- 1 | require "thor" 2 | 3 | class Blade::CLI < Thor 4 | desc "runner", "Start test runner in console mode" 5 | def runner 6 | Blade.start(interface: :runner) 7 | end 8 | 9 | desc "ci", "Start test runner in CI mode" 10 | def ci 11 | Blade.start(interface: :ci) 12 | end 13 | 14 | desc "build", "Build assets" 15 | def build 16 | Blade.build 17 | end 18 | 19 | desc "config", "Inspect Blade.config" 20 | def config 21 | require "pp" 22 | Blade.initialize! 23 | pp Blade.config 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/blade/model.rb: -------------------------------------------------------------------------------- 1 | require "securerandom" 2 | 3 | class Blade::Model < OpenStruct 4 | class << self 5 | def models 6 | @models ||= {} 7 | end 8 | 9 | def create(attributes) 10 | attributes[:id] ||= SecureRandom.hex(4) 11 | model = new(attributes) 12 | models[model.id] = model 13 | end 14 | 15 | def find(id) 16 | models[id] 17 | end 18 | 19 | def remove(id) 20 | models.delete(id) 21 | end 22 | 23 | def all 24 | models.values 25 | end 26 | 27 | def size 28 | models.size 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/blade/rack/router.rb: -------------------------------------------------------------------------------- 1 | module Blade::RackRouter 2 | extend ActiveSupport::Concern 3 | 4 | DEFAULT = :* 5 | 6 | included do 7 | cattr_accessor(:routes) { Hash.new } 8 | end 9 | 10 | class_methods do 11 | def route(path, action) 12 | pattern = /^\/?#{path.gsub(/\*/, ".*")}$/ 13 | base_path = path.match(/([^\*]*)\*?/)[1] 14 | routes[path] = { action: action, pattern: pattern, base_path: base_path } 15 | self.routes = routes.sort_by { |path, value| -path.size }.to_h 16 | routes[path] 17 | end 18 | 19 | def default_route(action) 20 | routes[DEFAULT] = { action: action } 21 | end 22 | 23 | def find_route(path) 24 | if route = routes.detect { |key, details| path =~ details[:pattern] } 25 | route[1] 26 | else 27 | routes[DEFAULT] 28 | end 29 | end 30 | end 31 | 32 | private 33 | def find_route(*args) 34 | self.class.find_route(*args) 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /assets/blade/index.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Blade Runner 6 | 7 | 8 | 9 | <% if environment["head.html"] %> 10 | <%= depend_on_asset("head.html").to_s %> 11 | <% end %> 12 | 13 | 14 | 15 | 16 | <% if environment["adapter/head.html"] %> 17 | <%= depend_on_asset("blade/adapter/head.html").to_s %> 18 | <% end %> 19 | 20 | <% logical_paths(:js).each do |path| %> 21 | 22 | <% end %> 23 | 24 | 25 | <% if environment["body.html"] %> 26 | <%= depend_on_asset("body.html").to_s %> 27 | <% end %> 28 | 29 | <% if environment["blade/adapter/body.html"] %> 30 | <%= depend_on_asset("blade/adapter/body.html").to_s %> 31 | <% end %> 32 | 33 | 34 | -------------------------------------------------------------------------------- /lib/blade/server.rb: -------------------------------------------------------------------------------- 1 | require "faye/websocket" 2 | require "useragent" 3 | 4 | module Blade::Server 5 | extend self 6 | include Blade::Component 7 | 8 | WEBSOCKET_PATH = "/blade/websocket" 9 | 10 | def start 11 | Faye::WebSocket.load_adapter("thin") 12 | Thin::Logging.silent = true 13 | Thin::Server.start(host, Blade.config.port, app, signals: false) 14 | end 15 | 16 | def host 17 | Thin::Server::DEFAULT_HOST 18 | end 19 | 20 | def websocket_url(path = "") 21 | Blade.url(WEBSOCKET_PATH + path) 22 | end 23 | 24 | def client 25 | @client ||= Faye::Client.new(websocket_url) 26 | end 27 | 28 | def subscribe(channel) 29 | client.subscribe(channel) do |message| 30 | yield message.with_indifferent_access 31 | end 32 | end 33 | 34 | def publish(channel, message) 35 | client.publish(channel, message) 36 | end 37 | 38 | private 39 | def app 40 | Rack::Builder.app do 41 | use Rack::ShowExceptions 42 | run Blade::RackAdapter.new 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Javan Makhmali 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /assets/blade/index.coffee: -------------------------------------------------------------------------------- 1 | @Blade = 2 | suiteDidBegin: ({total}) -> 3 | event = "begin" 4 | publish("/tests", {event, total}) 5 | 6 | testDidEnd: ({name, status, message}) -> 7 | event = "result" 8 | publish("/tests", {event, name, status, message}) 9 | 10 | suiteDidEnd: ({total}) -> 11 | event = "end" 12 | publish("/tests", {event, total}) 13 | 14 | publish = (channel, data = {}) -> 15 | client.publish(channel, copy(data, {session_id})) if session_id? 16 | 17 | copy = (object, withAttributes = {}) -> 18 | result = {} 19 | result[key] = value for key, value of object 20 | result[key] = value for key, value of withAttributes 21 | result 22 | 23 | getWebSocketURL = -> 24 | element = document.querySelector("script[data-websocket]") 25 | element.src.replace(/\/client\.js$/, "") 26 | 27 | getSessionId = -> 28 | document.cookie.match(/blade_session=(\w+)/)?[1] 29 | 30 | client = new Faye.Client(getWebSocketURL()) 31 | 32 | if session_id = getSessionId() 33 | client.subscribe "/assets", (data) -> 34 | location.reload() if data.changed 35 | 36 | setInterval -> 37 | publish("/browsers", event: "ping") 38 | , 1000 39 | -------------------------------------------------------------------------------- /lib/blade/test_results.rb: -------------------------------------------------------------------------------- 1 | class Blade::TestResults 2 | STATUS_DOTS = { pass: ".", fail: "✗" }.with_indifferent_access 3 | 4 | attr_reader :session_id, :state, :results, :total, :failures 5 | 6 | def initialize(session_id) 7 | @session_id = session_id 8 | reset 9 | 10 | Blade.subscribe("/tests") do |details| 11 | if details[:session_id] == session_id 12 | event = details.delete(:event) 13 | try("process_#{event}", details) 14 | end 15 | end 16 | end 17 | 18 | def reset 19 | @results = [] 20 | @state = "pending" 21 | @total = 0 22 | @failures = 0 23 | end 24 | 25 | def process_begin(details) 26 | reset 27 | @state = "running" 28 | @total = details[:total] 29 | publish(total: @total) 30 | end 31 | 32 | def process_result(details) 33 | result = details.slice(:status, :name, :message) 34 | @results << result 35 | 36 | if result[:status] == "fail" 37 | @state = "failing" 38 | @failures += 1 39 | end 40 | 41 | publish(result) 42 | end 43 | 44 | def process_end(details) 45 | @state = failures.zero? ? "finished" : "failed" 46 | publish(completed: true) 47 | end 48 | 49 | def publish(message = {}) 50 | Blade.publish("/results", message.merge(state: state, session_id: session_id)) 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /lib/blade/interface/ci.rb: -------------------------------------------------------------------------------- 1 | module Blade::CI 2 | extend self 3 | include Blade::Component 4 | 5 | def start 6 | @completed_sessions = 0 7 | @failures = [] 8 | 9 | Blade.subscribe("/results") do |details| 10 | process_result(details) 11 | end 12 | end 13 | 14 | private 15 | def process_result(details) 16 | if status = details[:status] 17 | STDOUT.print status_dot(status) 18 | 19 | if status == "fail" 20 | @failures << details 21 | end 22 | end 23 | 24 | if details[:completed] 25 | process_completion 26 | end 27 | end 28 | 29 | def process_completion 30 | @completed_sessions += 1 31 | 32 | if done? 33 | EM.add_timer 2 do 34 | display_failures 35 | STDOUT.puts 36 | exit_with_status_code 37 | end 38 | end 39 | end 40 | 41 | def status_dot(status) 42 | Blade::TestResults::STATUS_DOTS[status] 43 | end 44 | 45 | def done? 46 | @completed_sessions == (Blade.config.expected_sessions || 1) 47 | end 48 | 49 | def display_failures 50 | @failures.each do |details| 51 | STDERR.puts "\n\n#{status_dot(details[:status])} #{details[:name]} (#{Blade::Session.find(details[:session_id])})" 52 | STDERR.puts details[:message] 53 | end 54 | end 55 | 56 | def exit_with_status_code 57 | exit @failures.any? ? 1 : 0 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /blade.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'blade/version' 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = "blade" 8 | spec.version = Blade::VERSION 9 | spec.authors = ["Javan Makhmali"] 10 | spec.email = ["javan@javan.us"] 11 | 12 | spec.summary = %q{Blade} 13 | spec.description = %q{Sprockets test runner and toolkit} 14 | spec.homepage = "https://github.com/javan/blade" 15 | spec.license = "MIT" 16 | 17 | spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } 18 | spec.bindir = "exe" 19 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 20 | spec.require_paths = ["lib"] 21 | 22 | spec.add_development_dependency "bundler", ">= 2.0" 23 | spec.add_development_dependency "rake", ">= 10.0" 24 | 25 | spec.add_dependency "blade-qunit_adapter", ">= 2.0.1" 26 | spec.add_dependency "activesupport", ">= 3.0.0" 27 | spec.add_dependency "coffee-script" 28 | spec.add_dependency "coffee-script-source" 29 | spec.add_dependency "curses", ">= 1.4.0" 30 | spec.add_dependency "eventmachine" 31 | spec.add_dependency "faye" 32 | spec.add_dependency "sprockets", ">= 3.0" 33 | spec.add_dependency "thin", ">= 1.6.0" 34 | spec.add_dependency "useragent", ">= 0.16.7" 35 | spec.add_dependency "thor", ">= 0.19.1" 36 | end 37 | -------------------------------------------------------------------------------- /lib/blade/assets/builder.rb: -------------------------------------------------------------------------------- 1 | class Blade::Assets::Builder 2 | attr_accessor :environment 3 | 4 | def initialize(environment) 5 | @environment = environment 6 | end 7 | 8 | def build 9 | puts "Building assets…" 10 | 11 | clean 12 | compile 13 | clean_dist_path 14 | create_dist_path 15 | install 16 | end 17 | 18 | private 19 | def compile 20 | environment.js_compressor = Blade.config.build.js_compressor.try(:to_sym) 21 | environment.css_compressor = Blade.config.build.css_compressor.try(:to_sym) 22 | manifest.compile(logical_paths) 23 | end 24 | 25 | def install 26 | logical_paths.each do |logical_path| 27 | fingerprint_path = manifest.assets[logical_path] 28 | source_path = compile_path.join(fingerprint_path) 29 | destination_path = dist_path.join(logical_path) 30 | FileUtils.cp(source_path, destination_path) 31 | puts "[created] #{destination_path}" 32 | end 33 | end 34 | 35 | def manifest 36 | @manifest ||= Sprockets::Manifest.new(environment.index, compile_path) 37 | end 38 | 39 | def clean 40 | compile_path.rmtree if compile_path.exist? 41 | compile_path.mkpath 42 | end 43 | 44 | def logical_paths 45 | Blade.config.build.logical_paths 46 | end 47 | 48 | def create_dist_path 49 | dist_path.mkpath unless dist_path.exist? 50 | end 51 | 52 | def clean_dist_path 53 | if clean_dist_path? 54 | children = dist_path.children 55 | dist_path.rmtree 56 | children.each do |child| 57 | puts "[removed] #{child}" 58 | end 59 | end 60 | end 61 | 62 | def clean_dist_path? 63 | Blade.config.build.clean && dist_path.exist? 64 | end 65 | 66 | def dist_path 67 | @dist_path ||= Pathname.new(Blade.config.build.path) 68 | end 69 | 70 | def compile_path 71 | @compile_path ||= Blade.tmp_path.join("compile") 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /lib/blade/rack/adapter.rb: -------------------------------------------------------------------------------- 1 | class Blade::RackAdapter 2 | include Blade::RackRouter 3 | 4 | route "", to: :redirect_to_index 5 | route "/", to: :index 6 | route "/blade/websocket*", to: :websocket 7 | default_route to: :environment 8 | 9 | attr_reader :request, :env 10 | 11 | def initialize 12 | Blade.initialize! 13 | end 14 | 15 | def call(env) 16 | @env = env 17 | @request = Rack::Request.new(env) 18 | 19 | route = find_route(request.path_info) 20 | base_path, action = route.values_at(:base_path, :action) 21 | 22 | rewrite_path!(base_path) 23 | 24 | send(action[:to]) 25 | end 26 | 27 | def index 28 | request.path_info = "/blade/index.html" 29 | response = environment 30 | response = add_session_cookie(response) if needs_session_cookie? 31 | response.to_a 32 | end 33 | 34 | def redirect_to_index 35 | Rack::Response.new.tap do |response| 36 | path = request.path 37 | path = path + "/" unless path.last == "/" 38 | response.redirect(path) 39 | end.to_a 40 | end 41 | 42 | def websocket 43 | faye_adapter.call(env) 44 | end 45 | 46 | def environment 47 | Blade::Assets.environment.call(env) 48 | end 49 | 50 | private 51 | def needs_session_cookie? 52 | Blade.running? && !Blade::Session.find(request.cookies[Blade::Session::KEY]) 53 | end 54 | 55 | def add_session_cookie(response) 56 | user_agent = UserAgent.parse(request.user_agent) 57 | session = Blade::Session.create(user_agent: user_agent) 58 | status, headers, body = response 59 | response = Rack::Response.new(body, status, headers) 60 | response.set_cookie(Blade::Session::KEY, session.id) 61 | response 62 | end 63 | 64 | def rewrite_path!(path = nil) 65 | return if path.nil? 66 | request.path_info = request.path_info.sub(path, "").presence || "/" 67 | request.script_name = request.script_name + path 68 | end 69 | 70 | def faye_adapter 71 | @faye_adapter ||= Faye::RackAdapter.new(mount: "/", timeout: 25) 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Blade 2 | ### A [Sprockets](https://github.com/rails/sprockets) Toolkit for Building and Testing JavaScript Libraries 3 | 4 | ## Getting Started 5 | 6 | Add Blade to your `Gemfile`. 7 | 8 | ```ruby 9 | source "https://rubygems.org" 10 | 11 | gem 'blade' 12 | ``` 13 | 14 | Create a `.blade.yml` (or `blade.yml`) file in your project’s root, and define your Sprockets [load paths](https://github.com/rails/sprockets#the-load-path) and [logical paths](https://github.com/rails/sprockets#logical-paths). Example: 15 | 16 | ```yaml 17 | # .blade.yml 18 | load_paths: 19 | - src 20 | - test/src 21 | - test/vendor 22 | 23 | logical_paths: 24 | - widget.js 25 | - test.js 26 | ``` 27 | 28 | ## Compiling 29 | 30 | Configure your build paths and [compressors](https://github.com/rails/sprockets#minifying-assets): 31 | 32 | ```yaml 33 | # .blade.yml 34 | … 35 | build: 36 | logical_paths: 37 | - widget.js 38 | path: dist 39 | js_compressor: uglifier # Optional 40 | ``` 41 | 42 | Run `bundle exec blade build` to compile `dist/widget.js`. 43 | 44 | ## Testing Locally 45 | 46 | By default, Blade sets up a test runner using [QUnit](http://qunitjs.com/) via the [blade-qunit_adapter](https://github.com/javan/blade-qunit_adapter) gem. 47 | 48 | Run `bundle exec blade runner` to launch Blade’s test console and open the URL it displays in one or more browsers. Blade detects changes to your logical paths and automatically restarts the test suite. 49 | 50 | ![Blade Runner](https://cloud.githubusercontent.com/assets/5355/15481643/8aef7c98-20f9-11e6-9826-80a32ce7568c.png) 51 | 52 | ## Testing on CI 53 | 54 | Run `bundle exec blade ci` to start Blade’s test console in non-interactive CI mode, and launch a browser pointed at Blade’s testing URL (usually http://localhost:9876). The process will return `0` on success and non-zero on failure. 55 | 56 | To test on multiple browsers with [Sauce Labs](https://saucelabs.com/), see the [Sauce Labs plugin](https://github.com/javan/blade-sauce_labs_plugin). 57 | 58 | ## Projects Using Blade 59 | 60 | * [Trix](https://github.com/basecamp/trix) 61 | * [Turbolinks](https://github.com/turbolinks/turbolinks) 62 | * [Action Cable](https://github.com/rails/rails/tree/master/actioncable) 63 | 64 | --- 65 | 66 | Licensed under the [MIT License](LICENSE.txt) 67 | 68 | © 2016 Javan Makhmali 69 | -------------------------------------------------------------------------------- /lib/blade/assets.rb: -------------------------------------------------------------------------------- 1 | require "sprockets" 2 | 3 | module Blade::Assets 4 | autoload :Builder, "blade/assets/builder" 5 | 6 | extend self 7 | 8 | def environment 9 | @environment ||= Sprockets::Environment.new do |env| 10 | env.cache = Sprockets::Cache::FileStore.new(Blade.tmp_path) 11 | 12 | %w( blade user adapter ).each do |name| 13 | send("#{name}_load_paths").each do |path| 14 | env.append_path(path) 15 | end 16 | end 17 | 18 | env.context_class.class_eval do 19 | delegate :logical_paths, to: Blade::Assets 20 | end 21 | end 22 | end 23 | 24 | def build 25 | if Blade.config.build 26 | Builder.new(environment).build 27 | end 28 | end 29 | 30 | def logical_paths(type = nil) 31 | paths = Blade.config.logical_paths 32 | paths.select! { |path| File.extname(path) == ".#{type}" } if type 33 | paths 34 | end 35 | 36 | def blade_load_paths 37 | [ Blade.root_path.join("assets") ] 38 | end 39 | 40 | def user_load_paths 41 | Blade.config.load_paths.flat_map do |load_path| 42 | if load_path.is_a?(Hash) 43 | load_path.flat_map do |gem_name, paths| 44 | Array(paths).map{ |path| gem_pathname(gem_name).join(path) } 45 | end 46 | else 47 | Pathname.new(load_path) 48 | end 49 | end 50 | end 51 | 52 | def adapter_load_paths 53 | gem_name = "blade-#{Blade.config.framework}_adapter" 54 | [ gem_pathname(gem_name).join("assets") ] 55 | end 56 | 57 | def watch_logical_paths 58 | @mtimes = get_mtimes 59 | 60 | EM.add_periodic_timer(1) do 61 | mtimes = get_mtimes 62 | unless mtimes == @mtimes 63 | @mtimes = mtimes 64 | Blade.publish("/assets", changed: @mtimes) 65 | end 66 | end 67 | end 68 | 69 | private 70 | def get_mtimes 71 | {}.tap do |mtimes| 72 | Blade.config.logical_paths.each do |path| 73 | mtimes[path] = get_mtime(path) 74 | end 75 | end 76 | end 77 | 78 | def get_mtime(logical_path) 79 | environment[logical_path].mtime 80 | rescue Exception => e 81 | e.to_s 82 | end 83 | 84 | def gem_pathname(gem_name) 85 | gemspec = Gem::Specification.find_by_name(gem_name) 86 | Pathname.new(gemspec.gem_dir) 87 | end 88 | end 89 | -------------------------------------------------------------------------------- /lib/blade/interface/runner.rb: -------------------------------------------------------------------------------- 1 | require "curses" 2 | 3 | module Blade::Runner 4 | extend self 5 | include Blade::Component 6 | 7 | autoload :Tab, "blade/interface/runner/tab" 8 | 9 | COLOR_NAMES = %w( white yellow green red ) 10 | PADDING = 1 11 | 12 | def colors 13 | @colors ||= OpenStruct.new.tap do |colors| 14 | COLOR_NAMES.each do |name| 15 | const = Curses.const_get("COLOR_#{name.upcase}") 16 | Curses.init_pair(const, const, Curses::COLOR_BLACK) 17 | colors[name] = Curses.color_pair(const) 18 | end 19 | end 20 | end 21 | 22 | def create_window(options = {}) 23 | height = options[:height] || 0 24 | width = options[:width] || 0 25 | top = options[:top] || 0 26 | left = options[:left] || PADDING 27 | parent = options[:parent] || Curses.stdscr 28 | 29 | parent.subwin(height, width, top, left) 30 | end 31 | 32 | def start 33 | run 34 | Blade::Assets.watch_logical_paths 35 | end 36 | 37 | def stop 38 | Curses.close_screen 39 | end 40 | 41 | def run 42 | start_screen 43 | init_windows 44 | handle_keys 45 | handle_stale_tabs 46 | 47 | Blade.subscribe("/results") do |details| 48 | session = Blade::Session.find(details[:session_id]) 49 | 50 | unless tab = Tab.find(session.id) 51 | tab = Tab.create(id: session.id) 52 | tab.activate if Tab.size == 1 53 | end 54 | 55 | tab.draw 56 | Curses.doupdate 57 | end 58 | end 59 | 60 | private 61 | def start_screen 62 | Curses.init_screen 63 | Curses.start_color 64 | Curses.noecho 65 | Curses.curs_set(0) 66 | Curses.stdscr.keypad(true) 67 | end 68 | 69 | def init_windows 70 | header_window = create_window(height: 3) 71 | header_window.attron(Curses::A_BOLD) 72 | header_window.addstr "BLADE RUNNER [press 'q' to quit]\n" 73 | header_window.attroff(Curses::A_BOLD) 74 | header_window.addstr "Open #{Blade.url} to start" 75 | header_window.noutrefresh 76 | 77 | Tab.install(top: header_window.maxy) 78 | 79 | Curses.doupdate 80 | end 81 | 82 | def handle_keys 83 | EM.defer do 84 | while ch = Curses.getch 85 | case ch 86 | when Curses::KEY_LEFT 87 | Tab.active.try(:activate_previous) 88 | Curses.doupdate 89 | when Curses::KEY_RIGHT 90 | Tab.active.try(:activate_next) 91 | Curses.doupdate 92 | when "q" 93 | Blade.stop 94 | end 95 | end 96 | end 97 | end 98 | 99 | def handle_stale_tabs 100 | Blade.subscribe("/browsers") do |details| 101 | if details["message"] = "ping" 102 | if tab = Tab.find(details["session_id"]) 103 | tab.last_ping_at = Time.now 104 | end 105 | end 106 | end 107 | 108 | EM.add_periodic_timer(1) do 109 | Tab.stale.each { |t| remove_tab(t) } 110 | end 111 | end 112 | 113 | def remove_tab(tab) 114 | Tab.remove(tab.id) 115 | Curses.doupdate 116 | end 117 | end 118 | -------------------------------------------------------------------------------- /lib/blade.rb: -------------------------------------------------------------------------------- 1 | require "active_support/all" 2 | require "eventmachine" 3 | require "faye" 4 | require "pathname" 5 | require "yaml" 6 | 7 | require "blade/version" 8 | require "blade/cli" 9 | 10 | module Blade 11 | extend self 12 | 13 | CONFIG_DEFAULTS = { 14 | framework: :qunit, 15 | port: 9876, 16 | build: { path: "." } 17 | } 18 | 19 | CONFIG_FILENAMES = %w( blade.yml .blade.yml ) 20 | 21 | @components = [] 22 | 23 | def register_component(component) 24 | @components << component 25 | end 26 | 27 | require "blade/component" 28 | require "blade/server" 29 | 30 | autoload :Model, "blade/model" 31 | autoload :Assets, "blade/assets" 32 | autoload :Config, "blade/config" 33 | autoload :RackAdapter, "blade/rack/adapter" 34 | autoload :RackRouter, "blade/rack/router" 35 | autoload :Session, "blade/session" 36 | autoload :TestResults, "blade/test_results" 37 | 38 | delegate :subscribe, :publish, to: Server 39 | 40 | attr_reader :config 41 | 42 | def start(options = {}) 43 | return if running? 44 | ensure_tmp_path 45 | 46 | initialize!(options) 47 | load_interface 48 | 49 | handle_exit 50 | 51 | EM.run do 52 | @components.each { |c| c.try(:start) } 53 | @running = true 54 | end 55 | end 56 | 57 | def stop 58 | return if @stopping 59 | @stopping = true 60 | @components.each { |c| c.try(:stop) } 61 | EM.stop if EM.reactor_running? 62 | @running = false 63 | end 64 | 65 | def running? 66 | @running 67 | end 68 | 69 | def initialize!(options = {}) 70 | return if @initialized 71 | @initialized = true 72 | 73 | options = CONFIG_DEFAULTS.deep_merge(blade_file_options).deep_merge(options) 74 | @config = Blade::Config.new options 75 | 76 | config.load_paths = Array(config.load_paths) 77 | config.logical_paths = Array(config.logical_paths) 78 | 79 | if config.build? 80 | config.build.logical_paths = Array(config.build.logical_paths) 81 | config.build.path ||= "." 82 | end 83 | 84 | config.plugins ||= {} 85 | 86 | load_requires 87 | load_plugins 88 | load_adapter 89 | end 90 | 91 | def build 92 | initialize! 93 | Assets.build 94 | end 95 | 96 | def url(path = "/") 97 | "http://#{Server.host}:#{config.port}#{path}" 98 | end 99 | 100 | def root_path 101 | Pathname.new(File.dirname(__FILE__)).join("../") 102 | end 103 | 104 | def tmp_path 105 | Pathname.new(".").join("tmp/blade") 106 | end 107 | 108 | def ensure_tmp_path 109 | tmp_path.mkpath 110 | end 111 | 112 | def clean_tmp_path 113 | tmp_path.rmtree if tmp_path.exist? 114 | end 115 | 116 | private 117 | def handle_exit 118 | at_exit do 119 | stop 120 | exit $!.status if $!.is_a?(SystemExit) 121 | end 122 | 123 | %w( INT ).each do |signal| 124 | trap(signal) { exit(1) } 125 | end 126 | end 127 | 128 | def blade_file_options 129 | if filename = CONFIG_FILENAMES.detect { |name| File.exist?(name) } 130 | YAML.load_file(filename) 131 | else 132 | {} 133 | end 134 | end 135 | 136 | def load_interface 137 | require "blade/interface/#{config.interface}" 138 | end 139 | 140 | def load_adapter 141 | require "blade/#{config.framework}_adapter" 142 | end 143 | 144 | def load_requires 145 | Array(config.require).each do |path| 146 | require path 147 | end 148 | end 149 | 150 | def load_plugins 151 | config.plugins.keys.each do |name| 152 | require "blade/#{name}_plugin" 153 | end 154 | end 155 | end 156 | -------------------------------------------------------------------------------- /lib/blade/interface/runner/tab.rb: -------------------------------------------------------------------------------- 1 | class Blade::Runner::Tab < Blade::Model 2 | delegate :colors, :create_window, to: Blade::Runner 3 | 4 | class << self 5 | delegate :create_window, to: Blade::Runner 6 | 7 | attr_reader :window, :state_window, :content_window 8 | 9 | def install(options = {}) 10 | top = options[:top] 11 | @window = create_window(top: top, height: 3) 12 | 13 | top = @window.begy + @window.maxy + 1 14 | @state_window = create_window(top: top, height: 1) 15 | 16 | top = @state_window.begy + @state_window.maxy + 1 17 | @content_window = create_window(top: top) 18 | @content_window.scrollok(true) 19 | end 20 | 21 | def draw 22 | window.clear 23 | window.noutrefresh 24 | all.each(&:draw) 25 | end 26 | 27 | def remove(id) 28 | tab = find(id) 29 | tab.deactivate 30 | tab.window.close 31 | super 32 | draw 33 | end 34 | 35 | def active 36 | all.detect(&:active?) 37 | end 38 | 39 | def stale 40 | threshold = Time.now - 2 41 | all.select { |t| t.last_ping_at && t.last_ping_at < threshold } 42 | end 43 | end 44 | 45 | def tabs 46 | self.class 47 | end 48 | 49 | def height 50 | 3 51 | end 52 | 53 | def width 54 | 5 55 | end 56 | 57 | def top 58 | tabs.window.begy 59 | end 60 | 61 | def left 62 | tabs.window.begx + index * width 63 | end 64 | 65 | def window 66 | @window ||= create_window(height: height, width: width, top: top, left: left) 67 | end 68 | 69 | def draw 70 | window.clear 71 | active? ? draw_active : draw_inactive 72 | window.noutrefresh 73 | end 74 | 75 | def draw_active 76 | window.addstr "╔═══╗" 77 | window.addstr "║ " 78 | window.attron(color) 79 | window.addstr(dot) 80 | window.attroff(color) 81 | window.addstr(" ║") 82 | window.addstr "╝ ╚" 83 | draw_test_results 84 | end 85 | 86 | def draw_inactive 87 | window.addstr "\n" 88 | window.attron(color) 89 | window.addstr(" #{dot}\n") 90 | window.attroff(color) 91 | window.addstr "═════" 92 | end 93 | 94 | def draw_test_results 95 | tabs.content_window.clear 96 | failures = [] 97 | 98 | session.test_results.results.each do |result| 99 | tabs.content_window.addstr(status_dot(result)) 100 | failures << result if result[:status] == "fail" 101 | end 102 | 103 | failures.each do |result| 104 | tabs.content_window.addstr("\n\n") 105 | tabs.content_window.attron(Curses::A_BOLD) 106 | tabs.content_window.attron(colors.red) 107 | tabs.content_window.addstr("#{status_dot(result)} #{result[:name]}\n") 108 | tabs.content_window.attroff(colors.red) 109 | tabs.content_window.attroff(Curses::A_BOLD) 110 | tabs.content_window.addstr(result[:message]) 111 | end 112 | 113 | tabs.content_window.noutrefresh 114 | end 115 | 116 | def dot 117 | state == "pending" ? "○" : "●" 118 | end 119 | 120 | def status_dot(result) 121 | Blade::TestResults::STATUS_DOTS[result[:status]] 122 | end 123 | 124 | def index 125 | tabs.all.index(self) 126 | end 127 | 128 | def session 129 | Blade::Session.find(id) 130 | end 131 | 132 | def state 133 | session.test_results.state 134 | end 135 | 136 | def active? 137 | active 138 | end 139 | 140 | def activate 141 | return if active? 142 | 143 | if tab = tabs.active 144 | tab.deactivate 145 | end 146 | 147 | self.active = true 148 | draw 149 | 150 | tabs.state_window.addstr(session.to_s) 151 | tabs.state_window.noutrefresh 152 | end 153 | 154 | def deactivate 155 | return unless active? 156 | 157 | self.active = false 158 | draw 159 | 160 | tabs.state_window.clear 161 | tabs.state_window.noutrefresh 162 | 163 | tabs.content_window.clear 164 | tabs.content_window.noutrefresh 165 | end 166 | 167 | def activate_next 168 | all = tabs.all 169 | 170 | if all.last == self 171 | all.first.activate 172 | elsif tab = all[index + 1] 173 | tab.activate 174 | end 175 | end 176 | 177 | def activate_previous 178 | all = tabs.all 179 | 180 | if all.first == self 181 | all.last.activate 182 | elsif tab = all[index - 1] 183 | tab.activate 184 | end 185 | end 186 | 187 | def color 188 | case state 189 | when "running" then colors.yellow 190 | when "finished" then colors.green 191 | when /fail/ then colors.red 192 | else colors.white 193 | end 194 | end 195 | end 196 | --------------------------------------------------------------------------------