diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c30830a..9a7a75dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,10 @@ the browser; a failed `IO.close` is raised to the caller. ### Fixed +- `create_page` raised `Failed to find browser context with id` against a browser that came up with its own startup + window (browserless). That window lives in the browser's implicit context, which Chrome below 145 + won't address by id. Ferrum now detects it and exposes as `Ferrum::Context#implicit?`. It's never + disposed, so `#reset` won't clear such a browser. [#578] [#627] - Targets of a type `Ferrum::Contexts` doesn't track were left attached and paused by auto-attach for the lifetime of the browser; Chrome opens two `browser_ui` ones per browser context. They're now resumed and detached from - `Ferrum::Browser::Process` registered two `ObjectSpace` finalizers on the same object, a directory remover and diff --git a/lib/ferrum/context.rb b/lib/ferrum/context.rb index b7880e59..0f2c3790 100644 --- a/lib/ferrum/context.rb +++ b/lib/ferrum/context.rb @@ -15,14 +15,25 @@ class Context attr_reader :id, :targets - def initialize(client, contexts, id) + def initialize(client, contexts, id, implicit: false) @id = id + @implicit = implicit @client = client @contexts = contexts @targets = Concurrent::Map.new @pendings = Concurrent::Map.new end + # Whether this is the browser's implicit context, the one it puts its + # startup window in. We didn't create it, so we can't dispose it, and + # Chrome doesn't let us address it by id either: targets are created in + # it by omitting `browserContextId` altogether. + # + # @return [Boolean] + def implicit? + @implicit + end + # The context's first known target, creating one via # `Target.createTarget` if none has attached yet. # @@ -92,7 +103,8 @@ def create_page(**options) # # @raise [NoSuchTargetError] def create_target - target_id = @client.command("Target.createTarget", browserContextId: @id, url: "about:blank")["targetId"] + options = { browserContextId: @id } unless implicit? + target_id = @client.command("Target.createTarget", url: "about:blank", **Hash(options))["targetId"] new_pending = Concurrent::IVar.new pending = @pendings.put_if_absent(target_id, new_pending) || new_pending @@ -184,9 +196,10 @@ def close_targets_connection end end - # Disposes this browser context and all of its targets. + # Disposes this browser context and all of its targets. The browser's + # implicit context cannot be disposed, see {#implicit?}. # - # @return [Boolean] + # @return [Boolean, nil] def dispose @contexts.dispose(@id) end diff --git a/lib/ferrum/contexts.rb b/lib/ferrum/contexts.rb index 6f75590e..22b6b4d1 100644 --- a/lib/ferrum/contexts.rb +++ b/lib/ferrum/contexts.rb @@ -22,6 +22,7 @@ def initialize(client) @client = client @contexts = Concurrent::Map.new @manually_attached = Concurrent::Map.new + @default_context = find_implicit_context subscribe auto_attach discover @@ -33,7 +34,9 @@ def manually_attached(target_id) @manually_attached[target_id] = true end - # The browser's first context, created lazily. + # The context we work in unless told otherwise: the browser's implicit + # context when it came up with a startup window (see {#find_implicit_context}), + # otherwise one of our own, created lazily. # # @return [Context] def default_context @@ -84,14 +87,16 @@ def create(**options) context end - # Disposes a browser context and all of its targets. + # Disposes a browser context and all of its targets. The browser's implicit + # context is not ours to dispose, see {Context#implicit?}. # # @param [String] context_id # - # @return [Boolean] + # @return [Boolean, nil] def dispose(context_id) context = @contexts[context_id] return unless context + return if context.implicit? context.close_targets_connection @client.command("Target.disposeBrowserContext", browserContextId: context.id) @@ -251,9 +256,29 @@ def auto_attach def add_context(context_id) return if @contexts[context_id] - context = Context.new(@client, self, context_id) - @contexts[context_id] = context - @default_context ||= context # rubocop:disable Naming/MemoizedInstanceVariableName + @contexts[context_id] = Context.new(@client, self, context_id) + end + + # The browser's implicit context, the context Chrome uses for its startup window. + # We register it so events for that window are routed to it. + # `Target.getBrowserContexts` reports every context created with + # `Target.createBrowserContext`, ours and those of anyone else driving the same + # browser, so the context holding targets that it doesn't report is the implicit + # one. Chrome also reports its id as `defaultBrowserContextId`, but only since + # 145 and only as an experimental field, so we don't rely on it. + # + # Returns nil if the browser has no startup window, which happens when it is + # launched with `--no-startup-window` (used for `incognito: true`). In that case, + # we create our own context instead. + # + # @return [Context, nil] + def find_implicit_context + created_ids = @client.command("Target.getBrowserContexts")["browserContextIds"] + target_infos = @client.command("Target.getTargets")["targetInfos"] + implicit_id = (target_infos.filter_map { |t| t["browserContextId"] } - created_ids).first + return unless implicit_id + + @contexts[implicit_id] = Context.new(@client, self, implicit_id, implicit: true) end end end diff --git a/sig/ferrum/context.rbs b/sig/ferrum/context.rbs index a927b1f0..d0efb0aa 100644 --- a/sig/ferrum/context.rbs +++ b/sig/ferrum/context.rbs @@ -6,13 +6,16 @@ module Ferrum attr_reader targets: ::Concurrent::Map[String, Target] @id: String + @implicit: bool @client: Client @contexts: Contexts @targets: ::Concurrent::Map[String, Target] @pendings: ::Concurrent::Map[String, ::Concurrent::IVar] @default_target: Target? - def initialize: (Client, Contexts, String) -> void + def initialize: (Client, Contexts, String, ?implicit: bool) -> void + + def implicit?: () -> bool def default_target: () -> Target @@ -42,7 +45,7 @@ module Ferrum def close_targets_connection: () -> void - def dispose: () -> bool + def dispose: () -> bool? def target?: (String target_id) -> bool diff --git a/sig/ferrum/contexts.rbs b/sig/ferrum/contexts.rbs index 4416f9f2..73c3ac76 100644 --- a/sig/ferrum/contexts.rbs +++ b/sig/ferrum/contexts.rbs @@ -27,7 +27,7 @@ module Ferrum def create: (**untyped options) -> Context - def dispose: (String context_id) -> bool + def dispose: (String context_id) -> bool? def close_connections: () -> void @@ -62,5 +62,7 @@ module Ferrum def auto_attach: () -> void def add_context: (String? context_id) -> Context? + + def find_implicit_context: () -> Context? end end diff --git a/spec/contexts_spec.rb b/spec/contexts_spec.rb index c0997034..436d036e 100644 --- a/spec/contexts_spec.rb +++ b/spec/contexts_spec.rb @@ -23,4 +23,49 @@ browser.client.off("Target.attachedToTarget", attached_id) browser.client.off("Target.detachedFromTarget", detached_id) end + + describe "#default_context" do + it "works in the browser's startup window when it came up with one" do + with_external_browser(incognito: false) do |url| + remote = Ferrum::Browser.new(url: url) + + expect(remote.contexts.default_context).to be_implicit + expect { remote.create_page }.not_to raise_error + ensure + remote&.quit + end + end + + it "creates a context of its own when the browser has no startup window" do + with_external_browser do |url| + remote = Ferrum::Browser.new(url: url) + remote.create_page + + expect(remote.contexts.default_context).not_to be_implicit + + remote.reset + + expect(remote.contexts.size).to be_zero + ensure + remote&.quit + end + end + + it "ignores contexts another client created in the same browser" do + with_external_browser do |url| + first = Ferrum::Browser.new(url: url) + context = first.contexts.create + context.create_page + + second = Ferrum::Browser.new(url: url) + + expect(second.contexts.default_context).not_to be_implicit + expect(second.contexts.default_context.id).not_to eq(context.id) + expect { second.create_page }.not_to raise_error + ensure + second&.quit + first&.quit + end + end + end end diff --git a/spec/support/global_helpers.rb b/spec/support/global_helpers.rb index 864a1739..68ba691c 100644 --- a/spec/support/global_helpers.rb +++ b/spec/support/global_helpers.rb @@ -44,8 +44,9 @@ def with_timeout(new_timeout) browser.timeout = old_timeout end - def with_external_browser(host: "127.0.0.1", port: 32_001) - options = Ferrum::Browser::Options.new(host: host, port: port, window_size: [1400, 1400], headless: true) + def with_external_browser(host: "127.0.0.1", port: 32_001, incognito: true) + options = Ferrum::Browser::Options.new(host: host, port: port, window_size: [1400, 1400], + headless: true, incognito: incognito) process = Ferrum::Browser::Process.new(options) begin