From 8f308d8b76ab4073a3a6109d79c0292ff036cdfc Mon Sep 17 00:00:00 2001 From: Genadi Samokovarov Date: Sun, 24 Jan 2016 17:28:06 +0200 Subject: [PATCH] Let #console live in Kernel With this change, we can render a console from any part of an Rails application, not just in the controllers or the views. Wanna introspect a model or your plain old Ruby class? No problem! Hopefully, this change can boost up the usage of the `#console` helper. To do this juggling we now persist a binding or an exception in a fiber local variable. The middleware can create a console session from those, when needed. This is a global state, so we take precautions to clean it between requests, even in multi-threaded environments. --- lib/web_console.rb | 1 - lib/web_console/extensions.rb | 56 +++++++++++++------ lib/web_console/helper.rb | 22 -------- lib/web_console/middleware.rb | 13 ++--- lib/web_console/railtie.rb | 8 --- lib/web_console/session.rb | 27 +++++---- .../app/controllers/model_test_controller.rb | 16 ++++++ .../dummy/app/views/model_test/index.html.erb | 0 test/dummy/config/routes.rb | 1 + test/web_console/extensions_test.rb | 4 +- test/web_console/helper_test.rb | 5 +- test/web_console/middleware_test.rb | 43 +++++++------- test/web_console/session_test.rb | 24 ++++++-- 13 files changed, 125 insertions(+), 95 deletions(-) delete mode 100644 lib/web_console/helper.rb create mode 100644 test/dummy/app/controllers/model_test_controller.rb create mode 100644 test/dummy/app/views/model_test/index.html.erb diff --git a/lib/web_console.rb b/lib/web_console.rb index f091d523..d415c8a2 100644 --- a/lib/web_console.rb +++ b/lib/web_console.rb @@ -6,7 +6,6 @@ module WebConsole extend ActiveSupport::Autoload autoload :View - autoload :Helper autoload :Evaluator autoload :Session autoload :Response diff --git a/lib/web_console/extensions.rb b/lib/web_console/extensions.rb index 52e1e3c1..cb538ee8 100644 --- a/lib/web_console/extensions.rb +++ b/lib/web_console/extensions.rb @@ -1,24 +1,46 @@ -ActionDispatch::DebugExceptions.class_eval do - def render_exception_with_web_console(request, exception) - render_exception_without_web_console(request, exception).tap do - # Retain superficial Rails 4.2 compatibility. - env = Hash === request ? request : request.env +module Kernel + # Instructs Web Console to render a console in the specified binding. + # + # If +bidning+ isn't explicitly given it will default to the binding of the + # previous frame. E.g. the one that invoked +console+. + # + # Raises DoubleRenderError if a double +console+ invocation per request is + # detected. + def console(binding = WebConsole.caller_bindings.first) + raise WebConsole::DoubleRenderError if Thread.current[:__web_console_binding] - backtrace_cleaner = env['action_dispatch.backtrace_cleaner'] - error = ActionDispatch::ExceptionWrapper.new(backtrace_cleaner, exception).exception + Thread.current[:__web_console_binding] = binding - # Get the original exception if ExceptionWrapper decides to follow it. - env['web_console.exception'] = error + # Make sure nothing is rendered from the view helper. Otherwise + # you're gonna see unexpected # in the + # templates. + nil + end +end + +module ActionDispatch + class DebugExceptions + def render_exception_with_web_console(request, exception) + render_exception_without_web_console(request, exception).tap do + # Retain superficial Rails 4.2 compatibility. + env = Hash === request ? request : request.env + + backtrace_cleaner = env['action_dispatch.backtrace_cleaner'] + error = ExceptionWrapper.new(backtrace_cleaner, exception).exception - # ActionView::Template::Error bypass ExceptionWrapper original - # exception following. The backtrace in the view is generated from - # reaching out to original_exception in the view. - if error.is_a?(ActionView::Template::Error) - env['web_console.exception'] = error.cause + # Get the original exception if ExceptionWrapper decides to follow it. + Thread.current[:__web_console_exception] = error + + # ActionView::Template::Error bypass ExceptionWrapper original + # exception following. The backtrace in the view is generated from + # reaching out to original_exception in the view. + if error.is_a?(ActionView::Template::Error) + Thread.current[:__web_console_exception] = error.cause + end end end - end - alias_method :render_exception_without_web_console, :render_exception - alias_method :render_exception, :render_exception_with_web_console + alias_method :render_exception_without_web_console, :render_exception + alias_method :render_exception, :render_exception_with_web_console + end end diff --git a/lib/web_console/helper.rb b/lib/web_console/helper.rb deleted file mode 100644 index 8f8b627c..00000000 --- a/lib/web_console/helper.rb +++ /dev/null @@ -1,22 +0,0 @@ -module WebConsole - module Helper - # Communicates with the middleware to render a console in a +binding+. - # - # If +bidning+ isn't explicitly given, Binding#of_caller will be used to - # get the binding of the previous frame. E.g. the one that invoked - # +console+. - # - # Raises DoubleRenderError if a double +console+ invocation per request is - # detected. - def console(binding = nil) - raise DoubleRenderError if request.env['web_console.binding'] - - request.env['web_console.binding'] = binding || ::WebConsole.caller_bindings.first - - # Make sure nothing is rendered from the view helper. Otherwise - # you're gonna see unexpected # in the - # templates. - nil - end - end -end diff --git a/lib/web_console/middleware.rb b/lib/web_console/middleware.rb index b4477023..d0c36006 100644 --- a/lib/web_console/middleware.rb +++ b/lib/web_console/middleware.rb @@ -27,13 +27,7 @@ def call(env) status, headers, body = call_app(env) - if exception = env['web_console.exception'] - session = Session.from_exception(exception) - elsif binding = env['web_console.binding'] - session = Session.from_binding(binding) - end - - if session && acceptable_content_type?(headers) + if session = Session.from(Thread.current) and acceptable_content_type?(headers) response = Response.new(body, status, headers) template = Template.new(env, session) @@ -49,6 +43,11 @@ def call(env) WebConsole.logger.error("\n#{e.class}: #{e}\n\tfrom #{e.backtrace.join("\n\tfrom ")}") raise e ensure + # Clean up the fiber locals after the session creation. Object#console + # uses those to communicate the current binding or exception to the middleware. + Thread.current[:__web_console_exception] = nil + Thread.current[:__web_console_binding] = nil + raise app_exception if Exception === app_exception end diff --git a/lib/web_console/railtie.rb b/lib/web_console/railtie.rb index f06aaeac..71f3b778 100644 --- a/lib/web_console/railtie.rb +++ b/lib/web_console/railtie.rb @@ -9,14 +9,6 @@ class Railtie < ::Rails::Railtie require 'web_console/integration' require 'web_console/extensions' - ActiveSupport.on_load(:action_view) do - ActionView::Base.send(:include, Helper) - end - - ActiveSupport.on_load(:action_controller) do - ActionController::Base.send(:include, Helper) - end - if logger = ::Rails.logger WebConsole.logger = logger end diff --git a/lib/web_console/session.rb b/lib/web_console/session.rb index 33358963..a6418980 100644 --- a/lib/web_console/session.rb +++ b/lib/web_console/session.rb @@ -1,9 +1,9 @@ module WebConsole - # A session lets you persist wrap an +Evaluator+ instance in memory - # associated with multiple bindings. + # A session lets you persist an +Evaluator+ instance in memory associated + # with multiple bindings. # # Each newly created session is persisted into memory and you can find it - # later its +id+. + # later by its +id+. # # A session may be associated with multiple bindings. This is used by the # error pages only, as currently, this is the only client that needs to do @@ -21,14 +21,19 @@ def find(id) inmemory_storage[id] end - # Create a Session from an exception. - def from_exception(exc) - new(exc.bindings) - end - - # Create a Session from a single binding. - def from_binding(binding) - new(binding) + # Create a Session from an binding or exception in a storage. + # + # The storage is expected to respond to #[]. The binding is expected in + # :__web_console_binding and the exception in :__web_console_exception. + # + # Can return nil, if no binding or exception have been preserved in the + # storage. + def from(storage) + if exc = storage[:__web_console_exception] + new(exc.bindings) + elsif binding = storage[:__web_console_binding] + new(binding) + end end end diff --git a/test/dummy/app/controllers/model_test_controller.rb b/test/dummy/app/controllers/model_test_controller.rb new file mode 100644 index 00000000..6d891191 --- /dev/null +++ b/test/dummy/app/controllers/model_test_controller.rb @@ -0,0 +1,16 @@ +class ModelTestController < ApplicationController + def index + LocalModel.new.work + end + + class LocalModel + def initialize + @state = :state + end + + def work + local_var = 42 + console + end + end +end diff --git a/test/dummy/app/views/model_test/index.html.erb b/test/dummy/app/views/model_test/index.html.erb new file mode 100644 index 00000000..e69de29b diff --git a/test/dummy/config/routes.rb b/test/dummy/config/routes.rb index 5833f61b..0bad8550 100644 --- a/test/dummy/config/routes.rb +++ b/test/dummy/config/routes.rb @@ -4,6 +4,7 @@ get :exception_test, to: "exception_test#index" get :xhr_test, to: "exception_test#xhr" get :helper_test, to: "helper_test#index" + get :model_test, to: "model_test#index" get :helper_error, to: "helper_error#index" get :controller_helper_test, to: "controller_helper_test#index" diff --git a/test/web_console/extensions_test.rb b/test/web_console/extensions_test.rb index 0f74bd30..08a33223 100644 --- a/test/web_console/extensions_test.rb +++ b/test/web_console/extensions_test.rb @@ -15,14 +15,14 @@ def call(env) @app = DebugExceptions.new(Application.new) end - test "follows ActionView::Template::Error original error in env['web_console.exception']" do + test "follows ActionView::Template::Error original error in Thread.current[:__web_console_exception]" do get "/", params: {}, headers: { 'action_dispatch.show_detailed_exceptions' => true, 'action_dispatch.show_exceptions' => true, 'action_dispatch.logger' => Logger.new(StringIO.new) } - assert_equal 42, request.env['web_console.exception'].bindings.first.eval('@ivar') + assert_equal 42, Thread.current[:__web_console_exception].bindings.first.eval('@ivar') end end end diff --git a/test/web_console/helper_test.rb b/test/web_console/helper_test.rb index ef61a305..ce17400d 100644 --- a/test/web_console/helper_test.rb +++ b/test/web_console/helper_test.rb @@ -3,8 +3,6 @@ module WebConsole class HelperTest < ActionDispatch::IntegrationTest class BaseApplication - include Helper - def call(env) [ status, headers, body ] end @@ -59,6 +57,9 @@ def call(env) end setup do + Thread.current[:__web_console_exception] = nil + Thread.current[:__web_console_binding] = nil + Request.stubs(:whitelisted_ips).returns(IPAddr.new('0.0.0.0/0')) @app = Middleware.new(SingleConsoleApplication.new) diff --git a/test/web_console/middleware_test.rb b/test/web_console/middleware_test.rb index 4b76d03a..ec0dcbf7 100644 --- a/test/web_console/middleware_test.rb +++ b/test/web_console/middleware_test.rb @@ -43,59 +43,64 @@ def body end test 'render console in an html application from web_console.binding' do - get '/', params: nil, headers: { 'web_console.binding' => binding } + Thread.current[:__web_console_binding] = binding + + get '/', params: nil assert_select '#console' end test 'render console in an html application from web_console.exception' do - get '/', params: nil, headers: { 'web_console.exception' => raise_exception } + Thread.current[:__web_console_exception] = raise_exception + + get '/', params: nil assert_select 'body > #console' end test 'render console if response format is HTML' do + Thread.current[:__web_console_binding] = binding @app = Middleware.new(Application.new(response_content_type: Mime[:html])) - get '/', params: nil, headers: { 'web_console.binding' => binding } + + get '/', params: nil assert_select '#console' end test 'does not render console if response format is not HTML' do + Thread.current[:__web_console_binding] = binding @app = Middleware.new(Application.new(response_content_type: Mime[:json])) - get '/', params: nil, headers: { 'web_console.binding' => binding } + + get '/', params: nil assert_select '#console', 0 end test 'returns X-Web-Console-Session-Id as response header' do - get '/', params: nil, headers: { 'web_console.binding' => binding } + Thread.current[:__web_console_binding] = binding + + get '/', params: nil session_id = response.headers["X-Web-Console-Session-Id"] assert_not Session.find(session_id).nil? end - test 'prioritizes web_console.exception over web_console.binding' do - exception = raise_exception - - Session.expects(:from_exception).with(exception) - - get '/', params: nil, headers: { 'web_console.binding' => binding, 'web_console.exception' => exception } - end - test "doesn't render console in non html response" do + Thread.current[:__web_console_binding] = binding @app = Middleware.new(Application.new(response_content_type: Mime[:json])) - get '/', params: nil, headers: { 'web_console.binding' => binding } + + get '/', params: nil assert_select '#console', 0 end test "doesn't render console from non whitelisted IP" do + Thread.current[:__web_console_binding] = binding Request.stubs(:whitelisted_ips).returns(IPAddr.new('127.0.0.1')) silence(:stderr) do - get '/', params: nil, headers: { 'REMOTE_ADDR' => '1.1.1.1', 'web_console.binding' => binding } + get '/', params: nil, headers: { 'REMOTE_ADDR' => '1.1.1.1' } end assert_select '#console', 0 @@ -110,9 +115,9 @@ def body test 'can evaluate code and return it as a JSON' do session, line = Session.new(binding), __LINE__ - Session.stubs(:from_binding).returns(session) + Session.stubs(:from).returns(session) - get '/', params: nil, headers: { 'web-console.binding' => binding } + get '/', params: nil put "/repl_sessions/#{session.id}", xhr: true, params: { input: '__LINE__' } assert_equal({ output: "=> #{line}\n" }.to_json, response.body) @@ -121,9 +126,9 @@ def body test 'can switch bindings on error pages' do session = Session.new(exception = raise_exception) - Session.stubs(:from_exception).returns(session) + Session.stubs(:from).returns(session) - get '/', params: nil, headers: { 'web-console.exception' => exception } + get '/', params: nil post "/repl_sessions/#{session.id}/trace", xhr: true, params: { frame_id: 1 } assert_equal({ ok: true }.to_json, response.body) diff --git a/test/web_console/session_test.rb b/test/web_console/session_test.rb index 48ff8877..14ffd5bd 100644 --- a/test/web_console/session_test.rb +++ b/test/web_console/session_test.rb @@ -33,24 +33,36 @@ def initialize(line) assert_equal "=> 42\n", @session.eval('40 + 2') end - test 'can create session from a single binding' do + test '#from can create session from a single binding' do saved_line, saved_binding = __LINE__, binding - session = Session.from_binding(saved_binding) + Thread.current[:__web_console_binding] = saved_binding + + session = Session.from(__web_console_binding: saved_binding) assert_equal "=> #{saved_line}\n", session.eval('__LINE__') end - test 'can create session from an exception' do + test '#from can create session from an exception' do exc = LineAwareError.raise - session = Session.from_exception(exc) + + session = Session.from(__web_console_exception: exc) assert_equal "=> #{exc.line}\n", session.eval('__LINE__') end - test 'can switch to bindings' do + test '#from can switch to bindings' do + exc, saved_line = LineAwareError.raise, __LINE__ + + session = Session.from(__web_console_exception: exc) + session.switch_binding_to(1) + + assert_equal "=> #{saved_line}\n", session.eval('__LINE__') + end + + test '#from prioritizes exceptions over bindings' do exc, saved_line = LineAwareError.raise, __LINE__ - session = Session.from_exception(exc) + session = Session.from(__web_console_exception: exc, __web_console_binding: binding) session.switch_binding_to(1) assert_equal "=> #{saved_line}\n", session.eval('__LINE__')