ladybug 0.0.1.alpha

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 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: []