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,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
+