18 |
19 |
20 | HTML
21 | [200,
22 | { 'Content-Type' => 'text/html', 'Content-Length' => body.length.to_s },
23 | [body]]
24 | end
25 |
26 | @driver = Capybara::Driver::Webkit.new(app, :browser => $webkit_browser)
27 | @driver.visit("/hello/world?success=true")
28 | end
29 |
30 | after(:all) { @driver.reset! }
31 |
32 | def render(options)
33 | FileUtils.rm_f @file_name
34 | @driver.render @file_name, options
35 |
36 | @image = MiniMagick::Image.open @file_name
37 | end
38 |
39 | context "with default options" do
40 | before(:all){ render({}) }
41 |
42 | it "should be a PNG" do
43 | @image[:format].should == "PNG"
44 | end
45 |
46 | it "width default to 1000px (with 15px less for the scrollbar)" do
47 | @image[:width].should be < 1001
48 | @image[:width].should be > 1000-17
49 | end
50 |
51 | it "height should be at least 10px" do
52 | @image[:height].should >= 10
53 | end
54 | end
55 |
56 | context "with dimensions set larger than necessary" do
57 | before(:all){ render(:width => 500, :height => 400) }
58 |
59 | it "width should match the width given" do
60 | @image[:width].should == 500
61 | end
62 |
63 | it "height should match the height given" do
64 | @image[:height].should > 10
65 | end
66 | end
67 |
68 | context "with dimensions set smaller than the document's default" do
69 | before(:all){ render(:width => 50, :height => 10) }
70 |
71 | it "width should be greater than the width given" do
72 | @image[:width].should > 50
73 | end
74 |
75 | it "height should be greater than the height given" do
76 | @image[:height].should > 10
77 | end
78 | end
79 |
80 | end
81 |
--------------------------------------------------------------------------------
/src/Connection.cpp:
--------------------------------------------------------------------------------
1 | #include "Connection.h"
2 | #include "WebPage.h"
3 | #include "CommandParser.h"
4 | #include "CommandFactory.h"
5 | #include "PageLoadingCommand.h"
6 | #include "Command.h"
7 |
8 | #include
9 |
10 | Connection::Connection(QTcpSocket *socket, WebPage *page, QObject *parent) :
11 | QObject(parent) {
12 | m_socket = socket;
13 | m_page = page;
14 | m_commandFactory = new CommandFactory(page, this);
15 | m_commandParser = new CommandParser(socket, m_commandFactory, this);
16 | m_pageSuccess = true;
17 | m_commandWaiting = false;
18 | connect(m_socket, SIGNAL(readyRead()), m_commandParser, SLOT(checkNext()));
19 | connect(m_commandParser, SIGNAL(commandReady(Command *)), this, SLOT(commandReady(Command *)));
20 | connect(m_page, SIGNAL(pageFinished(bool)), this, SLOT(pendingLoadFinished(bool)));
21 | }
22 |
23 | void Connection::commandReady(Command *command) {
24 | m_queuedCommand = command;
25 | if (m_page->isLoading())
26 | m_commandWaiting = true;
27 | else
28 | startCommand();
29 | }
30 |
31 | void Connection::startCommand() {
32 | m_commandWaiting = false;
33 | if (m_pageSuccess) {
34 | m_runningCommand = new PageLoadingCommand(m_queuedCommand, m_page, this);
35 | connect(m_runningCommand, SIGNAL(finished(Response *)), this, SLOT(finishCommand(Response *)));
36 | m_runningCommand->start();
37 | } else {
38 | writePageLoadFailure();
39 | }
40 | }
41 |
42 | void Connection::pendingLoadFinished(bool success) {
43 | m_pageSuccess = success;
44 | if (m_commandWaiting)
45 | startCommand();
46 | }
47 |
48 | void Connection::writePageLoadFailure() {
49 | m_pageSuccess = true;
50 | QString message = m_page->failureString();
51 | writeResponse(new Response(false, message));
52 | }
53 |
54 | void Connection::finishCommand(Response *response) {
55 | m_runningCommand->deleteLater();
56 | writeResponse(response);
57 | }
58 |
59 | void Connection::writeResponse(Response *response) {
60 | if (response->isSuccess())
61 | m_socket->write("ok\n");
62 | else
63 | m_socket->write("failure\n");
64 |
65 | QByteArray messageUtf8 = response->message();
66 | QString messageLength = QString::number(messageUtf8.size()) + "\n";
67 | m_socket->write(messageLength.toAscii());
68 | m_socket->write(messageUtf8);
69 | delete response;
70 | }
71 |
72 |
--------------------------------------------------------------------------------
/src/Evaluate.cpp:
--------------------------------------------------------------------------------
1 | #include "Evaluate.h"
2 | #include "WebPage.h"
3 | #include
4 |
5 | Evaluate::Evaluate(WebPage *page, QStringList &arguments, QObject *parent) : Command(page, arguments, parent) {
6 | m_buffer = "";
7 | }
8 |
9 | void Evaluate::start() {
10 | QVariant result = page()->currentFrame()->evaluateJavaScript(arguments()[0]);
11 | addVariant(result);
12 | emit finished(new Response(true, m_buffer));
13 | }
14 |
15 | void Evaluate::addVariant(QVariant &object) {
16 | if (object.isValid()) {
17 | switch(object.type()) {
18 | case QMetaType::QString:
19 | {
20 | QString string = object.toString();
21 | addString(string);
22 | }
23 | break;
24 | case QMetaType::QVariantList:
25 | {
26 | QVariantList list = object.toList();
27 | addArray(list);
28 | }
29 | break;
30 | case QMetaType::Double:
31 | m_buffer.append(object.toString());
32 | break;
33 | case QMetaType::QVariantMap:
34 | {
35 | QVariantMap map = object.toMap();
36 | addMap(map);
37 | break;
38 | }
39 | case QMetaType::Bool:
40 | {
41 | m_buffer.append(object.toString());
42 | break;
43 | }
44 | default:
45 | m_buffer.append("null");
46 | }
47 | } else {
48 | m_buffer.append("null");
49 | }
50 | }
51 |
52 | void Evaluate::addString(QString &string) {
53 | QString escapedString(string);
54 | escapedString.replace("\"", "\\\"");
55 | m_buffer.append("\"");
56 | m_buffer.append(escapedString);
57 | m_buffer.append("\"");
58 | }
59 |
60 | void Evaluate::addArray(QVariantList &list) {
61 | m_buffer.append("[");
62 | for (int i = 0; i < list.length(); i++) {
63 | if (i > 0)
64 | m_buffer.append(",");
65 | addVariant(list[i]);
66 | }
67 | m_buffer.append("]");
68 | }
69 |
70 | void Evaluate::addMap(QVariantMap &map) {
71 | m_buffer.append("{");
72 | QMapIterator iterator(map);
73 | while (iterator.hasNext()) {
74 | iterator.next();
75 | QString key = iterator.key();
76 | QVariant value = iterator.value();
77 | addString(key);
78 | m_buffer.append(":");
79 | addVariant(value);
80 | if (iterator.hasNext())
81 | m_buffer.append(",");
82 | }
83 | m_buffer.append("}");
84 | }
85 |
--------------------------------------------------------------------------------
/src/CurrentUrl.cpp:
--------------------------------------------------------------------------------
1 | #include "CurrentUrl.h"
2 | #include "WebPage.h"
3 |
4 | CurrentUrl::CurrentUrl(WebPage *page, QStringList &arguments, QObject *parent) : Command(page, arguments, parent) {
5 | }
6 |
7 | /*
8 | * This CurrentUrl command attempts to produce a current_url value consistent
9 | * with that returned by the Selenium WebDriver Capybara driver.
10 | *
11 | * It does not currently return the correct value in the case of an iframe whose
12 | * source URL results in a redirect because the loading of the iframe does not
13 | * generate a history item. This is most likely a rare case and is consistent
14 | * with the current behavior of the capybara-webkit driver.
15 | *
16 | * The following two values are *not* affected by Javascript pushState.
17 | *
18 | * QWebFrame->url()
19 | * QWebHistoryItem.originalUrl()
20 | *
21 | * The following two values *are* affected by Javascript pushState.
22 | *
23 | * QWebFrame->requestedUrl()
24 | * QWebHistoryItem.url()
25 | *
26 | * In the cases that we have access to both the QWebFrame values and the
27 | * correct history item for that frame, we can compare the values and determine
28 | * if a redirect occurred and if pushState was used. The table below describes
29 | * the various combinations of URL values that are possible.
30 | *
31 | * O -> originally requested URL
32 | * R -> URL after redirection
33 | * P -> URL set by pushState
34 | * * -> denotes the desired URL value from the frame
35 | *
36 | * frame history
37 | * case url requestedUrl url originalUrl
38 | * -----------------------------------------------------------------
39 | * regular load O O* O O
40 | *
41 | * redirect w/o R* O R O
42 | * pushState
43 | *
44 | * pushState O P* P O
45 | * only
46 | *
47 | * redirect w/ R P* P O
48 | * pushState
49 | *
50 | * Based on the above information, we only need to check for the case of a
51 | * redirect w/o pushState, in which case QWebFrame->url() will have the correct
52 | * current_url value. In all other cases QWebFrame->requestedUrl() is correct.
53 | */
54 | void CurrentUrl::start() {
55 | QUrl humanUrl = wasRedirectedAndNotModifiedByJavascript() ?
56 | page()->currentFrame()->url() : page()->currentFrame()->requestedUrl();
57 | QByteArray encodedBytes = humanUrl.toEncoded();
58 | emit finished(new Response(true, encodedBytes));
59 | }
60 |
61 | bool CurrentUrl::wasRegularLoad() {
62 | return page()->currentFrame()->url() == page()->currentFrame()->requestedUrl();
63 | }
64 |
65 | bool CurrentUrl::wasRedirectedAndNotModifiedByJavascript() {
66 | return !wasRegularLoad() && page()->currentFrame()->url() == page()->history()->currentItem().url();
67 | }
68 |
69 |
--------------------------------------------------------------------------------
/lib/capybara/driver/webkit/node.rb:
--------------------------------------------------------------------------------
1 | class Capybara::Driver::Webkit
2 | class Node < Capybara::Driver::Node
3 | NBSP = "\xC2\xA0"
4 | NBSP.force_encoding("UTF-8") if NBSP.respond_to?(:force_encoding)
5 |
6 | def text
7 | invoke("text").gsub(NBSP, ' ').gsub(/\s+/u, ' ').strip
8 | end
9 |
10 | def [](name)
11 | value = invoke("attribute", name)
12 | if name == 'checked' || name == 'disabled'
13 | value == 'true'
14 | else
15 | value
16 | end
17 | end
18 |
19 | def value
20 | if multiple_select?
21 | self.find(".//option").select(&:selected?).map(&:value)
22 | else
23 | invoke "value"
24 | end
25 | end
26 |
27 | def set(value)
28 | invoke "set", value
29 | end
30 |
31 | def select_option
32 | invoke "selectOption"
33 | end
34 |
35 | def unselect_option
36 | select = find("ancestor::select").first
37 | if select.multiple_select?
38 | invoke "unselectOption"
39 | else
40 | raise Capybara::UnselectNotAllowed
41 | end
42 | end
43 |
44 | def click
45 | invoke "click"
46 | end
47 |
48 | def drag_to(element)
49 | invoke 'dragTo', element.native
50 | end
51 |
52 | def tag_name
53 | invoke "tagName"
54 | end
55 |
56 | def visible?
57 | invoke("visible") == "true"
58 | end
59 |
60 | def selected?
61 | invoke("selected") == "true"
62 | end
63 |
64 | def checked?
65 | self['checked']
66 | end
67 |
68 | def disabled?
69 | self['disabled']
70 | end
71 |
72 | def path
73 | invoke "path"
74 | end
75 |
76 | def submit(opts = {})
77 | invoke "submit"
78 | end
79 |
80 | def trigger(event)
81 | invoke "trigger", event
82 | end
83 |
84 | def find(xpath)
85 | invoke("findWithin", xpath).split(',').map do |native|
86 | self.class.new(driver, native)
87 | end
88 | end
89 |
90 | def invoke(name, *args)
91 | if allow_unattached_nodes? || attached?
92 | browser.command "Node", name, native, *args
93 | else
94 | raise Capybara::Driver::Webkit::NodeNotAttachedError
95 | end
96 | end
97 |
98 | def allow_unattached_nodes?
99 | !automatic_reload?
100 | end
101 |
102 | def automatic_reload?
103 | Capybara.respond_to?(:automatic_reload) && Capybara.automatic_reload
104 | end
105 |
106 | def attached?
107 | browser.command("Node", "isAttached", native) == "true"
108 | end
109 |
110 | def browser
111 | driver.browser
112 | end
113 |
114 | def multiple_select?
115 | self.tag_name == "select" && self["multiple"] == "multiple"
116 | end
117 | end
118 | end
119 |
--------------------------------------------------------------------------------
/ChangeLog:
--------------------------------------------------------------------------------
1 | 2012-03-09 Joe Ferris
2 | * node.rb, driver_spec.rb: Allow interaction with invisible elements
3 |
4 | 2012-03-02 Joe Ferris
5 | * browser.rb: Use Timeout from stdlib since Capybara.timeout is being removed
6 |
7 | 2012-03-02 Matthew Mongeau
8 | * capybara_webkit_builder.rb:
9 | set LANG to en_US.UTF-8 to prevent string encoding issues during install
10 |
11 | 2012-03-02 Marc Schwieterman
12 | * Rakefile: pro, find_command, and CommandFactory are more structured
13 |
14 | 2012-02-27 Joe Ferris
15 | * README.md: Fixed broken wiki link
16 |
17 | 2012-02-24 Joe Ferris
18 | * README.md: Update instructions for platform specific installation issues
19 |
20 | 2012-02-17 Matthew Mongeau
21 | * driver_spec.rb, capybara.js: Send proper keycode during keypress event
22 |
23 | 2012-02-17 Matthew Mongeau
24 | * ChangeLog, version.rb:
25 | Bump to 0.10.0
26 |
27 | 2012-02-17 Marc Schwieterman
28 | * driver_spec.rb, Reset.cpp, Reset.h: Reset history when resetting session.
29 | * driver_spec.rb, webkit.rb, browser.rb, CommandFactory.cpp, CurrentUrl.cpp,
30 | CurrentUrl.h, find_command.h, webkit_server.pro:
31 | current_url now conforms more closely to the behavior of Selenium.
32 |
33 | 2012-02-17 Igor Zubkov
34 | * spec_helper.rb: Fix typo.
35 |
36 | 2012-02-17 Matthew Mongeau
37 | * capybara_webkit_builder.rb, capybara_webkit_builder_spec.rb,
38 | spec_helper.rb:
39 | Allow for overriding MAKE, QMAKE, and SPEC options via the environment.
40 |
41 | 2012-02-12 Jason Petersen
42 | * driver_spec.rb, node.rb, capybara.js:
43 | Selected attribute is no longer removed when selecting/deselecting. Only the
44 | property is changed.
45 |
46 | 2012-02-09 Gabe Berke-Williams
47 | * capybara-webkit.gemspec: Note capybara-webkit's usage of Sinatra.
48 |
49 | 2012-02-03 Matthew Mongeau
50 | * version.rb:
51 | Bump to 0.9.0
52 |
53 | 2012-02-01 Joe Ferris
54 | * driver_spec.rb, Connection.cpp, Connection.h:
55 | Try to detect when a command starts a page load and wait for it to finish.
56 |
57 | 2012-01-27 Matthew Mongeau
58 | * driver_spec.rb, capybara.js: Trigger mousedown and mouseup events.
59 | * driver_spec.rb, capybara.js: Simulate browser events more closely.
60 |
61 | 2012-01-19 Marco Antonio
62 | * node.rb, driver_spec.rb:
63 | Raise ElementNotDisplayedError also for #drag_to and #select_option when they are invisible.
64 |
65 | 2012-01-18 Marco Antonio
66 | * node.rb, driver_spec.rb:
67 | Raise error when an invisible element receives #click so it resembles a browser more closely.
68 |
69 | 2012-01-15 Marc Schwieterman
70 | * CONTRIBUTING.md: add imagemagick dependency to contributing guide.
71 |
--------------------------------------------------------------------------------
/lib/capybara/driver/webkit.rb:
--------------------------------------------------------------------------------
1 | require "capybara"
2 | require "capybara/driver/webkit/version"
3 | require "capybara/driver/webkit/node"
4 | require "capybara/driver/webkit/connection"
5 | require "capybara/driver/webkit/browser"
6 | require "capybara/driver/webkit/socket_debugger"
7 | require "capybara/driver/webkit/cookie_jar"
8 |
9 | class Capybara::Driver::Webkit
10 | class WebkitInvalidResponseError < StandardError
11 | end
12 |
13 | class WebkitNoResponseError < StandardError
14 | end
15 |
16 | class NodeNotAttachedError < Capybara::ElementNotFound
17 | end
18 |
19 | attr_reader :browser
20 |
21 | def initialize(app, options={})
22 | @app = app
23 | @options = options
24 | @rack_server = Capybara::Server.new(@app)
25 | @rack_server.boot if Capybara.run_server
26 | @browser = options[:browser] || Browser.new(Connection.new(options))
27 | @browser.ignore_ssl_errors if options[:ignore_ssl_errors]
28 | end
29 |
30 | def current_url
31 | browser.current_url
32 | end
33 |
34 | def requested_url
35 | browser.requested_url
36 | end
37 |
38 | def visit(path)
39 | browser.visit(url(path))
40 | end
41 |
42 | def find(query)
43 | browser.find(query).map { |native| Node.new(self, native) }
44 | end
45 |
46 | def source
47 | browser.source
48 | end
49 |
50 | def body
51 | browser.body
52 | end
53 |
54 | def header(key, value)
55 | browser.header(key, value)
56 | end
57 |
58 | def execute_script(script)
59 | value = browser.execute_script script
60 | value.empty? ? nil : value
61 | end
62 |
63 | def evaluate_script(script)
64 | browser.evaluate_script script
65 | end
66 |
67 | def console_messages
68 | browser.console_messages
69 | end
70 |
71 | def error_messages
72 | browser.error_messages
73 | end
74 |
75 | def response_headers
76 | browser.response_headers
77 | end
78 |
79 | def status_code
80 | browser.status_code
81 | end
82 |
83 | def resize_window(width, height)
84 | browser.resize_window(width, height)
85 | end
86 |
87 | def within_frame(frame_id_or_index)
88 | browser.frame_focus(frame_id_or_index)
89 | begin
90 | yield
91 | ensure
92 | browser.frame_focus
93 | end
94 | end
95 |
96 | def within_window(handle)
97 | raise Capybara::NotSupportedByDriverError
98 | end
99 |
100 | def wait?
101 | true
102 | end
103 |
104 | def wait_until(*args)
105 | end
106 |
107 | def reset!
108 | browser.reset!
109 | end
110 |
111 | def has_shortcircuit_timeout?
112 | false
113 | end
114 |
115 | def render(path, options={})
116 | options[:width] ||= 1000
117 | options[:height] ||= 10
118 |
119 | browser.render path, options[:width], options[:height]
120 | end
121 |
122 | def server_port
123 | @rack_server.port
124 | end
125 |
126 | def cookies
127 | @cookie_jar ||= CookieJar.new(browser)
128 | end
129 |
130 | private
131 |
132 | def url(path)
133 | @rack_server.url(path)
134 | end
135 | end
136 |
137 |
--------------------------------------------------------------------------------
/lib/capybara/driver/webkit/connection.rb:
--------------------------------------------------------------------------------
1 | require 'socket'
2 | require 'timeout'
3 | require 'thread'
4 |
5 | class Capybara::Driver::Webkit
6 | class Connection
7 | WEBKIT_SERVER_START_TIMEOUT = 15
8 |
9 | attr_reader :port
10 |
11 | def initialize(options = {})
12 | @socket_class = options[:socket_class] || TCPSocket
13 | @stdout = options.has_key?(:stdout) ?
14 | options[:stdout] :
15 | $stdout
16 | start_server
17 | connect
18 | end
19 |
20 | def puts(string)
21 | @socket.puts string
22 | end
23 |
24 | def print(string)
25 | @socket.print string
26 | end
27 |
28 | def gets
29 | @socket.gets
30 | end
31 |
32 | def read(length)
33 | @socket.read(length)
34 | end
35 |
36 | private
37 |
38 | def start_server
39 | pipe = fork_server
40 | @port = discover_port(pipe)
41 | @stdout_thread = Thread.new do
42 | Thread.current.abort_on_exception = true
43 | forward_stdout(pipe)
44 | end
45 | end
46 |
47 | def fork_server
48 | server_path = File.expand_path("../../../../../bin/webkit_server", __FILE__)
49 | pipe, @pid = server_pipe_and_pid(server_path)
50 | register_shutdown_hook
51 | pipe
52 | end
53 |
54 | def kill_process(pid)
55 | if RUBY_PLATFORM =~ /mingw32/
56 | Process.kill(9, pid)
57 | else
58 | Process.kill("INT", pid)
59 | end
60 | end
61 |
62 | def register_shutdown_hook
63 | @owner_pid = Process.pid
64 | at_exit do
65 | if Process.pid == @owner_pid
66 | kill_process(@pid)
67 | end
68 | end
69 | end
70 |
71 | def server_pipe_and_pid(server_path)
72 | cmdline = [server_path]
73 | pipe = IO.popen(cmdline.join(" "))
74 | [pipe, pipe.pid]
75 | end
76 |
77 | def discover_port(read_pipe)
78 | return unless IO.select([read_pipe], nil, nil, WEBKIT_SERVER_START_TIMEOUT)
79 | ((read_pipe.first || '').match(/listening on port: (\d+)/) || [])[1].to_i
80 | end
81 |
82 | def forward_stdout(pipe)
83 | while pipe_readable?(pipe)
84 | line = pipe.readline
85 | if @stdout
86 | @stdout.write(line)
87 | @stdout.flush
88 | end
89 | end
90 | rescue EOFError
91 | end
92 |
93 | def connect
94 | Timeout.timeout(5) do
95 | while @socket.nil?
96 | attempt_connect
97 | end
98 | end
99 | end
100 |
101 | def attempt_connect
102 | @socket = @socket_class.open("127.0.0.1", @port)
103 | rescue Errno::ECONNREFUSED
104 | end
105 |
106 | if !defined?(RUBY_ENGINE) || (RUBY_ENGINE == "ruby" && RUBY_VERSION <= "1.8")
107 | # please note the use of IO::select() here, as it is used specifically to
108 | # preserve correct signal handling behavior in ruby 1.8.
109 | # https://github.com/thibaudgg/rb-fsevent/commit/d1a868bf8dc72dbca102bedbadff76c7e6c2dc21
110 | # https://github.com/thibaudgg/rb-fsevent/blob/1ca42b987596f350ee7b19d8f8210b7b6ae8766b/ext/fsevent/fsevent_watch.c#L171
111 | def pipe_readable?(pipe)
112 | IO.select([pipe])
113 | end
114 | else
115 | def pipe_readable?(pipe)
116 | !pipe.eof?
117 | end
118 | end
119 | end
120 | end
121 |
--------------------------------------------------------------------------------
/src/NetworkCookieJar.cpp:
--------------------------------------------------------------------------------
1 | #include "NetworkCookieJar.h"
2 | #include "QtCore/qdatetime.h"
3 |
4 | NetworkCookieJar::NetworkCookieJar(QObject *parent)
5 | : QNetworkCookieJar(parent)
6 | { }
7 |
8 | QList NetworkCookieJar::getAllCookies() const
9 | {
10 | return allCookies();
11 | }
12 |
13 | void NetworkCookieJar::clearCookies()
14 | {
15 | setAllCookies(QList());
16 | }
17 |
18 | static inline bool isParentDomain(QString domain, QString reference)
19 | {
20 | if (!reference.startsWith(QLatin1Char('.')))
21 | return domain == reference;
22 |
23 | return domain.endsWith(reference) || domain == reference.mid(1);
24 | }
25 |
26 | void NetworkCookieJar::overwriteCookies(const QList& cookieList)
27 | {
28 | /* this function is basically a copy-and-paste of the original
29 | QNetworkCookieJar::setCookiesFromUrl with the domain and
30 | path validations removed */
31 |
32 | QString defaultPath(QLatin1Char('/'));
33 | QDateTime now = QDateTime::currentDateTime();
34 | QList newCookies = allCookies();
35 |
36 | foreach (QNetworkCookie cookie, cookieList) {
37 | bool isDeletion = (!cookie.isSessionCookie() &&
38 | cookie.expirationDate() < now);
39 |
40 | // validate the cookie & set the defaults if unset
41 | if (cookie.path().isEmpty())
42 | cookie.setPath(defaultPath);
43 |
44 | // don't do path checking. See http://bugreports.qt.nokia.com/browse/QTBUG-5815
45 | // else if (!isParentPath(pathAndFileName, cookie.path())) {
46 | // continue; // not accepted
47 | // }
48 |
49 | if (cookie.domain().isEmpty()) {
50 | continue;
51 | } else {
52 | // Ensure the domain starts with a dot if its field was not empty
53 | // in the HTTP header. There are some servers that forget the
54 | // leading dot and this is actually forbidden according to RFC 2109,
55 | // but all browsers accept it anyway so we do that as well.
56 | if (!cookie.domain().startsWith(QLatin1Char('.')))
57 | cookie.setDomain(QLatin1Char('.') + cookie.domain());
58 |
59 | QString domain = cookie.domain();
60 |
61 | // the check for effective TLDs makes the "embedded dot" rule from RFC 2109 section 4.3.2
62 | // redundant; the "leading dot" rule has been relaxed anyway, see above
63 | // we remove the leading dot for this check
64 | /*
65 | if (QNetworkCookieJarPrivate::isEffectiveTLD(domain.remove(0, 1)))
66 | continue; // not accepted
67 | */
68 | }
69 |
70 | for (int i = 0; i < newCookies.size(); ++i) {
71 | // does this cookie already exist?
72 | const QNetworkCookie ¤t = newCookies.at(i);
73 | if (cookie.name() == current.name() &&
74 | cookie.domain() == current.domain() &&
75 | cookie.path() == current.path()) {
76 | // found a match
77 | newCookies.removeAt(i);
78 | break;
79 | }
80 | }
81 |
82 | // did not find a match
83 | if (!isDeletion) {
84 | int countForDomain = 0;
85 | for (int i = newCookies.size() - 1; i >= 0; --i) {
86 | // Start from the end and delete the oldest cookies to keep a maximum count of 50.
87 | const QNetworkCookie ¤t = newCookies.at(i);
88 | if (isParentDomain(cookie.domain(), current.domain())
89 | || isParentDomain(current.domain(), cookie.domain())) {
90 | if (countForDomain >= 49)
91 | newCookies.removeAt(i);
92 | else
93 | ++countForDomain;
94 | }
95 | }
96 |
97 | newCookies += cookie;
98 | }
99 | }
100 | setAllCookies(newCookies);
101 | }
102 |
--------------------------------------------------------------------------------
/lib/capybara/driver/webkit/browser.rb:
--------------------------------------------------------------------------------
1 | require 'json'
2 |
3 | class Capybara::Driver::Webkit
4 | class Browser
5 | def initialize(connection)
6 | @connection = connection
7 | end
8 |
9 | def visit(url)
10 | command "Visit", url
11 | end
12 |
13 | def header(key, value)
14 | command("Header", key, value)
15 | end
16 |
17 | def find(query)
18 | command("Find", query).split(",")
19 | end
20 |
21 | def reset!
22 | command("Reset")
23 | end
24 |
25 | def body
26 | command("Body")
27 | end
28 |
29 | def source
30 | command("Source")
31 | end
32 |
33 | def status_code
34 | command("Status").to_i
35 | end
36 |
37 | def console_messages
38 | command("ConsoleMessages").split("\n").map do |messages|
39 | parts = messages.split("|", 3)
40 | { :source => parts.first, :line_number => Integer(parts[1]), :message => parts.last }
41 | end
42 | end
43 |
44 | def error_messages
45 | console_messages.select do |message|
46 | message[:message] =~ /Error:/
47 | end
48 | end
49 |
50 | def response_headers
51 | Hash[command("Headers").split("\n").map { |header| header.split(": ") }]
52 | end
53 |
54 | def url
55 | command("Url")
56 | end
57 |
58 | def requested_url
59 | command("RequestedUrl")
60 | end
61 |
62 | def current_url
63 | command("CurrentUrl")
64 | end
65 |
66 | def frame_focus(frame_id_or_index=nil)
67 | if frame_id_or_index.is_a? Fixnum
68 | command("FrameFocus", "", frame_id_or_index.to_s)
69 | elsif frame_id_or_index
70 | command("FrameFocus", frame_id_or_index)
71 | else
72 | command("FrameFocus")
73 | end
74 | end
75 |
76 | def ignore_ssl_errors
77 | command("IgnoreSslErrors")
78 | end
79 |
80 | def command(name, *args)
81 | @connection.puts name
82 | @connection.puts args.size
83 | args.each do |arg|
84 | @connection.puts arg.to_s.bytesize
85 | @connection.print arg.to_s
86 | end
87 | check
88 | read_response
89 | end
90 |
91 | def evaluate_script(script)
92 | json = command('Evaluate', script)
93 | JSON.parse("[#{json}]").first
94 | end
95 |
96 | def execute_script(script)
97 | command('Execute', script)
98 | end
99 |
100 | def render(path, width, height)
101 | command "Render", path, width, height
102 | end
103 |
104 | def set_cookie(cookie)
105 | command "SetCookie", cookie
106 | end
107 |
108 | def clear_cookies
109 | command "ClearCookies"
110 | end
111 |
112 | def get_cookies
113 | command("GetCookies").lines.map{ |line| line.strip }.select{ |line| !line.empty? }
114 | end
115 |
116 | def set_proxy(options = {})
117 | options = default_proxy_options.merge(options)
118 | command("SetProxy", options[:host], options[:port], options[:user], options[:pass])
119 | end
120 |
121 | def clear_proxy
122 | command("SetProxy")
123 | end
124 |
125 | def resize_window(width, height)
126 | command("ResizeWindow", width.to_i, height.to_i)
127 | end
128 |
129 | private
130 |
131 | def check
132 | result = @connection.gets
133 | result.strip! if result
134 |
135 | if result.nil?
136 | raise WebkitNoResponseError, "No response received from the server."
137 | elsif result != 'ok'
138 | raise WebkitInvalidResponseError, read_response
139 | end
140 |
141 | result
142 | end
143 |
144 | def read_response
145 | response_length = @connection.gets.to_i
146 | if response_length > 0
147 | response = @connection.read(response_length)
148 | response.force_encoding("UTF-8") if response.respond_to?(:force_encoding)
149 | response
150 | else
151 | ""
152 | end
153 | end
154 |
155 | def default_proxy_options
156 | {
157 | :host => "localhost",
158 | :port => "0",
159 | :user => "",
160 | :pass => ""
161 | }
162 | end
163 | end
164 | end
165 |
--------------------------------------------------------------------------------
/spec/integration/session_spec.rb:
--------------------------------------------------------------------------------
1 | # -*- encoding: UTF-8 -*-
2 |
3 | require 'spec_helper'
4 | require 'capybara/webkit'
5 |
6 | describe Capybara::Session do
7 | subject { Capybara::Session.new(:reusable_webkit, @app) }
8 | after { subject.reset! }
9 |
10 | context "slow javascript app" do
11 | before(:all) do
12 | @app = lambda do |env|
13 | body = <<-HTML
14 |
15 |
16 |
Hello
17 |
18 |
27 |
28 | HTML
29 | [200,
30 | { 'Content-Type' => 'text/html', 'Content-Length' => body.length.to_s },
31 | [body]]
32 | end
33 | end
34 |
35 | before do
36 | @default_wait_time = Capybara.default_wait_time
37 | Capybara.default_wait_time = 1
38 | end
39 |
40 | after { Capybara.default_wait_time = @default_wait_time }
41 |
42 | it "waits for a request to load" do
43 | subject.visit("/")
44 | subject.find_button("Submit").click
45 | subject.should have_content("Goodbye");
46 | end
47 | end
48 |
49 | context "simple app" do
50 | before(:all) do
51 | @app = lambda do |env|
52 | body = <<-HTML
53 |
54 | Hello
55 | UTF8文字列
56 |
57 |
58 | HTML
59 | [200,
60 | { 'Content-Type' => 'text/html; charset=UTF-8', 'Content-Length' => body.length.to_s },
61 | [body]]
62 | end
63 | end
64 |
65 | before do
66 | subject.visit("/")
67 | end
68 |
69 | it "inspects nodes" do
70 | subject.all(:xpath, "//strong").first.inspect.should include("strong")
71 | end
72 |
73 | it "can read utf8 string" do
74 | utf8str = subject.all(:xpath, "//span").first.text
75 | utf8str.should eq('UTF8文字列')
76 | end
77 |
78 | it "can click utf8 string" do
79 | subject.click_button('ボタン')
80 | end
81 | end
82 |
83 | context "response headers with status code" do
84 | before(:all) do
85 | @app = lambda do |env|
86 | params = ::Rack::Utils.parse_query(env['QUERY_STRING'])
87 | if params["img"] == "true"
88 | body = 'not found'
89 | return [404, { 'Content-Type' => 'image/gif', 'Content-Length' => body.length.to_s }, [body]]
90 | end
91 | body = <<-HTML
92 |
93 |
94 |
95 |
96 |
97 | HTML
98 | [200,
99 | { 'Content-Type' => 'text/html', 'Content-Length' => body.length.to_s, 'X-Capybara' => 'WebKit'},
100 | [body]]
101 | end
102 | end
103 |
104 | it "should get status code" do
105 | subject.visit '/'
106 | subject.status_code.should == 200
107 | end
108 |
109 | it "should reset status code" do
110 | subject.visit '/'
111 | subject.status_code.should == 200
112 | subject.reset!
113 | subject.status_code.should == 0
114 | end
115 |
116 | it "should get response headers" do
117 | subject.visit '/'
118 | subject.response_headers['X-Capybara'].should == 'WebKit'
119 | end
120 |
121 | it "should reset response headers" do
122 | subject.visit '/'
123 | subject.response_headers['X-Capybara'].should == 'WebKit'
124 | subject.reset!
125 | subject.response_headers['X-Capybara'].should == nil
126 | end
127 | end
128 | end
129 |
130 | describe Capybara::Session, "with TestApp" do
131 | before do
132 | @session = Capybara::Session.new(:reusable_webkit, TestApp)
133 | end
134 |
135 | it_should_behave_like "session"
136 | it_should_behave_like "session with javascript support"
137 | end
138 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | capybara-webkit
2 | ===============
3 |
4 | A [capybara](https://github.com/jnicklas/capybara) driver that uses [WebKit](http://webkit.org) via [QtWebKit](http://doc.qt.nokia.com/4.7/qtwebkit.html).
5 |
6 | Qt Dependency and Installation Issues
7 | -------------
8 |
9 | capybara-webkit depends on a WebKit implementation from Qt, a cross-platform
10 | development toolkit. You'll need to download the Qt libraries to build and
11 | install the gem. You can find instructions for downloading and installing QT on
12 | the [capybara-webkit wiki](https://github.com/thoughtbot/capybara-webkit/wiki/Installing-Qt-and-compiling-capybara-webkit)
13 |
14 | Windows Support
15 | ---------------
16 |
17 | Currently 32bit Windows will compile Capybara-webkit. Support for Windows is provided by the open source community and Windows related issues should be posted to the [mailing list](http://groups.google.com/group/capybara-webkit)
18 |
19 | Reporting Issues
20 | ----------------
21 |
22 | Without access to your application code we can't easily debug most crashes or
23 | generic failures, so we've included a debug version of the driver that prints a
24 | log of what happened during each test. Before filing a crash bug, please see
25 | [Reporting Crashes](https://github.com/thoughtbot/capybara-webkit/wiki/Reporting-Crashes).
26 | You're much more likely to get a fix if you follow those instructions.
27 |
28 | If you are having compiling issues please check out the
29 | [capybara-webkit wiki](https://github.com/thoughtbot/capybara-webkit/wiki/Installing-Qt-and-compiling-capybara-webkit).
30 | If you don't have any luck there, please post to the
31 | [mailing list](http://groups.google.com/group/capybara-webkit). Please don't
32 | open a Github issue for a system-specific compiler issue.
33 |
34 | CI
35 | --
36 |
37 | If you're like us, you'll be using capybara-webkit on CI.
38 |
39 | On Linux platforms, capybara-webkit requires an X server to run, although it doesn't create any visible windows. Xvfb works fine for this. You can setup Xvfb yourself and set a DISPLAY variable, or try out the [headless gem](https://github.com/leonid-shevtsov/headless).
40 |
41 | Usage
42 | -----
43 |
44 | Add the capybara-webkit gem to your Gemfile:
45 |
46 | gem "capybara-webkit"
47 |
48 | Set your Capybara Javascript driver to webkit:
49 |
50 | Capybara.javascript_driver = :webkit
51 |
52 | In cucumber, tag scenarios with @javascript to run them using a headless WebKit browser.
53 |
54 | In RSpec, use the :js => true flag.
55 |
56 | Take note of the transactional fixtures section of the [capybara README](https://github.com/jnicklas/capybara/blob/master/README.md).
57 |
58 | If you're using capybara-webkit with Sinatra, don't forget to set
59 |
60 | Capybara.app = MySinatraApp.new
61 |
62 | Non-Standard Driver Methods
63 | ---------------------------
64 |
65 | capybara-webkit supports a few methods that are not part of the standard capybara API. You can access these by calling `driver` on the capybara session. When using the DSL, that will look like `page.driver.method_name`.
66 |
67 | **console_messages**: returns an array of messages printed using console.log
68 |
69 | # In Javascript:
70 | console.log("hello")
71 | # In Ruby:
72 | page.driver.console_messages
73 | => {:source=>"http://example.com", :line_number=>1, :message=>"hello"}
74 |
75 | **error_messages**: returns an array of Javascript errors that occurred
76 |
77 | page.driver.error_messages
78 | => {:source=>"http://example.com", :line_number=>1, :message=>"SyntaxError: Parse error"}
79 |
80 | **resize_window**: change the viewport size to the given width and height
81 |
82 | page.driver.resize_window(500, 300)
83 | page.driver.evaluate_script("window.innerWidth")
84 | => 500
85 |
86 | **render**: render a screenshot of the current view (requires [mini_magick](https://github.com/probablycorey/mini_magick) and [ImageMagick](http://www.imagemagick.org))
87 |
88 | page.driver.render "tmp/screenshot.png"
89 |
90 | **cookies**: allows read-only access of cookies for the current session
91 |
92 | page.driver.cookies["alpha"]
93 | => "abc"
94 |
95 | Contributing
96 | ------------
97 |
98 | See the CONTRIBUTING document.
99 |
100 | About
101 | -----
102 |
103 | The capybara WebKit driver is maintained by Joe Ferris and Matt Mongeau. It was written by [thoughtbot, inc](http://thoughtbot.com/community) with the help of numerous [contributions from the open source community](https://github.com/thoughtbot/capybara-webkit/contributors).
104 |
105 | Code for rendering the current webpage to a PNG is borrowed from Phantom.js' implementation.
106 |
107 | 
108 |
109 | The names and logos for thoughtbot are trademarks of thoughtbot, inc.
110 |
111 | License
112 | -------
113 |
114 | capybara-webkit is Copyright (c) 2011 thoughtbot, inc. It is free software, and may be redistributed under the terms specified in the LICENSE file.
115 |
--------------------------------------------------------------------------------
/spec/browser_spec.rb:
--------------------------------------------------------------------------------
1 | require 'spec_helper'
2 | require 'self_signed_ssl_cert'
3 | require 'stringio'
4 | require 'capybara/driver/webkit/browser'
5 | require 'capybara/driver/webkit/connection'
6 | require 'socket'
7 | require 'base64'
8 |
9 | describe Capybara::Driver::Webkit::Browser do
10 |
11 | let(:browser) { Capybara::Driver::Webkit::Browser.new(Capybara::Driver::Webkit::Connection.new) }
12 | let(:browser_ignore_ssl_err) do
13 | Capybara::Driver::Webkit::Browser.new(Capybara::Driver::Webkit::Connection.new).tap do |browser|
14 | browser.ignore_ssl_errors
15 | end
16 | end
17 |
18 | context 'handling of SSL validation errors' do
19 | before do
20 | # set up minimal HTTPS server
21 | @host = "127.0.0.1"
22 | @server = TCPServer.new(@host, 0)
23 | @port = @server.addr[1]
24 |
25 | # set up SSL layer
26 | ssl_serv = OpenSSL::SSL::SSLServer.new(@server, $openssl_self_signed_ctx)
27 |
28 | @server_thread = Thread.new(ssl_serv) do |serv|
29 | while conn = serv.accept do
30 | # read request
31 | request = []
32 | until (line = conn.readline.strip).empty?
33 | request << line
34 | end
35 |
36 | # write response
37 | html = "D'oh!"
38 | conn.write "HTTP/1.1 200 OK\r\n"
39 | conn.write "Content-Type:text/html\r\n"
40 | conn.write "Content-Length: %i\r\n" % html.size
41 | conn.write "\r\n"
42 | conn.write html
43 | conn.close
44 | end
45 | end
46 | end
47 |
48 | after do
49 | @server_thread.kill
50 | @server.close
51 | end
52 |
53 | it "doesn't accept a self-signed certificate by default" do
54 | lambda { browser.visit "https://#{@host}:#{@port}/" }.should raise_error
55 | end
56 |
57 | it 'accepts a self-signed certificate if configured to do so' do
58 | browser_ignore_ssl_err.visit "https://#{@host}:#{@port}/"
59 | end
60 | end
61 |
62 | describe "forking", :skip_on_windows => true do
63 | it "only shuts down the server from the main process" do
64 | browser.reset!
65 | pid = fork {}
66 | Process.wait(pid)
67 | expect { browser.reset! }.not_to raise_error
68 | end
69 | end
70 |
71 | describe '#set_proxy' do
72 | before do
73 | @host = '127.0.0.1'
74 | @user = 'user'
75 | @pass = 'secret'
76 | @url = "http://example.org/"
77 |
78 | @server = TCPServer.new(@host, 0)
79 | @port = @server.addr[1]
80 |
81 | @proxy_requests = []
82 | @proxy = Thread.new(@server, @proxy_requests) do |serv, proxy_requests|
83 | while conn = serv.accept do
84 | # read request
85 | request = []
86 | until (line = conn.readline.strip).empty?
87 | request << line
88 | end
89 |
90 | # send response
91 | auth_header = request.find { |h| h =~ /Authorization:/i }
92 | if auth_header || request[0].split(/\s+/)[1] =~ /^\//
93 | html = "D'oh!"
94 | conn.write "HTTP/1.1 200 OK\r\n"
95 | conn.write "Content-Type:text/html\r\n"
96 | conn.write "Content-Length: %i\r\n" % html.size
97 | conn.write "\r\n"
98 | conn.write html
99 | conn.close
100 | proxy_requests << request if auth_header
101 | else
102 | conn.write "HTTP/1.1 407 Proxy Auth Required\r\n"
103 | conn.write "Proxy-Authenticate: Basic realm=\"Proxy\"\r\n"
104 | conn.write "\r\n"
105 | conn.close
106 | proxy_requests << request
107 | end
108 | end
109 | end
110 |
111 | browser.set_proxy(:host => @host,
112 | :port => @port,
113 | :user => @user,
114 | :pass => @pass)
115 | browser.visit @url
116 | @proxy_requests.size.should == 2
117 | @request = @proxy_requests[-1]
118 | end
119 |
120 | after do
121 | @proxy.kill
122 | @server.close
123 | end
124 |
125 | it 'uses the HTTP proxy correctly' do
126 | @request[0].should match /^GET\s+http:\/\/example.org\/\s+HTTP/i
127 | @request.find { |header|
128 | header =~ /^Host:\s+example.org$/i }.should_not be nil
129 | end
130 |
131 | it 'sends correct proxy authentication' do
132 | auth_header = @request.find { |header|
133 | header =~ /^Proxy-Authorization:\s+/i }
134 | auth_header.should_not be nil
135 |
136 | user, pass = Base64.decode64(auth_header.split(/\s+/)[-1]).split(":")
137 | user.should == @user
138 | pass.should == @pass
139 | end
140 |
141 | it "uses the proxies' response" do
142 | browser.body.should include "D'oh!"
143 | end
144 |
145 | it 'uses original URL' do
146 | browser.url.should == @url
147 | end
148 |
149 | it 'uses URLs changed by javascript' do
150 | browser.execute_script "window.history.pushState('', '', '/blah')"
151 | browser.requested_url.should == 'http://example.org/blah'
152 | end
153 |
154 | it 'is possible to disable proxy again' do
155 | @proxy_requests.clear
156 | browser.clear_proxy
157 | browser.visit "http://#{@host}:#{@port}/"
158 | @proxy_requests.size.should == 0
159 | end
160 | end
161 |
162 | it "doesn't try to read an empty response" do
163 | connection = stub("connection")
164 | connection.stub(:puts)
165 | connection.stub(:print)
166 | connection.stub(:gets).and_return("ok\n", "0\n")
167 | connection.stub(:read).and_raise(StandardError.new("tried to read empty response"))
168 |
169 | browser = Capybara::Driver::Webkit::Browser.new(connection)
170 |
171 | expect { browser.visit("/") }.not_to raise_error(/empty response/)
172 | end
173 | end
174 |
--------------------------------------------------------------------------------
/src/WebPage.cpp:
--------------------------------------------------------------------------------
1 | #include "WebPage.h"
2 | #include "JavascriptInvocation.h"
3 | #include "NetworkAccessManager.h"
4 | #include "NetworkCookieJar.h"
5 | #include "UnsupportedContentHandler.h"
6 | #include
7 | #include
8 |
9 | WebPage::WebPage(QObject *parent) : QWebPage(parent) {
10 | setForwardUnsupportedContent(true);
11 | loadJavascript();
12 | setUserStylesheet();
13 |
14 | m_loading = false;
15 | m_ignoreSslErrors = false;
16 | this->setCustomNetworkAccessManager();
17 |
18 | connect(this, SIGNAL(loadStarted()), this, SLOT(loadStarted()));
19 | connect(this, SIGNAL(loadFinished(bool)), this, SLOT(loadFinished(bool)));
20 | connect(this, SIGNAL(frameCreated(QWebFrame *)),
21 | this, SLOT(frameCreated(QWebFrame *)));
22 | connect(this, SIGNAL(unsupportedContent(QNetworkReply*)),
23 | this, SLOT(handleUnsupportedContent(QNetworkReply*)));
24 | resetWindowSize();
25 | }
26 |
27 | void WebPage::resetWindowSize() {
28 | this->setViewportSize(QSize(1680, 1050));
29 | this->settings()->setAttribute(QWebSettings::LocalStorageDatabaseEnabled, true);
30 | }
31 |
32 | void WebPage::setCustomNetworkAccessManager() {
33 | NetworkAccessManager *manager = new NetworkAccessManager();
34 | manager->setCookieJar(new NetworkCookieJar());
35 | this->setNetworkAccessManager(manager);
36 | connect(manager, SIGNAL(finished(QNetworkReply *)), this, SLOT(replyFinished(QNetworkReply *)));
37 | connect(manager, SIGNAL(sslErrors(QNetworkReply *, QList)),
38 | this, SLOT(handleSslErrorsForReply(QNetworkReply *, QList)));
39 | }
40 |
41 | void WebPage::loadJavascript() {
42 | QResource javascript(":/capybara.js");
43 | if (javascript.isCompressed()) {
44 | QByteArray uncompressedBytes(qUncompress(javascript.data(), javascript.size()));
45 | m_capybaraJavascript = QString(uncompressedBytes);
46 | } else {
47 | char * javascriptString = new char[javascript.size() + 1];
48 | strcpy(javascriptString, (const char *)javascript.data());
49 | javascriptString[javascript.size()] = 0;
50 | m_capybaraJavascript = javascriptString;
51 | }
52 | }
53 |
54 | void WebPage::setUserStylesheet() {
55 | QString data = QString("* { font-family: 'Arial' ! important; }").toUtf8().toBase64();
56 | QUrl url = QUrl(QString("data:text/css;charset=utf-8;base64,") + data);
57 | settings()->setUserStyleSheetUrl(url);
58 | }
59 |
60 | QString WebPage::userAgentForUrl(const QUrl &url ) const {
61 | if (!m_userAgent.isEmpty()) {
62 | return m_userAgent;
63 | } else {
64 | return QWebPage::userAgentForUrl(url);
65 | }
66 | }
67 |
68 | QString WebPage::consoleMessages() {
69 | return m_consoleMessages.join("\n");
70 | }
71 |
72 | void WebPage::setUserAgent(QString userAgent) {
73 | m_userAgent = userAgent;
74 | }
75 |
76 | void WebPage::frameCreated(QWebFrame * frame) {
77 | connect(frame, SIGNAL(javaScriptWindowObjectCleared()),
78 | this, SLOT(injectJavascriptHelpers()));
79 | }
80 |
81 | void WebPage::injectJavascriptHelpers() {
82 | QWebFrame* frame = qobject_cast(QObject::sender());
83 | frame->evaluateJavaScript(m_capybaraJavascript);
84 | }
85 |
86 | bool WebPage::shouldInterruptJavaScript() {
87 | return false;
88 | }
89 |
90 | QVariant WebPage::invokeCapybaraFunction(const char *name, QStringList &arguments) {
91 | QString qname(name);
92 | QString objectName("CapybaraInvocation");
93 | JavascriptInvocation invocation(qname, arguments);
94 | currentFrame()->addToJavaScriptWindowObject(objectName, &invocation);
95 | QString javascript = QString("Capybara.invoke()");
96 | return currentFrame()->evaluateJavaScript(javascript);
97 | }
98 |
99 | QVariant WebPage::invokeCapybaraFunction(QString &name, QStringList &arguments) {
100 | return invokeCapybaraFunction(name.toAscii().data(), arguments);
101 | }
102 |
103 | void WebPage::javaScriptConsoleMessage(const QString &message, int lineNumber, const QString &sourceID) {
104 | QString fullMessage = QString::number(lineNumber) + "|" + message;
105 | if (!sourceID.isEmpty())
106 | fullMessage = sourceID + "|" + fullMessage;
107 | m_consoleMessages.append(fullMessage);
108 | std::cout << qPrintable(fullMessage) << std::endl;
109 | }
110 |
111 | void WebPage::javaScriptAlert(QWebFrame *frame, const QString &message) {
112 | Q_UNUSED(frame);
113 | std::cout << "ALERT: " << qPrintable(message) << std::endl;
114 | }
115 |
116 | bool WebPage::javaScriptConfirm(QWebFrame *frame, const QString &message) {
117 | Q_UNUSED(frame);
118 | Q_UNUSED(message);
119 | return true;
120 | }
121 |
122 | bool WebPage::javaScriptPrompt(QWebFrame *frame, const QString &message, const QString &defaultValue, QString *result) {
123 | Q_UNUSED(frame)
124 | Q_UNUSED(message)
125 | Q_UNUSED(defaultValue)
126 | Q_UNUSED(result)
127 | return false;
128 | }
129 |
130 | void WebPage::loadStarted() {
131 | m_loading = true;
132 | }
133 |
134 | void WebPage::loadFinished(bool success) {
135 | m_loading = false;
136 | emit pageFinished(success);
137 | }
138 |
139 | bool WebPage::isLoading() const {
140 | return m_loading;
141 | }
142 |
143 | QString WebPage::failureString() {
144 | return QString("Unable to load URL: ") + currentFrame()->requestedUrl().toString();
145 | }
146 |
147 | bool WebPage::render(const QString &fileName) {
148 | QFileInfo fileInfo(fileName);
149 | QDir dir;
150 | dir.mkpath(fileInfo.absolutePath());
151 |
152 | QSize viewportSize = this->viewportSize();
153 | QSize pageSize = this->mainFrame()->contentsSize();
154 | if (pageSize.isEmpty()) {
155 | return false;
156 | }
157 |
158 | QImage buffer(pageSize, QImage::Format_ARGB32);
159 | buffer.fill(qRgba(255, 255, 255, 0));
160 |
161 | QPainter p(&buffer);
162 | p.setRenderHint( QPainter::Antialiasing, true);
163 | p.setRenderHint( QPainter::TextAntialiasing, true);
164 | p.setRenderHint( QPainter::SmoothPixmapTransform, true);
165 |
166 | this->setViewportSize(pageSize);
167 | this->mainFrame()->render(&p);
168 | p.end();
169 | this->setViewportSize(viewportSize);
170 |
171 | return buffer.save(fileName);
172 | }
173 |
174 | QString WebPage::chooseFile(QWebFrame *parentFrame, const QString &suggestedFile) {
175 | Q_UNUSED(parentFrame);
176 | Q_UNUSED(suggestedFile);
177 |
178 | return getLastAttachedFileName();
179 | }
180 |
181 | bool WebPage::extension(Extension extension, const ExtensionOption *option, ExtensionReturn *output) {
182 | Q_UNUSED(option);
183 | if (extension == ChooseMultipleFilesExtension) {
184 | QStringList names = QStringList() << getLastAttachedFileName();
185 | static_cast(output)->fileNames = names;
186 | return true;
187 | }
188 | return false;
189 | }
190 |
191 | QString WebPage::getLastAttachedFileName() {
192 | return currentFrame()->evaluateJavaScript(QString("Capybara.lastAttachedFile")).toString();
193 | }
194 |
195 | void WebPage::replyFinished(QNetworkReply *reply) {
196 | if (reply->url() == this->currentFrame()->url()) {
197 | QStringList headers;
198 | m_lastStatus = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
199 | QList list = reply->rawHeaderList();
200 |
201 | int length = list.size();
202 | for(int i = 0; i < length; i++) {
203 | headers << list.at(i)+": "+reply->rawHeader(list.at(i));
204 | }
205 |
206 | m_pageHeaders = headers.join("\n");
207 | }
208 | }
209 |
210 | void WebPage::handleSslErrorsForReply(QNetworkReply *reply, const QList &errors) {
211 | if (m_ignoreSslErrors)
212 | reply->ignoreSslErrors(errors);
213 | }
214 |
215 | void WebPage::ignoreSslErrors() {
216 | m_ignoreSslErrors = true;
217 | }
218 |
219 | int WebPage::getLastStatus() {
220 | return m_lastStatus;
221 | }
222 |
223 | void WebPage::resetResponseHeaders() {
224 | m_lastStatus = 0;
225 | m_pageHeaders = QString();
226 | }
227 |
228 | void WebPage::resetConsoleMessages() {
229 | m_consoleMessages.clear();
230 | }
231 |
232 | QString WebPage::pageHeaders() {
233 | return m_pageHeaders;
234 | }
235 |
236 | void WebPage::handleUnsupportedContent(QNetworkReply *reply) {
237 | UnsupportedContentHandler *handler = new UnsupportedContentHandler(this, reply);
238 | Q_UNUSED(handler);
239 | }
240 |
--------------------------------------------------------------------------------
/src/capybara.js:
--------------------------------------------------------------------------------
1 | Capybara = {
2 | nextIndex: 0,
3 | nodes: {},
4 | lastAttachedFile: "",
5 |
6 | invoke: function () {
7 | return this[CapybaraInvocation.functionName].apply(this, CapybaraInvocation.arguments);
8 | },
9 |
10 | find: function (xpath) {
11 | return this.findRelativeTo(document, xpath);
12 | },
13 |
14 | findWithin: function (index, xpath) {
15 | return this.findRelativeTo(this.nodes[index], xpath);
16 | },
17 |
18 | findRelativeTo: function (reference, xpath) {
19 | var iterator = document.evaluate(xpath, reference, null, XPathResult.ORDERED_NODE_ITERATOR_TYPE, null);
20 | var node;
21 | var results = [];
22 | while (node = iterator.iterateNext()) {
23 | this.nextIndex++;
24 | this.nodes[this.nextIndex] = node;
25 | results.push(this.nextIndex);
26 | }
27 | return results.join(",");
28 | },
29 |
30 | isAttached: function(index) {
31 | return document.evaluate("ancestor-or-self::html", this.nodes[index], null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue != null;
32 | },
33 |
34 | text: function (index) {
35 | var node = this.nodes[index];
36 | var type = (node.type || node.tagName).toLowerCase();
37 | if (type == "textarea") {
38 | return node.innerHTML;
39 | } else {
40 | return node.innerText;
41 | }
42 | },
43 |
44 | attribute: function (index, name) {
45 | switch(name) {
46 | case 'checked':
47 | return this.nodes[index].checked;
48 | break;
49 |
50 | case 'disabled':
51 | return this.nodes[index].disabled;
52 | break;
53 |
54 | default:
55 | return this.nodes[index].getAttribute(name);
56 | }
57 | },
58 |
59 | path: function(index) {
60 | return "/" + this.getXPathNode(this.nodes[index]).join("/");
61 | },
62 |
63 | getXPathNode: function(node, path) {
64 | path = path || [];
65 | if (node.parentNode) {
66 | path = this.getXPathNode(node.parentNode, path);
67 | }
68 |
69 | var first = node;
70 | while (first.previousSibling)
71 | first = first.previousSibling;
72 |
73 | var count = 0;
74 | var index = 0;
75 | var iter = first;
76 | while (iter) {
77 | if (iter.nodeType == 1 && iter.nodeName == node.nodeName)
78 | count++;
79 | if (iter.isSameNode(node))
80 | index = count;
81 | iter = iter.nextSibling;
82 | continue;
83 | }
84 |
85 | if (node.nodeType == 1)
86 | path.push(node.nodeName.toLowerCase() + (node.id ? "[@id='"+node.id+"']" : count > 1 ? "["+index+"]" : ''));
87 |
88 | return path;
89 | },
90 |
91 | tagName: function(index) {
92 | return this.nodes[index].tagName.toLowerCase();
93 | },
94 |
95 | submit: function(index) {
96 | return this.nodes[index].submit();
97 | },
98 |
99 | mousedown: function(index) {
100 | var mousedownEvent = document.createEvent('MouseEvents');
101 | mousedownEvent.initMouseEvent('mousedown', true, true, window, 0, 0, 0, 0, 0, false, false, false, false, 0, null);
102 | this.nodes[index].dispatchEvent(mousedownEvent);
103 | },
104 |
105 | mouseup: function(index) {
106 | var mouseupEvent = document.createEvent('MouseEvents');
107 | mouseupEvent.initMouseEvent('mouseup', true, true, window, 0, 0, 0, 0, 0, false, false, false, false, 0, null);
108 | this.nodes[index].dispatchEvent(mouseupEvent);
109 | },
110 |
111 | click: function (index) {
112 | this.mousedown(index);
113 | this.mouseup(index);
114 | var clickEvent = document.createEvent('MouseEvents');
115 | clickEvent.initMouseEvent('click', true, true, window, 0, 0, 0, 0, 0, false, false, false, false, 0, null);
116 | this.nodes[index].dispatchEvent(clickEvent);
117 | },
118 |
119 | trigger: function (index, eventName) {
120 | var eventObject = document.createEvent("HTMLEvents");
121 | eventObject.initEvent(eventName, true, true);
122 | this.nodes[index].dispatchEvent(eventObject);
123 | },
124 |
125 | keypress: function(index, altKey, ctrlKey, shiftKey, metaKey, keyCode, charCode) {
126 | var eventObject = document.createEvent("Events");
127 | eventObject.initEvent('keypress', true, true);
128 | eventObject.window = window;
129 | eventObject.altKey = altKey;
130 | eventObject.ctrlKey = ctrlKey;
131 | eventObject.shiftKey = shiftKey;
132 | eventObject.metaKey = metaKey;
133 | eventObject.keyCode = keyCode;
134 | eventObject.charCode = charCode;
135 | eventObject.which = keyCode;
136 | this.nodes[index].dispatchEvent(eventObject);
137 | },
138 |
139 | keyupdown: function(index, eventName, keyCode) {
140 | var eventObject = document.createEvent("HTMLEvents");
141 | eventObject.initEvent(eventName, true, true);
142 | eventObject.keyCode = keyCode;
143 | eventObject.which = keyCode;
144 | eventObject.charCode = 0;
145 | this.nodes[index].dispatchEvent(eventObject);
146 | },
147 |
148 | visible: function (index) {
149 | var element = this.nodes[index];
150 | while (element) {
151 | if (element.ownerDocument.defaultView.getComputedStyle(element, null).getPropertyValue("display") == 'none')
152 | return false;
153 | element = element.parentElement;
154 | }
155 | return true;
156 | },
157 |
158 | selected: function (index) {
159 | return this.nodes[index].selected;
160 | },
161 |
162 | value: function(index) {
163 | return this.nodes[index].value;
164 | },
165 |
166 | characterToKeyCode: function(character) {
167 | var code = character.toUpperCase().charCodeAt(0);
168 | var specialKeys = {
169 | 96: 192, //`
170 | 45: 189, //-
171 | 61: 187, //=
172 | 91: 219, //[
173 | 93: 221, //]
174 | 92: 220, //\
175 | 59: 186, //;
176 | 39: 222, //'
177 | 44: 188, //,
178 | 46: 190, //.
179 | 47: 191, ///
180 | 127: 46, //delete
181 | 126: 192, //~
182 | 33: 49, //!
183 | 64: 50, //@
184 | 35: 51, //#
185 | 36: 52, //$
186 | 37: 53, //%
187 | 94: 54, //^
188 | 38: 55, //&
189 | 42: 56, //*
190 | 40: 57, //(
191 | 41: 48, //)
192 | 95: 189, //_
193 | 43: 187, //+
194 | 123: 219, //{
195 | 125: 221, //}
196 | 124: 220, //|
197 | 58: 186, //:
198 | 34: 222, //"
199 | 60: 188, //<
200 | 62: 190, //>
201 | 63: 191 //?
202 | };
203 | if (specialKeys[code]) {
204 | code = specialKeys[code];
205 | }
206 | return code;
207 | },
208 |
209 | set: function (index, value) {
210 | var length, maxLength, node, strindex, textTypes, type;
211 |
212 | node = this.nodes[index];
213 | type = (node.type || node.tagName).toLowerCase();
214 | textTypes = ["email", "number", "password", "search", "tel", "text", "textarea", "url"];
215 |
216 | if (textTypes.indexOf(type) != -1) {
217 | this.trigger(index, "focus");
218 |
219 | maxLength = this.attribute(index, "maxlength");
220 | if (maxLength && value.length > maxLength) {
221 | length = maxLength;
222 | } else {
223 | length = value.length;
224 | }
225 |
226 | node.value = "";
227 | for (strindex = 0; strindex < length; strindex++) {
228 | node.value += value[strindex];
229 | var keyCode = this.characterToKeyCode(value[strindex]);
230 | this.keyupdown(index, "keydown", keyCode);
231 | this.keypress(index, false, false, false, false, value.charCodeAt(strindex), value.charCodeAt(strindex));
232 | this.keyupdown(index, "keyup", keyCode);
233 | this.trigger(index, "input");
234 | }
235 | this.trigger(index, "change");
236 | this.trigger(index, "blur");
237 |
238 | } else if (type === "checkbox" || type === "radio") {
239 | if (node.checked != (value === "true")) {
240 | this.click(index)
241 | }
242 |
243 | } else if (type === "file") {
244 | this.lastAttachedFile = value;
245 | this.click(index)
246 |
247 | } else {
248 | node.value = value;
249 | }
250 | },
251 |
252 | selectOption: function(index) {
253 | this.nodes[index].selected = true;
254 | this.trigger(index, "change");
255 | },
256 |
257 | unselectOption: function(index) {
258 | this.nodes[index].selected = false;
259 | this.trigger(index, "change");
260 | },
261 |
262 | centerPostion: function(element) {
263 | this.reflow(element);
264 | var rect = element.getBoundingClientRect();
265 | var position = {
266 | x: rect.width / 2,
267 | y: rect.height / 2
268 | };
269 | do {
270 | position.x += element.offsetLeft;
271 | position.y += element.offsetTop;
272 | } while ((element = element.offsetParent));
273 | position.x = Math.floor(position.x), position.y = Math.floor(position.y);
274 |
275 | return position;
276 | },
277 |
278 | reflow: function(element, force) {
279 | if (force || element.offsetWidth === 0) {
280 | var prop, oldStyle = {}, newStyle = {position: "absolute", visibility : "hidden", display: "block" };
281 | for (prop in newStyle) {
282 | oldStyle[prop] = element.style[prop];
283 | element.style[prop] = newStyle[prop];
284 | }
285 | element.offsetWidth, element.offsetHeight; // force reflow
286 | for (prop in oldStyle)
287 | element.style[prop] = oldStyle[prop];
288 | }
289 | },
290 |
291 | dragTo: function (index, targetIndex) {
292 | var element = this.nodes[index], target = this.nodes[targetIndex];
293 | var position = this.centerPostion(element);
294 | var options = {
295 | clientX: position.x,
296 | clientY: position.y
297 | };
298 | var mouseTrigger = function(eventName, options) {
299 | var eventObject = document.createEvent("MouseEvents");
300 | eventObject.initMouseEvent(eventName, true, true, window, 0, 0, 0, options.clientX || 0, options.clientY || 0, false, false, false, false, 0, null);
301 | element.dispatchEvent(eventObject);
302 | }
303 | mouseTrigger('mousedown', options);
304 | options.clientX += 1, options.clientY += 1;
305 | mouseTrigger('mousemove', options);
306 |
307 | position = this.centerPostion(target), options = {
308 | clientX: position.x,
309 | clientY: position.y
310 | };
311 | mouseTrigger('mousemove', options);
312 | mouseTrigger('mouseup', options);
313 | }
314 | };
315 |
316 |
--------------------------------------------------------------------------------
/spec/driver_spec.rb:
--------------------------------------------------------------------------------
1 | require 'spec_helper'
2 | require 'capybara/driver/webkit'
3 |
4 | describe Capybara::Driver::Webkit do
5 | subject { Capybara::Driver::Webkit.new(@app, :browser => $webkit_browser) }
6 | before { subject.visit("/hello/world?success=true") }
7 | after { subject.reset! }
8 |
9 | context "iframe app" do
10 | before(:all) do
11 | @app = lambda do |env|
12 | params = ::Rack::Utils.parse_query(env['QUERY_STRING'])
13 | if params["iframe"] == "true"
14 | # We are in an iframe request.
15 | p_id = "farewell"
16 | msg = "goodbye"
17 | iframe = nil
18 | else
19 | # We are not in an iframe request and need to make an iframe!
20 | p_id = "greeting"
21 | msg = "hello"
22 | iframe = ""
23 | end
24 | body = <<-HTML
25 |
26 |
27 |
30 |
31 |
32 | #{iframe}
33 |
36 |
37 |
38 | HTML
39 | [200,
40 | { 'Content-Type' => 'text/html', 'Content-Length' => body.length.to_s },
41 | [body]]
42 | end
43 | end
44 |
45 | it "finds frames by index" do
46 | subject.within_frame(0) do
47 | subject.find("//*[contains(., 'goodbye')]").should_not be_empty
48 | end
49 | end
50 |
51 | it "finds frames by id" do
52 | subject.within_frame("f") do
53 | subject.find("//*[contains(., 'goodbye')]").should_not be_empty
54 | end
55 | end
56 |
57 | it "raises error for missing frame by index" do
58 | expect { subject.within_frame(1) { } }.
59 | to raise_error(Capybara::Driver::Webkit::WebkitInvalidResponseError)
60 | end
61 |
62 | it "raise_error for missing frame by id" do
63 | expect { subject.within_frame("foo") { } }.
64 | to raise_error(Capybara::Driver::Webkit::WebkitInvalidResponseError)
65 | end
66 |
67 | it "returns an attribute's value" do
68 | subject.within_frame("f") do
69 | subject.find("//p").first["id"].should == "farewell"
70 | end
71 | end
72 |
73 | it "returns a node's text" do
74 | subject.within_frame("f") do
75 | subject.find("//p").first.text.should == "goodbye"
76 | end
77 | end
78 |
79 | it "returns the current URL" do
80 | subject.within_frame("f") do
81 | port = subject.instance_variable_get("@rack_server").port
82 | subject.current_url.should == "http://127.0.0.1:#{port}/?iframe=true"
83 | end
84 | end
85 |
86 | it "returns the source code for the page" do
87 | subject.within_frame("f") do
88 | subject.source.should =~ %r{.*farewell.*}m
89 | end
90 | end
91 |
92 | it "evaluates Javascript" do
93 | subject.within_frame("f") do
94 | result = subject.evaluate_script(%)
95 | result.should == "goodbye"
96 | end
97 | end
98 |
99 | it "executes Javascript" do
100 | subject.within_frame("f") do
101 | subject.execute_script(%)
102 | subject.find("//p[contains(., 'yo')]").should_not be_empty
103 | end
104 | end
105 | end
106 |
107 | context "redirect app" do
108 | before(:all) do
109 | @app = lambda do |env|
110 | if env['PATH_INFO'] == '/target'
111 | content_type = "
#{env['CONTENT_TYPE']}
"
112 | [200, {"Content-Type" => "text/html", "Content-Length" => content_type.length.to_s}, [content_type]]
113 | elsif env['PATH_INFO'] == '/form'
114 | body = <<-HTML
115 |
116 |
117 |
120 |
121 |
122 | HTML
123 | [200, {"Content-Type" => "text/html", "Content-Length" => body.length.to_s}, [body]]
124 | else
125 | [301, {"Location" => "/target"}, [""]]
126 | end
127 | end
128 | end
129 |
130 | it "should redirect without content type" do
131 | subject.visit("/form")
132 | subject.find("//input").first.click
133 | subject.find("//p").first.text.should == ""
134 | end
135 |
136 | it "returns the current URL when changed by pushState after a redirect" do
137 | subject.visit("/redirect-me")
138 | port = subject.instance_variable_get("@rack_server").port
139 | subject.execute_script("window.history.pushState({}, '', '/pushed-after-redirect')")
140 | subject.current_url.should == "http://127.0.0.1:#{port}/pushed-after-redirect"
141 | end
142 |
143 | it "returns the current URL when changed by replaceState after a redirect" do
144 | subject.visit("/redirect-me")
145 | port = subject.instance_variable_get("@rack_server").port
146 | subject.execute_script("window.history.replaceState({}, '', '/replaced-after-redirect')")
147 | subject.current_url.should == "http://127.0.0.1:#{port}/replaced-after-redirect"
148 | end
149 | end
150 |
151 | context "css app" do
152 | before(:all) do
153 | body = "css"
154 | @app = lambda do |env|
155 | [200, {"Content-Type" => "text/css", "Content-Length" => body.length.to_s}, [body]]
156 | end
157 | subject.visit("/")
158 | end
159 |
160 | it "renders unsupported content types gracefully" do
161 | subject.body.should =~ /css/
162 | end
163 |
164 | it "sets the response headers with respect to the unsupported request" do
165 | subject.response_headers["Content-Type"].should == "text/css"
166 | end
167 | end
168 |
169 | context "hello app" do
170 | before(:all) do
171 | @app = lambda do |env|
172 | body = <<-HTML
173 |
174 |
175 |
178 |
179 |
180 |
Spaces not normalized
181 |
182 |
Can't see me
183 |
184 |
185 |
186 |
189 |
190 |
191 | HTML
192 | [200,
193 | { 'Content-Type' => 'text/html', 'Content-Length' => body.length.to_s },
194 | [body]]
195 | end
196 | end
197 |
198 | it "handles anchor tags" do
199 | subject.visit("#test")
200 | subject.find("//*[contains(., 'hello')]").should_not be_empty
201 | subject.visit("#test")
202 | subject.find("//*[contains(., 'hello')]").should_not be_empty
203 | end
204 |
205 | it "finds content after loading a URL" do
206 | subject.find("//*[contains(., 'hello')]").should_not be_empty
207 | end
208 |
209 | it "has an empty page after reseting" do
210 | subject.reset!
211 | subject.find("//*[contains(., 'hello')]").should be_empty
212 | end
213 |
214 | it "has a location of 'about:blank' after reseting" do
215 | subject.reset!
216 | subject.current_url.should == "about:blank"
217 | end
218 |
219 | it "raises an error for an invalid xpath query" do
220 | expect { subject.find("totally invalid salad") }.
221 | to raise_error(Capybara::Driver::Webkit::WebkitInvalidResponseError, /xpath/i)
222 | end
223 |
224 | it "returns an attribute's value" do
225 | subject.find("//p").first["id"].should == "greeting"
226 | end
227 |
228 | it "parses xpath with quotes" do
229 | subject.find('//*[contains(., "hello")]').should_not be_empty
230 | end
231 |
232 | it "returns a node's text" do
233 | subject.find("//p").first.text.should == "hello"
234 | end
235 |
236 | it "normalizes a node's text" do
237 | subject.find("//div[contains(@class, 'normalize')]").first.text.should == "Spaces not normalized"
238 | end
239 |
240 | it "returns the current URL" do
241 | port = subject.instance_variable_get("@rack_server").port
242 | subject.current_url.should == "http://127.0.0.1:#{port}/hello/world?success=true"
243 | end
244 |
245 | it "returns the current URL when changed by pushState" do
246 | port = subject.instance_variable_get("@rack_server").port
247 | subject.execute_script("window.history.pushState({}, '', '/pushed')")
248 | subject.current_url.should == "http://127.0.0.1:#{port}/pushed"
249 | end
250 |
251 | it "returns the current URL when changed by replaceState" do
252 | port = subject.instance_variable_get("@rack_server").port
253 | subject.execute_script("window.history.replaceState({}, '', '/replaced')")
254 | subject.current_url.should == "http://127.0.0.1:#{port}/replaced"
255 | end
256 |
257 | it "does not double-encode URLs" do
258 | subject.visit("/hello/world?success=%25true")
259 | subject.current_url.should =~ /success=\%25true/
260 | end
261 |
262 | it "visits a page with an anchor" do
263 | subject.visit("/hello#display_none")
264 | subject.current_url.should =~ /hello#display_none/
265 | end
266 |
267 | it "returns the source code for the page" do
268 | subject.source.should =~ %r{.*greeting.*}m
269 | end
270 |
271 | it "evaluates Javascript and returns a string" do
272 | result = subject.evaluate_script(%)
273 | result.should == "hello"
274 | end
275 |
276 | it "evaluates Javascript and returns an array" do
277 | result = subject.evaluate_script(%<["hello", "world"]>)
278 | result.should == %w(hello world)
279 | end
280 |
281 | it "evaluates Javascript and returns an int" do
282 | result = subject.evaluate_script(%<123>)
283 | result.should == 123
284 | end
285 |
286 | it "evaluates Javascript and returns a float" do
287 | result = subject.evaluate_script(%<1.5>)
288 | result.should == 1.5
289 | end
290 |
291 | it "evaluates Javascript and returns null" do
292 | result = subject.evaluate_script(%<(function () {})()>)
293 | result.should == nil
294 | end
295 |
296 | it "evaluates Javascript and returns an object" do
297 | result = subject.evaluate_script(%<({ 'one' : 1 })>)
298 | result.should == { 'one' => 1 }
299 | end
300 |
301 | it "evaluates Javascript and returns true" do
302 | result = subject.evaluate_script(%)
303 | result.should === true
304 | end
305 |
306 | it "evaluates Javascript and returns false" do
307 | result = subject.evaluate_script(%)
308 | result.should === false
309 | end
310 |
311 | it "evaluates Javascript and returns an escaped string" do
312 | result = subject.evaluate_script(%<'"'>)
313 | result.should === "\""
314 | end
315 |
316 | it "evaluates Javascript with multiple lines" do
317 | result = subject.evaluate_script("[1,\n2]")
318 | result.should == [1, 2]
319 | end
320 |
321 | it "executes Javascript" do
322 | subject.execute_script(%)
323 | subject.find("//p[contains(., 'yo')]").should_not be_empty
324 | end
325 |
326 | it "raises an error for failing Javascript" do
327 | expect { subject.execute_script(%) }.
328 | to raise_error(Capybara::Driver::Webkit::WebkitInvalidResponseError)
329 | end
330 |
331 | it "doesn't raise an error for Javascript that doesn't return anything" do
332 | lambda { subject.execute_script(%<(function () { "returns nothing" })()>) }.
333 | should_not raise_error
334 | end
335 |
336 | it "returns a node's tag name" do
337 | subject.find("//p").first.tag_name.should == "p"
338 | end
339 |
340 | it "reads disabled property" do
341 | subject.find("//input").first.should be_disabled
342 | end
343 |
344 | it "reads checked property" do
345 | subject.find("//input[@id='checktest']").first.should be_checked
346 | end
347 |
348 | it "finds visible elements" do
349 | subject.find("//p").first.should be_visible
350 | subject.find("//*[@id='invisible']").first.should_not be_visible
351 | end
352 | end
353 |
354 | context "console messages app" do
355 |
356 | before(:all) do
357 | @app = lambda do |env|
358 | body = <<-HTML
359 |
360 |
361 |
362 |
363 |
368 |
369 |
370 | HTML
371 | [200,
372 | { 'Content-Type' => 'text/html', 'Content-Length' => body.length.to_s },
373 | [body]]
374 | end
375 | end
376 |
377 | it "collects messages logged to the console" do
378 | subject.console_messages.first.should include :source, :message => "hello", :line_number => 6
379 | subject.console_messages.length.should eq 3
380 | end
381 |
382 | it "logs errors to the console" do
383 | subject.error_messages.length.should eq 1
384 | end
385 |
386 | end
387 |
388 | context "form app" do
389 | before(:all) do
390 | @app = lambda do |env|
391 | body = <<-HTML
392 |
393 |
416 |
417 | HTML
418 | [200,
419 | { 'Content-Type' => 'text/html', 'Content-Length' => body.length.to_s },
420 | [body]]
421 | end
422 | end
423 |
424 | it "returns a textarea's value" do
425 | subject.find("//textarea").first.value.should == "what a wonderful area for text"
426 | end
427 |
428 | it "returns a text input's value" do
429 | subject.find("//input").first.value.should == "bar"
430 | end
431 |
432 | it "returns a select's value" do
433 | subject.find("//select").first.value.should == "Capybara"
434 | end
435 |
436 | it "sets an input's value" do
437 | input = subject.find("//input").first
438 | input.set("newvalue")
439 | input.value.should == "newvalue"
440 | end
441 |
442 | it "sets an input's value greater than the max length" do
443 | input = subject.find("//input[@name='maxlength_foo']").first
444 | input.set("allegories (poems)")
445 | input.value.should == "allegories"
446 | end
447 |
448 | it "sets an input's value equal to the max length" do
449 | input = subject.find("//input[@name='maxlength_foo']").first
450 | input.set("allegories")
451 | input.value.should == "allegories"
452 | end
453 |
454 | it "sets an input's value less than the max length" do
455 | input = subject.find("//input[@name='maxlength_foo']").first
456 | input.set("poems")
457 | input.value.should == "poems"
458 | end
459 |
460 | it "sets an input's nil value" do
461 | input = subject.find("//input").first
462 | input.set(nil)
463 | input.value.should == ""
464 | end
465 |
466 | it "sets a select's value" do
467 | select = subject.find("//select").first
468 | select.set("Monkey")
469 | select.value.should == "Monkey"
470 | end
471 |
472 | it "sets a textarea's value" do
473 | textarea = subject.find("//textarea").first
474 | textarea.set("newvalue")
475 | textarea.value.should == "newvalue"
476 | end
477 |
478 | let(:monkey_option) { subject.find("//option[@id='select-option-monkey']").first }
479 | let(:capybara_option) { subject.find("//option[@id='select-option-capybara']").first }
480 | let(:animal_select) { subject.find("//select[@name='animal']").first }
481 | let(:apple_option) { subject.find("//option[@id='topping-apple']").first }
482 | let(:banana_option) { subject.find("//option[@id='topping-banana']").first }
483 | let(:cherry_option) { subject.find("//option[@id='topping-cherry']").first }
484 | let(:toppings_select) { subject.find("//select[@name='toppings']").first }
485 | let(:reset_button) { subject.find("//button[@type='reset']").first }
486 |
487 | context "a select element's selection has been changed" do
488 | before do
489 | animal_select.value.should == "Capybara"
490 | monkey_option.select_option
491 | end
492 |
493 | it "returns the new selection" do
494 | animal_select.value.should == "Monkey"
495 | end
496 |
497 | it "does not modify the selected attribute of a new selection" do
498 | monkey_option['selected'].should be_empty
499 | end
500 |
501 | it "returns the old value when a reset button is clicked" do
502 | reset_button.click
503 |
504 | animal_select.value.should == "Capybara"
505 | end
506 | end
507 |
508 | context "a multi-select element's option has been unselected" do
509 | before do
510 | toppings_select.value.should include("Apple", "Banana", "Cherry")
511 |
512 | apple_option.unselect_option
513 | end
514 |
515 | it "does not return the deselected option" do
516 | toppings_select.value.should_not include("Apple")
517 | end
518 |
519 | it "returns the deselected option when a reset button is clicked" do
520 | reset_button.click
521 |
522 | toppings_select.value.should include("Apple", "Banana", "Cherry")
523 | end
524 | end
525 |
526 | it "reselects an option in a multi-select" do
527 | apple_option.unselect_option
528 | banana_option.unselect_option
529 | cherry_option.unselect_option
530 |
531 | toppings_select.value.should == []
532 |
533 | apple_option.select_option
534 | banana_option.select_option
535 | cherry_option.select_option
536 |
537 | toppings_select.value.should include("Apple", "Banana", "Cherry")
538 | end
539 |
540 | let(:checked_box) { subject.find("//input[@name='checkedbox']").first }
541 | let(:unchecked_box) { subject.find("//input[@name='uncheckedbox']").first }
542 |
543 | it "knows a checked box is checked" do
544 | checked_box['checked'].should be_true
545 | end
546 |
547 | it "knows a checked box is checked using checked?" do
548 | checked_box.should be_checked
549 | end
550 |
551 | it "knows an unchecked box is unchecked" do
552 | unchecked_box['checked'].should_not be_true
553 | end
554 |
555 | it "knows an unchecked box is unchecked using checked?" do
556 | unchecked_box.should_not be_checked
557 | end
558 |
559 | it "checks an unchecked box" do
560 | unchecked_box.set(true)
561 | unchecked_box.should be_checked
562 | end
563 |
564 | it "unchecks a checked box" do
565 | checked_box.set(false)
566 | checked_box.should_not be_checked
567 | end
568 |
569 | it "leaves a checked box checked" do
570 | checked_box.set(true)
571 | checked_box.should be_checked
572 | end
573 |
574 | it "leaves an unchecked box unchecked" do
575 | unchecked_box.set(false)
576 | unchecked_box.should_not be_checked
577 | end
578 |
579 | let(:enabled_input) { subject.find("//input[@name='foo']").first }
580 | let(:disabled_input) { subject.find("//input[@id='disabled_input']").first }
581 |
582 | it "knows a disabled input is disabled" do
583 | disabled_input['disabled'].should be_true
584 | end
585 |
586 | it "knows a not disabled input is not disabled" do
587 | enabled_input['disabled'].should_not be_true
588 | end
589 | end
590 |
591 | context "dom events" do
592 | before(:all) do
593 | @app = lambda do |env|
594 | body = <<-HTML
595 |
596 |
597 | Link
598 |
599 |
615 |
616 | HTML
617 | [200,
618 | { 'Content-Type' => 'text/html', 'Content-Length' => body.length.to_s },
619 | [body]]
620 | end
621 | end
622 |
623 | it "triggers mouse events" do
624 | subject.find("//a").first.click
625 | subject.find("//li").map(&:text).should == %w(mousedown mouseup click)
626 | end
627 | end
628 |
629 | context "form events app" do
630 | before(:all) do
631 | @app = lambda do |env|
632 | body = <<-HTML
633 |
634 |
646 |
647 |
670 |
671 | HTML
672 | [200,
673 | { 'Content-Type' => 'text/html', 'Content-Length' => body.length.to_s },
674 | [body]]
675 | end
676 | end
677 |
678 | let(:newtext) { 'newvalue' }
679 |
680 | let(:keyevents) do
681 | (%w{focus} +
682 | newtext.length.times.collect { %w{keydown keypress keyup input} } +
683 | %w{change blur}).flatten
684 | end
685 |
686 | %w(email number password search tel text url).each do | field_type |
687 | it "triggers text input events on inputs of type #{field_type}" do
688 | subject.find("//input[@type='#{field_type}']").first.set(newtext)
689 | subject.find("//li").map(&:text).should == keyevents
690 | end
691 | end
692 |
693 | it "triggers textarea input events" do
694 | subject.find("//textarea").first.set(newtext)
695 | subject.find("//li").map(&:text).should == keyevents
696 | end
697 |
698 | it "triggers radio input events" do
699 | subject.find("//input[@type='radio']").first.set(true)
700 | subject.find("//li").map(&:text).should == %w(mousedown mouseup change click)
701 | end
702 |
703 | it "triggers checkbox events" do
704 | subject.find("//input[@type='checkbox']").first.set(true)
705 | subject.find("//li").map(&:text).should == %w(mousedown mouseup change click)
706 | end
707 | end
708 |
709 | context "mouse app" do
710 | before(:all) do
711 | @app =lambda do |env|
712 | body = <<-HTML
713 |
714 |
Change me
715 |
Push me
716 |
Release me
717 |
723 |
741 | Next
742 |
743 | HTML
744 | [200,
745 | { 'Content-Type' => 'text/html', 'Content-Length' => body.length.to_s },
746 | [body]]
747 | end
748 | end
749 |
750 | it "clicks an element" do
751 | subject.find("//a").first.click
752 | subject.current_url =~ %r{/next$}
753 | end
754 |
755 | it "fires a mouse event" do
756 | subject.find("//*[@id='mouseup']").first.trigger("mouseup")
757 | subject.find("//*[@class='triggered']").should_not be_empty
758 | end
759 |
760 | it "fires a non-mouse event" do
761 | subject.find("//*[@id='change']").first.trigger("change")
762 | subject.find("//*[@class='triggered']").should_not be_empty
763 | end
764 |
765 | it "fires a change on select" do
766 | select = subject.find("//select").first
767 | select.value.should == "1"
768 | option = subject.find("//option[@id='option-2']").first
769 | option.select_option
770 | select.value.should == "2"
771 | subject.find("//select[@class='triggered']").should_not be_empty
772 | end
773 |
774 | it "fires drag events" do
775 | draggable = subject.find("//*[@id='mousedown']").first
776 | container = subject.find("//*[@id='mouseup']").first
777 |
778 | draggable.drag_to(container)
779 |
780 | subject.find("//*[@class='triggered']").size.should == 1
781 | end
782 | end
783 |
784 | context "nesting app" do
785 | before(:all) do
786 | @app = lambda do |env|
787 | body = <<-HTML
788 |
789 |
790 |
Expected
791 |
792 |
Unexpected
793 |
794 | HTML
795 | [200,
796 | { 'Content-Type' => 'text/html', 'Content-Length' => body.length.to_s },
797 | [body]]
798 | end
799 | end
800 |
801 | it "evaluates nested xpath expressions" do
802 | parent = subject.find("//*[@id='parent']").first
803 | parent.find("./*[@class='find']").map(&:text).should == %w(Expected)
804 | end
805 | end
806 |
807 | context "slow app" do
808 | before(:all) do
809 | @result = ""
810 | @app = lambda do |env|
811 | if env["PATH_INFO"] == "/result"
812 | sleep(0.5)
813 | @result << "finished"
814 | end
815 | body = %{Go}
816 | [200,
817 | { 'Content-Type' => 'text/html', 'Content-Length' => body.length.to_s },
818 | [body]]
819 | end
820 | end
821 |
822 | it "waits for a request to load" do
823 | subject.find("//a").first.click
824 | @result.should == "finished"
825 | end
826 | end
827 |
828 | context "error app" do
829 | before(:all) do
830 | @app = lambda do |env|
831 | if env['PATH_INFO'] == "/error"
832 | [404, {}, []]
833 | else
834 | body = <<-HTML
835 |
836 |
837 |
838 | HTML
839 | [200,
840 | { 'Content-Type' => 'text/html', 'Content-Length' => body.length.to_s },
841 | [body]]
842 | end
843 | end
844 | end
845 |
846 | it "raises a webkit error for the requested url" do
847 | expect {
848 | subject.find("//input").first.click
849 | wait_for_error_to_complete
850 | subject.find("//body")
851 | }.
852 | to raise_error(Capybara::Driver::Webkit::WebkitInvalidResponseError, %r{/error})
853 | end
854 |
855 | def wait_for_error_to_complete
856 | sleep(0.5)
857 | end
858 | end
859 |
860 | context "slow error app" do
861 | before(:all) do
862 | @app = lambda do |env|
863 | if env['PATH_INFO'] == "/error"
864 | body = "error"
865 | sleep(1)
866 | [304, {}, []]
867 | else
868 | body = <<-HTML
869 |
870 |
871 |
hello
872 |
873 | HTML
874 | [200,
875 | { 'Content-Type' => 'text/html', 'Content-Length' => body.length.to_s },
876 | [body]]
877 | end
878 | end
879 | end
880 |
881 | it "raises a webkit error and then continues" do
882 | subject.find("//input").first.click
883 | expect { subject.find("//p") }.to raise_error(Capybara::Driver::Webkit::WebkitInvalidResponseError)
884 | subject.visit("/")
885 | subject.find("//p").first.text.should == "hello"
886 | end
887 | end
888 |
889 | context "popup app" do
890 | before(:all) do
891 | @app = lambda do |env|
892 | body = <<-HTML
893 |
894 |
899 |
success
900 |
901 | HTML
902 | sleep(0.5)
903 | [200,
904 | { 'Content-Type' => 'text/html', 'Content-Length' => body.length.to_s },
905 | [body]]
906 | end
907 | end
908 |
909 | it "doesn't crash from alerts" do
910 | subject.find("//p").first.text.should == "success"
911 | end
912 | end
913 |
914 | context "custom header" do
915 | before(:all) do
916 | @app = lambda do |env|
917 | body = <<-HTML
918 |
919 |
#{env['HTTP_USER_AGENT']}
920 |
#{env['HTTP_X_CAPYBARA_WEBKIT_HEADER']}
921 |
#{env['HTTP_ACCEPT']}
922 | /
923 |
924 | HTML
925 | [200,
926 | { 'Content-Type' => 'text/html', 'Content-Length' => body.length.to_s },
927 | [body]]
928 | end
929 | end
930 |
931 | before do
932 | subject.header('user-agent', 'capybara-webkit/custom-user-agent')
933 | subject.header('x-capybara-webkit-header', 'x-capybara-webkit-header')
934 | subject.header('accept', 'text/html')
935 | subject.visit('/')
936 | end
937 |
938 | it "can set user_agent" do
939 | subject.find('id("user-agent")').first.text.should == 'capybara-webkit/custom-user-agent'
940 | subject.evaluate_script('navigator.userAgent').should == 'capybara-webkit/custom-user-agent'
941 | end
942 |
943 | it "keep user_agent in next page" do
944 | subject.find("//a").first.click
945 | subject.find('id("user-agent")').first.text.should == 'capybara-webkit/custom-user-agent'
946 | subject.evaluate_script('navigator.userAgent').should == 'capybara-webkit/custom-user-agent'
947 | end
948 |
949 | it "can set custom header" do
950 | subject.find('id("x-capybara-webkit-header")').first.text.should == 'x-capybara-webkit-header'
951 | end
952 |
953 | it "can set Accept header" do
954 | subject.find('id("accept")').first.text.should == 'text/html'
955 | end
956 |
957 | it "can reset all custom header" do
958 | subject.reset!
959 | subject.visit('/')
960 | subject.find('id("user-agent")').first.text.should_not == 'capybara-webkit/custom-user-agent'
961 | subject.evaluate_script('navigator.userAgent').should_not == 'capybara-webkit/custom-user-agent'
962 | subject.find('id("x-capybara-webkit-header")').first.text.should be_empty
963 | subject.find('id("accept")').first.text.should_not == 'text/html'
964 | end
965 | end
966 |
967 | context "no response app" do
968 | before(:all) do
969 | @app = lambda do |env|
970 | body = <<-HTML
971 |
972 |
973 |
974 | HTML
975 | [200,
976 | { 'Content-Type' => 'text/html', 'Content-Length' => body.length.to_s },
977 | [body]]
978 | end
979 | end
980 |
981 | it "raises a webkit error for the requested url" do
982 | make_the_server_go_away
983 | expect {
984 | subject.find("//body")
985 | }.
986 | to raise_error(Capybara::Driver::Webkit::WebkitNoResponseError, %r{response})
987 | make_the_server_come_back
988 | end
989 |
990 | def make_the_server_come_back
991 | subject.browser.instance_variable_get(:@connection).unstub!(:gets)
992 | subject.browser.instance_variable_get(:@connection).unstub!(:puts)
993 | subject.browser.instance_variable_get(:@connection).unstub!(:print)
994 | end
995 |
996 | def make_the_server_go_away
997 | subject.browser.instance_variable_get(:@connection).stub!(:gets).and_return(nil)
998 | subject.browser.instance_variable_get(:@connection).stub!(:puts)
999 | subject.browser.instance_variable_get(:@connection).stub!(:print)
1000 | end
1001 | end
1002 |
1003 | context "custom font app" do
1004 | before(:all) do
1005 | @app = lambda do |env|
1006 | body = <<-HTML
1007 |
1008 |
1009 |
1012 |
1013 |
1014 |
Hello
1015 |
1016 |
1017 | HTML
1018 | [200,
1019 | { 'Content-Type' => 'text/html', 'Content-Length' => body.length.to_s },
1020 | [body]]
1021 | end
1022 | end
1023 |
1024 | it "ignores custom fonts" do
1025 | font_family = subject.evaluate_script(<<-SCRIPT)
1026 | var element = document.getElementById("text");
1027 | element.ownerDocument.defaultView.getComputedStyle(element, null).getPropertyValue("font-family");
1028 | SCRIPT
1029 | font_family.should == "Arial"
1030 | end
1031 | end
1032 |
1033 | context "cookie-based app" do
1034 | before(:all) do
1035 | @cookie = 'cookie=abc; domain=127.0.0.1; path=/'
1036 | @app = lambda do |env|
1037 | request = ::Rack::Request.new(env)
1038 |
1039 | body = <<-HTML
1040 |
1041 |
#{request.cookies["cookie"] || ""}
1042 |
1043 | HTML
1044 | [200,
1045 | { 'Content-Type' => 'text/html; charset=UTF-8',
1046 | 'Content-Length' => body.length.to_s,
1047 | 'Set-Cookie' => @cookie,
1048 | },
1049 | [body]]
1050 | end
1051 | end
1052 |
1053 | def echoed_cookie
1054 | subject.find('id("cookie")').first.text
1055 | end
1056 |
1057 | it "remembers the cookie on second visit" do
1058 | echoed_cookie.should == ""
1059 | subject.visit "/"
1060 | echoed_cookie.should == "abc"
1061 | end
1062 |
1063 | it "uses a custom cookie" do
1064 | subject.browser.set_cookie @cookie
1065 | subject.visit "/"
1066 | echoed_cookie.should == "abc"
1067 | end
1068 |
1069 | it "clears cookies" do
1070 | subject.browser.clear_cookies
1071 | subject.visit "/"
1072 | echoed_cookie.should == ""
1073 | end
1074 |
1075 | it "allows enumeration of cookies" do
1076 | cookies = subject.browser.get_cookies
1077 |
1078 | cookies.size.should == 1
1079 |
1080 | cookie = Hash[cookies[0].split(/\s*;\s*/).map { |x| x.split("=", 2) }]
1081 | cookie["cookie"].should == "abc"
1082 | cookie["domain"].should include "127.0.0.1"
1083 | cookie["path"].should == "/"
1084 | end
1085 |
1086 | it "allows reading access to cookies using a nice syntax" do
1087 | subject.cookies["cookie"].should == "abc"
1088 | end
1089 | end
1090 |
1091 | context "with socket debugger" do
1092 | let(:socket_debugger_class){ Capybara::Driver::Webkit::SocketDebugger }
1093 | let(:browser_with_debugger){
1094 | connection = Capybara::Driver::Webkit::Connection.new(:socket_class => socket_debugger_class)
1095 | Capybara::Driver::Webkit::Browser.new(connection)
1096 | }
1097 | let(:driver_with_debugger){ Capybara::Driver::Webkit.new(@app, :browser => browser_with_debugger) }
1098 |
1099 | before(:all) do
1100 | @app = lambda do |env|
1101 | body = <<-HTML
1102 |
1103 |
1104 |
Expected
1105 |
1106 |
Unexpected
1107 |
1108 | HTML
1109 | [200,
1110 | { 'Content-Type' => 'text/html', 'Content-Length' => body.length.to_s },
1111 | [body]]
1112 | end
1113 | end
1114 |
1115 | it "prints out sent content" do
1116 | socket_debugger_class.any_instance.stub(:received){|content| content }
1117 | sent_content = ['Find', 1, 17, "//*[@id='parent']"]
1118 | socket_debugger_class.any_instance.should_receive(:sent).exactly(sent_content.size).times
1119 | driver_with_debugger.find("//*[@id='parent']")
1120 | end
1121 |
1122 | it "prints out received content" do
1123 | socket_debugger_class.any_instance.stub(:sent)
1124 | socket_debugger_class.any_instance.should_receive(:received).at_least(:once).and_return("ok")
1125 | driver_with_debugger.find("//*[@id='parent']")
1126 | end
1127 | end
1128 |
1129 | context "remove node app" do
1130 | before(:all) do
1131 | @app = lambda do |env|
1132 | body = <<-HTML
1133 |
1134 |
1135 |
Hello
1136 |
1137 |
1138 | HTML
1139 | [200,
1140 | { 'Content-Type' => 'text/html', 'Content-Length' => body.length.to_s },
1141 | [body]]
1142 | end
1143 | end
1144 |
1145 | before { set_automatic_reload false }
1146 | after { set_automatic_reload true }
1147 |
1148 | def set_automatic_reload(value)
1149 | if Capybara.respond_to?(:automatic_reload)
1150 | Capybara.automatic_reload = value
1151 | end
1152 | end
1153 |
1154 | it "allows removed nodes when reloading is disabled" do
1155 | node = subject.find("//p[@id='removeMe']").first
1156 | subject.evaluate_script("document.getElementById('parent').innerHTML = 'Magic'")
1157 | node.text.should == 'Hello'
1158 | end
1159 | end
1160 |
1161 | context "app with a lot of HTML tags" do
1162 | before(:all) do
1163 | @app = lambda do |env|
1164 | body = <<-HTML
1165 |
1166 |
1167 | My eBook
1168 |
1169 |
1170 |
1171 |
1172 |