apparition 0.0.1 → 0.0.2
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/README.md +1 -0
- data/lib/capybara/apparition/browser.rb +41 -48
- data/lib/capybara/apparition/chrome_client.rb +52 -55
- data/lib/capybara/apparition/dev_tools_protocol/session.rb +2 -2
- data/lib/capybara/apparition/dev_tools_protocol/target.rb +1 -1
- data/lib/capybara/apparition/driver.rb +29 -135
- data/lib/capybara/apparition/keyboard.rb +44 -43
- data/lib/capybara/apparition/launcher.rb +35 -36
- data/lib/capybara/apparition/mouse.rb +25 -21
- data/lib/capybara/apparition/node.rb +279 -309
- data/lib/capybara/apparition/node/drag.rb +148 -0
- data/lib/capybara/apparition/page.rb +64 -30
- data/lib/capybara/apparition/response.rb +41 -0
- data/lib/capybara/apparition/version.rb +1 -1
- metadata +5 -4
- data/lib/capybara/apparition/command.rb +0 -21
@@ -0,0 +1,148 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Capybara::Apparition
|
4
|
+
module Drag
|
5
|
+
def drag_to(other, delay: 0.1)
|
6
|
+
return html5_drag_to(other) if html5_draggable?
|
7
|
+
|
8
|
+
pos = visible_center
|
9
|
+
raise ::Capybara::Apparition::MouseEventImpossible.new(self, 'args' => ['drag_to']) if pos.nil?
|
10
|
+
|
11
|
+
test = mouse_event_test(pos)
|
12
|
+
raise ::Capybara::Apparition::MouseEventFailed.new(self, 'args' => ['drag', test.selector, pos]) unless test.success
|
13
|
+
|
14
|
+
begin
|
15
|
+
@page.mouse.move_to(pos).down
|
16
|
+
sleep delay
|
17
|
+
|
18
|
+
other_pos = other.visible_center
|
19
|
+
raise ::Capybara::Apparition::MouseEventImpossible.new(self, 'args' => ['drag_to']) if other_pos.nil?
|
20
|
+
|
21
|
+
@page.mouse.move_to(other_pos.merge(button: 'left'))
|
22
|
+
sleep delay
|
23
|
+
ensure
|
24
|
+
@page.mouse.up
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
def drag_by(x, y, delay: 0.1)
|
29
|
+
pos = visible_center
|
30
|
+
raise ::Capybara::Apparition::MouseEventImpossible.new(self, 'args' => ['hover']) if pos.nil?
|
31
|
+
|
32
|
+
other_pos = { x: pos[:x] + x, y: pos[:y] + y }
|
33
|
+
raise ::Capybara::Apparition::MouseEventFailed.new(self, 'args' => ['drag', test['selector'], pos]) unless mouse_event_test?(pos)
|
34
|
+
|
35
|
+
@page.mouse.move_to(pos).down
|
36
|
+
sleep delay
|
37
|
+
@page.mouse.move_to(other_pos.merge(button: 'left'))
|
38
|
+
sleep delay
|
39
|
+
@page.mouse.up
|
40
|
+
end
|
41
|
+
|
42
|
+
private
|
43
|
+
|
44
|
+
def html5_drag_to(element)
|
45
|
+
driver.execute_script MOUSEDOWN_TRACKER
|
46
|
+
scroll_if_needed
|
47
|
+
@page.mouse.move_to(element.visible_center).down
|
48
|
+
if driver.evaluate_script('window.capybara_mousedown_prevented')
|
49
|
+
element.scroll_if_needed
|
50
|
+
@page.mouse.move_to(element.visible_center).up
|
51
|
+
else
|
52
|
+
driver.execute_script HTML5_DRAG_DROP_SCRIPT, self, element
|
53
|
+
@page.mouse.up(element.visible_center)
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
def html5_draggable?
|
58
|
+
native.property('draggable')
|
59
|
+
end
|
60
|
+
|
61
|
+
MOUSEDOWN_TRACKER = <<~JS
|
62
|
+
document.addEventListener('mousedown', ev => {
|
63
|
+
window.capybara_mousedown_prevented = ev.defaultPrevented;
|
64
|
+
}, { once: true, passive: true })
|
65
|
+
JS
|
66
|
+
|
67
|
+
HTML5_DRAG_DROP_SCRIPT = <<~JS
|
68
|
+
var source = arguments[0];
|
69
|
+
var target = arguments[1];
|
70
|
+
|
71
|
+
function rectCenter(rect){
|
72
|
+
return new DOMPoint(
|
73
|
+
(rect.left + rect.right)/2,
|
74
|
+
(rect.top + rect.bottom)/2
|
75
|
+
);
|
76
|
+
}
|
77
|
+
|
78
|
+
function pointOnRect(pt, rect) {
|
79
|
+
var rectPt = rectCenter(rect);
|
80
|
+
var slope = (rectPt.y - pt.y) / (rectPt.x - pt.x);
|
81
|
+
|
82
|
+
if (pt.x <= rectPt.x) { // left side
|
83
|
+
var minXy = slope * (rect.left - pt.x) + pt.y;
|
84
|
+
if (rect.top <= minXy && minXy <= rect.bottom)
|
85
|
+
return new DOMPoint(rect.left, minXy);
|
86
|
+
}
|
87
|
+
|
88
|
+
if (pt.x >= rectPt.x) { // right side
|
89
|
+
var maxXy = slope * (rect.right - pt.x) + pt.y;
|
90
|
+
if (rect.top <= maxXy && maxXy <= rect.bottom)
|
91
|
+
return new DOMPoint(rect.right, maxXy);
|
92
|
+
}
|
93
|
+
|
94
|
+
if (pt.y <= rectPt.y) { // top side
|
95
|
+
var minYx = (rectPt.top - pt.y) / slope + pt.x;
|
96
|
+
if (rect.left <= minYx && minYx <= rect.right)
|
97
|
+
return new DOMPoint(minYx, rect.top);
|
98
|
+
}
|
99
|
+
|
100
|
+
if (pt.y >= rectPt.y) { // bottom side
|
101
|
+
var maxYx = (rect.bottom - pt.y) / slope + pt.x;
|
102
|
+
if (rect.left <= maxYx && maxYx <= rect.right)
|
103
|
+
return new DOMPoint(maxYx, rect.bottom);
|
104
|
+
}
|
105
|
+
|
106
|
+
return new DOMPoint(pt.x,pt.y);
|
107
|
+
}
|
108
|
+
|
109
|
+
var dt = new DataTransfer();
|
110
|
+
var opts = { cancelable: true, bubbles: true, dataTransfer: dt };
|
111
|
+
|
112
|
+
if (source.tagName == 'A'){
|
113
|
+
dt.setData('text/uri-list', source.href);
|
114
|
+
dt.setData('text', source.href);
|
115
|
+
}
|
116
|
+
if (source.tagName == 'IMG'){
|
117
|
+
dt.setData('text/uri-list', source.src);
|
118
|
+
dt.setData('text', source.src);
|
119
|
+
}
|
120
|
+
|
121
|
+
var dragEvent = new DragEvent('dragstart', opts);
|
122
|
+
source.dispatchEvent(dragEvent);
|
123
|
+
target.scrollIntoView({behavior: 'instant', block: 'center', inline: 'center'});
|
124
|
+
var targetRect = target.getBoundingClientRect();
|
125
|
+
var sourceCenter = rectCenter(source.getBoundingClientRect());
|
126
|
+
|
127
|
+
// fire 2 dragover events to simulate dragging with a direction
|
128
|
+
var entryPoint = pointOnRect(sourceCenter, targetRect)
|
129
|
+
var dragOverOpts = Object.assign({clientX: entryPoint.x, clientY: entryPoint.y}, opts);
|
130
|
+
var dragOverEvent = new DragEvent('dragover', dragOverOpts);
|
131
|
+
target.dispatchEvent(dragOverEvent);
|
132
|
+
|
133
|
+
var targetCenter = rectCenter(targetRect);
|
134
|
+
dragOverOpts = Object.assign({clientX: targetCenter.x, clientY: targetCenter.y}, opts);
|
135
|
+
dragOverEvent = new DragEvent('dragover', dragOverOpts);
|
136
|
+
target.dispatchEvent(dragOverEvent);
|
137
|
+
|
138
|
+
var dragLeaveEvent = new DragEvent('dragleave', opts);
|
139
|
+
target.dispatchEvent(dragLeaveEvent);
|
140
|
+
if (dragOverEvent.defaultPrevented) {
|
141
|
+
var dropEvent = new DragEvent('drop', opts);
|
142
|
+
target.dispatchEvent(dropEvent);
|
143
|
+
}
|
144
|
+
var dragEndEvent = new DragEvent('dragend', opts);
|
145
|
+
source.dispatchEvent(dragEndEvent);
|
146
|
+
JS
|
147
|
+
end
|
148
|
+
end
|
@@ -12,20 +12,24 @@ module Capybara::Apparition
|
|
12
12
|
attr_accessor :perm_headers, :temp_headers, :temp_no_redirect_headers
|
13
13
|
attr_reader :network_traffic
|
14
14
|
|
15
|
-
def self.create(browser, session, id, ignore_https_errors:
|
15
|
+
def self.create(browser, session, id, ignore_https_errors: false, screenshot_task_queue: nil, js_errors: false)
|
16
16
|
session.command 'Page.enable'
|
17
|
-
|
18
|
-
|
17
|
+
|
18
|
+
# Provides a lot of info - but huge overhead
|
19
|
+
# session.command 'Page.setLifecycleEventsEnabled', enabled: true
|
19
20
|
|
20
21
|
page = Page.new(browser, session, id, ignore_https_errors, screenshot_task_queue, js_errors)
|
21
22
|
|
22
23
|
session.command 'Network.enable'
|
23
24
|
session.command 'Runtime.enable'
|
24
25
|
session.command 'Security.enable'
|
25
|
-
session.command 'Security.setOverrideCertificateErrors', override: true if ignore_https_errors
|
26
|
+
# session.command 'Security.setOverrideCertificateErrors', override: true if ignore_https_errors
|
27
|
+
session.command 'Security.setIgnoreCertificateErrors', ignore: !!ignore_https_errors
|
26
28
|
session.command 'DOM.enable'
|
27
29
|
# session.command 'Log.enable'
|
28
|
-
|
30
|
+
if Capybara.save_path
|
31
|
+
session.command 'Page.setDownloadBehavior', behavior: 'allow', downloadPath: Capybara.save_path
|
32
|
+
end
|
29
33
|
page
|
30
34
|
end
|
31
35
|
|
@@ -121,17 +125,23 @@ module Capybara::Apparition
|
|
121
125
|
params = {}
|
122
126
|
params[:paperWidth] = @browser.paper_size[:width].to_f if @browser.paper_size
|
123
127
|
params[:paperHeight] = @browser.paper_size[:height].to_f if @browser.paper_size
|
128
|
+
params[:scale] = @browser.zoom_factor if @browser.zoom_factor
|
124
129
|
command('Page.printToPDF', params)
|
125
130
|
else
|
126
|
-
if options[:selector]
|
131
|
+
clip_options = if options[:selector]
|
127
132
|
pos = evaluate("document.querySelector('#{options.delete(:selector)}').getBoundingClientRect().toJSON();")
|
128
|
-
|
133
|
+
%w[x y width height].each_with_object({}) { |key, hash| hash[key] = pos[key] }
|
129
134
|
elsif options[:full]
|
130
|
-
|
135
|
+
evaluate <<~JS
|
131
136
|
{ width: document.documentElement.clientWidth, height: document.documentElement.clientHeight}
|
132
137
|
JS
|
133
|
-
|
138
|
+
else
|
139
|
+
evaluate <<~JS
|
140
|
+
{ width: window.innerWidth, height: window.innerHeight }
|
141
|
+
JS
|
134
142
|
end
|
143
|
+
options[:clip] = { x: 0, y: 0, scale: 1 }.merge(clip_options)
|
144
|
+
options[:clip][:scale] = @browser.zoom_factor || 1
|
135
145
|
command('Page.captureScreenshot', options)
|
136
146
|
end['data']
|
137
147
|
end
|
@@ -139,10 +149,11 @@ module Capybara::Apparition
|
|
139
149
|
def push_frame(frame_el)
|
140
150
|
node = command('DOM.describeNode', objectId: frame_el.base.id)
|
141
151
|
frame_id = node['node']['frameId']
|
142
|
-
|
152
|
+
|
153
|
+
timer = Capybara::Helpers.timer(expire_in: 10)
|
143
154
|
while (frame = @frames.get(frame_id)).nil? || frame.loading?
|
144
155
|
# Wait for the frame creation messages to be processed
|
145
|
-
if
|
156
|
+
if timer.expired?
|
146
157
|
puts 'Timed out waiting from frame to be ready'
|
147
158
|
# byebug
|
148
159
|
raise TimeoutError.new('push_frame')
|
@@ -217,10 +228,10 @@ module Capybara::Apparition
|
|
217
228
|
attr_reader :status_code
|
218
229
|
|
219
230
|
def wait_for_loaded(allow_obsolete: false)
|
220
|
-
|
231
|
+
timer = Capybara::Helpers.timer(expire_in: 10)
|
221
232
|
cf = current_frame
|
222
233
|
until cf.usable? || (allow_obsolete && cf.obsolete?) || @js_error
|
223
|
-
if
|
234
|
+
if timer.expired?
|
224
235
|
puts 'Timedout waiting for page to be loaded'
|
225
236
|
# byebug
|
226
237
|
raise TimeoutError.new('wait_for_loaded')
|
@@ -248,6 +259,9 @@ module Capybara::Apparition
|
|
248
259
|
navigate_opts = { url: url, transitionType: 'reload' }
|
249
260
|
navigate_opts[:referrer] = extra_headers['Referer'] if extra_headers['Referer']
|
250
261
|
response = command('Page.navigate', navigate_opts)
|
262
|
+
|
263
|
+
raise StatusFailError, 'args' => [url, response['errorText']] if response['errorText']
|
264
|
+
|
251
265
|
main_frame.loading(response['loaderId'])
|
252
266
|
wait_for_loaded
|
253
267
|
rescue TimeoutError
|
@@ -306,11 +320,11 @@ module Capybara::Apparition
|
|
306
320
|
end
|
307
321
|
|
308
322
|
def command(name, **params)
|
309
|
-
@browser.command_for_session(@session.session_id, name, params)
|
323
|
+
@browser.command_for_session(@session.session_id, name, params).result
|
310
324
|
end
|
311
325
|
|
312
326
|
def async_command(name, **params)
|
313
|
-
@browser.command_for_session(@session.session_id, name, params
|
327
|
+
@browser.command_for_session(@session.session_id, name, params).discard_result
|
314
328
|
end
|
315
329
|
|
316
330
|
def extra_headers
|
@@ -378,6 +392,8 @@ module Capybara::Apparition
|
|
378
392
|
|
379
393
|
@session.on 'Page.windowOpen' do |params|
|
380
394
|
puts "**** windowOpen was called with: #{params}" if ENV['DEBUG']
|
395
|
+
# TODO: find a better way to handle this
|
396
|
+
sleep 0.4 # wait a bit so the window has time to start loading
|
381
397
|
end
|
382
398
|
|
383
399
|
@session.on 'Page.frameAttached' do |params|
|
@@ -399,16 +415,31 @@ module Capybara::Apparition
|
|
399
415
|
end
|
400
416
|
end
|
401
417
|
|
402
|
-
@session.on 'Page.
|
403
|
-
|
404
|
-
|
405
|
-
|
406
|
-
|
407
|
-
|
408
|
-
|
409
|
-
|
410
|
-
|
411
|
-
|
418
|
+
@session.on 'Page.frameStartedLoading' do |params|
|
419
|
+
@frames.get(params['frameId'])&.loading(-1)
|
420
|
+
end
|
421
|
+
|
422
|
+
@session.on 'Page.frameStoppedLoading' do |params|
|
423
|
+
@frames.get(params['frameId'])&.loaded!
|
424
|
+
end
|
425
|
+
|
426
|
+
# @session.on 'Page.lifecycleEvent' do |params|
|
427
|
+
# # Provides a lot of useful info - but lots of overhead
|
428
|
+
# puts "Lifecycle: #{params['name']} - frame: #{params['frameId']} - loader: #{params['loaderId']}" if ENV['DEBUG']
|
429
|
+
# case params['name']
|
430
|
+
# when 'init'
|
431
|
+
# @frames.get(params['frameId'])&.loading(params['loaderId'])
|
432
|
+
# when 'firstMeaningfulPaint',
|
433
|
+
# 'networkIdle'
|
434
|
+
# @frames.get(params['frameId']).tap do |frame|
|
435
|
+
# frame.loaded! if frame.loader_id == params['loaderId']
|
436
|
+
# end
|
437
|
+
# end
|
438
|
+
# end
|
439
|
+
|
440
|
+
@session.on('Page.domContentEventFired') do |params|
|
441
|
+
# TODO: Really need something better than this
|
442
|
+
main_frame.loaded! if @status_code != 200
|
412
443
|
end
|
413
444
|
|
414
445
|
@session.on 'Page.navigatedWithinDocument' do |params|
|
@@ -466,7 +497,7 @@ module Capybara::Apparition
|
|
466
497
|
@session.on 'Network.loadingFailed' do |params|
|
467
498
|
req = @network_traffic.find { |request| request.request_id == params['requestId'] }
|
468
499
|
req&.blocked_params = params if params['blockedReason']
|
469
|
-
puts "Loading Failed - request: #{params['requestId']}: #{params['errorText']}" if params['type'] == 'Document'
|
500
|
+
puts "Loading Failed - request: #{params['requestId']} : #{params['errorText']}" if params['type'] == 'Document'
|
470
501
|
end
|
471
502
|
|
472
503
|
@session.on 'Network.requestIntercepted' do |params|
|
@@ -484,6 +515,10 @@ module Capybara::Apparition
|
|
484
515
|
)
|
485
516
|
end
|
486
517
|
|
518
|
+
# @session.on 'Security.certificateError' do |params|
|
519
|
+
# async_command 'Network.continueInterceptedRequest', interceptionId: id, **params
|
520
|
+
# end
|
521
|
+
|
487
522
|
# @session.on 'Log.entryAdded' do |params|
|
488
523
|
# log_entry = params['entry']
|
489
524
|
# if params.values_at('source', 'level') == ['javascript', 'error']
|
@@ -634,7 +669,6 @@ module Capybara::Apparition
|
|
634
669
|
def decode_result(result, object_cache = {})
|
635
670
|
if result['type'] == 'object'
|
636
671
|
if result['subtype'] == 'array'
|
637
|
-
# remoteObject = @browser.command('Runtime.getProperties',
|
638
672
|
remote_object = command('Runtime.getProperties',
|
639
673
|
objectId: result['objectId'],
|
640
674
|
ownProperties: true)
|
@@ -655,7 +689,7 @@ module Capybara::Apparition
|
|
655
689
|
|
656
690
|
# releasePromises = [helper.releaseObject(@element._client, remoteObject)]
|
657
691
|
end
|
658
|
-
|
692
|
+
command('Runtime.releaseObject', objectId: result['objectId'])
|
659
693
|
return results
|
660
694
|
elsif result['subtype'] == 'node'
|
661
695
|
return result
|
@@ -667,10 +701,10 @@ module Capybara::Apparition
|
|
667
701
|
.find { |prop| prop['name'] == '[[StableObjectId]]' }
|
668
702
|
.dig('value', 'value')
|
669
703
|
# We could actually return cyclic objects here but Capybara would need to be updated to support
|
670
|
-
return '(cyclic structure)' if object_cache.key?(stable_id)
|
671
|
-
|
672
704
|
# return object_cache[stable_id] if object_cache.key?(stable_id)
|
673
705
|
|
706
|
+
return '(cyclic structure)' if object_cache.key?(stable_id)
|
707
|
+
|
674
708
|
object_cache[stable_id] = {}
|
675
709
|
properties = remote_object['result']
|
676
710
|
|
@@ -0,0 +1,41 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Capybara::Apparition
|
4
|
+
class ChromeClient
|
5
|
+
class Response
|
6
|
+
def initialize(client, *msg_ids, send_time: nil)
|
7
|
+
@send_time = send_time
|
8
|
+
@msg_ids = msg_ids
|
9
|
+
@client = client
|
10
|
+
end
|
11
|
+
|
12
|
+
def result
|
13
|
+
response = @msg_ids.map do |id|
|
14
|
+
resp = @client.send(:wait_for_msg_response, id)
|
15
|
+
handle_error(resp['error']) if resp['error']
|
16
|
+
resp
|
17
|
+
end.last
|
18
|
+
puts "Processed msg: #{@msg_ids.last} in #{Time.now - @send_time} seconds" if ENV['DEBUG']
|
19
|
+
|
20
|
+
response['result']
|
21
|
+
end
|
22
|
+
|
23
|
+
def discard_result
|
24
|
+
@msg_ids.each { |id| @client.add_async_id id }
|
25
|
+
@result_time = Time.now
|
26
|
+
nil
|
27
|
+
end
|
28
|
+
|
29
|
+
private
|
30
|
+
|
31
|
+
def handle_error(error)
|
32
|
+
case error['code']
|
33
|
+
when -32_000
|
34
|
+
raise WrongWorld.new(nil, error)
|
35
|
+
else
|
36
|
+
raise CDPError.new(error)
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: apparition
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.0.
|
4
|
+
version: 0.0.2
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Thomas Walpole
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2019-01-
|
11
|
+
date: 2019-01-26 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: capybara
|
@@ -197,7 +197,6 @@ files:
|
|
197
197
|
- lib/capybara/apparition.rb
|
198
198
|
- lib/capybara/apparition/browser.rb
|
199
199
|
- lib/capybara/apparition/chrome_client.rb
|
200
|
-
- lib/capybara/apparition/command.rb
|
201
200
|
- lib/capybara/apparition/cookie.rb
|
202
201
|
- lib/capybara/apparition/dev_tools_protocol/session.rb
|
203
202
|
- lib/capybara/apparition/dev_tools_protocol/target.rb
|
@@ -215,7 +214,9 @@ files:
|
|
215
214
|
- lib/capybara/apparition/network_traffic/request.rb
|
216
215
|
- lib/capybara/apparition/network_traffic/response.rb
|
217
216
|
- lib/capybara/apparition/node.rb
|
217
|
+
- lib/capybara/apparition/node/drag.rb
|
218
218
|
- lib/capybara/apparition/page.rb
|
219
|
+
- lib/capybara/apparition/response.rb
|
219
220
|
- lib/capybara/apparition/utility.rb
|
220
221
|
- lib/capybara/apparition/version.rb
|
221
222
|
- lib/capybara/apparition/web_socket_client.rb
|
@@ -238,7 +239,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
238
239
|
- !ruby/object:Gem::Version
|
239
240
|
version: '0'
|
240
241
|
requirements: []
|
241
|
-
rubygems_version: 3.0.
|
242
|
+
rubygems_version: 3.0.1
|
242
243
|
signing_key:
|
243
244
|
specification_version: 4
|
244
245
|
summary: Chrome driver using CDP for Capybara
|
@@ -1,21 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
require 'securerandom'
|
4
|
-
|
5
|
-
module Capybara::Apparition
|
6
|
-
class Command
|
7
|
-
attr_reader :id
|
8
|
-
attr_reader :name
|
9
|
-
attr_accessor :args
|
10
|
-
|
11
|
-
def initialize(name, params = {})
|
12
|
-
@id = SecureRandom.uuid
|
13
|
-
@name = name
|
14
|
-
@params = params
|
15
|
-
end
|
16
|
-
|
17
|
-
def message
|
18
|
-
JSON.dump('id' => @id, 'name' => @name, 'params' => @params)
|
19
|
-
end
|
20
|
-
end
|
21
|
-
end
|