├── 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 | 
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 |
--------------------------------------------------------------------------------