puppeteer-ruby 0.43.0 → 0.43.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/CHANGELOG.md +5 -1
- data/docs/api_coverage.md +1 -1
- data/lib/puppeteer/browser.rb +1 -1
- data/lib/puppeteer/chrome_target_manager.rb +28 -10
- data/lib/puppeteer/connection.rb +6 -3
- data/lib/puppeteer/dom_world.rb +0 -43
- data/lib/puppeteer/element_handle.rb +9 -30
- data/lib/puppeteer/frame.rb +16 -9
- data/lib/puppeteer/frame_manager.rb +4 -4
- data/lib/puppeteer/lifecycle_watcher.rb +22 -3
- data/lib/puppeteer/query_handler_manager.rb +35 -0
- data/lib/puppeteer/target.rb +2 -2
- data/lib/puppeteer/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: e723d5efd8074f8c70f1b94de1399f35ec39eca88fd86d1991b036db32d783a8
|
4
|
+
data.tar.gz: 463781ad3737b5db0f09b2ba9ec2ed8f62863991c011291437230c512671e0ba
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 46462c2894970d02792d5021088252fce262f41f81531449a73d9d40745ede1742fe187cde65cae64222ba817366500d347ca310aa658147040e4fbfa5d1b5db
|
7
|
+
data.tar.gz: d5b6e7ae1e0efc6c1e69e7bb136f90dfdc1c2370c29ec4bf9a6877c22bbe38cd6a81f698f086dce3157800bdfc7bfcb06f7eb35b518434870fd95456dc1fbe3c
|
data/CHANGELOG.md
CHANGED
@@ -1,7 +1,11 @@
|
|
1
|
-
### main [[diff](https://github.com/YusukeIwaki/puppeteer-ruby/compare/0.43.
|
1
|
+
### main [[diff](https://github.com/YusukeIwaki/puppeteer-ruby/compare/0.43.1...main)]
|
2
2
|
|
3
3
|
- xxx
|
4
4
|
|
5
|
+
### 0.43.1 [[diff](https://github.com/YusukeIwaki/puppeteer-ruby/compare/0.43.0...0.43.1)]
|
6
|
+
|
7
|
+
- Port Puppeteer v16.1 features, including bugfix and XPath query handler.
|
8
|
+
|
5
9
|
### 0.43.0 [[diff](https://github.com/YusukeIwaki/puppeteer-ruby/compare/0.42.0...0.43.0)]
|
6
10
|
|
7
11
|
- Port Puppeteer v16.0 features. Increasing stability.
|
data/docs/api_coverage.md
CHANGED
data/lib/puppeteer/browser.rb
CHANGED
@@ -196,7 +196,7 @@ class Puppeteer::Browser
|
|
196
196
|
session: session,
|
197
197
|
browser_context: context,
|
198
198
|
target_manager: @target_manager,
|
199
|
-
session_factory: -> { @connection.create_session(target_info) },
|
199
|
+
session_factory: -> (auto_attach_emulated) { @connection.create_session(target_info, auto_attach_emulated: auto_attach_emulated) },
|
200
200
|
ignore_https_errors: @ignore_https_errors,
|
201
201
|
default_viewport: @default_viewport,
|
202
202
|
is_page_target_callback: @is_page_target_callback,
|
@@ -1,4 +1,5 @@
|
|
1
1
|
class Puppeteer::ChromeTargetManager
|
2
|
+
include Puppeteer::DebugPrint
|
2
3
|
include Puppeteer::EventCallbackable
|
3
4
|
|
4
5
|
def initialize(connection:, target_factory:, target_filter_callback:)
|
@@ -33,20 +34,34 @@ class Puppeteer::ChromeTargetManager
|
|
33
34
|
)
|
34
35
|
|
35
36
|
setup_attachment_listeners(@connection)
|
36
|
-
@connection.async_send_message('Target.setDiscoverTargets',
|
37
|
+
@connection.async_send_message('Target.setDiscoverTargets', {
|
38
|
+
discover: true,
|
39
|
+
filter: [
|
40
|
+
{ type: 'tab', exclude: true },
|
41
|
+
{},
|
42
|
+
],
|
43
|
+
}).then do
|
44
|
+
store_existing_targets_for_init
|
45
|
+
end.rescue do |err|
|
46
|
+
debug_puts(err)
|
47
|
+
end
|
37
48
|
end
|
38
49
|
|
39
|
-
def
|
50
|
+
private def store_existing_targets_for_init
|
40
51
|
@discovered_targets_by_target_id.each do |target_id, target_info|
|
41
|
-
if @target_filter_callback.call(target_info)
|
52
|
+
if @target_filter_callback.call(target_info) && target_info.type != 'browser'
|
42
53
|
@target_ids_for_init << target_id
|
43
54
|
end
|
44
55
|
end
|
56
|
+
end
|
57
|
+
|
58
|
+
def init
|
45
59
|
@connection.send_message('Target.setAutoAttach', {
|
46
60
|
waitForDebuggerOnStart: true,
|
47
61
|
flatten: true,
|
48
62
|
autoAttach: true,
|
49
63
|
})
|
64
|
+
finish_initialization_if_ready
|
50
65
|
@initialize_promise.value!
|
51
66
|
end
|
52
67
|
|
@@ -114,7 +129,7 @@ class Puppeteer::ChromeTargetManager
|
|
114
129
|
# Special case (https://crbug.com/1338156): currently, shared_workers
|
115
130
|
# don't get auto-attached. This should be removed once the auto-attach
|
116
131
|
# works.
|
117
|
-
@connection.create_session(target_info)
|
132
|
+
@connection.create_session(target_info, auto_attach_emulated: true)
|
118
133
|
end
|
119
134
|
end
|
120
135
|
|
@@ -170,6 +185,8 @@ class Puppeteer::ChromeTargetManager
|
|
170
185
|
end
|
171
186
|
}
|
172
187
|
|
188
|
+
return unless @connection.auto_attached?(target_info.target_id)
|
189
|
+
|
173
190
|
# Special case for service workers: being attached to service workers will
|
174
191
|
# prevent them from ever being destroyed. Therefore, we silently detach
|
175
192
|
# from service workers unless the connection was manually created via
|
@@ -197,6 +214,8 @@ class Puppeteer::ChromeTargetManager
|
|
197
214
|
return
|
198
215
|
end
|
199
216
|
|
217
|
+
is_existing_target = @attached_targets_by_target_id.has_key?(target_info.target_id)
|
218
|
+
|
200
219
|
target = @attached_targets_by_target_id[target_info.target_id] || @target_factory.call(target_info, session)
|
201
220
|
setup_attachment_listeners(session)
|
202
221
|
|
@@ -218,11 +237,10 @@ class Puppeteer::ChromeTargetManager
|
|
218
237
|
end
|
219
238
|
|
220
239
|
@target_ids_for_init.delete(target.target_id)
|
221
|
-
|
222
|
-
|
223
|
-
if @target_ids_for_init.empty?
|
224
|
-
@initialize_promise.fulfill(nil) unless @initialize_promise.resolved?
|
240
|
+
unless is_existing_target
|
241
|
+
future { emit_event(TargetManagerEmittedEvents::TargetAvailable, target) }
|
225
242
|
end
|
243
|
+
finish_initialization_if_ready
|
226
244
|
|
227
245
|
future do
|
228
246
|
# TODO: the browser might be shutting down here. What do we do with the error?
|
@@ -239,8 +257,8 @@ class Puppeteer::ChromeTargetManager
|
|
239
257
|
end
|
240
258
|
end
|
241
259
|
|
242
|
-
private def finish_initialization_if_ready(target_id)
|
243
|
-
@target_ids_for_init.delete(target_id)
|
260
|
+
private def finish_initialization_if_ready(target_id = nil)
|
261
|
+
@target_ids_for_init.delete(target_id) if target_id
|
244
262
|
if @target_ids_for_init.empty?
|
245
263
|
@initialize_promise.fulfill(nil) unless @initialize_promise.resolved?
|
246
264
|
end
|
data/lib/puppeteer/connection.rb
CHANGED
@@ -319,15 +319,18 @@ class Puppeteer::Connection
|
|
319
319
|
end
|
320
320
|
|
321
321
|
def auto_attached?(target_id)
|
322
|
-
|
322
|
+
!@manually_attached.include?(target_id)
|
323
323
|
end
|
324
324
|
|
325
325
|
# @param {Protocol.Target.TargetInfo} targetInfo
|
326
326
|
# @return [CDPSession]
|
327
|
-
def create_session(target_info)
|
328
|
-
|
327
|
+
def create_session(target_info, auto_attach_emulated: false)
|
328
|
+
unless auto_attach_emulated
|
329
|
+
@manually_attached << target_info.target_id
|
330
|
+
end
|
329
331
|
result = send_message('Target.attachToTarget', targetId: target_info.target_id, flatten: true)
|
330
332
|
session_id = result['sessionId']
|
333
|
+
@manually_attached.delete(target_info.target_id)
|
331
334
|
@sessions[session_id]
|
332
335
|
end
|
333
336
|
end
|
data/lib/puppeteer/dom_world.rb
CHANGED
@@ -539,49 +539,6 @@ class Puppeteer::DOMWorld
|
|
539
539
|
handle.as_element
|
540
540
|
end
|
541
541
|
|
542
|
-
# @param xpath [String]
|
543
|
-
# @param visible [Boolean] Wait for element visible (not 'display: none' nor 'visibility: hidden') on true. default to false.
|
544
|
-
# @param hidden [Boolean] Wait for element invisible ('display: none' nor 'visibility: hidden') on true. default to false.
|
545
|
-
# @param timeout [Integer]
|
546
|
-
def wait_for_xpath(xpath, visible: nil, hidden: nil, timeout: nil, root: nil)
|
547
|
-
option_wait_for_visible = visible || false
|
548
|
-
option_wait_for_hidden = hidden || false
|
549
|
-
option_timeout = timeout || @timeout_settings.timeout
|
550
|
-
option_root = root
|
551
|
-
|
552
|
-
polling =
|
553
|
-
if option_wait_for_visible || option_wait_for_hidden
|
554
|
-
'raf'
|
555
|
-
else
|
556
|
-
'mutation'
|
557
|
-
end
|
558
|
-
title = "XPath #{xpath}#{option_wait_for_hidden ? 'to be hidden' : ''}"
|
559
|
-
|
560
|
-
xpath_predicate = make_predicate_string(
|
561
|
-
predicate_arg_def: '(root, selector, waitForVisible, waitForHidden)',
|
562
|
-
predicate_body: <<~JAVASCRIPT
|
563
|
-
const node = document.evaluate(selector, root, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
|
564
|
-
return checkWaitForOptions(node, waitForVisible, waitForHidden);
|
565
|
-
JAVASCRIPT
|
566
|
-
)
|
567
|
-
|
568
|
-
wait_task = Puppeteer::WaitTask.new(
|
569
|
-
dom_world: self,
|
570
|
-
predicate_body: xpath_predicate,
|
571
|
-
title: title,
|
572
|
-
polling: polling,
|
573
|
-
timeout: option_timeout,
|
574
|
-
args: [xpath, option_wait_for_visible, option_wait_for_hidden],
|
575
|
-
root: option_root,
|
576
|
-
)
|
577
|
-
handle = wait_task.await_promise
|
578
|
-
unless handle.as_element
|
579
|
-
handle.dispose
|
580
|
-
return nil
|
581
|
-
end
|
582
|
-
handle.as_element
|
583
|
-
end
|
584
|
-
|
585
542
|
# @param page_function [String]
|
586
543
|
# @param args [Array]
|
587
544
|
# @param polling [Integer|String]
|
@@ -125,28 +125,14 @@ class Puppeteer::ElementHandle < Puppeteer::JSHandle
|
|
125
125
|
# Defaults to `30000` (30 seconds). Pass `0` to disable timeout. The default
|
126
126
|
# value can be changed by using the {@link Page.setDefaultTimeout} method.
|
127
127
|
def wait_for_xpath(xpath, visible: nil, hidden: nil, timeout: nil)
|
128
|
-
frame = @context.frame
|
129
|
-
|
130
|
-
secondary_world = frame.secondary_world
|
131
|
-
adopted_root = secondary_world.execution_context.adopt_element_handle(self)
|
132
128
|
param_xpath =
|
133
129
|
if xpath.start_with?('//')
|
134
130
|
".#{xpath}"
|
135
131
|
else
|
136
132
|
xpath
|
137
133
|
end
|
138
|
-
unless param_xpath.start_with?('.//')
|
139
|
-
adopted_root.dispose
|
140
|
-
raise ArgumentError.new("Unsupported xpath expression: #{xpath}")
|
141
|
-
end
|
142
|
-
handle = secondary_world.wait_for_xpath(param_xpath, visible: visible, hidden: hidden, timeout: timeout, root: adopted_root)
|
143
|
-
adopted_root.dispose
|
144
|
-
return nil unless handle
|
145
134
|
|
146
|
-
|
147
|
-
result = main_world.execution_context.adopt_element_handle(handle)
|
148
|
-
handle.dispose
|
149
|
-
result
|
135
|
+
wait_for_selector("xpath/#{param_xpath}", visible: visible, hidden: hidden, timeout: timeout)
|
150
136
|
end
|
151
137
|
|
152
138
|
define_async_method :async_wait_for_xpath
|
@@ -623,21 +609,14 @@ class Puppeteer::ElementHandle < Puppeteer::JSHandle
|
|
623
609
|
# @param expression [String]
|
624
610
|
# @return [Array<ElementHandle>]
|
625
611
|
def Sx(expression)
|
626
|
-
|
627
|
-
|
628
|
-
|
629
|
-
|
630
|
-
|
631
|
-
|
632
|
-
|
633
|
-
|
634
|
-
return array;
|
635
|
-
}
|
636
|
-
JAVASCRIPT
|
637
|
-
handles = evaluate_handle(fn, expression)
|
638
|
-
properties = handles.properties
|
639
|
-
handles.dispose
|
640
|
-
properties.values.map(&:as_element).compact
|
612
|
+
param_xpath =
|
613
|
+
if expression.start_with?('//')
|
614
|
+
".#{expression}"
|
615
|
+
else
|
616
|
+
expression
|
617
|
+
end
|
618
|
+
|
619
|
+
query_selector_all("xpath/#{param_xpath}")
|
641
620
|
end
|
642
621
|
|
643
622
|
define_async_method :async_Sx
|
data/lib/puppeteer/frame.rb
CHANGED
@@ -106,7 +106,14 @@ class Puppeteer::Frame
|
|
106
106
|
# @param {string} expression
|
107
107
|
# @return {!Promise<!Array<!Puppeteer.ElementHandle>>}
|
108
108
|
def Sx(expression)
|
109
|
-
|
109
|
+
param_xpath =
|
110
|
+
if expression.start_with?('//')
|
111
|
+
".#{expression}"
|
112
|
+
else
|
113
|
+
expression
|
114
|
+
end
|
115
|
+
|
116
|
+
query_selector_all("xpath/#{param_xpath}")
|
110
117
|
end
|
111
118
|
|
112
119
|
define_async_method :async_Sx
|
@@ -274,14 +281,14 @@ class Puppeteer::Frame
|
|
274
281
|
# @param hidden [Boolean] Wait for element invisible ('display: none' nor 'visibility: hidden') on true. default to false.
|
275
282
|
# @param timeout [Integer]
|
276
283
|
def wait_for_xpath(xpath, visible: nil, hidden: nil, timeout: nil)
|
277
|
-
|
278
|
-
|
279
|
-
|
280
|
-
|
281
|
-
|
282
|
-
|
283
|
-
|
284
|
-
|
284
|
+
param_xpath =
|
285
|
+
if xpath.start_with?('//')
|
286
|
+
".#{xpath}"
|
287
|
+
else
|
288
|
+
xpath
|
289
|
+
end
|
290
|
+
|
291
|
+
wait_for_selector("xpath/#{param_xpath}", visible: visible, hidden: hidden, timeout: timeout)
|
285
292
|
end
|
286
293
|
|
287
294
|
define_async_method :async_wait_for_xpath
|
@@ -144,13 +144,13 @@ class Puppeteer::FrameManager
|
|
144
144
|
watcher.same_document_navigation_promise
|
145
145
|
end,
|
146
146
|
)
|
147
|
+
|
148
|
+
watcher.navigation_response
|
147
149
|
rescue Puppeteer::TimeoutError => err
|
148
150
|
raise NavigationError.new(err)
|
149
151
|
ensure
|
150
152
|
watcher.dispose
|
151
153
|
end
|
152
|
-
|
153
|
-
watcher.navigation_response
|
154
154
|
end
|
155
155
|
|
156
156
|
# @param timeout [number|nil]
|
@@ -168,13 +168,13 @@ class Puppeteer::FrameManager
|
|
168
168
|
watcher.same_document_navigation_promise,
|
169
169
|
watcher.new_document_navigation_promise,
|
170
170
|
)
|
171
|
+
|
172
|
+
watcher.navigation_response
|
171
173
|
rescue Puppeteer::TimeoutError => err
|
172
174
|
raise NavigationError.new(err)
|
173
175
|
ensure
|
174
176
|
watcher.dispose
|
175
177
|
end
|
176
|
-
|
177
|
-
watcher.navigation_response
|
178
178
|
end
|
179
179
|
|
180
180
|
# @param event [Hash]
|
@@ -81,7 +81,10 @@ class Puppeteer::LifecycleWatcher
|
|
81
81
|
@frame_manager.add_event_listener(FrameManagerEmittedEvents::FrameSwapped, &method(:handle_frame_swapped)),
|
82
82
|
@frame_manager.add_event_listener(FrameManagerEmittedEvents::FrameDetached, &method(:handle_frame_detached)),
|
83
83
|
]
|
84
|
-
@listener_ids['network_manager'] =
|
84
|
+
@listener_ids['network_manager'] = [
|
85
|
+
@frame_manager.network_manager.add_event_listener(NetworkManagerEmittedEvents::Request, &method(:handle_request)),
|
86
|
+
@frame_manager.network_manager.add_event_listener(NetworkManagerEmittedEvents::Response, &method(:handle_response)),
|
87
|
+
]
|
85
88
|
|
86
89
|
@same_document_navigation_promise = resolvable_future
|
87
90
|
@lifecycle_promise = resolvable_future
|
@@ -90,10 +93,24 @@ class Puppeteer::LifecycleWatcher
|
|
90
93
|
check_lifecycle_complete
|
91
94
|
end
|
92
95
|
|
96
|
+
class AnotherRequestReceivedError < StandardError ; end
|
97
|
+
|
93
98
|
# @param [Puppeteer::HTTPRequest] request
|
94
99
|
def handle_request(request)
|
95
100
|
return if request.frame != @frame || !request.navigation_request?
|
96
101
|
@navigation_request = request
|
102
|
+
@navigation_response_received&.reject(AnotherRequestReceivedError.new('New navigation request was received'))
|
103
|
+
@navigation_response_received = resolvable_future
|
104
|
+
if request.response && !@navigation_response_received.resolved?
|
105
|
+
@navigation_response_received.fulfill(nil)
|
106
|
+
end
|
107
|
+
end
|
108
|
+
|
109
|
+
# @param [Puppeteer::HTTPResponse] response
|
110
|
+
def handle_response(response)
|
111
|
+
return if @navigation_request&.internal&.request_id != response.request.internal.request_id
|
112
|
+
|
113
|
+
@navigation_response_received.fulfill(nil) unless @navigation_response_received.resolved?
|
97
114
|
end
|
98
115
|
|
99
116
|
# @param frame [Puppeteer::Frame]
|
@@ -107,6 +124,8 @@ class Puppeteer::LifecycleWatcher
|
|
107
124
|
|
108
125
|
# @return [Puppeteer::HTTPResponse]
|
109
126
|
def navigation_response
|
127
|
+
# Continue with a possibly null response.
|
128
|
+
@navigation_response_received.value! rescue nil
|
110
129
|
if_present(@navigation_request) do |request|
|
111
130
|
request.response
|
112
131
|
end
|
@@ -175,8 +194,8 @@ class Puppeteer::LifecycleWatcher
|
|
175
194
|
if_present(@listener_ids['frame_manager']) do |ids|
|
176
195
|
@frame_manager.remove_event_listener(*ids)
|
177
196
|
end
|
178
|
-
if_present(@listener_ids['network_manager']) do |
|
179
|
-
@frame_manager.network_manager.remove_event_listener(
|
197
|
+
if_present(@listener_ids['network_manager']) do |ids|
|
198
|
+
@frame_manager.network_manager.remove_event_listener(*ids)
|
180
199
|
end
|
181
200
|
end
|
182
201
|
end
|
@@ -6,6 +6,7 @@ class Puppeteer::QueryHandlerManager
|
|
6
6
|
def query_handlers
|
7
7
|
@query_handlers ||= {
|
8
8
|
aria: Puppeteer::AriaQueryHandler.new,
|
9
|
+
xpath: xpath_handler,
|
9
10
|
}
|
10
11
|
end
|
11
12
|
|
@@ -16,6 +17,40 @@ class Puppeteer::QueryHandlerManager
|
|
16
17
|
)
|
17
18
|
end
|
18
19
|
|
20
|
+
private def xpath_handler
|
21
|
+
@xpath_handler ||= Puppeteer::CustomQueryHandler.new(
|
22
|
+
query_one: <<~JAVASCRIPT,
|
23
|
+
(element, selector) => {
|
24
|
+
const doc = element.ownerDocument || document;
|
25
|
+
const result = doc.evaluate(
|
26
|
+
selector,
|
27
|
+
element,
|
28
|
+
null,
|
29
|
+
XPathResult.FIRST_ORDERED_NODE_TYPE
|
30
|
+
);
|
31
|
+
return result.singleNodeValue;
|
32
|
+
}
|
33
|
+
JAVASCRIPT
|
34
|
+
query_all: <<~JAVASCRIPT,
|
35
|
+
(element, selector) => {
|
36
|
+
const doc = element.ownerDocument || document;
|
37
|
+
const iterator = doc.evaluate(
|
38
|
+
selector,
|
39
|
+
element,
|
40
|
+
null,
|
41
|
+
XPathResult.ORDERED_NODE_ITERATOR_TYPE
|
42
|
+
);
|
43
|
+
const array = [];
|
44
|
+
let item;
|
45
|
+
while ((item = iterator.iterateNext())) {
|
46
|
+
array.push(item);
|
47
|
+
}
|
48
|
+
return array;
|
49
|
+
}
|
50
|
+
JAVASCRIPT
|
51
|
+
)
|
52
|
+
end
|
53
|
+
|
19
54
|
class Result
|
20
55
|
def initialize(query_handler:, selector:)
|
21
56
|
@query_handler = query_handler
|
data/lib/puppeteer/target.rb
CHANGED
@@ -93,7 +93,7 @@ class Puppeteer::Target
|
|
93
93
|
end
|
94
94
|
|
95
95
|
def create_cdp_session
|
96
|
-
@session_factory.call
|
96
|
+
@session_factory.call(false)
|
97
97
|
end
|
98
98
|
|
99
99
|
def target_manager
|
@@ -102,7 +102,7 @@ class Puppeteer::Target
|
|
102
102
|
|
103
103
|
def page
|
104
104
|
if @is_page_target_callback.call(@target_info) && @page.nil?
|
105
|
-
client = @session || @session_factory.call
|
105
|
+
client = @session || @session_factory.call(true)
|
106
106
|
@page = Puppeteer::Page.create(client, self, @ignore_https_errors, @default_viewport)
|
107
107
|
end
|
108
108
|
@page
|
data/lib/puppeteer/version.rb
CHANGED
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: puppeteer-ruby
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.43.
|
4
|
+
version: 0.43.1
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- YusukeIwaki
|
8
8
|
autorequire:
|
9
9
|
bindir: exe
|
10
10
|
cert_chain: []
|
11
|
-
date: 2022-09-
|
11
|
+
date: 2022-09-18 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: concurrent-ruby
|