async-webdriver 0.1.2 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (51) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/lib/async/webdriver/bridge/chrome.rb +81 -0
  4. data/lib/async/webdriver/bridge/firefox.rb +80 -0
  5. data/lib/async/webdriver/bridge/generic.rb +91 -0
  6. data/lib/async/webdriver/bridge/pool.rb +99 -0
  7. data/lib/async/webdriver/bridge/process_group.rb +77 -0
  8. data/lib/async/webdriver/bridge.rb +30 -0
  9. data/lib/async/webdriver/client.rb +71 -26
  10. data/lib/async/webdriver/element.rb +270 -17
  11. data/lib/async/webdriver/error.rb +214 -0
  12. data/lib/async/webdriver/locator.rb +127 -0
  13. data/lib/async/webdriver/request_helper.rb +120 -0
  14. data/lib/async/webdriver/scope/alerts.rb +40 -0
  15. data/lib/async/webdriver/scope/cookies.rb +43 -0
  16. data/lib/async/webdriver/scope/document.rb +41 -0
  17. data/lib/async/webdriver/scope/elements.rb +111 -0
  18. data/lib/async/webdriver/scope/fields.rb +66 -0
  19. data/lib/async/webdriver/scope/frames.rb +33 -0
  20. data/lib/async/webdriver/scope/navigation.rb +50 -0
  21. data/lib/async/webdriver/scope/printing.rb +22 -0
  22. data/lib/async/webdriver/scope/screen_capture.rb +23 -0
  23. data/lib/async/webdriver/scope/timeouts.rb +63 -0
  24. data/lib/async/webdriver/scope.rb +15 -0
  25. data/lib/async/webdriver/session.rb +107 -65
  26. data/lib/async/webdriver/version.rb +8 -3
  27. data/lib/async/webdriver/xpath.rb +29 -0
  28. data/lib/async/webdriver.rb +7 -12
  29. data/{LICENSE.txt → license.md} +6 -6
  30. data/readme.md +37 -0
  31. data.tar.gz.sig +0 -0
  32. metadata +71 -149
  33. metadata.gz.sig +0 -0
  34. data/.gitignore +0 -11
  35. data/.rspec +0 -3
  36. data/.ruby-gemset +0 -1
  37. data/.ruby-version +0 -1
  38. data/.travis.yml +0 -7
  39. data/Gemfile +0 -6
  40. data/Gemfile.lock +0 -103
  41. data/Guardfile +0 -45
  42. data/README.md +0 -3
  43. data/Rakefile +0 -6
  44. data/async-webdriver.gemspec +0 -37
  45. data/bin/console +0 -12
  46. data/bin/setup +0 -8
  47. data/examples/multiple_sessions.rb +0 -29
  48. data/lib/async/webdriver/connection.rb +0 -78
  49. data/lib/async/webdriver/connection_path.rb +0 -25
  50. data/lib/async/webdriver/execute.rb +0 -29
  51. data/lib/async/webdriver/session_creator.rb +0 -22
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fab04165eba20d959a8d0016d33475e838f0b3d30f54ddc15827f9153cbbfecb
4
- data.tar.gz: dc3933f0abe24f936993eb13e1531ab74e3b94642c8c1e8f35e4570d4a2574ea
3
+ metadata.gz: ee23c04114676fa39b6838b560ad843f0d011e8c89ac1de1038c66c2f7bd5e26
4
+ data.tar.gz: aa95efc428c3cdfcb362c8a9f6fbc80aaa4cec6258630adb2f057479ba051537
5
5
  SHA512:
6
- metadata.gz: b3b3ba2ab5a423e5935643ed2d579f62ad5b20b571bc0364e65d72b37cc6820d5f14a3df00eb103b99ce2b2a02cb10ecdf8ee5767304658bf30b746dd768b105
7
- data.tar.gz: b6466ab0b8b85d0b5b270789aa414c8d34d609d5f9c7e25a89b6986aa00c48bad6587b4c22f7d7a3a9893c8d416de560994e35f1f3786fe58945b3d78b18c1ae
6
+ metadata.gz: bf32864d5c72d2529151f4de5f1f72d6e4a5a3731d0598e49df758d97371f176ec34504d9922d6f56499855c9d21726fb7fba641b08c19f2ae084960a35ec90f
7
+ data.tar.gz: 67f0fd5bdba22e79cba73c2db37e69da65ddc3dc22a8603584a59d1cae158902ac1ca501a7d80304683e56f831aa621ca852acd3b31079c4c7a2b19d2a36711c
checksums.yaml.gz.sig ADDED
Binary file
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2023, by Samuel Williams.
5
+
6
+ require_relative 'generic'
7
+ require_relative 'process_group'
8
+
9
+ module Async
10
+ module WebDriver
11
+ module Bridge
12
+ # A bridge to the Chrome browser using `chromedriver`.
13
+ #
14
+ # ``` ruby
15
+ # begin
16
+ # bridge = Async::WebDriver::Bridge::Chrome.start
17
+ # client = Async::WebDriver::Client.open(bridge.endpoint)
18
+ # ensure
19
+ # bridge&.close
20
+ # end
21
+ # ```
22
+ class Chrome < Generic
23
+ # Create a new bridge to Chrome.
24
+ # @parameter path [String] The path to the `chromedriver` executable.
25
+ def initialize(path: "chromedriver")
26
+ super()
27
+
28
+ @path = path
29
+ @process = nil
30
+ end
31
+
32
+ # @returns [String] The version of the `chromedriver` executable.
33
+ def version
34
+ ::IO.popen([@path, "--version"]) do |io|
35
+ return io.read
36
+ end
37
+ rescue Errno::ENOENT
38
+ return nil
39
+ end
40
+
41
+ # @returns [Array(String)] The arguments to pass to the `chromedriver` executable.
42
+ def arguments
43
+ [
44
+ "--port=#{self.port}",
45
+ ].compact
46
+ end
47
+
48
+ # Start the driver.
49
+ def start
50
+ @process ||= ProcessGroup.spawn(@path, *arguments)
51
+
52
+ super
53
+ end
54
+
55
+ # Close the driver.
56
+ def close
57
+ super
58
+
59
+ if @process
60
+ @process.close
61
+ @process = nil
62
+ end
63
+ end
64
+
65
+ # The default capabilities for the Chrome browser which need to be provided when requesting a new session.
66
+ # @parameter headless [Boolean] Whether to run the browser in headless mode.
67
+ # @returns [Hash] The default capabilities for the Chrome browser.
68
+ def default_capabilities(headless: true)
69
+ {
70
+ alwaysMatch: {
71
+ browserName: "chrome",
72
+ "goog:chromeOptions": {
73
+ args: [headless ? "--headless" : nil].compact,
74
+ }
75
+ },
76
+ }
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2023, by Samuel Williams.
5
+
6
+ require_relative 'generic'
7
+ require_relative 'process_group'
8
+
9
+ module Async
10
+ module WebDriver
11
+ module Bridge
12
+ # A bridge to the Firefox browser using `geckodriver`.
13
+ #
14
+ # ``` ruby
15
+ # begin
16
+ # bridge = Async::WebDriver::Bridge::Firefox.start
17
+ # client = Async::WebDriver::Client.open(bridge.endpoint)
18
+ # ensure
19
+ # bridge&.close
20
+ # end
21
+ class Firefox < Generic
22
+ # Create a new bridge to Firefox.
23
+ # @parameter path [String] The path to the `geckodriver` executable.
24
+ def initialize(path: "geckodriver")
25
+ super()
26
+
27
+ @path = path
28
+ @process = nil
29
+ end
30
+
31
+ # @returns [String] The version of the `geckodriver` executable.
32
+ def version
33
+ ::IO.popen([@path, "--version"]) do |io|
34
+ return io.read
35
+ end
36
+ rescue Errno::ENOENT
37
+ return nil
38
+ end
39
+
40
+ # @returns [Array(String)] The arguments to pass to the `geckodriver` executable.
41
+ def arguments
42
+ [
43
+ "--port", self.port.to_s,
44
+ ].compact
45
+ end
46
+
47
+ # Start the driver.
48
+ def start
49
+ @process ||= ProcessGroup.spawn(@path, *arguments)
50
+
51
+ super
52
+ end
53
+
54
+ # Close the driver.
55
+ def close
56
+ super
57
+
58
+ if @process
59
+ @process.close
60
+ @process = nil
61
+ end
62
+ end
63
+
64
+ # The default capabilities for the Firefox browser which need to be provided when requesting a new session.
65
+ # @parameter headless [Boolean] Whether to run the browser in headless mode.
66
+ # @returns [Hash] The default capabilities for the Firefox browser.
67
+ def default_capabilities(headless: true)
68
+ {
69
+ alwaysMatch: {
70
+ browserName: "firefox",
71
+ "moz:firefoxOptions": {
72
+ "args": [headless ? "-headless" : nil].compact,
73
+ }
74
+ }
75
+ }
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2023, by Samuel Williams.
5
+
6
+ require 'socket'
7
+ require 'async/http/endpoint'
8
+ require 'async/http/client'
9
+
10
+ module Async
11
+ module WebDriver
12
+ module Bridge
13
+ # Generic W3C WebDriver implementation.
14
+ class Generic
15
+ # Start the driver and return a new instance.
16
+ def self.start(**options)
17
+ self.new(**options).tap do |bridge|
18
+ bridge.start
19
+ end
20
+ end
21
+
22
+ # Initialize the driver.
23
+ # @parameter port [Integer] The port to listen on.
24
+ def initialize(port: nil)
25
+ @port = port
26
+ @status = nil
27
+ end
28
+
29
+ # @attribute [String] The status of the driver after a connection has been established.
30
+ attr :status
31
+
32
+ # @returns [String | Nil] The version of the driver.
33
+ def version
34
+ nil
35
+ end
36
+
37
+ # @returns [Boolean] Is the driver supported/working?
38
+ def supported?
39
+ version != nil
40
+ end
41
+
42
+ # Start the driver.
43
+ # @parameter retries [Integer] The number of times to retry before giving up.
44
+ def start(retries: 100)
45
+ Console.debug(self, "Waiting for driver to start...")
46
+ count = 0
47
+
48
+ Async::HTTP::Client.open(endpoint) do |client|
49
+ begin
50
+ response = client.get("/status")
51
+ @status = JSON.parse(response.read)["value"]
52
+ Console.debug(self, "Successfully connected to driver.", status: @status)
53
+ rescue Errno::ECONNREFUSED
54
+ if count < retries
55
+ count += 1
56
+ sleep(0.001 * count)
57
+ Console.debug(self, "Driver not ready, retrying...")
58
+ retry
59
+ else
60
+ raise
61
+ end
62
+ end
63
+ end
64
+ end
65
+
66
+ # Close the driver and any associated resources.
67
+ def close
68
+ end
69
+
70
+ # Generate a port number for the driver to listen on if it was not specified.
71
+ # @returns [Integer] The port the driver is listening on.
72
+ def port
73
+ unless @port
74
+ address = ::Addrinfo.tcp("localhost", 0)
75
+ address.bind do |socket|
76
+ # We assume that it's unlikely the port will be reused any time soon...
77
+ @port = socket.local_address.ip_port
78
+ end
79
+ end
80
+
81
+ return @port
82
+ end
83
+
84
+ # @returns [Async::HTTP::Endpoint] The endpoint the driver is listening on.
85
+ def endpoint
86
+ Async::HTTP::Endpoint.parse("http://localhost:#{port}")
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2023, by Samuel Williams.
5
+
6
+ module Async
7
+ module WebDriver
8
+ module Bridge
9
+ # A pool of sessions.
10
+ #
11
+ # ``` ruby
12
+ # begin
13
+ # bridge = Async::WebDriver::Bridge::Pool.start(Async::WebDriver::Bridge::Chrome.new)
14
+ # session = bridge.session
15
+ # ensure
16
+ # bridge&.close
17
+ # end
18
+ # ```
19
+ class Pool
20
+ # Create a new session pool and start it.
21
+ # @parameter bridge [Bridge] The bridge to use to create sessions.
22
+ # @parameter capabilities [Hash] The capabilities to use when creating sessions.
23
+ # @returns [Pool] The pool.
24
+ def self.start(bridge, **options)
25
+ self.new(bridge, **options).tap do |pool|
26
+ pool.start
27
+ end
28
+ end
29
+
30
+ # Initialize the session pool.
31
+ # @parameter bridge [Bridge] The bridge to use to create sessions.
32
+ # @parameter capabilities [Hash] The capabilities to use when creating sessions.
33
+ # @parameter minimum [Integer] The minimum number of sessions to keep open.
34
+ def initialize(bridge, capabilities: bridge.default_capabilities, minimum: 2)
35
+ @bridge = bridge
36
+ @capabilities = capabilities
37
+ @minimum = minimum
38
+
39
+ @thread = nil
40
+
41
+ @waiting = Thread::Queue.new
42
+ @sessions = Thread::Queue.new
43
+ end
44
+
45
+ # Close the session pool.
46
+ def close
47
+ if @waiting
48
+ @waiting.close
49
+ end
50
+
51
+ if @thread
52
+ @thread.join
53
+ @thread = nil
54
+ end
55
+
56
+ if @sessions
57
+ @sessions.close
58
+ end
59
+ end
60
+
61
+ private def prepare_session(client)
62
+ client.post("session", {capabilities: @capabilities})
63
+ end
64
+
65
+ # Start the session pool.
66
+ def start
67
+ @thread ||= Thread.new do
68
+ Sync do
69
+ @bridge.start
70
+
71
+ client = Client.open(@bridge.endpoint)
72
+
73
+ @minimum.times do
74
+ @waiting << true
75
+ end
76
+
77
+ while @waiting.pop
78
+ session = prepare_session(client)
79
+ @sessions << session
80
+ end
81
+ ensure
82
+ client&.close
83
+ @bridge.close
84
+ end
85
+ end
86
+ end
87
+
88
+ # Open a session.
89
+ def session(&block)
90
+ @waiting << true
91
+
92
+ reply = @sessions.pop
93
+
94
+ Session.open(@bridge.endpoint, reply["sessionId"], reply["capabilities"], &block)
95
+ end
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2023, by Samuel Williams.
5
+
6
+ module Async
7
+ module WebDriver
8
+ module Bridge
9
+ # A group of processes that are all killed when the group is closed.
10
+ class ProcessGroup
11
+ # Spawn a new process group with a given command.
12
+ # @parameter arguments [Array] The command to execute.
13
+ def self.spawn(*arguments)
14
+ # This might be problematic...
15
+ self.new(
16
+ ::Process.spawn(*arguments, pgroup: true, out: File::NULL, err: File::NULL)
17
+ )
18
+ end
19
+
20
+ # Create a new process group from an existing process id.
21
+ # @parameter pid [Integer] The process id.
22
+ def initialize(pid)
23
+ @pid = pid
24
+
25
+ @status_task = Async(transient: true) do
26
+ @status = ::Process.wait(@pid)
27
+
28
+ unless @status.success?
29
+ Console.error(self, "Process exited unexpectedly: #{@status}")
30
+ end
31
+ ensure
32
+ self.close
33
+ end
34
+ end
35
+
36
+ # Close the process group.
37
+ def close
38
+ if @status_task
39
+ @status_task.stop
40
+ @status_task = nil
41
+ end
42
+
43
+ if @pid
44
+ ::Process.kill("INT", -@pid)
45
+
46
+ Async do |task|
47
+ task.with_timeout(1) do
48
+ ::Process.wait(@pid)
49
+ rescue Errno::ECHILD
50
+ # Done.
51
+ rescue Async::TimeoutError
52
+ Console.info(self, "Killing pid #{@pid}...")
53
+ ::Process.kill("KILL", -@pid)
54
+ end
55
+ end.wait
56
+
57
+ wait_all(-@pid)
58
+ @pid = nil
59
+ end
60
+ end
61
+
62
+ protected
63
+
64
+ # Wait for all processes in the group to exit.
65
+ def wait_all(pgid)
66
+ while true
67
+ pid, status = ::Process.wait2(pgid, ::Process::WNOHANG)
68
+
69
+ break unless pid
70
+ end
71
+ rescue Errno::ECHILD
72
+ # Done.
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2023, by Samuel Williams.
5
+
6
+ require_relative 'bridge/chrome'
7
+ require_relative 'bridge/firefox'
8
+
9
+ module Async
10
+ module WebDriver
11
+ # A bridge is a process that can be used to communicate with a browser.
12
+ # It is not needed in all cases, but is useful when you want to run integration tests without any external drivers/dependencies.
13
+ # As starting a bridge can be slow, it is recommended to use a shared bridge when possible.
14
+ module Bridge
15
+ ALL = [
16
+ Bridge::Chrome,
17
+ Bridge::Firefox,
18
+ ]
19
+
20
+ def self.each(&block)
21
+ return enum_for(:each) unless block_given?
22
+
23
+ ALL.each do |klass|
24
+ next unless klass.new.supported?
25
+ yield klass
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -1,30 +1,75 @@
1
- require "async/queue"
1
+ # frozen_string_literal: true
2
2
 
3
- module Async
4
- module Webdriver
5
- class Client
6
- def initialize(endpoint:, desired_capabilities: {})
7
- @connection = Connection.new endpoint: endpoint
8
- @desired_capabilities = desired_capabilities
9
- end
10
-
11
- def session
12
- SessionCreator.new connection: @connection, desired_capabilities: @desired_capabilities
13
- end
3
+ # Released under the MIT License.
4
+ # Copyright, 2023, by Samuel Williams.
14
5
 
15
- def status
16
- @connection.call method: :get, path: "status"
17
- end
6
+ require_relative 'request_helper'
7
+ require_relative 'session'
18
8
 
19
- def sessions
20
- value = @connection.call method: :get, path: "sessions"
21
- list = []
22
- for json in value do
23
- list << Session.new(json: json, connection: @connection)
24
- end
25
- list
26
- end
27
-
28
- end
29
- end
9
+ module Async
10
+ module WebDriver
11
+ # A client for the WebDriver protocol.
12
+ #
13
+ # If you have a running web driver server, you can connect to it like so (assuming it is running on port 4444):
14
+ #
15
+ # ``` ruby
16
+ # begin
17
+ # client = Async::WebDriver::Client.open(Async::HTTP::Endpoint.parse("http://localhost:4444"))
18
+ # session = client.session
19
+ # ensure
20
+ # client&.close
21
+ # end
22
+ # ```
23
+ class Client
24
+ include RequestHelper
25
+
26
+ # Open a new session.
27
+ # @parameter endpoint [Async::HTTP::Endpoint] The endpoint to connect to.
28
+ # @yields {|client| ...} The client will be closed automatically if you provide a block.
29
+ # @parameter client [Client] The client.
30
+ # @returns [Client] The client if no block is given.
31
+ def self.open(endpoint, **options)
32
+ client = self.new(
33
+ Async::HTTP::Client.open(endpoint, **options)
34
+ )
35
+
36
+ return client unless block_given?
37
+
38
+ begin
39
+ yield client
40
+ ensure
41
+ client.close
42
+ end
43
+ end
44
+
45
+ # Initialize the client.
46
+ # @parameter delegate [Protocol::HTTP::Middleware] The underlying HTTP client (or wrapper).
47
+ def initialize(delegate)
48
+ @delegate = delegate
49
+ end
50
+
51
+ # Close the client.
52
+ def close
53
+ @delegate.close
54
+ end
55
+
56
+ # Request a new session.
57
+ # @returns [Session] The session if no block is given.
58
+ # @yields {|session| ...} The session will be closed automatically if you provide a block.
59
+ # @parameter session [Session] The session.
60
+ def session(capabilities, &block)
61
+ reply = post("session", {capabilities: capabilities})
62
+
63
+ session = Session.new(@delegate, reply["sessionId"], reply["value"])
64
+
65
+ return session unless block_given?
66
+
67
+ begin
68
+ yield session
69
+ ensure
70
+ session.close
71
+ end
72
+ end
73
+ end
74
+ end
30
75
  end