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,180 @@
|
|
|
1
|
+
# Two-Layer Async Architecture
|
|
2
|
+
|
|
3
|
+
This codebase implements a two-layer architecture to separate async complexity from user-facing APIs.
|
|
4
|
+
|
|
5
|
+
## Architecture Overview
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
┌─────────────────────────────────────────────────────────┐
|
|
9
|
+
│ Upper Layer (Puppeteer::Bidi) │
|
|
10
|
+
│ - User-facing, synchronous API │
|
|
11
|
+
│ - Calls .wait internally on Core layer methods │
|
|
12
|
+
│ - Examples: Page, Frame, JSHandle, ElementHandle │
|
|
13
|
+
├─────────────────────────────────────────────────────────┤
|
|
14
|
+
│ Core Layer (Puppeteer::Bidi::Core) │
|
|
15
|
+
│ - Returns Async::Task for all async operations │
|
|
16
|
+
│ - Uses async_send_command internally │
|
|
17
|
+
│ - Examples: Session, BrowsingContext, Realm │
|
|
18
|
+
└─────────────────────────────────────────────────────────┘
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Design Principles
|
|
22
|
+
|
|
23
|
+
1. **Core Layer (Puppeteer::Bidi::Core)**:
|
|
24
|
+
- All methods that communicate with BiDi protocol return `Async::Task`
|
|
25
|
+
- Uses `session.async_send_command` (not `send_command`)
|
|
26
|
+
- Methods are explicitly async and composable
|
|
27
|
+
- Examples: `BrowsingContext#navigate`, `Realm#call_function`
|
|
28
|
+
|
|
29
|
+
2. **Upper Layer (Puppeteer::Bidi)**:
|
|
30
|
+
- All methods call `.wait` on Core layer async operations
|
|
31
|
+
- Provides synchronous, blocking API for users
|
|
32
|
+
- Users never see `Async::Task` directly
|
|
33
|
+
- Examples: `Page#goto`, `Frame#evaluate`, `JSHandle#get_property`
|
|
34
|
+
|
|
35
|
+
## Implementation Patterns
|
|
36
|
+
|
|
37
|
+
### Core Layer Pattern
|
|
38
|
+
|
|
39
|
+
```ruby
|
|
40
|
+
# lib/puppeteer/bidi/core/browsing_context.rb
|
|
41
|
+
def navigate(url, wait: nil)
|
|
42
|
+
Async do
|
|
43
|
+
raise BrowsingContextClosedError, @reason if closed?
|
|
44
|
+
params = { context: @id, url: url }
|
|
45
|
+
params[:wait] = wait if wait
|
|
46
|
+
result = session.async_send_command('browsingContext.navigate', params).wait
|
|
47
|
+
result
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def perform_actions(actions)
|
|
52
|
+
raise BrowsingContextClosedError, @reason if closed?
|
|
53
|
+
session.async_send_command('input.performActions', {
|
|
54
|
+
context: @id,
|
|
55
|
+
actions: actions
|
|
56
|
+
})
|
|
57
|
+
end
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
**Key points:**
|
|
61
|
+
- Returns `Async::Task` (implicitly from `Async do` block or explicitly from `async_send_command`)
|
|
62
|
+
- Users of Core layer must call `.wait` to get results
|
|
63
|
+
|
|
64
|
+
### Upper Layer Pattern
|
|
65
|
+
|
|
66
|
+
```ruby
|
|
67
|
+
# lib/puppeteer/bidi/frame.rb
|
|
68
|
+
def goto(url, wait_until: 'load', timeout: 30000)
|
|
69
|
+
response = wait_for_navigation(timeout: timeout, wait_until: wait_until) do
|
|
70
|
+
@browsing_context.navigate(url, wait: 'interactive').wait # .wait call
|
|
71
|
+
end
|
|
72
|
+
HTTPResponse.new(url: @browsing_context.url, status: 200)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# lib/puppeteer/bidi/keyboard.rb
|
|
76
|
+
def perform_actions(action_list)
|
|
77
|
+
@browsing_context.perform_actions([
|
|
78
|
+
{
|
|
79
|
+
type: 'key',
|
|
80
|
+
id: 'default keyboard',
|
|
81
|
+
actions: action_list
|
|
82
|
+
}
|
|
83
|
+
]).wait # .wait call
|
|
84
|
+
end
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
**Key points:**
|
|
88
|
+
- Always calls `.wait` on Core layer methods
|
|
89
|
+
- Returns plain Ruby objects (String, Hash, etc.), not Async::Task
|
|
90
|
+
- User-facing API is synchronous
|
|
91
|
+
|
|
92
|
+
## Common Mistakes and How to Fix Them
|
|
93
|
+
|
|
94
|
+
### Mistake 1: Forgetting .wait on Core Layer Methods
|
|
95
|
+
|
|
96
|
+
```ruby
|
|
97
|
+
# WRONG: Missing .wait
|
|
98
|
+
def query_selector(selector)
|
|
99
|
+
result = @realm.call_function(...)
|
|
100
|
+
if result['type'] == 'exception' # Error: undefined method '[]' for Async::Task
|
|
101
|
+
# ...
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# CORRECT: Add .wait
|
|
106
|
+
def query_selector(selector)
|
|
107
|
+
result = @realm.call_function(...).wait # Add .wait here
|
|
108
|
+
if result['type'] == 'exception'
|
|
109
|
+
# ...
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
### Mistake 2: Using send_command Instead of async_send_command in Core Layer
|
|
115
|
+
|
|
116
|
+
```ruby
|
|
117
|
+
# WRONG: Using send_command (doesn't exist)
|
|
118
|
+
def perform_actions(actions)
|
|
119
|
+
session.send_command('input.performActions', {...}) # Error: undefined method
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# CORRECT: Use async_send_command
|
|
123
|
+
def perform_actions(actions)
|
|
124
|
+
session.async_send_command('input.performActions', {...})
|
|
125
|
+
end
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
### Mistake 3: Not Calling .wait on All Core Methods
|
|
129
|
+
|
|
130
|
+
```ruby
|
|
131
|
+
# WRONG: Multiple Core calls, only one .wait
|
|
132
|
+
def get_properties
|
|
133
|
+
result = @realm.call_function(...).wait # OK
|
|
134
|
+
props_result = @realm.call_function(...) # Missing .wait!
|
|
135
|
+
if props_result['type'] == 'exception' # Error
|
|
136
|
+
# ...
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# CORRECT: Add .wait to all Core calls
|
|
141
|
+
def get_properties
|
|
142
|
+
result = @realm.call_function(...).wait
|
|
143
|
+
props_result = @realm.call_function(...).wait # Add .wait
|
|
144
|
+
if props_result['type'] == 'exception'
|
|
145
|
+
# ...
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
## Checklist for Adding New Methods
|
|
151
|
+
|
|
152
|
+
When adding new methods to the Upper Layer:
|
|
153
|
+
|
|
154
|
+
1. Identify all calls to Core layer methods
|
|
155
|
+
2. Add `.wait` to each Core layer method call
|
|
156
|
+
3. Verify the method returns plain Ruby objects, not Async::Task
|
|
157
|
+
4. Test with integration specs
|
|
158
|
+
|
|
159
|
+
When adding new methods to the Core Layer:
|
|
160
|
+
|
|
161
|
+
1. Use `session.async_send_command` (not `send_command`)
|
|
162
|
+
2. Wrap in `Async do ... end` if needed
|
|
163
|
+
3. Return `Async::Task` (don't call .wait)
|
|
164
|
+
4. Document that callers must call .wait
|
|
165
|
+
|
|
166
|
+
## Files Modified for Async Architecture
|
|
167
|
+
|
|
168
|
+
**Core Layer:**
|
|
169
|
+
- `lib/puppeteer/bidi/core/session.rb` - Changed `send_command` → `async_send_command`
|
|
170
|
+
- `lib/puppeteer/bidi/core/browsing_context.rb` - All methods use `async_send_command`
|
|
171
|
+
- `lib/puppeteer/bidi/core/realm.rb` - Methods return `Async::Task`
|
|
172
|
+
|
|
173
|
+
**Upper Layer:**
|
|
174
|
+
- `lib/puppeteer/bidi/realm.rb` - Added `.wait` to `execute_with_core`, `call_function`
|
|
175
|
+
- `lib/puppeteer/bidi/frame.rb` - Added `.wait` to `goto`
|
|
176
|
+
- `lib/puppeteer/bidi/js_handle.rb` - Added `.wait` to `dispose`, `get_property`, `get_properties`, `as_element`
|
|
177
|
+
- `lib/puppeteer/bidi/element_handle.rb` - Added `.wait` to `query_selector_all`, `eval_on_selector_all`
|
|
178
|
+
- `lib/puppeteer/bidi/keyboard.rb` - Added `.wait` to `perform_actions`
|
|
179
|
+
- `lib/puppeteer/bidi/mouse.rb` - Added `.wait` to `perform_actions`
|
|
180
|
+
- `lib/puppeteer/bidi/page.rb` - Added `.wait` to `capture_screenshot`, `close`, `set_viewport`, `set_javascript_enabled`
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
# Wrapped Element Click Implementation
|
|
2
|
+
|
|
3
|
+
## Problem
|
|
4
|
+
|
|
5
|
+
When clicking on wrapped or multi-line text elements, using `getBoundingClientRect()` returns a single large bounding box that may have empty space in the center, causing clicks to miss the actual element.
|
|
6
|
+
|
|
7
|
+
## Example: Wrapped Link
|
|
8
|
+
|
|
9
|
+
```html
|
|
10
|
+
<div style="width: 10ch; word-wrap: break-word;">
|
|
11
|
+
<a href='#clicked'>123321</a>
|
|
12
|
+
</div>
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
The link text wraps into two lines:
|
|
16
|
+
```
|
|
17
|
+
123
|
|
18
|
+
321
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
### getBoundingClientRect() Problem
|
|
22
|
+
|
|
23
|
+
Returns single large box:
|
|
24
|
+
```ruby
|
|
25
|
+
{x: 628.45, y: 62.47, width: 109.1, height: 49.73}
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Click point (center): `(683, 87)` → **hits empty space between lines!**
|
|
29
|
+
|
|
30
|
+
### getClientRects() Solution
|
|
31
|
+
|
|
32
|
+
Returns multiple boxes for wrapped text:
|
|
33
|
+
```ruby
|
|
34
|
+
[
|
|
35
|
+
{x: 708.58, y: 62.47, width: 32.73, height: 22.67}, # "123"
|
|
36
|
+
{x: 628.45, y: 85.15, width: 32.73, height: 22.67} # "321"
|
|
37
|
+
]
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Click point (first box center): `(725, 73)` → **hits actual text!**
|
|
41
|
+
|
|
42
|
+
## Implementation
|
|
43
|
+
|
|
44
|
+
### clickable_box Method
|
|
45
|
+
|
|
46
|
+
```ruby
|
|
47
|
+
def clickable_box
|
|
48
|
+
assert_not_disposed
|
|
49
|
+
|
|
50
|
+
# Get client rects - returns multiple boxes for wrapped elements
|
|
51
|
+
boxes = evaluate(<<~JS)
|
|
52
|
+
element => {
|
|
53
|
+
if (!(element instanceof Element)) {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
return [...element.getClientRects()].map(rect => {
|
|
57
|
+
return {x: rect.x, y: rect.y, width: rect.width, height: rect.height};
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
JS
|
|
61
|
+
|
|
62
|
+
return nil unless boxes&.is_a?(Array) && !boxes.empty?
|
|
63
|
+
|
|
64
|
+
# Intersect boxes with frame boundaries
|
|
65
|
+
intersect_bounding_boxes_with_frame(boxes)
|
|
66
|
+
|
|
67
|
+
# Find first box with valid dimensions
|
|
68
|
+
box = boxes.find { |rect| rect['width'] >= 1 && rect['height'] >= 1 }
|
|
69
|
+
return nil unless box
|
|
70
|
+
|
|
71
|
+
{
|
|
72
|
+
x: box['x'],
|
|
73
|
+
y: box['y'],
|
|
74
|
+
width: box['width'],
|
|
75
|
+
height: box['height']
|
|
76
|
+
}
|
|
77
|
+
end
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### Viewport Clipping: intersectBoundingBoxesWithFrame
|
|
81
|
+
|
|
82
|
+
Clips element boxes to visible viewport boundaries:
|
|
83
|
+
|
|
84
|
+
```ruby
|
|
85
|
+
def intersect_bounding_boxes_with_frame(boxes)
|
|
86
|
+
# Get document dimensions using element's evaluate
|
|
87
|
+
dimensions = evaluate(<<~JS)
|
|
88
|
+
element => {
|
|
89
|
+
return {
|
|
90
|
+
documentWidth: element.ownerDocument.documentElement.clientWidth,
|
|
91
|
+
documentHeight: element.ownerDocument.documentElement.clientHeight
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
JS
|
|
95
|
+
|
|
96
|
+
document_width = dimensions['documentWidth']
|
|
97
|
+
document_height = dimensions['documentHeight']
|
|
98
|
+
|
|
99
|
+
boxes.each do |box|
|
|
100
|
+
intersect_bounding_box(box, document_width, document_height)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def intersect_bounding_box(box, width, height)
|
|
105
|
+
# Clip width
|
|
106
|
+
box['width'] = [
|
|
107
|
+
box['x'] >= 0 ?
|
|
108
|
+
[width - box['x'], box['width']].min :
|
|
109
|
+
[width, box['width'] + box['x']].min,
|
|
110
|
+
0
|
|
111
|
+
].max
|
|
112
|
+
|
|
113
|
+
# Clip height
|
|
114
|
+
box['height'] = [
|
|
115
|
+
box['y'] >= 0 ?
|
|
116
|
+
[height - box['y'], box['height']].min :
|
|
117
|
+
[height, box['height'] + box['y']].min,
|
|
118
|
+
0
|
|
119
|
+
].max
|
|
120
|
+
|
|
121
|
+
# Ensure non-negative coordinates
|
|
122
|
+
box['x'] = [box['x'], 0].max
|
|
123
|
+
box['y'] = [box['y'], 0].max
|
|
124
|
+
end
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## Why This Matters
|
|
128
|
+
|
|
129
|
+
### Use Cases for getClientRects()
|
|
130
|
+
|
|
131
|
+
1. **Wrapped text**: Multi-line links, buttons with text wrapping
|
|
132
|
+
2. **Inline elements**: `<span>` elements that span multiple lines
|
|
133
|
+
3. **Complex layouts**: Elements with transforms, rotations
|
|
134
|
+
|
|
135
|
+
### Algorithm Flow
|
|
136
|
+
|
|
137
|
+
1. **Get all client rects** for element (array of boxes)
|
|
138
|
+
2. **Clip to viewport** using intersectBoundingBox algorithm
|
|
139
|
+
3. **Find first valid box** (width >= 1 && height >= 1)
|
|
140
|
+
4. **Click center of that box**
|
|
141
|
+
|
|
142
|
+
## Puppeteer Reference
|
|
143
|
+
|
|
144
|
+
Based on [ElementHandle.ts#clickableBox](https://github.com/puppeteer/puppeteer/blob/main/packages/puppeteer-core/src/api/ElementHandle.ts):
|
|
145
|
+
|
|
146
|
+
```typescript
|
|
147
|
+
async #clickableBox(): Promise<BoundingBox | null> {
|
|
148
|
+
const boxes = await this.evaluate(element => {
|
|
149
|
+
if (!(element instanceof Element)) {
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
return [...element.getClientRects()].map(rect => {
|
|
153
|
+
return {x: rect.x, y: rect.y, width: rect.width, height: rect.height};
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
if (!boxes?.length) {
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
await this.#intersectBoundingBoxesWithFrame(boxes);
|
|
162
|
+
|
|
163
|
+
// ... parent frame handling ...
|
|
164
|
+
|
|
165
|
+
const box = boxes.find(box => {
|
|
166
|
+
return box.width >= 1 && box.height >= 1;
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
return box || null;
|
|
170
|
+
}
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
## Testing
|
|
174
|
+
|
|
175
|
+
### Test Asset
|
|
176
|
+
|
|
177
|
+
Official Puppeteer test asset: `spec/assets/wrappedlink.html`
|
|
178
|
+
|
|
179
|
+
```html
|
|
180
|
+
<div style="width: 10ch; word-wrap: break-word; transform: rotate(33deg);">
|
|
181
|
+
<a href='#clicked'>123321</a>
|
|
182
|
+
</div>
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
**Critical**: Always use official test assets without modification!
|
|
186
|
+
|
|
187
|
+
### Test Case
|
|
188
|
+
|
|
189
|
+
```ruby
|
|
190
|
+
it 'should click wrapped links' do
|
|
191
|
+
with_test_state do |page:, server:, **|
|
|
192
|
+
page.goto("#{server.prefix}/wrappedlink.html")
|
|
193
|
+
page.click('a')
|
|
194
|
+
result = page.evaluate('window.__clicked')
|
|
195
|
+
expect(result).to be true
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
## Debugging Protocol Messages
|
|
201
|
+
|
|
202
|
+
Compare BiDi protocol messages with Puppeteer to verify coordinates:
|
|
203
|
+
|
|
204
|
+
```bash
|
|
205
|
+
# Ruby implementation
|
|
206
|
+
DEBUG_BIDI_COMMAND=1 bundle exec rspec spec/integration/click_spec.rb:138
|
|
207
|
+
|
|
208
|
+
# Look for input.performActions with click coordinates
|
|
209
|
+
# Verify they fall within actual element bounds
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
## Key Takeaways
|
|
213
|
+
|
|
214
|
+
1. **getClientRects() > getBoundingClientRect()** for clickable elements
|
|
215
|
+
2. **First valid box** is the click target (not center of bounding box)
|
|
216
|
+
3. **Viewport clipping** ensures clicks stay within visible area
|
|
217
|
+
4. **Test with official assets** - simplified versions hide edge cases
|
|
218
|
+
5. **Follow Puppeteer exactly** - algorithm has been battle-tested
|
|
219
|
+
|
|
220
|
+
## Performance
|
|
221
|
+
|
|
222
|
+
- `getClientRects()` is fast (native browser API)
|
|
223
|
+
- Intersection algorithm is O(n) where n = number of boxes (typically 1-3)
|
|
224
|
+
- No additional round-trips to browser
|
|
225
|
+
|
|
226
|
+
## Future: Parent Frame Support
|
|
227
|
+
|
|
228
|
+
For iframe support, add coordinate transformation:
|
|
229
|
+
|
|
230
|
+
```ruby
|
|
231
|
+
# TODO: Handle parent frames
|
|
232
|
+
frame = self.frame
|
|
233
|
+
while (parent_frame = frame.parent_frame)
|
|
234
|
+
# Adjust coordinates for parent frame offset
|
|
235
|
+
# boxes.each { |box| box['x'] += offset_x; box['y'] += offset_y }
|
|
236
|
+
end
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
## Files
|
|
240
|
+
|
|
241
|
+
- `lib/puppeteer/bidi/element_handle.rb`: clickable_box, intersect methods
|
|
242
|
+
- `spec/integration/click_spec.rb`: Test case for wrapped links
|
|
243
|
+
- `spec/assets/wrappedlink.html`: Official test asset (never modify!)
|
|
244
|
+
|
|
245
|
+
## Commit Reference
|
|
246
|
+
|
|
247
|
+
See commit: "Implement clickable_box with getClientRects() and viewport clipping"
|
data/CLAUDE.md
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
# Puppeteer-BiDi Development Guide
|
|
2
|
+
|
|
3
|
+
## Project Overview
|
|
4
|
+
|
|
5
|
+
Port the WebDriver BiDi protocol portions of Puppeteer to Ruby, providing a standards-based tool for Firefox automation.
|
|
6
|
+
|
|
7
|
+
### Development Principles
|
|
8
|
+
|
|
9
|
+
- **BiDi-only**: Do not port CDP protocol-related code
|
|
10
|
+
- **Standards compliance**: Adhere to W3C WebDriver BiDi specification
|
|
11
|
+
- **Firefox optimization**: Maximize BiDi protocol capabilities
|
|
12
|
+
- **Ruby conventions**: Design Ruby-idiomatic interfaces
|
|
13
|
+
|
|
14
|
+
## Quick Reference
|
|
15
|
+
|
|
16
|
+
### Running Tests
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
# All integration tests (requires Firefox Nightly for full functionality)
|
|
20
|
+
bundle exec rspec spec/integration/
|
|
21
|
+
|
|
22
|
+
# Single test file
|
|
23
|
+
bundle exec rspec spec/integration/click_spec.rb
|
|
24
|
+
|
|
25
|
+
# Non-headless mode
|
|
26
|
+
HEADLESS=false bundle exec rspec spec/integration/
|
|
27
|
+
|
|
28
|
+
# Debug protocol messages
|
|
29
|
+
DEBUG_BIDI_COMMAND=1 bundle exec rspec spec/integration/click_spec.rb
|
|
30
|
+
|
|
31
|
+
# Use specific Firefox path (e.g., Nightly)
|
|
32
|
+
FIREFOX_PATH="/Applications/Firefox Nightly.app/Contents/MacOS/firefox" bundle exec rspec
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
**Note**: Some features (e.g., FileChooser) require Firefox Nightly. The browser launcher prioritizes Nightly automatically.
|
|
36
|
+
|
|
37
|
+
### Key Architecture
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
Upper Layer (Puppeteer::Bidi) - User-facing synchronous API
|
|
41
|
+
└── Page, Frame, ElementHandle, JSHandle
|
|
42
|
+
|
|
43
|
+
Core Layer (Puppeteer::Bidi::Core) - Async operations, returns Async::Task
|
|
44
|
+
└── Session, BrowsingContext, Realm
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
**Critical**: Upper layer methods must call `.wait` on all Core layer method calls. See [Two-Layer Architecture](CLAUDE/two_layer_architecture.md).
|
|
48
|
+
|
|
49
|
+
### Async Programming
|
|
50
|
+
|
|
51
|
+
This project uses **Async (Fiber-based)**, NOT concurrent-ruby (Thread-based).
|
|
52
|
+
|
|
53
|
+
- No Mutex needed (cooperative multitasking)
|
|
54
|
+
- Similar to JavaScript async/await
|
|
55
|
+
- See [Async Programming Guide](CLAUDE/async_programming.md)
|
|
56
|
+
|
|
57
|
+
## Development Workflow
|
|
58
|
+
|
|
59
|
+
1. Study Puppeteer's TypeScript implementation first
|
|
60
|
+
2. Understand BiDi protocol calls
|
|
61
|
+
3. Implement with proper deserialization
|
|
62
|
+
4. Port tests from Puppeteer
|
|
63
|
+
5. See [Porting Puppeteer Guide](CLAUDE/porting_puppeteer.md)
|
|
64
|
+
|
|
65
|
+
## Coding Conventions
|
|
66
|
+
|
|
67
|
+
### Ruby
|
|
68
|
+
|
|
69
|
+
- Use Ruby 3.0+ features
|
|
70
|
+
- Follow RuboCop guidelines
|
|
71
|
+
- Class names: `PascalCase`, Methods: `snake_case`, Constants: `SCREAMING_SNAKE_CASE`
|
|
72
|
+
|
|
73
|
+
### Type Annotations (rbs-inline)
|
|
74
|
+
|
|
75
|
+
Use [rbs-inline](https://github.com/soutaro/rbs-inline) for type annotations in Ruby source files.
|
|
76
|
+
|
|
77
|
+
**Setup:**
|
|
78
|
+
- Add `# rbs_inline: enabled` magic comment at the top of the file
|
|
79
|
+
- Use Doc style syntax for type annotations
|
|
80
|
+
|
|
81
|
+
**Example:**
|
|
82
|
+
```ruby
|
|
83
|
+
# frozen_string_literal: true
|
|
84
|
+
# rbs_inline: enabled
|
|
85
|
+
|
|
86
|
+
class Example
|
|
87
|
+
attr_reader :name #: String
|
|
88
|
+
|
|
89
|
+
# @rbs name: String
|
|
90
|
+
# @rbs return: void
|
|
91
|
+
def initialize(name)
|
|
92
|
+
@name = name
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# @rbs selector: String
|
|
96
|
+
# @rbs return: ElementHandle?
|
|
97
|
+
def query_selector(selector)
|
|
98
|
+
# ...
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
**Conventions:**
|
|
104
|
+
- Use `A?` for nullable types (not `A | nil`)
|
|
105
|
+
- Use `A | B | nil` for union types with nil
|
|
106
|
+
- Add space around `--` for inline comments: `# @rbs name: String -- the name`
|
|
107
|
+
- Public methods should have type annotations
|
|
108
|
+
|
|
109
|
+
**Generate RBS files:**
|
|
110
|
+
```bash
|
|
111
|
+
bundle exec rake rbs # Generates sig/**/*.rbs
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
**Note:** `sig/` directory is gitignored. RBS files are generated automatically during `rake build`.
|
|
115
|
+
|
|
116
|
+
### Testing
|
|
117
|
+
|
|
118
|
+
- Use RSpec for unit and integration tests
|
|
119
|
+
- Integration tests in `spec/integration/`
|
|
120
|
+
- Use `with_test_state` helper for browser reuse
|
|
121
|
+
|
|
122
|
+
### Test Assets
|
|
123
|
+
|
|
124
|
+
**CRITICAL**: Always use Puppeteer's official test assets without modification.
|
|
125
|
+
|
|
126
|
+
- Source: https://github.com/puppeteer/puppeteer/tree/main/test/assets
|
|
127
|
+
- Never modify files in `spec/assets/`
|
|
128
|
+
- Revert any experimental changes before PRs
|
|
129
|
+
|
|
130
|
+
## Detailed Documentation
|
|
131
|
+
|
|
132
|
+
See the [CLAUDE/](CLAUDE/) directory for detailed implementation guides:
|
|
133
|
+
|
|
134
|
+
### Architecture & Patterns
|
|
135
|
+
|
|
136
|
+
- **[Two-Layer Architecture](CLAUDE/two_layer_architecture.md)** - Core vs Upper layer, async patterns
|
|
137
|
+
- **[Async Programming](CLAUDE/async_programming.md)** - Fiber-based concurrency with socketry/async
|
|
138
|
+
- **[Porting Puppeteer](CLAUDE/porting_puppeteer.md)** - Best practices for implementing features
|
|
139
|
+
- **[Core Layer Gotchas](CLAUDE/core_layer_gotchas.md)** - EventEmitter/Disposable pitfalls, @disposed conflicts
|
|
140
|
+
|
|
141
|
+
### Implementation Details
|
|
142
|
+
|
|
143
|
+
- **[QueryHandler](CLAUDE/query_handler.md)** - CSS, XPath, text selector handling
|
|
144
|
+
- **[JavaScript Evaluation](CLAUDE/javascript_evaluation.md)** - `evaluate()` and `evaluate_handle()`
|
|
145
|
+
- **[JSHandle and ElementHandle](CLAUDE/jshandle_implementation.md)** - Object handle management
|
|
146
|
+
- **[Selector Evaluation](CLAUDE/selector_evaluation.md)** - `eval_on_selector` methods
|
|
147
|
+
- **[Click Implementation](CLAUDE/click_implementation.md)** - Mouse input and clicking
|
|
148
|
+
- **[Mouse Implementation](CLAUDE/mouse_implementation.md)** - wheel, reset, hover, BoundingBox/Point data classes
|
|
149
|
+
- **[Wrapped Element Click](CLAUDE/wrapped_element_click.md)** - getClientRects() for multi-line elements
|
|
150
|
+
- **[Navigation Waiting](CLAUDE/navigation_waiting.md)** - waitForNavigation patterns
|
|
151
|
+
- **[Frame Architecture](CLAUDE/frame_architecture.md)** - Parent-based frame hierarchy
|
|
152
|
+
- **[FileChooser](CLAUDE/file_chooser.md)** - File upload and dialog handling (requires Firefox Nightly)
|
|
153
|
+
- **[Error Handling](CLAUDE/error_handling.md)** - Custom exception types
|
|
154
|
+
|
|
155
|
+
### Testing
|
|
156
|
+
|
|
157
|
+
- **[Testing Strategy](CLAUDE/testing_strategy.md)** - Test organization and optimization
|
|
158
|
+
- **[RSpec pending vs skip](CLAUDE/rspec_pending_vs_skip.md)** - Documenting limitations
|
|
159
|
+
- **[Test Server Routes](CLAUDE/test_server_routes.md)** - Dynamic route handling
|
|
160
|
+
|
|
161
|
+
## Releasing
|
|
162
|
+
|
|
163
|
+
To release a new version:
|
|
164
|
+
|
|
165
|
+
1. Update the version number in `lib/puppeteer/bidi/version.rb`
|
|
166
|
+
2. Commit the change and push to main
|
|
167
|
+
3. Create and push a version tag:
|
|
168
|
+
```bash
|
|
169
|
+
git tag 1.2.3
|
|
170
|
+
git push origin 1.2.3
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
GitHub Actions will automatically build and publish the gem to RubyGems. Supported tag formats:
|
|
174
|
+
- `1.2.3` - stable release
|
|
175
|
+
- `1.2.3.alpha1` - alpha release
|
|
176
|
+
- `1.2.3.beta2` - beta release
|
|
177
|
+
|
|
178
|
+
**Note:** `RUBYGEMS_API_KEY` must be configured in repository secrets.
|
|
179
|
+
|
|
180
|
+
## References
|
|
181
|
+
|
|
182
|
+
- [WebDriver BiDi Specification](https://w3c.github.io/webdriver-bidi/)
|
|
183
|
+
- [Puppeteer Documentation](https://pptr.dev/)
|
|
184
|
+
- [Puppeteer Source Code](https://github.com/puppeteer/puppeteer)
|
|
185
|
+
- [puppeteer-ruby](https://github.com/YusukeIwaki/puppeteer-ruby) - CDP implementation reference
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 YusukeIwaki
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|