diff --git a/qutebrowser/browser/network/filescheme.py b/qutebrowser/browser/network/filescheme.py
index 3f5226ee4..d4c20fd4d 100644
--- a/qutebrowser/browser/network/filescheme.py
+++ b/qutebrowser/browser/network/filescheme.py
@@ -57,9 +57,29 @@ def is_root(directory):
Return:
Whether the directory is a root directory or not.
"""
+ # If you're curious as why this works:
+ # dirname('/') = '/'
+ # dirname('/home') = '/'
+ # dirname('/home/') = '/home'
+ # dirname('/home/foo') = '/home'
+ # basically, for files (no trailing slash) it removes the file part, and
+ # for directories, it removes the trailing slash, so the only way for this
+ # to be equal is if the directory is the root directory.
return os.path.dirname(directory) == directory
+def parent_dir(directory):
+ """Return the parent directory for the given directory.
+
+ Args:
+ directory: The path to the directory.
+
+ Return:
+ The path to the parent directory.
+ """
+ return os.path.normpath(os.path.join(directory, os.pardir))
+
+
def dirbrowser_html(path):
"""Get the directory browser web page.
@@ -74,14 +94,14 @@ def dirbrowser_html(path):
if is_root(path):
parent = None
else:
- parent = os.path.dirname(path)
+ parent = parent_dir(path)
try:
all_files = os.listdir(path)
except OSError as e:
html = jinja.render('error.html',
title="Error while reading directory",
- url='file://{}'.format(path), error=str(e),
+ url='file:///{}'.format(path), error=str(e),
icon='')
return html.encode('UTF-8', errors='xmlcharrefreplace')
diff --git a/qutebrowser/html/dirbrowser.html b/qutebrowser/html/dirbrowser.html
index ab0000c36..1a73261f6 100644
--- a/qutebrowser/html/dirbrowser.html
+++ b/qutebrowser/html/dirbrowser.html
@@ -46,21 +46,21 @@ ul.files > li {
Browse directory: {{url}}
- {% if parent %}
+ {% if parent is not none %}
{% endif %}
diff --git a/qutebrowser/utils/jinja.py b/qutebrowser/utils/jinja.py
index e6ccd9c6c..e608cc3ff 100644
--- a/qutebrowser/utils/jinja.py
+++ b/qutebrowser/utils/jinja.py
@@ -74,6 +74,15 @@ def resource_url(path):
return QUrl.fromLocalFile(image).toString(QUrl.FullyEncoded)
+def file_url(path):
+ """Return a file:// url (as string) to the given local path.
+
+ Arguments:
+ path: The absolute path to the local file
+ """
+ return QUrl.fromLocalFile(path).toString(QUrl.FullyEncoded)
+
+
def render(template, **kwargs):
"""Render the given template and pass the given arguments to it."""
try:
@@ -85,5 +94,7 @@ def render(template, **kwargs):
tb = traceback.format_exc()
return err_template.format(pagename=template, traceback=tb)
+
_env = jinja2.Environment(loader=Loader('html'), autoescape=_guess_autoescape)
_env.globals['resource_url'] = resource_url
+_env.globals['file_url'] = file_url
diff --git a/tests/integration/data/click_element.html b/tests/integration/data/click_element.html
new file mode 100644
index 000000000..9e5ce5bb5
--- /dev/null
+++ b/tests/integration/data/click_element.html
@@ -0,0 +1,11 @@
+
+
+ quteprocess.click_element test
+
+
+ Test Element
+ "Don't", he shouted
+ Duplicate
+ Duplicate
+
+
diff --git a/tests/integration/quteprocess.py b/tests/integration/quteprocess.py
index cad527ac4..6154cfa5f 100644
--- a/tests/integration/quteprocess.py
+++ b/tests/integration/quteprocess.py
@@ -37,6 +37,7 @@ from PyQt5.QtCore import pyqtSignal, QUrl
import testprocess
from qutebrowser.misc import ipc
from qutebrowser.utils import log, utils
+from qutebrowser.browser import webelem
from helpers import utils as testutils
@@ -253,9 +254,14 @@ class QuteProc(testprocess.Process):
path if path != '/' else '')
def wait_for_js(self, message):
- """Wait for the given javascript console message."""
- self.wait_for(category='js', function='javaScriptConsoleMessage',
- message='[*] {}'.format(message))
+ """Wait for the given javascript console message.
+
+ Return:
+ The LogLine.
+ """
+ return self.wait_for(category='js',
+ function='javaScriptConsoleMessage',
+ message='[*] {}'.format(message))
def _is_error_logline(self, msg):
"""Check if the given LogLine is some kind of error message."""
@@ -348,10 +354,14 @@ class QuteProc(testprocess.Process):
def open_path(self, path, *, new_tab=False, new_window=False, port=None,
https=False):
"""Open the given path on the local webserver in qutebrowser."""
+ url = self.path_to_url(path, port=port, https=https)
+ self.open_url(url, new_tab=new_tab, new_window=new_window)
+
+ def open_url(self, url, *, new_tab=False, new_window=False):
+ """Open the given url in qutebrowser."""
if new_tab and new_window:
raise ValueError("new_tab and new_window given!")
- url = self.path_to_url(path, port=port, https=https)
if new_tab:
self.send_cmd(':open -t ' + url)
elif new_window:
@@ -418,6 +428,57 @@ class QuteProc(testprocess.Process):
"""Press the given keys using :fake-key."""
self.send_cmd(':fake-key -g "{}"'.format(keys))
+ def click_element(self, text):
+ """Click the element with the given text."""
+ # Use Javascript and XPath to find the right element, use console.log
+ # to return an error (no element found, ambiguous element)
+ script = (
+ 'var _es = document.evaluate(\'//*[text()={text}]\', document, '
+ 'null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);'
+ 'if (_es.snapshotLength == 0) {{ console.log("qute:no elems"); }} '
+ 'else if (_es.snapshotLength > 1) {{ console.log("qute:ambiguous '
+ 'elems") }} '
+ 'else {{ console.log("qute:okay"); _es.snapshotItem(0).click() }}'
+ ).format(text=webelem.javascript_escape(_xpath_escape(text)))
+ self.send_cmd(':jseval ' + script)
+ message = self.wait_for_js('qute:*').message
+ if message.endswith('qute:no elems'):
+ raise ValueError('No element with {!r} found'.format(text))
+ elif message.endswith('qute:ambiguous elems'):
+ raise ValueError('Element with {!r} is not unique'.format(text))
+ elif not message.endswith('qute:okay'):
+ raise ValueError('Invalid response from qutebrowser: {}'
+ .format(message))
+
+
+def _xpath_escape(text):
+ """Escape a string to be used in an XPath expression.
+
+ The resulting string should still be escaped with javascript_escape, to
+ prevent javascript from interpreting the quotes.
+
+ This function is needed because XPath does not provide any character
+ escaping mechanisms, so to get the string
+ "I'm back", he said
+ you have to use concat like
+ concat('"I', "'m back", '", he said')
+
+ Args:
+ text: Text to escape
+
+ Return:
+ The string "escaped" as a concat() call.
+ """
+ # Shortcut if at most a single quoting style is used
+ if "'" not in text or '"' not in text:
+ return repr(text)
+ parts = re.split('([\'"])', text)
+ # Python's repr() of strings will automatically choose the right quote
+ # type. Since each part only contains one "type" of quote, no escaping
+ # should be necessary.
+ parts = [repr(part) for part in parts if part]
+ return 'concat({})'.format(', '.join(parts))
+
@pytest.yield_fixture(scope='module')
def quteproc_process(qapp, httpbin, request):
diff --git a/tests/integration/test_dirbrowser.py b/tests/integration/test_dirbrowser.py
new file mode 100644
index 000000000..452dfbb4b
--- /dev/null
+++ b/tests/integration/test_dirbrowser.py
@@ -0,0 +1,189 @@
+# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
+
+# Copyright 2015-2016 Daniel Schadt
+#
+# This file is part of qutebrowser.
+#
+# qutebrowser is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# qutebrowser is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with qutebrowser. If not, see .
+
+"""Test the built-in directory browser."""
+
+import os
+import bs4
+import collections
+
+import pytest
+
+from PyQt5.QtCore import QUrl
+from qutebrowser.utils import jinja
+
+
+class DirLayout:
+
+ """Provide a fake directory layout to test dirbrowser."""
+
+ LAYOUT = [
+ 'folder0/file00',
+ 'folder0/file01',
+ 'folder1/folder10/file100',
+ 'folder1/file10',
+ 'folder1/file11',
+ 'file0',
+ 'file1',
+ ]
+
+ @classmethod
+ def layout_folders(cls):
+ """Return all folders in the root directory of the layout."""
+ folders = set()
+ for path in cls.LAYOUT:
+ parts = path.split('/')
+ if len(parts) > 1:
+ folders.add(parts[0])
+ folders = list(folders)
+ folders.sort()
+ return folders
+
+ @classmethod
+ def get_folder_content(cls, name):
+ """Return (folders, files) for the given folder in the root dir."""
+ folders = set()
+ files = set()
+ for path in cls.LAYOUT:
+ if not path.startswith(name + '/'):
+ continue
+ parts = path.split('/')
+ if len(parts) == 2:
+ files.add(parts[1])
+ else:
+ folders.add(parts[1])
+ folders = list(folders)
+ folders.sort()
+ files = list(files)
+ files.sort()
+ return (folders, files)
+
+ def __init__(self, factory):
+ self._factory = factory
+ self.base = factory.getbasetemp()
+ self.layout = factory.mktemp('layout')
+ self._mklayout()
+
+ def _mklayout(self):
+ for filename in self.LAYOUT:
+ self.layout.ensure(filename)
+
+ def file_url(self):
+ """Return a file:// link to the directory."""
+ return jinja.file_url(str(self.layout))
+
+ def path(self, *parts):
+ """Return the path to the given file inside the layout folder."""
+ return os.path.normpath(str(self.layout.join(*parts)))
+
+ def base_path(self):
+ """Return the path of the base temporary folder."""
+ return os.path.normpath(str(self.base))
+
+
+Parsed = collections.namedtuple('Parsed', 'path, parent, folders, files')
+Item = collections.namedtuple('Item', 'path, link, text')
+
+
+def parse(quteproc):
+ """Parse the dirbrowser content from the given quteproc.
+
+ Args:
+ quteproc: The quteproc fixture.
+ """
+ html = quteproc.get_content(plain=False)
+ soup = bs4.BeautifulSoup(html, 'html.parser')
+ print(soup.prettify())
+ title_prefix = 'Browse directory: '
+ # Strip off the title prefix to obtain the path of the folder that
+ # we're browsing
+ path = soup.title.string[len(title_prefix):]
+ path = os.path.normpath(path)
+
+ container = soup('div', id='dirbrowserContainer')[0]
+
+ parent_elem = container('ul', class_='parent')
+ if not parent_elem:
+ parent = None
+ else:
+ parent = QUrl(parent_elem[0].li.a['href']).toLocalFile()
+ parent = os.path.normpath(parent)
+
+ folders = []
+ files = []
+
+ for css_class, list_ in [('folders', folders), ('files', files)]:
+ for li in container('ul', class_=css_class)[0]('li'):
+ item_path = QUrl(li.a['href']).toLocalFile()
+ item_path = os.path.normpath(item_path)
+ list_.append(Item(path=item_path, link=li.a['href'],
+ text=str(li.a.string)))
+
+ return Parsed(path=path, parent=parent, folders=folders,
+ files=files)
+
+
+@pytest.fixture(scope='module')
+def dir_layout(tmpdir_factory):
+ return DirLayout(tmpdir_factory)
+
+
+def test_parent_folder(dir_layout, quteproc):
+ quteproc.open_url(dir_layout.file_url())
+ page = parse(quteproc)
+ assert page.parent == dir_layout.base_path()
+
+
+def test_parent_with_slash(dir_layout, quteproc):
+ """Test the parent link with an URL that has a trailing slash."""
+ quteproc.open_url(dir_layout.file_url() + '/')
+ page = parse(quteproc)
+ assert page.parent == dir_layout.base_path()
+
+
+def test_parent_in_root_dir(dir_layout, quteproc):
+ # This actually works on windows
+ root_path = os.path.realpath('/')
+ urlstr = QUrl.fromLocalFile(root_path).toString(QUrl.FullyEncoded)
+ quteproc.open_url(urlstr)
+ page = parse(quteproc)
+ assert page.parent is None
+
+
+def test_enter_folder_smoke(dir_layout, quteproc):
+ quteproc.open_url(dir_layout.file_url())
+ quteproc.send_cmd(':hint all normal')
+ # a is the parent link, s is the first listed folder/file
+ quteproc.send_cmd(':follow-hint s')
+ page = parse(quteproc)
+ assert page.path == dir_layout.path('folder0')
+
+
+@pytest.mark.parametrize('folder', DirLayout.layout_folders())
+def test_enter_folder(dir_layout, quteproc, folder):
+ quteproc.open_url(dir_layout.file_url())
+ quteproc.click_element(text=folder)
+ page = parse(quteproc)
+ assert page.path == dir_layout.path(folder)
+ assert page.parent == dir_layout.path()
+ folders, files = DirLayout.get_folder_content(folder)
+ foldernames = [item.text for item in page.folders]
+ assert foldernames == folders
+ filenames = [item.text for item in page.files]
+ assert filenames == files
diff --git a/tests/integration/test_quteprocess.py b/tests/integration/test_quteprocess.py
index 872ec73de..7698e1a07 100644
--- a/tests/integration/test_quteprocess.py
+++ b/tests/integration/test_quteprocess.py
@@ -158,3 +158,40 @@ def test_log_line_parse(data, attrs):
def test_log_line_no_match():
with pytest.raises(testprocess.InvalidLine):
quteprocess.LogLine("Hello World!")
+
+
+class TestClickElement:
+
+ @pytest.fixture(autouse=True)
+ def open_page(self, quteproc):
+ quteproc.open_path('data/click_element.html')
+ quteproc.wait_for_load_finished('data/click_element.html')
+
+ def test_click_element(self, quteproc):
+ quteproc.click_element('Test Element')
+ quteproc.wait_for_js('click_element clicked')
+
+ def test_click_special_chars(self, quteproc):
+ quteproc.click_element('"Don\'t", he shouted')
+ quteproc.wait_for_js('click_element special chars')
+
+ def test_duplicate(self, quteproc):
+ with pytest.raises(ValueError) as excinfo:
+ quteproc.click_element('Duplicate')
+ assert 'not unique' in str(excinfo.value)
+
+ def test_nonexistent(self, quteproc):
+ with pytest.raises(ValueError) as excinfo:
+ quteproc.click_element('no element exists with this text')
+ assert 'No element' in str(excinfo.value)
+
+
+@pytest.mark.parametrize('string, expected', [
+ ('Test', "'Test'"),
+ ("Don't", '"Don\'t"'),
+ # This is some serious string escaping madness
+ ('"Don\'t", he said',
+ "concat('\"', 'Don', \"'\", 't', '\"', ', he said')"),
+])
+def test_xpath_escape(string, expected):
+ assert quteprocess._xpath_escape(string) == expected
diff --git a/tests/unit/browser/network/test_filescheme.py b/tests/unit/browser/network/test_filescheme.py
index 484bddaa7..1321a649c 100644
--- a/tests/unit/browser/network/test_filescheme.py
+++ b/tests/unit/browser/network/test_filescheme.py
@@ -28,6 +28,7 @@ from PyQt5.QtCore import QUrl
from PyQt5.QtNetwork import QNetworkRequest
from qutebrowser.browser.network import filescheme
+from qutebrowser.utils import jinja
@pytest.mark.parametrize('create_file, create_dir, filterfunc, expected', [
@@ -57,6 +58,8 @@ class TestIsRoot:
@pytest.mark.windows
@pytest.mark.parametrize('directory, is_root', [
+ ('C:\\foo\\bar', False),
+ ('C:\\foo\\', False),
('C:\\foo', False),
('C:\\', True)
])
@@ -65,6 +68,8 @@ class TestIsRoot:
@pytest.mark.posix
@pytest.mark.parametrize('directory, is_root', [
+ ('/foo/bar', False),
+ ('/foo/', False),
('/foo', False),
('/', True)
])
@@ -72,6 +77,38 @@ class TestIsRoot:
assert filescheme.is_root(directory) == is_root
+class TestParentDir:
+
+ @pytest.mark.windows
+ @pytest.mark.parametrize('directory, parent', [
+ ('C:\\foo\\bar', 'C:\\foo'),
+ ('C:\\foo', 'C:\\'),
+ ('C:\\foo\\', 'C:\\'),
+ ('C:\\', 'C:\\'),
+ ])
+ def test_windows(self, directory, parent):
+ assert filescheme.parent_dir(directory) == parent
+
+ @pytest.mark.posix
+ @pytest.mark.parametrize('directory, parent', [
+ ('/home/foo', '/home'),
+ ('/home', '/'),
+ ('/home/', '/'),
+ ('/', '/'),
+ ])
+ def test_posix(self, directory, parent):
+ assert filescheme.parent_dir(directory) == parent
+
+
+def _file_url(path):
+ """Return a file:// url (as string) for the given LocalPath.
+
+ Arguments:
+ path: The filepath as LocalPath (as handled by py.path)
+ """
+ return jinja.file_url(str(path))
+
+
class TestDirbrowserHtml:
Parsed = collections.namedtuple('Parsed', 'parent, folders, files')
@@ -87,7 +124,7 @@ class TestDirbrowserHtml:
container = soup('div', id='dirbrowserContainer')[0]
parent_elem = container('ul', class_='parent')
- if len(parent_elem) == 0:
+ if not parent_elem:
parent = None
else:
parent = parent_elem[0].li.a.string
@@ -145,8 +182,8 @@ class TestDirbrowserHtml:
parsed = parser(str(tmpdir))
assert parsed.parent
assert not parsed.folders
- foo_item = self.Item('file://' + str(foo_file), foo_file.relto(tmpdir))
- bar_item = self.Item('file://' + str(bar_file), bar_file.relto(tmpdir))
+ foo_item = self.Item(_file_url(foo_file), foo_file.relto(tmpdir))
+ bar_item = self.Item(_file_url(bar_file), bar_file.relto(tmpdir))
assert parsed.files == [bar_item, foo_item]
def test_html_special_chars(self, tmpdir, parser):
@@ -154,8 +191,7 @@ class TestDirbrowserHtml:
special_file.ensure()
parsed = parser(str(tmpdir))
- item = self.Item('file://' + str(special_file),
- special_file.relto(tmpdir))
+ item = self.Item(_file_url(special_file), special_file.relto(tmpdir))
assert parsed.files == [item]
def test_dirs(self, tmpdir, parser):
@@ -167,8 +203,8 @@ class TestDirbrowserHtml:
parsed = parser(str(tmpdir))
assert parsed.parent
assert not parsed.files
- foo_item = self.Item('file://' + str(foo_dir), foo_dir.relto(tmpdir))
- bar_item = self.Item('file://' + str(bar_dir), bar_dir.relto(tmpdir))
+ foo_item = self.Item(_file_url(foo_dir), foo_dir.relto(tmpdir))
+ bar_item = self.Item(_file_url(bar_dir), bar_dir.relto(tmpdir))
assert parsed.folders == [bar_item, foo_item]
def test_mixed(self, tmpdir, parser):
@@ -178,8 +214,8 @@ class TestDirbrowserHtml:
bar_dir.ensure(dir=True)
parsed = parser(str(tmpdir))
- foo_item = self.Item('file://' + str(foo_file), foo_file.relto(tmpdir))
- bar_item = self.Item('file://' + str(bar_dir), bar_dir.relto(tmpdir))
+ foo_item = self.Item(_file_url(foo_file), foo_file.relto(tmpdir))
+ bar_item = self.Item(_file_url(bar_dir), bar_dir.relto(tmpdir))
assert parsed.parent
assert parsed.files == [foo_item]
assert parsed.folders == [bar_item]