async-webdriver 0.1.2 → 0.2.0

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.
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