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.
Files changed (76) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/.rubocop.yml +13 -0
  4. data/CLAUDE/README.md +158 -0
  5. data/CLAUDE/async_programming.md +158 -0
  6. data/CLAUDE/click_implementation.md +340 -0
  7. data/CLAUDE/core_layer_gotchas.md +136 -0
  8. data/CLAUDE/error_handling.md +232 -0
  9. data/CLAUDE/file_chooser.md +95 -0
  10. data/CLAUDE/frame_architecture.md +346 -0
  11. data/CLAUDE/javascript_evaluation.md +341 -0
  12. data/CLAUDE/jshandle_implementation.md +505 -0
  13. data/CLAUDE/keyboard_implementation.md +250 -0
  14. data/CLAUDE/mouse_implementation.md +140 -0
  15. data/CLAUDE/navigation_waiting.md +234 -0
  16. data/CLAUDE/porting_puppeteer.md +214 -0
  17. data/CLAUDE/query_handler.md +194 -0
  18. data/CLAUDE/rspec_pending_vs_skip.md +262 -0
  19. data/CLAUDE/selector_evaluation.md +198 -0
  20. data/CLAUDE/test_server_routes.md +263 -0
  21. data/CLAUDE/testing_strategy.md +236 -0
  22. data/CLAUDE/two_layer_architecture.md +180 -0
  23. data/CLAUDE/wrapped_element_click.md +247 -0
  24. data/CLAUDE.md +185 -0
  25. data/LICENSE.txt +21 -0
  26. data/README.md +488 -0
  27. data/Rakefile +21 -0
  28. data/lib/puppeteer/bidi/async_utils.rb +151 -0
  29. data/lib/puppeteer/bidi/browser.rb +285 -0
  30. data/lib/puppeteer/bidi/browser_context.rb +53 -0
  31. data/lib/puppeteer/bidi/browser_launcher.rb +240 -0
  32. data/lib/puppeteer/bidi/connection.rb +182 -0
  33. data/lib/puppeteer/bidi/core/README.md +169 -0
  34. data/lib/puppeteer/bidi/core/browser.rb +230 -0
  35. data/lib/puppeteer/bidi/core/browsing_context.rb +601 -0
  36. data/lib/puppeteer/bidi/core/disposable.rb +69 -0
  37. data/lib/puppeteer/bidi/core/errors.rb +64 -0
  38. data/lib/puppeteer/bidi/core/event_emitter.rb +83 -0
  39. data/lib/puppeteer/bidi/core/navigation.rb +128 -0
  40. data/lib/puppeteer/bidi/core/realm.rb +315 -0
  41. data/lib/puppeteer/bidi/core/request.rb +300 -0
  42. data/lib/puppeteer/bidi/core/session.rb +153 -0
  43. data/lib/puppeteer/bidi/core/user_context.rb +208 -0
  44. data/lib/puppeteer/bidi/core/user_prompt.rb +102 -0
  45. data/lib/puppeteer/bidi/core.rb +45 -0
  46. data/lib/puppeteer/bidi/deserializer.rb +132 -0
  47. data/lib/puppeteer/bidi/element_handle.rb +602 -0
  48. data/lib/puppeteer/bidi/errors.rb +42 -0
  49. data/lib/puppeteer/bidi/file_chooser.rb +52 -0
  50. data/lib/puppeteer/bidi/frame.rb +597 -0
  51. data/lib/puppeteer/bidi/http_response.rb +23 -0
  52. data/lib/puppeteer/bidi/injected.js +1 -0
  53. data/lib/puppeteer/bidi/injected_source.rb +21 -0
  54. data/lib/puppeteer/bidi/js_handle.rb +302 -0
  55. data/lib/puppeteer/bidi/keyboard.rb +265 -0
  56. data/lib/puppeteer/bidi/lazy_arg.rb +23 -0
  57. data/lib/puppeteer/bidi/mouse.rb +170 -0
  58. data/lib/puppeteer/bidi/page.rb +613 -0
  59. data/lib/puppeteer/bidi/query_handler.rb +397 -0
  60. data/lib/puppeteer/bidi/realm.rb +242 -0
  61. data/lib/puppeteer/bidi/serializer.rb +139 -0
  62. data/lib/puppeteer/bidi/target.rb +81 -0
  63. data/lib/puppeteer/bidi/task_manager.rb +44 -0
  64. data/lib/puppeteer/bidi/timeout_settings.rb +20 -0
  65. data/lib/puppeteer/bidi/transport.rb +129 -0
  66. data/lib/puppeteer/bidi/version.rb +7 -0
  67. data/lib/puppeteer/bidi/wait_task.rb +322 -0
  68. data/lib/puppeteer/bidi.rb +49 -0
  69. data/scripts/update_injected_source.rb +57 -0
  70. data/sig/puppeteer/bidi/browser.rbs +80 -0
  71. data/sig/puppeteer/bidi/element_handle.rbs +238 -0
  72. data/sig/puppeteer/bidi/frame.rbs +205 -0
  73. data/sig/puppeteer/bidi/js_handle.rbs +90 -0
  74. data/sig/puppeteer/bidi/page.rbs +247 -0
  75. data/sig/puppeteer/bidi.rbs +15 -0
  76. 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)