web-console 2.2.1 → 2.3.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/CHANGELOG.markdown +7 -1
- data/README.markdown +12 -0
- data/Rakefile +3 -34
- data/lib/web_console.rb +2 -0
- data/lib/web_console/extensions.rb +5 -2
- data/lib/web_console/locales/en.yml +15 -0
- data/lib/web_console/middleware.rb +57 -54
- data/lib/web_console/railtie.rb +8 -10
- data/lib/web_console/request.rb +18 -25
- data/lib/web_console/response.rb +23 -0
- data/lib/web_console/tasks/extensions.rake +60 -0
- data/lib/web_console/tasks/test_templates.rake +50 -0
- data/lib/web_console/template.rb +3 -29
- data/lib/web_console/templates/_markup.html.erb +3 -2
- data/lib/web_console/templates/console.js.erb +107 -40
- data/lib/web_console/templates/style.css.erb +5 -0
- data/lib/web_console/testing/erb_precompiler.rb +25 -0
- data/lib/web_console/testing/fake_middleware.rb +43 -0
- data/lib/web_console/testing/helper.rb +9 -0
- data/lib/web_console/version.rb +1 -1
- data/lib/web_console/view.rb +37 -0
- data/lib/web_console/whiny_request.rb +0 -7
- metadata +11 -4
- data/lib/web_console/tracer.rb +0 -11
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 02d40821ae813ac4e5893b4c853725833017cb46
|
4
|
+
data.tar.gz: 56b2826919677b8a93e6ecedce69a176949d15ee
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 6c6a07f241b5bfad39c7a588ac92bccc8f0786db1b807d3ab94b4ec13464d7d8514493634c88247a868e78e43169094d216da6f01c3559be45af39854a76a0a0
|
7
|
+
data.tar.gz: 4b656f08efb441715996f541f94806d9f6249171502fded529041c5fc5cead0a73ce4f155fe246f0c4a36c4a0b54f9bdd65fb3091ed5e9b9bfa86b9d95f3a552
|
data/CHANGELOG.markdown
CHANGED
@@ -2,9 +2,14 @@
|
|
2
2
|
|
3
3
|
## master (unreleased)
|
4
4
|
|
5
|
+
## 2.3.0
|
6
|
+
|
7
|
+
* [#181](https://github.com/rails/web-console/pull/181) Log internal Web Console errors ([@schneems])
|
8
|
+
* [#150](https://github.com/rails/web-console/pull/150) Revert #150. ([@gsamokovarov])
|
9
|
+
|
5
10
|
## 2.2.1
|
6
11
|
|
7
|
-
* [#150](https://github.com/rails/web-console/pull/150) Change config.development_only default until 4.2.4 is released.
|
12
|
+
* [#150](https://github.com/rails/web-console/pull/150) Change config.development_only default until 4.2.4 is released. ([@gsamokovarov])
|
8
13
|
|
9
14
|
## 2.2.0
|
10
15
|
|
@@ -48,3 +53,4 @@
|
|
48
53
|
[@parterburn]: https://github.com/parterburn
|
49
54
|
[@sh19910711]: https://github.com/sh19910711
|
50
55
|
[@frenesim]: https://github.com/frenesim
|
56
|
+
[@schneems]: https://github.com/schneems
|
data/README.markdown
CHANGED
@@ -161,6 +161,18 @@ end
|
|
161
161
|
You may wanna check the [templates] folder at the source tree for the files you
|
162
162
|
may override.
|
163
163
|
|
164
|
+
### config.web_console.mount_point
|
165
|
+
|
166
|
+
Usually the middleware of _Web Console_ is mounted at `/__web_console`.
|
167
|
+
If you wanna change the path for some reasons, you can specify it
|
168
|
+
by `config.web_console.mount_point`:
|
169
|
+
|
170
|
+
```ruby
|
171
|
+
Rails.application.configure do
|
172
|
+
config.web_console.mount_point = '/path/to/web_console'
|
173
|
+
end
|
174
|
+
```
|
175
|
+
|
164
176
|
## FAQ
|
165
177
|
|
166
178
|
### Where did /console go?
|
data/Rakefile
CHANGED
@@ -8,6 +8,8 @@ require 'socket'
|
|
8
8
|
require 'rake/testtask'
|
9
9
|
require 'tmpdir'
|
10
10
|
require 'securerandom'
|
11
|
+
require 'json'
|
12
|
+
require 'web_console/testing/erb_precompiler'
|
11
13
|
|
12
14
|
EXPANDED_CWD = File.expand_path(File.dirname(__FILE__))
|
13
15
|
|
@@ -18,40 +20,7 @@ Rake::TestTask.new(:test) do |t|
|
|
18
20
|
t.verbose = false
|
19
21
|
end
|
20
22
|
|
21
|
-
|
22
|
-
desc "Run tests for templates"
|
23
|
-
task :templates => "templates:all"
|
24
|
-
|
25
|
-
namespace :templates do
|
26
|
-
task :all => [:daemonize, :npm, :rackup, :mocha, :kill]
|
27
|
-
task :serve => [:npm, :rackup]
|
28
|
-
|
29
|
-
work_dir = Pathname(__FILE__).dirname.join("test/templates")
|
30
|
-
pid_file = Pathname(Dir.tmpdir).join("web_console.#{SecureRandom.uuid}.pid")
|
31
|
-
server_port = 29292
|
32
|
-
rackup_opts = "-p #{server_port}"
|
33
|
-
|
34
|
-
task :daemonize do
|
35
|
-
rackup_opts += " -D -P #{pid_file}"
|
36
|
-
end
|
37
|
-
|
38
|
-
task :npm do
|
39
|
-
Dir.chdir(work_dir) { system "npm install --silent" }
|
40
|
-
end
|
41
|
-
|
42
|
-
task :rackup do
|
43
|
-
Dir.chdir(work_dir) { system "bundle exec rackup #{rackup_opts}" }
|
44
|
-
end
|
45
|
-
|
46
|
-
task :mocha do
|
47
|
-
Dir.chdir(work_dir) { system "$(npm bin)/mocha-phantomjs http://localhost:#{server_port}/html/spec_runner.html" }
|
48
|
-
end
|
49
|
-
|
50
|
-
task :kill do
|
51
|
-
system "kill #{File.read pid_file}"
|
52
|
-
end
|
53
|
-
end
|
54
|
-
end
|
23
|
+
Dir['lib/web_console/tasks/**/*.rake'].each { |task| load task }
|
55
24
|
|
56
25
|
Bundler::GemHelper.install_tasks
|
57
26
|
|
data/lib/web_console.rb
CHANGED
@@ -13,6 +13,8 @@ require 'web_console/template'
|
|
13
13
|
require 'web_console/middleware'
|
14
14
|
require 'web_console/whitelist'
|
15
15
|
require 'web_console/request'
|
16
|
+
require 'web_console/response'
|
17
|
+
require 'web_console/view'
|
16
18
|
require 'web_console/whiny_request'
|
17
19
|
|
18
20
|
module WebConsole
|
@@ -1,6 +1,9 @@
|
|
1
1
|
ActionDispatch::DebugExceptions.class_eval do
|
2
|
-
def render_exception_with_web_console(
|
3
|
-
render_exception_without_web_console(
|
2
|
+
def render_exception_with_web_console(request, exception)
|
3
|
+
render_exception_without_web_console(request, exception).tap do
|
4
|
+
# Retain superficial Rails 5 compatibility.
|
5
|
+
env = Hash === request ? request : request.env
|
6
|
+
|
4
7
|
error = ActionDispatch::ExceptionWrapper.new(env, exception).exception
|
5
8
|
|
6
9
|
# Get the original exception if ExceptionWrapper decides to follow it.
|
@@ -0,0 +1,15 @@
|
|
1
|
+
en:
|
2
|
+
errors:
|
3
|
+
unavailable_session: |
|
4
|
+
Session %{id} is is no longer available in memory.
|
5
|
+
|
6
|
+
If you happen to run on a multi-process server (like Unicorn or Puma) the process
|
7
|
+
this request hit doesn't store %{id} in memory. Consider turning the number of
|
8
|
+
processes/workers to one (1) or using a different server in development.
|
9
|
+
|
10
|
+
unacceptable_request: |
|
11
|
+
A supported version is expected in the Accept header.
|
12
|
+
|
13
|
+
connection_refused: |
|
14
|
+
Oops! Failed to connect to the Web Console middleware.
|
15
|
+
Please make sure a rails development server is running.
|
@@ -4,60 +4,60 @@ module WebConsole
|
|
4
4
|
class Middleware
|
5
5
|
TEMPLATES_PATH = File.expand_path('../templates', __FILE__)
|
6
6
|
|
7
|
-
|
8
|
-
|
9
|
-
binding_change_re: %r{/repl_sessions/(?<id>.+?)/trace\z}
|
10
|
-
}
|
11
|
-
|
12
|
-
UNAVAILABLE_SESSION_MESSAGE = <<-END.strip_heredoc
|
13
|
-
Session %{id} is is no longer available in memory.
|
14
|
-
|
15
|
-
If you happen to run on a multi-process server (like Unicorn) the process
|
16
|
-
this request hit doesn't store %{id} in memory.
|
17
|
-
END
|
18
|
-
|
19
|
-
UNACCEPTABLE_REQUEST_MESSAGE = "A supported version is expected in the Accept header."
|
7
|
+
cattr_accessor :mount_point
|
8
|
+
@@mount_point = '/__web_console'
|
20
9
|
|
21
10
|
cattr_accessor :whiny_requests
|
22
11
|
@@whiny_requests = true
|
23
12
|
|
24
|
-
def initialize(app
|
25
|
-
@app
|
26
|
-
@options = DEFAULT_OPTIONS.merge(options)
|
13
|
+
def initialize(app)
|
14
|
+
@app = app
|
27
15
|
end
|
28
16
|
|
29
17
|
def call(env)
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
18
|
+
app_exception = catch :app_exception do
|
19
|
+
request = create_regular_or_whiny_request(env)
|
20
|
+
return @app.call(env) unless request.from_whitelited_ip?
|
21
|
+
|
22
|
+
if id = id_for_repl_session_update(request)
|
23
|
+
return update_repl_session(id, request)
|
24
|
+
elsif id = id_for_repl_session_stack_frame_change(request)
|
25
|
+
return change_stack_trace(id, request)
|
26
|
+
end
|
38
27
|
|
39
|
-
|
28
|
+
status, headers, body = @app.call(env)
|
40
29
|
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
30
|
+
if exception = env['web_console.exception']
|
31
|
+
session = Session.from_exception(exception)
|
32
|
+
elsif binding = env['web_console.binding']
|
33
|
+
session = Session.from_binding(binding)
|
34
|
+
end
|
46
35
|
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
template = Template.new(env, session)
|
36
|
+
if session && acceptable_content_type?(headers)
|
37
|
+
response = Response.new(body, status, headers)
|
38
|
+
template = Template.new(env, session)
|
51
39
|
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
40
|
+
response.headers["X-Web-Console-Session-Id"] = session.id
|
41
|
+
response.headers["X-Web-Console-Mount-Point"] = mount_point
|
42
|
+
response.write(template.render('index'))
|
43
|
+
response.finish
|
44
|
+
else
|
45
|
+
[ status, headers, body ]
|
46
|
+
end
|
56
47
|
end
|
48
|
+
rescue => e
|
49
|
+
WebConsole.logger.error("\n#{e.class}: #{e}\n\tfrom #{e.backtrace.join("\n\tfrom ")}")
|
50
|
+
raise e
|
51
|
+
ensure
|
52
|
+
raise app_exception if Exception === app_exception
|
57
53
|
end
|
58
54
|
|
59
55
|
private
|
60
56
|
|
57
|
+
def acceptable_content_type?(headers)
|
58
|
+
Mime::Type.parse(headers['Content-Type']).first == Mime::HTML
|
59
|
+
end
|
60
|
+
|
61
61
|
def json_response(opts = {})
|
62
62
|
status = opts.fetch(:status, 200)
|
63
63
|
headers = { 'Content-Type' => 'application/json; charset = utf-8' }
|
@@ -67,17 +67,10 @@ module WebConsole
|
|
67
67
|
end
|
68
68
|
|
69
69
|
def json_response_with_session(id, request, opts = {})
|
70
|
-
|
71
|
-
|
72
|
-
return respond_with_unacceptable_request
|
73
|
-
end
|
70
|
+
return respond_with_unacceptable_request unless request.acceptable?
|
71
|
+
return respond_with_unavailable_session(id) unless session = Session.find(id)
|
74
72
|
|
75
|
-
|
76
|
-
return respond_with_unavailable_session(id)
|
77
|
-
end
|
78
|
-
|
79
|
-
yield session
|
80
|
-
end
|
73
|
+
json_response(opts) { yield session }
|
81
74
|
end
|
82
75
|
|
83
76
|
def create_regular_or_whiny_request(env)
|
@@ -85,23 +78,27 @@ module WebConsole
|
|
85
78
|
whiny_requests ? WhinyRequest.new(request) : request
|
86
79
|
end
|
87
80
|
|
81
|
+
def repl_sessions_re
|
82
|
+
@_repl_sessions_re ||= %r{#{mount_point}/repl_sessions/(?<id>[^/]+)}
|
83
|
+
end
|
84
|
+
|
88
85
|
def update_re
|
89
|
-
@
|
86
|
+
@_update_re ||= %r{#{repl_sessions_re}\z}
|
90
87
|
end
|
91
88
|
|
92
89
|
def binding_change_re
|
93
|
-
@
|
90
|
+
@_binding_change_re ||= %r{#{repl_sessions_re}/trace\z}
|
94
91
|
end
|
95
92
|
|
96
93
|
def id_for_repl_session_update(request)
|
97
94
|
if request.xhr? && request.put?
|
98
|
-
update_re.match(request.
|
95
|
+
update_re.match(request.path) { |m| m[:id] }
|
99
96
|
end
|
100
97
|
end
|
101
98
|
|
102
99
|
def id_for_repl_session_stack_frame_change(request)
|
103
100
|
if request.xhr? && request.post?
|
104
|
-
binding_change_re.match(request.
|
101
|
+
binding_change_re.match(request.path) { |m| m[:id] }
|
105
102
|
end
|
106
103
|
end
|
107
104
|
|
@@ -121,14 +118,20 @@ module WebConsole
|
|
121
118
|
|
122
119
|
def respond_with_unavailable_session(id)
|
123
120
|
json_response(status: 404) do
|
124
|
-
{ output: format(
|
121
|
+
{ output: format(I18n.t('errors.unavailable_session'), id: id)}
|
125
122
|
end
|
126
123
|
end
|
127
124
|
|
128
125
|
def respond_with_unacceptable_request
|
129
126
|
json_response(status: 406) do
|
130
|
-
{
|
127
|
+
{ output: I18n.t('errors.unacceptable_request') }
|
131
128
|
end
|
132
129
|
end
|
130
|
+
|
131
|
+
def call_app(env)
|
132
|
+
@app.call(env)
|
133
|
+
rescue => e
|
134
|
+
throw :app_exception, e
|
135
|
+
end
|
133
136
|
end
|
134
137
|
end
|
data/lib/web_console/railtie.rb
CHANGED
@@ -5,10 +5,6 @@ module WebConsole
|
|
5
5
|
config.web_console = ActiveSupport::OrderedOptions.new
|
6
6
|
config.web_console.whitelisted_ips = %w( 127.0.0.1 ::1 )
|
7
7
|
|
8
|
-
# See rails/web-console#150 and rails/rails#20319. Revert when Ruby on
|
9
|
-
# Rails 4.2.4 is released.
|
10
|
-
config.web_console.development_only = false
|
11
|
-
|
12
8
|
initializer 'web_console.initialize' do
|
13
9
|
require 'web_console/extensions'
|
14
10
|
|
@@ -43,6 +39,12 @@ module WebConsole
|
|
43
39
|
app.middleware.insert_before ActionDispatch::DebugExceptions, Middleware
|
44
40
|
end
|
45
41
|
|
42
|
+
initializer 'web_console.mount_point' do
|
43
|
+
if mount_point = config.web_console.mount_point
|
44
|
+
Middleware.mount_point = mount_point.chomp('/')
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
46
48
|
initializer 'web_console.template_paths' do
|
47
49
|
if template_paths = config.web_console.template_paths
|
48
50
|
Template.template_paths.unshift(*Array(template_paths))
|
@@ -61,12 +63,8 @@ module WebConsole
|
|
61
63
|
end
|
62
64
|
end
|
63
65
|
|
64
|
-
|
65
|
-
|
66
|
-
initializer 'web_console.acceptable_content_types' do
|
67
|
-
if acceptable_content_types = config.web_console.acceptable_content_types
|
68
|
-
Request.acceptable_content_types.concat(Array(acceptable_content_types))
|
69
|
-
end
|
66
|
+
initializer 'i18n.load_path' do
|
67
|
+
config.i18n.load_path.concat(Dir[File.expand_path('../locales/*.yml', __FILE__)])
|
70
68
|
end
|
71
69
|
end
|
72
70
|
end
|
data/lib/web_console/request.rb
CHANGED
@@ -1,11 +1,6 @@
|
|
1
1
|
module WebConsole
|
2
2
|
# Web Console tailored request object.
|
3
3
|
class Request < ActionDispatch::Request
|
4
|
-
# While most of the servers will return blank content type if none given,
|
5
|
-
# Puma will return text/plain.
|
6
|
-
cattr_accessor :acceptable_content_types
|
7
|
-
@@acceptable_content_types = [Mime::HTML, Mime::TEXT, Mime::URL_ENCODED_FORM]
|
8
|
-
|
9
4
|
# Configurable set of whitelisted networks.
|
10
5
|
cattr_accessor :whitelisted_ips
|
11
6
|
@@whitelisted_ips = Whitelist.new
|
@@ -23,16 +18,7 @@ module WebConsole
|
|
23
18
|
|
24
19
|
# Determines the remote IP using our much stricter whitelist.
|
25
20
|
def strict_remote_ip
|
26
|
-
GetSecureIp.new(
|
27
|
-
end
|
28
|
-
|
29
|
-
# Returns whether the request is from an acceptable content type.
|
30
|
-
#
|
31
|
-
# We can render a console for HTML and TEXT by default. If a client didn't
|
32
|
-
# specified any content type and the server returned it as blank, we'll
|
33
|
-
# render it as well.
|
34
|
-
def acceptable_content_type?
|
35
|
-
content_type.blank? || content_type.in?(acceptable_content_types)
|
21
|
+
GetSecureIp.new(self, whitelisted_ips).to_s
|
36
22
|
end
|
37
23
|
|
38
24
|
# Returns whether the request is acceptable.
|
@@ -40,18 +26,25 @@ module WebConsole
|
|
40
26
|
xhr? && accepts.any? { |mime| Mime::WEB_CONSOLE_V2 == mime }
|
41
27
|
end
|
42
28
|
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
29
|
+
private
|
30
|
+
|
31
|
+
class GetSecureIp < ActionDispatch::RemoteIp::GetIp
|
32
|
+
def initialize(req, proxies)
|
33
|
+
# After rails/rails@07b2ff0 ActionDispatch::RemoteIp::GetIp initializes
|
34
|
+
# with a ActionDispatch::Request object instead of plain Rack
|
35
|
+
# environment hash. Keep both @req and @env here, so we don't if/else
|
36
|
+
# on Rails versions.
|
37
|
+
@req = req
|
38
|
+
@env = req.env
|
39
|
+
@check_ip = true
|
40
|
+
@proxies = proxies
|
41
|
+
end
|
49
42
|
|
50
|
-
|
51
|
-
|
52
|
-
|
43
|
+
def filter_proxies(ips)
|
44
|
+
ips.reject do |ip|
|
45
|
+
@proxies.include?(ip)
|
46
|
+
end
|
53
47
|
end
|
54
48
|
end
|
55
|
-
end
|
56
49
|
end
|
57
50
|
end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
module WebConsole
|
2
|
+
# A response object that writes content before the closing </body> tag, if
|
3
|
+
# possible.
|
4
|
+
#
|
5
|
+
# The object quacks like Rack::Response.
|
6
|
+
class Response < Struct.new(:body, :status, :headers)
|
7
|
+
def write(content)
|
8
|
+
raw_body = Array(body).first.to_s
|
9
|
+
|
10
|
+
if position = raw_body.rindex('</body>')
|
11
|
+
raw_body.insert(position, content)
|
12
|
+
else
|
13
|
+
raw_body << content
|
14
|
+
end
|
15
|
+
|
16
|
+
self.body = raw_body
|
17
|
+
end
|
18
|
+
|
19
|
+
def finish
|
20
|
+
Rack::Response.new(body, status, headers).finish
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
@@ -0,0 +1,60 @@
|
|
1
|
+
namespace :ext do
|
2
|
+
rootdir = Pathname('extensions')
|
3
|
+
|
4
|
+
desc 'Build Chrome Extension'
|
5
|
+
task chrome: 'chrome:build'
|
6
|
+
|
7
|
+
namespace :chrome do
|
8
|
+
dist = Pathname('dist/crx')
|
9
|
+
extdir = rootdir.join(dist)
|
10
|
+
manifest_json = rootdir.join('chrome/manifest.json')
|
11
|
+
|
12
|
+
directory extdir
|
13
|
+
|
14
|
+
task build: [ extdir, 'lib:templates' ] do
|
15
|
+
cd rootdir do
|
16
|
+
cp_r [ 'img/', 'tmp/lib/' ], dist
|
17
|
+
`cd chrome && git ls-files`.split("\n").each do |src|
|
18
|
+
dest = dist.join(src)
|
19
|
+
mkdir_p dest.dirname
|
20
|
+
cp Pathname('chrome').join(src), dest
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
# Generate a .crx file.
|
26
|
+
task crx: [ :build, :npm ] do
|
27
|
+
out = "crx-web-console-#{JSON.parse(File.read(manifest_json))["version"]}.crx"
|
28
|
+
cd(extdir) { sh "node \"$(npm bin)/crx\" pack ./ -p ../crx-web-console.pem -o ../#{out}" }
|
29
|
+
end
|
30
|
+
|
31
|
+
# Generate a .zip file for Chrome Web Store.
|
32
|
+
task zip: [ :build ] do
|
33
|
+
version = JSON.parse(File.read(manifest_json))["version"]
|
34
|
+
cd(extdir) { sh "zip -r ../crx-web-console-#{version}.zip ./" }
|
35
|
+
end
|
36
|
+
|
37
|
+
desc 'Launch a browser with the chrome extension.'
|
38
|
+
task run: [ :build ] do
|
39
|
+
cd(rootdir) { sh "sh ./script/run_chrome.sh --load-extension=#{dist}" }
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
task :npm do
|
44
|
+
cd(rootdir) { sh "npm install --silent" }
|
45
|
+
end
|
46
|
+
|
47
|
+
namespace :lib do
|
48
|
+
templates = Pathname('lib/web_console/templates')
|
49
|
+
tmplib = rootdir.join('tmp/lib/')
|
50
|
+
js_erb = FileList.new(templates.join('**/*.js.erb'))
|
51
|
+
dirs = js_erb.pathmap("%{^#{templates},#{tmplib}}d")
|
52
|
+
|
53
|
+
task templates: dirs + js_erb.pathmap("%{^#{templates},#{tmplib}}X")
|
54
|
+
|
55
|
+
dirs.each { |d| directory d }
|
56
|
+
rule '.js' => [ "%{^#{tmplib},#{templates}}X.js.erb" ] do |t|
|
57
|
+
File.write(t.name, WebConsole::Testing::ERBPrecompiler.new(t.source).build)
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
@@ -0,0 +1,50 @@
|
|
1
|
+
namespace :test do
|
2
|
+
desc "Run tests for templates"
|
3
|
+
task templates: "templates:all"
|
4
|
+
|
5
|
+
namespace :templates do
|
6
|
+
task all: [ :daemonize, :npm, :rackup, :wait, :mocha, :kill, :exit ]
|
7
|
+
task serve: [ :npm, :rackup ]
|
8
|
+
|
9
|
+
work_dir = Pathname(EXPANDED_CWD).join("test/templates")
|
10
|
+
pid_file = Pathname(Dir.tmpdir).join("web_console.#{SecureRandom.uuid}.pid")
|
11
|
+
runner_uri = URI.parse("http://localhost:29292/html/spec_runner.html")
|
12
|
+
rackup_opts = "-p #{runner_uri.port}"
|
13
|
+
test_result = nil
|
14
|
+
|
15
|
+
def need_to_wait?(uri)
|
16
|
+
Net::HTTP.start(uri.host, uri.port) { |http| http.get(uri.path) }
|
17
|
+
rescue Errno::ECONNREFUSED
|
18
|
+
retry if yield
|
19
|
+
end
|
20
|
+
|
21
|
+
task :daemonize do
|
22
|
+
rackup_opts += " -D -P #{pid_file}"
|
23
|
+
end
|
24
|
+
|
25
|
+
task :npm do
|
26
|
+
Dir.chdir(work_dir) { system "npm install --silent" }
|
27
|
+
end
|
28
|
+
|
29
|
+
task :rackup do
|
30
|
+
Dir.chdir(work_dir) { system "bundle exec rackup #{rackup_opts}" }
|
31
|
+
end
|
32
|
+
|
33
|
+
task :wait do
|
34
|
+
cnt = 0
|
35
|
+
need_to_wait?(runner_uri) { sleep 1; cnt += 1; cnt < 5 }
|
36
|
+
end
|
37
|
+
|
38
|
+
task :mocha do
|
39
|
+
Dir.chdir(work_dir) { test_result = system("$(npm bin)/mocha-phantomjs #{runner_uri}") }
|
40
|
+
end
|
41
|
+
|
42
|
+
task :kill do
|
43
|
+
system "kill #{File.read pid_file}"
|
44
|
+
end
|
45
|
+
|
46
|
+
task :exit do
|
47
|
+
exit test_result
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
data/lib/web_console/template.rb
CHANGED
@@ -4,33 +4,6 @@ module WebConsole
|
|
4
4
|
# It introduces template helpers to ease the inclusion of scripts only on
|
5
5
|
# Rails error pages.
|
6
6
|
class Template
|
7
|
-
class Context < ActionView::Base
|
8
|
-
# Execute a block only on error pages.
|
9
|
-
#
|
10
|
-
# The error pages are special, because they are the only pages that
|
11
|
-
# currently require multiple bindings. We get those from exceptions.
|
12
|
-
def only_on_error_page(*args)
|
13
|
-
yield if @env['web_console.exception'].present?
|
14
|
-
end
|
15
|
-
|
16
|
-
# Render JavaScript inside a script tag and a closure.
|
17
|
-
#
|
18
|
-
# This one lets write JavaScript that will automatically get wrapped in a
|
19
|
-
# script tag and enclosed in a closure, so you don't have to worry for
|
20
|
-
# leaking globals, unless you explicitly want to.
|
21
|
-
def render_javascript(template)
|
22
|
-
render(template: template, layout: 'layouts/javascript')
|
23
|
-
end
|
24
|
-
|
25
|
-
# Render inlined string to be used inside of JavaScript code.
|
26
|
-
#
|
27
|
-
# The inlined string is returned as an actual JavaScript string. You
|
28
|
-
# don't need to wrap the result yourself.
|
29
|
-
def render_inlined_string(template)
|
30
|
-
render(template: template, layout: 'layouts/inlined_string')
|
31
|
-
end
|
32
|
-
end
|
33
|
-
|
34
7
|
# Lets you customize the default templates folder location.
|
35
8
|
cattr_accessor :template_paths
|
36
9
|
@@template_paths = [ File.expand_path('../templates', __FILE__) ]
|
@@ -38,12 +11,13 @@ module WebConsole
|
|
38
11
|
def initialize(env, session)
|
39
12
|
@env = env
|
40
13
|
@session = session
|
14
|
+
@mount_point = Middleware.mount_point
|
41
15
|
end
|
42
16
|
|
43
17
|
# Render a template (inferred from +template_paths+) as a plain string.
|
44
18
|
def render(template)
|
45
|
-
|
46
|
-
|
19
|
+
view = View.new(template_paths, instance_values)
|
20
|
+
view.render(template: template, layout: false)
|
47
21
|
end
|
48
22
|
end
|
49
23
|
end
|
@@ -54,11 +54,69 @@ var styleElementId = 'sr02459pvbvrmhco';
|
|
54
54
|
|
55
55
|
// REPLConsole Constructor
|
56
56
|
function REPLConsole(config) {
|
57
|
+
function getConfig(key, defaultValue) {
|
58
|
+
return config && config[key] || defaultValue;
|
59
|
+
}
|
60
|
+
|
57
61
|
this.commandStorage = new CommandStorage();
|
58
|
-
this.prompt =
|
59
|
-
this.
|
62
|
+
this.prompt = getConfig('promptLabel', ' >>');
|
63
|
+
this.mountPoint = getConfig('mountPoint');
|
64
|
+
this.sessionId = getConfig('sessionId');
|
60
65
|
}
|
61
66
|
|
67
|
+
REPLConsole.prototype.getSessionUrl = function(path) {
|
68
|
+
var parts = [ this.mountPoint, 'repl_sessions', this.sessionId ];
|
69
|
+
if (path) {
|
70
|
+
parts.push(path);
|
71
|
+
}
|
72
|
+
// Join and remove duplicate slashes.
|
73
|
+
return parts.join('/').replace(/([^:]\/)\/+/g, '$1');
|
74
|
+
};
|
75
|
+
|
76
|
+
REPLConsole.prototype.commandHandle = function(line, callback) {
|
77
|
+
var self = this;
|
78
|
+
var params = 'input=' + encodeURIComponent(line);
|
79
|
+
callback = callback || function() {};
|
80
|
+
|
81
|
+
function isSuccess(status) {
|
82
|
+
return status >= 200 && status < 300 || status === 304;
|
83
|
+
}
|
84
|
+
|
85
|
+
function parseJSON(text) {
|
86
|
+
try {
|
87
|
+
return JSON.parse(text);
|
88
|
+
} catch (e) {
|
89
|
+
return null;
|
90
|
+
}
|
91
|
+
}
|
92
|
+
|
93
|
+
function getErrorText(xhr) {
|
94
|
+
if (!xhr.status) {
|
95
|
+
return "<%= t 'errors.connection_refused' %>";
|
96
|
+
} else {
|
97
|
+
return xhr.status + ' ' + xhr.statusText;
|
98
|
+
}
|
99
|
+
}
|
100
|
+
|
101
|
+
putRequest(self.getSessionUrl(), params, function(xhr) {
|
102
|
+
var response = parseJSON(xhr.responseText);
|
103
|
+
var result = isSuccess(xhr.status);
|
104
|
+
if (result) {
|
105
|
+
self.writeOutput(response.output);
|
106
|
+
} else {
|
107
|
+
if (response && response.output) {
|
108
|
+
self.writeError(response.output);
|
109
|
+
} else {
|
110
|
+
self.writeError(getErrorText(xhr));
|
111
|
+
}
|
112
|
+
}
|
113
|
+
callback(result, response);
|
114
|
+
});
|
115
|
+
};
|
116
|
+
|
117
|
+
REPLConsole.prototype.uninstall = function() {
|
118
|
+
this.container.parentNode.removeChild(this.container);
|
119
|
+
};
|
62
120
|
|
63
121
|
REPLConsole.prototype.install = function(container) {
|
64
122
|
var _this = this;
|
@@ -99,8 +157,8 @@ REPLConsole.prototype.install = function(container) {
|
|
99
157
|
|
100
158
|
// Make the console resizable.
|
101
159
|
function resizeContainer(ev) {
|
102
|
-
var startY
|
103
|
-
var startHeight
|
160
|
+
var startY = ev.clientY;
|
161
|
+
var startHeight = parseInt(document.defaultView.getComputedStyle(container).height, 10);
|
104
162
|
var scrollTopStart = consoleOuter.scrollTop;
|
105
163
|
var clientHeightStart = consoleOuter.clientHeight;
|
106
164
|
|
@@ -137,10 +195,10 @@ REPLConsole.prototype.install = function(container) {
|
|
137
195
|
}
|
138
196
|
|
139
197
|
// Initialize
|
198
|
+
this.container = container;
|
140
199
|
this.outer = consoleOuter;
|
141
200
|
this.inner = findChild(this.outer, 'console-inner');
|
142
201
|
this.clipboard = findChild(container, 'clipboard');
|
143
|
-
this.remotePath = container.dataset.remotePath;
|
144
202
|
this.newPromptBox();
|
145
203
|
this.insertCss();
|
146
204
|
|
@@ -263,6 +321,13 @@ REPLConsole.prototype.writeOutput = function(output) {
|
|
263
321
|
consoleMessage.innerHTML = escapeHTML(output);
|
264
322
|
this.inner.appendChild(consoleMessage);
|
265
323
|
this.newPromptBox();
|
324
|
+
return consoleMessage;
|
325
|
+
};
|
326
|
+
|
327
|
+
REPLConsole.prototype.writeError = function(output) {
|
328
|
+
var consoleMessage = this.writeOutput(output);
|
329
|
+
addClass(consoleMessage, "error-message");
|
330
|
+
return consoleMessage;
|
266
331
|
};
|
267
332
|
|
268
333
|
REPLConsole.prototype.onEnterKey = function() {
|
@@ -385,7 +450,7 @@ REPLConsole.prototype.scrollToBottom = function() {
|
|
385
450
|
|
386
451
|
// Change the binding of the console
|
387
452
|
REPLConsole.prototype.switchBindingTo = function(frameId, callback) {
|
388
|
-
var url = this.
|
453
|
+
var url = this.getSessionUrl('trace');
|
389
454
|
var params = "frame_id=" + encodeURIComponent(frameId);
|
390
455
|
postRequest(url, params, callback);
|
391
456
|
};
|
@@ -394,22 +459,16 @@ REPLConsole.prototype.switchBindingTo = function(frameId, callback) {
|
|
394
459
|
* Install the console into the element with a specific ID.
|
395
460
|
* Example: REPLConsole.installInto("target-id")
|
396
461
|
*/
|
397
|
-
REPLConsole.installInto = function(id) {
|
462
|
+
REPLConsole.installInto = function(id, options) {
|
398
463
|
var consoleElement = document.getElementById(id);
|
399
|
-
var remotePath = consoleElement.dataset.remotePath;
|
400
|
-
var replConsole = new REPLConsole({
|
401
|
-
promptLabel: consoleElement.dataset.initialPrompt,
|
402
|
-
commandHandle: function(line) {
|
403
|
-
var _this = this;
|
404
|
-
var url = remotePath;
|
405
|
-
var params = "input=" + encodeURIComponent(line);
|
406
|
-
putRequest(url, params, function(xhr) {
|
407
|
-
var response = JSON.parse(xhr.responseText);
|
408
|
-
_this.writeOutput(response.output);
|
409
|
-
});
|
410
|
-
}
|
411
|
-
});
|
412
464
|
|
465
|
+
options = options || {};
|
466
|
+
|
467
|
+
for (var prop in consoleElement.dataset) {
|
468
|
+
options[prop] = options[prop] || consoleElement.dataset[prop];
|
469
|
+
}
|
470
|
+
|
471
|
+
var replConsole = new REPLConsole(options);
|
413
472
|
replConsole.install(consoleElement);
|
414
473
|
return replConsole;
|
415
474
|
};
|
@@ -419,6 +478,26 @@ REPLConsole.installInto = function(id) {
|
|
419
478
|
// It allows to operate the current session from the other scripts.
|
420
479
|
REPLConsole.currentSession = null;
|
421
480
|
|
481
|
+
// This line is for the Firefox Add-on, because it doesn't have XMLHttpRequest as default.
|
482
|
+
// And so we need to require a module compatible with XMLHttpRequest from SDK.
|
483
|
+
REPLConsole.XMLHttpRequest = typeof XMLHttpRequest === 'undefined' ? null : XMLHttpRequest;
|
484
|
+
|
485
|
+
REPLConsole.request = function request(method, url, params, callback) {
|
486
|
+
var xhr = new REPLConsole.XMLHttpRequest();
|
487
|
+
|
488
|
+
xhr.open(method, url, true);
|
489
|
+
xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded");
|
490
|
+
xhr.setRequestHeader("X-Requested-With", "XMLHttpRequest");
|
491
|
+
xhr.setRequestHeader("Accept", "<%= Mime::WEB_CONSOLE_V2 %>");
|
492
|
+
xhr.send(params);
|
493
|
+
|
494
|
+
xhr.onreadystatechange = function() {
|
495
|
+
if (xhr.readyState === 4) {
|
496
|
+
callback(xhr);
|
497
|
+
}
|
498
|
+
};
|
499
|
+
};
|
500
|
+
|
422
501
|
// DOM helpers
|
423
502
|
function hasClass(el, className) {
|
424
503
|
var regex = new RegExp('(?:^|\\s)' + className + '(?!\\S)', 'g');
|
@@ -470,28 +549,16 @@ function escapeHTML(html) {
|
|
470
549
|
}
|
471
550
|
|
472
551
|
// XHR helpers
|
473
|
-
function
|
474
|
-
|
475
|
-
|
476
|
-
xhr.open(method, url, true);
|
477
|
-
xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded");
|
478
|
-
xhr.setRequestHeader("X-Requested-With", "XMLHttpRequest");
|
479
|
-
xhr.setRequestHeader("Accept", "<%= Mime::WEB_CONSOLE_V2 %>");
|
480
|
-
xhr.send(params);
|
481
|
-
|
482
|
-
xhr.onreadystatechange = function() {
|
483
|
-
if (xhr.readyState === 4) {
|
484
|
-
callback(xhr);
|
485
|
-
}
|
486
|
-
}
|
552
|
+
function postRequest() {
|
553
|
+
REPLConsole.request.apply(this, ["POST"].concat([].slice.call(arguments)));
|
487
554
|
}
|
488
555
|
|
489
|
-
function
|
490
|
-
request("
|
556
|
+
function putRequest() {
|
557
|
+
REPLConsole.request.apply(this, ["PUT"].concat([].slice.call(arguments)));
|
491
558
|
}
|
492
559
|
|
493
|
-
|
494
|
-
|
560
|
+
if (typeof exports === 'object') {
|
561
|
+
exports.REPLConsole = REPLConsole;
|
562
|
+
} else {
|
563
|
+
window.REPLConsole = REPLConsole;
|
495
564
|
}
|
496
|
-
|
497
|
-
window.REPLConsole = REPLConsole;
|
@@ -10,6 +10,7 @@
|
|
10
10
|
.console .console-inner { font-family: monospace; font-size: 11px; width: 100%; height: 100%; overflow: none; background: #333; }
|
11
11
|
.console .console-prompt-box { color: #FFF; }
|
12
12
|
.console .console-message { color: #1AD027; margin: 0; border: 0; white-space: pre-wrap; background-color: #333; padding: 0; }
|
13
|
+
.console .console-message.error-message { color: #fc9; }
|
13
14
|
.console .console-focus .console-cursor { background: #FEFEFE; color: #333; font-weight: bold; }
|
14
15
|
.console .resizer { background: #333; width: 100%; height: 4px; cursor: ns-resize; }
|
15
16
|
.console .console-actions { padding-right: 3px; }
|
@@ -20,3 +21,7 @@
|
|
20
21
|
.console .clipboard { height: 0px; padding: 0px; margin: 0px; width: 0px; margin-left: -1000px; }
|
21
22
|
.console .console-prompt-label { display: inline; color: #FFF; background: none repeat scroll 0% 0% #333; border: 0; padding: 0; }
|
22
23
|
.console .console-prompt-display { display: inline; color: #FFF; background: none repeat scroll 0% 0% #333; border: 0; padding: 0; }
|
24
|
+
.console.full-screen { height: 100%; }
|
25
|
+
.console.full-screen .console-outer { padding-top: 3px; }
|
26
|
+
.console.full-screen .resizer { display: none; }
|
27
|
+
.console.full-screen .close-button { display: none; }
|
@@ -0,0 +1,25 @@
|
|
1
|
+
require 'web_console/testing/helper'
|
2
|
+
require 'web_console/testing/fake_middleware'
|
3
|
+
|
4
|
+
module WebConsole
|
5
|
+
module Testing
|
6
|
+
# This class is to pre-compile 'templates/*.erb'.
|
7
|
+
class ERBPrecompiler
|
8
|
+
def initialize(path)
|
9
|
+
@erb = ERB.new(File.read(path))
|
10
|
+
@view = FakeMiddleware.new(
|
11
|
+
view_path: Helper.gem_root.join('lib/web_console/templates'),
|
12
|
+
).view
|
13
|
+
end
|
14
|
+
|
15
|
+
def build
|
16
|
+
@erb.result(binding)
|
17
|
+
end
|
18
|
+
|
19
|
+
def method_missing(name, *args, &block)
|
20
|
+
return super unless @view.respond_to?(name)
|
21
|
+
@view.send(name, *args, &block)
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
@@ -0,0 +1,43 @@
|
|
1
|
+
require 'action_view'
|
2
|
+
require 'action_dispatch'
|
3
|
+
require 'active_support/core_ext/string/access'
|
4
|
+
require 'json'
|
5
|
+
require 'web_console/whitelist'
|
6
|
+
require 'web_console/request'
|
7
|
+
require 'web_console/view'
|
8
|
+
require 'web_console/testing/helper'
|
9
|
+
|
10
|
+
module WebConsole
|
11
|
+
module Testing
|
12
|
+
class FakeMiddleware
|
13
|
+
I18n.load_path.concat(Dir[Helper.gem_root.join('lib/web_console/locales/*.yml')])
|
14
|
+
|
15
|
+
DEFAULT_HEADERS = { "Content-Type" => "application/javascript" }
|
16
|
+
|
17
|
+
def initialize(opts)
|
18
|
+
@headers = opts.fetch(:headers, DEFAULT_HEADERS)
|
19
|
+
@req_path_regex = opts[:req_path_regex]
|
20
|
+
@view_path = opts[:view_path]
|
21
|
+
end
|
22
|
+
|
23
|
+
def call(env)
|
24
|
+
[ 200, @headers, [ render(req_path(env)) ] ]
|
25
|
+
end
|
26
|
+
|
27
|
+
def view
|
28
|
+
@view ||= View.new(@view_path)
|
29
|
+
end
|
30
|
+
|
31
|
+
private
|
32
|
+
|
33
|
+
# extract target path from REQUEST_PATH
|
34
|
+
def req_path(env)
|
35
|
+
env["REQUEST_PATH"].match(@req_path_regex)[1]
|
36
|
+
end
|
37
|
+
|
38
|
+
def render(template)
|
39
|
+
view.render(template: template, layout: nil)
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
data/lib/web_console/version.rb
CHANGED
@@ -0,0 +1,37 @@
|
|
1
|
+
module WebConsole
|
2
|
+
class View < ActionView::Base
|
3
|
+
# Execute a block only on error pages.
|
4
|
+
#
|
5
|
+
# The error pages are special, because they are the only pages that
|
6
|
+
# currently require multiple bindings. We get those from exceptions.
|
7
|
+
def only_on_error_page(*args)
|
8
|
+
yield if @env['web_console.exception'].present?
|
9
|
+
end
|
10
|
+
|
11
|
+
# Render JavaScript inside a script tag and a closure.
|
12
|
+
#
|
13
|
+
# This one lets write JavaScript that will automatically get wrapped in a
|
14
|
+
# script tag and enclosed in a closure, so you don't have to worry for
|
15
|
+
# leaking globals, unless you explicitly want to.
|
16
|
+
def render_javascript(template)
|
17
|
+
render(template: template, layout: 'layouts/javascript')
|
18
|
+
end
|
19
|
+
|
20
|
+
# Render inlined string to be used inside of JavaScript code.
|
21
|
+
#
|
22
|
+
# The inlined string is returned as an actual JavaScript string. You
|
23
|
+
# don't need to wrap the result yourself.
|
24
|
+
def render_inlined_string(template)
|
25
|
+
render(template: template, layout: 'layouts/inlined_string')
|
26
|
+
end
|
27
|
+
|
28
|
+
# Override method for ActionView::Helpers::TranslationHelper#t.
|
29
|
+
#
|
30
|
+
# This method escapes the original return value for JavaScript, since the
|
31
|
+
# method returns a HTML tag with some attributes when the key is not found,
|
32
|
+
# so it could cause a syntax error if we use the value in the string literals.
|
33
|
+
def t(key, options = {})
|
34
|
+
j super
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
@@ -11,13 +11,6 @@ module WebConsole
|
|
11
11
|
end
|
12
12
|
end
|
13
13
|
|
14
|
-
def acceptable_content_type?
|
15
|
-
whine_unless request.acceptable_content_type? do
|
16
|
-
"Cannot render console with content type #{request.content_type}" \
|
17
|
-
"Allowed content types: #{request.acceptable_content_types}"
|
18
|
-
end
|
19
|
-
end
|
20
|
-
|
21
14
|
private
|
22
15
|
|
23
16
|
def whine_unless(condition)
|
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: web-console
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 2.
|
4
|
+
version: 2.3.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Charlie Somerville
|
@@ -11,7 +11,7 @@ authors:
|
|
11
11
|
autorequire:
|
12
12
|
bindir: bin
|
13
13
|
cert_chain: []
|
14
|
-
date:
|
14
|
+
date: 2016-01-27 00:00:00.000000000 Z
|
15
15
|
dependencies:
|
16
16
|
- !ruby/object:Gem::Dependency
|
17
17
|
name: railties
|
@@ -127,10 +127,14 @@ files:
|
|
127
127
|
- lib/web_console/integration/cruby.rb
|
128
128
|
- lib/web_console/integration/jruby.rb
|
129
129
|
- lib/web_console/integration/rubinius.rb
|
130
|
+
- lib/web_console/locales/en.yml
|
130
131
|
- lib/web_console/middleware.rb
|
131
132
|
- lib/web_console/railtie.rb
|
132
133
|
- lib/web_console/request.rb
|
134
|
+
- lib/web_console/response.rb
|
133
135
|
- lib/web_console/session.rb
|
136
|
+
- lib/web_console/tasks/extensions.rake
|
137
|
+
- lib/web_console/tasks/test_templates.rake
|
134
138
|
- lib/web_console/template.rb
|
135
139
|
- lib/web_console/templates/_inner_console_markup.html.erb
|
136
140
|
- lib/web_console/templates/_markup.html.erb
|
@@ -142,8 +146,11 @@ files:
|
|
142
146
|
- lib/web_console/templates/layouts/javascript.erb
|
143
147
|
- lib/web_console/templates/main.js.erb
|
144
148
|
- lib/web_console/templates/style.css.erb
|
145
|
-
- lib/web_console/
|
149
|
+
- lib/web_console/testing/erb_precompiler.rb
|
150
|
+
- lib/web_console/testing/fake_middleware.rb
|
151
|
+
- lib/web_console/testing/helper.rb
|
146
152
|
- lib/web_console/version.rb
|
153
|
+
- lib/web_console/view.rb
|
147
154
|
- lib/web_console/whiny_request.rb
|
148
155
|
- lib/web_console/whitelist.rb
|
149
156
|
homepage: https://github.com/rails/web-console
|
@@ -166,7 +173,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
166
173
|
version: '0'
|
167
174
|
requirements: []
|
168
175
|
rubyforge_project:
|
169
|
-
rubygems_version: 2.
|
176
|
+
rubygems_version: 2.5.1
|
170
177
|
signing_key:
|
171
178
|
specification_version: 4
|
172
179
|
summary: A debugging tool for your Ruby on Rails applications.
|