puppeteer-bidi 0.0.1.beta1
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 +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +13 -0
- data/CLAUDE/README.md +158 -0
- data/CLAUDE/async_programming.md +158 -0
- data/CLAUDE/click_implementation.md +340 -0
- data/CLAUDE/core_layer_gotchas.md +136 -0
- data/CLAUDE/error_handling.md +232 -0
- data/CLAUDE/file_chooser.md +95 -0
- data/CLAUDE/frame_architecture.md +346 -0
- data/CLAUDE/javascript_evaluation.md +341 -0
- data/CLAUDE/jshandle_implementation.md +505 -0
- data/CLAUDE/keyboard_implementation.md +250 -0
- data/CLAUDE/mouse_implementation.md +140 -0
- data/CLAUDE/navigation_waiting.md +234 -0
- data/CLAUDE/porting_puppeteer.md +214 -0
- data/CLAUDE/query_handler.md +194 -0
- data/CLAUDE/rspec_pending_vs_skip.md +262 -0
- data/CLAUDE/selector_evaluation.md +198 -0
- data/CLAUDE/test_server_routes.md +263 -0
- data/CLAUDE/testing_strategy.md +236 -0
- data/CLAUDE/two_layer_architecture.md +180 -0
- data/CLAUDE/wrapped_element_click.md +247 -0
- data/CLAUDE.md +185 -0
- data/LICENSE.txt +21 -0
- data/README.md +488 -0
- data/Rakefile +21 -0
- data/lib/puppeteer/bidi/async_utils.rb +151 -0
- data/lib/puppeteer/bidi/browser.rb +285 -0
- data/lib/puppeteer/bidi/browser_context.rb +53 -0
- data/lib/puppeteer/bidi/browser_launcher.rb +240 -0
- data/lib/puppeteer/bidi/connection.rb +182 -0
- data/lib/puppeteer/bidi/core/README.md +169 -0
- data/lib/puppeteer/bidi/core/browser.rb +230 -0
- data/lib/puppeteer/bidi/core/browsing_context.rb +601 -0
- data/lib/puppeteer/bidi/core/disposable.rb +69 -0
- data/lib/puppeteer/bidi/core/errors.rb +64 -0
- data/lib/puppeteer/bidi/core/event_emitter.rb +83 -0
- data/lib/puppeteer/bidi/core/navigation.rb +128 -0
- data/lib/puppeteer/bidi/core/realm.rb +315 -0
- data/lib/puppeteer/bidi/core/request.rb +300 -0
- data/lib/puppeteer/bidi/core/session.rb +153 -0
- data/lib/puppeteer/bidi/core/user_context.rb +208 -0
- data/lib/puppeteer/bidi/core/user_prompt.rb +102 -0
- data/lib/puppeteer/bidi/core.rb +45 -0
- data/lib/puppeteer/bidi/deserializer.rb +132 -0
- data/lib/puppeteer/bidi/element_handle.rb +602 -0
- data/lib/puppeteer/bidi/errors.rb +42 -0
- data/lib/puppeteer/bidi/file_chooser.rb +52 -0
- data/lib/puppeteer/bidi/frame.rb +597 -0
- data/lib/puppeteer/bidi/http_response.rb +23 -0
- data/lib/puppeteer/bidi/injected.js +1 -0
- data/lib/puppeteer/bidi/injected_source.rb +21 -0
- data/lib/puppeteer/bidi/js_handle.rb +302 -0
- data/lib/puppeteer/bidi/keyboard.rb +265 -0
- data/lib/puppeteer/bidi/lazy_arg.rb +23 -0
- data/lib/puppeteer/bidi/mouse.rb +170 -0
- data/lib/puppeteer/bidi/page.rb +613 -0
- data/lib/puppeteer/bidi/query_handler.rb +397 -0
- data/lib/puppeteer/bidi/realm.rb +242 -0
- data/lib/puppeteer/bidi/serializer.rb +139 -0
- data/lib/puppeteer/bidi/target.rb +81 -0
- data/lib/puppeteer/bidi/task_manager.rb +44 -0
- data/lib/puppeteer/bidi/timeout_settings.rb +20 -0
- data/lib/puppeteer/bidi/transport.rb +129 -0
- data/lib/puppeteer/bidi/version.rb +7 -0
- data/lib/puppeteer/bidi/wait_task.rb +322 -0
- data/lib/puppeteer/bidi.rb +49 -0
- data/scripts/update_injected_source.rb +57 -0
- data/sig/puppeteer/bidi/browser.rbs +80 -0
- data/sig/puppeteer/bidi/element_handle.rbs +238 -0
- data/sig/puppeteer/bidi/frame.rbs +205 -0
- data/sig/puppeteer/bidi/js_handle.rbs +90 -0
- data/sig/puppeteer/bidi/page.rbs +247 -0
- data/sig/puppeteer/bidi.rbs +15 -0
- metadata +176 -0
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
# Keyboard Implementation
|
|
2
|
+
|
|
3
|
+
This document describes the keyboard input implementation for WebDriver BiDi.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
The Keyboard class implements user keyboard input using the BiDi `input.performActions` protocol. It supports typing text, pressing individual keys, and handling modifier keys (Shift, Control, Alt, Meta).
|
|
8
|
+
|
|
9
|
+
## Architecture
|
|
10
|
+
|
|
11
|
+
### Class Structure
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
Keyboard
|
|
15
|
+
├── @page (Page) - Reference to the page for focused_frame access
|
|
16
|
+
├── @browsing_context (BrowsingContext) - Target context for input actions
|
|
17
|
+
└── @pressed_keys (Set) - Track currently pressed keys for modifier state
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
### Key Components
|
|
21
|
+
|
|
22
|
+
1. **Keyboard class** (`lib/puppeteer/bidi/keyboard.rb`)
|
|
23
|
+
- High-level keyboard input API
|
|
24
|
+
- Manages modifier key state
|
|
25
|
+
- Converts Ruby key names to BiDi format
|
|
26
|
+
|
|
27
|
+
2. **Key mappings** (`lib/puppeteer/bidi/key_definitions.rb`)
|
|
28
|
+
- Maps key names to Unicode code points
|
|
29
|
+
- Handles special keys (Enter, Tab, Arrow keys, etc.)
|
|
30
|
+
- Platform-specific keys (CtrlOrMeta)
|
|
31
|
+
|
|
32
|
+
## BiDi Protocol Usage
|
|
33
|
+
|
|
34
|
+
### input.performActions Format
|
|
35
|
+
|
|
36
|
+
```ruby
|
|
37
|
+
session.send_command('input.performActions', {
|
|
38
|
+
context: browsing_context_id,
|
|
39
|
+
actions: [
|
|
40
|
+
{
|
|
41
|
+
type: 'key',
|
|
42
|
+
id: 'keyboard',
|
|
43
|
+
actions: [
|
|
44
|
+
{ type: 'keyDown', value: 'a' },
|
|
45
|
+
{ type: 'keyUp', value: 'a' }
|
|
46
|
+
]
|
|
47
|
+
}
|
|
48
|
+
]
|
|
49
|
+
})
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
### Key Value Format
|
|
53
|
+
|
|
54
|
+
BiDi accepts two formats for key values:
|
|
55
|
+
|
|
56
|
+
1. **Single character**: `'a'`, `'1'`, `'!'`
|
|
57
|
+
2. **Unicode escape**: `"\uE007"` for Enter, `"\uE008"` for Shift, etc.
|
|
58
|
+
|
|
59
|
+
## Implementation Details
|
|
60
|
+
|
|
61
|
+
### Modifier Keys
|
|
62
|
+
|
|
63
|
+
**Special handling for CtrlOrMeta**:
|
|
64
|
+
```ruby
|
|
65
|
+
KEY_DEFINITIONS = {
|
|
66
|
+
'CtrlOrMeta' => {
|
|
67
|
+
key: RUBY_PLATFORM.include?('darwin') ? 'Meta' : 'Control',
|
|
68
|
+
code: RUBY_PLATFORM.include?('darwin') ? 'MetaLeft' : 'ControlLeft',
|
|
69
|
+
location: 1
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
**Modifier state tracking**:
|
|
75
|
+
```ruby
|
|
76
|
+
def down(key)
|
|
77
|
+
definition = get_key_definition(key)
|
|
78
|
+
@pressed_keys.add(definition[:key])
|
|
79
|
+
# ... send keyDown action
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def up(key)
|
|
83
|
+
definition = get_key_definition(key)
|
|
84
|
+
@pressed_keys.delete(definition[:key])
|
|
85
|
+
# ... send keyUp action
|
|
86
|
+
end
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### Type Method
|
|
90
|
+
|
|
91
|
+
The `type` method splits text into individual characters and presses each one:
|
|
92
|
+
|
|
93
|
+
```ruby
|
|
94
|
+
def type(text, delay: 0)
|
|
95
|
+
text.each_char do |char|
|
|
96
|
+
# Handle special keys (e.g., "\n" → Enter)
|
|
97
|
+
if SPECIAL_CHAR_MAP[char]
|
|
98
|
+
press(SPECIAL_CHAR_MAP[char])
|
|
99
|
+
else
|
|
100
|
+
press(char)
|
|
101
|
+
end
|
|
102
|
+
sleep(delay / 1000.0) if delay > 0
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### Send Character Method
|
|
108
|
+
|
|
109
|
+
The `send_character` method inserts text without triggering keydown/keyup events:
|
|
110
|
+
|
|
111
|
+
```ruby
|
|
112
|
+
def send_character(char)
|
|
113
|
+
raise ArgumentError, 'Cannot send more than 1 character.' if char.length > 1
|
|
114
|
+
|
|
115
|
+
# Get the focused frame (may be an iframe)
|
|
116
|
+
focused_frame = @page.focused_frame
|
|
117
|
+
|
|
118
|
+
# Execute insertText in the focused frame's realm
|
|
119
|
+
focused_frame.isolated_realm.call_function(
|
|
120
|
+
'function(char) { document.execCommand("insertText", false, char); }',
|
|
121
|
+
false,
|
|
122
|
+
arguments: [{ type: 'string', value: char }]
|
|
123
|
+
)
|
|
124
|
+
end
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## Focused Frame Detection
|
|
128
|
+
|
|
129
|
+
For iframe keyboard input, we need to detect which frame currently has focus.
|
|
130
|
+
|
|
131
|
+
### Implementation Pattern (from Puppeteer)
|
|
132
|
+
|
|
133
|
+
```ruby
|
|
134
|
+
# Page#focused_frame
|
|
135
|
+
def focused_frame
|
|
136
|
+
handle = main_frame.evaluate_handle(<<~JS)
|
|
137
|
+
() => {
|
|
138
|
+
let win = window;
|
|
139
|
+
while (
|
|
140
|
+
win.document.activeElement instanceof win.HTMLIFrameElement ||
|
|
141
|
+
win.document.activeElement instanceof win.HTMLFrameElement
|
|
142
|
+
) {
|
|
143
|
+
if (win.document.activeElement.contentWindow === null) {
|
|
144
|
+
break;
|
|
145
|
+
}
|
|
146
|
+
win = win.document.activeElement.contentWindow;
|
|
147
|
+
}
|
|
148
|
+
return win;
|
|
149
|
+
}
|
|
150
|
+
JS
|
|
151
|
+
|
|
152
|
+
# Get context ID from window object
|
|
153
|
+
remote_value = handle.remote_value
|
|
154
|
+
context_id = remote_value['value']['context']
|
|
155
|
+
|
|
156
|
+
# Find frame with matching context ID
|
|
157
|
+
frames.find { |f| f.browsing_context.id == context_id }
|
|
158
|
+
end
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
### Why This Matters
|
|
162
|
+
|
|
163
|
+
When typing in an iframe:
|
|
164
|
+
1. The user focuses a textarea inside the iframe
|
|
165
|
+
2. `document.activeElement` in main frame points to the iframe element
|
|
166
|
+
3. We traverse `activeElement.contentWindow` to find the focused frame
|
|
167
|
+
4. `send_character` executes `insertText` in the correct frame's realm
|
|
168
|
+
|
|
169
|
+
## Firefox BiDi Behavior Differences
|
|
170
|
+
|
|
171
|
+
### Modifier + Character Input Events
|
|
172
|
+
|
|
173
|
+
**Puppeteer (Chrome CDP) behavior**:
|
|
174
|
+
- Shift + '!' → triggers input event
|
|
175
|
+
- Control + '!' → no input event
|
|
176
|
+
- Alt + '!' → no input event
|
|
177
|
+
|
|
178
|
+
**Firefox BiDi behavior**:
|
|
179
|
+
- Shift + '!' → triggers input event ✅
|
|
180
|
+
- Control + '!' → triggers input event ⚠️
|
|
181
|
+
- Alt + '!' → triggers input event ⚠️
|
|
182
|
+
|
|
183
|
+
**Test adaptation**:
|
|
184
|
+
```ruby
|
|
185
|
+
# Instead of strict equality:
|
|
186
|
+
expect(result).to eq("Keydown: ! Digit1 [#{modifier_key}]")
|
|
187
|
+
|
|
188
|
+
# Use include to handle extra input events:
|
|
189
|
+
expect(result).to include("Keydown: ! Digit1 [#{modifier_key}]")
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
### Modifier State Persistence
|
|
193
|
+
|
|
194
|
+
**Known limitation**: BiDi doesn't maintain modifier state across separate `performActions` calls.
|
|
195
|
+
|
|
196
|
+
**Workaround**: Combine all actions into a single `performActions` call when modifier state needs to persist.
|
|
197
|
+
|
|
198
|
+
## Testing
|
|
199
|
+
|
|
200
|
+
### Test Structure
|
|
201
|
+
|
|
202
|
+
```ruby
|
|
203
|
+
with_test_state do |page:, server:, **|
|
|
204
|
+
page.goto("#{server.prefix}/input/keyboard.html")
|
|
205
|
+
|
|
206
|
+
# Type text
|
|
207
|
+
page.keyboard.type('Hello')
|
|
208
|
+
|
|
209
|
+
# Verify result
|
|
210
|
+
result = page.evaluate('() => globalThis.getResult()')
|
|
211
|
+
expect(result).to eq('Keydown: H KeyH []')
|
|
212
|
+
end
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
### Test Assets
|
|
216
|
+
|
|
217
|
+
**Important**: `spec/assets/input/textarea.html` must define:
|
|
218
|
+
```html
|
|
219
|
+
<script>
|
|
220
|
+
globalThis.result = '';
|
|
221
|
+
globalThis.textarea = document.querySelector('textarea');
|
|
222
|
+
textarea.addEventListener('input', () => result = textarea.value, false);
|
|
223
|
+
</script>
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
These global variables are used by multiple tests to verify keyboard input behavior.
|
|
227
|
+
|
|
228
|
+
## References
|
|
229
|
+
|
|
230
|
+
- **Puppeteer keyboard implementation**:
|
|
231
|
+
- TypeScript: `packages/puppeteer-core/src/bidi/Input.ts`
|
|
232
|
+
- Tests: `test/src/keyboard.spec.ts`
|
|
233
|
+
- **BiDi Specification**:
|
|
234
|
+
- [input.performActions](https://w3c.github.io/webdriver-bidi/#command-input-performActions)
|
|
235
|
+
- **Key definitions**:
|
|
236
|
+
- [W3C WebDriver Key Codes](https://www.w3.org/TR/webdriver/#keyboard-actions)
|
|
237
|
+
|
|
238
|
+
## Common Pitfalls
|
|
239
|
+
|
|
240
|
+
1. **Forgetting to update test assets**: Tests fail with "result is not defined" or "textarea is undefined"
|
|
241
|
+
- Solution: Download official Puppeteer test assets
|
|
242
|
+
|
|
243
|
+
2. **Modifier state not persisting**: Separate `performActions` calls don't maintain modifier state
|
|
244
|
+
- Solution: Combine actions in single call (current limitation)
|
|
245
|
+
|
|
246
|
+
3. **iframe input goes to main frame**: Text appears in wrong frame
|
|
247
|
+
- Solution: Implement `focused_frame` detection
|
|
248
|
+
|
|
249
|
+
4. **Platform-specific Meta key**: Meta key doesn't work on Linux/Windows
|
|
250
|
+
- Solution: Use CtrlOrMeta abstraction
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# Mouse Implementation
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
Mouse input is implemented using WebDriver BiDi's `input.performActions` command with different source types.
|
|
6
|
+
|
|
7
|
+
## BiDi Input Sources
|
|
8
|
+
|
|
9
|
+
Different input types use different source types:
|
|
10
|
+
|
|
11
|
+
| Input Type | Source Type | Source ID |
|
|
12
|
+
|------------|-------------|-----------|
|
|
13
|
+
| Mouse pointer | `pointer` | `default mouse` |
|
|
14
|
+
| Keyboard | `key` | `default keyboard` |
|
|
15
|
+
| Mouse wheel | `wheel` | `__puppeteer_wheel` |
|
|
16
|
+
|
|
17
|
+
## Mouse Methods
|
|
18
|
+
|
|
19
|
+
### move(x, y, steps:)
|
|
20
|
+
|
|
21
|
+
Moves mouse to coordinates with optional intermediate steps for smooth movement.
|
|
22
|
+
|
|
23
|
+
```ruby
|
|
24
|
+
mouse.move(100, 200) # Instant move
|
|
25
|
+
mouse.move(100, 200, steps: 5) # Smooth move with 5 intermediate points
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Uses linear interpolation for intermediate positions.
|
|
29
|
+
|
|
30
|
+
### click(x, y, button:, count:, delay:)
|
|
31
|
+
|
|
32
|
+
Moves to coordinates and performs click(s).
|
|
33
|
+
|
|
34
|
+
```ruby
|
|
35
|
+
mouse.click(100, 200) # Single left click
|
|
36
|
+
mouse.click(100, 200, button: 'right') # Right click
|
|
37
|
+
mouse.click(100, 200, button: 'middle') # Middle click (aux click)
|
|
38
|
+
mouse.click(100, 200, button: 'back') # Back button
|
|
39
|
+
mouse.click(100, 200, button: 'forward') # Forward button
|
|
40
|
+
mouse.click(100, 200, count: 2) # Double click
|
|
41
|
+
mouse.click(100, 200, delay: 100) # Click with 100ms delay between down/up
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### wheel(delta_x:, delta_y:)
|
|
45
|
+
|
|
46
|
+
Scrolls using mouse wheel at current mouse position.
|
|
47
|
+
|
|
48
|
+
```ruby
|
|
49
|
+
mouse.wheel(delta_y: -100) # Scroll up
|
|
50
|
+
mouse.wheel(delta_y: 100) # Scroll down
|
|
51
|
+
mouse.wheel(delta_x: 50) # Scroll right
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
**Important**: Uses separate `wheel` source type (not `pointer`).
|
|
55
|
+
|
|
56
|
+
### reset
|
|
57
|
+
|
|
58
|
+
Resets mouse state to origin and releases all pressed buttons.
|
|
59
|
+
|
|
60
|
+
```ruby
|
|
61
|
+
mouse.reset
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Uses `input.releaseActions` BiDi command.
|
|
65
|
+
|
|
66
|
+
## Data Classes
|
|
67
|
+
|
|
68
|
+
### ElementHandle::BoundingBox
|
|
69
|
+
|
|
70
|
+
```ruby
|
|
71
|
+
BoundingBox = Data.define(:x, :y, :width, :height)
|
|
72
|
+
|
|
73
|
+
box = element.bounding_box
|
|
74
|
+
box.x # => 10.0
|
|
75
|
+
box.width # => 100.0
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### ElementHandle::Point
|
|
79
|
+
|
|
80
|
+
```ruby
|
|
81
|
+
Point = Data.define(:x, :y)
|
|
82
|
+
|
|
83
|
+
point = element.clickable_point
|
|
84
|
+
point.x # => 50.0
|
|
85
|
+
point.y # => 75.0
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### ElementHandle::BoxModel
|
|
89
|
+
|
|
90
|
+
CSS box model with content, padding, border, and margin quads. Each quad is an array of 4 Points (top-left, top-right, bottom-right, bottom-left).
|
|
91
|
+
|
|
92
|
+
```ruby
|
|
93
|
+
BoxModel = Data.define(:content, :padding, :border, :margin, :width, :height)
|
|
94
|
+
|
|
95
|
+
box = element.box_model
|
|
96
|
+
box.width # => 200.0
|
|
97
|
+
box.height # => 100.0
|
|
98
|
+
box.border[0] # => Point(x: 10.0, y: 20.0) - top-left corner
|
|
99
|
+
box.content[0].x # => 21.0 (border.x + borderLeftWidth + paddingLeft)
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
**Note**: Frame offset handling is not yet implemented. For elements inside iframes, coordinates are relative to the iframe, not the main page.
|
|
103
|
+
|
|
104
|
+
## ElementHandle#click
|
|
105
|
+
|
|
106
|
+
ElementHandle has its own `click` method that:
|
|
107
|
+
1. Scrolls element into view if needed
|
|
108
|
+
2. Gets clickable point
|
|
109
|
+
3. Uses `frame.page.mouse.click()` to perform the click
|
|
110
|
+
|
|
111
|
+
```ruby
|
|
112
|
+
element = page.query_selector('button')
|
|
113
|
+
element.click # Single click
|
|
114
|
+
element.click(count: 2) # Double click
|
|
115
|
+
element.click(button: 'right') # Right click
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Note: ElementHandle gets its frame via `@realm.environment`, so no frame parameter is needed.
|
|
119
|
+
|
|
120
|
+
## Hover Implementation
|
|
121
|
+
|
|
122
|
+
Hover is implemented at three levels following Puppeteer's pattern:
|
|
123
|
+
|
|
124
|
+
1. **ElementHandle#hover** - Scrolls into view, gets clickable point, moves mouse
|
|
125
|
+
2. **Frame#hover(selector)** - Queries element, calls element.hover
|
|
126
|
+
3. **Page#hover(selector)** - Delegates to main_frame.hover
|
|
127
|
+
|
|
128
|
+
## WebDriver BiDi Limitations
|
|
129
|
+
|
|
130
|
+
### is_mobile not supported
|
|
131
|
+
|
|
132
|
+
The `set_viewport` method does not support `is_mobile` parameter because WebDriver BiDi protocol doesn't support device emulation yet.
|
|
133
|
+
|
|
134
|
+
- Tracking issue: https://github.com/w3c/webdriver-bidi/issues/772
|
|
135
|
+
- Puppeteer also doesn't implement this for BiDi
|
|
136
|
+
|
|
137
|
+
## References
|
|
138
|
+
|
|
139
|
+
- [WebDriver BiDi Input Module](https://w3c.github.io/webdriver-bidi/#module-input)
|
|
140
|
+
- [Puppeteer Input.ts](https://github.com/puppeteer/puppeteer/blob/main/packages/puppeteer-core/src/bidi/Input.ts)
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
# Navigation Waiting Pattern
|
|
2
|
+
|
|
3
|
+
This document explains the implementation of `Page.waitForNavigation` and `Frame.waitForNavigation`.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
Navigation waiting is a critical feature for browser automation. It allows you to execute an action that triggers navigation and wait for it to complete before proceeding.
|
|
8
|
+
|
|
9
|
+
## API Design
|
|
10
|
+
|
|
11
|
+
### Block-Based API
|
|
12
|
+
|
|
13
|
+
Following Ruby conventions, we provide a block-based API that hides Async complexity:
|
|
14
|
+
|
|
15
|
+
```ruby
|
|
16
|
+
# Wait for navigation triggered by block
|
|
17
|
+
page.wait_for_navigation(timeout: 30000, wait_until: 'load') do
|
|
18
|
+
page.click('a')
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Without block - waits for any navigation
|
|
22
|
+
page.wait_for_navigation(timeout: 30000)
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
**Parameters:**
|
|
26
|
+
- `timeout` (milliseconds): Navigation timeout (default: 30000)
|
|
27
|
+
- `wait_until`: When to consider navigation succeeded
|
|
28
|
+
- `'load'`: Wait for `load` event (default)
|
|
29
|
+
- `'domcontentloaded'`: Wait for `DOMContentLoaded` event
|
|
30
|
+
|
|
31
|
+
**Returns:**
|
|
32
|
+
- `HTTPResponse` object for full page navigation
|
|
33
|
+
- `nil` for fragment navigation (#hash) or History API operations
|
|
34
|
+
|
|
35
|
+
## Navigation Types
|
|
36
|
+
|
|
37
|
+
WebDriver BiDi distinguishes three types of navigation:
|
|
38
|
+
|
|
39
|
+
### 1. Full Page Navigation
|
|
40
|
+
|
|
41
|
+
**Trigger**: `page.goto()`, clicking links, form submission
|
|
42
|
+
|
|
43
|
+
**BiDi Events:**
|
|
44
|
+
1. `browsingContext.navigationStarted` - Creates Navigation object
|
|
45
|
+
2. `browsingContext.load` or `browsingContext.domContentLoaded`
|
|
46
|
+
|
|
47
|
+
**Response**: Returns HTTPResponse object
|
|
48
|
+
|
|
49
|
+
```ruby
|
|
50
|
+
response = page.wait_for_navigation do
|
|
51
|
+
page.evaluate("url => { window.location.href = url }", "https://example.com")
|
|
52
|
+
end
|
|
53
|
+
# => HTTPResponse object
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### 2. Fragment Navigation
|
|
57
|
+
|
|
58
|
+
**Trigger**: Anchor link clicks (`<a href="#section">`), hash changes
|
|
59
|
+
|
|
60
|
+
**BiDi Events:**
|
|
61
|
+
- `browsingContext.fragmentNavigated` (no navigationStarted)
|
|
62
|
+
|
|
63
|
+
**Response**: Returns `nil`
|
|
64
|
+
|
|
65
|
+
```ruby
|
|
66
|
+
response = page.wait_for_navigation do
|
|
67
|
+
page.click('a[href="#foobar"]')
|
|
68
|
+
end
|
|
69
|
+
# => nil
|
|
70
|
+
expect(page.url).to end_with('#foobar')
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### 3. History API Navigation
|
|
74
|
+
|
|
75
|
+
**Trigger**: `history.pushState()`, `history.replaceState()`, `history.back()`, `history.forward()`
|
|
76
|
+
|
|
77
|
+
**BiDi Events:**
|
|
78
|
+
- `browsingContext.historyUpdated` (no navigationStarted)
|
|
79
|
+
|
|
80
|
+
**Response**: Returns `nil`
|
|
81
|
+
|
|
82
|
+
```ruby
|
|
83
|
+
response = page.wait_for_navigation do
|
|
84
|
+
page.evaluate("() => { history.pushState({}, '', 'new.html') }")
|
|
85
|
+
end
|
|
86
|
+
# => nil
|
|
87
|
+
expect(page.url).to end_with('new.html')
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Implementation Pattern
|
|
91
|
+
|
|
92
|
+
### Event-Driven Waiting
|
|
93
|
+
|
|
94
|
+
The implementation uses an **event-driven pattern** with `Async::Promise`:
|
|
95
|
+
|
|
96
|
+
```ruby
|
|
97
|
+
def wait_for_navigation(timeout: 30000, wait_until: 'load', &block)
|
|
98
|
+
# Single promise that all event listeners resolve
|
|
99
|
+
promise = Async::Promise.new
|
|
100
|
+
|
|
101
|
+
# Register listeners for all 3 navigation types
|
|
102
|
+
navigation_listener = proc { ... promise.resolve(...) }
|
|
103
|
+
history_listener = proc { ... promise.resolve(nil) }
|
|
104
|
+
fragment_listener = proc { ... promise.resolve(nil) }
|
|
105
|
+
|
|
106
|
+
@browsing_context.on(:navigation, &navigation_listener)
|
|
107
|
+
@browsing_context.on(:history_updated, &history_listener)
|
|
108
|
+
@browsing_context.on(:fragment_navigated, &fragment_listener)
|
|
109
|
+
|
|
110
|
+
begin
|
|
111
|
+
block.call if block # Trigger navigation
|
|
112
|
+
|
|
113
|
+
Async do |task|
|
|
114
|
+
task.with_timeout(timeout / 1000.0) do
|
|
115
|
+
promise.wait # Wait for any listener to resolve
|
|
116
|
+
end
|
|
117
|
+
end.wait
|
|
118
|
+
ensure
|
|
119
|
+
# Clean up ALL listeners
|
|
120
|
+
@browsing_context.off(:navigation, &navigation_listener)
|
|
121
|
+
@browsing_context.off(:history_updated, &history_listener)
|
|
122
|
+
@browsing_context.off(:fragment_navigated, &fragment_listener)
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
### Why Not Use `AsyncUtils.promise_race`?
|
|
128
|
+
|
|
129
|
+
**TL;DR**: Event-driven waiting is different from racing independent tasks.
|
|
130
|
+
|
|
131
|
+
`AsyncUtils.promise_race` is designed for **parallel task execution**:
|
|
132
|
+
```ruby
|
|
133
|
+
# ✅ Good use case: Racing independent tasks
|
|
134
|
+
result = AsyncUtils.promise_race(
|
|
135
|
+
-> { fetch_from_api_1 },
|
|
136
|
+
-> { fetch_from_api_2 },
|
|
137
|
+
-> { fetch_from_api_3 }
|
|
138
|
+
)
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Navigation waiting requires **event listener coordination**:
|
|
142
|
+
- Need to register listeners *before* triggering navigation
|
|
143
|
+
- Must clean up *all* listeners regardless of which fires
|
|
144
|
+
- Share state between listeners (e.g., `navigation_received` flag)
|
|
145
|
+
- Nested event handling (Navigation object events)
|
|
146
|
+
|
|
147
|
+
The current pattern is **simpler and more appropriate** because:
|
|
148
|
+
1. ✅ Single promise resolved by multiple listeners
|
|
149
|
+
2. ✅ Clear cleanup path for all listeners in `ensure` block
|
|
150
|
+
3. ✅ No nested reactor issues (promise_race uses `Sync do`)
|
|
151
|
+
4. ✅ Handles complex nested events (Navigation object completion events)
|
|
152
|
+
5. ✅ Easy to debug and reason about
|
|
153
|
+
|
|
154
|
+
## Navigation Object Integration
|
|
155
|
+
|
|
156
|
+
For full page navigation, BiDi creates a Navigation object that tracks completion:
|
|
157
|
+
|
|
158
|
+
```ruby
|
|
159
|
+
navigation_listener = proc do |data|
|
|
160
|
+
navigation = data[:navigation]
|
|
161
|
+
navigation_received = true
|
|
162
|
+
|
|
163
|
+
# Listen for Navigation completion events
|
|
164
|
+
navigation.once(:fragment) { promise.resolve(nil) }
|
|
165
|
+
navigation.once(:failed) { promise.resolve(nil) }
|
|
166
|
+
navigation.once(:aborted) { promise.resolve(nil) }
|
|
167
|
+
|
|
168
|
+
# Also wait for load event
|
|
169
|
+
@browsing_context.once(load_event) do
|
|
170
|
+
promise.resolve(response_holder[:value])
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
This nested event handling is a key reason why `promise_race` doesn't simplify the implementation.
|
|
176
|
+
|
|
177
|
+
## Race Condition Prevention
|
|
178
|
+
|
|
179
|
+
The implementation prevents race conditions where multiple navigation types could fire:
|
|
180
|
+
|
|
181
|
+
```ruby
|
|
182
|
+
navigation_received = false
|
|
183
|
+
|
|
184
|
+
navigation_listener = proc do |data|
|
|
185
|
+
navigation_received = true
|
|
186
|
+
# ... set up Navigation object listeners
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
history_listener = proc do
|
|
190
|
+
# Only resolve if we haven't received navigation event
|
|
191
|
+
promise.resolve(nil) unless navigation_received || promise.resolved?
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
fragment_listener = proc do
|
|
195
|
+
# Only resolve if we haven't received navigation event
|
|
196
|
+
promise.resolve(nil) unless navigation_received || promise.resolved?
|
|
197
|
+
end
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
This ensures:
|
|
201
|
+
- Full page navigation takes precedence over history/fragment events
|
|
202
|
+
- No double-resolution of the promise
|
|
203
|
+
- Correct return value (HTTPResponse vs nil)
|
|
204
|
+
|
|
205
|
+
## Error Handling
|
|
206
|
+
|
|
207
|
+
Navigation can fail or timeout:
|
|
208
|
+
|
|
209
|
+
```ruby
|
|
210
|
+
begin
|
|
211
|
+
# ... wait for navigation
|
|
212
|
+
rescue Async::TimeoutError
|
|
213
|
+
raise Puppeteer::Bidi::TimeoutError,
|
|
214
|
+
"Navigation timeout of #{timeout}ms exceeded"
|
|
215
|
+
end
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
## Testing
|
|
219
|
+
|
|
220
|
+
See `spec/integration/navigation_spec.rb` for comprehensive tests covering:
|
|
221
|
+
1. Full page navigation
|
|
222
|
+
2. Fragment navigation (anchor links)
|
|
223
|
+
3. History API - pushState
|
|
224
|
+
4. History API - replaceState
|
|
225
|
+
5. History API - back/forward
|
|
226
|
+
|
|
227
|
+
All tests verify both the navigation completes and the URL updates correctly.
|
|
228
|
+
|
|
229
|
+
## References
|
|
230
|
+
|
|
231
|
+
- [Puppeteer Frame.ts](https://github.com/puppeteer/puppeteer/blob/main/packages/puppeteer-core/src/bidi/Frame.ts)
|
|
232
|
+
- [Puppeteer BrowsingContext.ts](https://github.com/puppeteer/puppeteer/blob/main/packages/puppeteer-core/src/bidi/core/BrowsingContext.ts)
|
|
233
|
+
- [Puppeteer Navigation.ts](https://github.com/puppeteer/puppeteer/blob/main/packages/puppeteer-core/src/bidi/core/Navigation.ts)
|
|
234
|
+
- [WebDriver BiDi Specification](https://w3c.github.io/webdriver-bidi/#module-browsingContext)
|