diff --git a/rb/lib/selenium/webdriver/bidi.rb b/rb/lib/selenium/webdriver/bidi.rb index 214091791fc45..ed3bcc7b12098 100644 --- a/rb/lib/selenium/webdriver/bidi.rb +++ b/rb/lib/selenium/webdriver/bidi.rb @@ -26,6 +26,10 @@ class BiDi autoload :BrowsingContext, 'selenium/webdriver/bidi/browsing_context' autoload :Struct, 'selenium/webdriver/bidi/struct' autoload :Network, 'selenium/webdriver/bidi/network' + autoload :InterceptedRequest, 'selenium/webdriver/bidi/network/intercepted_request' + autoload :InterceptedResponse, 'selenium/webdriver/bidi/network/intercepted_response' + autoload :InterceptedAuth, 'selenium/webdriver/bidi/network/intercepted_auth' + autoload :InterceptedItem, 'selenium/webdriver/bidi/network/intercepted_item' def initialize(url:) @ws = WebSocketConnection.new(url: url) diff --git a/rb/lib/selenium/webdriver/bidi/network.rb b/rb/lib/selenium/webdriver/bidi/network.rb index ae55f9200efe4..f3074fb6173b7 100644 --- a/rb/lib/selenium/webdriver/bidi/network.rb +++ b/rb/lib/selenium/webdriver/bidi/network.rb @@ -16,6 +16,7 @@ # KIND, either express or implied. See the License for the # specific language governing permissions and limitations # under the License. +require_relative 'network/url_pattern' module Selenium module WebDriver @@ -39,8 +40,12 @@ def initialize(bidi) @bidi = bidi end - def add_intercept(phases: [], contexts: nil, url_patterns: nil) - @bidi.send_cmd('network.addIntercept', phases: phases, contexts: contexts, urlPatterns: url_patterns) + def add_intercept(phases: [], contexts: nil, url_patterns: nil, pattern_type: :string) + url_patterns = url_patterns && pattern_type ? UrlPattern.format_pattern(url_patterns, pattern_type) : nil + @bidi.send_cmd('network.addIntercept', + phases: phases, + contexts: contexts, + urlPatterns: url_patterns) end def remove_intercept(intercept) @@ -50,31 +55,67 @@ def remove_intercept(intercept) def continue_with_auth(request_id, username, password) @bidi.send_cmd( 'network.continueWithAuth', - 'request' => request_id, - 'action' => 'provideCredentials', - 'credentials' => { - 'type' => 'password', - 'username' => username, - 'password' => password + request: request_id, + action: 'provideCredentials', + credentials: { + type: 'password', + username: username, + password: password } ) end - def continue_with_request(**args) + def continue_without_auth(request_id) @bidi.send_cmd( - 'network.continueWithRequest', - request: args[:request_id], - 'body' => args[:body], - 'cookies' => args[:cookies], - 'headers' => args[:headers], - 'method' => args[:method], - 'url' => args[:url] + 'network.continueWithAuth', + request: request_id, + action: 'default' + ) + end + + def cancel_auth(request_id) + @bidi.send_cmd( + 'network.continueWithAuth', + request: request_id, + action: 'cancel' + ) + end + + def continue_request(**args) + @bidi.send_cmd( + 'network.continueRequest', + request: args[:id], + body: args[:body], + cookies: args[:cookies], + headers: args[:headers], + method: args[:method], + url: args[:url] + ) + end + + def fail_request(request_id) + @bidi.send_cmd( + 'network.failRequest', + request: request_id + ) + end + + def continue_response(**args) + @bidi.send_cmd( + 'network.continueResponse', + request: args[:id], + cookies: args[:cookies], + credentials: args[:credentials], + headers: args[:headers], + reasonPhrase: args[:reason], + statusCode: args[:status] ) end def on(event, &) event = EVENTS[event] if event.is_a?(Symbol) @bidi.add_callback(event, &) + @bidi.session.subscribe(event) end end # Network end # BiDi diff --git a/rb/lib/selenium/webdriver/bidi/network/cookies.rb b/rb/lib/selenium/webdriver/bidi/network/cookies.rb new file mode 100644 index 0000000000000..7cc5d7871ce23 --- /dev/null +++ b/rb/lib/selenium/webdriver/bidi/network/cookies.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +# Licensed to the Software Freedom Conservancy (SFC) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The SFC licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +module Selenium + module WebDriver + class BiDi + class Cookies < Hash + def initialize(cookies = {}) + super() + merge!(cookies) + end + + def as_json + self[:name] = self[:name].to_s + self[:value] = {type: 'string', value: self[:value].to_s} + + [compact] + end + end + end # BiDi + end # WebDriver +end # Selenium diff --git a/rb/lib/selenium/webdriver/bidi/network/credentials.rb b/rb/lib/selenium/webdriver/bidi/network/credentials.rb new file mode 100644 index 0000000000000..a0e3db7905410 --- /dev/null +++ b/rb/lib/selenium/webdriver/bidi/network/credentials.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +# Licensed to the Software Freedom Conservancy (SFC) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The SFC licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +module Selenium + module WebDriver + class BiDi + class Credentials + attr_accessor :username, :password + + def initialize(username: nil, password: nil) + @username = username + @password = password + end + + def as_json + return nil unless username && password + + { + type: 'password', + username: username, + password: password + } + end + end + end # BiDi + end # WebDriver +end # Selenium diff --git a/rb/lib/selenium/webdriver/bidi/network/headers.rb b/rb/lib/selenium/webdriver/bidi/network/headers.rb new file mode 100644 index 0000000000000..44983f6eb58fb --- /dev/null +++ b/rb/lib/selenium/webdriver/bidi/network/headers.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +# Licensed to the Software Freedom Conservancy (SFC) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The SFC licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +module Selenium + module WebDriver + class BiDi + class Headers < Hash + def as_json + map do |name, val| + { + name: name.to_s, + value: { + type: 'string', + value: val.to_s + } + } + end + end + end + end # BiDi + end # WebDriver +end # Selenium diff --git a/rb/lib/selenium/webdriver/bidi/network/intercepted_auth.rb b/rb/lib/selenium/webdriver/bidi/network/intercepted_auth.rb new file mode 100644 index 0000000000000..71c5d7bcac785 --- /dev/null +++ b/rb/lib/selenium/webdriver/bidi/network/intercepted_auth.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +# Licensed to the Software Freedom Conservancy (SFC) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The SFC licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +module Selenium + module WebDriver + class BiDi + class InterceptedAuth < InterceptedItem + def authenticate(username, password) + network.continue_with_auth(id, username, password) + end + + def skip + network.continue_without_auth(id) + end + + def cancel + network.cancel_auth(id) + end + end + end # BiDi + end # WebDriver +end # Selenium diff --git a/rb/lib/selenium/webdriver/bidi/network/intercepted_item.rb b/rb/lib/selenium/webdriver/bidi/network/intercepted_item.rb new file mode 100644 index 0000000000000..1f7445ebbe78b --- /dev/null +++ b/rb/lib/selenium/webdriver/bidi/network/intercepted_item.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +# Licensed to the Software Freedom Conservancy (SFC) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The SFC licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +module Selenium + module WebDriver + class BiDi + class InterceptedItem + attr_reader :network, :request + + def initialize(network, request) + @network = network + @request = request + end + + def id + @id ||= @request['request'] + end + end + end # BiDi + end # WebDriver +end # Selenium diff --git a/rb/lib/selenium/webdriver/bidi/network/intercepted_request.rb b/rb/lib/selenium/webdriver/bidi/network/intercepted_request.rb new file mode 100644 index 0000000000000..ad2ef94295092 --- /dev/null +++ b/rb/lib/selenium/webdriver/bidi/network/intercepted_request.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +# Licensed to the Software Freedom Conservancy (SFC) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The SFC licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +require_relative 'cookies' +require_relative 'headers' + +module Selenium + module WebDriver + class BiDi + class InterceptedRequest < InterceptedItem + attr_accessor :method, :url + attr_reader :body + + def initialize(network, request) + super + @method = nil + @url = nil + @body = nil + end + + def continue + network.continue_request( + id: id, + body: body, + cookies: cookies.as_json, + headers: headers.as_json, + method: method, + url: url + ) + end + + def fail + network.fail_request(id) + end + + def body=(value) + @body = { + type: 'string', + value: value.to_json + } + end + + def headers + @headers ||= Headers.new + end + + def cookies(cookies = {}) + @cookies ||= Cookies.new(cookies) + end + end + end # BiDi + end # WebDriver +end # Selenium diff --git a/rb/lib/selenium/webdriver/bidi/network/intercepted_response.rb b/rb/lib/selenium/webdriver/bidi/network/intercepted_response.rb new file mode 100644 index 0000000000000..08c67d9745f65 --- /dev/null +++ b/rb/lib/selenium/webdriver/bidi/network/intercepted_response.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +# Licensed to the Software Freedom Conservancy (SFC) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The SFC licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +require_relative 'credentials' +require_relative 'headers' +require_relative 'cookies' + +module Selenium + module WebDriver + class BiDi + class InterceptedResponse < InterceptedItem + attr_accessor :reason + + def initialize(network, request) + super + @reason = nil + end + + def continue + network.continue_response( + id: id, + cookies: cookies.as_json, + headers: headers.as_json, + credentials: credentials.as_json, + reason: reason + ) + end + + def credentials(username: nil, password: nil) + @credentials ||= Credentials.new(username: username, password: password) + end + + def headers + @headers ||= Headers.new + end + + def cookies(cookies = {}) + @cookies ||= Cookies.new(cookies) + end + end + end # BiDi + end # WebDriver +end # Selenium diff --git a/rb/lib/selenium/webdriver/bidi/network/url_pattern.rb b/rb/lib/selenium/webdriver/bidi/network/url_pattern.rb new file mode 100644 index 0000000000000..651d7f95471b2 --- /dev/null +++ b/rb/lib/selenium/webdriver/bidi/network/url_pattern.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +# Licensed to the Software Freedom Conservancy (SFC) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The SFC licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +require 'uri' + +module Selenium + module WebDriver + class BiDi + module UrlPattern + module_function + + def format_pattern(url_patterns, pattern_type) + case pattern_type + when :string + to_url_string_pattern(url_patterns) + when :url + to_url_pattern(url_patterns) + else + raise ArgumentError, "Unknown pattern type: #{pattern_type}" + end + end + + def to_url_pattern(*url_patterns) + url_patterns.flatten.map do |url_pattern| + uri = URI.parse(url_pattern) + + { + type: 'pattern', + protocol: uri.scheme || '', + hostname: uri.host || '', + port: uri.port.to_s || '', + pathname: uri.path || '', + search: uri.query || '' + } + end + end + + def to_url_string_pattern(*url_patterns) + url_patterns.flatten.map do |url_pattern| + { + type: 'string', + pattern: url_pattern + } + end + end + end + end # BiDi + end # WebDriver +end # Selenium diff --git a/rb/lib/selenium/webdriver/common/network.rb b/rb/lib/selenium/webdriver/common/network.rb index 91bc0525c8340..3ac30b6eb68f6 100644 --- a/rb/lib/selenium/webdriver/common/network.rb +++ b/rb/lib/selenium/webdriver/common/network.rb @@ -17,47 +17,85 @@ # specific language governing permissions and limitations # under the License. +require 'forwardable' + module Selenium module WebDriver class Network - attr_reader :callbacks + extend Forwardable + + attr_reader :callbacks, :network + alias bidi network + + def_delegators :network, :continue_with_auth, :continue_with_request, :continue_with_response def initialize(bridge) @network = BiDi::Network.new(bridge.bidi) @callbacks = {} end - def add_authentication_handler(username, password) - intercept = @network.add_intercept(phases: [BiDi::Network::PHASES[:auth_required]]) - auth_id = @network.on(:auth_required) do |event| - request_id = event['requestId'] - @network.continue_with_auth(request_id, username, password) - end - @callbacks[auth_id] = intercept + def remove_handler(id) + intercept = callbacks[id] + network.remove_intercept(intercept['intercept']) + callbacks.delete(id) + end + + def clear_handlers + callbacks.each_key { |id| remove_handler(id) } + end - auth_id + def add_authentication_handler(username = nil, password = nil, *filter, pattern_type: nil, &block) + selected_block = + if username && password + proc { |auth| auth.authenticate(username, password) } + else + block + end + + add_handler( + :auth_required, + BiDi::Network::PHASES[:auth_required], + BiDi::InterceptedAuth, + filter, + pattern_type: pattern_type, + &selected_block + ) end - def remove_handler(id) - intercept = @callbacks[id] - @network.remove_intercept(intercept['intercept']) - @callbacks.delete(id) + def add_request_handler(*filter, pattern_type: nil, &) + add_handler( + :before_request, + BiDi::Network::PHASES[:before_request], + BiDi::InterceptedRequest, + filter, + pattern_type: pattern_type, + & + ) end - def clear_handlers - @callbacks.each_key { |id| remove_handler(id) } + def add_response_handler(*filter, pattern_type: nil, &) + add_handler( + :response_started, + BiDi::Network::PHASES[:response_started], + BiDi::InterceptedResponse, + filter, + pattern_type: pattern_type, + & + ) end - def add_request_handler - intercept = @network.add_intercept(phases: [BiDi::Network::PHASES[:before_request]]) - request_id = @network.on(:before_request) do |event| - request_id = event['requestId'] - @network.continue_with_request(request_id: request_id) - end + private - @callbacks[request_id] = intercept + def add_handler(event_type, phase, intercept_type, filter, pattern_type: nil) + intercept = network.add_intercept(phases: [phase], url_patterns: [filter].flatten, pattern_type: pattern_type) + callback_id = network.on(event_type) do |event| + request = event['request'] + intercepted_item = intercept_type.new(network, request) + yield(intercepted_item) + end - request_id + callbacks[callback_id] = intercept + callback_id end end # Network end # WebDriver diff --git a/rb/sig/lib/selenium/webdriver/bidi.rbs b/rb/sig/lib/selenium/webdriver/bidi.rbs index 314dc70fdd5f3..d5c257921d144 100644 --- a/rb/sig/lib/selenium/webdriver/bidi.rbs +++ b/rb/sig/lib/selenium/webdriver/bidi.rbs @@ -7,7 +7,7 @@ module Selenium def initialize: (url: String) -> void - def add_callback: (String event) { () -> void } -> Integer + def add_callback: (String | Symbol event) { () -> void } -> Integer def close: () -> nil diff --git a/rb/sig/lib/selenium/webdriver/bidi/network.rbs b/rb/sig/lib/selenium/webdriver/bidi/network.rbs index 44645cf05d250..71d2588c65750 100644 --- a/rb/sig/lib/selenium/webdriver/bidi/network.rbs +++ b/rb/sig/lib/selenium/webdriver/bidi/network.rbs @@ -10,15 +10,23 @@ module Selenium def initialize: (BiDi bidi) -> void - def add_intercept: (?phases: Array[String], ?contexts: BrowsingContext?, ?url_patterns: untyped?) -> Hash[String, String] + def add_intercept: (?phases: Array[String], ?contexts: BrowsingContext?, ?url_patterns: String | Array[String]?) -> Hash[String, String] - def continue_with_request: -> untyped + def cancel_auth: -> Hash[nil, nil] - def remove_intercept: (String intercept) -> untyped + def continue_request: -> Hash[nil, nil] - def continue_with_auth: (String request_id, String username, String password) -> untyped + def continue_response: -> Hash[nil, nil] - def on: (Symbol event) { (?) -> untyped } -> untyped + def continue_without_auth: -> Hash[nil, nil] + + def fail_request: -> Hash[nil, nil] + + def remove_intercept: (String intercept) -> Hash[nil, nil] + + def continue_with_auth: (String request_id, String username, String password) -> Hash[nil, nil] + + def on: (Symbol event) { (?) -> untyped } -> Hash[nil, nil] end end end diff --git a/rb/sig/lib/selenium/webdriver/bidi/network/cookies.rbs b/rb/sig/lib/selenium/webdriver/bidi/network/cookies.rbs new file mode 100644 index 0000000000000..bc5ceb7f1b680 --- /dev/null +++ b/rb/sig/lib/selenium/webdriver/bidi/network/cookies.rbs @@ -0,0 +1,13 @@ +module Selenium + module WebDriver + class BiDi + class Cookies + KNOWN_KEYS: Array[Symbol] + + def initialize: (Hash[Symbol, String | Integer | bool] cookies) -> void + + def as_json: () -> Array[Hash[Symbol, untyped]] + end + end + end +end diff --git a/rb/sig/lib/selenium/webdriver/bidi/network/credentials.rbs b/rb/sig/lib/selenium/webdriver/bidi/network/credentials.rbs new file mode 100644 index 0000000000000..5f9c75340ca72 --- /dev/null +++ b/rb/sig/lib/selenium/webdriver/bidi/network/credentials.rbs @@ -0,0 +1,19 @@ +module Selenium + module WebDriver + class BiDi + class Credentials + @username: String + + @password: String + + attr_accessor username: String + + attr_accessor password: String + + def initialize: (?username: String?, ?password: String?) -> void + + def as_json: () -> Hash[Symbol, String]? + end + end + end +end diff --git a/rb/sig/lib/selenium/webdriver/bidi/network/headers.rbs b/rb/sig/lib/selenium/webdriver/bidi/network/headers.rbs new file mode 100644 index 0000000000000..987952ee9c397 --- /dev/null +++ b/rb/sig/lib/selenium/webdriver/bidi/network/headers.rbs @@ -0,0 +1,11 @@ +module Selenium + module WebDriver + class BiDi + class Headers + def initialize: () -> void + + def as_json: () -> Array[Hash[Symbol, String]] + end + end + end +end diff --git a/rb/sig/lib/selenium/webdriver/bidi/network/intercepted_auth.rbs b/rb/sig/lib/selenium/webdriver/bidi/network/intercepted_auth.rbs new file mode 100644 index 0000000000000..c7fb60df6a32e --- /dev/null +++ b/rb/sig/lib/selenium/webdriver/bidi/network/intercepted_auth.rbs @@ -0,0 +1,13 @@ +module Selenium + module WebDriver + class BiDi + class InterceptedAuth < InterceptedItem + def authenticate: (String username, String password) -> Hash[nil, nil] + + def skip: () -> Hash[nil, nil] + + def cancel: () -> Hash[nil, nil] + end + end + end +end diff --git a/rb/sig/lib/selenium/webdriver/bidi/network/intercepted_item.rbs b/rb/sig/lib/selenium/webdriver/bidi/network/intercepted_item.rbs new file mode 100644 index 0000000000000..ab73782de2d5f --- /dev/null +++ b/rb/sig/lib/selenium/webdriver/bidi/network/intercepted_item.rbs @@ -0,0 +1,21 @@ +module Selenium + module WebDriver + class BiDi + class InterceptedItem + @network: Network + + @request: untyped + + @id: Integer + + attr_reader network: Network + + attr_reader request: untyped + + def initialize: (Network network, Hash[untyped, untyped] request) -> void + + def id: () -> Integer + end + end + end +end diff --git a/rb/sig/lib/selenium/webdriver/bidi/network/intercepted_request.rbs b/rb/sig/lib/selenium/webdriver/bidi/network/intercepted_request.rbs new file mode 100644 index 0000000000000..04cecb59457dc --- /dev/null +++ b/rb/sig/lib/selenium/webdriver/bidi/network/intercepted_request.rbs @@ -0,0 +1,35 @@ +module Selenium + module WebDriver + class BiDi + class InterceptedRequest < InterceptedItem + @method: String + + @url: String + + @body: Hash[untyped, untyped] + + @headers: Hash[untyped, untyped] + + @cookies: Hash[untyped, untyped] + + attr_accessor method: String + + attr_accessor url: String + + attr_reader body: Hash[untyped, untyped] + + def initialize: (Network network, Hash[untyped, untyped] request) -> void + + def continue: () -> Hash[nil, nil] + + def fail: () -> Hash[nil, nil] + + def body=: (Hash[untyped, untyped] value) -> Hash[untyped, untyped] + + def headers: () -> Hash[untyped, untyped] + + def cookies: () -> Hash[untyped, untyped] + end + end + end +end diff --git a/rb/sig/lib/selenium/webdriver/bidi/network/intercepted_response.rbs b/rb/sig/lib/selenium/webdriver/bidi/network/intercepted_response.rbs new file mode 100644 index 0000000000000..ecba7f2bac96f --- /dev/null +++ b/rb/sig/lib/selenium/webdriver/bidi/network/intercepted_response.rbs @@ -0,0 +1,27 @@ +module Selenium + module WebDriver + class BiDi + class InterceptedResponse < InterceptedItem + @cookies: Hash[Symbol, String | Integer]? + + @reason: String + + @credentials: untyped + + @headers: untyped + + attr_accessor reason: String + + def initialize: (untyped network, untyped request) -> void + + def continue: () -> untyped + + def cookies:(?Hash[Symbol, String | Integer]? set_cookie_headers) -> untyped + + def credentials: (?username: untyped?, ?password: untyped?) -> untyped + + def headers: () -> untyped + end + end + end +end diff --git a/rb/sig/lib/selenium/webdriver/bidi/network/set_cookie_headers.rbs b/rb/sig/lib/selenium/webdriver/bidi/network/set_cookie_headers.rbs new file mode 100644 index 0000000000000..7d7ce657d33ac --- /dev/null +++ b/rb/sig/lib/selenium/webdriver/bidi/network/set_cookie_headers.rbs @@ -0,0 +1,11 @@ +module Selenium + module WebDriver + class BiDi + class SetCookieHeaders + def initialize: (Hash[Symbol, untyped] set_cookie_headers) -> void + + def as_json: () -> Array[Hash[Symbol, untyped]] + end + end + end +end diff --git a/rb/sig/lib/selenium/webdriver/bidi/network/url_pattern.rbs b/rb/sig/lib/selenium/webdriver/bidi/network/url_pattern.rbs new file mode 100644 index 0000000000000..57353479f69f7 --- /dev/null +++ b/rb/sig/lib/selenium/webdriver/bidi/network/url_pattern.rbs @@ -0,0 +1,13 @@ +module Selenium + module WebDriver + class BiDi + module UrlPattern + def self?.format_pattern: (Array[String] | String url_patterns, Symbol pattern_type) -> Array[Hash[Symbol, String]] + + def self?.to_url_pattern: (*String url_patterns) -> Array[Hash[Symbol, String]] + + def self?.to_url_string_pattern: (*String url_patterns) -> Array[Hash[Symbol, String]] + end + end + end +end diff --git a/rb/sig/lib/selenium/webdriver/bidi/session.rbs b/rb/sig/lib/selenium/webdriver/bidi/session.rbs index 99b94c52afd5b..ecd687ba8e8e6 100644 --- a/rb/sig/lib/selenium/webdriver/bidi/session.rbs +++ b/rb/sig/lib/selenium/webdriver/bidi/session.rbs @@ -10,7 +10,7 @@ module Selenium def status: () -> untyped - def subscribe: (untyped events, ?untyped? browsing_contexts) -> untyped + def subscribe: (untyped events, ?untyped? browsing_contexts) -> Hash[nil, nil] def unsubscribe: (untyped events, ?untyped? browsing_contexts) -> untyped end diff --git a/rb/sig/lib/selenium/webdriver/common/network.rbs b/rb/sig/lib/selenium/webdriver/common/network.rbs index 2ad03a748167e..193e7385439af 100644 --- a/rb/sig/lib/selenium/webdriver/common/network.rbs +++ b/rb/sig/lib/selenium/webdriver/common/network.rbs @@ -7,15 +7,25 @@ module Selenium attr_reader callbacks: Hash[String, String] + attr_reader network: BiDi::Network + + alias bidi network + def initialize: (Remote::Bridge bridge) -> void - def add_authentication_handler: (String username, String password) -> String + def remove_handler: (Numeric id) -> untyped + + def clear_handlers: () -> untyped + + def add_authentication_handler: (?String? username, ?String? password, *String filter, ?pattern_type: Symbol?) { (?) -> untyped } -> untyped + + def add_request_handler: (*String filter, ?pattern_type: Symbol?) -> Hash[String, String] - def add_request_handler: -> Integer + def add_response_handler: (*String filter, ?pattern_type: Symbol?) -> Hash[String, String] - def clear_handlers: -> Hash[nil, nil] + private - def remove_handler: (Integer id) -> nil + def add_handler: (Symbol event_type, String phase, BiDi::InterceptedRequest | BiDi::InterceptedAuth | BiDi::InterceptedResponse intercept_type, Array[String] filter, ?pattern_type: Symbol?) { (untyped) -> untyped } -> untyped end end end diff --git a/rb/spec/integration/selenium/webdriver/bidi/network_spec.rb b/rb/spec/integration/selenium/webdriver/bidi/network_spec.rb index ad75644decac6..c02eee189450d 100644 --- a/rb/spec/integration/selenium/webdriver/bidi/network_spec.rb +++ b/rb/spec/integration/selenium/webdriver/bidi/network_spec.rb @@ -32,6 +32,26 @@ class BiDi end end + it 'adds an intercept with a default pattern type' do + reset_driver!(web_socket_url: true) do |driver| + network = described_class.new(driver.bidi) + pattern = 'http://localhost:4444/formPage.html' + intercept = network.add_intercept(phases: [described_class::PHASES[:before_request]], url_patterns: pattern) + expect(intercept).not_to be_nil + end + end + + it 'adds an intercept with a url pattern' do + reset_driver!(web_socket_url: true) do |driver| + network = described_class.new(driver.bidi) + pattern = 'http://localhost:4444/formPage.html' + intercept = network.add_intercept(phases: [described_class::PHASES[:before_request]], + url_patterns: pattern, + pattern_type: :url) + expect(intercept).not_to be_nil + end + end + it 'removes an intercept' do reset_driver!(web_socket_url: true) do |driver| network = described_class.new(driver.bidi) @@ -45,9 +65,10 @@ class BiDi password = SpecSupport::RackServer::TestApp::BASIC_AUTH_CREDENTIALS.last reset_driver!(web_socket_url: true) do |driver| network = described_class.new(driver.bidi) - network.add_intercept(phases: [described_class::PHASES[:auth_required]]) + phases = [Selenium::WebDriver::BiDi::Network::PHASES[:auth_required]] + network.add_intercept(phases: phases) network.on(:auth_required) do |event| - request_id = event['requestId'] + request_id = event['request']['request'] network.continue_with_auth(request_id, username, password) end @@ -56,13 +77,67 @@ class BiDi end end - it 'continues with request' do + it 'continues without auth' do + reset_driver!(web_socket_url: true) do |driver| + network = described_class.new(driver.bidi) + network.add_intercept(phases: [described_class::PHASES[:auth_required]]) + network.on(:auth_required) do |event| + request_id = event['request']['request'] + network.continue_without_auth(request_id) + end + + expect { driver.navigate.to url_for('basicAuth') }.to raise_error(Error::WebDriverError) + end + end + + it 'cancels auth' do + reset_driver!(web_socket_url: true) do |driver| + network = described_class.new(driver.bidi) + network.add_intercept(phases: [described_class::PHASES[:auth_required]]) + network.on(:auth_required) do |event| + request_id = event['request']['request'] + network.cancel_auth(request_id) + end + + driver.navigate.to url_for('basicAuth') + expect(driver.find_element(tag_name: 'pre').text).to eq('Login please') + end + end + + it 'continues request' do reset_driver!(web_socket_url: true) do |driver| network = described_class.new(driver.bidi) network.add_intercept(phases: [described_class::PHASES[:before_request]]) network.on(:before_request) do |event| - request_id = event['requestId'] - network.continue_with_request(request_id: request_id) + request_id = event['request']['request'] + network.continue_request(id: request_id) + end + + driver.navigate.to url_for('formPage.html') + expect(driver.find_element(name: 'login')).to be_displayed + end + end + + it 'fails request' do + reset_driver!(web_socket_url: true) do |driver| + network = described_class.new(driver.bidi) + network.add_intercept(phases: [described_class::PHASES[:before_request]]) + network.on(:before_request) do |event| + request_id = event['request']['request'] + network.fail_request(request_id) + end + + expect { driver.navigate.to url_for('formPage.html') }.to raise_error(Error::WebDriverError) + end + end + + it 'continues response' do + reset_driver!(web_socket_url: true) do |driver| + network = described_class.new(driver.bidi) + network.add_intercept(phases: [described_class::PHASES[:response_started]]) + network.on(:response_started) do |event| + request_id = event['request']['request'] + network.continue_response(id: request_id) end driver.navigate.to url_for('formPage.html') diff --git a/rb/spec/integration/selenium/webdriver/network_spec.rb b/rb/spec/integration/selenium/webdriver/network_spec.rb index 2ad4bc9f5365c..3e74c678f76d9 100644 --- a/rb/spec/integration/selenium/webdriver/network_spec.rb +++ b/rb/spec/integration/selenium/webdriver/network_spec.rb @@ -30,6 +30,38 @@ module WebDriver reset_driver!(web_socket_url: true) do |driver| network = described_class.new(driver) network.add_authentication_handler(username, password) + driver.navigate.to url_for('basicAuth') + expect(driver.find_element(tag_name: 'h1').text).to eq('authorized') + expect(network.callbacks.count).to be 1 + end + end + + it 'adds an auth handler with a filter' do + reset_driver!(web_socket_url: true) do |driver| + network = described_class.new(driver) + network.add_authentication_handler(username, password, url_for('basicAuth')) + driver.navigate.to url_for('basicAuth') + expect(driver.find_element(tag_name: 'h1').text).to eq('authorized') + expect(network.callbacks.count).to be 1 + end + end + + it 'adds an auth handler with multiple filters' do + reset_driver!(web_socket_url: true) do |driver| + network = described_class.new(driver) + network.add_authentication_handler(username, password, url_for('basicAuth'), url_for('formPage.html')) + driver.navigate.to url_for('basicAuth') + expect(driver.find_element(tag_name: 'h1').text).to eq('authorized') + expect(network.callbacks.count).to be 1 + end + end + + it 'adds an auth handler with a pattern type' do + reset_driver!(web_socket_url: true) do |driver| + network = described_class.new(driver) + network.add_authentication_handler(username, password, url_for('basicAuth'), pattern_type: :url) + driver.navigate.to url_for('basicAuth') + expect(driver.find_element(tag_name: 'h1').text).to eq('authorized') expect(network.callbacks.count).to be 1 end end @@ -46,25 +78,110 @@ module WebDriver it 'clears all auth handlers' do reset_driver!(web_socket_url: true) do |driver| network = described_class.new(driver) - network.add_authentication_handler(username, password) - network.add_authentication_handler(username, password) + 2.times { network.add_authentication_handler(username, password) } network.clear_handlers expect(network.callbacks.count).to be 0 end end + it 'continues without auth' do + reset_driver!(web_socket_url: true) do |driver| + network = described_class.new(driver) + network.add_authentication_handler(&:skip) + expect { driver.navigate.to url_for('basicAuth') }.to raise_error(Error::WebDriverError) + end + end + + it 'cancels auth' do + reset_driver!(web_socket_url: true) do |driver| + network = described_class.new(driver) + network.add_authentication_handler(&:cancel) + driver.navigate.to url_for('basicAuth') + expect(driver.find_element(tag_name: 'pre').text).to eq('Login please') + end + end + it 'adds a request handler' do reset_driver!(web_socket_url: true) do |driver| network = described_class.new(driver) - network.add_request_handler + network.add_request_handler(&:continue) + driver.navigate.to url_for('formPage.html') + expect(driver.current_url).to eq(url_for('formPage.html')) + expect(network.callbacks.count).to be 1 + end + end + + it 'adds a request handler with a filter' do + reset_driver!(web_socket_url: true) do |driver| + network = described_class.new(driver) + network.add_request_handler(url_for('formPage.html'), &:continue) + driver.navigate.to url_for('formPage.html') + expect(driver.current_url).to eq(url_for('formPage.html')) + expect(network.callbacks.count).to be 1 + end + end + + it 'adds a request handler with multiple filters' do + reset_driver!(web_socket_url: true) do |driver| + network = described_class.new(driver) + network.add_request_handler(url_for('formPage.html'), url_for('basicAuth'), &:continue) + driver.navigate.to url_for('formPage.html') + expect(driver.current_url).to eq(url_for('formPage.html')) expect(network.callbacks.count).to be 1 end end + it 'adds a request handler with a pattern type' do + reset_driver!(web_socket_url: true) do |driver| + network = described_class.new(driver) + network.add_request_handler(url_for('formPage.html'), pattern_type: :url, &:continue) + driver.navigate.to url_for('formPage.html') + expect(driver.current_url).to eq(url_for('formPage.html')) + expect(network.callbacks.count).to be 1 + end + end + + it 'adds a request handler with attributes' do + reset_driver!(web_socket_url: true) do |driver| + network = described_class.new(driver) + network.add_request_handler do |request| + request.method = 'GET' + request.url = url_for('formPage.html') + request.headers['foo'] = 'bar' + request.headers['baz'] = 'qux' + request.cookies({ + name: 'test', + value: 'value4', + domain: 'example.com', + path: '/path', + size: 1234, + httpOnly: true, + secure: true, + sameSite: 'Strict', + expiry: 1234 + }) + request.body = ({test: 'example'}) + request.continue + end + driver.navigate.to url_for('formPage.html') + expect(driver.current_url).to eq(url_for('formPage.html')) + expect(network.callbacks.count).to be 1 + end + end + + it 'fails a request' do + reset_driver!(web_socket_url: true) do |driver| + network = described_class.new(driver) + network.add_request_handler(&:fail) + expect(network.callbacks.count).to be 1 + expect { driver.navigate.to url_for('formPage.html') }.to raise_error(Error::WebDriverError) + end + end + it 'removes a request handler' do reset_driver!(web_socket_url: true) do |driver| network = described_class.new(driver) - id = network.add_request_handler + id = network.add_request_handler(&:continue) network.remove_handler(id) expect(network.callbacks.count).to be 0 end @@ -73,8 +190,92 @@ module WebDriver it 'clears all request handlers' do reset_driver!(web_socket_url: true) do |driver| network = described_class.new(driver) - network.add_request_handler - network.add_request_handler + 2.times { network.add_request_handler(&:continue) } + network.clear_handlers + expect(network.callbacks.count).to be 0 + end + end + + it 'adds a response handler' do + reset_driver!(web_socket_url: true) do |driver| + network = described_class.new(driver) + network.add_response_handler(&:continue) + driver.navigate.to url_for('formPage.html') + expect(driver.current_url).to eq(url_for('formPage.html')) + expect(network.callbacks.count).to be 1 + end + end + + it 'adds a response handler with a filter' do + reset_driver!(web_socket_url: true) do |driver| + network = described_class.new(driver) + network.add_response_handler(url_for('formPage.html'), &:continue) + driver.navigate.to url_for('formPage.html') + expect(driver.find_element(name: 'login')).to be_displayed + expect(network.callbacks.count).to be 1 + end + end + + it 'adds a response handler with multiple filters' do + reset_driver!(web_socket_url: true) do |driver| + network = described_class.new(driver) + network.add_response_handler(url_for('formPage.html'), url_for('basicAuth'), &:continue) + driver.navigate.to url_for('formPage.html') + expect(driver.find_element(name: 'login')).to be_displayed + expect(network.callbacks.count).to be 1 + end + end + + it 'adds a response handler with a pattern type' do + reset_driver!(web_socket_url: true) do |driver| + network = described_class.new(driver) + network.add_response_handler(url_for('formPage.html'), pattern_type: :url, &:continue) + driver.navigate.to url_for('formPage.html') + expect(driver.current_url).to eq(url_for('formPage.html')) + expect(network.callbacks.count).to be 1 + end + end + + it 'adds a response handler with attributes' do + reset_driver!(web_socket_url: true) do |driver| + network = described_class.new(driver) + network.add_response_handler do |response| + response.reason = 'OK' + response.headers['foo'] = 'bar' + response.credentials.username = 'foo' + response.credentials.password = 'bar' + response.cookies({ + name: 'foo', + domain: 'localhost', + httpOnly: true, + expiry: '1_000_000', + maxAge: 1_000, + path: '/', + sameSite: 'none', + secure: false + }) + response.continue + end + driver.navigate.to url_for('formPage.html') + expect(driver.current_url).to eq(url_for('formPage.html')) + expect(network.callbacks.count).to be 1 + end + end + + it 'removes a response handler' do + reset_driver!(web_socket_url: true) do |driver| + network = described_class.new(driver) + id = network.add_response_handler(&:continue) + network.remove_handler(id) + network.clear_handlers + expect(network.callbacks.count).to be 0 + end + end + + it 'clears all response handlers' do + reset_driver!(web_socket_url: true) do |driver| + network = described_class.new(driver) + 2.times { network.add_response_handler(&:continue) } network.clear_handlers expect(network.callbacks.count).to be 0 end diff --git a/rb/spec/unit/selenium/webdriver/bidi/cookies_spec.rb b/rb/spec/unit/selenium/webdriver/bidi/cookies_spec.rb new file mode 100644 index 0000000000000..582d946276989 --- /dev/null +++ b/rb/spec/unit/selenium/webdriver/bidi/cookies_spec.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +# Licensed to the Software Freedom Conservancy (SFC) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The SFC licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +require File.expand_path('../spec_helper', __dir__) +require File.expand_path('../../../../../lib/selenium/webdriver/bidi/network/cookies', __dir__) + +module Selenium + module WebDriver + class BiDi + describe Cookies do + it 'returns the cookies as json' do + cookies = described_class.new + cookies['key4'] = 'value4' + cookies['session_id'] = 'xyz123' + + formatted_cookies = cookies.as_json + expect(formatted_cookies).to be_an(Array) + expect(formatted_cookies.first['key4']).to eq('value4') + expect(formatted_cookies.first['session_id']).to eq('xyz123') + end + + it 'serializes the cookies needed for request' do + cookies = + described_class.new( + { + name: 'test', + value: 'value4', + domain: 'example.com', + path: '/path', + size: 1234, + httpOnly: true, + secure: true, + sameSite: 'Strict', + expiry: 1234 + } + ) + + formatted_cookies = cookies.as_json + expect(formatted_cookies).to be_an(Array) + expect(formatted_cookies.size).to eq(1) + + request_cookies = formatted_cookies.first + expect(request_cookies[:name]).to eq('test') + expect(request_cookies[:value][:type]).to eq('string') + expect(request_cookies[:value][:value]).to eq('value4') + expect(request_cookies[:domain]).to eq('example.com') + expect(request_cookies[:path]).to eq('/path') + expect(request_cookies[:expiry]).to eq(1234) + expect(request_cookies[:httpOnly]).to be(true) + expect(request_cookies[:secure]).to be(true) + expect(request_cookies[:sameSite]).to eq('Strict') + expect(request_cookies[:size]).to eq(1234) + end + + it 'serializes the cookies needed for response' do + cookies = described_class.new({ + name: 'test', + value: 'bar', + domain: 'localhost', + httpOnly: true, + expiry: '1_000_000', + maxAge: 1_000, + path: '/', + sameSite: 'lax', + secure: false + }) + + formatted_cookies = cookies.as_json + expect(formatted_cookies).to be_an(Array) + expect(formatted_cookies.size).to eq(1) + response_cookies = formatted_cookies.first + expect(response_cookies[:value][:type]).to eq('string') + expect(response_cookies[:value][:value]).to eq('bar') + expect(response_cookies[:domain]).to eq('localhost') + expect(response_cookies[:path]).to eq('/') + expect(response_cookies[:expiry]).to eq('1_000_000') + expect(response_cookies[:httpOnly]).to be(true) + expect(response_cookies[:secure]).to be(false) + expect(response_cookies[:sameSite]).to eq('lax') + expect(response_cookies[:maxAge]).to eq(1_000) + end + end + end + end +end diff --git a/rb/spec/unit/selenium/webdriver/bidi/credentials_spec.rb b/rb/spec/unit/selenium/webdriver/bidi/credentials_spec.rb new file mode 100644 index 0000000000000..3366a339f5d8e --- /dev/null +++ b/rb/spec/unit/selenium/webdriver/bidi/credentials_spec.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +# Licensed to the Software Freedom Conservancy (SFC) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The SFC licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +require File.expand_path('../spec_helper', __dir__) +require File.expand_path('../../../../../lib/selenium/webdriver/bidi/network/credentials', __dir__) + +module Selenium + module WebDriver + class BiDi + describe Credentials do + describe '#initialize' do + it 'initializes with nil username/password by default' do + creds = described_class.new + expect(creds.username).to be_nil + expect(creds.password).to be_nil + end + + it 'allows initialization with username and password' do + creds = described_class.new(username: 'alice', password: 'secret') + expect(creds.username).to eq('alice') + expect(creds.password).to eq('secret') + end + end + + describe '#username / #password' do + it 'allows setting and retrieving username' do + creds = described_class.new + creds.username = 'bob' + expect(creds.username).to eq('bob') + end + + it 'allows setting and retrieving password' do + creds = described_class.new + creds.password = 'my_password' + expect(creds.password).to eq('my_password') + end + end + + describe '#as_json' do + it 'returns nil if username is missing' do + creds = described_class.new(password: 'secret') + expect(creds.as_json).to be_nil + end + + it 'returns nil if password is missing' do + creds = described_class.new(username: 'alice') + expect(creds.as_json).to be_nil + end + + it 'returns a hash of the credentials when both username and password are present' do + creds = described_class.new(username: 'alice', password: 'secret') + formatted_creds = creds.as_json + + expect(formatted_creds).to eq( + type: 'password', + username: 'alice', + password: 'secret' + ) + end + end + end + end + end +end diff --git a/rb/spec/unit/selenium/webdriver/bidi/headers_spec.rb b/rb/spec/unit/selenium/webdriver/bidi/headers_spec.rb new file mode 100644 index 0000000000000..c749189851748 --- /dev/null +++ b/rb/spec/unit/selenium/webdriver/bidi/headers_spec.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +# Licensed to the Software Freedom Conservancy (SFC) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The SFC licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +require File.expand_path('../spec_helper', __dir__) +require File.expand_path('../../../../../lib/selenium/webdriver/bidi/network/headers', __dir__) + +module Selenium + module WebDriver + class BiDi + describe Headers do + let(:headers) { described_class.new } + + it 'returns headers formatted as json' do + headers['Accept'] = 'application/json' + headers['User-Agent'] = 'MyAgent/1.0' + + formatted_headers = headers.as_json + expect(formatted_headers).to be_an(Array) + expect(formatted_headers.size).to eq(2) + + accept_item = formatted_headers.find { |h| h[:name] == 'Accept' } + expect(accept_item).not_to be_nil + expect(accept_item[:value]).to eq({type: 'string', value: 'application/json'}) + + ua_item = formatted_headers.find { |h| h[:name] == 'User-Agent' } + expect(ua_item).not_to be_nil + expect(ua_item[:value]).to eq({type: 'string', value: 'MyAgent/1.0'}) + end + end + end + end +end