appmap 0.31.0 → 0.34.2
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 +4 -4
- data/.gitignore +1 -1
- data/.rbenv-gemsets +1 -0
- data/CHANGELOG.md +22 -0
- data/README.md +38 -4
- data/Rakefile +10 -3
- data/appmap.gemspec +5 -0
- data/ext/appmap/appmap.c +26 -0
- data/ext/appmap/extconf.rb +6 -0
- data/lib/appmap.rb +23 -10
- data/lib/appmap/class_map.rb +13 -7
- data/lib/appmap/config.rb +54 -30
- data/lib/appmap/cucumber.rb +19 -2
- data/lib/appmap/event.rb +25 -16
- data/lib/appmap/hook.rb +52 -77
- data/lib/appmap/hook/method.rb +103 -0
- data/lib/appmap/open.rb +57 -0
- data/lib/appmap/rails/action_handler.rb +7 -7
- data/lib/appmap/rails/sql_handler.rb +10 -8
- data/lib/appmap/rspec.rb +1 -1
- data/lib/appmap/trace.rb +7 -7
- data/lib/appmap/util.rb +19 -0
- data/lib/appmap/version.rb +1 -1
- data/spec/abstract_controller4_base_spec.rb +1 -1
- data/spec/abstract_controller_base_spec.rb +9 -2
- data/spec/fixtures/hook/instance_method.rb +4 -0
- data/spec/fixtures/hook/singleton_method.rb +21 -12
- data/spec/hook_spec.rb +140 -44
- data/spec/open_spec.rb +19 -0
- data/spec/record_sql_rails_pg_spec.rb +56 -33
- data/test/cli_test.rb +12 -2
- data/test/fixtures/openssl_recorder/Gemfile +3 -0
- data/test/fixtures/openssl_recorder/appmap.yml +3 -0
- data/{spec/fixtures/hook/openssl_sign.rb → test/fixtures/openssl_recorder/lib/openssl_cert_sign.rb} +11 -4
- data/test/fixtures/openssl_recorder/lib/openssl_encrypt.rb +34 -0
- data/test/fixtures/openssl_recorder/lib/openssl_key_sign.rb +28 -0
- data/test/openssl_test.rb +203 -0
- data/test/test_helper.rb +1 -0
- metadata +58 -4
data/lib/appmap/cucumber.rb
CHANGED
@@ -4,6 +4,8 @@ require 'appmap/util'
|
|
4
4
|
|
5
5
|
module AppMap
|
6
6
|
module Cucumber
|
7
|
+
APPMAP_OUTPUT_DIR = 'tmp/appmap/cucumber'
|
8
|
+
|
7
9
|
ScenarioAttributes = Struct.new(:name, :feature, :feature_group)
|
8
10
|
|
9
11
|
ProviderStruct = Struct.new(:scenario) do
|
@@ -38,18 +40,27 @@ module AppMap
|
|
38
40
|
end
|
39
41
|
|
40
42
|
class << self
|
43
|
+
def init
|
44
|
+
warn 'Configuring AppMap recorder for Cucumber'
|
45
|
+
|
46
|
+
FileUtils.mkdir_p APPMAP_OUTPUT_DIR
|
47
|
+
end
|
48
|
+
|
41
49
|
def write_scenario(scenario, appmap)
|
42
50
|
appmap['metadata'] = update_metadata(scenario, appmap['metadata'])
|
43
51
|
scenario_filename = AppMap::Util.scenario_filename(appmap['metadata']['name'])
|
44
52
|
|
45
|
-
|
46
|
-
File.write(File.join('tmp/appmap/cucumber', scenario_filename), JSON.generate(appmap))
|
53
|
+
File.write(File.join(APPMAP_OUTPUT_DIR, scenario_filename), JSON.generate(appmap))
|
47
54
|
end
|
48
55
|
|
49
56
|
def enabled?
|
50
57
|
ENV['APPMAP'] == 'true'
|
51
58
|
end
|
52
59
|
|
60
|
+
def run
|
61
|
+
init
|
62
|
+
end
|
63
|
+
|
53
64
|
protected
|
54
65
|
|
55
66
|
def cucumber_version
|
@@ -87,3 +98,9 @@ module AppMap
|
|
87
98
|
end
|
88
99
|
end
|
89
100
|
end
|
101
|
+
|
102
|
+
if AppMap::Cucumber.enabled?
|
103
|
+
require 'appmap'
|
104
|
+
|
105
|
+
AppMap::Cucumber.run
|
106
|
+
end
|
data/lib/appmap/event.rb
CHANGED
@@ -15,21 +15,15 @@ module AppMap
|
|
15
15
|
end
|
16
16
|
end
|
17
17
|
|
18
|
-
MethodEventStruct = Struct.new(:id, :event, :
|
18
|
+
MethodEventStruct = Struct.new(:id, :event, :thread_id)
|
19
19
|
|
20
20
|
class MethodEvent < MethodEventStruct
|
21
21
|
LIMIT = 100
|
22
22
|
|
23
23
|
class << self
|
24
|
-
def build_from_invocation(me, event_type
|
24
|
+
def build_from_invocation(me, event_type)
|
25
25
|
me.id = AppMap::Event.next_id_counter
|
26
26
|
me.event = event_type
|
27
|
-
me.defined_class = defined_class
|
28
|
-
me.method_id = method.name.to_s
|
29
|
-
path = method.source_location[0]
|
30
|
-
path = path[Dir.pwd.length + 1..-1] if path.index(Dir.pwd) == 0
|
31
|
-
me.path = path
|
32
|
-
me.lineno = method.source_location[1]
|
33
27
|
me.thread_id = Thread.current.object_id
|
34
28
|
end
|
35
29
|
|
@@ -58,15 +52,25 @@ module AppMap
|
|
58
52
|
(value_string||'')[0...LIMIT].encode('utf-8', invalid: :replace, undef: :replace, replace: '_')
|
59
53
|
end
|
60
54
|
end
|
61
|
-
|
62
55
|
end
|
63
56
|
|
64
57
|
class MethodCall < MethodEvent
|
65
|
-
attr_accessor :parameters, :receiver, :static
|
58
|
+
attr_accessor :defined_class, :method_id, :path, :lineno, :parameters, :receiver, :static
|
66
59
|
|
67
60
|
class << self
|
68
61
|
def build_from_invocation(mc = MethodCall.new, defined_class, method, receiver, arguments)
|
69
62
|
mc.tap do
|
63
|
+
static = receiver.is_a?(Module)
|
64
|
+
mc.defined_class = defined_class
|
65
|
+
mc.method_id = method.name.to_s
|
66
|
+
if method.source_location
|
67
|
+
path = method.source_location[0]
|
68
|
+
path = path[Dir.pwd.length + 1..-1] if path.index(Dir.pwd) == 0
|
69
|
+
mc.path = path
|
70
|
+
mc.lineno = method.source_location[1]
|
71
|
+
else
|
72
|
+
mc.path = [ defined_class, static ? '.' : '#', method.name ].join
|
73
|
+
end
|
70
74
|
mc.parameters = method.parameters.map.with_index do |method_param, idx|
|
71
75
|
param_type, param_name = method_param
|
72
76
|
param_name ||= 'arg'
|
@@ -84,17 +88,22 @@ module AppMap
|
|
84
88
|
object_id: receiver.__id__,
|
85
89
|
value: display_string(receiver)
|
86
90
|
}
|
87
|
-
mc.static =
|
88
|
-
MethodEvent.build_from_invocation(mc, :call
|
91
|
+
mc.static = static
|
92
|
+
MethodEvent.build_from_invocation(mc, :call)
|
89
93
|
end
|
90
94
|
end
|
91
95
|
end
|
92
96
|
|
93
97
|
def to_h
|
94
98
|
super.tap do |h|
|
99
|
+
h[:defined_class] = defined_class
|
100
|
+
h[:method_id] = method_id
|
101
|
+
h[:path] = path
|
102
|
+
h[:lineno] = lineno
|
95
103
|
h[:static] = static
|
96
104
|
h[:parameters] = parameters
|
97
105
|
h[:receiver] = receiver
|
106
|
+
h.delete_if { |_, v| v.nil? }
|
98
107
|
end
|
99
108
|
end
|
100
109
|
|
@@ -105,11 +114,11 @@ module AppMap
|
|
105
114
|
attr_accessor :parent_id, :elapsed
|
106
115
|
|
107
116
|
class << self
|
108
|
-
def build_from_invocation(mr = MethodReturnIgnoreValue.new,
|
117
|
+
def build_from_invocation(mr = MethodReturnIgnoreValue.new, parent_id, elapsed)
|
109
118
|
mr.tap do |_|
|
110
119
|
mr.parent_id = parent_id
|
111
120
|
mr.elapsed = elapsed
|
112
|
-
MethodEvent.build_from_invocation(mr, :return
|
121
|
+
MethodEvent.build_from_invocation(mr, :return)
|
113
122
|
end
|
114
123
|
end
|
115
124
|
end
|
@@ -126,7 +135,7 @@ module AppMap
|
|
126
135
|
attr_accessor :return_value, :exceptions
|
127
136
|
|
128
137
|
class << self
|
129
|
-
def build_from_invocation(mr = MethodReturn.new,
|
138
|
+
def build_from_invocation(mr = MethodReturn.new, parent_id, elapsed, return_value, exception)
|
130
139
|
mr.tap do |_|
|
131
140
|
if return_value
|
132
141
|
mr.return_value = {
|
@@ -152,7 +161,7 @@ module AppMap
|
|
152
161
|
|
153
162
|
mr.exceptions = exceptions
|
154
163
|
end
|
155
|
-
MethodReturnIgnoreValue.build_from_invocation(mr,
|
164
|
+
MethodReturnIgnoreValue.build_from_invocation(mr, parent_id, elapsed)
|
156
165
|
end
|
157
166
|
end
|
158
167
|
end
|
data/lib/appmap/hook.rb
CHANGED
@@ -4,27 +4,20 @@ require 'English'
|
|
4
4
|
|
5
5
|
module AppMap
|
6
6
|
class Hook
|
7
|
-
LOG =
|
8
|
-
|
9
|
-
HOOK_DISABLE_KEY = 'AppMap::Hook.disable'
|
7
|
+
LOG = (ENV['DEBUG'] == 'true')
|
10
8
|
|
11
9
|
class << self
|
10
|
+
def lock_builtins
|
11
|
+
return if @builtins_hooked
|
12
|
+
|
13
|
+
@builtins_hooked = true
|
14
|
+
end
|
15
|
+
|
12
16
|
# Return the class, separator ('.' or '#'), and method name for
|
13
17
|
# the given method.
|
14
18
|
def qualify_method_name(method)
|
15
19
|
if method.owner.singleton_class?
|
16
|
-
|
17
|
-
# #<Class:Foo> or
|
18
|
-
# #<Class:#<Bar:0x0123ABC>>. Retrieve the name of
|
19
|
-
# the class from the string.
|
20
|
-
#
|
21
|
-
# (There really isn't a better way to do this. The
|
22
|
-
# singleton's reference to the class it was created
|
23
|
-
# from is stored in an instance variable named
|
24
|
-
# '__attached__'. It doesn't have the '@' prefix, so
|
25
|
-
# it's internal only, and not accessible from user
|
26
|
-
# code.)
|
27
|
-
class_name = /#<Class:((#<(?<cls>.*?):)|((?<cls>.*?)>))/.match(method.owner.to_s)['cls']
|
20
|
+
class_name = singleton_method_owner_name(method)
|
28
21
|
[ class_name, '.', method.name ]
|
29
22
|
else
|
30
23
|
[ method.owner.name, '#', method.name ]
|
@@ -39,92 +32,74 @@ module AppMap
|
|
39
32
|
|
40
33
|
# Observe class loading and hook all methods which match the config.
|
41
34
|
def enable &block
|
42
|
-
|
43
|
-
require 'appmap/event'
|
44
|
-
call_event = AppMap::Event::MethodCall.build_from_invocation(defined_class, method, receiver, args)
|
45
|
-
AppMap.tracing.record_event call_event, defined_class: defined_class, method: method
|
46
|
-
[ call_event, Time.now ]
|
47
|
-
end
|
48
|
-
|
49
|
-
after_hook = lambda do |call_event, defined_class, method, start_time, return_value, exception|
|
50
|
-
require 'appmap/event'
|
51
|
-
elapsed = Time.now - start_time
|
52
|
-
return_event = AppMap::Event::MethodReturn.build_from_invocation \
|
53
|
-
defined_class, method, call_event.id, elapsed, return_value, exception
|
54
|
-
AppMap.tracing.record_event return_event
|
55
|
-
end
|
35
|
+
require 'appmap/hook/method'
|
56
36
|
|
57
|
-
|
58
|
-
# Don't record functions, such as to_s and inspect, that might be called
|
59
|
-
# by the fn. Otherwise there can be a stack overflow.
|
60
|
-
Thread.current[HOOK_DISABLE_KEY] = true
|
61
|
-
begin
|
62
|
-
fn.call
|
63
|
-
ensure
|
64
|
-
Thread.current[HOOK_DISABLE_KEY] = false
|
65
|
-
end
|
66
|
-
end
|
37
|
+
hook_builtins
|
67
38
|
|
68
|
-
tp = TracePoint.new(:end) do |
|
69
|
-
|
70
|
-
cls = tp.self
|
39
|
+
tp = TracePoint.new(:end) do |trace_point|
|
40
|
+
cls = trace_point.self
|
71
41
|
|
72
42
|
instance_methods = cls.public_instance_methods(false)
|
73
43
|
class_methods = cls.singleton_class.public_instance_methods(false) - instance_methods
|
74
44
|
|
75
|
-
|
45
|
+
hook = lambda do |hook_cls|
|
76
46
|
lambda do |method_id|
|
77
|
-
|
47
|
+
method = hook_cls.public_instance_method(method_id)
|
48
|
+
hook_method = Hook::Method.new(hook_cls, method)
|
49
|
+
|
50
|
+
warn "AppMap: Examining #{hook_cls} #{method.name}" if LOG
|
78
51
|
|
79
|
-
method = cls.public_instance_method(method_id)
|
80
52
|
disasm = RubyVM::InstructionSequence.disasm(method)
|
81
53
|
# Skip methods that have no instruction sequence, as they are obviously trivial.
|
82
54
|
next unless disasm
|
83
55
|
|
84
|
-
defined_class, method_symbol, method_name = Hook.qualify_method_name(method)
|
85
|
-
method_display_name = [defined_class, method_symbol, method_name].join
|
86
|
-
|
87
56
|
# Don't try and trace the AppMap methods or there will be
|
88
57
|
# a stack overflow in the defined hook method.
|
89
|
-
next if /\AAppMap[:\.]/.match?(method_display_name)
|
58
|
+
next if /\AAppMap[:\.]/.match?(hook_method.method_display_name)
|
90
59
|
|
91
60
|
next unless \
|
92
|
-
config.always_hook?(
|
61
|
+
config.always_hook?(hook_cls, method.name) ||
|
93
62
|
config.included_by_location?(method)
|
94
63
|
|
95
|
-
|
96
|
-
|
97
|
-
cls.define_method method_id do |*args, &block|
|
98
|
-
base_method = method.bind(self).to_proc
|
99
|
-
|
100
|
-
hook_disabled = Thread.current[HOOK_DISABLE_KEY]
|
101
|
-
enabled = true if !hook_disabled && AppMap.tracing.enabled?
|
102
|
-
return base_method.call(*args, &block) unless enabled
|
103
|
-
|
104
|
-
call_event, start_time = with_disabled_hook.call do
|
105
|
-
before_hook.call(defined_class, method, self, args)
|
106
|
-
end
|
107
|
-
return_value = nil
|
108
|
-
exception = nil
|
109
|
-
begin
|
110
|
-
return_value = base_method.call(*args, &block)
|
111
|
-
rescue
|
112
|
-
exception = $ERROR_INFO
|
113
|
-
raise
|
114
|
-
ensure
|
115
|
-
with_disabled_hook.call do
|
116
|
-
after_hook.call(call_event, defined_class, method, start_time, return_value, exception)
|
117
|
-
end
|
118
|
-
end
|
119
|
-
end
|
64
|
+
hook_method.activate
|
120
65
|
end
|
121
66
|
end
|
122
67
|
|
123
|
-
instance_methods.each(&
|
124
|
-
class_methods.each(&
|
68
|
+
instance_methods.each(&hook.(cls))
|
69
|
+
class_methods.each(&hook.(cls.singleton_class))
|
125
70
|
end
|
126
71
|
|
127
72
|
tp.enable(&block)
|
128
73
|
end
|
74
|
+
|
75
|
+
def hook_builtins
|
76
|
+
return unless self.class.lock_builtins
|
77
|
+
|
78
|
+
class_from_string = lambda do |fq_class|
|
79
|
+
fq_class.split('::').inject(Object) do |mod, class_name|
|
80
|
+
mod.const_get(class_name)
|
81
|
+
end
|
82
|
+
end
|
83
|
+
|
84
|
+
Config::BUILTIN_METHODS.each do |class_name, hook|
|
85
|
+
require hook.package.package_name if hook.package.package_name
|
86
|
+
Array(hook.method_names).each do |method_name|
|
87
|
+
method_name = method_name.to_sym
|
88
|
+
cls = class_from_string.(class_name)
|
89
|
+
method = \
|
90
|
+
begin
|
91
|
+
cls.instance_method(method_name)
|
92
|
+
rescue NameError
|
93
|
+
cls.method(method_name) rescue nil
|
94
|
+
end
|
95
|
+
|
96
|
+
if method
|
97
|
+
Hook::Method.new(cls, method).activate
|
98
|
+
else
|
99
|
+
warn "Method #{method_name} not found on #{cls.name}"
|
100
|
+
end
|
101
|
+
end
|
102
|
+
end
|
103
|
+
end
|
129
104
|
end
|
130
105
|
end
|
@@ -0,0 +1,103 @@
|
|
1
|
+
module AppMap
|
2
|
+
class Hook
|
3
|
+
class Method
|
4
|
+
attr_reader :hook_class, :hook_method
|
5
|
+
|
6
|
+
# +method_display_name+ may be nil if name resolution gets
|
7
|
+
# deferred until runtime (e.g. for a singleton method on an
|
8
|
+
# embedded Struct).
|
9
|
+
attr_reader :method_display_name
|
10
|
+
|
11
|
+
HOOK_DISABLE_KEY = 'AppMap::Hook.disable'
|
12
|
+
private_constant :HOOK_DISABLE_KEY
|
13
|
+
|
14
|
+
# Grab the definition of Time.now here, to avoid interfering
|
15
|
+
# with the method we're hooking.
|
16
|
+
TIME_NOW = Time.method(:now)
|
17
|
+
private_constant :TIME_NOW
|
18
|
+
|
19
|
+
def initialize(hook_class, hook_method)
|
20
|
+
@hook_class = hook_class
|
21
|
+
@hook_method = hook_method
|
22
|
+
|
23
|
+
# Get the class for the method, if it's known.
|
24
|
+
@defined_class, method_symbol = Hook.qualify_method_name(@hook_method)
|
25
|
+
@method_display_name = [@defined_class, method_symbol, @hook_method.name].join if @defined_class
|
26
|
+
end
|
27
|
+
|
28
|
+
def activate
|
29
|
+
if Hook::LOG
|
30
|
+
msg = if method_display_name
|
31
|
+
"#{method_display_name}"
|
32
|
+
else
|
33
|
+
"#{hook_method.name} (class resolution deferrred)"
|
34
|
+
end
|
35
|
+
warn "AppMap: Hooking " + msg
|
36
|
+
end
|
37
|
+
|
38
|
+
defined_class = @defined_class
|
39
|
+
hook_method = self.hook_method
|
40
|
+
before_hook = self.method(:before_hook)
|
41
|
+
after_hook = self.method(:after_hook)
|
42
|
+
with_disabled_hook = self.method(:with_disabled_hook)
|
43
|
+
|
44
|
+
hook_class.define_method hook_method.name do |*args, &block|
|
45
|
+
instance_method = hook_method.bind(self).to_proc
|
46
|
+
|
47
|
+
# We may not have gotten the class for the method during
|
48
|
+
# initialization (e.g. for a singleton method on an embedded
|
49
|
+
# struct), so make sure we have it now.
|
50
|
+
defined_class,_ = Hook.qualify_method_name(hook_method) unless defined_class
|
51
|
+
|
52
|
+
hook_disabled = Thread.current[HOOK_DISABLE_KEY]
|
53
|
+
enabled = true if !hook_disabled && AppMap.tracing.enabled?
|
54
|
+
return instance_method.call(*args, &block) unless enabled
|
55
|
+
|
56
|
+
call_event, start_time = with_disabled_hook.() do
|
57
|
+
before_hook.(self, defined_class, args)
|
58
|
+
end
|
59
|
+
return_value = nil
|
60
|
+
exception = nil
|
61
|
+
begin
|
62
|
+
return_value = instance_method.(*args, &block)
|
63
|
+
rescue
|
64
|
+
exception = $ERROR_INFO
|
65
|
+
raise
|
66
|
+
ensure
|
67
|
+
with_disabled_hook.() do
|
68
|
+
after_hook.(call_event, start_time, return_value, exception)
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
73
|
+
|
74
|
+
protected
|
75
|
+
|
76
|
+
def before_hook(receiver, defined_class, args)
|
77
|
+
require 'appmap/event'
|
78
|
+
call_event = AppMap::Event::MethodCall.build_from_invocation(defined_class, hook_method, receiver, args)
|
79
|
+
AppMap.tracing.record_event call_event, defined_class: defined_class, method: hook_method
|
80
|
+
[ call_event, TIME_NOW.call ]
|
81
|
+
end
|
82
|
+
|
83
|
+
def after_hook(call_event, start_time, return_value, exception)
|
84
|
+
require 'appmap/event'
|
85
|
+
elapsed = TIME_NOW.call - start_time
|
86
|
+
return_event = \
|
87
|
+
AppMap::Event::MethodReturn.build_from_invocation call_event.id, elapsed, return_value, exception
|
88
|
+
AppMap.tracing.record_event return_event
|
89
|
+
end
|
90
|
+
|
91
|
+
def with_disabled_hook(&fn)
|
92
|
+
# Don't record functions, such as to_s and inspect, that might be called
|
93
|
+
# by the fn. Otherwise there can be a stack overflow.
|
94
|
+
Thread.current[HOOK_DISABLE_KEY] = true
|
95
|
+
begin
|
96
|
+
fn.call
|
97
|
+
ensure
|
98
|
+
Thread.current[HOOK_DISABLE_KEY] = false
|
99
|
+
end
|
100
|
+
end
|
101
|
+
end
|
102
|
+
end
|
103
|
+
end
|
data/lib/appmap/open.rb
ADDED
@@ -0,0 +1,57 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module AppMap
|
4
|
+
OpenStruct = Struct.new(:appmap)
|
5
|
+
|
6
|
+
class Open < OpenStruct
|
7
|
+
attr_reader :port
|
8
|
+
|
9
|
+
def perform
|
10
|
+
server = run_server
|
11
|
+
open_browser
|
12
|
+
server.kill
|
13
|
+
end
|
14
|
+
|
15
|
+
def page
|
16
|
+
require 'rack/utils'
|
17
|
+
<<~PAGE
|
18
|
+
<!DOCTYPE html>
|
19
|
+
<html>
|
20
|
+
<head>
|
21
|
+
<title>…</title>
|
22
|
+
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
|
23
|
+
<script type="text/javascript">
|
24
|
+
function dosubmit() { document.forms[0].submit(); }
|
25
|
+
</script>
|
26
|
+
</head>
|
27
|
+
<body onload="dosubmit();">
|
28
|
+
<form action="https://app.land/scenario_uploads" method="POST" accept-charset="utf-8">
|
29
|
+
<input type="hidden" name="data" value='#{Rack::Utils.escape_html appmap.to_json}'>
|
30
|
+
</form>
|
31
|
+
</body>
|
32
|
+
</html>
|
33
|
+
PAGE
|
34
|
+
end
|
35
|
+
|
36
|
+
def run_server
|
37
|
+
require 'rack'
|
38
|
+
Thread.new do
|
39
|
+
Rack::Handler::WEBrick.run(
|
40
|
+
lambda do |env|
|
41
|
+
return [200, { 'Content-Type' => 'text/html' }, [page]]
|
42
|
+
end,
|
43
|
+
:Port => 0
|
44
|
+
) do |server|
|
45
|
+
@port = server.config[:Port]
|
46
|
+
end
|
47
|
+
end.tap do
|
48
|
+
sleep 1.0
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
def open_browser
|
53
|
+
system 'open', "http://localhost:#{@port}"
|
54
|
+
sleep 5.0
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|