puppeteer-bidi 0.0.3.beta1 → 0.0.3.beta2

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.
@@ -1,340 +0,0 @@
1
- # Click Implementation and Mouse Input
2
-
3
- This document provides comprehensive coverage of the click functionality implementation, including architecture, bug fixes, event handling, and BiDi protocol format requirements.
4
-
5
- ### Overview
6
-
7
- Implemented full click functionality following Puppeteer's architecture, including mouse input actions, element visibility detection, and automatic scrolling.
8
-
9
- ### Architecture
10
-
11
- ```
12
- Page#click
13
- ↓ delegates to
14
- Frame#click
15
- ↓ delegates to
16
- ElementHandle#click
17
- ↓ implementation
18
- 1. scroll_into_view_if_needed
19
- 2. clickable_point calculation
20
- 3. Mouse#click (BiDi input.performActions)
21
- ```
22
-
23
- ### Key Components
24
-
25
- #### Mouse Class (`lib/puppeteer/bidi/mouse.rb`)
26
-
27
- Implements mouse input actions via BiDi `input.performActions`:
28
-
29
- ```ruby
30
- def click(x, y, button: LEFT, count: 1, delay: nil)
31
- actions = []
32
- if @x != x || @y != y
33
- actions << {
34
- type: 'pointerMove',
35
- x: x.to_i,
36
- y: y.to_i,
37
- origin: 'viewport' # BiDi expects string, not hash!
38
- }
39
- end
40
- @x = x
41
- @y = y
42
- bidi_button = button_to_bidi(button)
43
- count.times do
44
- actions << { type: 'pointerDown', button: bidi_button }
45
- actions << { type: 'pause', duration: delay.to_i } if delay
46
- actions << { type: 'pointerUp', button: bidi_button }
47
- end
48
- perform_actions(actions)
49
- end
50
- ```
51
-
52
- **Critical BiDi Protocol Detail**: The `origin` parameter must be the string `'viewport'`, NOT a hash like `{type: 'viewport'}`. This caused a protocol error during initial implementation.
53
-
54
- #### ElementHandle Click Methods
55
-
56
- ##### scroll_into_view_if_needed
57
-
58
- Uses IntersectionObserver API to detect viewport visibility:
59
-
60
- ```ruby
61
- def scroll_into_view_if_needed
62
- return if intersecting_viewport?
63
-
64
- scroll_info = evaluate(<<~JS)
65
- element => {
66
- if (!element.isConnected) return 'Node is detached from document';
67
- if (element.nodeType !== Node.ELEMENT_NODE) return 'Node is not of type HTMLElement';
68
-
69
- element.scrollIntoView({
70
- block: 'center',
71
- inline: 'center',
72
- behavior: 'instant'
73
- });
74
- return false;
75
- }
76
- JS
77
-
78
- raise scroll_info if scroll_info
79
- end
80
- ```
81
-
82
- ##### intersecting_viewport?
83
-
84
- Uses browser's IntersectionObserver for accurate visibility detection:
85
-
86
- ```ruby
87
- def intersecting_viewport?(threshold: 0)
88
- evaluate(<<~JS, threshold)
89
- (element, threshold) => {
90
- return new Promise(resolve => {
91
- const observer = new IntersectionObserver(entries => {
92
- resolve(entries[0].intersectionRatio > threshold);
93
- observer.disconnect();
94
- });
95
- observer.observe(element);
96
- });
97
- }
98
- JS
99
- end
100
- ```
101
-
102
- ##### clickable_point
103
-
104
- Calculates click coordinates with optional offset:
105
-
106
- ```ruby
107
- def clickable_point(offset: nil)
108
- box = clickable_box
109
- if offset
110
- { x: box[:x] + offset[:x], y: box[:y] + offset[:y] }
111
- else
112
- { x: box[:x] + box[:width] / 2, y: box[:y] + box[:height] / 2 }
113
- end
114
- end
115
- ```
116
-
117
- ### Critical Bug Fixes
118
-
119
- #### 1. Missing session.subscribe Call
120
-
121
- **Problem**: Navigation events (browsingContext.load, etc.) were not firing, causing tests to timeout.
122
-
123
- **Root Cause**: Missing subscription to BiDi modules. Puppeteer subscribes to these modules on session creation:
124
- - browsingContext
125
- - network
126
- - log
127
- - script
128
- - input
129
-
130
- **Fix**: Added subscription in two places:
131
-
132
- ```ruby
133
- # lib/puppeteer/bidi/browser.rb
134
- subscribe_modules = %w[
135
- browsingContext
136
- network
137
- log
138
- script
139
- input
140
- ]
141
- @session.subscribe(subscribe_modules)
142
-
143
- # lib/puppeteer/bidi/core/session.rb
144
- def initialize_session
145
- subscribe_modules = %w[
146
- browsingContext
147
- network
148
- log
149
- script
150
- input
151
- ]
152
- subscribe(subscribe_modules)
153
- end
154
- ```
155
-
156
- **Impact**: This fix enabled all navigation-related functionality, including the "click links which cause navigation" test.
157
-
158
- #### 2. Event-Based URL Updates
159
-
160
- **Problem**: Initial implementation updated `@url` directly in `navigate()` method, which is not how Puppeteer works.
161
-
162
- **Puppeteer's Approach**: URL updates happen via BiDi events:
163
- - `browsingContext.historyUpdated`
164
- - `browsingContext.domContentLoaded`
165
- - `browsingContext.load`
166
-
167
- **Fix**: Removed direct URL assignment from navigate():
168
-
169
- ```ruby
170
- # lib/puppeteer/bidi/core/browsing_context.rb
171
- def navigate(url, wait: nil)
172
- raise BrowsingContextClosedError, @reason if closed?
173
- params = { context: @id, url: url }
174
- params[:wait] = wait if wait
175
- result = session.send_command('browsingContext.navigate', params)
176
- # URL will be updated via browsingContext.load event
177
- result
178
- end
179
- ```
180
-
181
- Event handlers (already implemented) update URL automatically:
182
-
183
- ```ruby
184
- # History updated
185
- session.on('browsingContext.historyUpdated') do |info|
186
- next unless info['context'] == @id
187
- @url = info['url']
188
- emit(:history_updated, nil)
189
- end
190
-
191
- # DOM content loaded
192
- session.on('browsingContext.domContentLoaded') do |info|
193
- next unless info['context'] == @id
194
- @url = info['url']
195
- emit(:dom_content_loaded, nil)
196
- end
197
-
198
- # Page loaded
199
- session.on('browsingContext.load') do |info|
200
- next unless info['context'] == @id
201
- @url = info['url']
202
- emit(:load, nil)
203
- end
204
- ```
205
-
206
- **Why this matters**: Event-based updates ensure URL synchronization even when navigation is triggered by user actions (like clicking links) rather than explicit `navigate()` calls.
207
-
208
- ### Test Coverage
209
-
210
- #### Click Tests (20 tests in spec/integration/click_spec.rb)
211
-
212
- Ported from [Puppeteer's click.spec.ts](https://github.com/puppeteer/puppeteer/blob/main/test/src/click.spec.ts):
213
-
214
- 1. **Basic clicking**: button, svg, wrapped links
215
- 2. **Edge cases**: window.Node removed, span with inline elements
216
- 3. **Navigation**: click after navigation, click links causing navigation
217
- 4. **Scrolling**: offscreen buttons, scrollable content
218
- 5. **Multi-click**: double click, triple click (text selection)
219
- 6. **Different buttons**: left, right (contextmenu), middle (auxclick)
220
- 7. **Visibility**: partially obscured button, rotated button
221
- 8. **Form elements**: checkbox toggle (input and label)
222
- 9. **Error handling**: missing selector
223
- 10. **Special cases**: disabled JavaScript, iframes (pending)
224
-
225
- #### Page Tests (3 tests in spec/integration/page_spec.rb)
226
-
227
- 1. **Page.url**: Verify URL updates after navigation
228
- 2. **Page.setJavaScriptEnabled**: Control JavaScript execution (pending - Firefox limitation)
229
-
230
- **All 108 integration tests pass** (4 pending due to Firefox BiDi limitations).
231
-
232
- ### Firefox BiDi Limitations
233
-
234
- - `emulation.setScriptingEnabled`: Part of WebDriver BiDi spec but not yet implemented in Firefox
235
- - Tests gracefully skip with clear messages using RSpec's `skip` feature
236
-
237
- ### Implementation Best Practices Learned
238
-
239
- #### 1. Always Consult Puppeteer's Implementation First
240
-
241
- **Workflow**:
242
- 1. Read Puppeteer's TypeScript implementation
243
- 2. Understand BiDi protocol calls being made
244
- 3. Implement Ruby equivalent with same logic flow
245
- 4. Port corresponding test cases
246
-
247
- **Example**: The click implementation journey revealed that Puppeteer's architecture (Page → Frame → ElementHandle delegation) is critical for proper functionality.
248
-
249
- #### 2. Stay Faithful to Puppeteer's Test Structure
250
-
251
- **Initial mistake**: Created complex polling logic for navigation test
252
- **Correction**: Simplified to match Puppeteer's simple approach:
253
-
254
- ```ruby
255
- # Simple and correct (matches Puppeteer)
256
- page.set_content("<a href=\"#{server.empty_page}\">empty.html</a>")
257
- page.click('a') # Should not hang
258
- ```
259
-
260
- #### 3. Event Subscription is Critical
261
-
262
- **Key lesson**: BiDi requires explicit subscription to event modules. Without it:
263
- - Navigation events don't fire
264
- - URL updates don't work
265
- - Tests timeout mysteriously
266
-
267
- **Solution**: Subscribe early in browser/session initialization.
268
-
269
- #### 4. Use RSpec `it` Syntax
270
-
271
- Per Ruby/RSpec conventions, use `it` instead of `example`:
272
-
273
- ```ruby
274
- # Correct
275
- it 'should click the button' do
276
- # ...
277
- end
278
-
279
- # Incorrect
280
- example 'should click the button' do
281
- # ...
282
- end
283
- ```
284
-
285
- ### BiDi Protocol Format Requirements
286
-
287
- #### Origin Parameter Format
288
-
289
- **Critical**: BiDi `input.performActions` expects `origin` as a string, not a hash:
290
-
291
- ```ruby
292
- # CORRECT
293
- origin: 'viewport'
294
-
295
- # WRONG - causes protocol error
296
- origin: { type: 'viewport' }
297
- ```
298
-
299
- **Error message if wrong**:
300
- ```
301
- Expected "origin" to be undefined, "viewport", "pointer", or an element,
302
- got: [object Object] {"type":"viewport"}
303
- ```
304
-
305
- ### Performance and Reliability
306
-
307
- - **IntersectionObserver**: Fast and accurate visibility detection
308
- - **Auto-scrolling**: Ensures elements are clickable before interaction
309
- - **Event-driven**: URL updates via events enable proper async handling
310
- - **Thread-safe**: BiDi protocol handles concurrent operations naturally
311
-
312
- ### Future Enhancements
313
-
314
- Potential improvements for click/mouse functionality:
315
-
316
- 1. **Drag and drop**: Implement drag operations
317
- 2. **Hover**: Mouse move without click
318
- 3. **Wheel**: Mouse wheel scrolling
319
- 4. **Touch**: Touch events for mobile emulation
320
- 5. **Keyboard modifiers**: Click with Ctrl/Shift/Alt
321
- 6. **Frame support**: Click inside iframes (currently pending)
322
-
323
- ### Reference Implementation
324
-
325
- Based on Puppeteer's implementation:
326
- - [Page.click](https://github.com/puppeteer/puppeteer/blob/main/packages/puppeteer-core/src/api/Page.ts)
327
- - [Frame.click](https://github.com/puppeteer/puppeteer/blob/main/packages/puppeteer-core/src/api/Frame.ts)
328
- - [ElementHandle.click](https://github.com/puppeteer/puppeteer/blob/main/packages/puppeteer-core/src/api/ElementHandle.ts)
329
- - [Mouse input](https://github.com/puppeteer/puppeteer/blob/main/packages/puppeteer-core/src/bidi/Input.ts)
330
- - [Test specs](https://github.com/puppeteer/puppeteer/blob/main/test/src/click.spec.ts)
331
-
332
- ### Key Takeaways
333
-
334
- 1. **session.subscribe is mandatory** for BiDi event handling - don't forget it!
335
- 2. **Event-based state management** (URL updates via events, not direct assignment)
336
- 3. **BiDi protocol details matter** (string vs hash for origin parameter)
337
- 4. **Follow Puppeteer's architecture** (delegation patterns, event handling)
338
- 5. **Test simplicity** - stay faithful to Puppeteer's test structure
339
- 6. **Browser limitations** - gracefully handle unimplemented features (setScriptingEnabled)
340
-
@@ -1,136 +0,0 @@
1
- # Core Layer Gotchas
2
-
3
- ## Overview
4
-
5
- This document covers non-obvious issues and pitfalls in the Core layer implementation.
6
-
7
- ## BrowsingContext: disposed? vs closed? Conflict
8
-
9
- ### The Problem
10
-
11
- `BrowsingContext` uses both `Disposable::DisposableMixin` and defines `alias disposed? closed?`:
12
-
13
- ```ruby
14
- class BrowsingContext < EventEmitter
15
- include Disposable::DisposableMixin
16
-
17
- def closed?
18
- !@reason.nil?
19
- end
20
-
21
- alias disposed? closed?
22
- end
23
- ```
24
-
25
- This creates a conflict:
26
- - `DisposableMixin#dispose` checks `disposed?` before proceeding
27
- - `disposed?` is aliased to `closed?`
28
- - `closed?` returns `true` when `@reason` is set
29
-
30
- ### The Bug
31
-
32
- Original `dispose_context` implementation:
33
-
34
- ```ruby
35
- def dispose_context(reason)
36
- @reason = reason # Sets @reason, making closed? return true
37
- dispose # dispose checks disposed?/closed?, sees true, returns early!
38
- end
39
- ```
40
-
41
- **Result**: `:closed` event was never emitted because `dispose` returned early.
42
-
43
- ### The Fix
44
-
45
- Set `@reason` AFTER calling `dispose`:
46
-
47
- ```ruby
48
- def dispose_context(reason)
49
- # IMPORTANT: Call dispose BEFORE setting @reason
50
- # Otherwise disposed?/closed? returns true and dispose returns early
51
- dispose
52
- @reason = reason
53
- end
54
- ```
55
-
56
- ### Additional Fix: Emit :closed Before @disposed = true
57
-
58
- `EventEmitter#emit` returns early if `@disposed` is true. But `DisposableMixin#dispose` sets `@disposed = true` before calling `perform_dispose`. This means any events emitted in `perform_dispose` would be ignored.
59
-
60
- Solution: Override `dispose` to emit `:closed` before calling `super`:
61
-
62
- ```ruby
63
- def dispose
64
- return if disposed?
65
-
66
- @reason ||= 'Browsing context closed'
67
- emit(:closed, { reason: @reason }) # Emit BEFORE @disposed = true
68
-
69
- super # This sets @disposed = true and calls perform_dispose
70
- end
71
- ```
72
-
73
- ## EventEmitter and DisposableMixin @disposed Interaction
74
-
75
- Both `EventEmitter` and `DisposableMixin` use `@disposed` instance variable:
76
-
77
- ```ruby
78
- # EventEmitter
79
- def emit(event, data = nil)
80
- return if @disposed # Early return if disposed
81
- # ...
82
- end
83
-
84
- def dispose
85
- @disposed = true
86
- @listeners.clear
87
- end
88
-
89
- # DisposableMixin
90
- def dispose
91
- return if @disposed
92
- @disposed = true
93
- perform_dispose
94
- end
95
- ```
96
-
97
- When a class includes both (like `BrowsingContext`), they share the same `@disposed` variable. This is usually fine, but be aware:
98
-
99
- 1. **Order matters**: If you need to emit events during disposal, do it BEFORE setting `@disposed = true`
100
- 2. **Check disposal state carefully**: Use `disposed?` method, not `@disposed` directly
101
- 3. **Override dispose if needed**: To emit events or do cleanup that requires the emitter to still be active
102
-
103
- ## Debugging Tips
104
-
105
- ### Enable BiDi Debug Logging
106
-
107
- ```bash
108
- DEBUG_BIDI_COMMAND=1 bundle exec rspec spec/integration/frame_spec.rb
109
- ```
110
-
111
- ### Track Disposal State
112
-
113
- Add temporary debug logs:
114
-
115
- ```ruby
116
- def dispose
117
- puts "[DEBUG] dispose called for #{@id}, disposed?=#{disposed?}"
118
- # ...
119
- end
120
- ```
121
-
122
- ### Check Event Listener Registration
123
-
124
- Verify listeners are registered on the correct instance:
125
-
126
- ```ruby
127
- browsing_context.once(:closed) do
128
- puts "[DEBUG] :closed received for #{browsing_context.id}"
129
- end
130
- ```
131
-
132
- ## Related Files
133
-
134
- - `lib/puppeteer/bidi/core/browsing_context.rb` - BrowsingContext implementation
135
- - `lib/puppeteer/bidi/core/event_emitter.rb` - EventEmitter base class
136
- - `lib/puppeteer/bidi/core/disposable.rb` - DisposableMixin module
@@ -1,232 +0,0 @@
1
- # Error Handling and Custom Exceptions
2
-
3
- This document covers the custom exception hierarchy, implementation patterns, and benefits of type-safe error handling in puppeteer-bidi.
4
-
5
- ### Philosophy
6
-
7
- Use custom exception classes instead of inline string raises for:
8
- - **Type safety**: Enable `rescue` by specific exception type
9
- - **DRY principle**: Centralize error messages
10
- - **Debugging**: Attach contextual data to exception objects
11
- - **Consistency**: Uniform error handling across codebase
12
-
13
- ### Custom Exception Hierarchy
14
-
15
- ```ruby
16
- StandardError
17
- └── Puppeteer::Bidi::Error
18
- ├── JSHandleDisposedError
19
- ├── PageClosedError
20
- ├── FrameDetachedError
21
- └── SelectorNotFoundError
22
- ```
23
-
24
- All custom exceptions inherit from `Puppeteer::Bidi::Error` for consistent rescue patterns.
25
-
26
- ### Exception Classes
27
-
28
- #### JSHandleDisposedError
29
-
30
- **When raised**: Attempting to use a disposed JSHandle or ElementHandle
31
-
32
- **Location**: `lib/puppeteer/bidi/errors.rb`
33
-
34
- ```ruby
35
- class JSHandleDisposedError < Error
36
- def initialize
37
- super('JSHandle is disposed')
38
- end
39
- end
40
- ```
41
-
42
- **Usage**:
43
- ```ruby
44
- # JSHandle and ElementHandle
45
- private
46
-
47
- def assert_not_disposed
48
- raise JSHandleDisposedError if @disposed
49
- end
50
- ```
51
-
52
- **Affected methods**:
53
- - `JSHandle#evaluate`, `#evaluate_handle`, `#get_property`, `#get_properties`, `#json_value`
54
- - `ElementHandle#query_selector`, `#query_selector_all`, `#eval_on_selector`, `#eval_on_selector_all`
55
-
56
- #### PageClosedError
57
-
58
- **When raised**: Attempting to use a closed Page
59
-
60
- **Location**: `lib/puppeteer/bidi/errors.rb`
61
-
62
- ```ruby
63
- class PageClosedError < Error
64
- def initialize
65
- super('Page is closed')
66
- end
67
- end
68
- ```
69
-
70
- **Usage**:
71
- ```ruby
72
- # Page
73
- private
74
-
75
- def assert_not_closed
76
- raise PageClosedError if closed?
77
- end
78
- ```
79
-
80
- **Affected methods**:
81
- - `Page#goto`, `#set_content`, `#screenshot`
82
-
83
- #### FrameDetachedError
84
-
85
- **When raised**: Attempting to use a detached Frame
86
-
87
- **Location**: `lib/puppeteer/bidi/errors.rb`
88
-
89
- ```ruby
90
- class FrameDetachedError < Error
91
- def initialize
92
- super('Frame is detached')
93
- end
94
- end
95
- ```
96
-
97
- **Usage**:
98
- ```ruby
99
- # Frame
100
- private
101
-
102
- def assert_not_detached
103
- raise FrameDetachedError if @browsing_context.closed?
104
- end
105
- ```
106
-
107
- **Affected methods**:
108
- - `Frame#evaluate`, `#evaluate_handle`, `#document`
109
-
110
- #### SelectorNotFoundError
111
-
112
- **When raised**: CSS selector doesn't match any elements in `eval_on_selector`
113
-
114
- **Location**: `lib/puppeteer/bidi/errors.rb`
115
-
116
- ```ruby
117
- class SelectorNotFoundError < Error
118
- attr_reader :selector
119
-
120
- def initialize(selector)
121
- @selector = selector
122
- super("Error: failed to find element matching selector \"#{selector}\"")
123
- end
124
- end
125
- ```
126
-
127
- **Usage**:
128
- ```ruby
129
- # ElementHandle#eval_on_selector
130
- element_handle = query_selector(selector)
131
- raise SelectorNotFoundError, selector unless element_handle
132
- ```
133
-
134
- **Contextual data**: The `selector` value is accessible via the exception object for debugging.
135
-
136
- ### Implementation Pattern
137
-
138
- #### 1. Define Custom Exception
139
-
140
- ```ruby
141
- # lib/puppeteer/bidi/errors.rb
142
- class MyCustomError < Error
143
- def initialize(context = nil)
144
- @context = context
145
- super("Error message with #{context}")
146
- end
147
- end
148
- ```
149
-
150
- #### 2. Add Private Assertion Method
151
-
152
- ```ruby
153
- class MyClass
154
- private
155
-
156
- def assert_valid_state
157
- raise MyCustomError, @context if invalid?
158
- end
159
- end
160
- ```
161
-
162
- #### 3. Replace Inline Raises
163
-
164
- ```ruby
165
- # Before
166
- def my_method
167
- raise 'Invalid state' if invalid?
168
- # ...
169
- end
170
-
171
- # After
172
- def my_method
173
- assert_valid_state
174
- # ...
175
- end
176
- ```
177
-
178
- ### Benefits
179
-
180
- **Type-safe error handling**:
181
- ```ruby
182
- begin
183
- page.eval_on_selector('.missing', 'e => e.id')
184
- rescue SelectorNotFoundError => e
185
- puts "Selector '#{e.selector}' not found"
186
- rescue JSHandleDisposedError
187
- puts "Handle was disposed"
188
- end
189
- ```
190
-
191
- **Consistent error messages**: Single source of truth for error text
192
-
193
- **Reduced duplication**: 16 inline raises eliminated across codebase
194
-
195
- **Better debugging**: Exception objects carry contextual information
196
-
197
- ### Testing Custom Exceptions
198
-
199
- Tests use regex matching for backward compatibility:
200
-
201
- ```ruby
202
- # Test remains compatible with custom exception
203
- expect {
204
- page.eval_on_selector('non-existing', 'e => e.id')
205
- }.to raise_error(/failed to find element matching selector/)
206
- ```
207
-
208
- This allows tests to pass with both string raises and custom exceptions.
209
-
210
- ### Refactoring Statistics
211
-
212
- | Class | Inline Raises Replaced | Private Assert Method |
213
- |-------|------------------------|----------------------|
214
- | JSHandle | 5 | `assert_not_disposed` |
215
- | ElementHandle | 4 + 1 (selector) | (inherited) |
216
- | Page | 3 | `assert_not_closed` |
217
- | Frame | 3 | `assert_not_detached` |
218
- | **Total** | **16** | **3 methods** |
219
-
220
- ### Future Considerations
221
-
222
- When adding new error conditions:
223
-
224
- 1. **Create custom exception** in `lib/puppeteer/bidi/errors.rb`
225
- 2. **Add to exception hierarchy** by inheriting from `Error`
226
- 3. **Include contextual data** as `attr_reader` if needed
227
- 4. **Create private assert method** in the relevant class
228
- 5. **Replace inline raises** with assert method calls
229
- 6. **Update tests** to use regex matching for flexibility
230
-
231
- This pattern ensures consistency and maintainability across the entire codebase.
232
-