diff --git a/AGENTS.md b/AGENTS.md index faa048d9..4c6d156e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,6 +35,36 @@ The suite boots a real Chrome and a Sinatra/Puma test app (`spec/support/applica `lib/ferrum/contexts.rb` -> `spec/unit/contexts_spec.rb`, `lib/ferrum/client/web_socket.rb` -> `spec/unit/client/web_socket_spec.rb`. Never add them to the feature spec files in `spec/`. - Don't test private methods; cover them through the public API. +- Structure every spec file in one of two shapes, nothing else: + + ```ruby + describe Ferrum::Context do + describe "#windows" do + it "waits for the window to load" do + ``` + + ```ruby + describe Ferrum::Cookies do + describe "#each" do + context "when a block is given" do + it "yields each cookie" do + ``` + + The top `describe` is the class, and the file path mirrors that class's path in `lib/`, for feature and unit + specs alike: `Ferrum::Page::Screenshot` -> `spec/page/screenshot_spec.rb`, `Ferrum::Browser::Process` -> + `spec/unit/browser/process_spec.rb`. Each nested `describe` names one public method (`"#method"`, `".method"` for + class methods), never a topic like `"context scope"`. +- A `describe` holds either only examples or only contexts, never a mix. Use contexts only for contrasting groups, + at least two that oppose each other (`"when a block is given"` / `"when no block is given"`). A single condition + next to plain examples isn't a context: keep the examples flat and put the condition in the example name, e.g. + `it "sets cookies for given domain when base_url isn't set"`. The one exception: a single context is fine when it + carries `let`, hooks (`before`/`after`/`around`) or tags that its examples share. +- Every example must be runnable alone with `rspec spec/file_spec.rb:LINE`, so each one gets its own `it` on its + own line. No `shared_examples`/`include_examples` and no loops that generate examples. Share the steps and + assertions through a helper method in the `describe` instead, and call it from one-line examples, one per + variant. The only exception is the data-table loops in `spec/unit/frame/script_spec.rb`. +- Start every example name with a verb in the third person: "yields each cookie", "returns the cookie with the + given name". Never "must ...", "should ..." or a bare noun. - Almost no comments in specs; the example name says what is checked. - For a bug fix, make sure the new spec fails without the fix and passes with it. diff --git a/spec/accessibility/ax_node_spec.rb b/spec/accessibility/ax_node_spec.rb new file mode 100644 index 00000000..bef90b1f --- /dev/null +++ b/spec/accessibility/ax_node_spec.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +describe Ferrum::Accessibility::AXNode do + describe "#value" do + before { browser.go_to("/accessibility") } + + it "returns the raw CDP value for a range input as a Numeric (Chrome does not stringify it)" do + ax = browser.page.accessibility.node_for(browser.at_css("#volume")) + value = ax.value + + # Chrome returns the range value as Integer 7, not the string "7" + expect(value).to eq(7) + expect(value).to be_a(Numeric) + end + end +end diff --git a/spec/accessibility_spec.rb b/spec/accessibility_spec.rb index 6ce96549..98809830 100644 --- a/spec/accessibility_spec.rb +++ b/spec/accessibility_spec.rb @@ -28,6 +28,18 @@ expect(nodes).to all(be_a(Ferrum::Accessibility::AXNode)) expect(nodes.map(&:role)).to include("button") end + + it "returns AXNodes for a node in an iframe" do + browser.go_to("/accessibility_iframe") + frame_node = browser.page.at_css("iframe").frame.at_css("#child_btn") + + nodes = browser.page.accessibility.partial_tree(node: frame_node) + + expect(nodes).to all(be_a(Ferrum::Accessibility::AXNode)) + expect(nodes).not_to be_empty + expect(nodes.map(&:role)).to include("button") + expect(nodes.map(&:name)).to include("Frame button") + end end describe "#snapshot" do @@ -63,34 +75,11 @@ expect(nodes).to be_empty end - end - - describe "across an iframe" do - before { browser.go_to("/accessibility_iframe") } - - let(:frame_node) do - frame = browser.page.at_css("iframe").frame - frame.at_css("#child_btn") - end - it "node_for resolves the AXNode across the frame boundary" do - ax = frame_node.axnode - - expect(ax).to be_a(Ferrum::Accessibility::AXNode) - expect(ax.role).to eq("button") - expect(ax.name).to eq("Frame button") - end + it "scopes the query to a node in an iframe" do + browser.go_to("/accessibility_iframe") + frame_node = browser.page.at_css("iframe").frame.at_css("#child_btn") - it "partial_tree returns AXNodes for a frame-resident node" do - nodes = browser.page.accessibility.partial_tree(node: frame_node) - - expect(nodes).to all(be_a(Ferrum::Accessibility::AXNode)) - expect(nodes).not_to be_empty - expect(nodes.map(&:role)).to include("button") - expect(nodes.map(&:name)).to include("Frame button") - end - - it "query scoped to a frame-resident node returns the button" do nodes = browser.page.accessibility.query(name: "Frame button", node: frame_node) expect(nodes).not_to be_empty @@ -105,21 +94,4 @@ expect(browser.page.accessibility.root).to be_a(Ferrum::Accessibility::AXNode) end end - - describe "#value" do - before { browser.go_to("/accessibility") } - - it "returns the raw CDP value for a range input as a Numeric (Chrome does not stringify it)" do - ax = browser.page.accessibility.node_for(browser.at_css("#volume")) - value = ax.value - - # Chrome returns the range value as Integer 7, not the string "7" - expect(value).to eq(7) - expect(value).to be_a(Numeric) - end - end - - it "is reachable from the browser" do - expect(browser.accessibility).to be_a(described_class) - end end diff --git a/spec/browser/binary_spec.rb b/spec/browser/binary_spec.rb index 9c6d7623..12f4669f 100644 --- a/spec/browser/binary_spec.rb +++ b/spec/browser/binary_spec.rb @@ -28,7 +28,7 @@ ENV["PATHEXT"] = @original_env_pathext end - describe "#find" do + describe ".find" do it "finds one binary" do expect(Ferrum::Browser::Binary.find("bin1")).to eq(bin1) end @@ -62,7 +62,7 @@ end end - describe "#all" do + describe ".all" do it "finds one binary" do expect(Ferrum::Browser::Binary.all("bin1")).to eq([bin1]) end @@ -90,7 +90,7 @@ end end - describe "#lazy_find" do + describe ".lazy_find" do it "works lazily" do enum = Ferrum::Browser::Binary.lazy_find([%w[ls which none]]) diff --git a/spec/browser/version_info_spec.rb b/spec/browser/version_info_spec.rb index 9dc97b76..64db0197 100644 --- a/spec/browser/version_info_spec.rb +++ b/spec/browser/version_info_spec.rb @@ -22,31 +22,31 @@ subject { described_class.new(properties) } describe "#protocol_version" do - it "must return the protocolVersion property" do + it "returns the protocolVersion property" do expect(subject.protocol_version).to eq(properties["protocolVersion"]) end end describe "#product" do - it "must return the product property" do + it "returns the product property" do expect(subject.product).to eq(properties["product"]) end end describe "#revision" do - it "must return the revision property" do + it "returns the revision property" do expect(subject.revision).to eq(properties["revision"]) end end describe "#user_agent" do - it "must return the userAgent property" do + it "returns the userAgent property" do expect(subject.user_agent).to eq(properties["userAgent"]) end end describe "#js_version" do - it "must return the jsVersion property" do + it "returns the jsVersion property" do expect(subject.js_version).to eq(properties["jsVersion"]) end end diff --git a/spec/browser/xvfb_spec.rb b/spec/browser/xvfb_spec.rb index 0788399d..78e6abe1 100644 --- a/spec/browser/xvfb_spec.rb +++ b/spec/browser/xvfb_spec.rb @@ -5,11 +5,11 @@ let(:xvfb_browser) { Ferrum::Browser.new(default_options.merge(options)) } let(:default_options) { Hash(headless: true, xvfb: true) } - context "headless" do - context "with window_size" do + describe ".start" do + context "when headless with window_size" do let(:options) { Hash(window_size: [1400, 1400]) } - it "allows to run tests configured to xvfb" do + it "runs the browser in Xvfb" do xvfb_browser.go_to(base_url) expect(xvfb_browser.body).to include("Hello world!") @@ -22,10 +22,10 @@ end end - context "without window_size" do + context "when headless without window_size" do let(:options) { {} } - it "allows to run tests configured to xvfb" do + it "runs the browser in Xvfb" do xvfb_browser.go_to(base_url) expect(xvfb_browser.body).to include("Hello world!") @@ -37,21 +37,21 @@ expect(process_alive?(process.xvfb.pid)).to be(false) end end - end - context "headful" do - let(:options) { Hash(headless: false) } + context "when headful" do + let(:options) { Hash(headless: false) } - it "allows to run tests configured to xvfb" do - xvfb_browser.go_to(base_url) + it "runs the browser in Xvfb" do + xvfb_browser.go_to(base_url) - expect(xvfb_browser.body).to include("Hello world!") - expect(process_alive?(process.xvfb.pid)).to be(true) - expect(process.xvfb.screen_size).to eq("1024x768x24") - ensure - xvfb_browser&.quit - pid = process&.xvfb&.pid - expect(process_alive?(pid)).to be(false) if pid + expect(xvfb_browser.body).to include("Hello world!") + expect(process_alive?(process.xvfb.pid)).to be(true) + expect(process.xvfb.screen_size).to eq("1024x768x24") + ensure + xvfb_browser&.quit + pid = process&.xvfb&.pid + expect(process_alive?(pid)).to be(false) if pid + end end end diff --git a/spec/browser_spec.rb b/spec/browser_spec.rb index 5a278dba..58f9ea7b 100644 --- a/spec/browser_spec.rb +++ b/spec/browser_spec.rb @@ -4,256 +4,252 @@ require "ferrum/proxy" describe Ferrum::Browser do - describe "#new" do + describe ".new" do let(:logger) { StringIO.new } - context ":browser_path argument" do - it "includes the process output in the error" do - path = "#{PROJECT_ROOT}/spec/support/broken_chrome" + it "includes the process output in the error for a broken :browser_path" do + path = "#{PROJECT_ROOT}/spec/support/broken_chrome" - expect do - Ferrum::Browser.new(browser_path: path) - end.to raise_error(Ferrum::ProcessTimeoutError) do |e| - expect(e.output).to include "Broken Chrome error message" - end + expect do + Ferrum::Browser.new(browser_path: path) + end.to raise_error(Ferrum::ProcessTimeoutError) do |e| + expect(e.output).to include "Broken Chrome error message" end + end - it "supports custom chrome path", :launch do - original_path = "#{PROJECT_ROOT}/spec/support/chrome_path" - File.write(original_path, browser.process.path) + it "supports custom chrome path", :launch do + original_path = "#{PROJECT_ROOT}/spec/support/chrome_path" + File.write(original_path, browser.process.path) - file = "#{PROJECT_ROOT}/spec/support/custom_chrome_called" - path = "#{PROJECT_ROOT}/spec/support/custom_chrome" + file = "#{PROJECT_ROOT}/spec/support/custom_chrome_called" + path = "#{PROJECT_ROOT}/spec/support/custom_chrome" - browser = Ferrum::Browser.new(browser_path: path) + browser = Ferrum::Browser.new(browser_path: path) - # If the correct custom path is called, it will touch the file. - # We allow at least 10 secs for this to happen before failing. + # If the correct custom path is called, it will touch the file. + # We allow at least 10 secs for this to happen before failing. - tries = 0 - until File.exist?(file) || tries == 100 - sleep 0.1 - tries += 1 - end + tries = 0 + until File.exist?(file) || tries == 100 + sleep 0.1 + tries += 1 + end - expect(File.exist?(file)).to be true - ensure - FileUtils.rm_f(original_path) - FileUtils.rm_f(file) - browser&.quit + expect(File.exist?(file)).to be true + ensure + FileUtils.rm_f(original_path) + FileUtils.rm_f(file) + browser&.quit + end + + it "supports :logger argument", :launch do + browser = Ferrum::Browser.new(logger: logger) + browser.go_to(base_url("/console_log")) + + wait_for { logger.string }.to include("Runtime.consoleAPICalled") + expect(logger.string).to include("DevTools listening on") + expect(logger.string).not_to include("[log] Hello world") + ensure + browser&.quit + end + + it "writes to a Logger at debug level", :launch do + browser = Ferrum::Browser.new(logger: Logger.new(logger, level: :debug)) + browser.go_to(base_url("/console_log")) + + expect(logger.string).to include("DEBUG -- ferrum:browser: DevTools listening on") + expect(logger.string).to include("DEBUG -- ferrum:protocol: \n\n▶") + ensure + browser&.quit + end + + it "writes nothing to a Logger above debug level", :launch do + browser = Ferrum::Browser.new(logger: Logger.new(logger, level: :info)) + browser.go_to(base_url("/console_log")) + + expect(logger.string).to be_empty + ensure + browser&.quit + end + + it "logs to $stdout with FERRUM_LOG", :launch do + with_env("FERRUM_LOG" => "browser") do + expect do + Ferrum::Browser.new.quit + end.to output(/DevTools listening on/).to_stdout end end - context ":logger argument" do - it "supports :logger argument", :launch do + it "logs nothing with FERRUM_LOG=0", :launch do + with_env("FERRUM_LOG" => "0") do browser = Ferrum::Browser.new(logger: logger) browser.go_to(base_url("/console_log")) - wait_for { logger.string }.to include("Runtime.consoleAPICalled") - expect(logger.string).to include("DevTools listening on") - expect(logger.string).not_to include("[log] Hello world") + expect(logger.string).to be_empty ensure browser&.quit end + end - it "writes to a Logger at debug level", :launch do - browser = Ferrum::Browser.new(logger: Logger.new(logger, level: :debug)) + it "logs everything with the deprecated FERRUM_DEBUG", :launch do + with_env("FERRUM_DEBUG" => "true") do + browser = Ferrum::Browser.new(logger: logger) browser.go_to(base_url("/console_log")) - expect(logger.string).to include("DEBUG -- ferrum:browser: DevTools listening on") - expect(logger.string).to include("DEBUG -- ferrum:protocol: \n\n▶") + expect(logger.string).to include("Page.navigate") + expect(logger.string).to include("DevTools listening on") ensure browser&.quit end + end - it "writes nothing to a Logger above debug level", :launch do - browser = Ferrum::Browser.new(logger: Logger.new(logger, level: :info)) + it "leaves out the channels excluded in FERRUM_LOG", :launch do + with_env("FERRUM_LOG" => "*,-browser") do + browser = Ferrum::Browser.new(logger: logger) browser.go_to(base_url("/console_log")) - expect(logger.string).to be_empty + expect(logger.string).to include("Page.navigate") + expect(logger.string).not_to include("DevTools listening on") ensure browser&.quit end + end - it "logs to $stdout with FERRUM_LOG", :launch do - with_env("FERRUM_LOG" => "browser") do - expect do - Ferrum::Browser.new.quit - end.to output(/DevTools listening on/).to_stdout - end - end - - it "logs nothing with FERRUM_LOG=0", :launch do - with_env("FERRUM_LOG" => "0") do - browser = Ferrum::Browser.new(logger: logger) - browser.go_to(base_url("/console_log")) - - expect(logger.string).to be_empty - ensure - browser&.quit - end - end - - it "logs everything with the deprecated FERRUM_DEBUG", :launch do - with_env("FERRUM_DEBUG" => "true") do - browser = Ferrum::Browser.new(logger: logger) - browser.go_to(base_url("/console_log")) - - expect(logger.string).to include("Page.navigate") - expect(logger.string).to include("DevTools listening on") - ensure - browser&.quit - end - end - - it "leaves out the channels excluded in FERRUM_LOG", :launch do - with_env("FERRUM_LOG" => "*,-browser") do - browser = Ferrum::Browser.new(logger: logger) - browser.go_to(base_url("/console_log")) - - expect(logger.string).to include("Page.navigate") - expect(logger.string).not_to include("DevTools listening on") - ensure - browser&.quit - end - end - - it "logs only CDP messages matching FERRUM_LOG", :launch do - with_env("FERRUM_LOG" => "Page.navigate") do - browser = Ferrum::Browser.new(logger: logger) - browser.go_to(base_url("/console_log")) - - expect(logger.string).to include(%("method":"Page.navigate")) - expect(logger.string).to match(/"result":\{"frameId":"\h+","loaderId"/) - expect(logger.string).not_to include("Runtime.") - expect(logger.string).not_to include("DevTools listening on") - ensure - browser&.quit - end - end - - it "leaves out CDP messages excluded in FERRUM_LOG", :launch do - with_env("FERRUM_LOG" => "protocol,-Network.*,-Page.navigate") do - browser = Ferrum::Browser.new(logger: logger) - browser.go_to(base_url("/console_log")) - - wait_for { logger.string }.to include("Runtime.consoleAPICalled") - expect(logger.string).not_to include("Network.") - expect(logger.string).not_to include("Page.navigate") - expect(logger.string).not_to match(/"result":\{"frameId":"\h+","loaderId"/) - ensure - browser&.quit - end - end - - it "writes CDP messages as JSON alone with FERRUM_LOG_FORMAT=json", :launch do - with_env("FERRUM_LOG" => "protocol", "FERRUM_LOG_FORMAT" => "json") do - def logger.tty? = true - browser = Ferrum::Browser.new(logger: logger) - browser.go_to(base_url("/console_log")) + it "logs only CDP messages matching FERRUM_LOG", :launch do + with_env("FERRUM_LOG" => "Page.navigate") do + browser = Ferrum::Browser.new(logger: logger) + browser.go_to(base_url("/console_log")) - messages = logger.string.lines.map { |line| JSON.parse(line) } - expect(messages).to include(a_hash_including("method" => "Page.navigate", "params" => { "url" => base_url("/console_log") })) - expect(messages).to include(a_hash_including("id" => a_kind_of(Integer), "result" => a_hash_including("frameId"))) - ensure - browser&.quit - end + expect(logger.string).to include(%("method":"Page.navigate")) + expect(logger.string).to match(/"result":\{"frameId":"\h+","loaderId"/) + expect(logger.string).not_to include("Runtime.") + expect(logger.string).not_to include("DevTools listening on") + ensure + browser&.quit end + end - it "annotates CDP messages with FERRUM_LOG_FORMAT=annotated", :launch do - with_env("FERRUM_LOG_FORMAT" => "annotated") do - browser = Ferrum::Browser.new(logger: logger) - browser.go_to(base_url("/console_log")) + it "leaves out CDP messages excluded in FERRUM_LOG", :launch do + with_env("FERRUM_LOG" => "protocol,-Network.*,-Page.navigate") do + browser = Ferrum::Browser.new(logger: logger) + browser.go_to(base_url("/console_log")) - expect(logger.string).to match(%r{^▶ \d+\.\d{3} #\d+ Page\.navigate \{"url":"http://[^"]+/console_log"\} @\h{8}$}) - id = logger.string[/▶ \S+ #(\d+) Page\.navigate/, 1] - expect(logger.string).to match(/^ ◀ \d+\.\d{3} ##{id} Page\.navigate \d+ms \{"frameId":"\h+",.+\} @\h{8}$/) - wait_for { logger.string }.to match(/^ ◀ \d+\.\d{3} Runtime\.consoleAPICalled \{"type":"log"/) - ensure - browser&.quit - end + wait_for { logger.string }.to include("Runtime.consoleAPICalled") + expect(logger.string).not_to include("Network.") + expect(logger.string).not_to include("Page.navigate") + expect(logger.string).not_to match(/"result":\{"frameId":"\h+","loaderId"/) + ensure + browser&.quit end + end - it "colors protocol lines written to a terminal", :launch do + it "writes CDP messages as JSON alone with FERRUM_LOG_FORMAT=json", :launch do + with_env("FERRUM_LOG" => "protocol", "FERRUM_LOG_FORMAT" => "json") do def logger.tty? = true browser = Ferrum::Browser.new(logger: logger) browser.go_to(base_url("/console_log")) - expect(logger.string).to include(%("method":"\e[1mPage.navigate\e[0m")) - expect(logger.string).to include("\e[36m▶\e[0m") + messages = logger.string.lines.map { |line| JSON.parse(line) } + expect(messages).to include(a_hash_including("method" => "Page.navigate", "params" => { "url" => base_url("/console_log") })) + expect(messages).to include(a_hash_including("id" => a_kind_of(Integer), "result" => a_hash_including("frameId"))) ensure browser&.quit end + end - it "writes plain text when not writing to a terminal", :launch do + it "annotates CDP messages with FERRUM_LOG_FORMAT=annotated", :launch do + with_env("FERRUM_LOG_FORMAT" => "annotated") do browser = Ferrum::Browser.new(logger: logger) browser.go_to(base_url("/console_log")) - expect(logger.string).to include("Page.navigate") - expect(logger.string).not_to include("\e[") + expect(logger.string).to match(%r{^▶ \d+\.\d{3} #\d+ Page\.navigate \{"url":"http://[^"]+/console_log"\} @\h{8}$}) + id = logger.string[/▶ \S+ #(\d+) Page\.navigate/, 1] + expect(logger.string).to match(/^ ◀ \d+\.\d{3} ##{id} Page\.navigate \d+ms \{"frameId":"\h+",.+\} @\h{8}$/) + wait_for { logger.string }.to match(/^ ◀ \d+\.\d{3} Runtime\.consoleAPICalled \{"type":"log"/) ensure browser&.quit end + end - it "colors lines written anywhere with FERRUM_LOG_COLOR=always", :launch do - with_env("FERRUM_LOG_COLOR" => "always") do - browser = Ferrum::Browser.new(logger: logger) - browser.go_to(base_url("/console_log")) + it "colors protocol lines written to a terminal", :launch do + def logger.tty? = true + browser = Ferrum::Browser.new(logger: logger) + browser.go_to(base_url("/console_log")) - expect(logger.string).to include("\e[36m▶\e[0m") - ensure - browser&.quit - end - end + expect(logger.string).to include(%("method":"\e[1mPage.navigate\e[0m")) + expect(logger.string).to include("\e[36m▶\e[0m") + ensure + browser&.quit + end - it "writes plain text to a terminal with FERRUM_LOG_COLOR=never", :launch do - with_env("FERRUM_LOG_COLOR" => "never") do - def logger.tty? = true - browser = Ferrum::Browser.new(logger: logger) - browser.go_to(base_url("/console_log")) + it "writes plain text when not writing to a terminal", :launch do + browser = Ferrum::Browser.new(logger: logger) + browser.go_to(base_url("/console_log")) - expect(logger.string).to include("Page.navigate") - expect(logger.string).not_to include("\e[") - ensure - browser&.quit - end - end + expect(logger.string).to include("Page.navigate") + expect(logger.string).not_to include("\e[") + ensure + browser&.quit + end - it "keeps the browser working when the logger fails", :launch do - broken = Object.new - def broken.puts(*) = raise(IOError, "closed stream") - browser = nil + it "colors lines written anywhere with FERRUM_LOG_COLOR=always", :launch do + with_env("FERRUM_LOG_COLOR" => "always") do + browser = Ferrum::Browser.new(logger: logger) + browser.go_to(base_url("/console_log")) - expect do - browser = Ferrum::Browser.new(logger: broken) - browser.go_to(base_url("/console_log")) - end.to output(/couldn't write to the logger, IOError: closed stream/).to_stderr - expect(browser.evaluate("1 + 1")).to eq(2) + expect(logger.string).to include("\e[36m▶\e[0m") ensure browser&.quit end + end - it "logs CDP messages nested deeper than JSON's default limit", :launch do + it "writes plain text to a terminal with FERRUM_LOG_COLOR=never", :launch do + with_env("FERRUM_LOG_COLOR" => "never") do + def logger.tty? = true browser = Ferrum::Browser.new(logger: logger) - browser.go_to(base_url("/deeply_nested")) + browser.go_to(base_url("/console_log")) - expect(browser.evaluate(%(document.getElementById("text"))).text).to eq("text") - expect(logger.string).to include("DOM.setChildNodes") + expect(logger.string).to include("Page.navigate") + expect(logger.string).not_to include("\e[") ensure browser&.quit end + end - it "shortens long strings in logged CDP messages", :launch, skip: Ferrum::Log::VALUE_LENGTH.zero? do - browser = Ferrum::Browser.new(logger: logger) + it "keeps the browser working when the logger fails", :launch do + broken = Object.new + def broken.puts(*) = raise(IOError, "closed stream") + browser = nil + + expect do + browser = Ferrum::Browser.new(logger: broken) browser.go_to(base_url("/console_log")) - screenshot = browser.screenshot(encoding: :base64) + end.to output(/couldn't write to the logger, IOError: closed stream/).to_stderr + expect(browser.evaluate("1 + 1")).to eq(2) + ensure + browser&.quit + end - expect(logger.string).not_to include(screenshot) - expect(logger.string).to include("#{screenshot[0, Ferrum::Log::VALUE_LENGTH]}... (" \ - "#{screenshot.size - Ferrum::Log::VALUE_LENGTH} more characters)") - ensure - browser&.quit - end + it "logs CDP messages nested deeper than JSON's default limit", :launch do + browser = Ferrum::Browser.new(logger: logger) + browser.go_to(base_url("/deeply_nested")) + + expect(browser.evaluate(%(document.getElementById("text"))).text).to eq("text") + expect(logger.string).to include("DOM.setChildNodes") + ensure + browser&.quit + end + + it "shortens long strings in logged CDP messages", :launch, skip: Ferrum::Log::VALUE_LENGTH.zero? do + browser = Ferrum::Browser.new(logger: logger) + browser.go_to(base_url("/console_log")) + screenshot = browser.screenshot(encoding: :base64) + + expect(logger.string).not_to include(screenshot) + expect(logger.string).to include("#{screenshot[0, Ferrum::Log::VALUE_LENGTH]}... (" \ + "#{screenshot.size - Ferrum::Log::VALUE_LENGTH} more characters)") + ensure + browser&.quit end it "keeps the output of the browser process", :launch do @@ -300,106 +296,80 @@ def broken.puts(*) = raise(IOError, "closed stream") ) end - context ":extensions argument" do - it "extends the browser's world with files", :launch do - browser = Ferrum::Browser.new(base_url: base_url, - extensions: [File.expand_path("support/geolocation.js", __dir__)]) - - browser.go_to("/requiring_custom_extension") + it "extends the browser's world with files", :launch do + browser = Ferrum::Browser.new(base_url: base_url, + extensions: [File.expand_path("support/geolocation.js", __dir__)]) - expect( - browser.body - ).to include(%(Location: 1,-1)) - - expect( - browser.evaluate(%(document.getElementById("location").innerHTML)) - ).to eq("1,-1") - - expect( - browser.evaluate("navigator.geolocation") - ).to_not eq(nil) - ensure - browser&.quit - end + browser.go_to("/requiring_custom_extension") - it "extends the browser's world with source", :launch do - browser = Ferrum::Browser.new(base_url: base_url, - extensions: [{ source: "window.secret = 'top'" }]) + expect( + browser.body + ).to include(%(Location: 1,-1)) - browser.go_to("/requiring_custom_extension") + expect( + browser.evaluate(%(document.getElementById("location").innerHTML)) + ).to eq("1,-1") - expect(browser.evaluate(%(window.secret))).to eq("top") - ensure - browser&.quit - end + expect( + browser.evaluate("navigator.geolocation") + ).to_not eq(nil) + ensure + browser&.quit + end - it "extends a popup's world before its scripts run", :launch do - browser = Ferrum::Browser.new(base_url: base_url, - extensions: [File.expand_path("support/geolocation.js", __dir__)]) - browser.go_to + it "extends the browser's world with source", :launch do + browser = Ferrum::Browser.new(base_url: base_url, + extensions: [{ source: "window.secret = 'top'" }]) - browser.execute(%(window.open("/requiring_custom_extension", "popup"))) - popup, = browser.windows(:last) + browser.go_to("/requiring_custom_extension") - expect(popup.at_css("#location").text).to eq("1,-1") - ensure - browser&.quit - end + expect(browser.evaluate(%(window.secret))).to eq("top") + ensure + browser&.quit + end - it "extends an out-of-process iframe's world before its scripts run", :launch do - report = <<~JS - if (window !== top) { - addEventListener("load", () => parent.postMessage(document.getElementById("location").textContent, "*")); - } - JS - browser = Ferrum::Browser.new(base_url: base_url, - browser_options: { "site-per-process" => nil }, - extensions: [File.expand_path("support/geolocation.js", __dir__), { source: report }]) - browser.go_to - browser.execute(<<~JS) - addEventListener("message", (event) => { window.iframeLocation = event.data }); - document.body.innerHTML = ` - - -
- HTML - nodes = browser.evaluate(<<~JS) - (() => { - const host = document.getElementById("host"); - host.attachShadow({ mode: "open" }); - const p = document.querySelector("p"); - return [document, document.doctype, p, p.firstChild, p.lastChild, document.querySelector("iframe"), - document.querySelector("my-element"), document.querySelector("circle"), host.shadowRoot]; - })() - JS + it "returns nodes of every type" do + browser.content = <<~HTML + +

text

+ + + +
+ HTML + nodes = browser.evaluate(<<~JS) + (() => { + const host = document.getElementById("host"); + host.attachShadow({ mode: "open" }); + const p = document.querySelector("p"); + return [document, document.doctype, p, p.firstChild, p.lastChild, document.querySelector("iframe"), + document.querySelector("my-element"), document.querySelector("circle"), host.shadowRoot]; + })() + JS - expect(nodes.map(&:tag_name)).to eq( - %w[#document html p #text #comment iframe my-element circle #document-fragment] - ) - expect(nodes.map(&:node_type)).to eq([9, 10, 1, 3, 8, 1, 1, 1, 11]) - expect(nodes.map(&:node?)).to eq([false, false, true, false, false, true, true, true, false]) - expect(nodes[5].frame_id).to eq(browser.frames.reject(&:main?).first.id) - expect(nodes.values_at(0..4, 6..8).map(&:frame_id)).to all(be_nil) - expect(nodes[2].description).to include("nodeName" => "P", "attributes" => %w[class a id b]) - expect(nodes[8].description).to include("shadowRootType" => "open") - end + expect(nodes.map(&:tag_name)).to eq( + %w[#document html p #text #comment iframe my-element circle #document-fragment] + ) + expect(nodes.map(&:node_type)).to eq([9, 10, 1, 3, 8, 1, 1, 1, 11]) + expect(nodes.map(&:node?)).to eq([false, false, true, false, false, true, true, true, false]) + expect(nodes[5].frame_id).to eq(browser.frames.reject(&:main?).first.id) + expect(nodes.values_at(0..4, 6..8).map(&:frame_id)).to all(be_nil) + expect(nodes[2].description).to include("nodeName" => "P", "attributes" => %w[class a id b]) + expect(nodes[8].description).to include("shadowRootType" => "open") + end - it "returns values properly" do - expect(browser.evaluate("null")).to be_nil - expect(browser.evaluate("false")).to be false - expect(browser.evaluate("true")).to be true - expect(browser.evaluate("undefined")).to eq(nil) - - expect(browser.evaluate("3;")).to eq(3) - expect(browser.evaluate("31337")).to eq(31_337) - expect(browser.evaluate(%("string"))).to eq("string") - expect(browser.evaluate(%({foo: "bar"}))).to eq("foo" => "bar") - - expect(browser.evaluate("new Object")).to eq({}) - expect(browser.evaluate("new Date(2012, 0).toDateString()")).to eq("Sun Jan 01 2012") - expect(browser.evaluate("new Object({a: 1})")).to eq({ "a" => 1 }) - expect(browser.evaluate("new Array")).to eq([]) - expect(browser.evaluate("new Function")).to eq({}) - - expect do - browser.evaluate(%(throw "smth")) - end.to raise_error(Ferrum::JavaScriptError) - end + it "returns values properly" do + expect(browser.evaluate("null")).to be_nil + expect(browser.evaluate("false")).to be false + expect(browser.evaluate("true")).to be true + expect(browser.evaluate("undefined")).to eq(nil) - it "raises when a string is thrown" do - expect do - browser.evaluate(%((() => { throw "smth" })())) - end.to raise_error(Ferrum::JavaScriptError, "Uncaught smth") - end + expect(browser.evaluate("3;")).to eq(3) + expect(browser.evaluate("31337")).to eq(31_337) + expect(browser.evaluate(%("string"))).to eq("string") + expect(browser.evaluate(%({foo: "bar"}))).to eq("foo" => "bar") - it "raises when an object is thrown" do - expect do - browser.evaluate("(() => { throw { code: 1 } })()") - end.to raise_error(Ferrum::JavaScriptError) { |e| expect(e.class_name).to eq("Object") } - end + expect(browser.evaluate("new Object")).to eq({}) + expect(browser.evaluate("new Date(2012, 0).toDateString()")).to eq("Sun Jan 01 2012") + expect(browser.evaluate("new Object({a: 1})")).to eq({ "a" => 1 }) + expect(browser.evaluate("new Array")).to eq([]) + expect(browser.evaluate("new Function")).to eq({}) - it "returns an Error object instead of raising" do - expect(browser.evaluate(%(new Error("smth")))).to eq({}) - end + expect do + browser.evaluate(%(throw "smth")) + end.to raise_error(Ferrum::JavaScriptError) + end - it "keeps null and undefined in arrays" do - expect(browser.evaluate("[1, null, undefined, 3]")).to eq([1, nil, nil, 3]) - end + it "raises when a string is thrown" do + expect do + browser.evaluate(%((() => { throw "smth" })())) + end.to raise_error(Ferrum::JavaScriptError, "Uncaught smth") + end - it "returns numbers JSON can't represent" do - expect(browser.evaluate("NaN")).to be_nan - expect(browser.evaluate("1 / 0")).to eq(Float::INFINITY) - expect(browser.evaluate("-1 / 0")).to eq(-Float::INFINITY) - expect(browser.evaluate("-0").to_s).to eq("-0.0") - expect(browser.evaluate("{ big: 2n ** 64n }")).to eq("big" => 2**64) - end + it "raises when an object is thrown" do + expect do + browser.evaluate("(() => { throw { code: 1 } })()") + end.to raise_error(Ferrum::JavaScriptError) { |e| expect(e.class_name).to eq("Object") } + end - it "returns dates as ISO strings" do - expect(browser.evaluate("[new Date(Date.UTC(2012, 0))]")).to eq(["2012-01-01T00:00:00.000Z"]) - end + it "returns an Error object instead of raising" do + expect(browser.evaluate(%(new Error("smth")))).to eq({}) + end - it "returns values of getters" do - expect(browser.evaluate("{ get a() { return 1 } }")).to eq("a" => 1) - end + it "keeps null and undefined in arrays" do + expect(browser.evaluate("[1, null, undefined, 3]")).to eq([1, nil, nil, 3]) + end - it "returns objects without a Ruby equivalent as empty hashes" do - expect(browser.evaluate("[new Map([[1, 2]]), new Set([1]), /a/, Symbol('a')]")).to eq([{}, {}, {}, nil]) - end + it "returns numbers JSON can't represent" do + expect(browser.evaluate("NaN")).to be_nan + expect(browser.evaluate("1 / 0")).to eq(Float::INFINITY) + expect(browser.evaluate("-1 / 0")).to eq(-Float::INFINITY) + expect(browser.evaluate("-0").to_s).to eq("-0.0") + expect(browser.evaluate("{ big: 2n ** 64n }")).to eq("big" => 2**64) + end + + it "returns dates as ISO strings" do + expect(browser.evaluate("[new Date(Date.UTC(2012, 0))]")).to eq(["2012-01-01T00:00:00.000Z"]) + end - it "converts an object shared by many references once" do - result = browser.evaluate("() => { let x = { v: 1 }; for (let i = 0; i < 20; i++) x = { l: x, r: x }; return x }") + it "returns values of getters" do + expect(browser.evaluate("{ get a() { return 1 } }")).to eq("a" => 1) + end + + it "returns objects without a Ruby equivalent as empty hashes" do + expect(browser.evaluate("[new Map([[1, 2]]), new Set([1]), /a/, Symbol('a')]")).to eq([{}, {}, {}, nil]) + end - expect(result["l"]).to equal(result["r"]) - expect(result.dig(*["l"] * 20)).to eq("v" => 1) + it "converts an object shared by many references once" do + result = browser.evaluate("() => { let x = { v: 1 }; for (let i = 0; i < 20; i++) x = { l: x, r: x }; return x }") + + expect(result["l"]).to equal(result["r"]) + expect(result.dig(*["l"] * 20)).to eq("v" => 1) + end end - context "when cyclic structure is returned" do + context "with a cyclic structure" do let(:cyclic) { Ferrum::CyclicObject.instance } - context "ignores seen" do - let(:code) do - <<~JS - (function() { - var a = {}; - var b = {}; - var c = {}; - c.a = a; - a.a = a; - a.b = b; - a.c = c; - return %s; - })() - JS - end + let(:code) do + <<~JS + (function() { + var a = {}; + var b = {}; + var c = {}; + c.a = a; + a.a = a; + a.b = b; + a.c = c; + return %s; + })() + JS + end - it "returns object" do - expect(browser.evaluate(code % "a")).to eq("a" => cyclic, "b" => {}, "c" => { "a" => cyclic }) - end + it "marks seen objects in a returned object" do + expect(browser.evaluate(code % "a")).to eq("a" => cyclic, "b" => {}, "c" => { "a" => cyclic }) + end - it "returns array" do - expect(browser.evaluate(code % "[a]")).to eq([{ "a" => cyclic, "b" => {}, "c" => { "a" => cyclic } }]) - end + it "marks seen objects in a returned array" do + expect(browser.evaluate(code % "[a]")).to eq([{ "a" => cyclic, "b" => {}, "c" => { "a" => cyclic } }]) end it "backtracks what it has seen" do @@ -280,298 +280,298 @@ expect(browser.page).to have_received(:command).once end end - end - describe "#evaluate with a positional function declaration" do - let(:function) do - <<~JS - function(c) { - let a = 1; - let b = 2; - return a + b + c; - } - JS - end + context "with a positional function declaration" do + let(:function) do + <<~JS + function(c) { + let a = 1; + let b = 2; + return a + b + c; + } + JS + end - it "evaluates multiple lines of javascript function" do - expect(browser.evaluate(function, 3)).to eq(6) - end + it "evaluates multiple lines of javascript function" do + expect(browser.evaluate(function, 3)).to eq(6) + end - it "evaluates a function on a node" do - browser.go_to("/index") - node = browser.at_xpath(".//a") + it "evaluates a function on a node" do + browser.go_to("/index") + node = browser.at_xpath(".//a") - function = <<~JS - function(attributeName) { - return this.getAttribute(attributeName); - } - JS + function = <<~JS + function(attributeName) { + return this.getAttribute(attributeName); + } + JS - expect(node.evaluate(function, "href")).to eq("js_redirect") + expect(node.evaluate(function, "href")).to eq("js_redirect") + end end - end - describe "on a node of the previous page" do - it "does not retry if node is not around anymore" do - stub_const("Ferrum::Frame::Runtime::INTERMITTENT_SLEEP", 5) + context "with a node of the previous page" do + it "does not retry if node is not around anymore" do + stub_const("Ferrum::Frame::Runtime::INTERMITTENT_SLEEP", 5) - browser.go_to("/table") - node = browser.at_xpath(".//td") + browser.go_to("/table") + node = browser.at_xpath(".//td") - browser.go_to("/table") + browser.go_to("/table") - started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC) - expect { node.evaluate("this.textContent") }.to raise_error(Ferrum::NodeNotFoundError) - elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at + started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC) + expect { node.evaluate("this.textContent") }.to raise_error(Ferrum::NodeNotFoundError) + elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at - expect(elapsed).to be < Ferrum::Frame::Runtime::INTERMITTENT_SLEEP - end + expect(elapsed).to be < Ferrum::Frame::Runtime::INTERMITTENT_SLEEP + end - it "raises NodeNotFoundError for a node resolved on the previous page" do - browser.go_to("/table") - node = browser.at_xpath(".//td") - object_id = browser.page.command("DOM.resolveNode", nodeId: node.node_id).dig("object", "objectId") + it "raises NodeNotFoundError for a node resolved on the previous page" do + browser.go_to("/table") + node = browser.at_xpath(".//td") + object_id = browser.page.command("DOM.resolveNode", nodeId: node.node_id).dig("object", "objectId") - browser.go_to("/table") + browser.go_to("/table") - expect do - browser.evaluate("arguments[0].textContent", { "objectId" => object_id }) - end.to raise_error(Ferrum::NodeNotFoundError, /same JavaScript world/) + expect do + browser.evaluate("arguments[0].textContent", { "objectId" => object_id }) + end.to raise_error(Ferrum::NodeNotFoundError, /same JavaScript world/) + end end - end - describe "#evaluate with named arguments" do - it "names arguments as function parameters" do - expect(browser.evaluate("a + b", a: 1, b: 2)).to eq(3) - end + context "with named arguments" do + it "names arguments as function parameters" do + expect(browser.evaluate("a + b", a: 1, b: 2)).to eq(3) + end - it "sends each argument separately so nodes stay live" do - browser.go_to("/index") - node = browser.at_xpath(".//a") + it "sends each argument separately so nodes stay live" do + browser.go_to("/index") + node = browser.at_xpath(".//a") - expect(browser.evaluate("el.getAttribute(name)", el: node, name: "href")).to eq("js_redirect") - end + expect(browser.evaluate("el.getAttribute(name)", el: node, name: "href")).to eq("js_redirect") + end - it "passes positional arguments to a function declaration" do - expect(browser.evaluate("function(a, b) { return a - b }", 5, 1)).to eq(4) - expect(browser.evaluate("(a, b) => a * b", 3, 4)).to eq(12) - expect(browser.evaluate("function({ x }, y = 0) { return x - y }", { x: 5 }, 1)).to eq(4) - end + it "passes positional arguments to a function declaration" do + expect(browser.evaluate("function(a, b) { return a - b }", 5, 1)).to eq(4) + expect(browser.evaluate("(a, b) => a * b", 3, 4)).to eq(12) + expect(browser.evaluate("function({ x }, y = 0) { return x - y }", { x: 5 }, 1)).to eq(4) + end - it "raises for named arguments to a function declaration" do - expect { browser.evaluate("function(a, b) { return a - b }", a: 5, b: 1) } - .to raise_error(ArgumentError, /A function declaration names its own parameters/) - end + it "raises for named arguments to a function declaration" do + expect { browser.evaluate("function(a, b) { return a - b }", a: 5, b: 1) } + .to raise_error(ArgumentError, /A function declaration names its own parameters/) + end - it "raises for a node inside an array or hash" do - browser.go_to("/index") - link = browser.at_xpath(".//a") + it "raises for a node inside an array or hash" do + browser.go_to("/index") + link = browser.at_xpath(".//a") - expect { browser.evaluate("links.length", links: [link]) } - .to raise_error(ArgumentError, /inside an Array or Hash can't be passed/) - expect { browser.evaluate("o.links", o: { links: [link] }) } - .to raise_error(ArgumentError, /inside an Array or Hash can't be passed/) - end + expect { browser.evaluate("links.length", links: [link]) } + .to raise_error(ArgumentError, /inside an Array or Hash can't be passed/) + expect { browser.evaluate("o.links", o: { links: [link] }) } + .to raise_error(ArgumentError, /inside an Array or Hash can't be passed/) + end - it "passes nil, hashes and arrays by value" do - expect(browser.evaluate("value", value: nil)).to be_nil - expect(browser.evaluate("value.foo", value: { foo: "bar" })).to eq("bar") - expect(browser.evaluate("value.length", value: [1, 2, 3])).to eq(3) - end + it "passes nil, hashes and arrays by value" do + expect(browser.evaluate("value", value: nil)).to be_nil + expect(browser.evaluate("value.foo", value: { foo: "bar" })).to eq("bar") + expect(browser.evaluate("value.length", value: [1, 2, 3])).to eq(3) + end - it "raises a SyntaxError for a reserved word as a name" do - expect { browser.evaluate("1", class: 1) }.to raise_error(Ferrum::JavaScriptError, /SyntaxError/) - end + it "raises a SyntaxError for a reserved word as a name" do + expect { browser.evaluate("1", class: 1) }.to raise_error(Ferrum::JavaScriptError, /SyntaxError/) + end - it "passes positional arguments through arguments" do - expect(browser.evaluate("arguments[0] + arguments[1]", 1, 2)).to eq(3) + it "passes positional arguments through arguments" do + expect(browser.evaluate("arguments[0] + arguments[1]", 1, 2)).to eq(3) + end end - end - describe "#evaluate with a function declaration" do - it "evaluates multiple statements" do - expect(browser.evaluate(<<~JS, 3)).to eq(6) - function(c) { - let a = 1; - let b = 2; - return a + b + c; - } - JS - end + context "with a function declaration" do + it "evaluates multiple statements" do + expect(browser.evaluate(<<~JS, 3)).to eq(6) + function(c) { + let a = 1; + let b = 2; + return a + b + c; + } + JS + end - it "evaluates a named function declaration" do - expect(browser.evaluate("function sum(a, b) { return a + b }", 1, 2)).to eq(3) - end + it "evaluates a named function declaration" do + expect(browser.evaluate("function sum(a, b) { return a + b }", 1, 2)).to eq(3) + end - it "treats an immediately invoked function as an expression, not a declaration" do - expect(browser.evaluate(<<~JS)).to eq(3) - function() { - let a = 1; - return a + 2; - }(); - JS - expect(browser.evaluate("(function() { return 3 })()")).to eq(3) - expect(browser.evaluate("(() => 3)()")).to eq(3) - end + it "treats an immediately invoked function as an expression, not a declaration" do + expect(browser.evaluate(<<~JS)).to eq(3) + function() { + let a = 1; + return a + 2; + }(); + JS + expect(browser.evaluate("(function() { return 3 })()")).to eq(3) + expect(browser.evaluate("(() => 3)()")).to eq(3) + end - it "accepts arrow functions with default and destructured parameters" do - expect(browser.evaluate("(a = 1) => a + 41")).to eq(42) - expect(browser.evaluate("({ x }) => x", { x: 1 })).to eq(1) - end + it "accepts arrow functions with default and destructured parameters" do + expect(browser.evaluate("(a = 1) => a + 41")).to eq(42) + expect(browser.evaluate("({ x }) => x", { x: 1 })).to eq(1) + end - it "reports errors on the line of the script they're on" do - expect { browser.evaluate("a +\nmissing", a: 1) } - .to raise_error(Ferrum::JavaScriptError, /missing is not defined\n\s+at :2:/) - end + it "reports errors on the line of the script they're on" do + expect { browser.evaluate("a +\nmissing", a: 1) } + .to raise_error(Ferrum::JavaScriptError, /missing is not defined\n\s+at :2:/) + end - it "includes the compiled function when a script doesn't compile" do - expect { browser.evaluate("a +", a: 1) } - .to raise_error(Ferrum::JavaScriptError, /Unexpected token '\)'\n in: function\(a\) \{ return \(a \+\n\);/) - end + it "includes the compiled function when a script doesn't compile" do + expect { browser.evaluate("a +", a: 1) } + .to raise_error(Ferrum::JavaScriptError, /Unexpected token '\)'\n in: function\(a\) \{ return \(a \+\n\);/) + end - it "includes a thrown value that isn't an Error in the message" do - expect { browser.evaluate("function() { throw 'boom' }") } - .to raise_error(Ferrum::JavaScriptError, "Uncaught boom") - end + it "includes a thrown value that isn't an Error in the message" do + expect { browser.evaluate("function() { throw 'boom' }") } + .to raise_error(Ferrum::JavaScriptError, "Uncaught boom") + end - it "ignores trailing comments and semicolons when detecting a declaration" do - expect(browser.evaluate("function(a) { return a } // adds nothing", 1)).to eq(1) - expect(browser.evaluate("function(a) { return a };", 1)).to eq(1) - end + it "ignores trailing comments and semicolons when detecting a declaration" do + expect(browser.evaluate("function(a) { return a } // adds nothing", 1)).to eq(1) + expect(browser.evaluate("function(a) { return a };", 1)).to eq(1) + end - it "keeps a // inside strings and regular expressions on the last line" do - expect(browser.evaluate("'http://example.com'")).to eq("http://example.com") - expect(browser.evaluate("function() { return 'http://example.com' }")).to eq("http://example.com") - expect(browser.evaluate("'a//b'.replace(/\\/\\//, '-')")).to eq("a-b") - expect(browser.evaluate("`${'http'}://example.com`")).to eq("http://example.com") - end + it "keeps a // inside strings and regular expressions on the last line" do + expect(browser.evaluate("'http://example.com'")).to eq("http://example.com") + expect(browser.evaluate("function() { return 'http://example.com' }")).to eq("http://example.com") + expect(browser.evaluate("'a//b'.replace(/\\/\\//, '-')")).to eq("a-b") + expect(browser.evaluate("`${'http'}://example.com`")).to eq("http://example.com") + end - it "calls a declaration in parentheses" do - expect(browser.evaluate("(function(v) { return v * 2 })", 21)).to eq(42) - expect(browser.evaluate("((a, b) => a + b)", 1, 2)).to eq(3) - end + it "calls a declaration in parentheses" do + expect(browser.evaluate("(function(v) { return v * 2 })", 21)).to eq(42) + expect(browser.evaluate("((a, b) => a + b)", 1, 2)).to eq(3) + end - it "ignores comments before a declaration" do - expect(browser.evaluate("// doubles\nfunction(a) { return a * 2 }", 2)).to eq(4) - end + it "ignores comments before a declaration" do + expect(browser.evaluate("// doubles\nfunction(a) { return a * 2 }", 2)).to eq(4) + end - it "reads scripts with non-ASCII text" do - expect(browser.evaluate("/* café */ a + 1 // ü", a: 1)).to eq(2) - expect(browser.evaluate("// 日本語\nfunction(名前) { return 名前 + '!' }", "é")).to eq("é!") - end + it "reads scripts with non-ASCII text" do + expect(browser.evaluate("/* café */ a + 1 // ü", a: 1)).to eq(2) + expect(browser.evaluate("// 日本語\nfunction(名前) { return 名前 + '!' }", "é")).to eq("é!") + end - it "does not call a function that is merely the result of an expression" do - expect(browser.evaluate("new Function")).to eq({}) - expect(browser.evaluate("window.setTimeout")).to eq({}) + it "does not call a function that is merely the result of an expression" do + expect(browser.evaluate("new Function")).to eq({}) + expect(browser.evaluate("window.setTimeout")).to eq({}) + end end - end - describe "#evaluate with promises" do - it "awaits an expression" do - expect(browser.evaluate("await Promise.resolve(42)")).to eq(42) - end + context "with a promise" do + it "awaits an expression" do + expect(browser.evaluate("await Promise.resolve(42)")).to eq(42) + end - it "awaits a promise returned by a function declaration" do - expect(browser.evaluate("function() { return new Promise(resolve => setTimeout(() => resolve(42), 100)) }")) - .to eq(42) - end + it "awaits a promise returned by a function declaration" do + expect(browser.evaluate("function() { return new Promise(resolve => setTimeout(() => resolve(42), 100)) }")) + .to eq(42) + end - it "awaits an async function declaration" do - expect(browser.evaluate("async function(a) { return await Promise.resolve(a) }", 7)).to eq(7) - end + it "awaits an async function declaration" do + expect(browser.evaluate("async function(a) { return await Promise.resolve(a) }", 7)).to eq(7) + end - it "propagates a rejection" do - expect { browser.evaluate(%(await Promise.reject(new Error("nope")))) } - .to raise_error(Ferrum::JavaScriptError, /nope/) - end + it "propagates a rejection" do + expect { browser.evaluate(%(await Promise.reject(new Error("nope")))) } + .to raise_error(Ferrum::JavaScriptError, /nope/) + end - it "doesn't run the script again when the page navigates while awaiting" do - browser.go_to("/index") - browser.execute("sessionStorage.runs = 0") + it "doesn't run the script again when the page navigates while awaiting" do + browser.go_to("/index") + browser.execute("sessionStorage.runs = 0") - expect do - browser.execute(<<~JS) - sessionStorage.runs++; - setTimeout(() => location.reload(), 50); - await new Promise(() => {}); - JS - end.to raise_error(Ferrum::NodeNotFoundError) - expect(browser.evaluate("sessionStorage.runs")).to eq("1") - end + expect do + browser.execute(<<~JS) + sessionStorage.runs++; + setTimeout(() => location.reload(), 50); + await new Promise(() => {}); + JS + end.to raise_error(Ferrum::NodeNotFoundError) + expect(browser.evaluate("sessionStorage.runs")).to eq("1") + end - it "times out" do - expect { browser.evaluate("new Promise(() => {})", timeout: 1) } - .to raise_error(Ferrum::ScriptTimeoutError) - end + it "times out" do + expect { browser.evaluate("new Promise(() => {})", timeout: 1) } + .to raise_error(Ferrum::ScriptTimeoutError) + end - it "waits past the page timeout when given a longer one" do - with_timeout(1) do - expect(browser.evaluate("new Promise(resolve => setTimeout(() => resolve(1), 1500))", timeout: 3)).to eq(1) + it "waits past the page timeout when given a longer one" do + with_timeout(1) do + expect(browser.evaluate("new Promise(resolve => setTimeout(() => resolve(1), 1500))", timeout: 3)).to eq(1) + end end - end - it "raises a script timeout that is also a Ferrum::TimeoutError" do - expect { browser.evaluate("new Promise(() => {})", timeout: 1) }.to raise_error(Ferrum::TimeoutError) - end + it "raises a script timeout that is also a Ferrum::TimeoutError" do + expect { browser.evaluate("new Promise(() => {})", timeout: 1) }.to raise_error(Ferrum::TimeoutError) + end - it "times out when the page replaces setTimeout" do - browser.execute("window.setTimeout = () => 0") + it "times out when the page replaces setTimeout" do + browser.execute("window.setTimeout = () => 0") - expect { browser.evaluate("new Promise(() => {})", timeout: 1) }.to raise_error(Ferrum::ScriptTimeoutError) - end + expect { browser.evaluate("new Promise(() => {})", timeout: 1) }.to raise_error(Ferrum::ScriptTimeoutError) + end - it "keeps working when the page replaces Promise" do - browser.execute("window.Promise = undefined") + it "keeps working when the page replaces Promise" do + browser.execute("window.Promise = undefined") - expect(browser.evaluate("a + 1", a: 1)).to eq(2) - end + expect(browser.evaluate("a + 1", a: 1)).to eq(2) + end - it "honours a timeout longer than the page timeout" do - script = "new Promise(resolve => setTimeout(() => resolve(1), 100))" - expect(browser.evaluate(script, timeout: browser.timeout + 5)).to eq(1) + it "honours a timeout longer than the page timeout" do + script = "new Promise(resolve => setTimeout(() => resolve(1), 100))" + expect(browser.evaluate(script, timeout: browser.timeout + 5)).to eq(1) + end end - end - describe "scripts written for the old API" do - it "awaits a returned promise and raises when it rejects" do - expect(browser.evaluate("new Promise(resolve => setTimeout(() => resolve(42), 100))")).to eq(42) - expect { browser.evaluate("Promise.reject(new Error('nope'))") }.to raise_error(Ferrum::JavaScriptError, /nope/) - end + context "with a script written for the old API" do + it "awaits a returned promise and raises when it rejects" do + expect(browser.evaluate("new Promise(resolve => setTimeout(() => resolve(42), 100))")).to eq(42) + expect { browser.evaluate("Promise.reject(new Error('nope'))") }.to raise_error(Ferrum::JavaScriptError, /nope/) + end - it "awaits a returned object with a then method" do - expect(browser.evaluate("({ then(resolve) { resolve(42) } })")).to eq(42) - end + it "awaits a returned object with a then method" do + expect(browser.evaluate("({ then(resolve) { resolve(42) } })")).to eq(42) + end - it "makes keywords parameters even when the script reads arguments" do - expect(browser.evaluate("[foo, arguments[0]]", foo: 1)).to eq([1, 1]) - end + it "makes keywords parameters even when the script reads arguments" do + expect(browser.evaluate("[foo, arguments[0]]", foo: 1)).to eq([1, 1]) + end - it "rejects mixing positional and named arguments" do - expect { browser.evaluate("a", 1, a: 2) }.to raise_error(ArgumentError, /either positionally or by name/) - end + it "rejects mixing positional and named arguments" do + expect { browser.evaluate("a", 1, a: 2) }.to raise_error(ArgumentError, /either positionally or by name/) + end - it "returns the value of the last of several statements" do - expect(browser.evaluate("1; 2")).to eq(2) - expect(browser.evaluate("document.title = 'x'\ndocument.title")).to eq("x") - expect(browser.evaluate("const a = 1")).to be_nil - end + it "returns the value of the last of several statements" do + expect(browser.evaluate("1; 2")).to eq(2) + expect(browser.evaluate("document.title = 'x'\ndocument.title")).to eq("x") + expect(browser.evaluate("const a = 1")).to be_nil + end - it "runs a script after a leading line break" do - expect(browser.evaluate("\n 1 + 1\n")).to eq(2) - end + it "runs a script after a leading line break" do + expect(browser.evaluate("\n 1 + 1\n")).to eq(2) + end - it "waits for a promise returned to #execute" do - browser.execute("return new Promise(resolve => setTimeout(() => { window.done = true; resolve() }, 100))") + it "waits for a promise returned to #execute" do + browser.execute("return new Promise(resolve => setTimeout(() => { window.done = true; resolve() }, 100))") - expect(browser.evaluate("window.done")).to be(true) - end + expect(browser.evaluate("window.done")).to be(true) + end - it "resolves nodes and dates from Node#evaluate" do - browser.go_to("/index") - node = browser.at_xpath(".//a") + it "resolves nodes and dates from Node#evaluate" do + browser.go_to("/index") + node = browser.at_xpath(".//a") - expect(node.evaluate("this.parentNode")).to be_a(Ferrum::Node) - expect(node.evaluate("new Date(0)")).to be_a(String) + expect(node.evaluate("this.parentNode")).to be_a(Ferrum::Node) + expect(node.evaluate("new Date(0)")).to be_a(String) + end end end @@ -600,103 +600,4 @@ expect(frame.evaluate_handle("({ title: document.title })").value).to eq("title" => "This is the child frame title") end end - - describe "Ferrum::Node#evaluate" do - before { browser.go_to("/index") } - - it "binds this to the node" do - expect(browser.at_xpath(".//a").evaluate("this.getAttribute('href')")).to eq("js_redirect") - end - - it "accepts named arguments" do - expect(browser.at_xpath(".//a").evaluate("this.getAttribute(name)", name: "href")).to eq("js_redirect") - end - - it "resolves nodes returned by a function declaration" do - expect(browser.at_xpath(".//a").evaluate("function() { return this.parentNode }")).to be_a(Ferrum::Node) - end - - it "calls a named function with #execute" do - browser.execute("function setup() { window.ran = true }") - - expect(browser.evaluate("window.ran")).to be(true) - end - - it "runs statements with #execute" do - node = browser.at_xpath(".//a") - node.execute("this.dataset.touched = value", value: "yes") - - expect(node.evaluate("this.dataset.touched")).to eq("yes") - end - end - - describe "#add_script_tag" do - it "adds by url" do - browser.go_to - expect do - browser.evaluate("$('a').first().text()") - end.to raise_error(Ferrum::JavaScriptError) - - browser.add_script_tag(url: "/jquery.min.js") - - expect(browser.evaluate("$('a').first().text()")).to eq("Relative") - end - - it "adds by path" do - browser.go_to - path = "#{Ferrum::Application::FERRUM_PUBLIC}/jquery-3.7.1.min.js" - expect do - browser.evaluate("$('a').first().text()") - end.to raise_error(Ferrum::JavaScriptError) - - browser.add_script_tag(path: path) - - expect(browser.evaluate("$('a').first().text()")).to eq("Relative") - end - - it "adds by content" do - browser.go_to - - browser.add_script_tag(content: "function yay() { return 'yay!'; }") - - expect(browser.evaluate("yay()")).to eq("yay!") - end - end - - describe "#add_style_tag" do - let(:font_size) do - <<~JS - window - .getComputedStyle(document.querySelector('a')) - .getPropertyValue('font-size') - JS - end - - it "adds by url" do - browser.go_to - expect(browser.evaluate(font_size)).to eq("16px") - - browser.add_style_tag(url: "/add_style_tag.css") - - expect(browser.evaluate(font_size)).to eq("50px") - end - - it "adds by path" do - browser.go_to - path = "#{Ferrum::Application::FERRUM_PUBLIC}/add_style_tag.css" - expect(browser.evaluate(font_size)).to eq("16px") - - browser.add_style_tag(path: path) - - expect(browser.evaluate(font_size)).to eq("50px") - end - - it "adds by content" do - browser.go_to - - browser.add_style_tag(content: "a { font-size: 20px; }") - - expect(browser.evaluate(font_size)).to eq("20px") - end - end end diff --git a/spec/frame_spec.rb b/spec/frame_spec.rb index 8dda8a36..97855cd0 100644 --- a/spec/frame_spec.rb +++ b/spec/frame_spec.rb @@ -1,523 +1,88 @@ # frozen_string_literal: true describe Ferrum::Frame do - describe "#at_xpath" do - it "works correctly when JSON is overwritten" do - page.go_to("/index") - page.execute("JSON = {};") - expect { page.at_xpath("//a[text() = 'JS redirect']") }.not_to raise_error - end - end - - it "supports selection by index" do - page.go_to("/frames") - frame = page.at_xpath("//iframe").frame - expect(frame.url).to end_with("/slow") - end - - it "supports selection by element" do - page.go_to("/frames") - frame = page.at_css("iframe[name]").frame - expect(frame.url).to end_with("/slow") - end - - it "supports selection by element without name or id" do - page.go_to("/frames") - frame = page.at_css("iframe:not([name]):not([id])").frame - expect(frame.url).to end_with("/headers") - end - - it "supports selection by element with id but no name" do - page.go_to("/frames") - frame = page.at_css("iframe[id]:not([name])").frame - expect(frame.url).to end_with("/get_cookie") - end - - it "finds main frame properly" do - browser.go_to("/popup_frames") - - browser.at_xpath("//a[text()='pop up']").click - - expect(browser.pages.size).to eq(2) - opened_page = browser.pages.last - expect(opened_page.main_frame.url).to end_with("/frames") - end - - it "finds parent frame properly" do - page.go_to("/frames") - parent_frame = page.at_xpath("//iframe").frame.parent - expect(parent_frame.url).to end_with("/frames") - end - - it "waits for the frame to load" do - page.go_to - page.execute <<-JS - document.body.innerHTML += "