From abdbba7fb2c8f1113933355163de087153bec23e Mon Sep 17 00:00:00 2001 From: Dmitry Vorotilin Date: Tue, 18 Aug 2026 12:59:19 +0300 Subject: [PATCH] feat: add support for workers --- CHANGELOG.md | 9 ++ docs/21-workers.md | 130 ++++++++++++++++++++++++++ lib/ferrum/browser.rb | 3 +- lib/ferrum/context.rb | 30 +++++- lib/ferrum/contexts.rb | 81 ++++++++++++++-- lib/ferrum/interceptable.rb | 62 ++++++++++++ lib/ferrum/network.rb | 2 +- lib/ferrum/page.rb | 57 +++++------ lib/ferrum/target.rb | 39 +++++++- lib/ferrum/worker.rb | 106 +++++++++++++++++++++ rbs_collection.lock.yaml | 44 +++------ sig/ferrum/context.rbs | 8 +- sig/ferrum/contexts.rbs | 28 +++++- sig/ferrum/interceptable.rbs | 9 ++ sig/ferrum/page.rbs | 3 +- sig/ferrum/target.rbs | 19 +++- sig/ferrum/worker.rbs | 43 +++++++++ spec/network_spec.rb | 29 ++++++ spec/support/application.rb | 6 ++ spec/support/public/one.png | Bin 0 -> 883 bytes spec/support/views/service_worker.erb | 48 ++++++++++ spec/worker_spec.rb | 79 ++++++++++++++++ 22 files changed, 749 insertions(+), 86 deletions(-) create mode 100644 docs/21-workers.md create mode 100644 lib/ferrum/interceptable.rb create mode 100644 lib/ferrum/worker.rb create mode 100644 sig/ferrum/interceptable.rbs create mode 100644 sig/ferrum/worker.rbs create mode 100644 spec/support/public/one.png create mode 100644 spec/support/views/service_worker.erb create mode 100644 spec/worker_spec.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index a789acff..f72e829a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,15 @@ - `Ferrum::Frame#idle?` whether frame was loaded [#583] - `page.accessibility` API and `Node#axnode` for reading the CDP accessibility tree - `--no-crashpad` is now included in the default Chrome flags, fully disabling the crashpad handler process [#610] +- Support for dedicated/shared Workers and Service Workers [#391], [#388]: + - `Ferrum::Worker` -- a lightweight connection to a Worker's single execution context, with its own `#network`, + `#evaluate`/`#evaluate_async`/`#execute`, and `#on(:request)`/`#on(:auth)` for interception. + - Dedicated and shared workers spawned by a page are discovered and connected to automatically and are reachable + through `Browser#workers`/ `Context#workers`. + - Service workers are discovered too, through `Browser#service_workers`/`Context#service_workers`, but aren't + connected to by default. Attaching to one keeps it alive forever, so that's opt-in via `Context#attach_target`, + then `target.worker`. + - `Ferrum::Target#worker?`, `#shared_worker?`, `#service_worker?`, and `#parent_id` for telling targets apart. ### Fixed - `Ferrum::Client` command id generation and `Ferrum::Client::WebSocket`'s driver interactions were not thread-safe, allowing concurrent commands to collide on the same id or corrupt the frame stream; both are now serialized under a mutex [#602] diff --git a/docs/21-workers.md b/docs/21-workers.md new file mode 100644 index 00000000..835d2bca --- /dev/null +++ b/docs/21-workers.md @@ -0,0 +1,130 @@ +--- +sidebar_position: 21 +--- + +# Workers + +Ferrum discovers dedicated workers (`new Worker`), shared workers (`new SharedWorker`) and service workers +(`navigator.serviceWorker.register`) spawned anywhere in a context. + +Dedicated and shared workers are connected to automatically as soon as they're discovered, there's no need to wait +for them. Service workers are treated differently: attaching to a service worker's session prevents Chrome from ever +terminating it while the connection stays open, so Ferrum only discovers them and leaves the connection alone unless +you explicitly ask for it with `attach_target`. + +#### workers : `Array` + +Returns all dedicated and shared workers spawned by pages in this context, already connected. + +```ruby +page.execute <<~JS + new Worker(URL.createObjectURL(new Blob( + ["self.onmessage = () => self.postMessage(1 + 1)"], + { type: "application/javascript" } + ))) +JS + +sleep 0.1 # give Ferrum a moment to discover and connect to it + +worker = browser.workers.first # => # +worker.evaluate("1 + 1") # => 2 +``` + +#### service_workers : `Array` + +Returns service worker targets registered in this context. Unlike `workers`, these are plain `Target` objects and +are not connected to -- call `attach_target` first if you want to interact with one. + +```ruby +page.evaluate_async(%(navigator.serviceWorker.register("/sw.js").then(arguments[0])), 5) + +sleep 0.1 + +target = browser.service_workers.first # => # +target.connected? # => false +``` + +#### attach_target(target_id) : `Boolean` + +Manually attaches to a target discovered via `service_workers`. Once attached, `target.worker` returns a connected +`Worker` for it, same as for a dedicated or shared worker. + +```ruby +target = browser.service_workers.first +browser.attach_target(target.id) +target.worker.evaluate("1 + 1") # => 2 +``` + +Keep in mind this keeps the service worker alive for as long as the connection is open -- only attach to the ones +you actually intend to talk to. + +## Worker + +A dedicated or shared Worker spawned by a page. Unlike `Page` it has no DOM, frames, mouse/keyboard, or navigation +history -- just a single global execution context and its own network activity, reachable through `worker.network`. + +#### target_id : `String` + +The worker's CDP target id. + +#### url : `String` + +The worker's script URL, e.g. a `blob:` URL for workers created from an inline `Blob`, as is common for +dedicated/shared workers. + +#### network : `Network` + +Same API as `page.network` -- see [Network](/docs/ferrum/network). Useful for asserting on requests a worker made on +its own, independently of the page that spawned it. + +```ruby +worker.network.wait_for_idle +worker.network.traffic # => [# 2 +``` + +#### on(:request) / on(:auth) + +Subscribe to the worker's own network interception events, same as `page.on(:request)` -- see the `intercept` method +in [Network](/docs/ferrum/network). + +#### close : `Boolean` + +Closes the underlying CDP target and the worker's own connection. + +```ruby +worker.close +``` + +## Example + +Spawn a page's worker, wait for Ferrum to discover it, and read back a result it computes and posts to the page: + +```ruby +page.go_to +page.execute <<~JS + window.worker = new Worker(URL.createObjectURL(new Blob( + ["self.onmessage = (e) => self.postMessage(e.data * 2)"], + { type: "application/javascript" } + ))) + window.worker.onmessage = (e) => { window.result = e.data } +JS + +worker = nil +until worker + worker = browser.workers.first + sleep 0.05 +end + +page.execute("window.worker.postMessage(21)") +sleep 0.1 +page.evaluate("window.result") # => 42 +``` diff --git a/lib/ferrum/browser.rb b/lib/ferrum/browser.rb index 096ad926..5c30d664 100644 --- a/lib/ferrum/browser.rb +++ b/lib/ferrum/browser.rb @@ -3,6 +3,7 @@ require "base64" require "forwardable" require "ferrum/page" +require "ferrum/worker" require "ferrum/client" require "ferrum/contexts" require "ferrum/browser/xvfb" @@ -16,7 +17,7 @@ class Browser extend Forwardable delegate %i[default_context] => :contexts - delegate %i[targets create_target page pages windows] => :default_context + delegate %i[targets create_target page pages windows workers service_workers attach_target] => :default_context delegate %i[go_to goto go back forward refresh reload stop wait_for_reload at_css at_xpath css xpath current_url current_title url title body doctype content= diff --git a/lib/ferrum/context.rb b/lib/ferrum/context.rb index 9f819680..950c9fec 100644 --- a/lib/ferrum/context.rb +++ b/lib/ferrum/context.rb @@ -25,7 +25,24 @@ def page end def pages - @targets.values.reject(&:iframe?).map(&:page) + @targets.values.select(&:page?).map(&:page) + end + + # Dedicated and shared workers spawned by any page in this context. + # + # @return [Array] + def workers + @targets.values.select { |t| t.worker? || t.shared_worker? }.map(&:worker) + end + + # Service worker targets registered in this context. Unlike {#workers}, + # these are plain {Target}s and are not connected to. Attaching to a + # service worker's session keeps it alive indefinitely, so we only do + # that on demand, via `target.worker`. + # + # @return [Array] + def service_workers + @targets.values.select(&:service_worker?) end # When we call `page` method on target it triggers ruby to connect to given @@ -68,7 +85,7 @@ def add_target(params:, session_id: nil) new_pending = Concurrent::IVar.new pending = @pendings.put_if_absent(target.id, new_pending) || new_pending pending.try_set(true) - true + target end def update_target(target_id, params) @@ -79,10 +96,17 @@ def delete_target(target_id) @targets.delete(target_id) end + # Manually attaches to a target, e.g. a service worker discovered via + # {#service_workers}. Once attached, `target.worker`/`target.page` + # returns a connected {Worker}/{Page} for it. + # + # Note: attaching to a service worker's session prevents Chrome from + # ever terminating it while the connection is open. def attach_target(target_id) target = @targets[target_id] raise NoSuchTargetError unless target + @contexts.manually_attached(target_id) session = @client.command("Target.attachToTarget", targetId: target_id, flatten: true) target.session_id = session["sessionId"] true @@ -98,7 +122,7 @@ def close_targets_connection @targets.each_value do |target| next unless target.connected? - target.page.close_connection + target.close_connection end end diff --git a/lib/ferrum/contexts.rb b/lib/ferrum/contexts.rb index 2953f2f6..4b3f5045 100644 --- a/lib/ferrum/contexts.rb +++ b/lib/ferrum/contexts.rb @@ -4,20 +4,28 @@ module Ferrum class Contexts - ALLOWED_TARGET_TYPES = %w[page iframe].freeze + ALLOWED_TARGET_TYPES = %w[page iframe worker shared_worker service_worker].freeze + RECURSIVE_AUTO_ATTACH_TYPES = %w[page iframe worker shared_worker].freeze include Enumerable attr_reader :contexts def initialize(client) - @contexts = Concurrent::Map.new @client = client + @contexts = Concurrent::Map.new + @manually_attached = Concurrent::Map.new subscribe auto_attach discover end + # Marks a target, so the next time we see it attached, we leave its session alone instead of {#detach}ing it. + # Used by {Context#attach_target} right before it manually attaches to a service worker on the caller's behalf. + def manually_attached(target_id) + @manually_attached[target_id] = true + end + def default_context @default_context ||= create end @@ -72,20 +80,28 @@ def size private - def subscribe # rubocop:disable Metrics/PerceivedComplexity, Metrics/CyclomaticComplexity - @client.on("Target.attachedToTarget") do |params| + def subscribe + subscribe_attached_target(@client) + subscribe_target_created + end + + # Registered once on the top-level client, and again on every page's/ + # worker's own session once we re-arm auto-attach on it. + def subscribe_attached_target(client) + client.on("Target.attachedToTarget") do |params| info, session_id = params.values_at("targetInfo", "sessionId") next unless ALLOWED_TARGET_TYPES.include?(info["type"]) context_id = info["browserContextId"] add_context(context_id) + target = @contexts[context_id]&.add_target(session_id: session_id, params: info) - @contexts[context_id]&.add_target(session_id: session_id, params: info) - if params["waitingForDebugger"] - @client.session(session_id).command("Runtime.runIfWaitingForDebugger", async: true) - end + rearm_auto_attach(session_id, info["type"]) + handle_attach(target, session_id, params) end + end + def subscribe_target_created @client.on("Target.targetCreated") do |params| info = params["targetInfo"] next unless ALLOWED_TARGET_TYPES.include?(info["type"]) @@ -120,6 +136,55 @@ def subscribe # rubocop:disable Metrics/PerceivedComplexity, Metrics/CyclomaticC end end + def rearm_auto_attach(session_id, type) + return unless RECURSIVE_AUTO_ATTACH_TYPES.include?(type) + + client = @client.session(session_id) + client.command("Target.setAutoAttach", autoAttach: true, waitForDebuggerOnStart: true, flatten: true, async: true) + subscribe_attached_target(client) + end + + def handle_attach(target, session_id, params) + return unless target + + if target.service_worker? + detach_unless_manually_attached(target, session_id) + elsif target.worker? || target.shared_worker? + connect_worker(target) + elsif params["waitingForDebugger"] + resume(session_id) + end + end + + # Attaching keeps a service worker alive forever, so unless the caller + # explicitly asked to connect to it (via Context#attach_target), we + # just resume it and let go. + def detach_unless_manually_attached(target, session_id) + return if @manually_attached.delete(target.id) + + detach(session_id) + rescue BrowserError + nil + end + + # Workers have no events to notify us when they're ready, so we + # connect right away. Worker#prepare enables the Network domain and + # only then resumes the debugger itself. + def connect_worker(target) + target.worker + rescue BrowserError + nil + end + + def resume(session_id) + @client.session(session_id).command("Runtime.runIfWaitingForDebugger", async: true) + end + + def detach(session_id) + resume(session_id) + @client.command("Target.detachFromTarget", sessionId: session_id) + end + def discover @client.command("Target.setDiscoverTargets", discover: true) end diff --git a/lib/ferrum/interceptable.rb b/lib/ferrum/interceptable.rb new file mode 100644 index 00000000..32372aa9 --- /dev/null +++ b/lib/ferrum/interceptable.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +module Ferrum + # Shared by {Page} and {Worker}: subscribes to the CDP events behind the + # `:request` (Fetch-domain request interception) and `:auth` (proxy/basic + # auth challenges) pseudo-events, on top of the includer's own `client` + # and `network`. Anything else is passed straight through to `client`. + module Interceptable + # Subscribes to a CDP event, or to `:request`/`:auth`. + # + # @param [Symbol, String] name + # + # @return [Integer] + # The subscription id, used to unsubscribe via {#off}. + def on(name, &block) + case name + when :request + client.on("Fetch.requestPaused") do |params, index, total| + request = Network::InterceptedRequest.new(client, params) + exchange = network.find_or_build_exchange(request.network_id) + exchange.intercepted_request = request + block.call(request, index, total) + end + when :auth + client.on("Fetch.authRequired") do |params, index, total| + request = Network::AuthRequest.new(self, params) + block.call(request, index, total) + end + else + client.on(name, &block) + end + end + + # Unsubscribes a listener previously registered via {#on}. + # + # @param [Symbol, String] name + # + # @param [Integer] id + # The subscription id returned by {#on}. + # + # @return [void] + def off(name, id) + case name + when :request + client.off("Fetch.requestPaused", id) + when :auth + client.off("Fetch.authRequired", id) + else + client.off(name, id) + end + end + + # Whether there's at least one callback registered for the event. + # + # @param [String] event + # + # @return [Boolean] + def subscribed?(event) + client.subscribed?(event) + end + end +end diff --git a/lib/ferrum/network.rb b/lib/ferrum/network.rb index 7bb016c9..c72bb53e 100644 --- a/lib/ferrum/network.rb +++ b/lib/ferrum/network.rb @@ -403,7 +403,7 @@ def subscribe_request_will_be_sent request.headers.merge!(Hash(exchange.request_extra_info&.dig("headers"))) exchange.request = request - if exchange.navigation_request?(@page.main_frame.id) + if exchange.navigation_request?(@page.main_frame&.id) @exchange = exchange classify_pending_exchanges(exchange.loader_id) end diff --git a/lib/ferrum/page.rb b/lib/ferrum/page.rb index e98ab14a..9d0a6549 100644 --- a/lib/ferrum/page.rb +++ b/lib/ferrum/page.rb @@ -9,6 +9,7 @@ require "ferrum/dialog" require "ferrum/network" require "ferrum/accessibility" +require "ferrum/interceptable" require "ferrum/downloads" require "ferrum/page/frames" require "ferrum/page/screencast" @@ -34,6 +35,7 @@ class Page include Screenshot include Frames include Stream + include Interceptable attr_accessor :referrer attr_reader :context_id, :target_id, :event, :tracing @@ -378,45 +380,34 @@ def command(method, wait: 0, slowmoable: false, **params) result end + # Subscribes to a CDP event, or to `:dialog`, `:request`, `:auth` (the + # latter two handled by {Interceptable}). + # + # @param [Symbol, String] name + # + # @return [Integer] + # The subscription id, used to unsubscribe via {#off}. def on(name, &block) - case name - when :dialog - client.on("Page.javascriptDialogOpening") do |params, index, total| - dialog = Dialog.new(self, params) - block.call(dialog, index, total) - end - when :request - client.on("Fetch.requestPaused") do |params, index, total| - request = Network::InterceptedRequest.new(client, params) - exchange = network.find_or_build_exchange(request.network_id) - exchange.intercepted_request = request - block.call(request, index, total) - end - when :auth - client.on("Fetch.authRequired") do |params, index, total| - request = Network::AuthRequest.new(self, params) - block.call(request, index, total) - end - else - client.on(name, &block) + return super unless name == :dialog + + client.on("Page.javascriptDialogOpening") do |params, index, total| + dialog = Dialog.new(self, params) + block.call(dialog, index, total) end end + # Unsubscribes a listener previously registered via {#on}. + # + # @param [Symbol, String] name + # + # @param [Integer] id + # The subscription id returned by {#on}. + # + # @return [void] def off(name, id) - case name - when :dialog - client.off("Page.javascriptDialogOpening", id) - when :request - client.off("Fetch.requestPaused", id) - when :auth - client.off("Fetch.authRequired", id) - else - client.off(name, id) - end - end + return super unless name == :dialog - def subscribed?(event) - client.subscribed?(event) + client.off("Page.javascriptDialogOpening", id) end def use_proxy? diff --git a/lib/ferrum/target.rb b/lib/ferrum/target.rb index d5becb1f..345cd9fd 100644 --- a/lib/ferrum/target.rb +++ b/lib/ferrum/target.rb @@ -13,6 +13,7 @@ class Target def initialize(browser_client, session_id = nil, params = nil) @page = nil + @worker = nil @session_id = session_id @params = params @browser_client = browser_client @@ -24,13 +25,17 @@ def update(params) end def connected? - !!@page + !!@page || !!@worker end def page @page ||= build_page end + def worker + @worker ||= build_worker + end + def client @client ||= build_client end @@ -40,6 +45,15 @@ def build_page(**options) Page.new(client, context_id: context_id, target_id: id, **options) end + def build_worker + Worker.new(client, target_id: id, url: url) + end + + def close_connection + @page&.close_connection + @worker&.close_connection + end + def id @params["targetId"] end @@ -60,6 +74,13 @@ def opener_id @params["openerId"] end + # The id of the target that spawned this one, set for iframes and + # workers. Unlike `opener_id`, which is only set for windows/tabs opened + # via `window.open`/links/etc. + def parent_id + @params["parentId"] + end + def context_id @params["browserContextId"] end @@ -72,6 +93,22 @@ def iframe? type == "iframe" end + def page? + type == "page" + end + + def worker? + type == "worker" + end + + def shared_worker? + type == "shared_worker" + end + + def service_worker? + type == "service_worker" + end + def maybe_sleep_if_new_window # Dirty hack because new window doesn't have events at all sleep(NEW_WINDOW_WAIT) if window? diff --git a/lib/ferrum/worker.rb b/lib/ferrum/worker.rb new file mode 100644 index 00000000..e30d8386 --- /dev/null +++ b/lib/ferrum/worker.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +require "ferrum/frame/runtime" +require "ferrum/interceptable" + +module Ferrum + # A dedicated or shared Worker spawned by a page. Unlike {Page} it has no + # DOM, frames, mouse/keyboard, or navigation history -- just a single + # global execution context and its own network activity. + class Worker + include Frame::Runtime + include Interceptable + + # Client connection. + # + # @return [Client, SessionClient] + attr_reader :client + + attr_reader :target_id, :url + + def initialize(client, target_id:, url:) + @client = client + @target_id = target_id + @url = url + @options = client.options + @page = self + @execution_id = Concurrent::MVar.new + @network = Network.new(self) + + subscribe + prepare + end + + # Network object. + # + # @return [Network] + attr_reader :network + + def timeout + @options.timeout + end + + def command(...) + client.command(...) + end + + # Workers have a single execution context and no navigable document, so + # there's nothing for {Network} to check requests against. + def main_frame + nil + end + + def close + client.command("Target.closeTarget", async: true, targetId: target_id) + close_connection + + true + end + + def close_connection + client&.close + end + + def inspect + "#<#{self.class} @target_id=#{@target_id.inspect} @url=#{@url.inspect}>" + end + + private + + def subscribe + network.subscribe + + on("Runtime.executionContextCreated") do |params| + self.execution_id = params.dig("context", "id") + end + + return unless @options.js_errors + + on("Runtime.exceptionThrown") do |params| + # FIXME: https://jvns.ca/blog/2015/11/27/why-rubys-timeout-is-dangerous-and-thread-dot-raise-is-terrifying/ + Thread.main.raise JavaScriptError, params["exceptionDetails"] + end + end + + def prepare + command("Runtime.enable") + command("Network.enable") + command("Runtime.runIfWaitingForDebugger") + end + + def execution_id! + value = @execution_id.borrow(timeout, &:itself) + raise NoExecutionContextError if value.instance_of?(Object) + + value + end + + def execution_id=(value) + if value.nil? + @execution_id.try_take! + else + @execution_id.try_put!(value) + end + end + end +end diff --git a/rbs_collection.lock.yaml b/rbs_collection.lock.yaml index e4690bab..6472b388 100644 --- a/rbs_collection.lock.yaml +++ b/rbs_collection.lock.yaml @@ -61,14 +61,6 @@ gems: revision: f17b218ad76ff3800d651e9bc42a15ba311095b4 remote: https://github.com/ruby/gem_rbs_collection.git repo_dir: gems -- name: digest - version: '0' - source: - type: stdlib -- name: erb - version: '0' - source: - type: stdlib - name: fileutils version: '0' source: @@ -77,19 +69,23 @@ gems: version: '0' source: type: stdlib -- name: logger - version: '0' +- name: language_server-protocol + version: 3.17.0.6 source: - type: stdlib -- name: monitor - version: '0' + type: rubygems +- name: lint_roller + version: '1.1' source: - type: stdlib -- name: openssl + type: git + name: ruby/gem_rbs_collection + revision: 9ab762c8247028f90620fc93c4e7aac20eb693d7 + remote: https://github.com/ruby/gem_rbs_collection.git + repo_dir: gems +- name: logger version: '0' source: type: stdlib -- name: optparse +- name: monitor version: '0' source: type: stdlib @@ -141,10 +137,6 @@ gems: version: 3.10.2 source: type: rubygems -- name: rdoc - version: '0' - source: - type: stdlib - name: regexp_parser version: '2.8' source: @@ -177,14 +169,6 @@ gems: revision: f17b218ad76ff3800d651e9bc42a15ba311095b4 remote: https://github.com/ruby/gem_rbs_collection.git repo_dir: gems -- name: singleton - version: '0' - source: - type: stdlib -- name: socket - version: '0' - source: - type: stdlib - name: stringio version: '0' source: @@ -201,8 +185,4 @@ gems: version: '0' source: type: stdlib -- name: webrick - version: 1.9.2 - source: - type: rubygems gemfile_lock_path: Gemfile.lock diff --git a/sig/ferrum/context.rbs b/sig/ferrum/context.rbs index 4d381541..a927b1f0 100644 --- a/sig/ferrum/context.rbs +++ b/sig/ferrum/context.rbs @@ -20,19 +20,23 @@ module Ferrum def pages: () -> ::Array[Page] + def workers: () -> ::Array[Worker] + + def service_workers: () -> ::Array[Target] + def windows: (?(:first | :last)?, ?::Integer) -> ::Array[Page] def create_page: (**untyped options) -> Page def create_target: () -> Target - def add_target: (params: Hash[String, untyped], ?session_id: String?) -> bool + def add_target: (params: Hash[String, untyped], ?session_id: String?) -> Target def update_target: (String target_id, Hash[String, untyped] params) -> void def delete_target: (String target_id) -> Target? - def attach_target: (String target_id) -> bool + def attach_target: (String target_id) -> true def find_target: () { (Target) -> bool } -> Target? diff --git a/sig/ferrum/contexts.rbs b/sig/ferrum/contexts.rbs index 59dd0613..36a25a89 100644 --- a/sig/ferrum/contexts.rbs +++ b/sig/ferrum/contexts.rbs @@ -1,6 +1,7 @@ module Ferrum class Contexts ALLOWED_TARGET_TYPES: ::Array[String] + RECURSIVE_AUTO_ATTACH_TYPES: ::Array[String] include Enumerable[[String, Context]] @@ -9,9 +10,12 @@ module Ferrum @contexts: ::Concurrent::Map[String, Context] @client: Client @default_context: Context? + @manually_attached: ::Concurrent::Map[String, bool] def initialize: (Client client) -> void + def manually_attached: (String target_id) -> true + def default_context: () -> Context def each: () { ([String, Context]) -> void } -> void @@ -23,7 +27,7 @@ module Ferrum def create: (**untyped options) -> Context - def dispose: (String context_id) -> true + def dispose: (String context_id) -> bool def close_connections: () -> void @@ -35,8 +39,26 @@ module Ferrum def subscribe: () -> void - def discover: () -> Hash[String, untyped] + def subscribe_attached_target: ((Client | SessionClient) client) -> void + + def subscribe_target_created: () -> void + + def rearm_auto_attach: (String session_id, String type) -> void + + def handle_attach: (Target? target, String session_id, Hash[String, untyped] params) -> void + + def detach_unless_manually_attached: (Target target, String session_id) -> void + + def connect_worker: (Target target) -> void + + def resume: (String session_id) -> void + + def detach: (String session_id) -> void + + def discover: () -> void + + def auto_attach: () -> void - def auto_attach: () -> Hash[String, untyped]? + def add_context: (String? context_id) -> Context? end end diff --git a/sig/ferrum/interceptable.rbs b/sig/ferrum/interceptable.rbs new file mode 100644 index 00000000..fd249771 --- /dev/null +++ b/sig/ferrum/interceptable.rbs @@ -0,0 +1,9 @@ +module Ferrum + module Interceptable + def on: (Symbol | String name) { (*untyped) -> void } -> Integer + + def off: (Symbol | String name, Integer id) -> void + + def subscribed?: (String event) -> bool + end +end diff --git a/sig/ferrum/page.rbs b/sig/ferrum/page.rbs index fd842ecc..28fd511a 100644 --- a/sig/ferrum/page.rbs +++ b/sig/ferrum/page.rbs @@ -9,6 +9,7 @@ module Ferrum include Screencast include Frames include Stream + include Interceptable attr_accessor referrer: String? attr_reader context_id: String @@ -83,7 +84,7 @@ module Ferrum def on: ((Symbol | String) event) ?{ (Hash[String, untyped]) -> void } -> Integer - def subscribed?: (String event) -> bool + def off: ((Symbol | String) event, Integer id) -> void def use_proxy?: () -> boolish diff --git a/sig/ferrum/target.rbs b/sig/ferrum/target.rbs index e9b687de..2fc409f0 100644 --- a/sig/ferrum/target.rbs +++ b/sig/ferrum/target.rbs @@ -7,6 +7,7 @@ module Ferrum attr_accessor session_id: String? @page: Page? + @worker: Worker? @session_id: String? @params: Hash[String, untyped] @browser_client: Client @@ -21,10 +22,16 @@ module Ferrum def page: () -> Page + def worker: () -> Worker + def client: () -> (Client | SessionClient) def build_page: (**untyped options) -> Page + def build_worker: () -> Worker + + def close_connection: () -> void + def id: () -> String def type: () -> String @@ -35,15 +42,25 @@ module Ferrum def opener_id: () -> String? + def parent_id: () -> String? + def context_id: () -> String? def window?: () -> bool def iframe?: () -> bool + def page?: () -> bool + + def worker?: () -> bool + + def shared_worker?: () -> bool + + def service_worker?: () -> bool + def maybe_sleep_if_new_window: () -> void - def command: (*untyped) -> Hash[String, untyped] + def command: (*untyped) -> (bool | Hash[String, untyped]) private diff --git a/sig/ferrum/worker.rbs b/sig/ferrum/worker.rbs new file mode 100644 index 00000000..675766ad --- /dev/null +++ b/sig/ferrum/worker.rbs @@ -0,0 +1,43 @@ +module Ferrum + class Worker + include Frame::Runtime + include Interceptable + + attr_reader client: (Client | SessionClient) + attr_reader target_id: String + attr_reader url: String + attr_reader network: Network + + @client: (Client | SessionClient) + @target_id: String + @url: String + @options: Browser::Options + @page: Worker + @execution_id: untyped + @network: Network + + def initialize: ((Client | SessionClient) client, target_id: String, url: String) -> void + + def timeout: () -> ::Numeric + + def command: (*untyped) -> (bool | Hash[String, untyped]) + + def main_frame: () -> nil + + def close: () -> true + + def close_connection: () -> void + + def inspect: () -> ::String + + private + + def subscribe: () -> void + + def prepare: () -> void + + def execution_id!: () -> untyped + + def execution_id=: (untyped value) -> untyped + end +end diff --git a/spec/network_spec.rb b/spec/network_spec.rb index c12f1140..4336190f 100644 --- a/spec/network_spec.rb +++ b/spec/network_spec.rb @@ -30,6 +30,35 @@ browser.go_to("/with_js") expect(browser.network.traffic.length).to eq(4) end + + it "keeps track of service workers" do + page.go_to("/service_worker") + + # Workers are discovered and connected to asynchronously, so give it + # a moment to show up. + start = Ferrum::Utils::ElapsedTime.monotonic_time + worker_target = nil + until worker_target + worker_target = browser.targets.values.find { |t| t.worker? && t.connected? } + raise Ferrum::TimeoutError if Ferrum::Utils::ElapsedTime.timeout?(start, browser.timeout) + + sleep 0.05 + end + + worker_target.worker.network.wait_for_idle + + traffic = browser.targets.values.flat_map do |t| + next [] unless t.connected? + + (t.worker? ? t.worker : t.page).network.traffic + end.select(&:request) + urls = traffic.map { |e| e.request.url }.reject { |u| u.end_with?("/favicon.ico") } + + expect(urls.size).to eq(3) + expect(urls.grep(%r{/service_worker$}).size).to eq(1) + expect(urls.grep(%r{/one.png$}).size).to eq(1) + expect(urls.grep(/^blob:/).size).to eq(1) + end end describe "#wait_for_idle" do diff --git a/spec/support/application.rb b/spec/support/application.rb index c196368b..773d8221 100644 --- a/spec/support/application.rb +++ b/spec/support/application.rb @@ -189,6 +189,12 @@ def authorized?(login, password) render_view :with_ajax_connection_refused, closed_port: port end + get "/sw.js" do + content_type :js + "self.addEventListener('install', () => self.skipWaiting());" \ + "self.addEventListener('activate', () => self.clients.claim());" + end + get "/:view" do |view| render_view view end diff --git a/spec/support/public/one.png b/spec/support/public/one.png new file mode 100644 index 0000000000000000000000000000000000000000..d442972ac4eae4d81058d7b8036806c1e9db706c GIT binary patch literal 883 zcmV-(1C0EMP)6wrcnurFaKl)#-MRzE+T4Z0cv5gmA(%o+|)(ybLLS&}|*rB9stv)=OO^pEhAbWl0hciS=+}*WyH^Mse?CGI`w(dtm`uMTfRK!_IdZ#=W& zFY7LD1o+B5UIun|JtLDw>q^N#&&Pvhd0t;#MJf=Y zTtXaw%wVq{8~{5zNExmG(ULZ=k0B8y$H&0=d9d*>FF>b*q=`-dUf!mKJkRhv5nf&* zX`&wj!?4%S&VZ{c@B0)Ci}(a!ueVxpZE`{ry#Va>lM~?P25BT(0eF&Qud@g#B{~5( z)Q^skcA{MdGe98#45ttPhEoUt!zl!S;S>VEa0&rnIE4T(oI(H?P9Xpcrw{;!!vZ)k z7e_*C2c|-{O9=4*Qz6@hwyZiS@f%6Ni4#FNLXVH}{5MIddOuB$lO#>Z5*IhP|7W!a0TF;S-O}cf<2inu2d78KALy5e(rmp83^DiGV~(12=RTm zl5AuvOfR^S;wSP;d$?MJ2`-wdR&7#h4|zgsTXehgi>4TA*q}>Zh65);p9UGvOg#-! zsI)RaG4g~59Fi@~X-&eF&T>UbwuS_I{hAzjIInazeDQSph0x^j9`)~n4FBWrZRQeM zfWdF!vjb40^8;6!RKVHhLX`17ELOPS9pxSOM61+ax?i|_{y*!E3E8dSIbr|+002ov JPDHLkV1iAmgZBUc literal 0 HcmV?d00001 diff --git a/spec/support/views/service_worker.erb b/spec/support/views/service_worker.erb new file mode 100644 index 00000000..783a4b85 --- /dev/null +++ b/spec/support/views/service_worker.erb @@ -0,0 +1,48 @@ + + + + + + diff --git a/spec/worker_spec.rb b/spec/worker_spec.rb new file mode 100644 index 00000000..8cb85dbe --- /dev/null +++ b/spec/worker_spec.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +describe Ferrum::Worker do + def wait_for_target(&block) + start = Ferrum::Utils::ElapsedTime.monotonic_time + target = nil + + until target + target = browser.targets.values.find(&block) + raise Ferrum::TimeoutError if Ferrum::Utils::ElapsedTime.timeout?(start, browser.timeout) + + sleep 0.05 + end + + target + end + + describe "dedicated workers" do + it "is discovered, connected to, and reachable through Context#workers" do + page.go_to + + page.execute <<~JS + new Worker(URL.createObjectURL(new Blob( + ["self.onmessage = () => self.postMessage(1 + 1)"], + { type: "application/javascript" } + ))) + JS + + target = wait_for_target(&:worker?) + + expect(target.url).to start_with("blob:") + expect(target.connected?).to eq(true) + expect(browser.workers).to contain_exactly(target.worker) + expect(target.worker.evaluate("1 + 1")).to eq(2) + end + end + + describe "shared workers" do + it "is discovered, connected to, and reachable through Context#workers" do + page.go_to + + page.execute <<~JS + new SharedWorker(URL.createObjectURL(new Blob( + ["self.onconnect = () => {}"], + { type: "application/javascript" } + ))) + JS + + target = wait_for_target(&:shared_worker?) + + expect(target.connected?).to eq(true) + expect(browser.workers).to contain_exactly(target.worker) + expect(target.worker.evaluate("1 + 1")).to eq(2) + end + end + + describe "service workers" do + it "discovers registered service workers without connecting to them" do + page.go_to + page.evaluate_async(%(navigator.serviceWorker.register("/sw.js").then(arguments[0])), 5) + + target = wait_for_target(&:service_worker?) + + expect(target.url).to end_with("/sw.js") + expect(target.connected?).to eq(false) + expect(browser.service_workers).to contain_exactly(target) + end + + it "connects on demand through Context#attach_target, keeping it alive" do + page.go_to + page.evaluate_async(%(navigator.serviceWorker.register("/sw.js").then(arguments[0])), 5) + + target = wait_for_target(&:service_worker?) + browser.attach_target(target.id) + + expect(target.worker.evaluate("1 + 1")).to eq(2) + end + end +end