puppeteer-ruby 0.35.0 → 0.37.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -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'