puppeteer-ruby 0.35.0 → 0.37.1

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.
@@ -0,0 +1,49 @@
1
+ class Puppeteer::Page
2
+ class Metrics
3
+ SUPPORTED_KEYS = Set.new([
4
+ 'Timestamp',
5
+ 'Documents',
6
+ 'Frames',
7
+ 'JSEventListeners',
8
+ 'Nodes',
9
+ 'LayoutCount',
10
+ 'RecalcStyleCount',
11
+ 'LayoutDuration',
12
+ 'RecalcStyleDuration',
13
+ 'ScriptDuration',
14
+ 'TaskDuration',
15
+ 'JSHeapUsedSize',
16
+ 'JSHeapTotalSize',
17
+ ]).freeze
18
+
19
+ SUPPORTED_KEYS.each do |key|
20
+ attr_reader key
21
+ end
22
+
23
+ # @param metrics_result [Hash] response for Performance.getMetrics
24
+ def initialize(metrics_response)
25
+ metrics_response.each do |metric|
26
+ if SUPPORTED_KEYS.include?(metric['name'])
27
+ instance_variable_set(:"@#{metric['name']}", metric['value'])
28
+ end
29
+ end
30
+ end
31
+
32
+ def [](key)
33
+ if SUPPORTED_KEYS.include?(key.to_s)
34
+ instance_variable_get(:"@#{key}")
35
+ else
36
+ raise ArgumentError.new("invalid metric key specified: #{key}")
37
+ end
38
+ end
39
+ end
40
+
41
+ class MetricsEvent
42
+ def initialize(metrics_event)
43
+ @title = metrics_event['title']
44
+ @metrics = Metrics.new(metrics_event['metrics'])
45
+ end
46
+
47
+ attr_reader :title, :metrics
48
+ end
49
+ end
@@ -15,7 +15,7 @@ class Puppeteer::Page
15
15
  # @params options [Hash]
16
16
  def initialize(options)
17
17
  if options[:type]
18
- unless [:png, :jpeg].include?(options[:type].to_sym)
18
+ unless [:png, :jpeg, :webp].include?(options[:type].to_sym)
19
19
  raise ArgumentError.new("Unknown options.type value: #{options[:type]}")
20
20
  end
21
21
  @type = options[:type]
@@ -25,6 +25,8 @@ class Puppeteer::Page
25
25
  @type = 'png'
26
26
  elsif mime_types.include?('image/jpeg')
27
27
  @type = 'jpeg'
28
+ elsif mime_types.include?('image/webp')
29
+ @type = 'webp'
28
30
  else
29
31
  raise ArgumentError.new("Unsupported screenshot mime type resolved: #{mime_types}, path: #{options[:path]}")
30
32
  end
@@ -2,11 +2,13 @@ require 'base64'
2
2
  require 'json'
3
3
  require "stringio"
4
4
 
5
+ require_relative './page/metrics'
5
6
  require_relative './page/pdf_options'
6
7
  require_relative './page/screenshot_options'
7
8
  require_relative './page/screenshot_task_queue'
8
9
 
9
10
  class Puppeteer::Page
11
+ include Puppeteer::DebugPrint
10
12
  include Puppeteer::EventCallbackable
11
13
  include Puppeteer::IfPresent
12
14
  using Puppeteer::DefineAsyncMethod
@@ -46,6 +48,8 @@ class Puppeteer::Page
46
48
  @screenshot_task_queue = ScreenshotTaskQueue.new
47
49
 
48
50
  @workers = {}
51
+ @user_drag_interception_enabled = false
52
+
49
53
  @client.on_event('Target.attachedToTarget') do |event|
50
54
  if event['targetInfo']['type'] != 'worker'
51
55
  # If we don't detach from service workers, they will never die.
@@ -102,7 +106,9 @@ class Puppeteer::Page
102
106
  @client.on('Runtime.consoleAPICalled') do |event|
103
107
  handle_console_api(event)
104
108
  end
105
- # client.on('Runtime.bindingCalled', event => this._onBindingCalled(event));
109
+ @client.on('Runtime.bindingCalled') do |event|
110
+ handle_binding_called(event)
111
+ end
106
112
  @client.on_event('Page.javascriptDialogOpening') do |event|
107
113
  handle_dialog_opening(event)
108
114
  end
@@ -112,7 +118,9 @@ class Puppeteer::Page
112
118
  @client.on_event('Inspector.targetCrashed') do |event|
113
119
  handle_target_crashed
114
120
  end
115
- # client.on('Performance.metrics', event => this._emitMetrics(event));
121
+ @client.on_event('Performance.metrics') do |event|
122
+ emit_event(PageEmittedEvents::Metrics, MetricsEvent.new(event))
123
+ end
116
124
  @client.on_event('Log.entryAdded') do |event|
117
125
  handle_log_entry_added(event)
118
126
  end
@@ -134,6 +142,11 @@ class Puppeteer::Page
134
142
  )
135
143
  end
136
144
 
145
+ def drag_interception_enabled?
146
+ @user_drag_interception_enabled
147
+ end
148
+ alias_method :drag_interception_enabled, :drag_interception_enabled?
149
+
137
150
  # @param event_name [Symbol]
138
151
  def on(event_name, &block)
139
152
  unless PageEmittedEvents.values.include?(event_name.to_s)
@@ -266,10 +279,20 @@ class Puppeteer::Page
266
279
  @frame_manager.network_manager.request_interception = value
267
280
  end
268
281
 
282
+ def drag_interception_enabled=(enabled)
283
+ @user_drag_interception_enabled = enabled
284
+ @client.send_message('Input.setInterceptDrags', enabled: enabled)
285
+ end
286
+
269
287
  def offline_mode=(enabled)
270
288
  @frame_manager.network_manager.offline_mode = enabled
271
289
  end
272
290
 
291
+ # @param network_condition [Puppeteer::NetworkCondition|nil]
292
+ def emulate_network_conditions(network_condition)
293
+ @frame_manager.network_manager.emulate_network_conditions(network_condition)
294
+ end
295
+
273
296
  # @param {number} timeout
274
297
  def default_navigation_timeout=(timeout)
275
298
  @timeout_settings.default_navigation_timeout = timeout
@@ -381,8 +404,9 @@ class Puppeteer::Page
381
404
  # @param path [String?]
382
405
  # @param content [String?]
383
406
  # @param type [String?]
384
- def add_script_tag(url: nil, path: nil, content: nil, type: nil)
385
- main_frame.add_script_tag(url: url, path: path, content: content, type: type)
407
+ # @param id [String?]
408
+ def add_script_tag(url: nil, path: nil, content: nil, type: nil, id: nil)
409
+ main_frame.add_script_tag(url: url, path: path, content: content, type: type, id: id)
386
410
  end
387
411
 
388
412
  # @param url [String?]
@@ -392,37 +416,51 @@ class Puppeteer::Page
392
416
  main_frame.add_style_tag(url: url, path: path, content: content)
393
417
  end
394
418
 
395
- # /**
396
- # * @param {string} name
397
- # * @param {Function} puppeteerFunction
398
- # */
399
- # async exposeFunction(name, puppeteerFunction) {
400
- # if (this._pageBindings.has(name))
401
- # throw new Error(`Failed to add page binding with name ${name}: window['${name}'] already exists!`);
402
- # this._pageBindings.set(name, puppeteerFunction);
403
-
404
- # const expression = helper.evaluationString(addPageBinding, name);
405
- # await this._client.send('Runtime.addBinding', {name: name});
406
- # await this._client.send('Page.addScriptToEvaluateOnNewDocument', {source: expression});
407
- # await Promise.all(this.frames().map(frame => frame.evaluate(expression).catch(debugError)));
408
-
409
- # function addPageBinding(bindingName) {
410
- # const binding = window[bindingName];
411
- # window[bindingName] = (...args) => {
412
- # const me = window[bindingName];
413
- # let callbacks = me['callbacks'];
414
- # if (!callbacks) {
415
- # callbacks = new Map();
416
- # me['callbacks'] = callbacks;
417
- # }
418
- # const seq = (me['lastSeq'] || 0) + 1;
419
- # me['lastSeq'] = seq;
420
- # const promise = new Promise((resolve, reject) => callbacks.set(seq, {resolve, reject}));
421
- # binding(JSON.stringify({name: bindingName, seq, args}));
422
- # return promise;
423
- # };
424
- # }
425
- # }
419
+ # @param name [String]
420
+ # @param puppeteer_function [Proc]
421
+ def expose_function(name, puppeteer_function)
422
+ if @page_bindings[name]
423
+ raise ArgumentError.new("Failed to add page binding with name `#{name}` already exists!")
424
+ end
425
+ @page_bindings[name] = puppeteer_function
426
+
427
+ add_page_binding = <<~JAVASCRIPT
428
+ function (type, bindingName) {
429
+ /* Cast window to any here as we're about to add properties to it
430
+ * via win[bindingName] which TypeScript doesn't like.
431
+ */
432
+ const win = window;
433
+ const binding = win[bindingName];
434
+
435
+ win[bindingName] = (...args) => {
436
+ const me = window[bindingName];
437
+ let callbacks = me.callbacks;
438
+ if (!callbacks) {
439
+ callbacks = new Map();
440
+ me.callbacks = callbacks;
441
+ }
442
+ const seq = (me.lastSeq || 0) + 1;
443
+ me.lastSeq = seq;
444
+ const promise = new Promise((resolve, reject) =>
445
+ callbacks.set(seq, { resolve, reject })
446
+ );
447
+ binding(JSON.stringify({ type, name: bindingName, seq, args }));
448
+ return promise;
449
+ };
450
+ }
451
+ JAVASCRIPT
452
+
453
+ source = JavaScriptFunction.new(add_page_binding, ['exposedFun', name]).source
454
+ @client.send_message('Runtime.addBinding', name: name)
455
+ @client.send_message('Page.addScriptToEvaluateOnNewDocument', source: source)
456
+
457
+ promises = @frame_manager.frames.map do |frame|
458
+ frame.async_evaluate("() => #{source}")
459
+ end
460
+ await_all(*promises)
461
+
462
+ nil
463
+ end
426
464
 
427
465
  # @param username [String?]
428
466
  # @param password [String?]
@@ -436,40 +474,16 @@ class Puppeteer::Page
436
474
  end
437
475
 
438
476
  # @param user_agent [String]
439
- def user_agent=(user_agent)
440
- @frame_manager.network_manager.user_agent = user_agent
441
- end
442
-
443
- # /**
444
- # * @return {!Promise<!Metrics>}
445
- # */
446
- # async metrics() {
447
- # const response = await this._client.send('Performance.getMetrics');
448
- # return this._buildMetricsObject(response.metrics);
449
- # }
450
-
451
- # /**
452
- # * @param {!Protocol.Performance.metricsPayload} event
453
- # */
454
- # _emitMetrics(event) {
455
- # this.emit(PageEmittedEvents::Metrics, {
456
- # title: event.title,
457
- # metrics: this._buildMetricsObject(event.metrics)
458
- # });
459
- # }
460
-
461
- # /**
462
- # * @param {?Array<!Protocol.Performance.Metric>} metrics
463
- # * @return {!Metrics}
464
- # */
465
- # _buildMetricsObject(metrics) {
466
- # const result = {};
467
- # for (const metric of metrics || []) {
468
- # if (supportedMetrics.has(metric.name))
469
- # result[metric.name] = metric.value;
470
- # }
471
- # return result;
472
- # }
477
+ # @param user_agent_metadata [Hash]
478
+ def set_user_agent(user_agent, user_agent_metadata = nil)
479
+ @frame_manager.network_manager.set_user_agent(user_agent, user_agent_metadata)
480
+ end
481
+ alias_method :user_agent=, :set_user_agent
482
+
483
+ def metrics
484
+ response = @client.send_message('Performance.getMetrics')
485
+ Metrics.new(response['metrics'])
486
+ end
473
487
 
474
488
  class PageError < StandardError ; end
475
489
 
@@ -506,56 +520,51 @@ class Puppeteer::Page
506
520
  add_console_message(event['type'], values, event['stackTrace'])
507
521
  end
508
522
 
509
- # /**
510
- # * @param {!Protocol.Runtime.bindingCalledPayload} event
511
- # */
512
- # async _onBindingCalled(event) {
513
- # const {name, seq, args} = JSON.parse(event.payload);
514
- # let expression = null;
515
- # try {
516
- # const result = await this._pageBindings.get(name)(...args);
517
- # expression = helper.evaluationString(deliverResult, name, seq, result);
518
- # } catch (error) {
519
- # if (error instanceof Error)
520
- # expression = helper.evaluationString(deliverError, name, seq, error.message, error.stack);
521
- # else
522
- # expression = helper.evaluationString(deliverErrorValue, name, seq, error);
523
- # }
524
- # this._client.send('Runtime.evaluate', { expression, contextId: event.executionContextId }).catch(debugError);
525
-
526
- # /**
527
- # * @param {string} name
528
- # * @param {number} seq
529
- # * @param {*} result
530
- # */
531
- # function deliverResult(name, seq, result) {
532
- # window[name]['callbacks'].get(seq).resolve(result);
533
- # window[name]['callbacks'].delete(seq);
534
- # }
535
-
536
- # /**
537
- # * @param {string} name
538
- # * @param {number} seq
539
- # * @param {string} message
540
- # * @param {string} stack
541
- # */
542
- # function deliverError(name, seq, message, stack) {
543
- # const error = new Error(message);
544
- # error.stack = stack;
545
- # window[name]['callbacks'].get(seq).reject(error);
546
- # window[name]['callbacks'].delete(seq);
547
- # }
548
-
549
- # /**
550
- # * @param {string} name
551
- # * @param {number} seq
552
- # * @param {*} value
553
- # */
554
- # function deliverErrorValue(name, seq, value) {
555
- # window[name]['callbacks'].get(seq).reject(value);
556
- # window[name]['callbacks'].delete(seq);
557
- # }
558
- # }
523
+ def handle_binding_called(event)
524
+ execution_context_id = event['executionContextId']
525
+ payload =
526
+ begin
527
+ JSON.parse(event['payload'])
528
+ rescue
529
+ # The binding was either called by something in the page or it was
530
+ # called before our wrapper was initialized.
531
+ return
532
+ end
533
+ name = payload['name']
534
+ seq = payload['seq']
535
+ args = payload['args']
536
+
537
+ if payload['type'] != 'exposedFun' || !@page_bindings[name]
538
+ return
539
+ end
540
+
541
+ expression =
542
+ begin
543
+ result = @page_bindings[name].call(*args)
544
+
545
+ deliver_result = <<~JAVASCRIPT
546
+ function (name, seq, result) {
547
+ window[name].callbacks.get(seq).resolve(result);
548
+ window[name].callbacks.delete(seq);
549
+ }
550
+ JAVASCRIPT
551
+
552
+ JavaScriptFunction.new(deliver_result, [name, seq, result]).source
553
+ rescue => err
554
+ deliver_error = <<~JAVASCRIPT
555
+ function (name, seq, message) {
556
+ const error = new Error(message);
557
+ window[name].callbacks.get(seq).reject(error);
558
+ window[name].callbacks.delete(seq);
559
+ }
560
+ JAVASCRIPT
561
+ JavaScriptFunction.new(deliver_error, [name, seq, err.message]).source
562
+ end
563
+
564
+ @client.async_send_message('Runtime.evaluate', expression: expression, contextId: execution_context_id).rescue do |error|
565
+ debug_puts(error)
566
+ end
567
+ end
559
568
 
560
569
  private def add_console_message(type, args, stack_trace)
561
570
  text_tokens = args.map { |arg| arg.remote_object.value }
@@ -631,10 +640,9 @@ class Puppeteer::Page
631
640
  # @param wait_until [string|nil] 'load' | 'domcontentloaded' | 'networkidle0' | 'networkidle2'
632
641
  # @return [Puppeteer::Response]
633
642
  def reload(timeout: nil, wait_until: nil)
634
- await_all(
635
- async_wait_for_navigation(timeout: timeout, wait_until: wait_until),
636
- @client.async_send_message('Page.reload'),
637
- ).first
643
+ wait_for_navigation(timeout: timeout, wait_until: wait_until) do
644
+ @client.send_message('Page.reload')
645
+ end
638
646
  end
639
647
 
640
648
  def wait_for_navigation(timeout: nil, wait_until: nil)
@@ -760,10 +768,9 @@ class Puppeteer::Page
760
768
  entries = history['entries']
761
769
  index = history['currentIndex'] + delta
762
770
  if_present(entries[index]) do |entry|
763
- await_all(
764
- async_wait_for_navigation(timeout: timeout, wait_until: wait_until),
765
- @client.async_send_message('Page.navigateToHistoryEntry', entryId: entry['id']),
766
- )
771
+ wait_for_navigation(timeout: timeout, wait_until: wait_until) do
772
+ @client.send_message('Page.navigateToHistoryEntry', entryId: entry['id'])
773
+ end
767
774
  end
768
775
  end
769
776
 
@@ -799,6 +806,15 @@ class Puppeteer::Page
799
806
  @client.send_message('Emulation.setEmulatedMedia', media: media_type_str)
800
807
  end
801
808
 
809
+ # @param factor [Number|nil] Factor at which the CPU will be throttled (2x, 2.5x. 3x, ...). Passing `nil` disables cpu throttling.
810
+ def emulate_cpu_throttling(factor)
811
+ if factor.nil? || factor >= 1
812
+ @client.send_message('Emulation.setCPUThrottlingRate', rate: factor || 1)
813
+ else
814
+ raise ArgumentError.new('Throttling rate should be greater or equal to 1')
815
+ end
816
+ end
817
+
802
818
  # @param features [Array]
803
819
  def emulate_media_features(features)
804
820
  if features.nil?
@@ -921,7 +937,7 @@ class Puppeteer::Page
921
937
  main_frame.title
922
938
  end
923
939
 
924
- # @param type [String] "png"|"jpeg"
940
+ # @param type [String] "png"|"jpeg"|"webp"
925
941
  # @param path [String]
926
942
  # @param full_page [Boolean]
927
943
  # @param clip [Hash]
@@ -1015,11 +1031,20 @@ class Puppeteer::Page
1015
1031
 
1016
1032
  # @return [Enumerable<String>]
1017
1033
  def create_pdf_stream(options = {})
1034
+ timeout_helper = Puppeteer::TimeoutHelper.new('Page.printToPDF',
1035
+ timeout_ms: options[:timeout],
1036
+ default_timeout_ms: 30000)
1018
1037
  pdf_options = PDFOptions.new(options)
1019
1038
  omit_background = options[:omit_background]
1020
1039
  set_transparent_background_color if omit_background
1021
- result = @client.send_message('Page.printToPDF', pdf_options.page_print_args)
1022
- reset_default_background_color if omit_background
1040
+ result =
1041
+ begin
1042
+ timeout_helper.with_timeout do
1043
+ @client.send_message('Page.printToPDF', pdf_options.page_print_args)
1044
+ end
1045
+ ensure
1046
+ reset_default_background_color if omit_background
1047
+ end
1023
1048
 
1024
1049
  Puppeteer::ProtocolStreamReader.new(
1025
1050
  client: @client,
@@ -149,6 +149,11 @@ class Puppeteer::Puppeteer
149
149
  # # ???
150
150
  # end
151
151
 
152
+ # @return [Puppeteer::NetworkConditions]
153
+ def network_conditions
154
+ Puppeteer::NetworkConditions
155
+ end
156
+
152
157
  # @param args [Array<String>]
153
158
  # @param user_data_dir [String]
154
159
  # @param devtools [Boolean]
@@ -0,0 +1,22 @@
1
+ require 'timeout'
2
+
3
+ class Puppeteer::TimeoutHelper
4
+ # @param timeout_ms [String|Integer|nil]
5
+ # @param default_timeout_ms [Integer]
6
+ def initialize(task_name, timeout_ms:, default_timeout_ms:)
7
+ @task_name = task_name
8
+ @timeout_ms = (timeout_ms || default_timeout_ms).to_i
9
+ end
10
+
11
+ def with_timeout(&block)
12
+ if @timeout_ms > 0
13
+ begin
14
+ Timeout.timeout(@timeout_ms / 1000.0, &block)
15
+ rescue Timeout::Error
16
+ raise Puppeteer::TimeoutError.new("waiting for #{@task_name} failed: timeout #{@timeout_ms}ms exceeded")
17
+ end
18
+ else
19
+ block.call
20
+ end
21
+ end
22
+ end
@@ -27,11 +27,16 @@ class Puppeteer::Tracing
27
27
  option_categories << 'disabled-by-default-devtools.screenshot'
28
28
  end
29
29
 
30
+ ex_cat = option_categories.select { |cat| cat.start_with?('-') }.map { |cat| cat[1..-1] }
31
+ in_cat = option_categories.reject { |cat| cat.start_with?('-') }
30
32
  @path = path
31
33
  @recording = true
32
34
  @client.send_message('Tracing.start',
33
35
  transferMode: 'ReturnAsStream',
34
- categories: option_categories.join(','),
36
+ traceConfig: {
37
+ excludedCategories: ex_cat,
38
+ includedCategories: in_cat,
39
+ },
35
40
  )
36
41
  end
37
42
 
@@ -1,3 +1,3 @@
1
1
  module Puppeteer
2
- VERSION = '0.35.0'
2
+ VERSION = '0.37.1'
3
3
  end
@@ -55,6 +55,7 @@ class Puppeteer::WebSocket
55
55
  def initialize(url:, max_payload_size:)
56
56
  @impl = DriverImpl.new(url)
57
57
  @driver = ::WebSocket::Driver.client(@impl, max_length: max_payload_size)
58
+ @driver.set_header('User-Agent', "Puppeteer #{Puppeteer::VERSION}")
58
59
 
59
60
  setup
60
61
  @driver.start
@@ -0,0 +1,2 @@
1
+ # just an alias.
2
+ require 'puppeteer'
data/lib/puppeteer.rb CHANGED
@@ -5,7 +5,6 @@ module Puppeteer; end
5
5
  require 'puppeteer/env'
6
6
 
7
7
  # Custom data types.
8
- require 'puppeteer/device'
9
8
  require 'puppeteer/events'
10
9
  require 'puppeteer/errors'
11
10
  require 'puppeteer/geolocation'
@@ -44,6 +43,7 @@ require 'puppeteer/keyboard'
44
43
  require 'puppeteer/launcher'
45
44
  require 'puppeteer/lifecycle_watcher'
46
45
  require 'puppeteer/mouse'
46
+ require 'puppeteer/network_conditions'
47
47
  require 'puppeteer/network_manager'
48
48
  require 'puppeteer/page'
49
49
  require 'puppeteer/protocol_stream_reader'
@@ -54,6 +54,7 @@ require 'puppeteer/request'
54
54
  require 'puppeteer/response'
55
55
  require 'puppeteer/target'
56
56
  require 'puppeteer/tracing'
57
+ require 'puppeteer/timeout_helper'
57
58
  require 'puppeteer/timeout_settings'
58
59
  require 'puppeteer/touch_screen'
59
60
  require 'puppeteer/version'
@@ -66,17 +67,19 @@ require 'puppeteer/element_handle'
66
67
 
67
68
  # ref: https://github.com/puppeteer/puppeteer/blob/master/lib/Puppeteer.js
68
69
  module Puppeteer
69
- module_function def method_missing(method, *args, **kwargs, &block)
70
- @puppeteer ||= ::Puppeteer::Puppeteer.new(
71
- project_root: __dir__,
72
- preferred_revision: '706915',
73
- is_puppeteer_core: true,
74
- )
75
-
76
- if kwargs.empty? # for Ruby < 2.7
77
- @puppeteer.public_send(method, *args, &block)
78
- else
79
- @puppeteer.public_send(method, *args, **kwargs, &block)
70
+ @puppeteer ||= ::Puppeteer::Puppeteer.new(
71
+ project_root: __dir__,
72
+ preferred_revision: '706915',
73
+ is_puppeteer_core: true,
74
+ ).tap do |instance|
75
+ instance.public_methods(false).each do |method_name|
76
+ define_singleton_method(method_name) do |*args, **kwargs, &block|
77
+ if kwargs.empty? # for Ruby < 2.7
78
+ @puppeteer.public_send(method_name, *args, &block)
79
+ else
80
+ @puppeteer.public_send(method_name, *args, **kwargs, &block)
81
+ end
82
+ end
80
83
  end
81
84
  end
82
85
  end
@@ -32,7 +32,7 @@ Gem::Specification.new do |spec|
32
32
  spec.add_development_dependency 'rollbar'
33
33
  spec.add_development_dependency 'rspec', '~> 3.10.0 '
34
34
  spec.add_development_dependency 'rspec_junit_formatter' # for CircleCI.
35
- spec.add_development_dependency 'rubocop', '~> 1.18.0'
35
+ spec.add_development_dependency 'rubocop', '~> 1.22.0'
36
36
  spec.add_development_dependency 'rubocop-rspec'
37
37
  spec.add_development_dependency 'sinatra'
38
38
  spec.add_development_dependency 'webrick'