puppeteer-ruby 0.52.1 → 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 +1 -0
- 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/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 +6 -5
- 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/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 +4 -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 +22 -2
|
@@ -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
|