diff --git a/AGENTS.md b/AGENTS.md index 4c6d156e..bcff4559 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -54,11 +54,11 @@ The suite boots a real Chrome and a Sinatra/Puma test app (`spec/support/applica 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. +- 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 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 exception is a context + that carries `let`, hooks (`before`/`after`/`around`) or tags its examples share; it may sit next to plain + examples. Never nest contexts. - 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 diff --git a/spec/console/message_spec.rb b/spec/console/message_spec.rb new file mode 100644 index 00000000..d85fa4d7 --- /dev/null +++ b/spec/console/message_spec.rb @@ -0,0 +1,160 @@ +# frozen_string_literal: true + +describe Ferrum::Console::Message do + def log(script) + page.go_to + page.execute(script) + wait_for { page.console.size }.to eq(1) + page.console.last + end + + def throw_error + log(%(setTimeout(function thrower() { throw new Error("boom") }))) + end + + def fail_to_load_image + log(%(document.body.appendChild(document.createElement("img")).src = "/unexist.png")) + end + + def log_nested + log(<<~JS) + function inner() { console.log("nested") } + function outer() { inner() } + outer() + JS + end + + describe "#text" do + it "joins console arguments with spaces" do + expect(log(%(console.log("a", 1, true))).text).to eq("a 1 true") + end + + it "renders objects and arrays as JSON" do + expect(log(%(console.log({ a: 1 }, [1, "b"]))).text).to eq(%({"a":1} [1,"b"])) + end + + it "returns the exception text for an uncaught exception" do + expect(throw_error.text).to start_with("Uncaught Error: boom") + end + + it "returns the entry text for a browser log entry" do + expect(fail_to_load_image.text).to include("404") + end + end + + describe "#args" do + it "returns special numbers as floats" do + expect(log(%(console.log(Infinity, -Infinity, -0))).args).to eq([Float::INFINITY, -Float::INFINITY, -0.0]) + end + + it "converts the properties of an object preview" do + message = log(%(console.log({ n: 1.5, t: true, f: false }, { u: undefined, z: null, big: 10n, inf: Infinity }))) + + expect(message.args).to eq( + [{ "n" => 1.5, "t" => true, "f" => false }, { "u" => nil, "z" => nil, "big" => 10, "inf" => Float::INFINITY }] + ) + end + + it "returns the description of a DOM node" do + expect(log(%(console.log(document.body))).args).to eq(["body"]) + end + + it "returns an empty array for an uncaught exception" do + expect(throw_error.args).to eq([]) + end + end + + describe "#stack_trace" do + it "lists the frames innermost first" do + expect(log_nested.stack_trace.map(&:function_name).first(2)).to eq(%w[inner outer]) + end + + it "returns the frame of the thrown exception" do + expect(throw_error.stack_trace.first.function_name).to eq("thrower") + end + end + + describe "#location" do + it "returns the innermost frame" do + message = log_nested + + expect(message.location.function_name).to eq("inner") + expect(message.location.line).to eq(1) + expect(message.location.column).to be_positive + end + + it "returns the url of a resource that failed to load" do + expect(fail_to_load_image.location.to_s).to eq(base_url("/unexist.png")) + end + end + + describe "#severity" do + it "returns :error for console.error" do + expect(log(%(console.error("x"))).severity).to eq(:error) + end + + it "returns :error for a failed console.assert" do + expect(log(%(console.assert(false, "x"))).severity).to eq(:error) + end + + it "returns :warn for console.warn" do + expect(log(%(console.warn("x"))).severity).to eq(:warn) + end + + it "returns :debug for console.debug" do + expect(log(%(console.debug("x"))).severity).to eq(:debug) + end + + it "returns :info for console.log" do + expect(log(%(console.log("x"))).severity).to eq(:info) + end + end + + describe "#error?" do + it "returns true for console.error" do + expect(log(%(console.error("x")))).to be_error + end + + it "returns true for an uncaught exception" do + expect(throw_error).to be_error + end + + it "returns false for console.warn" do + expect(log(%(console.warn("x")))).not_to be_error + end + end + + describe "#warning?" do + it "returns true for console.warn" do + expect(log(%(console.warn("x")))).to be_warning + end + + it "returns false for console.error" do + expect(log(%(console.error("x")))).not_to be_warning + end + end + + describe "#match?" do + it "matches a regexp against the text" do + expect(log(%(console.log("Hello world")))).to be_match(/hello W/i) + end + + it "checks whether the text includes a string" do + expect(log(%(console.log("Hello world")))).to be_match("lo wo") + end + + it "treats a string pattern literally" do + expect(log(%(console.log("Hello world")))).not_to be_match("Hello.world") + end + end + + describe "#to_s" do + it "prints the type and text followed by a line per frame" do + message = log_nested + + expect(message.to_s.lines.first(3)).to match( + ["[log] nested\n", /\A at inner \(.*:1:\d+\)\n\z/, /\A at outer \(.*:2:\d+\)\n\z/] + ) + end + end +end diff --git a/spec/console_spec.rb b/spec/console_spec.rb index 8e7eac2c..a610088f 100644 --- a/spec/console_spec.rb +++ b/spec/console_spec.rb @@ -80,6 +80,75 @@ expect(console.size).to eq(described_class::LIMIT) expect(console.first.text).to eq("5") end + + it "returns an enumerator when no block is given" do + page.go_to + page.execute(%(console.log("a"); console.log("b"))) + + wait_for { console.size }.to eq(2) + enumerator = console.each + expect(enumerator.size).to eq(2) + expect(enumerator.map(&:text)).to eq(%w[a b]) + end + end + + describe "#to_a" do + it "returns the collected messages oldest first" do + page.go_to + page.execute(%(console.log("a"); console.log("b"))) + + wait_for { console.to_a.map(&:text) }.to eq(%w[a b]) + end + + it "returns a copy that doesn't change the console" do + page.go_to("/console_log") + wait_for { console.size }.to eq(1) + + console.to_a.clear + + expect(console.size).to eq(1) + end + end + + describe "#last" do + it "returns the most recent message" do + page.go_to + page.execute(%(console.log("a"); console.log("b"))) + + wait_for { console.size }.to eq(2) + expect(console.last.text).to eq("b") + end + + it "returns nil when no message was collected" do + page.go_to + + expect(console.last).to be_nil + end + end + + describe "#size" do + it "returns the number of collected messages" do + page.go_to + expect(console.size).to eq(0) + + page.execute(%(console.log("a"); console.log("b"))) + + wait_for { console.size }.to eq(2) + end + end + + describe "#empty?" do + it "returns true when no message was collected" do + page.go_to + + expect(console).to be_empty + end + + it "returns false once a message is collected" do + page.go_to("/console_log") + + wait_for { console.empty? }.to be(false) + end end describe "#clear" do @@ -94,4 +163,39 @@ wait_for { console.map(&:text) }.to eq(["again"]) end end + + describe "#on" do + it "calls the block for every new message" do + texts = Concurrent::Array.new + console.on { |message| texts << message.text } + + page.go_to + page.execute(%(console.log("a"); console.log("b"))) + + wait_for { texts }.to eq(%w[a b]) + end + + it "returns a distinct id for every listener" do + first = console.on { nil } + second = console.on { nil } + + expect(first).not_to eq(second) + end + end + + describe "#off" do + it "stops calling the removed listener" do + removed = Concurrent::Array.new + kept = Concurrent::Array.new + id = console.on { |message| removed << message.text } + console.on { |message| kept << message.text } + + console.off(id) + page.go_to + page.execute(%(console.log("a"))) + + wait_for { kept }.to eq(["a"]) + expect(removed).to be_empty + end + end end diff --git a/spec/context_spec.rb b/spec/context_spec.rb index 3e47b2f9..b44a969b 100644 --- a/spec/context_spec.rb +++ b/spec/context_spec.rb @@ -1,80 +1,69 @@ # frozen_string_literal: true describe Ferrum::Context do - describe "#windows" do - it "waits for the window to load" do - browser.go_to - - browser.execute <<-JS - window.open("/slow", "popup") - JS - - popup, = browser.windows(:last) - expect(popup.body).to include("slow page") - popup.close + describe "#implicit?" do + it "returns false for a context created by Ferrum" do + expect(browser.contexts.create).not_to be_implicit end - it "opens a second window under the same name" do - browser.go_to + it "returns true for the context of the browser's startup window", :launch do + with_external_browser(incognito: false) do |url| + remote = Ferrum::Browser.new(url: url) - browser.execute <<-JS - window.open("/simple", "popup") - JS + expect(remote.contexts.default_context).to be_implicit + ensure + remote&.quit + end + end + end - popup, = browser.windows(:last) - expect(popup.body).to include("Test") - popup.close + describe "#default_target" do + it "creates a target when the context has none" do + context = browser.contexts.create - sleep 0.5 # https://github.com/ChromeDevTools/devtools-protocol/issues/145 + target = context.default_target - browser.execute <<-JS - window.open("/simple", "popup") - JS + expect(context.targets.values).to eq([target]) + end - sleep 0.5 # https://github.com/ChromeDevTools/devtools-protocol/issues/145 + it "returns the first target of the context" do + context = browser.contexts.create + page = context.create_page + context.create_page - same, = browser.windows(:last) - expect(same.body).to include("Test") - same.close + expect(context.default_target.id).to eq(page.target_id) end end - describe "#pages" do - it "lists pages in the order they were created" do - pages = Array.new(10) { browser.create_page } + describe "#cookies" do + it "returns the cookies set by any page of the context" do + context = browser.contexts.create + context.create_page.go_to("/set_cookie") - expect(browser.pages.last(10).map(&:target_id)).to eq(pages.map(&:target_id)) - ensure - pages&.each(&:close) + expect(context.cookies.map(&:name)).to eq(["stealth"]) + expect(browser.contexts.create.cookies.to_a).to be_empty end end - describe "#attach_target" do - it "reuses the session of an already attached target", :launch do - browser = Ferrum::Browser.new(base_url: base_url, - extensions: [{ source: "window.runs = (window.runs || 0) + 1" }]) - page = browser.create_page - page.go_to - target = browser.targets[page.target_id] - session_id = target.session_id + describe "#page" do + it "connects to the page of the default target" do + context = browser.contexts.create - 2.times { expect(browser.attach_target(target.id)).to eq(target) } - page.refresh + page = context.page - expect(target.session_id).to eq(session_id) - expect(page.evaluate("window.runs")).to eq(1) - ensure - browser&.quit + expect(page.target_id).to eq(context.default_target.id) + expect(context.default_target).to be_connected + expect(context.page).to equal(page) end + end - it "connects to a service worker on demand, keeping it alive" do - page.go_to - page.evaluate(%(await navigator.serviceWorker.register("/sw.js")), timeout: 5) - - target = wait_for_target(&:service_worker?) - browser.attach_target(target.id) + describe "#pages" do + it "lists pages in the order they were created" do + pages = Array.new(10) { browser.create_page } - expect(target.worker.evaluate("1 + 1")).to eq(2) + expect(browser.pages.last(10).map(&:target_id)).to eq(pages.map(&:target_id)) + ensure + pages&.each(&:close) end end @@ -156,4 +145,138 @@ expect(target.session_id).to be_nil end end + + describe "#windows" do + it "waits for the window to load" do + browser.go_to + + browser.execute <<-JS + window.open("/slow", "popup") + JS + + popup, = browser.windows(:last) + expect(popup.body).to include("slow page") + popup.close + end + + it "opens a second window under the same name" do + browser.go_to + + browser.execute <<-JS + window.open("/simple", "popup") + JS + + popup, = browser.windows(:last) + expect(popup.body).to include("Test") + popup.close + + sleep 0.5 # https://github.com/ChromeDevTools/devtools-protocol/issues/145 + + browser.execute <<-JS + window.open("/simple", "popup") + JS + + sleep 0.5 # https://github.com/ChromeDevTools/devtools-protocol/issues/145 + + same, = browser.windows(:last) + expect(same.body).to include("Test") + same.close + end + end + + describe "#create_page" do + it "creates a connected page in the context" do + context = browser.contexts.create + + page = context.create_page + + expect(page.context_id).to eq(context.id) + expect(context.targets[page.target_id]).to be_connected + expect(context.pages).to eq([page]) + end + end + + describe "#attach_target" do + it "reuses the session of an already attached target", :launch do + browser = Ferrum::Browser.new(base_url: base_url, + extensions: [{ source: "window.runs = (window.runs || 0) + 1" }]) + page = browser.create_page + page.go_to + target = browser.targets[page.target_id] + session_id = target.session_id + + 2.times { expect(browser.attach_target(target.id)).to eq(target) } + page.refresh + + expect(target.session_id).to eq(session_id) + expect(page.evaluate("window.runs")).to eq(1) + ensure + browser&.quit + end + + it "connects to a service worker on demand, keeping it alive" do + page.go_to + page.evaluate(%(await navigator.serviceWorker.register("/sw.js")), timeout: 5) + + target = wait_for_target(&:service_worker?) + browser.attach_target(target.id) + + expect(target.worker.evaluate("1 + 1")).to eq(2) + end + end + + describe "#find_target" do + it "returns the first target the block matches" do + context = browser.contexts.create + page = context.create_page + + expect(context.find_target { |target| target.id == page.target_id }.id).to eq(page.target_id) + end + + it "returns nil when no target matches" do + context = browser.contexts.create + context.create_page + + expect(context.find_target { false }).to be_nil + end + end + + describe "#dispose" do + it "disposes the context and its targets" do + context = browser.contexts.create + page = context.create_page + + expect(context.dispose).to be(true) + expect(browser.contexts[context.id]).to be_nil + expect(browser.client.command("Target.getBrowserContexts")["browserContextIds"]).not_to include(context.id) + target_ids = browser.client.command("Target.getTargets")["targetInfos"].map { |info| info["targetId"] } + expect(target_ids).not_to include(page.target_id) + end + + it "leaves the context of the browser's startup window alone", :launch do + with_external_browser(incognito: false) do |url| + remote = Ferrum::Browser.new(url: url) + context = remote.contexts.default_context + + expect(context.dispose).to be_nil + expect(remote.contexts[context.id]).to eq(context) + ensure + remote&.quit + end + end + end + + describe "#target?" do + it "returns true for a target of the context" do + context = browser.contexts.create + + expect(context.target?(context.create_page.target_id)).to be(true) + end + + it "returns false for a target of another context" do + context = browser.contexts.create + + expect(context.target?(browser.contexts.create.create_page.target_id)).to be(false) + end + end end diff --git a/spec/contexts_spec.rb b/spec/contexts_spec.rb index 716e7251..2fe477d1 100644 --- a/spec/contexts_spec.rb +++ b/spec/contexts_spec.rb @@ -70,4 +70,75 @@ end end end + + describe "#each" do + it "yields the id and the context of every known context" do + context = browser.contexts.create + + expect(browser.contexts.each.to_a).to include([context.id, context]) + end + + it "returns an enumerator when no block is given" do + expect(browser.contexts.each).to be_a(Enumerator) + end + end + + describe "#[]" do + it "returns the context with the given id" do + context = browser.contexts.create + + expect(browser.contexts[context.id]).to eq(context) + end + + it "returns nil for an unknown id" do + expect(browser.contexts["unknown"]).to be_nil + end + end + + describe "#find_by" do + it "returns the context owning the given target" do + context = browser.contexts.create + page = context.create_page + + expect(browser.contexts.find_by(target_id: page.target_id)).to eq(context) + end + + it "returns nil for an unknown target" do + expect(browser.contexts.find_by(target_id: "unknown")).to be_nil + end + end + + describe "#create" do + it "creates a browser context" do + context = browser.contexts.create + + expect(context).not_to be_implicit + expect(browser.client.command("Target.getBrowserContexts")["browserContextIds"]).to include(context.id) + end + end + + describe "#dispose" do + it "disposes the context and forgets it" do + context = browser.contexts.create + context.create_page + + expect(browser.contexts.dispose(context.id)).to be(true) + expect(browser.contexts[context.id]).to be_nil + expect(browser.client.command("Target.getBrowserContexts")["browserContextIds"]).not_to include(context.id) + end + + it "returns nil for an unknown context" do + expect(browser.contexts.dispose("unknown")).to be_nil + end + end + + describe "#size" do + it "returns the number of known contexts" do + size = browser.contexts.size + + browser.contexts.create + + expect(browser.contexts.size).to eq(size + 1) + end + end end diff --git a/spec/cookies/cookie_spec.rb b/spec/cookies/cookie_spec.rb index 13684f86..02e0cedb 100644 --- a/spec/cookies/cookie_spec.rb +++ b/spec/cookies/cookie_spec.rb @@ -7,6 +7,7 @@ "value" => "-19027681:", "domain" => ".google.com", "path" => "/", + "sameSite" => "Lax", "expires" => 1_691_287_370, "size" => 13, "httpOnly" => false, @@ -45,6 +46,12 @@ end end + describe "#samesite" do + it "returns the 'sameSite' attribute" do + expect(subject.samesite).to eq(attributes["sameSite"]) + end + end + describe "#size" do it "returns the 'size' attribute" do expect(subject.size).to eq(attributes["size"]) diff --git a/spec/dialog_spec.rb b/spec/dialog_spec.rb index 6d53766d..5fdbcf2c 100644 --- a/spec/dialog_spec.rb +++ b/spec/dialog_spec.rb @@ -1,6 +1,36 @@ # frozen_string_literal: true describe Ferrum::Dialog do + def open_prompt(&block) + browser.on(:dialog) { |dialog, _index, _total| block.call(dialog) } + browser.go_to + browser.execute(%(setTimeout(() => window.result = prompt("Your name?", "Bob")))) + end + + describe "#message" do + it "returns the text of the dialog" do + messages = Concurrent::Array.new + open_prompt do |dialog| + messages << dialog.message + dialog.dismiss + end + + wait_for { messages }.to eq(["Your name?"]) + end + end + + describe "#default_prompt" do + it "returns the default text of a prompt" do + defaults = Concurrent::Array.new + open_prompt do |dialog| + defaults << dialog.default_prompt + dialog.dismiss + end + + wait_for { defaults }.to eq(["Bob"]) + end + end + describe "#accept" do it "works with nested modals" do browser.go_to("/with_js") @@ -34,6 +64,18 @@ popup.close end + + it "fills a prompt with the given text" do + open_prompt { |dialog| dialog.accept("Alice") } + + wait_for { browser.evaluate("window.result") }.to eq("Alice") + end + + it "fills a prompt with its default text when no text is given" do + open_prompt(&:accept) + + wait_for { browser.evaluate("window.result") }.to eq("Bob") + end end describe "#dismiss" do @@ -54,6 +96,13 @@ popup.close end + + it "cancels a prompt" do + open_prompt(&:dismiss) + + wait_for { browser.evaluate("'result' in window") }.to be(true) + expect(browser.evaluate("window.result")).to be_nil + end end describe "#match?" do diff --git a/spec/frame_spec.rb b/spec/frame_spec.rb index 97855cd0..b8270982 100644 --- a/spec/frame_spec.rb +++ b/spec/frame_spec.rb @@ -1,6 +1,36 @@ # frozen_string_literal: true describe Ferrum::Frame do + describe "#parent_id" do + it "returns the id of the parent frame" do + page.go_to("/frame_parent") + + expect(page.at_css("#childFrame").frame.parent_id).to eq(page.main_frame.id) + end + + it "returns nil for the main frame" do + page.go_to("/frame_parent") + + expect(page.main_frame.parent_id).to be_nil + end + end + + describe "#state" do + it "returns :stopped_loading once the frame has loaded" do + page.go_to("/simple") + + expect(page.main_frame.state).to eq(:stopped_loading) + end + + it "returns :started_loading while the frame loads" do + page.go_to("/simple") + + page.execute("location.href = '/really_slow'") + + wait_for { page.main_frame.state }.to eq(:started_loading) + end + end + describe "#url" do it "returns the url of a frame selected by index" do page.go_to("/frames") @@ -69,6 +99,45 @@ end end + describe "#title" do + it "returns the title of the frame's document" do + page.go_to("/frame_parent") + + expect(page.main_frame.title).to eq("This is the parent frame title") + expect(page.at_css("#childFrame").frame.title).to eq("This is the child frame title") + end + end + + describe "#main?" do + it "returns true for the main frame" do + page.go_to("/frame_parent") + + expect(page.main_frame.main?).to be(true) + end + + it "returns false for a nested frame" do + page.go_to("/frame_parent") + + expect(page.at_css("#childFrame").frame.main?).to be(false) + end + end + + describe "#idle?" do + it "returns true once the frame has loaded" do + page.go_to("/simple") + + expect(page.main_frame.idle?).to be(true) + end + + it "returns false while the frame loads" do + page.go_to("/simple") + + page.execute("location.href = '/really_slow'") + + wait_for { page.main_frame.idle? }.to be(false) + end + end + describe "#parent" do it "returns the parent frame" do page.go_to("/frames") @@ -85,4 +154,29 @@ expect(page.at_css("a").text).to eq("Link") end end + + describe "#isolated_world" do + it "returns the same world for the same name" do + page.go_to("/simple") + + expect(page.main_frame.isolated_world("ferrum")).to equal(page.main_frame.isolated_world("ferrum")) + end + + it "returns a separate world for another name" do + page.go_to("/simple") + page.main_frame.isolated_world("one").execute("window.secret = 'one'") + + expect(page.main_frame.isolated_world("two").evaluate("window.secret")).to be_nil + end + end + + describe "#isolated_worlds" do + it "returns the worlds used so far" do + page.go_to("/simple") + page.main_frame.isolated_world("one") + page.main_frame.isolated_world("two") + + expect(page.main_frame.isolated_worlds.map(&:name)).to include("one", "two") + end + end end diff --git a/spec/keyboard_spec.rb b/spec/keyboard_spec.rb index 0b24b0bb..27aafef1 100644 --- a/spec/keyboard_spec.rb +++ b/spec/keyboard_spec.rb @@ -33,6 +33,38 @@ end end + describe "#type" do + before { browser.go_to("/type") } + + it "types text into the focused element" do + input = browser.at_css("#empty_input").focus + + browser.keyboard.type("Hello") + + expect(input.value).to eq("Hello") + end + + it "types a key combination given as an array" do + input = browser.at_css("#empty_input").focus + + browser.keyboard.type([:shift, "s"], "tring") + + expect(input.value).to eq("String") + end + + it "presses special keys given as symbols" do + input = browser.at_css("#filled_input").focus + + browser.keyboard.type(:end, :backspace) + + expect(input.value).to eq("Tex") + end + + it "returns itself" do + expect(browser.keyboard.type("a")).to eq(browser.keyboard) + end + end + describe "#hold" do before do browser.go_to("/type") @@ -107,4 +139,18 @@ expect { browser.keyboard.hold(:shift) }.to raise_error(ArgumentError, "block is required") end end + + describe "#modifiers" do + it "returns the bitfield of the given keys" do + expect(browser.keyboard.modifiers(%i[alt ctrl meta shift])).to eq(15) + end + + it "accepts the aliases of modifier keys" do + expect(browser.keyboard.modifiers(%i[control command])).to eq(6) + end + + it "ignores keys that aren't modifiers" do + expect(browser.keyboard.modifiers(%i[enter shift])).to eq(8) + end + end end diff --git a/spec/mouse_spec.rb b/spec/mouse_spec.rb index 519bbbc3..662e6a2e 100644 --- a/spec/mouse_spec.rb +++ b/spec/mouse_spec.rb @@ -6,6 +6,16 @@ Ferrum::Browser.new(ignore_default_browser_options: true, browser_options: defaults.merge("no-sandbox" => nil)) end + def record_mouse_buttons + browser.go_to("/simple") + browser.execute(<<~JS) + window.events = []; + ["mousedown", "mouseup"].forEach(type => + document.addEventListener(type, e => events.push(`${type}:${e.button}:${e.detail}`))); + JS + browser.mouse.move(x: 10, y: 10) + end + describe "#click" do it "supports clicking precise coordinates" do browser.go_to("/click_coordinates") @@ -117,6 +127,58 @@ end end + describe "#down" do + it "presses the left button by default" do + record_mouse_buttons + + browser.mouse.down + + expect(browser.evaluate("events")).to eq(["mousedown:0:1"]) + end + + it "presses the given button with the given click count" do + record_mouse_buttons + + browser.mouse.down(button: :right, count: 2) + + expect(browser.evaluate("events")).to eq(["mousedown:2:2"]) + end + + it "raises for an unknown button" do + expect { browser.mouse.down(button: :wheel) }.to raise_error(RuntimeError, "Invalid button: wheel") + end + + it "returns itself" do + expect(browser.mouse.down).to eq(browser.mouse) + end + end + + describe "#up" do + it "releases the left button by default" do + record_mouse_buttons + + browser.mouse.down.up + + expect(browser.evaluate("events")).to eq(%w[mousedown:0:1 mouseup:0:1]) + end + + it "releases the given button with the given click count" do + record_mouse_buttons + + browser.mouse.down(button: :right, count: 2).up(button: :right, count: 2) + + expect(browser.evaluate("events")).to eq(%w[mousedown:2:2 mouseup:2:2]) + end + + it "raises for an unknown button" do + expect { browser.mouse.up(button: :wheel) }.to raise_error(RuntimeError, "Invalid button: wheel") + end + + it "returns itself" do + expect(browser.mouse.down.up).to eq(browser.mouse) + end + end + describe "#move" do it "sends modifiers" do browser.go_to("/simple") diff --git a/spec/network/auth_request_spec.rb b/spec/network/auth_request_spec.rb index 95b7a89a..8263fb80 100644 --- a/spec/network/auth_request_spec.rb +++ b/spec/network/auth_request_spec.rb @@ -24,4 +24,51 @@ expect(request.match?("pattern.svg?1")).to be(false) end end + + describe "#continue" do + it "answers the auth challenge with the given response" do + network.intercept + page.on(:request) { |request, _index, _total| request.continue } + page.on(:auth) do |request, _index, _total| + request.continue(authChallengeResponse: { response: "ProvideCredentials", username: "login", password: "pass" }) + end + + page.go_to("/basic_auth") + + expect(network.status).to eq(200) + expect(page.body).to include("Welcome, authenticated client") + end + end + + describe "#url" do + it "returns the url of the request" do + expect(auth_request("/basic_auth").url).to eq(base_url("/basic_auth")) + end + end + + describe "#method" do + it "returns the HTTP method of the request" do + expect(auth_request("/basic_auth").method).to eq("GET") + end + end + + describe "#headers" do + it "returns the headers of the request" do + page.headers.set("X-Ferrum" => "yes") + + expect(auth_request("/basic_auth").headers).to include("X-Ferrum" => "yes") + end + end + + def auth_request(path) + challenged = nil + network.intercept + page.on(:request) { |request, _index, _total| request.continue } + page.on(:auth) do |request, _index, _total| + challenged ||= request + request.continue(authChallengeResponse: { response: "CancelAuth" }) + end + page.go_to(path) + challenged + end end diff --git a/spec/network/exchange_spec.rb b/spec/network/exchange_spec.rb index 95ec41b2..ad322bff 100644 --- a/spec/network/exchange_spec.rb +++ b/spec/network/exchange_spec.rb @@ -115,6 +115,20 @@ end end + describe "#loader_id" do + it "returns the loader id of the request" do + page.go_to + + expect(last_exchange.loader_id).to eq(page.main_frame.loader_id) + end + + it "returns nil when request is blank" do + exchange = Ferrum::Network::Exchange.new(page, "1") + + expect(exchange.loader_id).to be_nil + end + end + describe "#blank?" do it "determines if exchange is empty" do page.go_to @@ -182,6 +196,20 @@ end end + describe "#xhr?" do + it "returns true for XHR requests" do + page.go_to("/headers_with_ajax") + + expect(last_exchange.xhr?).to be(true) + end + + it "returns false for document requests" do + page.go_to("/headers_with_ajax") + + expect(first_exchange.xhr?).to be(false) + end + end + describe "#redirect?" do it "determines if exchange is a redirect" do page.go_to("/redirect_again") @@ -200,6 +228,23 @@ end end + describe "#blob?" do + it "returns true for blob requests" do + exchange = Ferrum::Network::Exchange.new(page, "1") + exchange.request = Ferrum::Network::Request.new( + { "type" => "Document", "request" => { "url" => "blob:null/75787aaf-a81f-4237-98d7-0d51fe6cfcba" } } + ) + + expect(exchange.blob?).to be(true) + end + + it "returns false for http requests" do + page.go_to + + expect(last_exchange.blob?).to be(false) + end + end + describe "#pending?" do it "determines if exchange is not fully loaded" do allow(page).to receive(:timeout) { 2 } diff --git a/spec/network/intercepted_request_spec.rb b/spec/network/intercepted_request_spec.rb index 6db53045..d64202a8 100644 --- a/spec/network/intercepted_request_spec.rb +++ b/spec/network/intercepted_request_spec.rb @@ -11,6 +11,35 @@ } end + describe "#status?" do + it "returns true for the status the request was handled with" do + handled = nil + network.intercept + page.on(:request) do |request| + request.continue + handled = request + end + + page.go_to + + expect(handled.status?(:continued)).to be(true) + expect(handled.status?("continued")).to be(true) + end + + it "returns false for another status" do + handled = nil + network.intercept + page.on(:request) do |request| + request.continue + handled = request + end + + page.go_to + + expect(handled.status?(:aborted)).to be(false) + end + end + describe "#match?" do it "matches regular expressions" do expect(request.match?(/pattern\.svg\?\d/)).to be(true) @@ -24,4 +53,75 @@ expect(request.match?("pattern.svg?1")).to be(false) end end + + describe "#respond" do + it "fulfills the request with the given body" do + network.intercept + page.on(:request) { |request| request.respond(body: "
foobar
" - end + before { browser.go_to("/attributes_properties") } - it "gets an attribute whose name has a quote" do - expect(browser.at_css("#my_link").attribute("it's")).to be_nil - end - - it "gets attribute" do - link = browser.at_xpath("//a[text() = 'Loop']") - expect(link.attribute("data-random")).to eq "42" - expect(link.attribute("onclick")).to eq "return false;" + it "returns the property with the given name" do + expect(browser.at_css(".some_other_class")["innerHTML"]).to eq "foobar
" end - it "gets boolean attributes as booleans" do - expect(browser.at_css("input#checked").property("checked")).to be true - expect(browser.at_css("input#unchecked").property("checked")).to be false + it "returns boolean properties as booleans" do + expect(browser.at_css("input#checked")["checked"]).to be true + expect(browser.at_css("input#unchecked")["checked"]).to be false end end @@ -475,6 +594,24 @@ def expect_select_to_clear(options) end end + describe "#wait_for_stop_moving" do + it "returns the content quads of a static node" do + browser.go_to("/simple") + + quads = browser.at_css("#nav").wait_for_stop_moving + + expect(quads.size).to eq(1) + expect(quads.first.size).to eq(8) + end + + it "raises NodeMovingError for a node that keeps moving" do + browser.go_to("/animation") + + expect { browser.at_css(".animated").wait_for_stop_moving(delay: 0.1, attempts: 2) } + .to raise_error(Ferrum::NodeMovingError, /is moving/) + end + end + describe "#moving?" do it "is true for an animated node" do browser.go_to("/animation") @@ -582,6 +719,23 @@ def expect_select_to_clear(options) end end + describe "#value" do + before { browser.go_to("/form") } + + it "returns the value of an input" do + expect(browser.at_css("#form_name").value).to eq("John Smith") + end + + it "returns the value the user typed" do + input = browser.at_css("#form_name") + input.execute("this.value = ''") + + input.focus.type("Jane") + + expect(input.value).to eq("Jane") + end + end + describe "#property" do before do browser.go_to("/attributes_properties") @@ -618,6 +772,32 @@ def expect_select_to_clear(options) end end + describe "#attribute" do + before { browser.go_to("/attributes_properties") } + + it "returns the attribute as written in markup rather than the live property" do + link = browser.at_xpath("//a[text() = 'Loop']") + + expect(link.attribute("href")).to eq("/attributes_properties") + expect(link.property("href")).to eq(base_url("/attributes_properties")) + end + + it "returns custom attributes" do + link = browser.at_xpath("//a[text() = 'Loop']") + + expect(link.attribute("data-random")).to eq "42" + expect(link.attribute("onclick")).to eq "return false;" + end + + it "returns nil for a missing attribute" do + expect(browser.at_css("#my_link").attribute("missing")).to be_nil + end + + it "returns nil for an attribute whose name has a quote" do + expect(browser.at_css("#my_link").attribute("it's")).to be_nil + end + end + describe "#text" do it "skips BR" do browser.go_to("/simple") @@ -647,14 +827,10 @@ def expect_select_to_clear(options) expect(node.text).to eq("😀 and ☀") end - context "SVG tests" do - before do - browser.go_to("/svg_test") - end + it "gets text from an SVG tspan node" do + browser.go_to("/svg_test") - it "gets text from tspan node" do - expect(browser.at_css("tspan").text).to eq("svg foo") - end + expect(browser.at_css("tspan").text).to eq("svg foo") end it "returns the text with surrounding whitespace stripped" do @@ -1060,6 +1236,70 @@ def expect_select_to_clear(options) end end + describe "#evaluate_handle" do + before { browser.go_to("/index") } + + it "returns a remote object bound to the node" do + handle = browser.at_xpath(".//a").evaluate_handle("this.attributes") + + expect(handle).to be_a(Ferrum::RemoteObject) + expect(handle.description).to eq("NamedNodeMap") + end + + it "returns a node for a node" do + node = browser.at_xpath(".//a") + + expect(node.evaluate_handle("this")).to eq(node) + end + + it "returns a primitive as is" do + expect(browser.at_xpath(".//a").evaluate_handle("this.getAttribute('href')")).to eq("js_redirect") + end + end + + describe "#find_position" do + before { browser.go_to("/simple") } + + def rect + browser.at_css("#nav").evaluate("this.getBoundingClientRect().toJSON()") + end + + it "returns the center of the node" do + x, y = browser.at_css("#nav").find_position + + expect(x).to be_within(0.5).of(rect["left"] + (rect["width"] / 2)) + expect(y).to be_within(0.5).of(rect["top"] + (rect["height"] / 2)) + end + + it "returns the offset from the top-left corner of the node" do + x, y = browser.at_css("#nav").find_position(x: 5, y: 3) + + expect(x).to be_within(0.5).of(rect["left"] + 5) + expect(y).to be_within(0.5).of(rect["top"] + 3) + end + + it "returns the offset from the center of the node with position: :center" do + x, y = browser.at_css("#nav").find_position(x: 5, y: 3, position: :center) + + expect(x).to be_within(0.5).of(rect["left"] + (rect["width"] / 2) + 5) + expect(y).to be_within(0.5).of(rect["top"] + (rect["height"] / 2) + 3) + end + + it "returns the center of the node when only one offset is given" do + x, y = browser.at_css("#nav").find_position(x: 5) + + expect(x).to be_within(0.5).of(rect["left"] + (rect["width"] / 2)) + expect(y).to be_within(0.5).of(rect["top"] + (rect["height"] / 2)) + end + + it "raises CoordinatesNotFoundError for a hidden node" do + node = browser.at_css("#nav") + node.execute("this.style.display = 'none'") + + expect { node.find_position }.to raise_error(Ferrum::CoordinatesNotFoundError) + end + end + describe "#execute" do before { browser.go_to("/index") } diff --git a/spec/page/frames_spec.rb b/spec/page/frames_spec.rb index 9e88413b..f044dae4 100644 --- a/spec/page/frames_spec.rb +++ b/spec/page/frames_spec.rb @@ -1,7 +1,49 @@ # frozen_string_literal: true describe Ferrum::Page::Frames do + describe "#main_frame" do + it "returns the top frame of the page" do + page.go_to("/frame_parent") + + expect(page.main_frame.main?).to be(true) + expect(page.main_frame.url).to eq(base_url("/frame_parent")) + end + end + + describe "#frames" do + it "returns the main frame and all nested frames" do + page.go_to("/frame_parent") + + expect(page.frames.map(&:title)).to contain_exactly( + "This is the parent frame title", + "This is the child frame title", + "This is the title of frame one", + "This is the title of frame two" + ) + end + end + describe "#frame_by" do + it "returns the frame with the given id" do + page.go_to("/frame_parent") + frame = page.at_css("#childFrame").frame + + expect(page.frame_by(id: frame.id)).to equal(frame) + end + + it "returns the frame with the given name" do + page.go_to("/frames") + + expect(page.frame_by(name: "frame").url).to end_with("/slow") + end + + it "returns the frame with the given execution id" do + page.go_to("/frame_parent") + frame = page.at_css("#childFrame").frame + + expect(page.frame_by(execution_id: frame.execution_id)).to equal(frame) + end + it "returns nil for an unknown frame without waiting for it" do page.go_to diff --git a/spec/page/screenshot_spec.rb b/spec/page/screenshot_spec.rb index 4ffde462..3e66a04a 100644 --- a/spec/page/screenshot_spec.rb +++ b/spec/page/screenshot_spec.rb @@ -525,4 +525,27 @@ def expect_scaled_screenshot(scale) browser&.quit end end + + describe "#device_pixel_ratio" do + it "returns 1 by default" do + page.go_to + + expect(page.device_pixel_ratio).to eq(1) + end + + it "returns the emulated scale factor" do + page.go_to + page.set_viewport(width: 500, height: 300, scale_factor: 3) + + expect(page.device_pixel_ratio).to eq(3) + end + end + + describe "#document_size" do + it "returns the full size of the document beyond the viewport" do + page.go_to("/custom_html_size") + + expect(page.document_size).to eq([1280, 1024]) + end + end end diff --git a/spec/page_spec.rb b/spec/page_spec.rb index e0a9dfb2..356e007f 100644 --- a/spec/page_spec.rb +++ b/spec/page_spec.rb @@ -74,6 +74,27 @@ end end + describe "#close" do + it "closes the page's target" do + page.go_to + target_id = page.target_id + + expect(page.close).to be(true) + + wait_for { browser.targets.key?(target_id) }.to be(false) + end + end + + describe "#position" do + it "returns the left and top coordinates of the window" do + skip if Ferrum::Utils::Platform.mac? && !browser.options.headless + + page.window_bounds = { left: 30, top: 40 } + + expect(page.position).to eq([30, 40]) + end + end + describe "#position=" do it "allows the window to be positioned" do skip if Ferrum::Utils::Platform.mac? && !browser.options.headless @@ -86,6 +107,73 @@ end end + describe "#window_bounds=" do + it "resizes the window" do + page.window_bounds = { width: 800, height: 600 } + + expect(page.window_bounds).to include("width" => 800, "height" => 600) + end + + it "changes the window state" do + page.go_to + + page.window_bounds = { window_state: "fullscreen" } + + expect(page.window_bounds["windowState"]).to eq("fullscreen") + end + end + + describe "#window_bounds" do + it "returns the bounds and state of the window" do + expect(page.window_bounds).to include( + "left" => an_instance_of(Integer), + "top" => an_instance_of(Integer), + "width" => an_instance_of(Integer), + "height" => an_instance_of(Integer), + "windowState" => "normal" + ) + end + end + + describe "#window_id" do + it "returns the same id for pages in the same window" do + other = browser.create_page + + expect(page.window_id).to be_an(Integer) + expect(page.window_id).to eq(other.window_id) + end + + it "returns another id for a page in another context" do + other = browser.create_page(new_context: true) + + expect(page.window_id).not_to eq(other.window_id) + end + end + + describe "#refresh" do + it "reloads the page and waits for it to load" do + page.go_to("/slow") + page.execute("window.marker = 'set'") + + page.refresh + + expect(page.evaluate("window.marker")).to be_nil + expect(page.body).to include("slow page") + end + end + + describe "#stop" do + it "stops the pending navigation" do + page.go_to("/simple") + page.execute("location.href = '/really_slow'") + + page.stop + + expect(page.url).to eq(base_url("/simple")) + expect(page.at_css("title").text).to eq("Test") + end + end + describe "#url" do it "supports whitespace characters" do page.go_to("/arbitrary_path/200/foo%20bar%20baz") @@ -251,19 +339,58 @@ expect(page.evaluate("window.secret")).to eq("top") end + end - it "stops running removed JavaScript" do + describe "#remove_script_on_new_document" do + it "stops running the removed JavaScript in new documents" do id = page.evaluate_on_new_document("window.secret = 'top'") page.go_to("/simple") expect(page.evaluate("window.secret")).to eq("top") - page.remove_script_on_new_document(id) + expect(page.remove_script_on_new_document(id)).to be(true) page.reload expect(page.evaluate("window.secret")).to be_nil end end + describe "#activate" do + it "brings the page to the front" do + page.go_to + other = browser.create_page + other.go_to + other.activate + wait_for { page.evaluate("document.visibilityState") }.to eq("hidden") + + expect(page.activate).to be(true) + + wait_for { page.evaluate("document.visibilityState") }.to eq("visible") + end + end + + describe "#command" do + it "sends a CDP command and returns its result" do + result = page.command("Runtime.evaluate", expression: "1 + 1") + + expect(result.dig("result", "value")).to eq(2) + end + + it "waits for the navigation the command starts with wait:" do + page.go_to + loaded = false + page.on("Page.loadEventFired") { loaded = true } + + page.command("Page.navigate", wait: 1, url: base_url("/slow")) + + expect(loaded).to be(true) + end + + it "raises when the response doesn't come within timeout:" do + expect { page.command("Runtime.evaluate", timeout: 0.5, expression: "new Promise(() => {})", awaitPromise: true) } + .to raise_error(Ferrum::TimeoutError) + end + end + describe "#timeout=" do it "supports to change timeout dynamically" do page.timeout = 4 @@ -457,4 +584,18 @@ def is_mobile? end end end + + describe "#document_node_id" do + it "returns the node id of the document" do + page.go_to + + expect(page.document_node_id).to be_a(Integer).and be_positive + end + + it "returns true when sent asynchronously" do + page.go_to + + expect(page.document_node_id(async: true)).to be(true) + end + end end diff --git a/spec/remote_object_spec.rb b/spec/remote_object_spec.rb new file mode 100644 index 00000000..c0beabce --- /dev/null +++ b/spec/remote_object_spec.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +describe Ferrum::RemoteObject do + describe "#value" do + it "serializes the handle into a Ruby value" do + handle = browser.evaluate_handle("({ list: [1, 'a'], flag: true })") + + expect(handle.value).to eq("list" => [1, "a"], "flag" => true) + end + + it "resolves nodes into Node objects" do + browser.go_to("/index") + handle = browser.evaluate_handle("Array.from(document.querySelectorAll('a'))") + + expect(handle.value).to eq(browser.css("a")) + end + end + + describe "#release" do + it "makes the handle unusable" do + handle = browser.evaluate_handle("({ a: 1 })") + + expect(handle.release).to be_nil + expect { handle.value }.to raise_error(Ferrum::BrowserError) + end + + it "returns nil when the handle is already released" do + handle = browser.evaluate_handle("({ a: 1 })") + handle.release + + expect(handle.release).to be_nil + end + end +end diff --git a/spec/target_spec.rb b/spec/target_spec.rb index a7e36939..2a153327 100644 --- a/spec/target_spec.rb +++ b/spec/target_spec.rb @@ -1,14 +1,167 @@ # frozen_string_literal: true describe Ferrum::Target do + def target_of(page) + browser.targets[page.target_id] + end + + def open_popup + page.go_to + page.execute(%(window.open("/simple", "popup"))) + wait_for_target { |target| target.opener_id == page.target_id } + end + + def spawn_worker(type) + constructor = { "worker" => "Worker", "shared_worker" => "SharedWorker" }.fetch(type) + page.go_to + page.execute(<<~JS) + new #{constructor}(URL.createObjectURL(new Blob(["self.onconnect = self.onmessage = () => {}"], + { type: "application/javascript" }))) + JS + wait_for_target { |target| target.type == type } + end + + def register_service_worker + page.go_to + page.execute(%(await navigator.serviceWorker.register("/sw.js")), timeout: 5) + wait_for_target(&:service_worker?) + end + + describe "#connected?" do + it "returns false until the target is connected to" do + target = browser.create_target + + expect(target).not_to be_connected + target.page + expect(target).to be_connected + end + end + + describe "#page" do + it "connects to the target as a page" do + target = browser.create_target + + expect(target.page.target_id).to eq(target.id) + expect(target.page).to equal(target.page) + end + end + describe "#worker" do it "raises for a service worker that isn't attached" do - page.go_to - page.execute(%(await navigator.serviceWorker.register("/sw.js")), timeout: 5) - target = wait_for_target(&:service_worker?) + target = register_service_worker wait_for { target.session_id }.to be_nil expect { target.worker }.to raise_error(Ferrum::NoSuchTargetError, /attach_target/) end end + + describe "#id" do + it "returns the id of the target" do + expect(target_of(page).id).to eq(page.target_id) + end + end + + describe "#type" do + it "returns page for a page" do + expect(target_of(page).type).to eq("page") + end + + it "returns worker for a dedicated worker" do + expect(spawn_worker("worker").type).to eq("worker") + end + end + + describe "#url" do + it "returns the url of the page" do + page.go_to("/simple") + + wait_for { target_of(page).url }.to eq(base_url("/simple")) + end + end + + describe "#opener_id" do + it "returns the id of the page that opened a popup" do + expect(open_popup.opener_id).to eq(page.target_id) + end + + it "returns nil for a page created directly" do + expect(target_of(page).opener_id).to be_nil + end + end + + describe "#parent_id" do + it "returns the id of the page that spawned a worker" do + expect(spawn_worker("worker").parent_id).to eq(page.target_id) + end + end + + describe "#context_id" do + it "returns the id of the context the target belongs to" do + context = browser.contexts.create + + page = context.create_page + + expect(context.targets[page.target_id].context_id).to eq(context.id) + end + end + + describe "#window?" do + it "returns true for a popup" do + expect(open_popup).to be_window + end + + it "returns false for a page created directly" do + expect(target_of(page)).not_to be_window + end + end + + describe "#page?" do + it "returns true for a page" do + expect(target_of(page)).to be_page + end + + it "returns false for a worker" do + expect(spawn_worker("worker")).not_to be_page + end + end + + describe "#worker?" do + it "returns true for a dedicated worker" do + expect(spawn_worker("worker")).to be_worker + end + + it "returns false for a shared worker" do + expect(spawn_worker("shared_worker")).not_to be_worker + end + end + + describe "#shared_worker?" do + it "returns true for a shared worker" do + expect(spawn_worker("shared_worker")).to be_shared_worker + end + + it "returns false for a dedicated worker" do + expect(spawn_worker("worker")).not_to be_shared_worker + end + end + + describe "#service_worker?" do + it "returns true for a service worker" do + expect(register_service_worker).to be_service_worker + end + + it "returns false for a page" do + expect(target_of(page)).not_to be_service_worker + end + end + + describe "#command" do + it "sends a CDP command to the target's session" do + page.go_to("/simple") + + result = target_of(page).command("Runtime.evaluate", expression: "document.title") + + expect(result.dig("result", "value")).to eq("Test") + end + end end diff --git a/spec/worker_spec.rb b/spec/worker_spec.rb index 94cd84cf..0f9b06eb 100644 --- a/spec/worker_spec.rb +++ b/spec/worker_spec.rb @@ -1,22 +1,44 @@ # frozen_string_literal: true describe Ferrum::Worker do + def spawn_worker + page.go_to + page.execute <<~JS + new Worker(URL.createObjectURL(new Blob( + ["self.onmessage = () => {}"], + { type: "application/javascript" } + ))) + JS + wait_for_target(&:worker?).worker + end + describe "#console" do it "collects its console messages" do - page.go_to - - page.execute <<~JS - new Worker(URL.createObjectURL(new Blob( - ["self.onmessage = () => {}"], - { type: "application/javascript" } - ))) - JS - - worker = wait_for_target(&:worker?).worker + worker = spawn_worker worker.execute(%(console.warn("from worker"))) wait_for { worker.console.map(&:text) }.to eq(["from worker"]) expect(worker.console.first).to be_warning end end + + describe "#timeout" do + it "returns the browser's timeout" do + expect(spawn_worker.timeout).to eq(browser.timeout) + end + end + + describe "#command" do + it "sends a CDP command to the worker" do + result = spawn_worker.command("Runtime.evaluate", expression: "typeof WorkerGlobalScope") + + expect(result.dig("result", "value")).to eq("function") + end + end + + describe "#main_frame" do + it "returns nil" do + expect(spawn_worker.main_frame).to be_nil + end + end end