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,198 @@
|
|
|
1
|
+
# Selector Evaluation Methods Implementation
|
|
2
|
+
|
|
3
|
+
This document explains the implementation of `eval_on_selector` and `eval_on_selector_all` methods, including delegation patterns, handle lifecycle, and performance considerations.
|
|
4
|
+
|
|
5
|
+
### Overview
|
|
6
|
+
|
|
7
|
+
The `eval_on_selector` and `eval_on_selector_all` methods provide convenient shortcuts for querying elements and evaluating JavaScript functions on them, equivalent to Puppeteer's `$eval` and `$$eval`.
|
|
8
|
+
|
|
9
|
+
### API Design
|
|
10
|
+
|
|
11
|
+
#### Method Naming Convention
|
|
12
|
+
|
|
13
|
+
Ruby cannot use `$` in method names, so we use descriptive alternatives:
|
|
14
|
+
|
|
15
|
+
| Puppeteer | Ruby | Description |
|
|
16
|
+
|-----------|------|-------------|
|
|
17
|
+
| `$eval` | `eval_on_selector` | Evaluate on first matching element |
|
|
18
|
+
| `$$eval` | `eval_on_selector_all` | Evaluate on all matching elements |
|
|
19
|
+
|
|
20
|
+
#### Implementation Hierarchy
|
|
21
|
+
|
|
22
|
+
Following Puppeteer's delegation pattern:
|
|
23
|
+
|
|
24
|
+
```
|
|
25
|
+
Page#eval_on_selector(_all)
|
|
26
|
+
↓ delegates to
|
|
27
|
+
Frame#eval_on_selector(_all)
|
|
28
|
+
↓ delegates to
|
|
29
|
+
ElementHandle#eval_on_selector(_all) (on document)
|
|
30
|
+
↓ implementation
|
|
31
|
+
1. query_selector(_all) - Find element(s)
|
|
32
|
+
2. Validate results
|
|
33
|
+
3. evaluate() - Execute function
|
|
34
|
+
4. dispose - Clean up handles
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### Implementation Details
|
|
38
|
+
|
|
39
|
+
#### Page and Frame Methods
|
|
40
|
+
|
|
41
|
+
```ruby
|
|
42
|
+
# lib/puppeteer/bidi/page.rb
|
|
43
|
+
def eval_on_selector(selector, page_function, *args)
|
|
44
|
+
main_frame.eval_on_selector(selector, page_function, *args)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# lib/puppeteer/bidi/frame.rb
|
|
48
|
+
def eval_on_selector(selector, page_function, *args)
|
|
49
|
+
document.eval_on_selector(selector, page_function, *args)
|
|
50
|
+
end
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
**Design rationale**: Page and Frame act as thin wrappers, delegating to the document element handle.
|
|
54
|
+
|
|
55
|
+
#### ElementHandle#eval_on_selector
|
|
56
|
+
|
|
57
|
+
```ruby
|
|
58
|
+
def eval_on_selector(selector, page_function, *args)
|
|
59
|
+
assert_not_disposed
|
|
60
|
+
|
|
61
|
+
element_handle = query_selector(selector)
|
|
62
|
+
raise SelectorNotFoundError, selector unless element_handle
|
|
63
|
+
|
|
64
|
+
begin
|
|
65
|
+
element_handle.evaluate(page_function, *args)
|
|
66
|
+
ensure
|
|
67
|
+
element_handle.dispose
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
**Key points**:
|
|
73
|
+
- Throws `SelectorNotFoundError` if no element found (matches Puppeteer behavior)
|
|
74
|
+
- Uses `begin/ensure` to guarantee handle disposal
|
|
75
|
+
- Searches within element's subtree (not page-wide)
|
|
76
|
+
|
|
77
|
+
#### ElementHandle#eval_on_selector_all
|
|
78
|
+
|
|
79
|
+
```ruby
|
|
80
|
+
def eval_on_selector_all(selector, page_function, *args)
|
|
81
|
+
assert_not_disposed
|
|
82
|
+
|
|
83
|
+
element_handles = query_selector_all(selector)
|
|
84
|
+
|
|
85
|
+
begin
|
|
86
|
+
# Create array handle in browser context
|
|
87
|
+
array_handle = @realm.call_function(
|
|
88
|
+
'(...elements) => elements',
|
|
89
|
+
false,
|
|
90
|
+
arguments: element_handles.map(&:remote_value)
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
array_js_handle = JSHandle.from(array_handle['result'], @realm)
|
|
94
|
+
|
|
95
|
+
begin
|
|
96
|
+
array_js_handle.evaluate(page_function, *args)
|
|
97
|
+
ensure
|
|
98
|
+
array_js_handle.dispose
|
|
99
|
+
end
|
|
100
|
+
ensure
|
|
101
|
+
element_handles.each(&:dispose)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
**Key points**:
|
|
107
|
+
- Returns result for empty array without error (differs from `eval_on_selector`)
|
|
108
|
+
- Creates array handle using spread operator trick: `(...elements) => elements`
|
|
109
|
+
- Nested `ensure` blocks for proper resource cleanup
|
|
110
|
+
- Disposes both individual element handles and array handle
|
|
111
|
+
|
|
112
|
+
### Error Handling Differences
|
|
113
|
+
|
|
114
|
+
| Method | Behavior when no elements found |
|
|
115
|
+
|--------|--------------------------------|
|
|
116
|
+
| `eval_on_selector` | Throws `SelectorNotFoundError` |
|
|
117
|
+
| `eval_on_selector_all` | Returns evaluation result (e.g., `0` for `divs => divs.length`) |
|
|
118
|
+
|
|
119
|
+
This matches Puppeteer's behavior:
|
|
120
|
+
- `$eval`: Must find exactly one element
|
|
121
|
+
- `$$eval`: Works with zero or more elements
|
|
122
|
+
|
|
123
|
+
### Usage Examples
|
|
124
|
+
|
|
125
|
+
```ruby
|
|
126
|
+
# Basic usage
|
|
127
|
+
page.set_content('<section id="test">Hello</section>')
|
|
128
|
+
id = page.eval_on_selector('section', 'e => e.id')
|
|
129
|
+
# => "test"
|
|
130
|
+
|
|
131
|
+
# With arguments
|
|
132
|
+
text = page.eval_on_selector('section', '(e, suffix) => e.textContent + suffix', '!')
|
|
133
|
+
# => "Hello!"
|
|
134
|
+
|
|
135
|
+
# ElementHandle arguments
|
|
136
|
+
div = page.query_selector('div')
|
|
137
|
+
result = page.eval_on_selector('section', '(e, div) => e.textContent + div.textContent', div)
|
|
138
|
+
|
|
139
|
+
# eval_on_selector_all with multiple elements
|
|
140
|
+
page.set_content('<div>A</div><div>B</div><div>C</div>')
|
|
141
|
+
count = page.eval_on_selector_all('div', 'divs => divs.length')
|
|
142
|
+
# => 3
|
|
143
|
+
|
|
144
|
+
# Subtree search with ElementHandle
|
|
145
|
+
tweet = page.query_selector('.tweet')
|
|
146
|
+
likes = tweet.eval_on_selector('.like', 'node => node.innerText')
|
|
147
|
+
# Only searches within .tweet element
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
### Test Coverage
|
|
151
|
+
|
|
152
|
+
**Total**: 13 integration tests
|
|
153
|
+
|
|
154
|
+
**Page.eval_on_selector** (4 tests):
|
|
155
|
+
- Basic functionality (property access)
|
|
156
|
+
- Argument passing
|
|
157
|
+
- ElementHandle arguments
|
|
158
|
+
- Error on missing selector
|
|
159
|
+
|
|
160
|
+
**ElementHandle.eval_on_selector** (3 tests):
|
|
161
|
+
- Basic functionality
|
|
162
|
+
- Subtree isolation
|
|
163
|
+
- Error on missing selector
|
|
164
|
+
|
|
165
|
+
**Page.eval_on_selector_all** (4 tests):
|
|
166
|
+
- Basic functionality (array length)
|
|
167
|
+
- Extra arguments
|
|
168
|
+
- ElementHandle arguments
|
|
169
|
+
- Large element count (1001 elements)
|
|
170
|
+
|
|
171
|
+
**ElementHandle.eval_on_selector_all** (2 tests):
|
|
172
|
+
- Subtree retrieval
|
|
173
|
+
- Empty result handling
|
|
174
|
+
|
|
175
|
+
### Performance Considerations
|
|
176
|
+
|
|
177
|
+
#### Handle Lifecycle
|
|
178
|
+
|
|
179
|
+
- **eval_on_selector**: Creates 1 temporary handle per call
|
|
180
|
+
- **eval_on_selector_all**: Creates N+1 handles (N elements + 1 array)
|
|
181
|
+
- All handles automatically disposed after evaluation
|
|
182
|
+
|
|
183
|
+
#### Large Element Sets
|
|
184
|
+
|
|
185
|
+
Tested with 1001 elements without issues. The implementation efficiently:
|
|
186
|
+
1. Queries all elements at once
|
|
187
|
+
2. Creates single array handle
|
|
188
|
+
3. Evaluates function in single round-trip
|
|
189
|
+
4. Disposes all handles in parallel
|
|
190
|
+
|
|
191
|
+
### Reference Implementation
|
|
192
|
+
|
|
193
|
+
Based on Puppeteer's implementation:
|
|
194
|
+
- [Page.$eval](https://github.com/puppeteer/puppeteer/blob/main/packages/puppeteer-core/src/api/Page.ts)
|
|
195
|
+
- [Frame.$eval](https://github.com/puppeteer/puppeteer/blob/main/packages/puppeteer-core/src/api/Frame.ts)
|
|
196
|
+
- [ElementHandle.$eval](https://github.com/puppeteer/puppeteer/blob/main/packages/puppeteer-core/src/api/ElementHandle.ts)
|
|
197
|
+
- [Test specs](https://github.com/puppeteer/puppeteer/blob/main/test/src/queryselector.spec.ts)
|
|
198
|
+
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
# Test Server Dynamic Routes
|
|
2
|
+
|
|
3
|
+
This document describes the dynamic route handling functionality added to the test server infrastructure.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
The test server (`spec/support/test_server.rb`) has been extended to support dynamic route interception and request synchronization, matching Puppeteer's test server capabilities.
|
|
8
|
+
|
|
9
|
+
## Features
|
|
10
|
+
|
|
11
|
+
### 1. Dynamic Route Interception
|
|
12
|
+
|
|
13
|
+
**Purpose**: Intercept specific routes and control the response timing/content.
|
|
14
|
+
|
|
15
|
+
**API:**
|
|
16
|
+
|
|
17
|
+
```ruby
|
|
18
|
+
server.set_route(path) do |request, response|
|
|
19
|
+
# Control when/how to respond
|
|
20
|
+
response_holder[:response] = response
|
|
21
|
+
end
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
**Usage Example:**
|
|
25
|
+
|
|
26
|
+
```ruby
|
|
27
|
+
it 'should intercept CSS loading' do
|
|
28
|
+
with_test_state do |page:, server:, **|
|
|
29
|
+
response_holder = {}
|
|
30
|
+
|
|
31
|
+
# Intercept CSS file to control when it loads
|
|
32
|
+
server.set_route('/one-style.css') do |_req, res|
|
|
33
|
+
response_holder[:response] = res
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Navigate to page (will wait for CSS)
|
|
37
|
+
page.goto("#{server.prefix}/one-style.html", wait: 'none')
|
|
38
|
+
|
|
39
|
+
# Do something while CSS is pending...
|
|
40
|
+
|
|
41
|
+
# Release the CSS response
|
|
42
|
+
response_holder[:response].finish
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### 2. Request Synchronization
|
|
48
|
+
|
|
49
|
+
**Purpose**: Wait for a specific request to arrive at the server before proceeding.
|
|
50
|
+
|
|
51
|
+
**API:**
|
|
52
|
+
|
|
53
|
+
```ruby
|
|
54
|
+
# Returns an Async task that resolves when request is received
|
|
55
|
+
task = server.wait_for_request(path)
|
|
56
|
+
task.wait # Block until request arrives (with 5s timeout)
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
**Usage Example:**
|
|
60
|
+
|
|
61
|
+
```ruby
|
|
62
|
+
it 'should wait for image request' do
|
|
63
|
+
with_test_state do |page:, server:, **|
|
|
64
|
+
# Start navigation
|
|
65
|
+
page.goto("#{server.prefix}/page-with-image.html", wait: 'none')
|
|
66
|
+
|
|
67
|
+
# Wait for the image to be requested
|
|
68
|
+
begin
|
|
69
|
+
server.wait_for_request('/image.png').wait
|
|
70
|
+
puts "Image was requested!"
|
|
71
|
+
rescue Async::TimeoutError
|
|
72
|
+
puts "Image request never arrived"
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Implementation Details
|
|
79
|
+
|
|
80
|
+
### Async HTTP Server
|
|
81
|
+
|
|
82
|
+
`TestServer::Server` now runs an `Async::HTTP::Server` inside a dedicated thread. The server keeps two shared hashes guarded by mutexes:
|
|
83
|
+
|
|
84
|
+
- `@routes` maps request paths to custom handlers.
|
|
85
|
+
- `@request_promises` stores waiters created via `wait_for_request`.
|
|
86
|
+
|
|
87
|
+
Incoming requests execute the following flow:
|
|
88
|
+
|
|
89
|
+
```ruby
|
|
90
|
+
server = Async::HTTP::Server.for(endpoint) do |request|
|
|
91
|
+
if handler = lookup_route(request.path)
|
|
92
|
+
notify_request(request.path)
|
|
93
|
+
respond_with_handler(handler, request)
|
|
94
|
+
else
|
|
95
|
+
serve_static_asset(request)
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Static assets are served from `spec/assets`, while dynamic route handlers receive a lightweight wrapper (`RouteRequest`) exposing `path`, `headers`, `params`, and optional `body` accessors.
|
|
101
|
+
|
|
102
|
+
### Response Writer
|
|
103
|
+
|
|
104
|
+
Dynamic handlers interact with a `ResponseWriter` instance that buffers data until `finish` is invoked:
|
|
105
|
+
|
|
106
|
+
```ruby
|
|
107
|
+
server.set_route('/slow.css') do |_request, writer|
|
|
108
|
+
writer.add_header('content-type', 'text/css; charset=utf-8')
|
|
109
|
+
writer.write("body { background: red; }")
|
|
110
|
+
writer.finish
|
|
111
|
+
end
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
The server task waits asynchronously for `writer.finish` before constructing the final `Protocol::HTTP::Response`. Handlers may capture the writer and complete it later from other tasks or threads, enabling Puppeteer-style resource gating.
|
|
115
|
+
|
|
116
|
+
## Testing Navigation Events
|
|
117
|
+
|
|
118
|
+
### Puppeteer Test Pattern
|
|
119
|
+
|
|
120
|
+
A common Puppeteer test pattern tests the timing of `domcontentloaded` vs `load` events:
|
|
121
|
+
|
|
122
|
+
```typescript
|
|
123
|
+
it("should work with both domcontentloaded and load", async () => {
|
|
124
|
+
let response!: ServerResponse;
|
|
125
|
+
server.setRoute("/one-style.css", (_req, res) => {
|
|
126
|
+
return (response = res);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
let bothFired = false;
|
|
130
|
+
|
|
131
|
+
const navigationPromise = page.goto(server.PREFIX + "/one-style.html");
|
|
132
|
+
const domContentLoadedPromise = page.waitForNavigation({
|
|
133
|
+
waitUntil: "domcontentloaded",
|
|
134
|
+
});
|
|
135
|
+
const loadFiredPromise = page
|
|
136
|
+
.waitForNavigation({ waitUntil: "load" })
|
|
137
|
+
.then(() => {
|
|
138
|
+
bothFired = true;
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
await server.waitForRequest("/one-style.css");
|
|
142
|
+
await domContentLoadedPromise;
|
|
143
|
+
expect(bothFired).toBe(false); // load hasn't fired yet
|
|
144
|
+
|
|
145
|
+
response.end(); // Release CSS
|
|
146
|
+
await loadFiredPromise; // Now load fires
|
|
147
|
+
});
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
## Known Limitations and Challenges
|
|
151
|
+
|
|
152
|
+
### 1. Navigation Timing Coordination
|
|
153
|
+
|
|
154
|
+
**Challenge**: Testing `domcontentloaded` vs `load` timing requires careful coordination of:
|
|
155
|
+
|
|
156
|
+
- Navigation start (must not wait for load)
|
|
157
|
+
- Event listeners (must be registered before events fire)
|
|
158
|
+
- Resource loading (must control when resources complete)
|
|
159
|
+
|
|
160
|
+
**Issue**: In Ruby implementation, using `page.goto()` causes problems because:
|
|
161
|
+
|
|
162
|
+
- `goto(url)` internally calls `navigate(url, wait: 'complete')` by default
|
|
163
|
+
- This blocks until the `load` event fires
|
|
164
|
+
- Cannot register `wait_for_navigation` listeners after navigation completes
|
|
165
|
+
|
|
166
|
+
**Attempted Solutions:**
|
|
167
|
+
|
|
168
|
+
1. **Using `wait: 'none'`**:
|
|
169
|
+
|
|
170
|
+
```ruby
|
|
171
|
+
page.browsing_context.navigate(url, wait: 'none')
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
- Doesn't block on navigation
|
|
175
|
+
- But bypasses high-level `Page` API
|
|
176
|
+
- Still has timing issues with event listener registration
|
|
177
|
+
|
|
178
|
+
2. **Parallel Async tasks**:
|
|
179
|
+
```ruby
|
|
180
|
+
navigation_task = Async { page.goto(url) }
|
|
181
|
+
dom_loaded_task = Async { page.wait_for_navigation(wait_until: 'domcontentloaded') }
|
|
182
|
+
load_task = Async { page.wait_for_navigation(wait_until: 'load') }
|
|
183
|
+
```
|
|
184
|
+
- Race condition: `wait_for_navigation` might miss events if called after navigation completes
|
|
185
|
+
- Async task scheduling order is not guaranteed
|
|
186
|
+
|
|
187
|
+
### 2. Request Promise Resolution
|
|
188
|
+
|
|
189
|
+
**Issue**: `server.wait_for_request()` timeout errors are logged as warnings:
|
|
190
|
+
|
|
191
|
+
```
|
|
192
|
+
Async::TimeoutError: execution expired
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
This is expected behavior when the request arrives immediately (before `wait_for_request` is called), but the warning is noisy.
|
|
196
|
+
|
|
197
|
+
**Current Workaround:**
|
|
198
|
+
|
|
199
|
+
```ruby
|
|
200
|
+
begin
|
|
201
|
+
server.wait_for_request('/one-style.css').wait
|
|
202
|
+
rescue Async::TimeoutError
|
|
203
|
+
# Request might have already arrived - ignore
|
|
204
|
+
end
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
### 3. Response Writer Semantics
|
|
208
|
+
|
|
209
|
+
**Limitation**: The custom `ResponseWriter` currently buffers the entire body before sending it back through `Protocol::HTTP::Response`. True streaming responses are not yet implemented, so large payloads are held in memory until `finish` is called. Handlers should keep payloads small, or extend the writer to stream chunks if needed in future work.
|
|
210
|
+
|
|
211
|
+
## Future Improvements
|
|
212
|
+
|
|
213
|
+
### 1. Navigation API Enhancement
|
|
214
|
+
|
|
215
|
+
Consider adding a `Page.navigate` method that exposes the `wait` parameter:
|
|
216
|
+
|
|
217
|
+
```ruby
|
|
218
|
+
# High-level API with wait control
|
|
219
|
+
page.navigate(url, wait: 'none') # Start navigation without waiting
|
|
220
|
+
page.navigate(url, wait: 'interactive') # Wait for domcontentloaded
|
|
221
|
+
page.navigate(url, wait: 'complete') # Wait for load (default)
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
### 2. Event Listener Preregistration
|
|
225
|
+
|
|
226
|
+
Add API to register navigation listeners before starting navigation:
|
|
227
|
+
|
|
228
|
+
```ruby
|
|
229
|
+
page.with_navigation_listeners do |listeners|
|
|
230
|
+
listeners.on_dom_content_loaded { puts "DOM ready" }
|
|
231
|
+
listeners.on_load { puts "Page loaded" }
|
|
232
|
+
|
|
233
|
+
page.goto(url) # Listeners already registered
|
|
234
|
+
end
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
### 3. Test Server Request Queue
|
|
238
|
+
|
|
239
|
+
Store all incoming requests in a queue for post-facto checking:
|
|
240
|
+
|
|
241
|
+
```ruby
|
|
242
|
+
# Record all requests
|
|
243
|
+
server.enable_request_recording
|
|
244
|
+
|
|
245
|
+
page.goto(url)
|
|
246
|
+
|
|
247
|
+
# Check what was requested
|
|
248
|
+
requests = server.recorded_requests
|
|
249
|
+
expect(requests.map(&:path)).to include('/one-style.css')
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
## Related Files
|
|
253
|
+
|
|
254
|
+
- `spec/support/test_server.rb` - Test server implementation
|
|
255
|
+
- `spec/integration/navigation_spec.rb` - Navigation tests using dynamic routes
|
|
256
|
+
- `spec/assets/one-style.html` - Test asset with external CSS
|
|
257
|
+
- `spec/assets/one-style.css` - CSS file for testing resource loading
|
|
258
|
+
|
|
259
|
+
## References
|
|
260
|
+
|
|
261
|
+
- [Puppeteer test server](https://github.com/puppeteer/puppeteer/blob/main/test/src/server/index.ts)
|
|
262
|
+
- [Puppeteer navigation tests](https://github.com/puppeteer/puppeteer/blob/main/test/src/navigation.spec.ts)
|
|
263
|
+
- [async-http server guide](https://socketry.github.io/async-http/guides/getting-started/index.html#making-a-server)
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
# Testing Strategy and Performance Optimization
|
|
2
|
+
|
|
3
|
+
This document covers integration test organization, performance optimization strategies, golden image testing, and debugging techniques.
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
#### Integration Tests Organization
|
|
7
|
+
|
|
8
|
+
```
|
|
9
|
+
spec/
|
|
10
|
+
├── unit/ # Fast unit tests (future)
|
|
11
|
+
├── integration/ # Browser automation tests
|
|
12
|
+
│ ├── examples/ # Example-based tests
|
|
13
|
+
│ │ └── screenshot_spec.rb
|
|
14
|
+
│ └── screenshot_spec.rb # Feature test suites
|
|
15
|
+
├── assets/ # Test HTML/CSS/JS files
|
|
16
|
+
│ ├── grid.html
|
|
17
|
+
│ ├── scrollbar.html
|
|
18
|
+
│ ├── empty.html
|
|
19
|
+
│ └── digits/*.png
|
|
20
|
+
├── golden-firefox/ # Reference images
|
|
21
|
+
│ └── screenshot-*.png
|
|
22
|
+
└── support/ # Test utilities
|
|
23
|
+
├── test_server.rb
|
|
24
|
+
└── golden_comparator.rb
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
#### Implemented Screenshot Tests
|
|
28
|
+
|
|
29
|
+
All 12 tests ported from [Puppeteer's screenshot.spec.ts](https://github.com/puppeteer/puppeteer/blob/main/test/src/screenshot.spec.ts):
|
|
30
|
+
|
|
31
|
+
1. **should work** - Basic screenshot functionality
|
|
32
|
+
2. **should clip rect** - Clipping specific region
|
|
33
|
+
3. **should get screenshot bigger than the viewport** - Offscreen clip with captureBeyondViewport
|
|
34
|
+
4. **should clip bigger than the viewport without "captureBeyondViewport"** - Viewport coordinate transformation
|
|
35
|
+
5. **should run in parallel** - Thread-safe parallel screenshots on single page
|
|
36
|
+
6. **should take fullPage screenshots** - Full page with document origin
|
|
37
|
+
7. **should take fullPage screenshots without captureBeyondViewport** - Full page with viewport resize
|
|
38
|
+
8. **should run in parallel in multiple pages** - Concurrent screenshots across multiple pages
|
|
39
|
+
9. **should work with odd clip size on Retina displays** - Odd pixel dimensions (11x11)
|
|
40
|
+
10. **should return base64** - Base64 encoding verification
|
|
41
|
+
11. **should take fullPage screenshots when defaultViewport is null** - No explicit viewport
|
|
42
|
+
12. **should restore to original viewport size** - Viewport restoration after fullPage
|
|
43
|
+
|
|
44
|
+
Run tests:
|
|
45
|
+
```bash
|
|
46
|
+
bundle exec rspec spec/integration/screenshot_spec.rb
|
|
47
|
+
# Expected: 12 examples, 0 failures (completes in ~8 seconds with optimized spec_helper)
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
#### Test Performance Optimization
|
|
51
|
+
|
|
52
|
+
**Critical**: Integration tests are ~19x faster with browser reuse strategy.
|
|
53
|
+
|
|
54
|
+
##### Before Optimization (Per-test Browser Launch)
|
|
55
|
+
```ruby
|
|
56
|
+
def with_test_state(**options)
|
|
57
|
+
server = TestServer::Server.new
|
|
58
|
+
server.start
|
|
59
|
+
|
|
60
|
+
with_browser(**options) do |browser| # New browser per test!
|
|
61
|
+
context = browser.default_browser_context
|
|
62
|
+
page = browser.new_page
|
|
63
|
+
yield(page: page, server: server, browser: browser, context: context)
|
|
64
|
+
end
|
|
65
|
+
ensure
|
|
66
|
+
server.stop
|
|
67
|
+
end
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
**Performance**: ~195 seconds for 35 tests (browser launch overhead × 35)
|
|
71
|
+
|
|
72
|
+
##### After Optimization (Shared Browser)
|
|
73
|
+
```ruby
|
|
74
|
+
# In spec_helper.rb
|
|
75
|
+
config.before(:suite) do
|
|
76
|
+
if RSpec.configuration.files_to_run.any? { |f| f.include?('spec/integration') }
|
|
77
|
+
$shared_browser = Puppeteer::Bidi.launch(headless: headless_mode?)
|
|
78
|
+
$shared_test_server = TestServer::Server.new
|
|
79
|
+
$shared_test_server.start
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def with_test_state(**options)
|
|
84
|
+
if $shared_browser && options.empty?
|
|
85
|
+
# Create new page (tab) per test
|
|
86
|
+
page = $shared_browser.new_page
|
|
87
|
+
context = $shared_browser.default_browser_context
|
|
88
|
+
|
|
89
|
+
begin
|
|
90
|
+
yield(page: page, server: $shared_test_server, browser: $shared_browser, context: context)
|
|
91
|
+
ensure
|
|
92
|
+
page.close unless page.closed? # Clean up tab
|
|
93
|
+
end
|
|
94
|
+
else
|
|
95
|
+
# Fall back to per-test browser for custom options
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
**Performance**: ~10 seconds for 35 tests (1 browser launch + 35 tab creations)
|
|
101
|
+
|
|
102
|
+
##### Performance Results
|
|
103
|
+
|
|
104
|
+
| Test Suite | Before | After | Improvement |
|
|
105
|
+
|------------|--------|-------|-------------|
|
|
106
|
+
| **evaluation_spec (23 tests)** | 127s | **7.17s** | **17.7x faster** |
|
|
107
|
+
| **screenshot_spec (12 tests)** | 68s | **8.47s** | **8.0x faster** |
|
|
108
|
+
| **Combined (35 tests)** | 195s | **10.33s** | **18.9x faster** 🚀 |
|
|
109
|
+
|
|
110
|
+
**Key Benefits**:
|
|
111
|
+
- Browser launch only once per suite
|
|
112
|
+
- Each test gets fresh page (tab) for isolation
|
|
113
|
+
- Cleanup handled automatically
|
|
114
|
+
- Backward compatible (custom options fall back to per-test browser)
|
|
115
|
+
|
|
116
|
+
#### Environment Variables
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
HEADLESS=false # Run browser in non-headless mode for debugging
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
### Debugging Techniques
|
|
123
|
+
|
|
124
|
+
#### 1. Save Screenshots for Inspection
|
|
125
|
+
|
|
126
|
+
```ruby
|
|
127
|
+
# In golden_comparator.rb
|
|
128
|
+
def save_screenshot(screenshot_base64, filename)
|
|
129
|
+
output_dir = File.join(__dir__, '../output')
|
|
130
|
+
FileUtils.mkdir_p(output_dir)
|
|
131
|
+
File.binwrite(File.join(output_dir, filename),
|
|
132
|
+
Base64.decode64(screenshot_base64))
|
|
133
|
+
end
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
#### 2. Compare Images Pixel-by-Pixel
|
|
137
|
+
|
|
138
|
+
```ruby
|
|
139
|
+
cat > /tmp/compare.rb << 'EOF'
|
|
140
|
+
require 'chunky_png'
|
|
141
|
+
|
|
142
|
+
golden = ChunkyPNG::Image.from_file('spec/golden-firefox/screenshot.png')
|
|
143
|
+
actual = ChunkyPNG::Image.from_file('spec/output/debug.png')
|
|
144
|
+
|
|
145
|
+
diff_count = 0
|
|
146
|
+
(0...golden.height).each do |y|
|
|
147
|
+
(0...golden.width).each do |x|
|
|
148
|
+
if golden[x, y] != actual[x, y]
|
|
149
|
+
diff_count += 1
|
|
150
|
+
puts "Diff at (#{x}, #{y})" if diff_count <= 10
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
puts "Total: #{diff_count} pixels differ"
|
|
155
|
+
EOF
|
|
156
|
+
ruby /tmp/compare.rb
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
#### 3. Debug BiDi Responses
|
|
160
|
+
|
|
161
|
+
```ruby
|
|
162
|
+
# Temporarily add debugging
|
|
163
|
+
result = @browsing_context.default_realm.evaluate(script, true)
|
|
164
|
+
puts "BiDi result: #{result.inspect}"
|
|
165
|
+
deserialize_result(result)
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
### Common Pitfalls and Solutions
|
|
169
|
+
|
|
170
|
+
#### 1. BiDi Protocol Differences
|
|
171
|
+
|
|
172
|
+
**Problem:** BiDi `origin` parameter behavior differs from expectations
|
|
173
|
+
|
|
174
|
+
**Solution:** Consult BiDi spec and test both `'document'` and `'viewport'` origins
|
|
175
|
+
|
|
176
|
+
```ruby
|
|
177
|
+
# document: Absolute coordinates in full page
|
|
178
|
+
# viewport: Relative to current viewport
|
|
179
|
+
options[:origin] = capture_beyond_viewport ? 'document' : 'viewport'
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
#### 2. Image Comparison Failures
|
|
183
|
+
|
|
184
|
+
**Problem:** Golden images don't match exactly (1-2 pixel differences)
|
|
185
|
+
|
|
186
|
+
**Solution:** Implement tolerance in comparison
|
|
187
|
+
|
|
188
|
+
```ruby
|
|
189
|
+
# Allow small rendering differences (±1 RGB per channel)
|
|
190
|
+
compare_with_golden(screenshot, 'golden.png', pixel_threshold: 1)
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
#### 3. Viewport State Management
|
|
194
|
+
|
|
195
|
+
**Problem:** Viewport not restored after fullPage screenshot
|
|
196
|
+
|
|
197
|
+
**Solution:** Use `ensure` block
|
|
198
|
+
|
|
199
|
+
```ruby
|
|
200
|
+
begin
|
|
201
|
+
set_viewport(full_page_dimensions)
|
|
202
|
+
screenshot = capture_screenshot(...)
|
|
203
|
+
ensure
|
|
204
|
+
set_viewport(original_viewport) if original_viewport
|
|
205
|
+
end
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
#### 4. Thread Safety
|
|
209
|
+
|
|
210
|
+
**Problem:** Parallel screenshots cause race conditions
|
|
211
|
+
|
|
212
|
+
**Solution:** BiDi protocol handles this naturally - test with threads
|
|
213
|
+
|
|
214
|
+
```ruby
|
|
215
|
+
threads = (0...3).map do |i|
|
|
216
|
+
Thread.new { page.screenshot(clip: {...}) }
|
|
217
|
+
end
|
|
218
|
+
screenshots = threads.map(&:value)
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
### Documentation References
|
|
222
|
+
|
|
223
|
+
**Essential reading for implementation:**
|
|
224
|
+
|
|
225
|
+
1. **WebDriver BiDi Spec**: https://w3c.github.io/webdriver-bidi/
|
|
226
|
+
2. **Puppeteer Source**: https://github.com/puppeteer/puppeteer
|
|
227
|
+
3. **Puppeteer BiDi Tests**: https://github.com/puppeteer/puppeteer/tree/main/test/src
|
|
228
|
+
4. **Firefox BiDi Impl**: Check Firefox implementation notes for quirks
|
|
229
|
+
|
|
230
|
+
**Reference implementation workflow:**
|
|
231
|
+
1. Find corresponding Puppeteer test in `test/src/`
|
|
232
|
+
2. Read TypeScript implementation in `packages/puppeteer-core/src/`
|
|
233
|
+
3. Check BiDi spec for protocol details
|
|
234
|
+
4. Implement Ruby version maintaining same logic
|
|
235
|
+
5. Download golden images and verify pixel-perfect match (with tolerance)
|
|
236
|
+
|