blade 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.gitignore +9 -0
- data/Gemfile +4 -0
- data/LICENSE.txt +21 -0
- data/README.md +1 -0
- data/Rakefile +12 -0
- data/assets/index.coffee +39 -0
- data/assets/index.html.erb +34 -0
- data/bin/console +14 -0
- data/bin/setup +7 -0
- data/blade.gemspec +38 -0
- data/exe/blade +10 -0
- data/lib/blade.rb +144 -0
- data/lib/blade/assets.rb +84 -0
- data/lib/blade/cli.rb +13 -0
- data/lib/blade/combined_test_results.rb +42 -0
- data/lib/blade/component.rb +5 -0
- data/lib/blade/interface/ci.rb +51 -0
- data/lib/blade/interface/console.rb +125 -0
- data/lib/blade/interface/console/tab.rb +173 -0
- data/lib/blade/model.rb +31 -0
- data/lib/blade/rack_adapter.rb +81 -0
- data/lib/blade/server.rb +29 -0
- data/lib/blade/session.rb +22 -0
- data/lib/blade/test_helper.rb +7 -0
- data/lib/blade/test_results.rb +91 -0
- data/lib/blade/version.rb +3 -0
- metadata +253 -0
@@ -0,0 +1,42 @@
|
|
1
|
+
class Blade::CombinedTestResults
|
2
|
+
attr_reader :sessions, :all_test_results
|
3
|
+
|
4
|
+
def initialize(sessions)
|
5
|
+
@sessions = sessions
|
6
|
+
@all_test_results = sessions.map(&:test_results)
|
7
|
+
end
|
8
|
+
|
9
|
+
def total
|
10
|
+
sum(totals)
|
11
|
+
end
|
12
|
+
|
13
|
+
def lines(type = :results)
|
14
|
+
sessions.flat_map do |session|
|
15
|
+
session.test_results.send(type).map do |line|
|
16
|
+
line.sub(/ok/, "ok [#{session}]")
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
def to_s
|
22
|
+
lines = ["1..#{total}"] + lines(:failures) + lines(:passes)
|
23
|
+
lines.join("\n")
|
24
|
+
end
|
25
|
+
|
26
|
+
def failed?
|
27
|
+
statuses.include?("failed")
|
28
|
+
end
|
29
|
+
|
30
|
+
private
|
31
|
+
def sum(values)
|
32
|
+
values.inject(0) { |sum, total| sum + total }
|
33
|
+
end
|
34
|
+
|
35
|
+
def totals
|
36
|
+
all_test_results.map(&:total).compact
|
37
|
+
end
|
38
|
+
|
39
|
+
def statuses
|
40
|
+
all_test_results.map(&:status)
|
41
|
+
end
|
42
|
+
end
|
@@ -0,0 +1,51 @@
|
|
1
|
+
module Blade::CI
|
2
|
+
extend self
|
3
|
+
include Blade::Component
|
4
|
+
|
5
|
+
def start
|
6
|
+
@completed_sessions = 0
|
7
|
+
|
8
|
+
log "# Running"
|
9
|
+
Blade.subscribe("/results") do |details|
|
10
|
+
process_result(details)
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
private
|
15
|
+
def process_result(details)
|
16
|
+
if details.has_key?("pass")
|
17
|
+
log details["pass"] ? "." : "F"
|
18
|
+
end
|
19
|
+
|
20
|
+
if details["completed"]
|
21
|
+
process_completion
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
def process_completion
|
26
|
+
@completed_sessions += 1
|
27
|
+
|
28
|
+
if done?
|
29
|
+
log "\n"
|
30
|
+
display_results_and_exit
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
def done?
|
35
|
+
@completed_sessions == (Blade.config.expected_sessions || 1)
|
36
|
+
end
|
37
|
+
|
38
|
+
def display_results_and_exit
|
39
|
+
results = Blade::Session.combined_test_results
|
40
|
+
display results
|
41
|
+
exit results.failed? ? 1 : 0
|
42
|
+
end
|
43
|
+
|
44
|
+
def log(message)
|
45
|
+
STDERR.print message.to_s
|
46
|
+
end
|
47
|
+
|
48
|
+
def display(message)
|
49
|
+
STDOUT.puts message.to_s
|
50
|
+
end
|
51
|
+
end
|
@@ -0,0 +1,125 @@
|
|
1
|
+
require "curses"
|
2
|
+
|
3
|
+
module Blade::Console
|
4
|
+
extend self
|
5
|
+
include Blade::Component
|
6
|
+
|
7
|
+
autoload :Tab, "blade/interface/console/tab"
|
8
|
+
|
9
|
+
extend Forwardable
|
10
|
+
def_delegators "Blade::Console", :create_window
|
11
|
+
|
12
|
+
COLOR_NAMES = %w( white yellow green red )
|
13
|
+
PADDING = 1
|
14
|
+
|
15
|
+
def colors
|
16
|
+
@colors ||= OpenStruct.new.tap do |colors|
|
17
|
+
COLOR_NAMES.each do |name|
|
18
|
+
const = Curses.const_get("COLOR_#{name.upcase}")
|
19
|
+
Curses.init_pair(const, const, Curses::COLOR_BLACK)
|
20
|
+
colors[name] = Curses.color_pair(const)
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
def create_window(options = {})
|
26
|
+
height = options[:height] || 0
|
27
|
+
width = options[:width] || 0
|
28
|
+
top = options[:top] || 0
|
29
|
+
left = options[:left] || PADDING
|
30
|
+
parent = options[:parent] || Curses.stdscr
|
31
|
+
|
32
|
+
parent.subwin(height, width, top, left)
|
33
|
+
end
|
34
|
+
|
35
|
+
def start
|
36
|
+
run
|
37
|
+
Blade::Assets.watch_logical_paths
|
38
|
+
end
|
39
|
+
|
40
|
+
def stop
|
41
|
+
Curses.close_screen
|
42
|
+
end
|
43
|
+
|
44
|
+
def run
|
45
|
+
start_screen
|
46
|
+
init_windows
|
47
|
+
handle_keys
|
48
|
+
handle_stale_tabs
|
49
|
+
|
50
|
+
Blade.subscribe("/results") do |details|
|
51
|
+
session = Blade::Session.find(details["session_id"])
|
52
|
+
|
53
|
+
if tab = Tab.find(session.id)
|
54
|
+
if details["line"] && tab.active?
|
55
|
+
Tab.content_window.addstr(details["line"] + "\n")
|
56
|
+
Tab.content_window.noutrefresh
|
57
|
+
end
|
58
|
+
tab.draw
|
59
|
+
else
|
60
|
+
tab = Tab.create(id: session.id)
|
61
|
+
tab.activate if Tab.size == 1
|
62
|
+
end
|
63
|
+
|
64
|
+
Curses.doupdate
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
private
|
69
|
+
def start_screen
|
70
|
+
Curses.init_screen
|
71
|
+
Curses.start_color
|
72
|
+
Curses.noecho
|
73
|
+
Curses.curs_set(0)
|
74
|
+
Curses.stdscr.keypad(true)
|
75
|
+
end
|
76
|
+
|
77
|
+
def init_windows
|
78
|
+
header_window = create_window(height: 3)
|
79
|
+
header_window.attron(Curses::A_BOLD)
|
80
|
+
header_window.addstr "BLADE RUNNER [press 'q' to quit]\n"
|
81
|
+
header_window.attroff(Curses::A_BOLD)
|
82
|
+
header_window.addstr "Open #{Blade.url} to start"
|
83
|
+
header_window.noutrefresh
|
84
|
+
|
85
|
+
Tab.install(top: header_window.maxy)
|
86
|
+
|
87
|
+
Curses.doupdate
|
88
|
+
end
|
89
|
+
|
90
|
+
def handle_keys
|
91
|
+
EM.defer do
|
92
|
+
while ch = Curses.getch
|
93
|
+
case ch
|
94
|
+
when Curses::KEY_LEFT
|
95
|
+
Tab.active.activate_previous
|
96
|
+
Curses.doupdate
|
97
|
+
when Curses::KEY_RIGHT
|
98
|
+
Tab.active.activate_next
|
99
|
+
Curses.doupdate
|
100
|
+
when "q"
|
101
|
+
Blade.stop
|
102
|
+
end
|
103
|
+
end
|
104
|
+
end
|
105
|
+
end
|
106
|
+
|
107
|
+
def handle_stale_tabs
|
108
|
+
Blade.subscribe("/browsers") do |details|
|
109
|
+
if details["message"] = "ping"
|
110
|
+
if tab = Tab.find(details["session_id"])
|
111
|
+
tab.last_ping_at = Time.now
|
112
|
+
end
|
113
|
+
end
|
114
|
+
end
|
115
|
+
|
116
|
+
EM.add_periodic_timer(1) do
|
117
|
+
Tab.stale.each { |t| remove_tab(t) }
|
118
|
+
end
|
119
|
+
end
|
120
|
+
|
121
|
+
def remove_tab(tab)
|
122
|
+
Tab.remove(tab.id)
|
123
|
+
Curses.doupdate
|
124
|
+
end
|
125
|
+
end
|
@@ -0,0 +1,173 @@
|
|
1
|
+
class Blade::Console::Tab < Blade::Model
|
2
|
+
extend Forwardable
|
3
|
+
def_delegators "Blade::Console", :colors, :create_window
|
4
|
+
|
5
|
+
class << self
|
6
|
+
extend Forwardable
|
7
|
+
def_delegators "Blade::Console", :create_window
|
8
|
+
|
9
|
+
attr_reader :window, :status_window, :content_window
|
10
|
+
|
11
|
+
def install(options = {})
|
12
|
+
top = options[:top]
|
13
|
+
@window = create_window(top: top, height: 3)
|
14
|
+
|
15
|
+
top = @window.begy + @window.maxy + 1
|
16
|
+
@status_window = create_window(top: top, height: 1)
|
17
|
+
|
18
|
+
top = @status_window.begy + @status_window.maxy + 1
|
19
|
+
@content_window = create_window(top: top)
|
20
|
+
@content_window.scrollok(true)
|
21
|
+
end
|
22
|
+
|
23
|
+
def draw
|
24
|
+
window.clear
|
25
|
+
window.noutrefresh
|
26
|
+
all.each(&:draw)
|
27
|
+
end
|
28
|
+
|
29
|
+
def remove(id)
|
30
|
+
tab = find(id)
|
31
|
+
tab.deactivate
|
32
|
+
tab.window.close
|
33
|
+
super
|
34
|
+
draw
|
35
|
+
end
|
36
|
+
|
37
|
+
def active
|
38
|
+
all.detect(&:active?)
|
39
|
+
end
|
40
|
+
|
41
|
+
def stale
|
42
|
+
threshold = Time.now - 2
|
43
|
+
all.select { |t| t.last_ping_at && t.last_ping_at < threshold }
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
def tabs
|
48
|
+
self.class
|
49
|
+
end
|
50
|
+
|
51
|
+
def height
|
52
|
+
3
|
53
|
+
end
|
54
|
+
|
55
|
+
def width
|
56
|
+
5
|
57
|
+
end
|
58
|
+
|
59
|
+
def top
|
60
|
+
tabs.window.begy
|
61
|
+
end
|
62
|
+
|
63
|
+
def left
|
64
|
+
tabs.window.begx + index * width
|
65
|
+
end
|
66
|
+
|
67
|
+
def window
|
68
|
+
@window ||= create_window(height: height, width: width, top: top, left: left)
|
69
|
+
end
|
70
|
+
|
71
|
+
def draw
|
72
|
+
window.clear
|
73
|
+
active? ? draw_active : draw_inactive
|
74
|
+
window.noutrefresh
|
75
|
+
end
|
76
|
+
|
77
|
+
def draw_active
|
78
|
+
window.addstr "╔═══╗"
|
79
|
+
window.addstr "║ "
|
80
|
+
window.attron(color)
|
81
|
+
window.addstr(dot)
|
82
|
+
window.attroff(color)
|
83
|
+
window.addstr(" ║")
|
84
|
+
window.addstr "╝ ╚"
|
85
|
+
end
|
86
|
+
|
87
|
+
def draw_inactive
|
88
|
+
window.addstr "\n"
|
89
|
+
window.attron(color)
|
90
|
+
window.addstr(" #{dot}\n")
|
91
|
+
window.attroff(color)
|
92
|
+
window.addstr "═════"
|
93
|
+
end
|
94
|
+
|
95
|
+
def dot
|
96
|
+
status == "pending" ? "○" : "●"
|
97
|
+
end
|
98
|
+
|
99
|
+
def index
|
100
|
+
tabs.all.index(self)
|
101
|
+
end
|
102
|
+
|
103
|
+
def session
|
104
|
+
Blade::Session.find(id)
|
105
|
+
end
|
106
|
+
|
107
|
+
def status
|
108
|
+
session.test_results.status
|
109
|
+
end
|
110
|
+
|
111
|
+
def active?
|
112
|
+
active
|
113
|
+
end
|
114
|
+
|
115
|
+
def activate
|
116
|
+
return if active?
|
117
|
+
|
118
|
+
if tab = tabs.active
|
119
|
+
tab.deactivate
|
120
|
+
end
|
121
|
+
|
122
|
+
self.active = true
|
123
|
+
draw
|
124
|
+
|
125
|
+
tabs.status_window.addstr(session.to_s)
|
126
|
+
tabs.status_window.noutrefresh
|
127
|
+
|
128
|
+
tabs.content_window.addstr(session.test_results.to_s)
|
129
|
+
tabs.content_window.noutrefresh
|
130
|
+
end
|
131
|
+
|
132
|
+
def deactivate
|
133
|
+
return unless active?
|
134
|
+
|
135
|
+
self.active = false
|
136
|
+
draw
|
137
|
+
|
138
|
+
tabs.status_window.clear
|
139
|
+
tabs.status_window.noutrefresh
|
140
|
+
|
141
|
+
tabs.content_window.clear
|
142
|
+
tabs.content_window.noutrefresh
|
143
|
+
end
|
144
|
+
|
145
|
+
def activate_next
|
146
|
+
tabs = tabs.all
|
147
|
+
|
148
|
+
if tabs.last == self
|
149
|
+
tabs.first.activate
|
150
|
+
elsif tab = tabs[index + 1]
|
151
|
+
tab.activate
|
152
|
+
end
|
153
|
+
end
|
154
|
+
|
155
|
+
def activate_previous
|
156
|
+
tabs = tabs.all
|
157
|
+
|
158
|
+
if tabs.first == self
|
159
|
+
tabs.last.activate
|
160
|
+
elsif tab = tabs[index - 1]
|
161
|
+
tab.activate
|
162
|
+
end
|
163
|
+
end
|
164
|
+
|
165
|
+
def color
|
166
|
+
case status
|
167
|
+
when "running" then colors.yellow
|
168
|
+
when "finished" then colors.green
|
169
|
+
when /fail/ then colors.red
|
170
|
+
else colors.white
|
171
|
+
end
|
172
|
+
end
|
173
|
+
end
|
data/lib/blade/model.rb
ADDED
@@ -0,0 +1,31 @@
|
|
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
|
@@ -0,0 +1,81 @@
|
|
1
|
+
class Blade::RackAdapter
|
2
|
+
extend Forwardable
|
3
|
+
def_delegators "Blade::Assets", :environment
|
4
|
+
|
5
|
+
PATH = "/blade"
|
6
|
+
ADAPTER_PATH = PATH + "/adapter"
|
7
|
+
WEBSOCKET_PATH = PATH + "/websocket"
|
8
|
+
|
9
|
+
def initialize(app = nil, options = {})
|
10
|
+
Blade.initialize!
|
11
|
+
|
12
|
+
if app.is_a?(Hash)
|
13
|
+
@options = app
|
14
|
+
else
|
15
|
+
@app, @options = app, options
|
16
|
+
end
|
17
|
+
|
18
|
+
@mount_path = @options[:mount]
|
19
|
+
@mount_path_pattern = /^#{@mount_path}/
|
20
|
+
|
21
|
+
@blade_path_pattern = /^#{PATH}/
|
22
|
+
@adapter_path_pattern = /^#{ADAPTER_PATH}/
|
23
|
+
@websocket_path_pattern = /^#{WEBSOCKET_PATH}/
|
24
|
+
end
|
25
|
+
|
26
|
+
def call(env)
|
27
|
+
unless @mount_path.nil?
|
28
|
+
if env["PATH_INFO"] =~ @mount_path_pattern
|
29
|
+
env["PATH_INFO"].sub!(@mount_path_pattern, "")
|
30
|
+
elsif @app
|
31
|
+
return @app.call(env)
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
case env["PATH_INFO"]
|
36
|
+
when ""
|
37
|
+
add_forward_slash(env)
|
38
|
+
when "/"
|
39
|
+
env["PATH_INFO"] = "index.html"
|
40
|
+
response = environment(:blade).call(env)
|
41
|
+
response_with_session(response, env)
|
42
|
+
when @websocket_path_pattern
|
43
|
+
bayeux.call(env)
|
44
|
+
when @adapter_path_pattern
|
45
|
+
env["PATH_INFO"].sub!(@adapter_path_pattern, "")
|
46
|
+
environment(:adapter).call(env)
|
47
|
+
when @blade_path_pattern
|
48
|
+
env["PATH_INFO"].sub!(@blade_path_pattern, "")
|
49
|
+
environment(:blade).call(env)
|
50
|
+
else
|
51
|
+
environment(:user).call(env)
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
private
|
56
|
+
def bayeux
|
57
|
+
@bayeux ||= Faye::RackAdapter.new(mount: WEBSOCKET_PATH, timeout: 25)
|
58
|
+
end
|
59
|
+
|
60
|
+
def add_forward_slash(env)
|
61
|
+
path = @mount_path || env["REQUEST_PATH"] || env["SCRIPT_NAME"]
|
62
|
+
redirect_to(File.join(path.to_s, "/"))
|
63
|
+
end
|
64
|
+
|
65
|
+
def redirect_to(location, status = 301)
|
66
|
+
[status, { Location: location }, []]
|
67
|
+
end
|
68
|
+
|
69
|
+
def response_with_session(response, env)
|
70
|
+
if Blade.running?
|
71
|
+
user_agent = UserAgent.parse(env["HTTP_USER_AGENT"])
|
72
|
+
session = Blade::Session.create(user_agent: user_agent)
|
73
|
+
status, headers, body = response
|
74
|
+
response = Rack::Response.new(body, status, headers)
|
75
|
+
response.set_cookie("blade_session", session.id)
|
76
|
+
response.finish
|
77
|
+
else
|
78
|
+
response
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|