ladybug 0.0.1.alpha

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 19fccf01c424b4f35e62b84e6d7aaf3978e0253d
4
+ data.tar.gz: 5a12abd7a1eb3148e9fd067675ffacb4012f4dd4
5
+ SHA512:
6
+ metadata.gz: 481c6763053abdaea1057eba44c38003bc8475010a9684a02c82e5f5e55b217b960f93f29250c19a6cd5306aca4aac8bc6f644c1cd709e86017355a09504f3ac
7
+ data.tar.gz: 61f3f7a14e828a8192a21836f8f9325ebba844a091390b46e3540673476d8492db06f68f1bf70fa420ee9755beb22308276cdb1aa34abe5356a5dad8190c2f96
data/README.md ADDED
@@ -0,0 +1 @@
1
+ # Ladybug
@@ -0,0 +1,156 @@
1
+ # A simple debugger using set_trace_func
2
+ # Allows for external control while in a breakpoint
3
+
4
+ module Ladybug
5
+ class Debugger
6
+ def initialize
7
+ @breakpoints = []
8
+
9
+ @to_main_thread = Queue.new
10
+ @from_main_thread = Queue.new
11
+
12
+ @on_pause = -> {}
13
+ @on_resume = -> {}
14
+ end
15
+
16
+ def start
17
+ RubyVM::InstructionSequence.compile_option = {
18
+ trace_instruction: true
19
+ }
20
+
21
+ set_trace_func trace_func
22
+ end
23
+
24
+ def on_pause(&block)
25
+ @on_pause = block
26
+ end
27
+
28
+ def on_resume(&block)
29
+ @on_resume = block
30
+ end
31
+
32
+ def resume
33
+ @to_main_thread.push({ command: 'continue' })
34
+ end
35
+
36
+ def step_over
37
+ @to_main_thread.push({ command: 'step_over' })
38
+ end
39
+
40
+ def evaluate(expression)
41
+ @to_main_thread.push({
42
+ command: 'eval',
43
+ arguments: {
44
+ expression: expression
45
+ }
46
+ })
47
+
48
+ # Block on eval, returns result
49
+ @from_main_thread.pop
50
+ end
51
+
52
+ # returns a breakpoint ID
53
+ def set_breakpoint(filename:, line_number:)
54
+ breakpoint = {
55
+ filename: filename,
56
+ line_number: line_number,
57
+ id: "filename:#{line_number}"
58
+ }
59
+
60
+ @breakpoints.push(breakpoint)
61
+
62
+ breakpoint[:id]
63
+ end
64
+
65
+ def remove_breakpoint(breakpoint_id)
66
+ filename, line_number = breakpoint_id.split(":")
67
+ line_number = line_number.to_i
68
+
69
+ @breakpoints.delete_if { |bp| bp[:id] == breakpoint_id }
70
+ end
71
+
72
+ private
73
+
74
+ def trace_func
75
+ @step_over_file = nil
76
+
77
+ proc { |event, filename, line_number, id, binding, klass, *rest|
78
+ # This check is called a lot so perhaps worth making faster,
79
+ # but might not matter much with small number of breakpoints in practice
80
+ breakpoint_hit = @breakpoints.find do |bp|
81
+ bp[:filename] == filename && bp[:line_number] == line_number
82
+ end
83
+
84
+ break_on_step_over = (@step_over_file == filename)
85
+
86
+ if breakpoint_hit || break_on_step_over
87
+ local_variables =
88
+ binding.local_variables.each_with_object({}) do |lvar, hash|
89
+ hash[lvar] = binding.local_variable_get(lvar)
90
+ end
91
+
92
+ instance_variables =
93
+ binding.eval("instance_variables").each_with_object({}) do |ivar, hash|
94
+ hash[ivar] = binding.eval("instance_variable_get(:#{ivar})")
95
+ end
96
+
97
+ pause_info = {
98
+ breakpoint_id: breakpoint_hit ? breakpoint_hit[:id] : nil,
99
+ label: Kernel.caller_locations.first.base_label,
100
+ local_variables: local_variables,
101
+ instance_variables: instance_variables,
102
+ filename: filename,
103
+ line_number: line_number
104
+ # call_frames: []
105
+
106
+ # Call frames are pretty complicated...
107
+ # call_frames: Kernel.caller_locations.first(3).map do |call_frame|
108
+ # {
109
+ # callFrameId: SecureRandom.uuid,
110
+ # functionName: call_frame.base_label,
111
+ # scopeChain: [
112
+ # {
113
+ # type: "local",
114
+ # startLocation: ,
115
+ # endLocation:
116
+ # }
117
+ # ],
118
+ # url: "#{"http://rails.com"}/#{filename}",
119
+ # this:
120
+ # }
121
+ # end
122
+ }
123
+
124
+ @on_pause.call(pause_info)
125
+
126
+ loop do
127
+ # block until we get a command from the debugger thread
128
+ message = @to_main_thread.pop
129
+
130
+ case message[:command]
131
+ when 'continue'
132
+ @step_over_file = nil
133
+ break
134
+ when 'step_over'
135
+ # todo: does "step over" really mean break in the same file,
136
+ # or is there are a deeper meaning
137
+ @step_over_file = filename
138
+ break
139
+ when 'eval'
140
+ evaluated =
141
+ begin
142
+ binding.eval(message[:arguments][:expression])
143
+ rescue
144
+ nil
145
+ end
146
+ @from_main_thread.push(evaluated)
147
+ end
148
+ end
149
+
150
+ # Notify the debugger thread that we've resumed
151
+ @on_resume.call({})
152
+ end
153
+ }
154
+ end
155
+ end
156
+ end
@@ -0,0 +1,245 @@
1
+ require 'faye/websocket'
2
+ require 'json'
3
+ require 'thread'
4
+ require 'pathname'
5
+
6
+ require 'ladybug/script_repository'
7
+ require 'ladybug/debugger'
8
+ require 'ladybug/object_manager'
9
+
10
+ # A Rack middleware that accepts a websocket connection from the Chrome
11
+ # Devtools UI and responds to requests, interacting with Ladybug::Debugger
12
+ module Ladybug
13
+ class Middleware
14
+ def initialize(app)
15
+ @app = app
16
+ @scripts = {}
17
+
18
+ @script_repository = ScriptRepository.new
19
+ @debugger = Debugger.new
20
+ @object_manager = ObjectManager.new
21
+ end
22
+
23
+ def call(env)
24
+ puts "Debug in Chrome: chrome-devtools://devtools/bundled/inspector.html?ws=#{env['HTTP_HOST']}"
25
+
26
+ # For now, all websocket connections are assumed to be a debug connection
27
+ if Faye::WebSocket.websocket?(env)
28
+ ws = create_websocket(env)
29
+
30
+ # Return async Rack response
31
+ ws.rack_response
32
+ else
33
+ @app.call(env)
34
+ end
35
+ end
36
+
37
+ private
38
+
39
+ def create_websocket(env)
40
+ ws = Faye::WebSocket.new(env)
41
+
42
+ ws.on :message do |event|
43
+ # The WebSockets library silently swallows errors.
44
+ # Insert our own error handling for debugging purposes.
45
+
46
+ begin
47
+ data = JSON.parse(event.data)
48
+
49
+ if data["method"] == "Page.getResourceTree"
50
+ result = {
51
+ frameTree: {
52
+ frame: {
53
+ id: "123",
54
+ loaderId: "123",
55
+ mimeType: "text/plain",
56
+ securityOrigin: "http://localhost",
57
+ url: "http://localhost"
58
+ },
59
+ resources: @script_repository.all.map do |script|
60
+ {
61
+ mimeType: "text/plain",
62
+ type: "Script",
63
+ contentSize: script.size,
64
+ lastModified: script.last_modified_time.to_i,
65
+ url: script.virtual_url
66
+ }
67
+ end
68
+ }
69
+ }
70
+ elsif data["method"] == "Page.getResourceContent"
71
+ result = {
72
+ base64Encoded: false,
73
+ content: "hello world"
74
+ }
75
+ elsif data["method"] == "Debugger.getScriptSource"
76
+ script_id = data["params"]["scriptId"]
77
+ path = @script_repository.find(id: script_id).path
78
+ result = {
79
+ scriptSource: File.new(path, "r").read
80
+ }
81
+ elsif data["method"] == "Debugger.getPossibleBreakpoints"
82
+ # Just echo back the location the browser requested
83
+ result = { locations: [ data["params"]["start"] ] }
84
+ elsif data["method"] == "Debugger.setBreakpointByUrl"
85
+ # Chrome gives us a virtual URL;
86
+ # we need an absolute path to the file to match the API for set_trace_func
87
+ script = @script_repository.find(virtual_url: data["params"]["url"])
88
+
89
+ # DevTools gives us 0-indexed line numbers but
90
+ # ruby uses 1-indexed line numbers
91
+ line_number = data["params"]["lineNumber"]
92
+ ruby_line_number = line_number + 1
93
+
94
+ puts "setting breakpoint on #{script.path}:#{ruby_line_number}"
95
+
96
+ breakpoint_id = @debugger.set_breakpoint(
97
+ filename: script.absolute_path,
98
+ line_number: ruby_line_number
99
+ )
100
+
101
+ result = {
102
+ breakpointId: breakpoint_id,
103
+ locations: [
104
+ {
105
+ scriptId: script.id,
106
+ lineNumber: line_number,
107
+ columnNumber: data["params"]["columnNumber"],
108
+ }
109
+ ]
110
+ }
111
+ elsif data["method"] == "Debugger.resume"
112
+ # Synchronously just ack the command;
113
+ # we'll async hear back from the main thread when execution resumes
114
+ @debugger.resume
115
+ result = {}
116
+ elsif data["method"] == "Debugger.stepOver"
117
+ # Synchronously just ack the command;
118
+ # we'll async hear back from the main thread when execution resumes
119
+ @debugger.step_over
120
+ result = {}
121
+ elsif data["method"] == "Debugger.evaluateOnCallFrame"
122
+ evaluated = @debugger.evaluate(data["params"]["expression"])
123
+ result = {
124
+ result: @object_manager.serialize(evaluated)
125
+ }
126
+ elsif data["method"] == "Debugger.removeBreakpoint"
127
+ @debugger.remove_breakpoint(data["params"]["breakpointId"])
128
+ result = {}
129
+ elsif data["method"] == "Runtime.getProperties"
130
+ object = @object_manager.find(data["params"]["objectId"])
131
+
132
+ result = {
133
+ result: @object_manager.get_properties(object)
134
+ }
135
+ else
136
+ result = {}
137
+ end
138
+
139
+ response = {
140
+ id: data["id"],
141
+ result: result
142
+ }
143
+
144
+ ws.send(response.to_json)
145
+
146
+ # After we send a resource tree response, we need to send these
147
+ # messages as well to get the files to show up
148
+ if data["method"] == "Page.getResourceTree"
149
+ @script_repository.all.each do |script|
150
+ message = {
151
+ method: "Debugger.scriptParsed",
152
+ params: {
153
+ scriptId: script.id,
154
+ url: script.virtual_url,
155
+ startLine: 0,
156
+ startColumn: 0,
157
+ endLine: 100, #todo: really populate
158
+ endColumn: 100
159
+ }
160
+ }.to_json
161
+
162
+ ws.send(message)
163
+ end
164
+ end
165
+ rescue => e
166
+ puts e.message
167
+ puts e.backtrace
168
+
169
+ raise e
170
+ end
171
+ end
172
+
173
+ ws.on :close do |event|
174
+ p [:close, event.code, event.reason]
175
+ ws = nil
176
+ end
177
+
178
+ # Spawn a thread to handle messages from the main thread
179
+ # and notify the client.
180
+
181
+ @debugger.on_pause do |info|
182
+ # Generate an object representing this scope
183
+ # (this is here not in the debugger because the debugger
184
+ # shouldn't need to know about the requirement for a virtual object)
185
+ virtual_scope_object =
186
+ info[:local_variables].merge(info[:instance_variables])
187
+
188
+ # Register the virtual object to give it an ID and hold a ref to it
189
+ object_id = @object_manager.register(virtual_scope_object)
190
+
191
+ script = @script_repository.find(absolute_path: info[:filename])
192
+
193
+ location = {
194
+ scriptId: script.id,
195
+ lineNumber: info[:line_number] - 1,
196
+ columnNumber: 0
197
+ }
198
+
199
+ msg_to_client = {
200
+ method: "Debugger.paused",
201
+ params: {
202
+ callFrames: [
203
+ {
204
+ location: location,
205
+ callFrameId: SecureRandom.uuid,
206
+ functionName: info[:label],
207
+ scopeChain: [
208
+ {
209
+ type: "local",
210
+ startLocation: location,
211
+ endLocation: location,
212
+ object: {
213
+ className: "Object",
214
+ description: "Object",
215
+ type: "object",
216
+ objectId: object_id
217
+ }
218
+ }
219
+ ],
220
+ url: script.virtual_url
221
+ }
222
+ ],
223
+ hitBreakpoints: info[:breakpoint_id] ? [info[:breakpoint_id]] : [],
224
+ reason: "other"
225
+ }
226
+ }
227
+
228
+ ws.send(msg_to_client.to_json)
229
+ end
230
+
231
+ @debugger.on_resume do
232
+ msg_to_client = {
233
+ method: "Debugger.resumed",
234
+ params: {}
235
+ }
236
+
237
+ ws.send(msg_to_client.to_json)
238
+ end
239
+
240
+ @debugger.start
241
+
242
+ ws
243
+ end
244
+ end
245
+ end
@@ -0,0 +1,139 @@
1
+ module Ladybug
2
+ # This class:
3
+ #
4
+ # * serializes objects for display in the Chrome UI
5
+ # * maintains references to all objects for which it has given out IDs for;
6
+ # this ensures they don't get GC'd and can be dereferenced later by ID.
7
+ #
8
+ # TODO: Handle Chrome's "release" APIs to enable releasing references
9
+ # at some point and avoid too much memory growth
10
+
11
+ class ObjectManager
12
+ def initialize
13
+ @objects = {}
14
+ end
15
+
16
+ # Given an ID, return the object from our registry
17
+ def find(id)
18
+ @objects[id]
19
+ end
20
+
21
+ # Given an object, register it in our internal state and
22
+ # return an ID for it
23
+ def register(object)
24
+ object_id = SecureRandom.uuid
25
+ @objects[object_id] = object
26
+ object_id
27
+ end
28
+
29
+ # Convert a Ruby object to a hash representing a Chrome RemoteObject
30
+ # https://chromedevtools.github.io/devtools-protocol/tot/Runtime#type-RemoteObject
31
+ def serialize(object)
32
+ case object
33
+ when String
34
+ {
35
+ type: "string",
36
+ value: object,
37
+ description: object
38
+ }
39
+ when Numeric
40
+ {
41
+ type: "number",
42
+ value: object,
43
+ description: object.to_s
44
+ }
45
+ when TrueClass, FalseClass
46
+ {
47
+ type: "boolean",
48
+ value: object,
49
+ description: object.to_s
50
+ }
51
+ when Symbol
52
+ {
53
+ type: "symbol",
54
+ value: object,
55
+ description: object.to_s
56
+ }
57
+ when Array
58
+ result = {
59
+ type: "object",
60
+ className: object.class.to_s,
61
+ description: "Array(#{object.length})",
62
+ objectId: register(object),
63
+ subtype: "array"
64
+ }
65
+
66
+ result.merge!(
67
+ preview: result.merge(
68
+ overflow: false,
69
+ properties: get_properties(object)
70
+ )
71
+ )
72
+
73
+ result
74
+ when nil
75
+ {
76
+ type: "object",
77
+ subtype: "null",
78
+ value: nil
79
+ }
80
+ else
81
+ {
82
+ type: "object",
83
+ className: object.class.to_s,
84
+ description: object.to_s,
85
+ objectId: register(object)
86
+ }
87
+ end
88
+ end
89
+
90
+ # Ruby objects don't have properties like JS objects do,
91
+ # so we need to figure out the best properties to show for non-primitives.
92
+ #
93
+ # We first give the object a chance to tell us its debug properties,
94
+ # then we fall back to handling a bunch of common cases,
95
+ # then finally we give up and just serialize its instance variables.
96
+ def get_properties(object)
97
+ if object.respond_to?(:chrome_debug_properties)
98
+ get_properties(object.chrome_debug_properties)
99
+ elsif object.is_a? Hash
100
+ object.
101
+ map do |key, value|
102
+ kv = {
103
+ name: key,
104
+ value: serialize(value)
105
+ }
106
+
107
+ kv
108
+ end.
109
+ reject { |property| property[:value].nil? }
110
+ elsif object.is_a? Array
111
+ object.map.with_index do |element, index|
112
+ {
113
+ name: index.to_s,
114
+ value: serialize(element)
115
+ }
116
+ end
117
+
118
+ # TODO: This section is too magical,
119
+ # better to let users just define their own chrome_debug_properties
120
+ # and then add an optional Rails plugin to the gem which
121
+ # monkey patches rails classes.
122
+ elsif object.respond_to?(:to_a)
123
+ get_properties(object.to_a)
124
+ elsif object.respond_to?(:attributes)
125
+ get_properties(object.attributes)
126
+ elsif object.respond_to?(:to_hash)
127
+ get_properties(object.to_hash)
128
+ elsif object.respond_to?(:to_h)
129
+ get_properties(object.to_h)
130
+ else
131
+ ivar_hash = object.instance_variables.each_with_object({}) do |ivar, hash|
132
+ hash[ivar] = object.instance_variable_get(ivar)
133
+ end
134
+
135
+ get_properties(ivar_hash)
136
+ end
137
+ end
138
+ end
139
+ end
@@ -0,0 +1,50 @@
1
+ # A repository of the source files for the app.
2
+ # The Chrome Devtools API switches back and forth between different
3
+ # references to a source file; this repository enables lookup by
4
+ # different attributes and conversion between them.
5
+
6
+ module Ladybug
7
+ class ScriptRepository
8
+ # todo: would be nice to dynamically set this to the server name
9
+ ROOT_URL = "http://localhost"
10
+
11
+ # todo: accept path as param?
12
+ def initialize
13
+ @scripts = enumerate_scripts
14
+ end
15
+
16
+ def all
17
+ @scripts
18
+ end
19
+
20
+ def find(args)
21
+ @scripts.find do |script|
22
+ args.all? do |key, value|
23
+ script[key] == value
24
+ end
25
+ end
26
+ end
27
+
28
+ private
29
+
30
+ # Return a list of Scripts with all attributes populated
31
+ def enumerate_scripts
32
+ Dir.glob("**/*").
33
+ reject { |f| File.directory?(f) }.
34
+ select { |f| File.extname(f) == ".rb" }.
35
+ map do |filename|
36
+ stat = File.stat(filename)
37
+
38
+ OpenStruct.new(
39
+ id: SecureRandom.uuid,
40
+ path: filename,
41
+ absolute_path: File.expand_path(filename),
42
+ virtual_url: "#{ROOT_URL}/#{filename}",
43
+ size: stat.size,
44
+ last_modified_time: stat.mtime
45
+ )
46
+ end
47
+ end
48
+ end
49
+ end
50
+
data/lib/ladybug.rb ADDED
@@ -0,0 +1,4 @@
1
+ module Ladybug
2
+ end
3
+
4
+ require 'ladybug/middleware'
metadata ADDED
@@ -0,0 +1,63 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: ladybug
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1.alpha
5
+ platform: ruby
6
+ authors:
7
+ - Geoffrey Litt
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2018-01-21 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: faye-websocket
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '0'
27
+ description: Debug Ruby code using Chrome Devtools
28
+ email: gklitt@gmail.com
29
+ executables: []
30
+ extensions: []
31
+ extra_rdoc_files: []
32
+ files:
33
+ - README.md
34
+ - lib/ladybug.rb
35
+ - lib/ladybug/debugger.rb
36
+ - lib/ladybug/middleware.rb
37
+ - lib/ladybug/object_manager.rb
38
+ - lib/ladybug/script_repository.rb
39
+ homepage: http://rubygems.org/gems/ladybug
40
+ licenses:
41
+ - MIT
42
+ metadata: {}
43
+ post_install_message:
44
+ rdoc_options: []
45
+ require_paths:
46
+ - lib
47
+ required_ruby_version: !ruby/object:Gem::Requirement
48
+ requirements:
49
+ - - ">="
50
+ - !ruby/object:Gem::Version
51
+ version: '0'
52
+ required_rubygems_version: !ruby/object:Gem::Requirement
53
+ requirements:
54
+ - - ">"
55
+ - !ruby/object:Gem::Version
56
+ version: 1.3.1
57
+ requirements: []
58
+ rubyforge_project:
59
+ rubygems_version: 2.6.10
60
+ signing_key:
61
+ specification_version: 4
62
+ summary: Ladybug
63
+ test_files: []