async-webdriver 0.9.1 → 0.11.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.
- checksums.yaml +4 -4
- checksums.yaml.gz.sig +0 -0
- data/context/index.yaml +4 -0
- data/context/navigation-timing.md +157 -0
- data/lib/async/webdriver/bridge/chrome.rb +7 -1
- data/lib/async/webdriver/bridge/driver.rb +10 -0
- data/lib/async/webdriver/bridge/firefox.rb +7 -0
- data/lib/async/webdriver/bridge/generic.rb +3 -0
- data/lib/async/webdriver/bridge/pool.rb +30 -0
- data/lib/async/webdriver/bridge/process_group.rb +5 -0
- data/lib/async/webdriver/bridge/safari.rb +6 -0
- data/lib/async/webdriver/bridge.rb +4 -0
- data/lib/async/webdriver/element.rb +2 -0
- data/lib/async/webdriver/error.rb +1 -0
- data/lib/async/webdriver/scope/navigation.rb +33 -0
- data/lib/async/webdriver/scope/printing.rb +37 -5
- data/lib/async/webdriver/scope/window.rb +50 -0
- data/lib/async/webdriver/scope.rb +9 -0
- data/lib/async/webdriver/session.rb +2 -0
- data/lib/async/webdriver/version.rb +3 -1
- data/readme.md +27 -0
- data/releases.md +9 -0
- data.tar.gz.sig +0 -0
- metadata +5 -3
- metadata.gz.sig +0 -0
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9a70af1593c116227ab4863c4e209bb4002faf9bdfe18e7183b0656811b83066
|
|
4
|
+
data.tar.gz: 64ca1a5125e4a8289f2d55b6a6fe8b6e8579ae1b0b9ffd139bcc407d22a4702a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e9770cacf231cf999256001afb4e53a6c8d2dbbe5ea34f9d67974651ba8c2d04ff7ce0984aa083dfe15895d63ec7c2265de7d6d919e9571a4e7cd11a8f65cc6a
|
|
7
|
+
data.tar.gz: f775e069001ebbf3a52a4d909e8734242c039410c218142c44cd1973921af1f6490098debd3be854dbd214e017908e277d4a6a5f6c1585393254cacce3992d97
|
checksums.yaml.gz.sig
CHANGED
|
Binary file
|
data/context/index.yaml
CHANGED
|
@@ -15,6 +15,10 @@ files:
|
|
|
15
15
|
title: Debugging
|
|
16
16
|
description: This guide explains how to debug WebDriver issues by capturing HTML
|
|
17
17
|
source and screenshots when tests fail.
|
|
18
|
+
- path: navigation-timing.md
|
|
19
|
+
title: Navigation Timing
|
|
20
|
+
description: This guide explains how to avoid race conditions when triggering navigation
|
|
21
|
+
operations while browser navigation is already in progress.
|
|
18
22
|
- path: github-actions-integration.md
|
|
19
23
|
title: GitHub Actions Integrations
|
|
20
24
|
description: This guide explains how to use `async-webdriver` with GitHub Actions.
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# Navigation Timing
|
|
2
|
+
|
|
3
|
+
This guide explains how to avoid race conditions when triggering navigation operations while browser navigation is already in progress.
|
|
4
|
+
|
|
5
|
+
## The Problem
|
|
6
|
+
|
|
7
|
+
When you trigger navigation in a browser (form submission, link clicks), the browser starts a complex process that takes time. If you call `navigate_to` while this process is still running, it will interrupt the ongoing navigation, potentially causing:
|
|
8
|
+
|
|
9
|
+
- Server-side effects (like setting session cookies) to not complete.
|
|
10
|
+
- The intended navigation to never finish.
|
|
11
|
+
- Your test to end up in an unexpected state.
|
|
12
|
+
|
|
13
|
+
## Understanding the Race Condition
|
|
14
|
+
|
|
15
|
+
When you trigger navigation (form submission, link clicks), the browser starts a process:
|
|
16
|
+
|
|
17
|
+
1. **Submit Request**: Form data is sent to the server.
|
|
18
|
+
2. **Server Processing**: Server handles the request (authentication, validation, etc.).
|
|
19
|
+
3. **Response**: Server sends back response (redirect, new page, etc.).
|
|
20
|
+
4. **Browser Navigation**: Browser processes the response and updates the page.
|
|
21
|
+
5. **Page Load**: New page loads and `document.readyState` becomes "complete".
|
|
22
|
+
|
|
23
|
+
If any navigation operation is triggered during steps 1-4, it **interrupts** this process:
|
|
24
|
+
- Server-side effects (like setting session cookies) may not complete.
|
|
25
|
+
- The intended navigation never finishes.
|
|
26
|
+
- Your test ends up in an unexpected state.
|
|
27
|
+
|
|
28
|
+
## The Redirect Race Condition
|
|
29
|
+
|
|
30
|
+
A particularly common variant of this race condition occurs with **HTTP redirects** (302, 301, etc.). When a form submission or other action triggers a redirect:
|
|
31
|
+
|
|
32
|
+
1. **Form Submission**: Browser sends POST request to `/submit`.
|
|
33
|
+
2. **Server Response**: Server returns `302 Found` with `Location: /success` header.
|
|
34
|
+
3. **Redirect Processing**: Browser receives the 302 response (usually with empty body).
|
|
35
|
+
4. **Follow Redirect**: Browser automatically navigates to `/success`.
|
|
36
|
+
5. **Final Page Load**: Success page loads with actual content.
|
|
37
|
+
|
|
38
|
+
The race condition occurs because element-based waits can execute during step 3, when the browser has received the 302 response but hasn't yet loaded the target page:
|
|
39
|
+
|
|
40
|
+
```ruby
|
|
41
|
+
session.click_button("Submit") # Triggers POST -> 302 redirect
|
|
42
|
+
session.find_element(xpath: "//h1") # May execute on empty 302 page!
|
|
43
|
+
session.navigate_to("/other-page") # Interrupts redirect to /success
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
This explains why redirect-based workflows (login forms, contact forms, checkout processes) are particularly susceptible to race conditions.
|
|
47
|
+
|
|
48
|
+
## Problematic Code Examples
|
|
49
|
+
|
|
50
|
+
### Example 1: Login Form Race Condition
|
|
51
|
+
|
|
52
|
+
```ruby
|
|
53
|
+
# ❌ PROBLEMATIC: May interrupt login before authentication completes
|
|
54
|
+
session.click_button("Login") # Triggers form submission.
|
|
55
|
+
session.navigate_to("/dashboard") # May interrupt login process!
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
### Example 2: Form Submission Race Condition
|
|
59
|
+
|
|
60
|
+
```ruby
|
|
61
|
+
# ❌ PROBLEMATIC: May interrupt form submission
|
|
62
|
+
session.fill_in("email", "user@example.com")
|
|
63
|
+
session.click_button("Subscribe") # Triggers form submission.
|
|
64
|
+
session.navigate_to("/thank-you") # May interrupt subscription action on server!
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### Example 3: Redirect Race Condition
|
|
68
|
+
|
|
69
|
+
```ruby
|
|
70
|
+
# ❌ PROBLEMATIC: May interrupt redirect before it completes
|
|
71
|
+
session.click_button("Submit") # POST -> 302 redirect.
|
|
72
|
+
session.find_element(xpath: "//h1") # May find element on 302 page and fail.
|
|
73
|
+
session.navigate_to("/dashboard") # Interrupts redirect to success page.
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Detection and Mitigation Strategies
|
|
77
|
+
|
|
78
|
+
⚠️ **Important**: Element-based waits (`find_element`) are **insufficient** for preventing race conditions because navigation can be interrupted before target elements ever appear on the page.
|
|
79
|
+
|
|
80
|
+
### Reliable Strategy: Use `wait_for_navigation`
|
|
81
|
+
|
|
82
|
+
The most reliable approach is to use `wait_for_navigation` to wait for the URL or page state to change:
|
|
83
|
+
|
|
84
|
+
```ruby
|
|
85
|
+
# ✅ RELIABLE: Wait for URL change
|
|
86
|
+
session.click_button("Submit")
|
|
87
|
+
session.wait_for_navigation{|url| url.end_with?("/success")}
|
|
88
|
+
session.navigate_to("/next-page") # Now safe
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
### Alternative: Wait for Server-Side Effects
|
|
92
|
+
|
|
93
|
+
For critical operations like authentication, wait for server-side effects to complete:
|
|
94
|
+
|
|
95
|
+
```ruby
|
|
96
|
+
# ✅ RELIABLE: Wait for authentication cookie
|
|
97
|
+
session.click_button("Login")
|
|
98
|
+
session.wait_for_navigation do |url, ready_state|
|
|
99
|
+
ready_state == "complete" && session.cookies.any?{|cookie| cookie["name"] == "auth_token"}
|
|
100
|
+
end
|
|
101
|
+
session.navigate_to("/dashboard") # Now safe
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### Unreliable Approaches (Common But Insufficient)
|
|
105
|
+
|
|
106
|
+
These approaches are commonly used but **may still allow race conditions**:
|
|
107
|
+
|
|
108
|
+
#### Element-based Waits
|
|
109
|
+
|
|
110
|
+
Unfortunately, waiting for specific elements to appear does not always work when navigation operations are in progress. This is especially problematic with redirects, where element waits can execute on the intermediate redirect response (which typically has no content) rather than the final destination page.
|
|
111
|
+
|
|
112
|
+
```ruby
|
|
113
|
+
# ❌ UNRELIABLE: Navigation can be interrupted before element appears
|
|
114
|
+
session.click_button("Submit") # Triggers POST -> 302 redirect
|
|
115
|
+
|
|
116
|
+
# In principle, wait for the form submission to complete:
|
|
117
|
+
session.find_element(xpath: "//h1[text()='Success']")
|
|
118
|
+
# However, in reality it may:
|
|
119
|
+
# 1. Execute on the 302 redirect page (empty content) and fail immediately
|
|
120
|
+
# 2. Hang if the redirect navigation is still in progress
|
|
121
|
+
# 3. Succeed by chance if the redirect has completed sufficiently
|
|
122
|
+
|
|
123
|
+
# Assuming the previous operation did not hang, this navigation may interrupt the redirect:
|
|
124
|
+
session.navigate_to("/next-page")
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
#### Generic Page Waits
|
|
128
|
+
|
|
129
|
+
```ruby
|
|
130
|
+
# ❌ UNRELIABLE: Doesn't ensure the intended navigation completed
|
|
131
|
+
session.click_button("Submit")
|
|
132
|
+
|
|
133
|
+
# This can find the wrong element on the initial page before the form submission causes a page navigation operation:
|
|
134
|
+
session.find_element(xpath: "//html")
|
|
135
|
+
|
|
136
|
+
# This navigation may interrupt the form submission:
|
|
137
|
+
session.navigate_to("/next-page")
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
These approaches fail because `navigate_to` can interrupt the ongoing navigation before the target page (and its elements) ever loads.
|
|
141
|
+
|
|
142
|
+
## Best Practices Summary
|
|
143
|
+
|
|
144
|
+
1. **Always wait** after triggering navigation before calling `navigate_to` again.
|
|
145
|
+
2. **Use `wait_for_navigation`** with URL or state conditions for reliable synchronization.
|
|
146
|
+
3. **Test for race conditions** in your test suite with deliberate delays.
|
|
147
|
+
4. **Avoid element-based waits** for navigation synchronization (they're unreliable).
|
|
148
|
+
5. **Consider server-side effects** when designing wait conditions.
|
|
149
|
+
6. **Prefer URL-based waits** over element-based waits for navigation timing.
|
|
150
|
+
|
|
151
|
+
## Common Pitfalls
|
|
152
|
+
|
|
153
|
+
- **Don't assume** `click_button` waits for navigation to complete.
|
|
154
|
+
- **Don't rely on** element-based waits (`find_element`) to prevent race conditions.
|
|
155
|
+
- **Don't use** arbitrary `sleep` calls instead of proper synchronization.
|
|
156
|
+
- **Don't ignore** server-side effects like cookie setting or session management.
|
|
157
|
+
- **Don't chain** multiple navigation operations without URL-based synchronization.
|
|
@@ -20,6 +20,7 @@ module Async
|
|
|
20
20
|
# end
|
|
21
21
|
# ```
|
|
22
22
|
class Chrome < Generic
|
|
23
|
+
# @returns [String] The path to the `chromedriver` executable.
|
|
23
24
|
def path
|
|
24
25
|
@options.fetch(:path, "chromedriver")
|
|
25
26
|
end
|
|
@@ -33,7 +34,10 @@ module Async
|
|
|
33
34
|
return nil
|
|
34
35
|
end
|
|
35
36
|
|
|
37
|
+
# A locally managed `chromedriver` process.
|
|
36
38
|
class Driver < Bridge::Driver
|
|
39
|
+
# Initialize a managed Chrome driver process.
|
|
40
|
+
# @parameter options [Hash] Driver configuration options.
|
|
37
41
|
def initialize(**options)
|
|
38
42
|
super(**options)
|
|
39
43
|
@process_group = nil
|
|
@@ -47,12 +51,14 @@ module Async
|
|
|
47
51
|
].compact
|
|
48
52
|
end
|
|
49
53
|
|
|
54
|
+
# Start the managed Chrome driver process and wait for readiness.
|
|
50
55
|
def start
|
|
51
56
|
@process_group = ProcessGroup.spawn(*arguments(**@options))
|
|
52
57
|
|
|
53
58
|
super
|
|
54
59
|
end
|
|
55
60
|
|
|
61
|
+
# Stop the managed Chrome driver process.
|
|
56
62
|
def close
|
|
57
63
|
if @process_group
|
|
58
64
|
@process_group.close
|
|
@@ -76,7 +82,7 @@ module Async
|
|
|
76
82
|
alwaysMatch: {
|
|
77
83
|
browserName: "chrome",
|
|
78
84
|
"goog:chromeOptions": {
|
|
79
|
-
args: [headless ? "--headless" : nil].compact,
|
|
85
|
+
args: [headless ? "--headless=new" : nil].compact,
|
|
80
86
|
},
|
|
81
87
|
webSocketUrl: true,
|
|
82
88
|
},
|
|
@@ -8,12 +8,15 @@ module Async
|
|
|
8
8
|
module Bridge
|
|
9
9
|
# Represents an instance of a locally running driver (usually with a process group).
|
|
10
10
|
class Driver
|
|
11
|
+
# Initialize a driver wrapper.
|
|
12
|
+
# @parameter options [Hash] Driver configuration options.
|
|
11
13
|
def initialize(**options)
|
|
12
14
|
@options = options
|
|
13
15
|
@count = 0
|
|
14
16
|
@closed = false
|
|
15
17
|
end
|
|
16
18
|
|
|
19
|
+
# @returns [Integer] The number of concurrent sessions the driver can sustain.
|
|
17
20
|
def concurrency
|
|
18
21
|
@options.fetch(:concurrency, 128)
|
|
19
22
|
end
|
|
@@ -23,18 +26,22 @@ module Async
|
|
|
23
26
|
# @attribute [Hash] The status of the driver after a connection has been established.
|
|
24
27
|
attr :status
|
|
25
28
|
|
|
29
|
+
# @returns [Boolean] Whether the driver can still be used.
|
|
26
30
|
def viable?
|
|
27
31
|
!@closed
|
|
28
32
|
end
|
|
29
33
|
|
|
34
|
+
# @returns [Boolean] Whether the driver has been closed.
|
|
30
35
|
def closed?
|
|
31
36
|
@closed
|
|
32
37
|
end
|
|
33
38
|
|
|
39
|
+
# Mark the driver as closed.
|
|
34
40
|
def close
|
|
35
41
|
@closed = true
|
|
36
42
|
end
|
|
37
43
|
|
|
44
|
+
# @returns [Boolean] Whether the driver may be returned to a pool.
|
|
38
45
|
def reusable?
|
|
39
46
|
@options.fetch(:reusable, !@closed)
|
|
40
47
|
end
|
|
@@ -50,14 +57,17 @@ module Async
|
|
|
50
57
|
end
|
|
51
58
|
end
|
|
52
59
|
|
|
60
|
+
# @returns [Integer] The port the driver listens on.
|
|
53
61
|
def port
|
|
54
62
|
@port ||= @options.fetch(:port, self.ephemeral_port)
|
|
55
63
|
end
|
|
56
64
|
|
|
65
|
+
# @returns [Async::HTTP::Endpoint] The HTTP endpoint exposed by the driver.
|
|
57
66
|
def endpoint
|
|
58
67
|
Async::HTTP::Endpoint.parse("http://localhost", port: self.port)
|
|
59
68
|
end
|
|
60
69
|
|
|
70
|
+
# @returns [Client] A client connected to the driver endpoint.
|
|
61
71
|
def client
|
|
62
72
|
Client.open(self.endpoint)
|
|
63
73
|
end
|
|
@@ -19,6 +19,7 @@ module Async
|
|
|
19
19
|
# bridge&.close
|
|
20
20
|
# end
|
|
21
21
|
class Firefox < Generic
|
|
22
|
+
# @returns [String] The path to the `geckodriver` executable.
|
|
22
23
|
def path
|
|
23
24
|
@options.fetch(:path, "geckodriver")
|
|
24
25
|
end
|
|
@@ -32,12 +33,16 @@ module Async
|
|
|
32
33
|
return nil
|
|
33
34
|
end
|
|
34
35
|
|
|
36
|
+
# A locally managed `geckodriver` process.
|
|
35
37
|
class Driver < Bridge::Driver
|
|
38
|
+
# Initialize a managed Firefox driver process.
|
|
39
|
+
# @parameter options [Hash] Driver configuration options.
|
|
36
40
|
def initialize(**options)
|
|
37
41
|
super(**options)
|
|
38
42
|
@process_group = nil
|
|
39
43
|
end
|
|
40
44
|
|
|
45
|
+
# @returns [Integer] Firefox drivers support one session at a time.
|
|
41
46
|
def concurrency
|
|
42
47
|
1
|
|
43
48
|
end
|
|
@@ -50,12 +55,14 @@ module Async
|
|
|
50
55
|
].compact
|
|
51
56
|
end
|
|
52
57
|
|
|
58
|
+
# Start the managed Firefox driver process and wait for readiness.
|
|
53
59
|
def start
|
|
54
60
|
@process_group = ProcessGroup.spawn(*arguments(**@options))
|
|
55
61
|
|
|
56
62
|
super
|
|
57
63
|
end
|
|
58
64
|
|
|
65
|
+
# Stop the managed Firefox driver process.
|
|
59
66
|
def close
|
|
60
67
|
if @process_group
|
|
61
68
|
@process_group.close
|
|
@@ -12,6 +12,8 @@ module Async
|
|
|
12
12
|
module Bridge
|
|
13
13
|
# Generic W3C WebDriver implementation.
|
|
14
14
|
class Generic
|
|
15
|
+
# Initialize a generic bridge wrapper.
|
|
16
|
+
# @parameter options [Hash] Bridge configuration options.
|
|
15
17
|
def initialize(**options)
|
|
16
18
|
@options = options
|
|
17
19
|
end
|
|
@@ -26,6 +28,7 @@ module Async
|
|
|
26
28
|
version != nil
|
|
27
29
|
end
|
|
28
30
|
|
|
31
|
+
# @returns [Boolean] Whether headless mode is enabled by default.
|
|
29
32
|
def headless?
|
|
30
33
|
@options.fetch(:headless, true)
|
|
31
34
|
end
|
|
@@ -24,14 +24,22 @@ module Async
|
|
|
24
24
|
# end
|
|
25
25
|
# ```
|
|
26
26
|
class Pool
|
|
27
|
+
# Controls pooled drivers and cached sessions.
|
|
27
28
|
class BridgeController
|
|
29
|
+
# Initialize the bridge controller.
|
|
30
|
+
# @parameter bridge [Bridge] The bridge used to create drivers.
|
|
31
|
+
# @parameter capabilities [Hash] Capabilities used for new sessions.
|
|
28
32
|
def initialize(bridge, capabilities: bridge.default_capabilities)
|
|
29
33
|
@bridge = bridge
|
|
30
34
|
@capabilities = capabilities
|
|
31
35
|
@pool = Async::Pool::Controller.new(self)
|
|
32
36
|
end
|
|
33
37
|
|
|
38
|
+
# Caches sessions created from a single driver instance.
|
|
34
39
|
class SessionCache
|
|
40
|
+
# Initialize a session cache for one driver instance.
|
|
41
|
+
# @parameter driver [Driver] The driver backing cached sessions.
|
|
42
|
+
# @parameter capabilities [Hash] Capabilities for newly created sessions.
|
|
35
43
|
def initialize(driver, capabilities)
|
|
36
44
|
@driver = driver
|
|
37
45
|
@capabilities = capabilities
|
|
@@ -40,14 +48,17 @@ module Async
|
|
|
40
48
|
@sessions = []
|
|
41
49
|
end
|
|
42
50
|
|
|
51
|
+
# @returns [Boolean] Whether the underlying driver remains usable.
|
|
43
52
|
def viable?
|
|
44
53
|
@driver&.viable?
|
|
45
54
|
end
|
|
46
55
|
|
|
56
|
+
# @returns [Boolean] Whether cached sessions may be reused.
|
|
47
57
|
def reusable?
|
|
48
58
|
@driver&.reusable?
|
|
49
59
|
end
|
|
50
60
|
|
|
61
|
+
# Close the cached sessions, driver, and HTTP client.
|
|
51
62
|
def close
|
|
52
63
|
if @driver
|
|
53
64
|
@driver.close
|
|
@@ -64,10 +75,13 @@ module Async
|
|
|
64
75
|
end
|
|
65
76
|
end
|
|
66
77
|
|
|
78
|
+
# @returns [Integer] The number of concurrently usable sessions.
|
|
67
79
|
def concurrency
|
|
68
80
|
@driver.concurrency
|
|
69
81
|
end
|
|
70
82
|
|
|
83
|
+
# Acquire a cached or newly created session payload.
|
|
84
|
+
# @returns [Hash] A WebDriver session payload.
|
|
71
85
|
def acquire
|
|
72
86
|
if @sessions.empty?
|
|
73
87
|
session = @client.post("session", {capabilities: @capabilities})
|
|
@@ -85,6 +99,8 @@ module Async
|
|
|
85
99
|
end
|
|
86
100
|
end
|
|
87
101
|
|
|
102
|
+
# Return a session payload to the cache.
|
|
103
|
+
# @parameter session [Hash] The session payload to cache.
|
|
88
104
|
def release(session)
|
|
89
105
|
@sessions.push(session)
|
|
90
106
|
end
|
|
@@ -95,12 +111,16 @@ module Async
|
|
|
95
111
|
SessionCache.new(@bridge.start, @capabilities)
|
|
96
112
|
end
|
|
97
113
|
|
|
114
|
+
# Acquire a session payload from the pool.
|
|
115
|
+
# @returns [Hash] The acquired session payload.
|
|
98
116
|
def acquire
|
|
99
117
|
session_cache = @pool.acquire
|
|
100
118
|
|
|
101
119
|
return session_cache.acquire
|
|
102
120
|
end
|
|
103
121
|
|
|
122
|
+
# Return a session payload to the pool.
|
|
123
|
+
# @parameter session [Hash] The session payload to release.
|
|
104
124
|
def release(session)
|
|
105
125
|
session_cache = session[:cache]
|
|
106
126
|
|
|
@@ -109,6 +129,8 @@ module Async
|
|
|
109
129
|
@pool.release(session_cache)
|
|
110
130
|
end
|
|
111
131
|
|
|
132
|
+
# Retire a session payload and its cache from the pool.
|
|
133
|
+
# @parameter session [Hash] The session payload to retire.
|
|
112
134
|
def retire(session)
|
|
113
135
|
session_cache = session[:cache]
|
|
114
136
|
|
|
@@ -117,6 +139,7 @@ module Async
|
|
|
117
139
|
@pool.retire(session_cache)
|
|
118
140
|
end
|
|
119
141
|
|
|
142
|
+
# Close the underlying driver pool.
|
|
120
143
|
def close
|
|
121
144
|
if @pool
|
|
122
145
|
@pool.close
|
|
@@ -136,15 +159,19 @@ module Async
|
|
|
136
159
|
@controller.close
|
|
137
160
|
end
|
|
138
161
|
|
|
162
|
+
# A pooled session wrapper that returns sessions to the cache on close.
|
|
139
163
|
class CachedWrapper < Session
|
|
164
|
+
# @returns [Pool] The pool responsible for reusing this session.
|
|
140
165
|
def pool
|
|
141
166
|
@options[:pool]
|
|
142
167
|
end
|
|
143
168
|
|
|
169
|
+
# @returns [Hash] The raw session payload returned by the bridge.
|
|
144
170
|
def payload
|
|
145
171
|
@options[:payload]
|
|
146
172
|
end
|
|
147
173
|
|
|
174
|
+
# Return the session to the pool when possible.
|
|
148
175
|
def close
|
|
149
176
|
unless self.pool.reuse(self)
|
|
150
177
|
super
|
|
@@ -167,6 +194,9 @@ module Async
|
|
|
167
194
|
end
|
|
168
195
|
end
|
|
169
196
|
|
|
197
|
+
# Reset and return a session to the pool.
|
|
198
|
+
# @parameter session [CachedWrapper] The session to reuse.
|
|
199
|
+
# @returns [Boolean] Always returns `true` once the session is released.
|
|
170
200
|
def reuse(session)
|
|
171
201
|
session.reset!
|
|
172
202
|
|
|
@@ -75,13 +75,18 @@ module Async
|
|
|
75
75
|
end
|
|
76
76
|
end
|
|
77
77
|
|
|
78
|
+
# A driver wrapper that closes an associated process handle.
|
|
78
79
|
class ProcessDriver < Driver
|
|
80
|
+
# Initialize a process-backed driver.
|
|
81
|
+
# @parameter endpoint [Object] Driver options or endpoint information.
|
|
82
|
+
# @parameter process [ProcessGroup] The managed process group.
|
|
79
83
|
def initialize(endpoint, process)
|
|
80
84
|
super(endpoint)
|
|
81
85
|
|
|
82
86
|
@process = process
|
|
83
87
|
end
|
|
84
88
|
|
|
89
|
+
# Close the driver and its process group.
|
|
85
90
|
def close
|
|
86
91
|
super
|
|
87
92
|
|
|
@@ -20,6 +20,7 @@ module Async
|
|
|
20
20
|
# end
|
|
21
21
|
# ```
|
|
22
22
|
class Safari < Generic
|
|
23
|
+
# @returns [String] The path to the `safaridriver` executable.
|
|
23
24
|
def path
|
|
24
25
|
@options.fetch(:path, "safaridriver")
|
|
25
26
|
end
|
|
@@ -33,7 +34,10 @@ module Async
|
|
|
33
34
|
return nil
|
|
34
35
|
end
|
|
35
36
|
|
|
37
|
+
# A locally managed `safaridriver` process.
|
|
36
38
|
class Driver < Bridge::Driver
|
|
39
|
+
# Initialize a managed Safari driver process.
|
|
40
|
+
# @parameter options [Hash] Driver configuration options.
|
|
37
41
|
def initialize(**options)
|
|
38
42
|
super(**options)
|
|
39
43
|
@process_group = nil
|
|
@@ -47,12 +51,14 @@ module Async
|
|
|
47
51
|
].compact
|
|
48
52
|
end
|
|
49
53
|
|
|
54
|
+
# Start the managed Safari driver process and wait for readiness.
|
|
50
55
|
def start
|
|
51
56
|
@process_group = ProcessGroup.spawn(*arguments(**@options))
|
|
52
57
|
|
|
53
58
|
super
|
|
54
59
|
end
|
|
55
60
|
|
|
61
|
+
# Stop the managed Safari driver process.
|
|
56
62
|
def close
|
|
57
63
|
if @process_group
|
|
58
64
|
@process_group.close
|
|
@@ -21,6 +21,9 @@ module Async
|
|
|
21
21
|
Bridge::Safari,
|
|
22
22
|
]
|
|
23
23
|
|
|
24
|
+
# Iterate over supported bridge implementations.
|
|
25
|
+
# @yields {|bridge| ...} Each supported bridge class.
|
|
26
|
+
# @parameter bridge [Class] A supported bridge implementation.
|
|
24
27
|
def self.each(&block)
|
|
25
28
|
return enum_for(:each) unless block_given?
|
|
26
29
|
|
|
@@ -45,6 +48,7 @@ module Async
|
|
|
45
48
|
# ```
|
|
46
49
|
ASYNC_WEBDRIVER_BRIDGE_HEADLESS = "ASYNC_WEBDRIVER_BRIDGE_HEADLESS"
|
|
47
50
|
|
|
51
|
+
# Raised when no supported bridge implementation is available.
|
|
48
52
|
class UnsupportedError < Error
|
|
49
53
|
end
|
|
50
54
|
|
|
@@ -4,11 +4,14 @@
|
|
|
4
4
|
# Copyright, 2023-2025, by Samuel Williams.
|
|
5
5
|
|
|
6
6
|
require "uri"
|
|
7
|
+
require "async/clock"
|
|
7
8
|
|
|
8
9
|
module Async
|
|
9
10
|
module WebDriver
|
|
10
11
|
module Scope
|
|
11
12
|
# Helpers for navigating the browser.
|
|
13
|
+
#
|
|
14
|
+
# ⚠️ **Important**: Navigation operations (and events that trigger navigation) may result in race conditions if not properly synchronized. Consult the "Navigation Timing" Guide in the documentation for more details.
|
|
12
15
|
module Navigation
|
|
13
16
|
# Navigate to the given URL.
|
|
14
17
|
# @parameter url [String] The URL to navigate to.
|
|
@@ -44,6 +47,36 @@ module Async
|
|
|
44
47
|
def refresh
|
|
45
48
|
session.post("refresh")
|
|
46
49
|
end
|
|
50
|
+
|
|
51
|
+
# Wait for navigation to complete with custom conditions.
|
|
52
|
+
#
|
|
53
|
+
# This method helps avoid race conditions by polling the browser state until your specified conditions are met.
|
|
54
|
+
#
|
|
55
|
+
# @parameter timeout [Float] Maximum time to wait in seconds (default: 10.0).
|
|
56
|
+
# @yields {|current_url| ...} Yields the current URL to the block, when the ready state is "complete".
|
|
57
|
+
# @yields {|current_url, ready_state| ...} Yields both the current URL and ready state to the block, allowing more complex conditions.
|
|
58
|
+
def wait_for_navigation(timeout: 10.0, &block)
|
|
59
|
+
clock = Clock.start
|
|
60
|
+
duration = [timeout / 100.0, 0.005].max
|
|
61
|
+
|
|
62
|
+
while true
|
|
63
|
+
current_url = session.current_url
|
|
64
|
+
ready_state = session.execute("return document.readyState;")
|
|
65
|
+
|
|
66
|
+
if block.arity > 1
|
|
67
|
+
break if yield(current_url, ready_state)
|
|
68
|
+
else
|
|
69
|
+
break if ready_state == "complete" && yield(current_url)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
if clock.total > timeout
|
|
73
|
+
raise TimeoutError, "Timed out waiting for navigation to complete (current_url: #{current_url}, ready_state: #{ready_state})"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
Console.debug(self, "Waiting for navigation...", ready_state: ready_state, location: current_url, elapsed: clock.total)
|
|
77
|
+
sleep(duration)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
47
80
|
end
|
|
48
81
|
end
|
|
49
82
|
end
|
|
@@ -8,13 +8,45 @@ require "base64"
|
|
|
8
8
|
module Async
|
|
9
9
|
module WebDriver
|
|
10
10
|
module Scope
|
|
11
|
-
# Helpers for
|
|
11
|
+
# Helpers for printing the current page to PDF.
|
|
12
12
|
module Printing
|
|
13
|
-
# Print the current page
|
|
14
|
-
|
|
15
|
-
|
|
13
|
+
# Print the current page as a PDF and return the raw binary data.
|
|
14
|
+
#
|
|
15
|
+
# All margin and page measurements are in centimetres. The W3C WebDriver
|
|
16
|
+
# default page size is US Letter (21.59 × 27.94 cm) with 1 cm margins.
|
|
17
|
+
#
|
|
18
|
+
# @parameter orientation [String | Nil] `"portrait"` or `"landscape"`. Default: `"portrait"`.
|
|
19
|
+
# @parameter scale [Float | Nil] Scaling factor between 0.1 and 2.0. Default: `1.0`.
|
|
20
|
+
# @parameter background [Boolean | Nil] Whether to print background graphics and colours. Default: `false`.
|
|
21
|
+
# @parameter page [Hash | Nil] Page dimensions in cm. Keys: `:width`, `:height`.
|
|
22
|
+
# @parameter margin [Hash | Nil] Page margins in cm. Keys: `:top`, `:bottom`, `:left`, `:right`.
|
|
23
|
+
# @parameter page_ranges [Array(String) | Nil] Page ranges to print, e.g. `["1-5", "8"]`. Default: all pages.
|
|
24
|
+
# @parameter shrink_to_fit [Boolean | Nil] Whether to shrink content to fit the page. Default: `true`.
|
|
25
|
+
# @returns [String] The raw PDF binary data.
|
|
26
|
+
def print(orientation: nil, scale: nil, background: nil, page: nil, margin: nil, page_ranges: nil, shrink_to_fit: nil)
|
|
27
|
+
parameters = {
|
|
28
|
+
orientation: orientation,
|
|
29
|
+
scale: scale,
|
|
30
|
+
background: background,
|
|
31
|
+
page: page,
|
|
32
|
+
margin: margin,
|
|
33
|
+
pageRanges: page_ranges,
|
|
34
|
+
shrinkToFit: shrink_to_fit,
|
|
35
|
+
}.compact
|
|
16
36
|
|
|
17
|
-
|
|
37
|
+
# Synchronise with Chrome's rendering pipeline before issuing the print
|
|
38
|
+
# command. The underlying CDP call (Page.printToPDF) is synchronous: if
|
|
39
|
+
# the renderer process has not yet fully initialised its print pipeline
|
|
40
|
+
# by the time the command arrives, Chrome returns JSON-RPC error -32000
|
|
41
|
+
# ("Printing failed") with no retry. A JavaScript round-trip forces
|
|
42
|
+
# ChromeDriver to wait for the renderer to be live (a JS execution
|
|
43
|
+
# context must exist), which also guarantees the print pipeline is ready.
|
|
44
|
+
# Without this, fast-loading pages can trigger the race intermittently.
|
|
45
|
+
session.execute("return document.readyState")
|
|
46
|
+
|
|
47
|
+
reply = session.post("print", parameters)
|
|
48
|
+
|
|
49
|
+
return Base64.decode64(reply)
|
|
18
50
|
end
|
|
19
51
|
end
|
|
20
52
|
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Released under the MIT License.
|
|
4
|
+
# Copyright, 2023-2025, by Samuel Williams.
|
|
5
|
+
|
|
6
|
+
module Async
|
|
7
|
+
module WebDriver
|
|
8
|
+
module Scope
|
|
9
|
+
# Helpers for managing the browser window size and position.
|
|
10
|
+
module Window
|
|
11
|
+
# Get the current window rect (position and size).
|
|
12
|
+
# @returns [Hash] The window rect with keys `"x"`, `"y"`, `"width"`, `"height"`.
|
|
13
|
+
def window_rect
|
|
14
|
+
session.get("window/rect")
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Set the window rect (position and/or size).
|
|
18
|
+
# @parameter x [Integer | Nil] The x position of the window.
|
|
19
|
+
# @parameter y [Integer | Nil] The y position of the window.
|
|
20
|
+
# @parameter width [Integer | Nil] The width of the window in CSS pixels.
|
|
21
|
+
# @parameter height [Integer | Nil] The height of the window in CSS pixels.
|
|
22
|
+
def set_window_rect(x: nil, y: nil, width: nil, height: nil)
|
|
23
|
+
session.post("window/rect", {x: x, y: y, width: width, height: height}.compact)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Resize the browser window to the given dimensions.
|
|
27
|
+
# @parameter width [Integer] The new width in CSS pixels.
|
|
28
|
+
# @parameter height [Integer] The new height in CSS pixels.
|
|
29
|
+
def resize_window(width, height)
|
|
30
|
+
set_window_rect(width: width, height: height)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Maximize the browser window.
|
|
34
|
+
def maximize_window
|
|
35
|
+
session.post("window/maximize")
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Minimize the browser window.
|
|
39
|
+
def minimize_window
|
|
40
|
+
session.post("window/minimize")
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Make the browser window fullscreen.
|
|
44
|
+
def fullscreen_window
|
|
45
|
+
session.post("window/fullscreen")
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -13,3 +13,12 @@ require_relative "scope/navigation"
|
|
|
13
13
|
require_relative "scope/printing"
|
|
14
14
|
require_relative "scope/screen_capture"
|
|
15
15
|
require_relative "scope/timeouts"
|
|
16
|
+
require_relative "scope/window"
|
|
17
|
+
|
|
18
|
+
module Async
|
|
19
|
+
module WebDriver
|
|
20
|
+
# @namespace
|
|
21
|
+
module Scope
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -56,6 +56,7 @@ module Async
|
|
|
56
56
|
@options = options
|
|
57
57
|
end
|
|
58
58
|
|
|
59
|
+
# @returns [String] A concise representation of the session.
|
|
59
60
|
def inspect
|
|
60
61
|
"\#<#{self.class} id=#{@id.inspect}>"
|
|
61
62
|
end
|
|
@@ -126,6 +127,7 @@ module Async
|
|
|
126
127
|
include Scope::Printing
|
|
127
128
|
include Scope::ScreenCapture
|
|
128
129
|
include Scope::Timeouts
|
|
130
|
+
include Scope::Window
|
|
129
131
|
|
|
130
132
|
# Reset the session to a clean state.
|
|
131
133
|
def reset!
|
data/readme.md
CHANGED
|
@@ -18,6 +18,8 @@ Please see the [project documentation](https://socketry.github.io/async-webdrive
|
|
|
18
18
|
|
|
19
19
|
- [Debugging](https://socketry.github.io/async-webdriver/guides/debugging/index) - This guide explains how to debug WebDriver issues by capturing HTML source and screenshots when tests fail.
|
|
20
20
|
|
|
21
|
+
- [Navigation Timing](https://socketry.github.io/async-webdriver/guides/navigation-timing/index) - This guide explains how to avoid race conditions when triggering navigation operations while browser navigation is already in progress.
|
|
22
|
+
|
|
21
23
|
- [GitHub Actions Integrations](https://socketry.github.io/async-webdriver/guides/github-actions-integration/index) - This guide explains how to use `async-webdriver` with GitHub Actions.
|
|
22
24
|
|
|
23
25
|
- [Sus Integration](https://socketry.github.io/async-webdriver/guides/sus-integration/index) - This guide will show you how to integrate `async-webdriver` with the sus test framework.
|
|
@@ -26,6 +28,15 @@ Please see the [project documentation](https://socketry.github.io/async-webdrive
|
|
|
26
28
|
|
|
27
29
|
Please see the [project releases](https://socketry.github.io/async-webdriver/releases/index) for all releases.
|
|
28
30
|
|
|
31
|
+
### v0.11.0
|
|
32
|
+
|
|
33
|
+
- Add `Scope::Window` with `#window_rect`, `#resize_window`, `#set_window_rect`, `#maximize_window`, `#minimize_window`, and `#fullscreen_window`.
|
|
34
|
+
- Expand `Scope::Printing#print` with full W3C WebDriver parameters: `orientation`, `scale`, `background`, `page`, `margin`, `page_ranges`, and `shrink_to_fit`.
|
|
35
|
+
|
|
36
|
+
### v0.10.0
|
|
37
|
+
|
|
38
|
+
- Introduce `Scope#wait_for_navigation` to properly wait for page navigations to complete.
|
|
39
|
+
|
|
29
40
|
### v0.9.0
|
|
30
41
|
|
|
31
42
|
- Fix `Scope#screenshot` to use the correct HTTP method (`GET` instead of `POST`).
|
|
@@ -49,6 +60,22 @@ We welcome contributions to this project.
|
|
|
49
60
|
4. Push to the branch (`git push origin my-new-feature`).
|
|
50
61
|
5. Create new Pull Request.
|
|
51
62
|
|
|
63
|
+
### Running Tests
|
|
64
|
+
|
|
65
|
+
To run the test suite:
|
|
66
|
+
|
|
67
|
+
``` shell
|
|
68
|
+
bundle exec sus
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### Making Releases
|
|
72
|
+
|
|
73
|
+
To make a new release:
|
|
74
|
+
|
|
75
|
+
``` shell
|
|
76
|
+
bundle exec bake gem:release:patch # or minor or major
|
|
77
|
+
```
|
|
78
|
+
|
|
52
79
|
### Developer Certificate of Origin
|
|
53
80
|
|
|
54
81
|
In order to protect users of this project, we require all contributors to comply with the [Developer Certificate of Origin](https://developercertificate.org/). This ensures that all contributions are properly licensed and attributed.
|
data/releases.md
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
# Releases
|
|
2
2
|
|
|
3
|
+
## v0.11.0
|
|
4
|
+
|
|
5
|
+
- Add `Scope::Window` with `#window_rect`, `#resize_window`, `#set_window_rect`, `#maximize_window`, `#minimize_window`, and `#fullscreen_window`.
|
|
6
|
+
- Expand `Scope::Printing#print` with full W3C WebDriver parameters: `orientation`, `scale`, `background`, `page`, `margin`, `page_ranges`, and `shrink_to_fit`.
|
|
7
|
+
|
|
8
|
+
## v0.10.0
|
|
9
|
+
|
|
10
|
+
- Introduce `Scope#wait_for_navigation` to properly wait for page navigations to complete.
|
|
11
|
+
|
|
3
12
|
## v0.9.0
|
|
4
13
|
|
|
5
14
|
- Fix `Scope#screenshot` to use the correct HTTP method (`GET` instead of `POST`).
|
data.tar.gz.sig
CHANGED
|
Binary file
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: async-webdriver
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.11.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Samuel Williams
|
|
@@ -116,6 +116,7 @@ files:
|
|
|
116
116
|
- context/getting-started.md
|
|
117
117
|
- context/github-actions-integration.md
|
|
118
118
|
- context/index.yaml
|
|
119
|
+
- context/navigation-timing.md
|
|
119
120
|
- context/sus-integration.md
|
|
120
121
|
- lib/async/webdriver.rb
|
|
121
122
|
- lib/async/webdriver/bridge.rb
|
|
@@ -142,6 +143,7 @@ files:
|
|
|
142
143
|
- lib/async/webdriver/scope/printing.rb
|
|
143
144
|
- lib/async/webdriver/scope/screen_capture.rb
|
|
144
145
|
- lib/async/webdriver/scope/timeouts.rb
|
|
146
|
+
- lib/async/webdriver/scope/window.rb
|
|
145
147
|
- lib/async/webdriver/session.rb
|
|
146
148
|
- lib/async/webdriver/version.rb
|
|
147
149
|
- lib/async/webdriver/xpath.rb
|
|
@@ -162,14 +164,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
162
164
|
requirements:
|
|
163
165
|
- - ">="
|
|
164
166
|
- !ruby/object:Gem::Version
|
|
165
|
-
version: '3.
|
|
167
|
+
version: '3.3'
|
|
166
168
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
167
169
|
requirements:
|
|
168
170
|
- - ">="
|
|
169
171
|
- !ruby/object:Gem::Version
|
|
170
172
|
version: '0'
|
|
171
173
|
requirements: []
|
|
172
|
-
rubygems_version:
|
|
174
|
+
rubygems_version: 4.0.6
|
|
173
175
|
specification_version: 4
|
|
174
176
|
summary: A native library implementing the W3C WebDriver client specification.
|
|
175
177
|
test_files: []
|
metadata.gz.sig
CHANGED
|
Binary file
|