Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
18 changes: 18 additions & 0 deletions docs/13-javascript.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion lib/ferrum/browser.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
30 changes: 30 additions & 0 deletions lib/ferrum/frame.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

require "ferrum/frame/dom"
require "ferrum/frame/runtime"
require "ferrum/isolated_world"

module Ferrum
#
Expand Down Expand Up @@ -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}.
Expand Down Expand Up @@ -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<IsolatedWorld>]
#
def isolated_worlds
@isolated_worlds.values
end

#
# Debug representation of the frame, including its internal state.
#
Expand Down
17 changes: 10 additions & 7 deletions lib/ferrum/frame/runtime.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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!)
Expand Down Expand Up @@ -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
Expand Down
94 changes: 94 additions & 0 deletions lib/ferrum/isolated_world.rb
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion lib/ferrum/page.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
13 changes: 10 additions & 3 deletions lib/ferrum/page/frames.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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

Expand All @@ -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

Expand All @@ -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
Expand Down
6 changes: 6 additions & 0 deletions sig/ferrum/frame.rbs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
4 changes: 4 additions & 0 deletions sig/ferrum/frame/runtime.rbs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
27 changes: 27 additions & 0 deletions sig/ferrum/isolated_world.rbs
Original file line number Diff line number Diff line change
@@ -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
48 changes: 48 additions & 0 deletions spec/isolated_world_spec.rb
Original file line number Diff line number Diff line change
@@ -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
Loading