puppeteer-ruby 0.52.0 → 0.53.0
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 +4 -4
- data/docs/api_coverage.md +16 -15
- data/lib/puppeteer/accessibility.rb +300 -0
- data/lib/puppeteer/browser.rb +107 -8
- data/lib/puppeteer/browser_connector.rb +42 -6
- data/lib/puppeteer/browser_context.rb +0 -1
- data/lib/puppeteer/cdp_session.rb +29 -8
- data/lib/puppeteer/chrome_target_manager.rb +145 -34
- data/lib/puppeteer/chrome_user_data_dir.rb +89 -0
- data/lib/puppeteer/connection.rb +19 -0
- data/lib/puppeteer/coverage.rb +5 -0
- data/lib/puppeteer/css_coverage.rb +4 -0
- data/lib/puppeteer/dialog.rb +5 -0
- data/lib/puppeteer/element_handle.rb +2 -3
- data/lib/puppeteer/emulation_manager.rb +70 -9
- data/lib/puppeteer/events.rb +2 -0
- data/lib/puppeteer/execution_context.rb +7 -0
- data/lib/puppeteer/extension.rb +20 -6
- data/lib/puppeteer/frame.rb +7 -3
- data/lib/puppeteer/frame_manager.rb +71 -26
- data/lib/puppeteer/http_response.rb +15 -1
- data/lib/puppeteer/isolated_world.rb +10 -4
- data/lib/puppeteer/js_coverage.rb +4 -0
- data/lib/puppeteer/keyboard.rb +6 -0
- data/lib/puppeteer/launcher/browser_options.rb +13 -1
- data/lib/puppeteer/launcher/chrome.rb +48 -4
- data/lib/puppeteer/lifecycle_watcher.rb +1 -6
- data/lib/puppeteer/locators.rb +54 -26
- data/lib/puppeteer/mouse.rb +12 -24
- data/lib/puppeteer/network_manager.rb +16 -4
- data/lib/puppeteer/page.rb +259 -33
- data/lib/puppeteer/puppeteer.rb +13 -7
- data/lib/puppeteer/screen_recorder.rb +269 -0
- data/lib/puppeteer/target.rb +7 -3
- data/lib/puppeteer/touch_screen.rb +6 -0
- data/lib/puppeteer/tracing.rb +4 -0
- data/lib/puppeteer/version.rb +2 -2
- data/lib/puppeteer/wait_task.rb +1 -2
- data/lib/puppeteer/web_mcp.rb +227 -0
- data/lib/puppeteer/web_worker.rb +135 -9
- data/lib/puppeteer.rb +11 -1
- data/puppeteer-ruby.gemspec +1 -0
- data/sig/_supplementary.rbs +33 -1
- data/sig/puppeteer/accessibility.rbs +73 -0
- data/sig/puppeteer/browser.rbs +27 -3
- data/sig/puppeteer/cdp_session.rbs +4 -0
- data/sig/puppeteer/chrome_user_data_dir.rbs +30 -0
- data/sig/puppeteer/dialog.rbs +3 -0
- data/sig/puppeteer/element_handle.rbs +1 -2
- data/sig/puppeteer/execution_context.rbs +5 -0
- data/sig/puppeteer/extension.rbs +4 -0
- data/sig/puppeteer/frame.rbs +4 -2
- data/sig/puppeteer/http_response.rbs +4 -0
- data/sig/puppeteer/keyboard.rbs +4 -0
- data/sig/puppeteer/locators.rbs +3 -4
- data/sig/puppeteer/mouse.rbs +5 -6
- data/sig/puppeteer/page.rbs +48 -4
- data/sig/puppeteer/puppeteer.rbs +5 -5
- data/sig/puppeteer/screen_recorder.rbs +48 -0
- data/sig/puppeteer/touch_screen.rbs +4 -0
- data/sig/puppeteer/web_mcp.rbs +112 -0
- data/sig/puppeteer/web_worker.rbs +39 -0
- metadata +24 -2
data/lib/puppeteer/puppeteer.rb
CHANGED
|
@@ -33,6 +33,7 @@ class Puppeteer::Puppeteer
|
|
|
33
33
|
# @rbs network_enabled: bool? -- Enable network domain
|
|
34
34
|
# @rbs issues_enabled: bool? -- Enable issues domain
|
|
35
35
|
# @rbs block_list: Array[String]? -- URL block list patterns
|
|
36
|
+
# @rbs allow_list: Array[String]? -- URL allow list patterns
|
|
36
37
|
# @rbs enable_extensions: (bool | Array[String])? -- Enable extensions or load unpacked extensions
|
|
37
38
|
# @rbs default_viewport: Puppeteer::Viewport? -- Default viewport
|
|
38
39
|
# @rbs slow_mo: Integer? -- Delay between operations (ms)
|
|
@@ -61,6 +62,7 @@ class Puppeteer::Puppeteer
|
|
|
61
62
|
network_enabled: true,
|
|
62
63
|
issues_enabled: true,
|
|
63
64
|
block_list: nil,
|
|
65
|
+
allow_list: nil,
|
|
64
66
|
enable_extensions: false,
|
|
65
67
|
default_viewport: NoViewport.new,
|
|
66
68
|
slow_mo: nil,
|
|
@@ -93,6 +95,7 @@ class Puppeteer::Puppeteer
|
|
|
93
95
|
network_enabled: network_enabled,
|
|
94
96
|
issues_enabled: issues_enabled,
|
|
95
97
|
block_list: block_list,
|
|
98
|
+
allow_list: allow_list,
|
|
96
99
|
enable_extensions: enable_extensions,
|
|
97
100
|
default_viewport: default_viewport,
|
|
98
101
|
slow_mo: slow_mo,
|
|
@@ -140,10 +143,12 @@ class Puppeteer::Puppeteer
|
|
|
140
143
|
# @rbs browser_ws_endpoint: String? -- Browser WebSocket endpoint
|
|
141
144
|
# @rbs browser_url: String? -- Browser HTTP URL for WebSocket discovery
|
|
142
145
|
# @rbs transport: Puppeteer::WebSocketTransport? -- Pre-connected transport
|
|
146
|
+
# @rbs channel: (String | Symbol)? -- Browser channel
|
|
143
147
|
# @rbs ignore_https_errors: bool? -- Ignore HTTPS errors
|
|
144
148
|
# @rbs network_enabled: bool? -- Enable network domain
|
|
145
149
|
# @rbs issues_enabled: bool? -- Enable issues domain
|
|
146
150
|
# @rbs block_list: Array[String]? -- URL block list patterns
|
|
151
|
+
# @rbs allow_list: Array[String]? -- URL allow list patterns
|
|
147
152
|
# @rbs default_viewport: Puppeteer::Viewport? -- Default viewport
|
|
148
153
|
# @rbs slow_mo: Integer? -- Delay between operations (ms)
|
|
149
154
|
# @rbs protocol_timeout: Integer? -- CDP protocol timeout in milliseconds
|
|
@@ -153,11 +158,13 @@ class Puppeteer::Puppeteer
|
|
|
153
158
|
browser_ws_endpoint: nil,
|
|
154
159
|
browser_url: nil,
|
|
155
160
|
transport: nil,
|
|
161
|
+
channel: nil,
|
|
156
162
|
ignore_https_errors: nil,
|
|
157
163
|
network_enabled: true,
|
|
158
164
|
issues_enabled: true,
|
|
159
165
|
block_list: nil,
|
|
160
|
-
|
|
166
|
+
allow_list: nil,
|
|
167
|
+
default_viewport: NoViewport.new,
|
|
161
168
|
slow_mo: nil,
|
|
162
169
|
protocol_timeout: nil,
|
|
163
170
|
&block
|
|
@@ -166,14 +173,18 @@ class Puppeteer::Puppeteer
|
|
|
166
173
|
browser_ws_endpoint: browser_ws_endpoint,
|
|
167
174
|
browser_url: browser_url,
|
|
168
175
|
transport: transport,
|
|
176
|
+
channel: channel&.to_s,
|
|
169
177
|
ignore_https_errors: ignore_https_errors,
|
|
170
178
|
network_enabled: network_enabled,
|
|
171
179
|
issues_enabled: issues_enabled,
|
|
172
180
|
block_list: block_list,
|
|
173
|
-
|
|
181
|
+
allow_list: allow_list,
|
|
174
182
|
slow_mo: slow_mo,
|
|
175
183
|
protocol_timeout: protocol_timeout,
|
|
176
184
|
}.compact
|
|
185
|
+
unless default_viewport.is_a?(NoViewport)
|
|
186
|
+
options[:default_viewport] = default_viewport
|
|
187
|
+
end
|
|
177
188
|
if async_context?
|
|
178
189
|
browser = Puppeteer::BrowserConnector.new(options).connect_to_browser
|
|
179
190
|
if block
|
|
@@ -221,11 +232,6 @@ class Puppeteer::Puppeteer
|
|
|
221
232
|
)
|
|
222
233
|
end
|
|
223
234
|
|
|
224
|
-
# @rbs return: String -- Product name
|
|
225
|
-
def product
|
|
226
|
-
launcher.product
|
|
227
|
-
end
|
|
228
|
-
|
|
229
235
|
private def async_context?
|
|
230
236
|
task = Async::Task.current
|
|
231
237
|
!task.nil?
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
require 'open3'
|
|
6
|
+
require 'etc'
|
|
7
|
+
|
|
8
|
+
class Puppeteer::ScreenRecorder
|
|
9
|
+
DEFAULT_FPS = 30
|
|
10
|
+
DEFAULT_QUALITY = 30
|
|
11
|
+
STOP = Object.new.freeze
|
|
12
|
+
|
|
13
|
+
# @rbs start_timestamp: Numeric -- First CDP frame timestamp
|
|
14
|
+
# @rbs previous_timestamp: Numeric -- Previous CDP frame timestamp
|
|
15
|
+
# @rbs timestamp: Numeric -- Current CDP frame timestamp
|
|
16
|
+
# @rbs fps: Numeric -- Output frame rate
|
|
17
|
+
# @rbs return: Integer -- Frames to emit for this interval
|
|
18
|
+
def self.count_frames(start_timestamp, previous_timestamp, timestamp, fps)
|
|
19
|
+
finish = ((timestamp - start_timestamp) * fps).round
|
|
20
|
+
start = ((previous_timestamp - start_timestamp) * fps).round
|
|
21
|
+
[0, finish - start].max
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# @rbs page: Puppeteer::Page -- Recorded page
|
|
25
|
+
# @rbs width: Numeric -- Native viewport width
|
|
26
|
+
# @rbs height: Numeric -- Native viewport height
|
|
27
|
+
# @rbs options: Hash[Symbol, untyped] -- Recorder options
|
|
28
|
+
def initialize(page, width, height, options = {})
|
|
29
|
+
@page = page
|
|
30
|
+
@fps = options.fetch(:fps, DEFAULT_FPS)
|
|
31
|
+
@format = (options[:format] || 'webm').to_s
|
|
32
|
+
@path = options[:path]
|
|
33
|
+
@stopped = false
|
|
34
|
+
@stop_mutex = Mutex.new
|
|
35
|
+
@finished = false
|
|
36
|
+
@finish_mutex = Mutex.new
|
|
37
|
+
@frame_queue = Queue.new
|
|
38
|
+
@output = +''.b
|
|
39
|
+
@last_buffer = ''.b
|
|
40
|
+
@last_written_at = monotonic_time
|
|
41
|
+
@start_timestamp = nil
|
|
42
|
+
@previous_timestamp = nil
|
|
43
|
+
@previous_buffer = nil
|
|
44
|
+
|
|
45
|
+
ensure_output_directory
|
|
46
|
+
command = build_command(width, height, options)
|
|
47
|
+
@stdin, @stdout, @stderr, @wait_thread = Open3.popen3(*command)
|
|
48
|
+
@stdin.binmode
|
|
49
|
+
@stdout.binmode
|
|
50
|
+
|
|
51
|
+
@writer_thread = Thread.new { write_frames }
|
|
52
|
+
@stdout_thread = Thread.new { read_output }
|
|
53
|
+
@stderr_thread = Thread.new { read_stderr }
|
|
54
|
+
|
|
55
|
+
@client = @page.main_frame.client
|
|
56
|
+
@frame_listener_id = @client.add_event_listener('Page.screencastFrame') do |event|
|
|
57
|
+
handle_frame(event)
|
|
58
|
+
end
|
|
59
|
+
@disconnect_listener_id = @client.once(CDPSessionEmittedEvents::Disconnected) do
|
|
60
|
+
stop
|
|
61
|
+
rescue StandardError => error
|
|
62
|
+
warn(error.message) if ENV['DEBUG']
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# @rbs return: String -- Encoded bytes produced so far
|
|
67
|
+
def data
|
|
68
|
+
@output.dup
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# @rbs return: void -- Stop recording and flush FFmpeg
|
|
72
|
+
def stop
|
|
73
|
+
should_stop = @stop_mutex.synchronize do
|
|
74
|
+
next false if @stopped
|
|
75
|
+
|
|
76
|
+
@stopped = true
|
|
77
|
+
true
|
|
78
|
+
end
|
|
79
|
+
unless should_stop
|
|
80
|
+
finish_process
|
|
81
|
+
return
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
begin
|
|
85
|
+
@page._stop_screencast
|
|
86
|
+
rescue StandardError
|
|
87
|
+
# The page or its CDP session may already be gone.
|
|
88
|
+
end
|
|
89
|
+
enqueue_last_frame
|
|
90
|
+
finish_process
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
private def ensure_output_directory
|
|
94
|
+
return unless @path
|
|
95
|
+
|
|
96
|
+
FileUtils.mkdir_p(File.dirname(File.expand_path(@path)))
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
private def build_command(width, height, options)
|
|
100
|
+
ffmpeg_path = options[:ffmpeg_path] || 'ffmpeg'
|
|
101
|
+
speed = options[:speed]
|
|
102
|
+
scale = options[:scale]
|
|
103
|
+
crop = options[:crop]
|
|
104
|
+
loop_count = options[:loop]
|
|
105
|
+
loop_count = -1 if loop_count.nil? || loop_count.zero?
|
|
106
|
+
delay = options.fetch(:delay, -1)
|
|
107
|
+
quality = options.fetch(:quality, DEFAULT_QUALITY)
|
|
108
|
+
colors = options.fetch(:colors, 256)
|
|
109
|
+
|
|
110
|
+
filters = [
|
|
111
|
+
"crop='min(#{width},iw):min(#{height},ih):0:0'",
|
|
112
|
+
"pad=#{width}:#{height}:0:0",
|
|
113
|
+
]
|
|
114
|
+
filters << "setpts=#{1.0 / speed}*PTS" if speed
|
|
115
|
+
if crop
|
|
116
|
+
filters << "crop=#{crop[:width]}:#{crop[:height]}:#{crop[:x]}:#{crop[:y]}"
|
|
117
|
+
end
|
|
118
|
+
filters << "scale=iw*#{scale}:-1:flags=lanczos" if scale
|
|
119
|
+
|
|
120
|
+
format_args = format_args(@format, @fps, loop_count, delay, quality, colors)
|
|
121
|
+
if (vf_index = format_args.index('-vf'))
|
|
122
|
+
filters << format_args.slice!(vf_index, 2).last
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
[
|
|
126
|
+
ffmpeg_path,
|
|
127
|
+
'-loglevel', 'error',
|
|
128
|
+
'-avioflags', 'direct',
|
|
129
|
+
'-fpsprobesize', '0',
|
|
130
|
+
'-probesize', '32',
|
|
131
|
+
'-analyzeduration', '0',
|
|
132
|
+
'-fflags', 'nobuffer',
|
|
133
|
+
# -framerate is an input option and must precede -i.
|
|
134
|
+
'-framerate', @fps.to_s,
|
|
135
|
+
'-f', 'image2pipe',
|
|
136
|
+
'-vcodec', 'png',
|
|
137
|
+
'-i', 'pipe:0',
|
|
138
|
+
'-an',
|
|
139
|
+
'-threads', '1',
|
|
140
|
+
'-b:v', '0',
|
|
141
|
+
*format_args,
|
|
142
|
+
'-vf', filters.join(','),
|
|
143
|
+
options.fetch(:overwrite, true) ? '-y' : '-n',
|
|
144
|
+
'pipe:1'
|
|
145
|
+
]
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
private def format_args(format, fps, loop_count, delay, quality, colors)
|
|
149
|
+
cpu_used = [Etc.nprocessors / 2.0, 8].min
|
|
150
|
+
cpu_used = cpu_used.to_i if cpu_used.to_i == cpu_used
|
|
151
|
+
libvpx = [
|
|
152
|
+
'-vcodec', 'vp9',
|
|
153
|
+
'-crf', quality.to_s,
|
|
154
|
+
'-deadline', 'realtime',
|
|
155
|
+
'-cpu-used', cpu_used.to_s
|
|
156
|
+
]
|
|
157
|
+
case format
|
|
158
|
+
when 'webm'
|
|
159
|
+
[*libvpx, '-f', 'webm']
|
|
160
|
+
when 'gif'
|
|
161
|
+
gif_fps = fps == DEFAULT_FPS ? 20 : 'source_fps'
|
|
162
|
+
gif_loop = loop_count.infinite? ? 0 : loop_count
|
|
163
|
+
gif_delay = delay == -1 ? -1 : delay / 10.0
|
|
164
|
+
[
|
|
165
|
+
'-vf',
|
|
166
|
+
"fps=#{gif_fps},split[s0][s1];" \
|
|
167
|
+
"[s0]palettegen=stats_mode=diff:max_colors=#{colors}[p];" \
|
|
168
|
+
'[s1][p]paletteuse=dither=bayer',
|
|
169
|
+
'-loop', gif_loop.to_s,
|
|
170
|
+
'-final_delay', gif_delay.to_s,
|
|
171
|
+
'-f', 'gif'
|
|
172
|
+
]
|
|
173
|
+
when 'mp4'
|
|
174
|
+
[*libvpx, '-movflags', 'hybrid_fragmented', '-f', 'mp4']
|
|
175
|
+
else
|
|
176
|
+
raise ArgumentError.new("Unknown screencast format: #{format}")
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
private def handle_frame(event)
|
|
181
|
+
@client.async_send_message(
|
|
182
|
+
'Page.screencastFrameAck',
|
|
183
|
+
sessionId: event['sessionId'],
|
|
184
|
+
)
|
|
185
|
+
timestamp = event.dig('metadata', 'timestamp')
|
|
186
|
+
return unless timestamp
|
|
187
|
+
|
|
188
|
+
buffer = Base64.decode64(event['data'])
|
|
189
|
+
if @previous_timestamp
|
|
190
|
+
@start_timestamp ||= @previous_timestamp
|
|
191
|
+
count = self.class.count_frames(
|
|
192
|
+
@start_timestamp,
|
|
193
|
+
@previous_timestamp,
|
|
194
|
+
timestamp,
|
|
195
|
+
@fps,
|
|
196
|
+
)
|
|
197
|
+
count.times { @frame_queue << @previous_buffer }
|
|
198
|
+
unless count.zero?
|
|
199
|
+
@last_buffer = @previous_buffer
|
|
200
|
+
@last_written_at = monotonic_time
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
@previous_timestamp = timestamp
|
|
204
|
+
@previous_buffer = buffer
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
private def enqueue_last_frame
|
|
208
|
+
buffer = @last_buffer
|
|
209
|
+
return if buffer.empty?
|
|
210
|
+
|
|
211
|
+
remaining = [1, (@fps * (monotonic_time - @last_written_at)).round].max
|
|
212
|
+
remaining.times { @frame_queue << buffer }
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
private def finish_process
|
|
216
|
+
@finish_mutex.synchronize do
|
|
217
|
+
return if @finished
|
|
218
|
+
|
|
219
|
+
begin
|
|
220
|
+
@client&.remove_event_listener(@frame_listener_id, @disconnect_listener_id)
|
|
221
|
+
@frame_queue << STOP if @writer_thread&.alive?
|
|
222
|
+
@writer_thread&.join
|
|
223
|
+
@stdin&.close unless @stdin&.closed?
|
|
224
|
+
@wait_thread&.join
|
|
225
|
+
@stdout_thread&.join
|
|
226
|
+
@stderr_thread&.join
|
|
227
|
+
File.binwrite(@path, @output) if @path
|
|
228
|
+
unless @wait_thread&.value&.success?
|
|
229
|
+
raise Puppeteer::Error.new("ffmpeg exited unsuccessfully: #{@ffmpeg_error}")
|
|
230
|
+
end
|
|
231
|
+
ensure
|
|
232
|
+
@finished = true
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
nil
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
private def write_frames
|
|
239
|
+
loop do
|
|
240
|
+
buffer = @frame_queue.pop
|
|
241
|
+
break if buffer.equal?(STOP)
|
|
242
|
+
|
|
243
|
+
@stdin.write(buffer)
|
|
244
|
+
end
|
|
245
|
+
rescue Errno::EPIPE, IOError
|
|
246
|
+
nil
|
|
247
|
+
ensure
|
|
248
|
+
@stdin.close unless @stdin.closed?
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
private def read_output
|
|
252
|
+
while (chunk = @stdout.read(16 * 1024)) && !chunk.empty?
|
|
253
|
+
@output << chunk
|
|
254
|
+
end
|
|
255
|
+
rescue IOError
|
|
256
|
+
nil
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
private def read_stderr
|
|
260
|
+
@ffmpeg_error = @stderr.read.to_s
|
|
261
|
+
warn(@ffmpeg_error) if ENV['DEBUG'] && !@ffmpeg_error.empty?
|
|
262
|
+
rescue IOError
|
|
263
|
+
nil
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
private def monotonic_time
|
|
267
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
268
|
+
end
|
|
269
|
+
end
|
data/lib/puppeteer/target.rb
CHANGED
|
@@ -6,11 +6,12 @@ class Puppeteer::Target
|
|
|
6
6
|
@type = options['type']
|
|
7
7
|
@title = options['title']
|
|
8
8
|
@url = options['url']
|
|
9
|
+
@subtype = options['subtype']
|
|
9
10
|
@attached = options['attached']
|
|
10
11
|
@browser_context_id = options['browserContextId']
|
|
11
12
|
@opener_id = options['openerId']
|
|
12
13
|
end
|
|
13
|
-
attr_reader :target_id, :type, :title, :url, :attached, :browser_context_id, :opener_id
|
|
14
|
+
attr_reader :target_id, :type, :title, :url, :subtype, :attached, :browser_context_id, :opener_id
|
|
14
15
|
end
|
|
15
16
|
|
|
16
17
|
# @param {!Protocol.Target.TargetInfo} targetInfo
|
|
@@ -112,6 +113,10 @@ class Puppeteer::Target
|
|
|
112
113
|
@is_initialized
|
|
113
114
|
end
|
|
114
115
|
|
|
116
|
+
def exposed?
|
|
117
|
+
raw_type != 'tab' && !@target_info.subtype
|
|
118
|
+
end
|
|
119
|
+
|
|
115
120
|
# @return [CDPSession|nil]
|
|
116
121
|
def session
|
|
117
122
|
@session
|
|
@@ -144,8 +149,7 @@ class Puppeteer::Target
|
|
|
144
149
|
|
|
145
150
|
# @return [Puppeteer::Page]
|
|
146
151
|
def as_page
|
|
147
|
-
|
|
148
|
-
return existing_page if existing_page
|
|
152
|
+
return @page if @page
|
|
149
153
|
return @as_page if @as_page
|
|
150
154
|
|
|
151
155
|
client = @session || @session_factory.call(false)
|
|
@@ -13,6 +13,12 @@ class Puppeteer::TouchScreen
|
|
|
13
13
|
@touches = []
|
|
14
14
|
end
|
|
15
15
|
|
|
16
|
+
# @rbs client: Puppeteer::CDPSession -- Replacement CDP session
|
|
17
|
+
# @rbs return: void -- No return value
|
|
18
|
+
def update_client(client)
|
|
19
|
+
@client = client
|
|
20
|
+
end
|
|
21
|
+
|
|
16
22
|
# @rbs x: Numeric -- X coordinate
|
|
17
23
|
# @rbs y: Numeric -- Y coordinate
|
|
18
24
|
# @rbs return: void -- No return value
|
data/lib/puppeteer/tracing.rb
CHANGED
data/lib/puppeteer/version.rb
CHANGED
data/lib/puppeteer/wait_task.rb
CHANGED
|
@@ -174,7 +174,6 @@ class Puppeteer::WaitTask
|
|
|
174
174
|
WAIT_FOR_PREDICATE_PAGE_FUNCTION = <<~JAVASCRIPT
|
|
175
175
|
function _(root, predicateBody, polling, ...args) {
|
|
176
176
|
const predicate = new Function('...args', predicateBody);
|
|
177
|
-
const observedRoot = root || document;
|
|
178
177
|
if (polling === 'mutation' && typeof MutationObserver === 'undefined') {
|
|
179
178
|
polling = 'raf';
|
|
180
179
|
}
|
|
@@ -346,7 +345,7 @@ class Puppeteer::WaitTask
|
|
|
346
345
|
if (polling === 'raf') {
|
|
347
346
|
poller = new RAFPoller(runner);
|
|
348
347
|
} else if (polling === 'mutation') {
|
|
349
|
-
poller = new MutationPoller(runner,
|
|
348
|
+
poller = new MutationPoller(runner, root || document);
|
|
350
349
|
} else if (typeof polling === 'number') {
|
|
351
350
|
poller = new IntervalPoller(runner, polling);
|
|
352
351
|
} else {
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
class Puppeteer::WebMCPTool
|
|
5
|
+
include Puppeteer::EventCallbackable
|
|
6
|
+
|
|
7
|
+
# @rbs web_mcp: Puppeteer::WebMCP -- Owning WebMCP instance
|
|
8
|
+
# @rbs tool: Hash[String, untyped] -- CDP tool payload
|
|
9
|
+
# @rbs frame: Puppeteer::Frame -- Defining frame
|
|
10
|
+
def initialize(web_mcp, tool, frame)
|
|
11
|
+
@web_mcp = web_mcp
|
|
12
|
+
@name = tool['name']
|
|
13
|
+
@description = tool['description']
|
|
14
|
+
@input_schema = tool['inputSchema']
|
|
15
|
+
@annotations = tool['annotations']
|
|
16
|
+
@frame = frame
|
|
17
|
+
@backend_node_id = tool['backendNodeId']
|
|
18
|
+
@raw_stack_trace = tool['stackTrace']
|
|
19
|
+
@form_element = nil
|
|
20
|
+
|
|
21
|
+
call_frame = @raw_stack_trace&.fetch('callFrames', [])&.first
|
|
22
|
+
if call_frame
|
|
23
|
+
@location = Puppeteer::ConsoleMessage::Location.new(
|
|
24
|
+
url: call_frame['url'],
|
|
25
|
+
line_number: call_frame['lineNumber'],
|
|
26
|
+
column_number: call_frame['columnNumber'],
|
|
27
|
+
)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
attr_reader :name, :description, :input_schema, :annotations, :frame,
|
|
32
|
+
:location, :raw_stack_trace
|
|
33
|
+
|
|
34
|
+
# @rbs return: Puppeteer::ElementHandle? -- Declarative form element
|
|
35
|
+
def form_element
|
|
36
|
+
return @form_element if @form_element && !@form_element.disposed?
|
|
37
|
+
return nil unless @backend_node_id
|
|
38
|
+
|
|
39
|
+
@form_element = @frame.main_world.adopt_backend_node(@backend_node_id)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# @rbs input: Hash[untyped, untyped] -- Tool input
|
|
43
|
+
# @rbs return: Puppeteer::WebMCPToolCallResult -- Invocation result
|
|
44
|
+
def execute(input = {})
|
|
45
|
+
invocation_id = @web_mcp.invoke_tool(self, input).fetch('invocationId')
|
|
46
|
+
promise = Async::Promise.new
|
|
47
|
+
listener = nil
|
|
48
|
+
listener = @web_mcp.add_event_listener('toolresponded') do |result|
|
|
49
|
+
next unless result.id == invocation_id
|
|
50
|
+
|
|
51
|
+
@web_mcp.remove_event_listener(listener)
|
|
52
|
+
promise.resolve(result)
|
|
53
|
+
end
|
|
54
|
+
promise.wait
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
class Puppeteer::WebMCPToolCall
|
|
59
|
+
# @rbs id: String -- Invocation id
|
|
60
|
+
# @rbs tool: Puppeteer::WebMCPTool -- Invoked tool
|
|
61
|
+
# @rbs input: String -- JSON-encoded input
|
|
62
|
+
def initialize(id, tool, input)
|
|
63
|
+
@id = id
|
|
64
|
+
@tool = tool
|
|
65
|
+
@input = JSON.parse(input)
|
|
66
|
+
rescue JSON::ParserError => error
|
|
67
|
+
warn(error.message) if ENV['DEBUG']
|
|
68
|
+
@input = {}
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
attr_reader :id, :tool, :input
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
class Puppeteer::WebMCPToolCallResult
|
|
75
|
+
# @rbs id: String -- Invocation id
|
|
76
|
+
# @rbs call: Puppeteer::WebMCPToolCall? -- Corresponding call
|
|
77
|
+
# @rbs status: String -- Invocation status
|
|
78
|
+
# @rbs output: untyped -- Invocation output
|
|
79
|
+
# @rbs error_text: String? -- Error text
|
|
80
|
+
# @rbs exception: Hash[String, untyped]? -- Remote exception
|
|
81
|
+
def initialize(id:, call:, status:, output:, error_text:, exception:)
|
|
82
|
+
@id = id
|
|
83
|
+
@call = call
|
|
84
|
+
@status = status
|
|
85
|
+
@output = output
|
|
86
|
+
@error_text = error_text
|
|
87
|
+
@exception = exception
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
attr_reader :id, :call, :status, :output, :error_text, :exception
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
class Puppeteer::WebMCP
|
|
94
|
+
include Puppeteer::EventCallbackable
|
|
95
|
+
include Puppeteer::DebugPrint
|
|
96
|
+
using Puppeteer::DefineAsyncMethod
|
|
97
|
+
|
|
98
|
+
ToolsEvent = Struct.new(:tools, keyword_init: true)
|
|
99
|
+
|
|
100
|
+
# @rbs client: Puppeteer::CDPSession -- Primary page session
|
|
101
|
+
# @rbs frame_manager: Puppeteer::FrameManager -- Page frame manager
|
|
102
|
+
def initialize(client, frame_manager)
|
|
103
|
+
@client = client
|
|
104
|
+
@frame_manager = frame_manager
|
|
105
|
+
@tools = {}
|
|
106
|
+
@pending_calls = {}
|
|
107
|
+
@context_listener_ids = {}
|
|
108
|
+
bind_listeners
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# @rbs return: void -- Enable the WebMCP CDP domain when available
|
|
112
|
+
def initialize_domain
|
|
113
|
+
@client.send_message('WebMCP.enable')
|
|
114
|
+
rescue => error
|
|
115
|
+
debug_puts(error)
|
|
116
|
+
nil
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
define_async_method :async_initialize_domain
|
|
120
|
+
|
|
121
|
+
# @rbs tool: Puppeteer::WebMCPTool -- Tool to invoke
|
|
122
|
+
# @rbs input: Hash[untyped, untyped] -- Invocation input
|
|
123
|
+
# @rbs return: Hash[String, untyped] -- Invocation metadata
|
|
124
|
+
def invoke_tool(tool, input)
|
|
125
|
+
@client.send_message('WebMCP.invokeTool', {
|
|
126
|
+
frameId: tool.frame.id,
|
|
127
|
+
toolName: tool.name,
|
|
128
|
+
input: input,
|
|
129
|
+
})
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# @rbs return: Array[Puppeteer::WebMCPTool] -- Registered tools
|
|
133
|
+
def tools
|
|
134
|
+
@tools.values.flat_map(&:values)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# @rbs client: Puppeteer::CDPSession -- Replacement primary session
|
|
138
|
+
# @rbs return: void -- No return value
|
|
139
|
+
def update_client(client)
|
|
140
|
+
@client.remove_event_listener(*@listener_ids) if @listener_ids
|
|
141
|
+
@client = client
|
|
142
|
+
bind_listeners
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
private def bind_listeners
|
|
146
|
+
@listener_ids = []
|
|
147
|
+
@listener_ids << @client.add_event_listener('WebMCP.toolsAdded') do |event|
|
|
148
|
+
handle_tools_added(event)
|
|
149
|
+
end
|
|
150
|
+
@listener_ids << @client.add_event_listener('WebMCP.toolsRemoved') do |event|
|
|
151
|
+
handle_tools_removed(event)
|
|
152
|
+
end
|
|
153
|
+
@listener_ids << @client.add_event_listener('WebMCP.toolInvoked') do |event|
|
|
154
|
+
handle_tool_invoked(event)
|
|
155
|
+
end
|
|
156
|
+
@listener_ids << @client.add_event_listener('WebMCP.toolResponded') do |event|
|
|
157
|
+
handle_tool_responded(event)
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
private def handle_tools_added(event)
|
|
162
|
+
added = event.fetch('tools', []).filter_map do |payload|
|
|
163
|
+
frame = @frame_manager.frame(payload['frameId'])
|
|
164
|
+
next unless frame
|
|
165
|
+
|
|
166
|
+
frame_tools = (@tools[payload['frameId']] ||= {})
|
|
167
|
+
listen_to_context_destroyed(frame) if frame_tools.empty?
|
|
168
|
+
tool = Puppeteer::WebMCPTool.new(self, payload, frame)
|
|
169
|
+
frame_tools[tool.name] = tool
|
|
170
|
+
tool
|
|
171
|
+
end
|
|
172
|
+
emit_event('toolsadded', ToolsEvent.new(tools: added))
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
private def handle_tools_removed(event)
|
|
176
|
+
removed = event.fetch('tools', []).filter_map do |payload|
|
|
177
|
+
tool = @tools.dig(payload['frameId'], payload['name'])
|
|
178
|
+
@tools[payload['frameId']]&.delete(payload['name'])
|
|
179
|
+
tool
|
|
180
|
+
end
|
|
181
|
+
emit_event('toolsremoved', ToolsEvent.new(tools: removed))
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
private def handle_tool_invoked(event)
|
|
185
|
+
tool = @tools.dig(event['frameId'], event['toolName'])
|
|
186
|
+
return unless tool
|
|
187
|
+
|
|
188
|
+
call = Puppeteer::WebMCPToolCall.new(event['invocationId'], tool, event['input'])
|
|
189
|
+
@pending_calls[call.id] = call
|
|
190
|
+
tool.emit_event('toolinvoked', call)
|
|
191
|
+
emit_event('toolinvoked', call)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
private def handle_tool_responded(event)
|
|
195
|
+
call = @pending_calls.delete(event['invocationId'])
|
|
196
|
+
result = Puppeteer::WebMCPToolCallResult.new(
|
|
197
|
+
id: event['invocationId'],
|
|
198
|
+
call: call,
|
|
199
|
+
status: event['status'],
|
|
200
|
+
output: event['output'],
|
|
201
|
+
error_text: event['errorText'],
|
|
202
|
+
exception: event['exception'],
|
|
203
|
+
)
|
|
204
|
+
emit_event('toolresponded', result)
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
private def listen_to_context_destroyed(frame)
|
|
208
|
+
context = frame.main_world.context
|
|
209
|
+
return unless context
|
|
210
|
+
return if @context_listener_ids[frame.id]
|
|
211
|
+
|
|
212
|
+
listener_id = context.once('disposed') do
|
|
213
|
+
handle_context_disposed(frame)
|
|
214
|
+
end
|
|
215
|
+
@context_listener_ids[frame.id] = [context, listener_id]
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
private def handle_context_disposed(frame)
|
|
219
|
+
@pending_calls.clear
|
|
220
|
+
@context_listener_ids.delete(frame.id)
|
|
221
|
+
frame_tools = @tools.delete(frame.id)
|
|
222
|
+
return unless frame_tools
|
|
223
|
+
|
|
224
|
+
removed = frame_tools.values
|
|
225
|
+
emit_event('toolsremoved', ToolsEvent.new(tools: removed)) unless removed.empty?
|
|
226
|
+
end
|
|
227
|
+
end
|