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
data/README.md
ADDED
|
@@ -0,0 +1,488 @@
|
|
|
1
|
+
# Puppeteer::BiDi
|
|
2
|
+
|
|
3
|
+
A Ruby port of [Puppeteer](https://pptr.dev/) using the WebDriver BiDi protocol for Firefox automation.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
`puppeteer-bidi` is a Ruby implementation of Puppeteer that leverages the [WebDriver BiDi protocol](https://w3c.github.io/webdriver-bidi/) to automate Firefox browsers. Unlike the existing [puppeteer-ruby](https://github.com/YusukeIwaki/puppeteer-ruby) gem which uses the Chrome DevTools Protocol (CDP), this gem focuses specifically on BiDi protocol support for cross-browser automation.
|
|
8
|
+
|
|
9
|
+
### Why BiDi?
|
|
10
|
+
|
|
11
|
+
- **Cross-browser compatibility**: BiDi is a W3C standard protocol designed to work across different browsers
|
|
12
|
+
- **Firefox-first**: While CDP is Chrome-centric, BiDi provides better support for Firefox automation
|
|
13
|
+
- **Future-proof**: BiDi represents the future direction of browser automation standards
|
|
14
|
+
|
|
15
|
+
### Relationship with puppeteer-ruby
|
|
16
|
+
|
|
17
|
+
This gem complements the existing `puppeteer-ruby` ecosystem:
|
|
18
|
+
|
|
19
|
+
- **puppeteer-ruby**: Uses CDP (Chrome DevTools Protocol) → Best for Chrome/Chromium automation
|
|
20
|
+
- **puppeteer-bidi** (this gem): Uses BiDi protocol → Best for Firefox automation
|
|
21
|
+
|
|
22
|
+
This gem ports only the BiDi-related portions of Puppeteer to Ruby, intentionally excluding CDP implementations.
|
|
23
|
+
|
|
24
|
+
## Installation
|
|
25
|
+
|
|
26
|
+
Install the gem and add to the application's Gemfile by executing:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
bundle add puppeteer-bidi
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
If bundler is not being used to manage dependencies, install the gem by executing:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
gem install puppeteer-bidi
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Or add this line to your application's Gemfile:
|
|
39
|
+
|
|
40
|
+
```ruby
|
|
41
|
+
gem 'puppeteer-bidi'
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Prerequisites
|
|
45
|
+
|
|
46
|
+
- Ruby 3.2 or higher (required by socketry/async dependency)
|
|
47
|
+
- Firefox browser with BiDi support
|
|
48
|
+
|
|
49
|
+
## Usage
|
|
50
|
+
|
|
51
|
+
### High-Level Page API (Recommended)
|
|
52
|
+
|
|
53
|
+
```ruby
|
|
54
|
+
require 'puppeteer/bidi'
|
|
55
|
+
|
|
56
|
+
# Launch Firefox with BiDi protocol
|
|
57
|
+
browser = Puppeteer::Bidi.launch(headless: false)
|
|
58
|
+
|
|
59
|
+
# Create a new page
|
|
60
|
+
page = browser.new_page
|
|
61
|
+
|
|
62
|
+
# Set viewport size
|
|
63
|
+
page.set_viewport(width: 1280, height: 720)
|
|
64
|
+
|
|
65
|
+
# Navigate to a URL
|
|
66
|
+
page.goto('https://example.com')
|
|
67
|
+
|
|
68
|
+
# Take a screenshot
|
|
69
|
+
page.screenshot(path: 'screenshot.png')
|
|
70
|
+
|
|
71
|
+
# Take a full page screenshot
|
|
72
|
+
page.screenshot(path: 'fullpage.png', full_page: true)
|
|
73
|
+
|
|
74
|
+
# Screenshot with clipping
|
|
75
|
+
page.screenshot(
|
|
76
|
+
path: 'clip.png',
|
|
77
|
+
clip: { x: 0, y: 0, width: 100, height: 100 }
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
# Evaluate JavaScript expressions
|
|
81
|
+
title = page.evaluate('document.title')
|
|
82
|
+
puts "Page title: #{title}"
|
|
83
|
+
|
|
84
|
+
# Evaluate JavaScript functions with arguments
|
|
85
|
+
sum = page.evaluate('(a, b) => a + b', 3, 4)
|
|
86
|
+
puts "Sum: #{sum}" # => 7
|
|
87
|
+
|
|
88
|
+
# Access frame and evaluate
|
|
89
|
+
frame = page.main_frame
|
|
90
|
+
result = frame.evaluate('() => window.innerWidth')
|
|
91
|
+
|
|
92
|
+
# Query selectors
|
|
93
|
+
section = page.query_selector('section')
|
|
94
|
+
divs = page.query_selector_all('div')
|
|
95
|
+
|
|
96
|
+
# Evaluate on selectors (convenience methods)
|
|
97
|
+
# Equivalent to Puppeteer's $eval and $$eval
|
|
98
|
+
id = page.eval_on_selector('section', 'e => e.id')
|
|
99
|
+
count = page.eval_on_selector_all('div', 'divs => divs.length')
|
|
100
|
+
|
|
101
|
+
# Set page content
|
|
102
|
+
page.set_content('<h1>Hello, World!</h1>')
|
|
103
|
+
|
|
104
|
+
# Wait for navigation (Async/Fiber-based, no race conditions)
|
|
105
|
+
# Block pattern - executes code and waits for resulting navigation
|
|
106
|
+
response = page.wait_for_navigation do
|
|
107
|
+
page.click('a#navigation-link')
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Wait for fragment navigation (#hash changes)
|
|
111
|
+
page.wait_for_navigation do
|
|
112
|
+
page.click('a[href="#section"]')
|
|
113
|
+
end # => nil (fragment navigation returns nil)
|
|
114
|
+
|
|
115
|
+
# Wait for History API navigation
|
|
116
|
+
page.wait_for_navigation do
|
|
117
|
+
page.evaluate('history.pushState({}, "", "/new-url")')
|
|
118
|
+
end # => nil (History API returns nil)
|
|
119
|
+
|
|
120
|
+
# Wait with different conditions
|
|
121
|
+
page.wait_for_navigation(wait_until: 'domcontentloaded') do
|
|
122
|
+
page.click('a')
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# User input simulation
|
|
126
|
+
page.click('button#submit')
|
|
127
|
+
page.type('input[name="email"]', 'user@example.com', delay: 100)
|
|
128
|
+
page.focus('textarea')
|
|
129
|
+
|
|
130
|
+
# Close the page
|
|
131
|
+
page.close
|
|
132
|
+
|
|
133
|
+
# Close the browser
|
|
134
|
+
browser.close
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
### Low-Level BiDi API
|
|
138
|
+
|
|
139
|
+
```ruby
|
|
140
|
+
require 'puppeteer/bidi'
|
|
141
|
+
|
|
142
|
+
# Launch Firefox with BiDi protocol
|
|
143
|
+
browser = Puppeteer::Bidi.launch(headless: false)
|
|
144
|
+
|
|
145
|
+
# Create a new browsing context (tab)
|
|
146
|
+
result = browser.new_context(type: 'tab')
|
|
147
|
+
context_id = result['context']
|
|
148
|
+
|
|
149
|
+
# Navigate to a URL
|
|
150
|
+
browser.navigate(
|
|
151
|
+
context: context_id,
|
|
152
|
+
url: 'https://example.com',
|
|
153
|
+
wait: 'complete'
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
# Close the browsing context
|
|
157
|
+
browser.close_context(context_id)
|
|
158
|
+
|
|
159
|
+
# Close the browser
|
|
160
|
+
browser.close
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
### Launch Options
|
|
164
|
+
|
|
165
|
+
```ruby
|
|
166
|
+
browser = Puppeteer::Bidi.launch(
|
|
167
|
+
headless: true, # Run in headless mode (default: true)
|
|
168
|
+
executable_path: '/path/to/firefox', # Path to Firefox executable (optional)
|
|
169
|
+
user_data_dir: '/path/to/profile', # User data directory (optional)
|
|
170
|
+
args: ['--width=1280', '--height=720'] # Additional Firefox arguments
|
|
171
|
+
)
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
### Event Handling
|
|
175
|
+
|
|
176
|
+
```ruby
|
|
177
|
+
require 'puppeteer/bidi'
|
|
178
|
+
|
|
179
|
+
browser = Puppeteer::Bidi.launch(headless: false)
|
|
180
|
+
|
|
181
|
+
# Subscribe to BiDi events
|
|
182
|
+
browser.subscribe([
|
|
183
|
+
'browsingContext.navigationStarted',
|
|
184
|
+
'browsingContext.navigationComplete'
|
|
185
|
+
])
|
|
186
|
+
|
|
187
|
+
# Register event handlers
|
|
188
|
+
browser.on('browsingContext.navigationStarted') do |params|
|
|
189
|
+
puts "Navigation started: #{params['url']}"
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
browser.on('browsingContext.navigationComplete') do |params|
|
|
193
|
+
puts "Navigation completed: #{params['url']}"
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Create context and navigate
|
|
197
|
+
result = browser.new_context(type: 'tab')
|
|
198
|
+
browser.navigate(context: result['context'], url: 'https://example.com')
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
### Connecting to Existing Browser
|
|
202
|
+
|
|
203
|
+
```ruby
|
|
204
|
+
# Connect to an already running Firefox instance with BiDi
|
|
205
|
+
browser = Puppeteer::Bidi.connect('ws://localhost:9222/session')
|
|
206
|
+
|
|
207
|
+
# Use the browser
|
|
208
|
+
status = browser.status
|
|
209
|
+
puts "Connected to browser: #{status.inspect}"
|
|
210
|
+
|
|
211
|
+
browser.close
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
### Using Core Layer (Advanced)
|
|
215
|
+
|
|
216
|
+
The Core layer provides a structured API over BiDi protocol:
|
|
217
|
+
|
|
218
|
+
```ruby
|
|
219
|
+
require 'puppeteer/bidi'
|
|
220
|
+
|
|
221
|
+
# Launch browser and access connection
|
|
222
|
+
browser = Puppeteer::Bidi.launch(headless: false)
|
|
223
|
+
|
|
224
|
+
# Create Core layer objects
|
|
225
|
+
session_info = { 'sessionId' => 'default-session', 'capabilities' => {} }
|
|
226
|
+
session = Puppeteer::Bidi::Core::Session.new(browser.connection, session_info)
|
|
227
|
+
core_browser = Puppeteer::Bidi::Core::Browser.from(session)
|
|
228
|
+
session.browser = core_browser
|
|
229
|
+
|
|
230
|
+
# Get default user context
|
|
231
|
+
context = core_browser.default_user_context
|
|
232
|
+
|
|
233
|
+
# Create browsing context with Core API
|
|
234
|
+
browsing_context = context.create_browsing_context('tab')
|
|
235
|
+
|
|
236
|
+
# Subscribe to events
|
|
237
|
+
browsing_context.on(:load) do
|
|
238
|
+
puts "Page loaded!"
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
browsing_context.subscribe(['browsingContext.load'])
|
|
242
|
+
|
|
243
|
+
# Navigate
|
|
244
|
+
browsing_context.navigate('https://example.com', wait: 'complete')
|
|
245
|
+
|
|
246
|
+
# Evaluate JavaScript
|
|
247
|
+
result = browsing_context.default_realm.evaluate('document.title', true)
|
|
248
|
+
puts "Title: #{result['value']}"
|
|
249
|
+
|
|
250
|
+
# Take screenshot
|
|
251
|
+
image_data = browsing_context.capture_screenshot(format: 'png')
|
|
252
|
+
|
|
253
|
+
# Error handling with custom exceptions
|
|
254
|
+
begin
|
|
255
|
+
browsing_context.navigate('https://example.com')
|
|
256
|
+
rescue Puppeteer::Bidi::Core::BrowsingContextClosedError => e
|
|
257
|
+
puts "Context was closed: #{e.reason}"
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# Clean up
|
|
261
|
+
browsing_context.close
|
|
262
|
+
core_browser.close
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
For more details on the Core layer, see `lib/puppeteer/bidi/core/README.md`.
|
|
266
|
+
|
|
267
|
+
For more examples, see the [examples](examples/) directory and integration tests in [spec/integration/](spec/integration/).
|
|
268
|
+
|
|
269
|
+
## Testing
|
|
270
|
+
|
|
271
|
+
### Running Tests
|
|
272
|
+
|
|
273
|
+
```bash
|
|
274
|
+
# Run all tests
|
|
275
|
+
bundle exec rspec
|
|
276
|
+
|
|
277
|
+
# Run integration tests (launches actual Firefox browser)
|
|
278
|
+
bundle exec rspec spec/integration/
|
|
279
|
+
|
|
280
|
+
# Run evaluation tests (23 examples, ~7 seconds)
|
|
281
|
+
bundle exec rspec spec/integration/evaluation_spec.rb
|
|
282
|
+
|
|
283
|
+
# Run screenshot tests (12 examples, ~8 seconds)
|
|
284
|
+
bundle exec rspec spec/integration/screenshot_spec.rb
|
|
285
|
+
|
|
286
|
+
# Run all integration tests (35 examples, ~10 seconds)
|
|
287
|
+
bundle exec rspec spec/integration/evaluation_spec.rb spec/integration/screenshot_spec.rb
|
|
288
|
+
|
|
289
|
+
# Run in non-headless mode for debugging
|
|
290
|
+
HEADLESS=false bundle exec rspec spec/integration/
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
### Integration Tests
|
|
294
|
+
|
|
295
|
+
Integration tests in `spec/integration/` demonstrate real-world usage by launching Firefox and performing browser automation tasks. These tests are useful for:
|
|
296
|
+
|
|
297
|
+
- Verifying end-to-end functionality
|
|
298
|
+
- Learning by example
|
|
299
|
+
- Ensuring browser compatibility
|
|
300
|
+
|
|
301
|
+
**Performance Note**: Integration tests are optimized to reuse a single browser instance across all tests (~19x faster than launching per test). Each test gets a fresh page (tab) for proper isolation.
|
|
302
|
+
|
|
303
|
+
#### Test Coverage
|
|
304
|
+
|
|
305
|
+
**Integration Tests**: 136 examples covering end-to-end functionality
|
|
306
|
+
|
|
307
|
+
- **Evaluation Tests** (`evaluation_spec.rb`): 23 tests ported from Puppeteer
|
|
308
|
+
- JavaScript expression and function evaluation
|
|
309
|
+
- Argument serialization (numbers, arrays, objects, special values)
|
|
310
|
+
- Result deserialization (NaN, Infinity, -0, Maps)
|
|
311
|
+
- Exception handling and thrown values
|
|
312
|
+
- IIFE (Immediately Invoked Function Expression) support
|
|
313
|
+
- Frame.evaluate functionality
|
|
314
|
+
|
|
315
|
+
- **Screenshot Tests** (`screenshot_spec.rb`): 12 tests ported from Puppeteer
|
|
316
|
+
- Basic screenshots and clipping regions
|
|
317
|
+
- Full page screenshots with viewport management
|
|
318
|
+
- Parallel execution across single/multiple pages
|
|
319
|
+
- Retina display compatibility
|
|
320
|
+
- Viewport restoration
|
|
321
|
+
- All tests use golden image comparison with tolerance for cross-platform compatibility
|
|
322
|
+
|
|
323
|
+
- **Navigation Tests** (`navigation_spec.rb`): 8 tests ported from Puppeteer
|
|
324
|
+
- Full page navigation with HTTPResponse
|
|
325
|
+
- Fragment navigation (#hash changes)
|
|
326
|
+
- History API navigation (pushState, replaceState, back, forward)
|
|
327
|
+
- Multiple wait conditions (load, domcontentloaded)
|
|
328
|
+
- Async/Fiber-based concurrent navigation waiting
|
|
329
|
+
|
|
330
|
+
- **Click Tests** (`click_spec.rb`): 20 tests ported from Puppeteer
|
|
331
|
+
- Element clicking (buttons, links, SVG, checkboxes)
|
|
332
|
+
- Scrolling and viewport handling
|
|
333
|
+
- Wrapped element clicks (multi-line text)
|
|
334
|
+
- Obscured element detection
|
|
335
|
+
- Rotated element clicks
|
|
336
|
+
- Mouse button variations (left, right, middle)
|
|
337
|
+
- Click counts (single, double, triple)
|
|
338
|
+
|
|
339
|
+
- **Keyboard Tests** (`keyboard_spec.rb`): Tests for keyboard input simulation
|
|
340
|
+
- Text typing with customizable delay
|
|
341
|
+
- Special key presses
|
|
342
|
+
- Key combinations
|
|
343
|
+
|
|
344
|
+
## Project Status
|
|
345
|
+
|
|
346
|
+
This project is in early development. The API may change as the implementation progresses.
|
|
347
|
+
|
|
348
|
+
### Implemented Features
|
|
349
|
+
|
|
350
|
+
#### High-Level Page API (`lib/puppeteer/bidi/`)
|
|
351
|
+
Puppeteer-compatible API for browser automation:
|
|
352
|
+
|
|
353
|
+
- ✅ **Browser**: Browser instance management
|
|
354
|
+
- ✅ **BrowserContext**: Isolated browsing sessions
|
|
355
|
+
- ✅ **Page**: High-level page automation
|
|
356
|
+
- ✅ Navigation (`goto`, `set_content`, `wait_for_navigation`)
|
|
357
|
+
- ✅ Wait for full page navigation, fragment navigation (#hash), and History API (pushState/replaceState)
|
|
358
|
+
- ✅ Async/Fiber-based concurrency (no race conditions, Thread-safe)
|
|
359
|
+
- ✅ Multiple wait conditions (`load`, `domcontentloaded`)
|
|
360
|
+
- ✅ JavaScript evaluation (`evaluate`, `evaluate_handle`) with functions, arguments, and IIFE support
|
|
361
|
+
- ✅ Element querying (`query_selector`, `query_selector_all`)
|
|
362
|
+
- ✅ Selector evaluation (`eval_on_selector`, `eval_on_selector_all`) - Ruby equivalents of Puppeteer's `$eval` and `$$eval`
|
|
363
|
+
- ✅ User input (`click`, `type`, `focus`)
|
|
364
|
+
- ✅ Mouse operations (click with offset, double-click, context menu, middle-click)
|
|
365
|
+
- ✅ Keyboard operations (type with delay, press, key combinations)
|
|
366
|
+
- ✅ Screenshots (basic, clipping, full page, parallel)
|
|
367
|
+
- ✅ Viewport management with automatic restoration
|
|
368
|
+
- ✅ Page state queries (`title`, `url`, `viewport`)
|
|
369
|
+
- ✅ Frame access (`main_frame`, `focused_frame`)
|
|
370
|
+
- ✅ **Frame**: Frame-level operations
|
|
371
|
+
- ✅ JavaScript evaluation with full feature parity to Page
|
|
372
|
+
- ✅ Element querying and selector evaluation
|
|
373
|
+
- ✅ Navigation waiting (`wait_for_navigation`)
|
|
374
|
+
- ✅ User input (`click`, `type`, `focus`)
|
|
375
|
+
- ✅ **JSHandle & ElementHandle**: JavaScript object references
|
|
376
|
+
- ✅ Handle creation, disposal, and property access
|
|
377
|
+
- ✅ Element operations (click, bounding box, scroll into view)
|
|
378
|
+
- ✅ Type-safe custom exceptions for error handling
|
|
379
|
+
- ✅ **Mouse & Keyboard**: User input simulation
|
|
380
|
+
- ✅ Mouse clicks (single, double, triple) with customizable delay
|
|
381
|
+
- ✅ Mouse movements and button states
|
|
382
|
+
- ✅ Keyboard typing with per-character delay
|
|
383
|
+
- ✅ Special key support
|
|
384
|
+
|
|
385
|
+
#### Screenshot Features
|
|
386
|
+
Comprehensive screenshot functionality with 12 passing tests:
|
|
387
|
+
|
|
388
|
+
- ✅ Basic screenshots
|
|
389
|
+
- ✅ Clipping regions (document/viewport coordinates)
|
|
390
|
+
- ✅ Full page screenshots (with/without viewport expansion)
|
|
391
|
+
- ✅ Thread-safe parallel execution (single/multiple pages)
|
|
392
|
+
- ✅ Retina display compatibility (odd-sized clips)
|
|
393
|
+
- ✅ Automatic viewport restoration
|
|
394
|
+
- ✅ Base64 encoding
|
|
395
|
+
|
|
396
|
+
#### Foundation Layer
|
|
397
|
+
- ✅ Browser launching with Firefox
|
|
398
|
+
- ✅ BiDi protocol connection (WebSocket-based)
|
|
399
|
+
- ✅ WebSocket transport with async/await support
|
|
400
|
+
- ✅ Command execution with timeout
|
|
401
|
+
- ✅ Event subscription and handling
|
|
402
|
+
|
|
403
|
+
#### Core Layer (`lib/puppeteer/bidi/core/`)
|
|
404
|
+
A low-level object-oriented abstraction over the WebDriver BiDi protocol:
|
|
405
|
+
|
|
406
|
+
- ✅ **Infrastructure**: EventEmitter, Disposable, Custom Exceptions
|
|
407
|
+
- ✅ **Session Management**: BiDi session lifecycle
|
|
408
|
+
- ✅ **Browser & Contexts**: Browser, UserContext, BrowsingContext
|
|
409
|
+
- ✅ **Navigation**: Navigation lifecycle tracking
|
|
410
|
+
- ✅ **JavaScript Execution**: Realm classes (Window, Worker)
|
|
411
|
+
- ✅ **Network**: Request/Response management with interception
|
|
412
|
+
- ✅ **User Interaction**: UserPrompt handling (alert/confirm/prompt)
|
|
413
|
+
|
|
414
|
+
#### BiDi Operations
|
|
415
|
+
- ✅ Browsing context management (create/close tabs/windows)
|
|
416
|
+
- ✅ Page navigation with wait conditions
|
|
417
|
+
- ✅ JavaScript evaluation (`script.evaluate`, `script.callFunction`)
|
|
418
|
+
- ✅ Expression evaluation
|
|
419
|
+
- ✅ Function calls with argument serialization
|
|
420
|
+
- ✅ Result deserialization (numbers, strings, arrays, objects, Maps, special values)
|
|
421
|
+
- ✅ Exception handling and propagation
|
|
422
|
+
- ✅ IIFE support
|
|
423
|
+
- ✅ Screenshot capture
|
|
424
|
+
- ✅ PDF generation
|
|
425
|
+
- ✅ Cookie management (get/set/delete)
|
|
426
|
+
- ✅ Network request interception
|
|
427
|
+
- ✅ Geolocation and timezone emulation
|
|
428
|
+
|
|
429
|
+
### Custom Exception Handling
|
|
430
|
+
|
|
431
|
+
The gem provides type-safe custom exceptions for better error handling:
|
|
432
|
+
|
|
433
|
+
```ruby
|
|
434
|
+
begin
|
|
435
|
+
page.eval_on_selector('.missing', 'e => e.id')
|
|
436
|
+
rescue Puppeteer::Bidi::SelectorNotFoundError => e
|
|
437
|
+
puts "Selector '#{e.selector}' not found"
|
|
438
|
+
rescue Puppeteer::Bidi::JSHandleDisposedError
|
|
439
|
+
puts "Handle was disposed"
|
|
440
|
+
rescue Puppeteer::Bidi::PageClosedError
|
|
441
|
+
puts "Page is closed"
|
|
442
|
+
rescue Puppeteer::Bidi::FrameDetachedError
|
|
443
|
+
puts "Frame was detached"
|
|
444
|
+
end
|
|
445
|
+
```
|
|
446
|
+
|
|
447
|
+
Available custom exceptions:
|
|
448
|
+
- `JSHandleDisposedError` - JSHandle or ElementHandle is disposed
|
|
449
|
+
- `PageClosedError` - Page is closed
|
|
450
|
+
- `FrameDetachedError` - Frame is detached
|
|
451
|
+
- `SelectorNotFoundError` - Selector doesn't match any elements (includes `selector` attribute)
|
|
452
|
+
|
|
453
|
+
### Planned Features
|
|
454
|
+
|
|
455
|
+
- File upload handling
|
|
456
|
+
- Enhanced network monitoring (NetworkManager)
|
|
457
|
+
- Frame management (FrameManager with iframe support)
|
|
458
|
+
- Service Worker support
|
|
459
|
+
- Dialog handling (alert, confirm, prompt)
|
|
460
|
+
- Advanced navigation options (referrer, AbortSignal support)
|
|
461
|
+
|
|
462
|
+
## Comparison with Puppeteer (Node.js)
|
|
463
|
+
|
|
464
|
+
This gem aims to provide a Ruby-friendly API that closely mirrors the original Puppeteer API while following Ruby conventions and idioms.
|
|
465
|
+
|
|
466
|
+
## Development
|
|
467
|
+
|
|
468
|
+
After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
|
|
469
|
+
|
|
470
|
+
To install this gem onto your local machine, run `bundle exec rake install`.
|
|
471
|
+
|
|
472
|
+
### Type Annotations
|
|
473
|
+
|
|
474
|
+
This gem includes RBS type definitions generated by [rbs-inline](https://github.com/soutaro/rbs-inline).
|
|
475
|
+
|
|
476
|
+
## Contributing
|
|
477
|
+
|
|
478
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/YusukeIwaki/puppeteer-bidi.
|
|
479
|
+
|
|
480
|
+
1. Fork the repository
|
|
481
|
+
2. Create your feature branch (`git checkout -b my-new-feature`)
|
|
482
|
+
3. Commit your changes (`git commit -am 'Add some feature'`)
|
|
483
|
+
4. Push to the branch (`git push origin my-new-feature`)
|
|
484
|
+
5. Create a new Pull Request
|
|
485
|
+
|
|
486
|
+
## License
|
|
487
|
+
|
|
488
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
data/Rakefile
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/gem_tasks"
|
|
4
|
+
require "rspec/core/rake_task"
|
|
5
|
+
|
|
6
|
+
RSpec::Core::RakeTask.new(:spec)
|
|
7
|
+
|
|
8
|
+
require "rubocop/rake_task"
|
|
9
|
+
|
|
10
|
+
RuboCop::RakeTask.new
|
|
11
|
+
|
|
12
|
+
task default: %i[spec rubocop]
|
|
13
|
+
|
|
14
|
+
# Generate RBS files from rbs-inline annotations
|
|
15
|
+
desc "Generate RBS files with rbs-inline"
|
|
16
|
+
task :rbs do
|
|
17
|
+
sh "bundle", "exec", "rbs-inline", "--output=sig", "lib"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Run rbs-inline before building the gem
|
|
21
|
+
Rake::Task[:build].enhance([:rbs])
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'async'
|
|
4
|
+
require 'async/promise'
|
|
5
|
+
require 'async/barrier'
|
|
6
|
+
|
|
7
|
+
module Puppeteer
|
|
8
|
+
module Bidi
|
|
9
|
+
# Utility methods for working with Async tasks
|
|
10
|
+
# Provides Promise.all and Promise.race equivalents using Async::Barrier
|
|
11
|
+
module AsyncUtils
|
|
12
|
+
extend self
|
|
13
|
+
|
|
14
|
+
def await(task)
|
|
15
|
+
if task.is_a?(Proc)
|
|
16
|
+
task.call
|
|
17
|
+
elsif task.respond_to?(:wait)
|
|
18
|
+
task.wait
|
|
19
|
+
else
|
|
20
|
+
task
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Execute a task with a timeout using Async::Task#with_timeout
|
|
25
|
+
# @param timeout_ms [Numeric] Timeout duration in milliseconds
|
|
26
|
+
# @param task [Proc, Async::Promise, nil] Task to execute; falls back to block
|
|
27
|
+
# @yield [async_task] Execute a task within the timeout, optionally receiving Async::Task
|
|
28
|
+
# @return [Async::Task] Async task that resolves/rejects once the operation completes
|
|
29
|
+
def async_timeout(timeout_ms, task = nil, &block)
|
|
30
|
+
timeout_seconds = timeout_ms / 1000.0
|
|
31
|
+
|
|
32
|
+
if task
|
|
33
|
+
Async do |async_task|
|
|
34
|
+
async_task.with_timeout(timeout_seconds) do
|
|
35
|
+
if task.is_a?(Proc)
|
|
36
|
+
args = task.arity.positive? ? [async_task] : []
|
|
37
|
+
task.call(*args)
|
|
38
|
+
else
|
|
39
|
+
await(task)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
elsif block
|
|
44
|
+
Async do |async_task|
|
|
45
|
+
async_task.with_timeout(timeout_seconds) do
|
|
46
|
+
args = block.arity.positive? ? [async_task] : []
|
|
47
|
+
await(block.call(*args))
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
else
|
|
51
|
+
raise ArgumentError, 'AsyncUtils.async_timeout requires a task or block'
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def promise_all(*tasks)
|
|
56
|
+
Async { zip(*tasks) }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Wait for all async tasks to complete and return results
|
|
60
|
+
# Similar to Promise.all in JavaScript
|
|
61
|
+
# @param tasks [Array<Proc, Async::Promise>] Array of procs or promises
|
|
62
|
+
# @return [Array] Array of results in the same order as the input tasks
|
|
63
|
+
# @raise If any task raises an exception, it will be propagated
|
|
64
|
+
# @example With procs
|
|
65
|
+
# results = AsyncUtils.await_promise_all(
|
|
66
|
+
# -> { sleep 0.1; "first" },
|
|
67
|
+
# -> { sleep 0.2; "second" },
|
|
68
|
+
# -> { sleep 0.05; "third" }
|
|
69
|
+
# )
|
|
70
|
+
# # => ["first", "second", "third"]
|
|
71
|
+
# @example With promises
|
|
72
|
+
# promise1 = Async::Promise.new
|
|
73
|
+
# promise2 = Async::Promise.new
|
|
74
|
+
# Thread.new { sleep 0.1; promise1.resolve("first") }
|
|
75
|
+
# Thread.new { sleep 0.2; promise2.resolve("second") }
|
|
76
|
+
# results = AsyncUtils.await_promise_all(promise1, promise2)
|
|
77
|
+
# # => ["first", "second"]
|
|
78
|
+
def await_promise_all(*tasks)
|
|
79
|
+
Sync { zip(*tasks) }
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def promise_race(*tasks)
|
|
84
|
+
Async { first(*tasks) }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Race multiple async tasks and return the result of the first one to complete
|
|
88
|
+
# Similar to Promise.race in JavaScript
|
|
89
|
+
# @param tasks [Array<Proc, Async::Promise>] Array of procs or promises
|
|
90
|
+
# @return The result of the first task to complete
|
|
91
|
+
# @example With procs
|
|
92
|
+
# result = AsyncUtils.await_promise_race(
|
|
93
|
+
# -> { sleep 1; "slow" },
|
|
94
|
+
# -> { sleep 0.1; "fast" }
|
|
95
|
+
# )
|
|
96
|
+
# # => "fast"
|
|
97
|
+
# @example With promises
|
|
98
|
+
# promise1 = Async::Promise.new
|
|
99
|
+
# promise2 = Async::Promise.new
|
|
100
|
+
# Thread.new { sleep 0.3; promise1.resolve("slow") }
|
|
101
|
+
# Thread.new { sleep 0.1; promise2.resolve("fast") }
|
|
102
|
+
# result = AsyncUtils.await_promise_race(promise1, promise2)
|
|
103
|
+
# # => "fast"
|
|
104
|
+
def await_promise_race(*tasks)
|
|
105
|
+
Sync { first(*tasks) }
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
private
|
|
109
|
+
|
|
110
|
+
def zip(*tasks)
|
|
111
|
+
barrier = Async::Barrier.new
|
|
112
|
+
results = Array.new(tasks.size)
|
|
113
|
+
|
|
114
|
+
tasks.each_with_index do |task, index|
|
|
115
|
+
barrier.async do
|
|
116
|
+
results[index] = await(task)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Wait for all tasks to complete
|
|
121
|
+
barrier.wait
|
|
122
|
+
|
|
123
|
+
results
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def first(*tasks)
|
|
127
|
+
barrier = Async::Barrier.new
|
|
128
|
+
result = nil
|
|
129
|
+
|
|
130
|
+
begin
|
|
131
|
+
tasks.each do |task|
|
|
132
|
+
barrier.async do
|
|
133
|
+
await(task)
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Wait for the first task to complete
|
|
138
|
+
barrier.wait do |completed_task|
|
|
139
|
+
result = completed_task.wait
|
|
140
|
+
break # Stop waiting after the first task completes
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
result
|
|
144
|
+
ensure
|
|
145
|
+
# Cancel all remaining tasks
|
|
146
|
+
barrier.stop
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|