From 7235af58aa7981ee553c919ecbe07a7ef293ea62 Mon Sep 17 00:00:00 2001 From: Dmitry Vorotilin Date: Wed, 21 Oct 2026 21:41:45 +0300 Subject: [PATCH] feat(frame): add isolated worlds to evaluate JavaScript hidden from the page page.isolated_world(name) and frame.isolated_world(name) return an IsolatedWorld with the frame's evaluation methods, running in a world created with Page.createIsolatedWorld, the same one evaluate_on_new_document(world_name:) uses. Only the default execution context sets the frame's execution_id now, named isolated ones go to their world. --- CHANGELOG.md | 1 + docs/13-javascript.md | 18 +++++++ lib/ferrum/browser.rb | 2 +- lib/ferrum/frame.rb | 30 +++++++++++ lib/ferrum/frame/runtime.rb | 17 ++++--- lib/ferrum/isolated_world.rb | 94 +++++++++++++++++++++++++++++++++++ lib/ferrum/page.rb | 2 +- lib/ferrum/page/frames.rb | 13 +++-- sig/ferrum/frame.rbs | 6 +++ sig/ferrum/frame/runtime.rbs | 4 ++ sig/ferrum/isolated_world.rbs | 27 ++++++++++ spec/isolated_world_spec.rb | 48 ++++++++++++++++++ 12 files changed, 250 insertions(+), 12 deletions(-) create mode 100644 lib/ferrum/isolated_world.rb create mode 100644 sig/ferrum/isolated_world.rbs create mode 100644 spec/isolated_world_spec.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 01ce6578..39d07643 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ - Ferrum keeps reading what Chrome writes after startup: it's logged on the `browser` channel, the last 100 lines are in `browser.process.output` - `browser.add_extension` adds an extension for pages and iframes attached afterwards [#663] +- `page.isolated_world(name)` and `frame.isolated_world(name)` evaluate JavaScript in a world hidden from the page ### Changed - The logger cuts any string value over 1024 characters in CDP messages, not just screenshots. diff --git a/docs/13-javascript.md b/docs/13-javascript.md index 6f955711..362d095b 100644 --- a/docs/13-javascript.md +++ b/docs/13-javascript.md @@ -59,6 +59,24 @@ id = page.evaluate_on_new_document <<~JS JS ``` +#### isolated_world(name) : `IsolatedWorld` + +Returns the frame's isolated world with this name, its own JavaScript globals over the frame's DOM, so the page's +scripts can't see or override what runs there. It has `evaluate`, `evaluate_async`, `execute` and `evaluate_func`, +takes and returns nodes, and comes back empty in every new document. On a page it's the main frame's world, use +`frame.isolated_world(name)` for an iframe. It's the same world `evaluate_on_new_document` runs in with +`world_name:`. + +* name `String` + +```ruby +world = page.isolated_world("ferrum") +world.execute("window.secret = 'top'") +world.evaluate("window.secret") # => "top" +page.evaluate("window.secret") # => nil +world.evaluate("document.title") # => same as page.evaluate("document.title") +``` + #### remove_script_on_new_document(identifier) : `Boolean` Stops running a script added with `evaluate_on_new_document` in new documents. diff --git a/lib/ferrum/browser.rb b/lib/ferrum/browser.rb index 2003cf69..6d698242 100644 --- a/lib/ferrum/browser.rb +++ b/lib/ferrum/browser.rb @@ -36,7 +36,7 @@ class Browser screenshot pdf mhtml viewport_size device_pixel_ratio start_screencast stop_screencast frames frame_by main_frame - evaluate evaluate_on evaluate_async execute evaluate_func + evaluate evaluate_on evaluate_async execute evaluate_func isolated_world add_script_tag add_style_tag bypass_csp evaluate_on_new_document remove_script_on_new_document on position position= diff --git a/lib/ferrum/frame.rb b/lib/ferrum/frame.rb index 40dfcff5..c53c344d 100644 --- a/lib/ferrum/frame.rb +++ b/lib/ferrum/frame.rb @@ -2,6 +2,7 @@ require "ferrum/frame/dom" require "ferrum/frame/runtime" +require "ferrum/isolated_world" module Ferrum # @@ -62,6 +63,7 @@ def initialize(id, page, parent_id = nil) @parent_id = parent_id @lifecycle_events = [] @execution_id = Concurrent::MVar.new + @isolated_worlds = Concurrent::Map.new end # Sets the frame's state, validating it's one of {STATE_VALUES}. @@ -222,6 +224,34 @@ def execution_id=(value) end end + # + # Returns the frame's isolated world with the given name: its own + # JavaScript globals over the frame's DOM. Created on first use. + # + # @param [String] name + # + # @return [IsolatedWorld] + # + # @example + # world = page.main_frame.isolated_world("ferrum") + # world.execute("window.secret = 'top'") + # world.evaluate("window.secret") # => "top" + # page.evaluate("window.secret") # => nil + # + def isolated_world(name) + @isolated_worlds.compute_if_absent(name) { IsolatedWorld.new(self, name) } + end + + # + # The frame's isolated worlds used so far, by {#isolated_world}, + # {Page#evaluate_on_new_document} with `world_name:` or extensions. + # + # @return [Array] + # + def isolated_worlds + @isolated_worlds.values + end + # # Debug representation of the frame, including its internal state. # diff --git a/lib/ferrum/frame/runtime.rb b/lib/ferrum/frame/runtime.rb index 84592539..535e9123 100644 --- a/lib/ferrum/frame/runtime.rb +++ b/lib/ferrum/frame/runtime.rb @@ -152,11 +152,7 @@ def call(expression:, arguments: [], on: nil, wait: 0, handle: true, **options) Utils::Attempt.with_retry(errors: errors, max: INTERMITTENT_ATTEMPTS, wait: INTERMITTENT_SLEEP) do params = options.dup - if on - response = @page.command("DOM.resolveNode", nodeId: on.node_id) - object_id = response.dig("object", "objectId") - params = params.merge(objectId: object_id) - end + params = params.merge(objectId: resolve_node(on)) if on if params[:executionContextId].nil? && params[:objectId].nil? params = params.merge(executionContextId: execution_id!) @@ -221,11 +217,18 @@ def handle_response(response, check_cyclic: true) end end + def resolve_node(node) + @page.command("DOM.resolveNode", nodeId: node.node_id, **resolve_node_options).dig("object", "objectId") + end + + def resolve_node_options + {} + end + def prepare_args(args) args.map do |arg| if arg.is_a?(Node) - resolved = @page.command("DOM.resolveNode", nodeId: arg.node_id) - { objectId: resolved["object"]["objectId"] } + { objectId: resolve_node(arg) } elsif arg.is_a?(Hash) && arg["objectId"] { objectId: arg["objectId"] } else diff --git a/lib/ferrum/isolated_world.rb b/lib/ferrum/isolated_world.rb new file mode 100644 index 00000000..08fefcdf --- /dev/null +++ b/lib/ferrum/isolated_world.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +module Ferrum + # + # A named JavaScript world in a {Frame}: it shares the frame's DOM but has + # its own globals, so the page's scripts can't see or tamper with what runs + # in it. Chrome creates the world on first use and again in every new + # document, and it's the same world that + # {Page#evaluate_on_new_document} with `world_name:` runs scripts in. + # Evaluation methods are the same as {Frame}'s, see {Frame::Runtime}. + # + class IsolatedWorld + include Frame::Runtime + + # The world's name. + # + # @return [String] + attr_reader :name + + # The frame the world belongs to. + # + # @return [Frame] + attr_reader :frame + + # The page the world belongs to. + # + # @return [Page] + attr_reader :page + + def initialize(frame, name) + @frame = frame + @page = frame.page + @name = name + @execution_id = Concurrent::MVar.new + end + + # + # Execution context id of the world in the frame's current document, + # creating the world there if it doesn't exist yet. + # + # @return [Integer] + # + def execution_id! + execution_id || begin + id = @page.command("Page.createIsolatedWorld", frameId: @frame.id, worldName: @name)["executionContextId"] + self.execution_id = id + execution_id || id + end + end + + # + # Execution context id of the world in the frame's current document. + # + # @return [Integer, nil] + # + def execution_id + value = @execution_id.value + return if value.instance_of?(Object) + + value + end + + # + # Sets the execution context id, or clears it if +nil+ is given, e.g. + # when the document the world was created in is gone. + # + # @param [Integer, nil] value + # + # @return [Integer, nil] + # + def execution_id=(value) + if value.nil? + @execution_id.try_take! + else + @execution_id.try_put!(value) + end + end + + # + # Debug representation of the world. + # + # @return [String] + # + def inspect + "#<#{self.class} @name=#{@name.inspect} @frame_id=#{@frame.id.inspect} @execution_id=#{execution_id.inspect}>" + end + + private + + def resolve_node_options + { executionContextId: execution_id! } + end + end +end diff --git a/lib/ferrum/page.rb b/lib/ferrum/page.rb index cad99476..577ae345 100644 --- a/lib/ferrum/page.rb +++ b/lib/ferrum/page.rb @@ -34,7 +34,7 @@ class Page delegate %i[at_css at_xpath css xpath wait_for_selector current_url current_title url title body doctype content= - execution_id execution_id! evaluate evaluate_on evaluate_async execute evaluate_func + execution_id execution_id! evaluate evaluate_on evaluate_async execute evaluate_func isolated_world add_script_tag add_style_tag] => :main_frame delegate %i[base_url default_user_agent timeout timeout=] => :@options diff --git a/lib/ferrum/page/frames.rb b/lib/ferrum/page/frames.rb index aedeb277..64a859f0 100644 --- a/lib/ferrum/page/frames.rb +++ b/lib/ferrum/page/frames.rb @@ -203,8 +203,9 @@ def subscribe_request_will_be_sent def subscribe_execution_context_created on("Runtime.executionContextCreated") do |params| - context_id = params.dig("context", "id") - frame_id = params.dig("context", "auxData", "frameId") + context = params["context"] + context_id = context["id"] + frame_id = context.dig("auxData", "frameId") unless @main_frame.id root_frame = command("Page.getFrameTree").dig("frameTree", "frame", "id") @@ -215,7 +216,11 @@ def subscribe_execution_context_created end frame = @frames.fetch_or_store(frame_id, Frame.new(frame_id, self)) - frame.execution_id = context_id + if context.dig("auxData", "isDefault") + frame.execution_id = context_id + elsif !context["name"].to_s.empty? + frame.isolated_world(context["name"]).execution_id = context_id + end end end @@ -225,6 +230,7 @@ def subscribe_execution_context_destroyed frame = frame_by(execution_id: execution_id) frame&.execution_id = nil frame&.state = :canceled + frames.flat_map(&:isolated_worlds).find { |w| w.execution_id == execution_id }&.execution_id = nil end end @@ -240,6 +246,7 @@ def subscribe_execution_contexts_cleared @frames.each do |frame_id, f| if f.main? f.execution_id = nil + f.isolated_worlds.each { |w| w.execution_id = nil } f.loader_id = nil f.lifecycle_events.clear f.state = :canceled diff --git a/sig/ferrum/frame.rbs b/sig/ferrum/frame.rbs index 9bab61e7..a28d776d 100644 --- a/sig/ferrum/frame.rbs +++ b/sig/ferrum/frame.rbs @@ -20,6 +20,8 @@ module Ferrum attr_reader lifecycle_events: ::Array[::Hash[::String, ::String | ::Float]] + @isolated_worlds: ::Concurrent::Map[String, IsolatedWorld] + def initialize: (untyped id, untyped page, ?untyped? parent_id) -> void def state=: (untyped value) -> untyped @@ -42,6 +44,10 @@ module Ferrum def execution_id=: (untyped value) -> untyped + def isolated_world: (String name) -> IsolatedWorld + + def isolated_worlds: () -> ::Array[IsolatedWorld] + def inspect: () -> ::String end end diff --git a/sig/ferrum/frame/runtime.rbs b/sig/ferrum/frame/runtime.rbs index 90d9173c..5c93ba6c 100644 --- a/sig/ferrum/frame/runtime.rbs +++ b/sig/ferrum/frame/runtime.rbs @@ -29,6 +29,10 @@ module Ferrum def handle_response: (untyped response) -> untyped + def resolve_node: (Node node) -> String + + def resolve_node_options: () -> ::Hash[Symbol, Integer] + def prepare_args: (untyped args) -> untyped def reduce_props: (untyped object_id, untyped to) { (untyped, untyped, untyped) -> untyped } -> (::Array[untyped] | untyped | untyped) diff --git a/sig/ferrum/isolated_world.rbs b/sig/ferrum/isolated_world.rbs new file mode 100644 index 00000000..9f4ff5aa --- /dev/null +++ b/sig/ferrum/isolated_world.rbs @@ -0,0 +1,27 @@ +module Ferrum + class IsolatedWorld + include Frame::Runtime + + attr_reader name: String + + attr_reader frame: Frame + + attr_reader page: Page + + @execution_id: ::Concurrent::MVar + + def initialize: (Frame frame, String name) -> void + + def execution_id!: () -> Integer + + def execution_id: () -> Integer? + + def execution_id=: (Integer? value) -> (Integer | bool | nil) + + def inspect: () -> ::String + + private + + def resolve_node_options: () -> ::Hash[Symbol, Integer] + end +end diff --git a/spec/isolated_world_spec.rb b/spec/isolated_world_spec.rb new file mode 100644 index 00000000..125c4480 --- /dev/null +++ b/spec/isolated_world_spec.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +describe Ferrum::IsolatedWorld do + it "evaluates apart from the page's globals but shares its DOM" do + page.go_to("/simple") + world = page.isolated_world("ferrum") + + world.execute("window.secret = 'isolated'") + + expect(world.evaluate("window.secret")).to eq("isolated") + expect(world.evaluate("document.title")).to eq("Test") + expect(page.evaluate("window.secret")).to be_nil + end + + it "reaches the world of a script added with evaluate_on_new_document" do + page.evaluate_on_new_document("window.secret = 'isolated'", world_name: "ferrum") + page.go_to("/simple") + + expect(page.isolated_world("ferrum").evaluate("window.secret")).to eq("isolated") + expect(page.evaluate("window.secret")).to be_nil + end + + it "starts over in a new document" do + page.go_to("/simple") + world = page.isolated_world("ferrum") + world.execute("window.secret = 'isolated'") + + page.go_to("/simple") + + expect(world.evaluate("window.secret")).to be_nil + expect(world.evaluate("document.title")).to eq("Test") + end + + it "takes and returns nodes" do + page.go_to("/simple") + world = page.isolated_world("ferrum") + + expect(world.evaluate("arguments[0].id", page.at_css("#break"))).to eq("break") + expect(world.evaluate("document.querySelector('#nav')").text).to eq("Home") + end + + it "evaluates in a frame" do + page.go_to("/frames") + frame = page.at_css("iframe[name]").frame + + expect(frame.isolated_world("ferrum").evaluate("location.pathname")).to eq("/slow") + end +end