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.
Files changed (61) hide show
  1. checksums.yaml +4 -4
  2. data/docs/api_coverage.md +16 -15
  3. data/lib/puppeteer/accessibility.rb +300 -0
  4. data/lib/puppeteer/browser.rb +107 -8
  5. data/lib/puppeteer/browser_connector.rb +1 -0
  6. data/lib/puppeteer/browser_context.rb +0 -1
  7. data/lib/puppeteer/cdp_session.rb +29 -8
  8. data/lib/puppeteer/chrome_target_manager.rb +145 -34
  9. data/lib/puppeteer/connection.rb +19 -0
  10. data/lib/puppeteer/coverage.rb +5 -0
  11. data/lib/puppeteer/css_coverage.rb +4 -0
  12. data/lib/puppeteer/dialog.rb +5 -0
  13. data/lib/puppeteer/element_handle.rb +2 -3
  14. data/lib/puppeteer/emulation_manager.rb +70 -9
  15. data/lib/puppeteer/events.rb +2 -0
  16. data/lib/puppeteer/execution_context.rb +7 -0
  17. data/lib/puppeteer/extension.rb +20 -6
  18. data/lib/puppeteer/frame.rb +7 -3
  19. data/lib/puppeteer/frame_manager.rb +71 -26
  20. data/lib/puppeteer/http_response.rb +15 -1
  21. data/lib/puppeteer/isolated_world.rb +10 -4
  22. data/lib/puppeteer/js_coverage.rb +4 -0
  23. data/lib/puppeteer/keyboard.rb +6 -0
  24. data/lib/puppeteer/launcher/browser_options.rb +13 -1
  25. data/lib/puppeteer/launcher/chrome.rb +48 -4
  26. data/lib/puppeteer/lifecycle_watcher.rb +1 -6
  27. data/lib/puppeteer/locators.rb +54 -26
  28. data/lib/puppeteer/mouse.rb +12 -24
  29. data/lib/puppeteer/network_manager.rb +16 -4
  30. data/lib/puppeteer/page.rb +259 -33
  31. data/lib/puppeteer/puppeteer.rb +6 -5
  32. data/lib/puppeteer/screen_recorder.rb +269 -0
  33. data/lib/puppeteer/target.rb +7 -3
  34. data/lib/puppeteer/touch_screen.rb +6 -0
  35. data/lib/puppeteer/tracing.rb +4 -0
  36. data/lib/puppeteer/version.rb +2 -2
  37. data/lib/puppeteer/wait_task.rb +1 -2
  38. data/lib/puppeteer/web_mcp.rb +227 -0
  39. data/lib/puppeteer/web_worker.rb +135 -9
  40. data/lib/puppeteer.rb +11 -1
  41. data/puppeteer-ruby.gemspec +1 -0
  42. data/sig/_supplementary.rbs +33 -1
  43. data/sig/puppeteer/accessibility.rbs +73 -0
  44. data/sig/puppeteer/browser.rbs +27 -3
  45. data/sig/puppeteer/cdp_session.rbs +4 -0
  46. data/sig/puppeteer/dialog.rbs +3 -0
  47. data/sig/puppeteer/element_handle.rbs +1 -2
  48. data/sig/puppeteer/execution_context.rbs +5 -0
  49. data/sig/puppeteer/extension.rbs +4 -0
  50. data/sig/puppeteer/frame.rbs +4 -2
  51. data/sig/puppeteer/http_response.rbs +4 -0
  52. data/sig/puppeteer/keyboard.rbs +4 -0
  53. data/sig/puppeteer/locators.rbs +3 -4
  54. data/sig/puppeteer/mouse.rbs +5 -6
  55. data/sig/puppeteer/page.rbs +48 -4
  56. data/sig/puppeteer/puppeteer.rbs +4 -5
  57. data/sig/puppeteer/screen_recorder.rbs +48 -0
  58. data/sig/puppeteer/touch_screen.rbs +4 -0
  59. data/sig/puppeteer/web_mcp.rbs +112 -0
  60. data/sig/puppeteer/web_worker.rbs +39 -0
  61. 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
@@ -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
- existing_page = page
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
@@ -5,6 +5,10 @@ class Puppeteer::Tracing
5
5
  @recording = false
6
6
  end
7
7
 
8
+ def update_client(client)
9
+ @client = client
10
+ end
11
+
8
12
  DEFAULT_CATEGORIES = [
9
13
  '-*',
10
14
  'devtools.timeline',
@@ -1,4 +1,4 @@
1
1
  module Puppeteer
2
- VERSION = '0.52.1'
3
- REF_PUPPETEER_VERSION = '24.42.0'
2
+ VERSION = '0.53.0'
3
+ REF_PUPPETEER_VERSION = '25.3.0'
4
4
  end
@@ -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, observedRoot);
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