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,346 @@
|
|
|
1
|
+
# Frame Architecture Implementation
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
This document details the Frame architecture implementation following Puppeteer's parent-based design pattern.
|
|
6
|
+
|
|
7
|
+
## Architecture Change
|
|
8
|
+
|
|
9
|
+
### Before (Incorrect)
|
|
10
|
+
|
|
11
|
+
```ruby
|
|
12
|
+
class Frame
|
|
13
|
+
def initialize(browsing_context, page = nil)
|
|
14
|
+
@browsing_context = browsing_context
|
|
15
|
+
@page = page
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Page creates frame
|
|
20
|
+
Frame.new(@browsing_context, self)
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
**Problem**: Frame directly stores reference to Page, doesn't support nested frames (iframe).
|
|
24
|
+
|
|
25
|
+
### After (Correct - Following Puppeteer)
|
|
26
|
+
|
|
27
|
+
```ruby
|
|
28
|
+
class Frame
|
|
29
|
+
def initialize(parent, browsing_context)
|
|
30
|
+
@parent = parent # Page or Frame
|
|
31
|
+
@browsing_context = browsing_context
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def page
|
|
35
|
+
@parent.is_a?(Page) ? @parent : @parent.page
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def parent_frame
|
|
39
|
+
@parent.is_a?(Frame) ? @parent : nil
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Page creates frame
|
|
44
|
+
Frame.new(self, @browsing_context)
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
**Benefits**:
|
|
48
|
+
- Supports nested frames (iframe within iframe)
|
|
49
|
+
- Matches Puppeteer's TypeScript implementation
|
|
50
|
+
- Enables recursive page traversal
|
|
51
|
+
- Simplifies parent_frame implementation
|
|
52
|
+
|
|
53
|
+
## Reference Implementation
|
|
54
|
+
|
|
55
|
+
Based on [Puppeteer's Frame.ts](https://github.com/puppeteer/puppeteer/blob/main/packages/puppeteer-core/src/bidi/Frame.ts):
|
|
56
|
+
|
|
57
|
+
```typescript
|
|
58
|
+
export class BidiFrame extends Frame {
|
|
59
|
+
#parent: BidiPage | BidiFrame;
|
|
60
|
+
#browsingContext: BrowsingContext;
|
|
61
|
+
|
|
62
|
+
constructor(
|
|
63
|
+
parent: BidiPage | BidiFrame,
|
|
64
|
+
browsingContext: BrowsingContext,
|
|
65
|
+
) {
|
|
66
|
+
super();
|
|
67
|
+
this.#parent = parent;
|
|
68
|
+
this.#browsingContext = browsingContext;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
override get page(): BidiPage {
|
|
72
|
+
let parent = this.#parent;
|
|
73
|
+
while (parent instanceof BidiFrame) {
|
|
74
|
+
parent = parent.#parent;
|
|
75
|
+
}
|
|
76
|
+
return parent;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
override get parentFrame(): BidiFrame | null {
|
|
80
|
+
if (this.#parent instanceof BidiFrame) {
|
|
81
|
+
return this.#parent;
|
|
82
|
+
}
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## Implementation Details
|
|
89
|
+
|
|
90
|
+
### Constructor Signature
|
|
91
|
+
|
|
92
|
+
**Critical**: The first parameter is `parent` (Page or Frame), not `page`:
|
|
93
|
+
|
|
94
|
+
```ruby
|
|
95
|
+
def initialize(parent, browsing_context)
|
|
96
|
+
@parent = parent
|
|
97
|
+
@browsing_context = browsing_context
|
|
98
|
+
end
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### Page Traversal
|
|
102
|
+
|
|
103
|
+
Recursive implementation using ternary operator:
|
|
104
|
+
|
|
105
|
+
```ruby
|
|
106
|
+
def page
|
|
107
|
+
@parent.is_a?(Page) ? @parent : @parent.page
|
|
108
|
+
end
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
This is simpler than a while loop and matches Puppeteer's logic flow.
|
|
112
|
+
|
|
113
|
+
### Parent Frame Access
|
|
114
|
+
|
|
115
|
+
```ruby
|
|
116
|
+
def parent_frame
|
|
117
|
+
@parent.is_a?(Frame) ? @parent : nil
|
|
118
|
+
end
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
- `Frame` instance if this is a child frame
|
|
123
|
+
- `nil` if this is a main frame (parent is Page)
|
|
124
|
+
|
|
125
|
+
## Usage Examples
|
|
126
|
+
|
|
127
|
+
### Main Frame
|
|
128
|
+
|
|
129
|
+
```ruby
|
|
130
|
+
page = browser.new_page
|
|
131
|
+
main_frame = page.main_frame
|
|
132
|
+
|
|
133
|
+
main_frame.page # => page
|
|
134
|
+
main_frame.parent_frame # => nil
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
### Nested Frames (Future)
|
|
138
|
+
|
|
139
|
+
```ruby
|
|
140
|
+
# When iframe support is added:
|
|
141
|
+
iframe = main_frame.child_frames.first
|
|
142
|
+
|
|
143
|
+
iframe.page # => page (traverses up to Page)
|
|
144
|
+
iframe.parent_frame # => main_frame
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
## Testing
|
|
148
|
+
|
|
149
|
+
All 108 integration tests pass with this architecture:
|
|
150
|
+
|
|
151
|
+
```bash
|
|
152
|
+
bundle exec rspec spec/integration/
|
|
153
|
+
# 108 examples, 0 failures, 4 pending
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
## Key Takeaways
|
|
157
|
+
|
|
158
|
+
1. **Follow Puppeteer's constructor signature exactly** - `(parent, browsing_context)` not `(browsing_context, page)`
|
|
159
|
+
2. **Use ternary operator for simplicity** - `@parent.is_a?(Page) ? @parent : @parent.page`
|
|
160
|
+
3. **Enables future iframe support** - Architecture supports nested frame trees
|
|
161
|
+
4. **Remove redundant attr_reader** - No need for `attr_reader :parent` when using private instance variable
|
|
162
|
+
|
|
163
|
+
## Frame Events
|
|
164
|
+
|
|
165
|
+
### Overview
|
|
166
|
+
|
|
167
|
+
Frame lifecycle events are emitted on the Page object, following Puppeteer's pattern:
|
|
168
|
+
|
|
169
|
+
- `:frameattached` - Fired when a new child frame is created
|
|
170
|
+
- `:framedetached` - Fired when a frame's browsing context is closed
|
|
171
|
+
- `:framenavigated` - Fired on DOMContentLoaded or fragment navigation
|
|
172
|
+
|
|
173
|
+
### Event Emission Locations (Following Puppeteer Exactly)
|
|
174
|
+
|
|
175
|
+
**Critical**: The location where each event is emitted matters for correct behavior.
|
|
176
|
+
|
|
177
|
+
| Event | Location | Trigger |
|
|
178
|
+
|-------|----------|---------|
|
|
179
|
+
| `:frameattached` | `Frame#create_frame_target` | Child browsing context created |
|
|
180
|
+
| `:framedetached` | `Frame#initialize_frame` | **Self's** browsing context closed |
|
|
181
|
+
| `:framenavigated` | `Frame#initialize_frame` | DOMContentLoaded or fragment_navigated |
|
|
182
|
+
|
|
183
|
+
### Puppeteer Reference Code
|
|
184
|
+
|
|
185
|
+
From [Puppeteer's bidi/Frame.ts](https://github.com/puppeteer/puppeteer/blob/main/packages/puppeteer-core/src/bidi/Frame.ts):
|
|
186
|
+
|
|
187
|
+
```typescript
|
|
188
|
+
// In #initialize() - FrameDetached is emitted for THIS frame
|
|
189
|
+
this.browsingContext.on('closed', () => {
|
|
190
|
+
this.page().trustedEmitter.emit(PageEvent.FrameDetached, this);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
// In #createFrameTarget() - FrameAttached is emitted for child frame
|
|
194
|
+
#createFrameTarget(browsingContext: BrowsingContext) {
|
|
195
|
+
const frame = BidiFrame.from(this, browsingContext);
|
|
196
|
+
this.#frames.set(browsingContext, frame);
|
|
197
|
+
this.page().trustedEmitter.emit(PageEvent.FrameAttached, frame);
|
|
198
|
+
|
|
199
|
+
// Note: FrameDetached is NOT emitted here
|
|
200
|
+
browsingContext.on('closed', () => {
|
|
201
|
+
this.#frames.delete(browsingContext);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
return frame;
|
|
205
|
+
}
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
### Ruby Implementation
|
|
209
|
+
|
|
210
|
+
```ruby
|
|
211
|
+
# Frame#initialize_frame
|
|
212
|
+
def initialize_frame
|
|
213
|
+
# ... child frame setup ...
|
|
214
|
+
|
|
215
|
+
# FrameDetached: emit when THIS frame's context closes
|
|
216
|
+
@browsing_context.on(:closed) do
|
|
217
|
+
@frames.clear
|
|
218
|
+
page.emit(:framedetached, self)
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# FrameNavigated: emit on navigation events
|
|
222
|
+
@browsing_context.on(:dom_content_loaded) do
|
|
223
|
+
page.emit(:framenavigated, self)
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
@browsing_context.on(:fragment_navigated) do
|
|
227
|
+
page.emit(:framenavigated, self)
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# Frame#create_frame_target
|
|
232
|
+
def create_frame_target(browsing_context)
|
|
233
|
+
frame = Frame.from(self, browsing_context)
|
|
234
|
+
@frames[browsing_context.id] = frame
|
|
235
|
+
|
|
236
|
+
# FrameAttached: emit for the new child frame
|
|
237
|
+
page.emit(:frameattached, frame)
|
|
238
|
+
|
|
239
|
+
# Only cleanup, NO FrameDetached here
|
|
240
|
+
browsing_context.once(:closed) do
|
|
241
|
+
@frames.delete(browsing_context.id)
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
frame
|
|
245
|
+
end
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
### Common Mistake
|
|
249
|
+
|
|
250
|
+
**Wrong**: Emitting `:framedetached` in `create_frame_target` when child's context closes.
|
|
251
|
+
|
|
252
|
+
**Correct**: Each frame emits its own `:framedetached` in `initialize_frame` when its own browsing context closes.
|
|
253
|
+
|
|
254
|
+
This matters because the event should be emitted by the frame instance that is being detached, not by its parent.
|
|
255
|
+
|
|
256
|
+
## Page Event Emitter
|
|
257
|
+
|
|
258
|
+
Page delegates to `Core::EventEmitter` for event handling:
|
|
259
|
+
|
|
260
|
+
```ruby
|
|
261
|
+
class Page
|
|
262
|
+
def initialize(...)
|
|
263
|
+
@emitter = Core::EventEmitter.new
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def on(event, &block)
|
|
267
|
+
@emitter.on(event, &block)
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def emit(event, data = nil)
|
|
271
|
+
@emitter.emit(event, data)
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
## Files Changed
|
|
277
|
+
|
|
278
|
+
- `lib/puppeteer/bidi/frame.rb`: Constructor signature, page method, parent_frame method, frame events
|
|
279
|
+
- `lib/puppeteer/bidi/page.rb`: main_frame initialization, event emitter delegation
|
|
280
|
+
|
|
281
|
+
## BiDi Protocol Limitations
|
|
282
|
+
|
|
283
|
+
### Frame.frameElement with Shadow DOM
|
|
284
|
+
|
|
285
|
+
**Status**: Not supported in BiDi protocol
|
|
286
|
+
|
|
287
|
+
`Frame#frame_element` returns `nil` for iframes inside Shadow DOM (both open and closed).
|
|
288
|
+
|
|
289
|
+
#### Root Cause
|
|
290
|
+
|
|
291
|
+
| Protocol | Behavior | Mechanism |
|
|
292
|
+
|----------|----------|-----------|
|
|
293
|
+
| **CDP (Chrome)** | Works | Uses `DOM.getFrameOwner` command |
|
|
294
|
+
| **BiDi (Firefox)** | Returns nil | Uses `document.querySelectorAll` (cannot traverse Shadow DOM) |
|
|
295
|
+
|
|
296
|
+
#### Technical Details
|
|
297
|
+
|
|
298
|
+
1. **CDP Implementation** (`cdp/Frame.js`):
|
|
299
|
+
```javascript
|
|
300
|
+
const { backendNodeId } = await parent.client.send('DOM.getFrameOwner', {
|
|
301
|
+
frameId: this._id,
|
|
302
|
+
});
|
|
303
|
+
return await parent.mainRealm().adoptBackendNode(backendNodeId);
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
2. **BiDi Implementation** (base `api/Frame.js`):
|
|
307
|
+
```javascript
|
|
308
|
+
const list = await parentFrame.isolatedRealm().evaluateHandle(() => {
|
|
309
|
+
return document.querySelectorAll('iframe,frame');
|
|
310
|
+
});
|
|
311
|
+
// Cannot find elements inside Shadow DOM
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
3. **WebDriver BiDi Specification**: No `DOM.getFrameOwner` equivalent command exists.
|
|
315
|
+
|
|
316
|
+
#### Verification
|
|
317
|
+
|
|
318
|
+
Tested with Puppeteer (Node.js) using both protocols:
|
|
319
|
+
|
|
320
|
+
```
|
|
321
|
+
=== Firefox (BiDi protocol) ===
|
|
322
|
+
Frame element is NULL - Shadow DOM issue confirmed
|
|
323
|
+
|
|
324
|
+
=== Chrome (CDP protocol) ===
|
|
325
|
+
Frame element tagName: iframe
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
#### References
|
|
329
|
+
|
|
330
|
+
- [Puppeteer Issue #13155](https://github.com/puppeteer/puppeteer/issues/13155) - Original bug report
|
|
331
|
+
- [Puppeteer PR #13156](https://github.com/puppeteer/puppeteer/pull/13156) - CDP-only fix (October 2024)
|
|
332
|
+
- [WebDriver BiDi Specification](https://w3c.github.io/webdriver-bidi/) - browsingContext module
|
|
333
|
+
|
|
334
|
+
#### Test Status
|
|
335
|
+
|
|
336
|
+
```ruby
|
|
337
|
+
it 'should handle shadow roots', pending: 'BiDi protocol limitation: no DOM.getFrameOwner equivalent' do
|
|
338
|
+
# ...
|
|
339
|
+
end
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
This is a **protocol limitation**, not an implementation bug in this library.
|
|
343
|
+
|
|
344
|
+
## Commit Reference
|
|
345
|
+
|
|
346
|
+
See commit: "Refactor Frame to use parent-based architecture following Puppeteer"
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
# JavaScript Evaluation Implementation
|
|
2
|
+
|
|
3
|
+
This document details the implementation of JavaScript evaluation in Page and Frame classes, including script detection logic, argument serialization, and result deserialization.
|
|
4
|
+
|
|
5
|
+
### Page.evaluate and Frame.evaluate
|
|
6
|
+
|
|
7
|
+
The `evaluate` method supports both JavaScript expressions and functions with proper argument serialization.
|
|
8
|
+
|
|
9
|
+
#### Detection Logic
|
|
10
|
+
|
|
11
|
+
The implementation distinguishes between three types of JavaScript code:
|
|
12
|
+
|
|
13
|
+
1. **Expressions**: Simple JavaScript code
|
|
14
|
+
2. **Functions**: Arrow functions or function declarations (use `script.callFunction`)
|
|
15
|
+
3. **IIFE**: Immediately Invoked Function Expressions (use `script.evaluate`)
|
|
16
|
+
|
|
17
|
+
```ruby
|
|
18
|
+
# Expression - uses script.evaluate
|
|
19
|
+
page.evaluate('7 * 3') # => 21
|
|
20
|
+
|
|
21
|
+
# Function - uses script.callFunction
|
|
22
|
+
page.evaluate('(a, b) => a + b', 3, 4) # => 7
|
|
23
|
+
|
|
24
|
+
# IIFE - uses script.evaluate (not script.callFunction)
|
|
25
|
+
page.evaluate('(() => document.title)()') # => "Page Title"
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
#### IIFE Detection Pattern
|
|
29
|
+
|
|
30
|
+
**Critical**: IIFE must be detected and treated as expressions:
|
|
31
|
+
|
|
32
|
+
```ruby
|
|
33
|
+
# Check if it's an IIFE - ends with () after the function body
|
|
34
|
+
is_iife = script_trimmed.match?(/\)\s*\(\s*\)\s*\z/)
|
|
35
|
+
|
|
36
|
+
# Only treat as function if not IIFE
|
|
37
|
+
is_function = !is_iife && (
|
|
38
|
+
script_trimmed.match?(/\A\s*(?:async\s+)?(?:\(.*?\)|[a-zA-Z_$][\w$]*)\s*=>/) ||
|
|
39
|
+
script_trimmed.match?(/\A\s*(?:async\s+)?function\s*\w*\s*\(/)
|
|
40
|
+
)
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
**Why this matters**: IIFE like `(() => {...})()` looks like a function but must be evaluated as an expression. Using `script.callFunction` on IIFE causes syntax errors.
|
|
44
|
+
|
|
45
|
+
#### Argument Serialization
|
|
46
|
+
|
|
47
|
+
Arguments are serialized to BiDi `LocalValue` format:
|
|
48
|
+
|
|
49
|
+
```ruby
|
|
50
|
+
# Special numbers
|
|
51
|
+
{ type: 'number', value: 'NaN' }
|
|
52
|
+
{ type: 'number', value: 'Infinity' }
|
|
53
|
+
{ type: 'number', value: '-0' }
|
|
54
|
+
|
|
55
|
+
# Collections
|
|
56
|
+
{ type: 'array', value: [...] }
|
|
57
|
+
{ type: 'object', value: [[key, value], ...] }
|
|
58
|
+
{ type: 'map', value: [[key, value], ...] }
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
#### Result Deserialization
|
|
62
|
+
|
|
63
|
+
BiDi returns results in special format that must be deserialized:
|
|
64
|
+
|
|
65
|
+
```ruby
|
|
66
|
+
# BiDi response format
|
|
67
|
+
{
|
|
68
|
+
"type" => "success",
|
|
69
|
+
"realm" => "...",
|
|
70
|
+
"result" => {
|
|
71
|
+
"type" => "number",
|
|
72
|
+
"value" => 42
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
# Extract and deserialize
|
|
77
|
+
actual_result = result['result'] || result
|
|
78
|
+
deserialize_result(actual_result) # => 42
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
#### Exception Handling
|
|
82
|
+
|
|
83
|
+
Exceptions from JavaScript are returned in the result, not thrown by BiDi:
|
|
84
|
+
|
|
85
|
+
```ruby
|
|
86
|
+
if result['type'] == 'exception'
|
|
87
|
+
exception_details = result['exceptionDetails']
|
|
88
|
+
text = exception_details['text'] # "ReferenceError: notExistingObject is not defined"
|
|
89
|
+
raise text
|
|
90
|
+
end
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### Core::Realm Return Values
|
|
94
|
+
|
|
95
|
+
**Important**: Core::Realm methods return the **complete BiDi result**, not just the value:
|
|
96
|
+
|
|
97
|
+
```ruby
|
|
98
|
+
# Core::Realm.call_function returns:
|
|
99
|
+
{
|
|
100
|
+
"type" => "success" | "exception",
|
|
101
|
+
"realm" => "...",
|
|
102
|
+
"result" => {...} | nil,
|
|
103
|
+
"exceptionDetails" => {...} | nil
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
# NOT result['result'] (this was a bug that was fixed)
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### Testing Strategy
|
|
110
|
+
|
|
111
|
+
#### Integration Tests Organization
|
|
112
|
+
|
|
113
|
+
```
|
|
114
|
+
spec/
|
|
115
|
+
├── unit/ # Fast unit tests (future)
|
|
116
|
+
├── integration/ # Browser automation tests
|
|
117
|
+
│ ├── examples/ # Example-based tests
|
|
118
|
+
│ │ └── screenshot_spec.rb
|
|
119
|
+
│ └── screenshot_spec.rb # Feature test suites
|
|
120
|
+
├── assets/ # Test HTML/CSS/JS files
|
|
121
|
+
│ ├── grid.html
|
|
122
|
+
│ ├── scrollbar.html
|
|
123
|
+
│ ├── empty.html
|
|
124
|
+
│ └── digits/*.png
|
|
125
|
+
├── golden-firefox/ # Reference images
|
|
126
|
+
│ └── screenshot-*.png
|
|
127
|
+
└── support/ # Test utilities
|
|
128
|
+
├── test_server.rb
|
|
129
|
+
└── golden_comparator.rb
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
#### Implemented Screenshot Tests
|
|
133
|
+
|
|
134
|
+
All 12 tests ported from [Puppeteer's screenshot.spec.ts](https://github.com/puppeteer/puppeteer/blob/main/test/src/screenshot.spec.ts):
|
|
135
|
+
|
|
136
|
+
1. **should work** - Basic screenshot functionality
|
|
137
|
+
2. **should clip rect** - Clipping specific region
|
|
138
|
+
3. **should get screenshot bigger than the viewport** - Offscreen clip with captureBeyondViewport
|
|
139
|
+
4. **should clip bigger than the viewport without "captureBeyondViewport"** - Viewport coordinate transformation
|
|
140
|
+
5. **should run in parallel** - Thread-safe parallel screenshots on single page
|
|
141
|
+
6. **should take fullPage screenshots** - Full page with document origin
|
|
142
|
+
7. **should take fullPage screenshots without captureBeyondViewport** - Full page with viewport resize
|
|
143
|
+
8. **should run in parallel in multiple pages** - Concurrent screenshots across multiple pages
|
|
144
|
+
9. **should work with odd clip size on Retina displays** - Odd pixel dimensions (11x11)
|
|
145
|
+
10. **should return base64** - Base64 encoding verification
|
|
146
|
+
11. **should take fullPage screenshots when defaultViewport is null** - No explicit viewport
|
|
147
|
+
12. **should restore to original viewport size** - Viewport restoration after fullPage
|
|
148
|
+
|
|
149
|
+
Run tests:
|
|
150
|
+
```bash
|
|
151
|
+
bundle exec rspec spec/integration/screenshot_spec.rb
|
|
152
|
+
# Expected: 12 examples, 0 failures (completes in ~8 seconds with optimized spec_helper)
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
#### Test Performance Optimization
|
|
156
|
+
|
|
157
|
+
**Critical**: Integration tests are ~19x faster with browser reuse strategy.
|
|
158
|
+
|
|
159
|
+
##### Before Optimization (Per-test Browser Launch)
|
|
160
|
+
```ruby
|
|
161
|
+
def with_test_state(**options)
|
|
162
|
+
server = TestServer::Server.new
|
|
163
|
+
server.start
|
|
164
|
+
|
|
165
|
+
with_browser(**options) do |browser| # New browser per test!
|
|
166
|
+
context = browser.default_browser_context
|
|
167
|
+
page = browser.new_page
|
|
168
|
+
yield(page: page, server: server, browser: browser, context: context)
|
|
169
|
+
end
|
|
170
|
+
ensure
|
|
171
|
+
server.stop
|
|
172
|
+
end
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
**Performance**: ~195 seconds for 35 tests (browser launch overhead × 35)
|
|
176
|
+
|
|
177
|
+
##### After Optimization (Shared Browser)
|
|
178
|
+
```ruby
|
|
179
|
+
# In spec_helper.rb
|
|
180
|
+
config.before(:suite) do
|
|
181
|
+
if RSpec.configuration.files_to_run.any? { |f| f.include?('spec/integration') }
|
|
182
|
+
$shared_browser = Puppeteer::Bidi.launch(headless: headless_mode?)
|
|
183
|
+
$shared_test_server = TestServer::Server.new
|
|
184
|
+
$shared_test_server.start
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def with_test_state(**options)
|
|
189
|
+
if $shared_browser && options.empty?
|
|
190
|
+
# Create new page (tab) per test
|
|
191
|
+
page = $shared_browser.new_page
|
|
192
|
+
context = $shared_browser.default_browser_context
|
|
193
|
+
|
|
194
|
+
begin
|
|
195
|
+
yield(page: page, server: $shared_test_server, browser: $shared_browser, context: context)
|
|
196
|
+
ensure
|
|
197
|
+
page.close unless page.closed? # Clean up tab
|
|
198
|
+
end
|
|
199
|
+
else
|
|
200
|
+
# Fall back to per-test browser for custom options
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
**Performance**: ~10 seconds for 35 tests (1 browser launch + 35 tab creations)
|
|
206
|
+
|
|
207
|
+
##### Performance Results
|
|
208
|
+
|
|
209
|
+
| Test Suite | Before | After | Improvement |
|
|
210
|
+
|------------|--------|-------|-------------|
|
|
211
|
+
| **evaluation_spec (23 tests)** | 127s | **7.17s** | **17.7x faster** |
|
|
212
|
+
| **screenshot_spec (12 tests)** | 68s | **8.47s** | **8.0x faster** |
|
|
213
|
+
| **Combined (35 tests)** | 195s | **10.33s** | **18.9x faster** 🚀 |
|
|
214
|
+
|
|
215
|
+
**Key Benefits**:
|
|
216
|
+
- Browser launch only once per suite
|
|
217
|
+
- Each test gets fresh page (tab) for isolation
|
|
218
|
+
- Cleanup handled automatically
|
|
219
|
+
- Backward compatible (custom options fall back to per-test browser)
|
|
220
|
+
|
|
221
|
+
#### Environment Variables
|
|
222
|
+
|
|
223
|
+
```bash
|
|
224
|
+
HEADLESS=false # Run browser in non-headless mode for debugging
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
### Debugging Techniques
|
|
228
|
+
|
|
229
|
+
#### 1. Save Screenshots for Inspection
|
|
230
|
+
|
|
231
|
+
```ruby
|
|
232
|
+
# In golden_comparator.rb
|
|
233
|
+
def save_screenshot(screenshot_base64, filename)
|
|
234
|
+
output_dir = File.join(__dir__, '../output')
|
|
235
|
+
FileUtils.mkdir_p(output_dir)
|
|
236
|
+
File.binwrite(File.join(output_dir, filename),
|
|
237
|
+
Base64.decode64(screenshot_base64))
|
|
238
|
+
end
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
#### 2. Compare Images Pixel-by-Pixel
|
|
242
|
+
|
|
243
|
+
```ruby
|
|
244
|
+
cat > /tmp/compare.rb << 'EOF'
|
|
245
|
+
require 'chunky_png'
|
|
246
|
+
|
|
247
|
+
golden = ChunkyPNG::Image.from_file('spec/golden-firefox/screenshot.png')
|
|
248
|
+
actual = ChunkyPNG::Image.from_file('spec/output/debug.png')
|
|
249
|
+
|
|
250
|
+
diff_count = 0
|
|
251
|
+
(0...golden.height).each do |y|
|
|
252
|
+
(0...golden.width).each do |x|
|
|
253
|
+
if golden[x, y] != actual[x, y]
|
|
254
|
+
diff_count += 1
|
|
255
|
+
puts "Diff at (#{x}, #{y})" if diff_count <= 10
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
puts "Total: #{diff_count} pixels differ"
|
|
260
|
+
EOF
|
|
261
|
+
ruby /tmp/compare.rb
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
#### 3. Debug BiDi Responses
|
|
265
|
+
|
|
266
|
+
```ruby
|
|
267
|
+
# Temporarily add debugging
|
|
268
|
+
result = @browsing_context.default_realm.evaluate(script, true)
|
|
269
|
+
puts "BiDi result: #{result.inspect}"
|
|
270
|
+
deserialize_result(result)
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
### Common Pitfalls and Solutions
|
|
274
|
+
|
|
275
|
+
#### 1. BiDi Protocol Differences
|
|
276
|
+
|
|
277
|
+
**Problem:** BiDi `origin` parameter behavior differs from expectations
|
|
278
|
+
|
|
279
|
+
**Solution:** Consult BiDi spec and test both `'document'` and `'viewport'` origins
|
|
280
|
+
|
|
281
|
+
```ruby
|
|
282
|
+
# document: Absolute coordinates in full page
|
|
283
|
+
# viewport: Relative to current viewport
|
|
284
|
+
options[:origin] = capture_beyond_viewport ? 'document' : 'viewport'
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
#### 2. Image Comparison Failures
|
|
288
|
+
|
|
289
|
+
**Problem:** Golden images don't match exactly (1-2 pixel differences)
|
|
290
|
+
|
|
291
|
+
**Solution:** Implement tolerance in comparison
|
|
292
|
+
|
|
293
|
+
```ruby
|
|
294
|
+
# Allow small rendering differences (±1 RGB per channel)
|
|
295
|
+
compare_with_golden(screenshot, 'golden.png', pixel_threshold: 1)
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
#### 3. Viewport State Management
|
|
299
|
+
|
|
300
|
+
**Problem:** Viewport not restored after fullPage screenshot
|
|
301
|
+
|
|
302
|
+
**Solution:** Use `ensure` block
|
|
303
|
+
|
|
304
|
+
```ruby
|
|
305
|
+
begin
|
|
306
|
+
set_viewport(full_page_dimensions)
|
|
307
|
+
screenshot = capture_screenshot(...)
|
|
308
|
+
ensure
|
|
309
|
+
set_viewport(original_viewport) if original_viewport
|
|
310
|
+
end
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
#### 4. Thread Safety
|
|
314
|
+
|
|
315
|
+
**Problem:** Parallel screenshots cause race conditions
|
|
316
|
+
|
|
317
|
+
**Solution:** BiDi protocol handles this naturally - test with threads
|
|
318
|
+
|
|
319
|
+
```ruby
|
|
320
|
+
threads = (0...3).map do |i|
|
|
321
|
+
Thread.new { page.screenshot(clip: {...}) }
|
|
322
|
+
end
|
|
323
|
+
screenshots = threads.map(&:value)
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
### Documentation References
|
|
327
|
+
|
|
328
|
+
**Essential reading for implementation:**
|
|
329
|
+
|
|
330
|
+
1. **WebDriver BiDi Spec**: https://w3c.github.io/webdriver-bidi/
|
|
331
|
+
2. **Puppeteer Source**: https://github.com/puppeteer/puppeteer
|
|
332
|
+
3. **Puppeteer BiDi Tests**: https://github.com/puppeteer/puppeteer/tree/main/test/src
|
|
333
|
+
4. **Firefox BiDi Impl**: Check Firefox implementation notes for quirks
|
|
334
|
+
|
|
335
|
+
**Reference implementation workflow:**
|
|
336
|
+
1. Find corresponding Puppeteer test in `test/src/`
|
|
337
|
+
2. Read TypeScript implementation in `packages/puppeteer-core/src/`
|
|
338
|
+
3. Check BiDi spec for protocol details
|
|
339
|
+
4. Implement Ruby version maintaining same logic
|
|
340
|
+
5. Download golden images and verify pixel-perfect match (with tolerance)
|
|
341
|
+
|