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]