# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: # Copyright 2015-2016 Florian Bruhin (The Compiler) # # 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 . """Tests for qutebrowser.misc.sessions.""" import os import textwrap import logging import pytest import yaml from PyQt5.QtCore import QUrl, QPoint, QByteArray, QObject QWebView = pytest.importorskip('PyQt5.QtWebKitWidgets').QWebView from qutebrowser.misc import sessions from qutebrowser.misc.sessions import TabHistoryItem as Item from qutebrowser.utils import objreg, qtutils from qutebrowser.browser.webkit import tabhistory from qutebrowser.commands import cmdexc pytestmark = pytest.mark.qt_log_ignore('QIODevice::read.*: device not open') webengine_refactoring_xfail = pytest.mark.xfail( True, reason='Broke during QtWebEngine refactoring, will be fixed after ' 'sessions are refactored too.') @pytest.fixture def sess_man(): """Fixture providing a SessionManager with no session dir.""" return sessions.SessionManager(base_path=None) class TestInit: @pytest.fixture(autouse=True) def cleanup(self): yield objreg.delete('session-manager') def test_no_standarddir(self, monkeypatch): monkeypatch.setattr('qutebrowser.misc.sessions.standarddir.data', lambda: None) sessions.init() manager = objreg.get('session-manager') assert manager._base_path is None @pytest.mark.parametrize('create_dir', [True, False]) def test_with_standarddir(self, tmpdir, monkeypatch, create_dir): monkeypatch.setattr('qutebrowser.misc.sessions.standarddir.data', lambda: str(tmpdir)) session_dir = tmpdir / 'sessions' if create_dir: session_dir.ensure(dir=True) sessions.init() manager = objreg.get('session-manager') assert session_dir.exists() assert manager._base_path == str(session_dir) def test_did_not_load(sess_man): assert not sess_man.did_load class TestExists: @pytest.mark.parametrize('absolute', [True, False]) def test_existent(self, tmpdir, absolute): session_dir = tmpdir / 'sessions' abs_session = tmpdir / 'foo.yml' rel_session = session_dir / 'foo.yml' session_dir.ensure(dir=True) abs_session.ensure() rel_session.ensure() man = sessions.SessionManager(str(session_dir)) if absolute: name = str(abs_session) else: name = 'foo' assert man.exists(name) @pytest.mark.parametrize('absolute', [True, False]) def test_inexistent(self, tmpdir, absolute): man = sessions.SessionManager(str(tmpdir)) if absolute: name = str(tmpdir / 'foo') else: name = 'foo' assert not man.exists(name) @pytest.mark.parametrize('absolute', [True, False]) def test_no_datadir(self, sess_man, tmpdir, absolute): abs_session = tmpdir / 'foo.yml' abs_session.ensure() if absolute: assert sess_man.exists(str(abs_session)) else: assert not sess_man.exists('foo') class HistTester: """Helper object for the hist_tester fixture. Makes it possible to use TabHistoryItem objects to easily load data into a QWebHistory, does some basic checks, and provides the serialized history. Attributes: sess_man: The SessionManager which is used. webview: The WebView where the history is loaded to. """ def __init__(self, webview): self.sess_man = sessions.SessionManager(base_path=None) self.webview = webview def get(self, items): """Get the serialized history for the given items. Args: items: A list of TabHistoryItems. Return: A list of serialized items, as dicts. """ history = self.webview.page().history() stream, _data, user_data = tabhistory.serialize(items) qtutils.deserialize_stream(stream, history) for i, data in enumerate(user_data): history.itemAt(i).setUserData(data) d = self.sess_man._save_tab(self.webview, active=True) new_history = d['history'] assert len(new_history) == len(items) return new_history def get_single(self, item): """Convenience method to use get() with a single item.""" ret = self.get([item]) assert len(ret) == 1 return ret[0] @webengine_refactoring_xfail class TestSaveTab: @pytest.fixture def hist_tester(self, webview): """Helper to test saving of history.""" return HistTester(webview) @pytest.mark.parametrize('is_active', [True, False]) def test_active(self, sess_man, webview, is_active): data = sess_man._save_tab(webview, is_active) if is_active: assert data['active'] else: assert 'active' not in data def test_no_history(self, sess_man, webview): data = sess_man._save_tab(webview, active=False) assert not data['history'] def test_single_item(self, hist_tester): item = Item(url=QUrl('http://www.qutebrowser.org/'), title='Test title', active=True) expected = { 'url': 'http://www.qutebrowser.org/', 'title': 'Test title', 'active': True, 'scroll-pos': {'x': 0, 'y': 0}, 'zoom': 1.0 } hist = hist_tester.get_single(item) assert hist == expected def test_original_url(self, hist_tester): """Test with an original-url which differs from the URL.""" item = Item(url=QUrl('http://www.example.com/'), original_url=QUrl('http://www.example.org/'), title='Test title', active=True) hist = hist_tester.get_single(item) assert hist['url'] == 'http://www.example.com/' assert hist['original-url'] == 'http://www.example.org/' def test_multiple_items(self, hist_tester): items = [ Item(url=QUrl('http://www.qutebrowser.org/'), title='test 1'), Item(url=QUrl('http://www.example.com/'), title='test 2', active=True), Item(url=QUrl('http://www.example.org/'), title='test 3'), ] expected = [ { 'url': 'http://www.qutebrowser.org/', 'title': 'test 1', }, { 'url': 'http://www.example.com/', 'title': 'test 2', 'active': True, 'scroll-pos': {'x': 0, 'y': 0}, 'zoom': 1.0 }, { 'url': 'http://www.example.org/', 'title': 'test 3', }, ] hist = hist_tester.get(items) assert hist == expected @pytest.mark.parametrize('factor', [-1.0, 0.0, 1.5]) def test_zoom(self, hist_tester, factor): """Test zoom.""" items = [ Item(url=QUrl('http://www.example.com/'), title='Test title', active=True), Item(url=QUrl('http://www.example.com/'), title='Test title', user_data={'zoom': factor}), ] hist_tester.webview.setZoomFactor(factor) hist = hist_tester.get(items) assert hist[0]['zoom'] == factor assert hist[1]['zoom'] == factor @pytest.mark.parametrize('pos_x, pos_y', [ (0.0, 0.0), (1.0, 1.0), (0.0, 1.0), (1.0, 0.0), ]) def test_scroll_current(self, hist_tester, pos_x, pos_y): """Test scroll position on the current URL.""" items = [ Item(url=QUrl('http://www.example.com/'), title='Test title', active=True), Item(url=QUrl('http://www.example.com/'), title='Test title', user_data={'scroll-pos': QPoint(pos_x, pos_y)}), ] frame = hist_tester.webview.page().mainFrame() text = '{}\n'.format('x' * 100) * 100 frame.setHtml('{}'.format(text)) frame.setScrollPosition(QPoint(pos_x, pos_y)) hist = hist_tester.get(items) assert hist[0]['scroll-pos'] == {'x': pos_x, 'y': pos_y} assert hist[1]['scroll-pos'] == {'x': pos_x, 'y': pos_y} class FakeMainWindow(QObject): """Helper class for the fake_main_window fixture. A fake MainWindow which provides a saveGeometry method. Needs to be a QObject so sip.isdeleted works. """ def __init__(self, geometry, win_id, parent=None): super().__init__(parent) self._geometry = QByteArray(geometry) self.win_id = win_id def saveGeometry(self): return self._geometry class FakeTabbedBrowser: """A fake tabbed-browser which contains some widgets.""" def __init__(self, widgets): self._widgets = widgets def widgets(self): return self._widgets def currentIndex(self): return 1 @pytest.fixture def fake_windows(win_registry, stubs, monkeypatch, qtbot): """Fixture which provides two fake main windows and tabbedbrowsers.""" win_registry.add_window(1) win0 = FakeMainWindow(b'fake-geometry-0', win_id=0) win1 = FakeMainWindow(b'fake-geometry-1', win_id=1) objreg.register('main-window', win0, scope='window', window=0) objreg.register('main-window', win1, scope='window', window=1) webview0 = QWebView() qtbot.add_widget(webview0) webview1 = QWebView() qtbot.add_widget(webview1) webview2 = QWebView() qtbot.add_widget(webview2) webview3 = QWebView() qtbot.add_widget(webview3) browser0 = FakeTabbedBrowser([webview0, webview1]) browser1 = FakeTabbedBrowser([webview2, webview3]) objreg.register('tabbed-browser', browser0, scope='window', window=0) objreg.register('tabbed-browser', browser1, scope='window', window=1) qapp = stubs.FakeQApplication(active_window=win0) monkeypatch.setattr('qutebrowser.misc.sessions.QApplication', qapp) yield browser0, browser1 objreg.delete('main-window', scope='window', window=0) objreg.delete('main-window', scope='window', window=1) objreg.delete('tabbed-browser', scope='window', window=0) objreg.delete('tabbed-browser', scope='window', window=1) class TestSaveAll: def test_no_history(self, sess_man): assert not objreg.window_registry data = sess_man._save_all() assert not data['windows'] @webengine_refactoring_xfail def test_normal(self, fake_windows, sess_man): """Test with some windows and tabs set up.""" data = sess_man._save_all() win1 = { 'active': True, 'geometry': b'fake-geometry-0', 'tabs': [ {'history': []}, {'active': True, 'history': []}, ], } win2 = { 'geometry': b'fake-geometry-1', 'tabs': [ {'history': []}, {'active': True, 'history': []}, ], } expected = {'windows': [win1, win2]} assert data == expected @webengine_refactoring_xfail def test_no_active_window(self, sess_man, fake_windows, stubs, monkeypatch): qapp = stubs.FakeQApplication(active_window=None) monkeypatch.setattr('qutebrowser.misc.sessions.QApplication', qapp) sess_man._save_all() @pytest.mark.parametrize('arg, config, current, expected', [ ('foo', None, None, 'foo'), (sessions.default, 'foo', None, 'foo'), (sessions.default, None, 'foo', 'foo'), (sessions.default, None, None, 'default'), ]) def test_get_session_name(config_stub, sess_man, arg, config, current, expected): config_stub.data = {'general': {'session-default-name': config}} sess_man._current = current assert sess_man._get_session_name(arg) == expected class TestSave: @pytest.fixture def state_config(self): state = {'general': {}} objreg.register('state-config', state) yield state objreg.delete('state-config') @pytest.fixture def fake_history(self, win_registry, stubs, monkeypatch, webview): """Fixture which provides a window with a fake history.""" win = FakeMainWindow(b'fake-geometry-0', win_id=0) objreg.register('main-window', win, scope='window', window=0) browser = FakeTabbedBrowser([webview]) objreg.register('tabbed-browser', browser, scope='window', window=0) qapp = stubs.FakeQApplication(active_window=win) monkeypatch.setattr('qutebrowser.misc.sessions.QApplication', qapp) def set_data(items): history = browser.widgets()[0].page().history() stream, _data, user_data = tabhistory.serialize(items) qtutils.deserialize_stream(stream, history) for i, data in enumerate(user_data): history.itemAt(i).setUserData(data) yield set_data objreg.delete('main-window', scope='window', window=0) objreg.delete('tabbed-browser', scope='window', window=0) def test_no_config_storage(self, sess_man): with pytest.raises(sessions.SessionError) as excinfo: sess_man.save('foo') assert str(excinfo.value) == "No data storage configured." def test_simple_dump(self, sess_man, tmpdir): session_path = tmpdir / 'foo.yml' name = sess_man.save(str(session_path)) assert name == str(session_path) data = session_path.read_text('utf-8') assert data == 'windows: []\n' def test_update_completion_signal(self, sess_man, tmpdir, qtbot): session_path = tmpdir / 'foo.yml' with qtbot.waitSignal(sess_man.update_completion): sess_man.save(str(session_path)) def test_no_state_config(self, sess_man, tmpdir, state_config): session_path = tmpdir / 'foo.yml' sess_man.save(str(session_path)) assert 'session' not in state_config['general'] def test_last_window_session_none(self, caplog, sess_man, tmpdir): session_path = tmpdir / 'foo.yml' with caplog.at_level(logging.ERROR): sess_man.save(str(session_path), last_window=True) assert len(caplog.records) == 1 msg = "last_window_session is None while saving!" assert caplog.records[0].msg == msg assert not session_path.exists() def test_last_window_session(self, sess_man, tmpdir): sess_man.save_last_window_session() session_path = tmpdir / 'foo.yml' sess_man.save(str(session_path), last_window=True) data = session_path.read_text('utf-8') assert data == 'windows: []\n' @pytest.mark.parametrize('exception', [ OSError('foo'), UnicodeEncodeError('ascii', '', 0, 2, 'foo'), yaml.YAMLError('foo')]) def test_fake_exception(self, mocker, sess_man, tmpdir, exception): mocker.patch('qutebrowser.misc.sessions.yaml.dump', side_effect=exception) with pytest.raises(sessions.SessionError) as excinfo: sess_man.save(str(tmpdir / 'foo.yml')) assert str(excinfo.value) == str(exception) assert not tmpdir.listdir() def test_directory(self, sess_man, tmpdir): """Test with a directory given as session file.""" with pytest.raises(sessions.SessionError) as excinfo: sess_man.save(str(tmpdir)) assert str(excinfo.value) in ["Filename refers to a directory", "Commit failed!"] assert not tmpdir.listdir() def test_load_next_time(self, tmpdir, state_config, sess_man): session_path = tmpdir / 'foo.yml' sess_man.save(str(session_path), load_next_time=True) assert state_config['general']['session'] == str(session_path) @webengine_refactoring_xfail def test_utf_8_valid(self, tmpdir, sess_man, fake_history): """Make sure data containing valid UTF8 gets saved correctly.""" session_path = tmpdir / 'foo.yml' fake_history([Item(QUrl('http://www.qutebrowser.org/'), 'foo☃bar', active=True)]) sess_man.save(str(session_path)) data = session_path.read_text('utf-8') assert 'title: foo☃bar' in data @webengine_refactoring_xfail def test_utf_8_invalid(self, tmpdir, sess_man, fake_history): """Make sure data containing invalid UTF8 raises SessionError.""" session_path = tmpdir / 'foo.yml' fake_history([Item(QUrl('http://www.qutebrowser.org/'), '\ud800', active=True)]) try: sess_man.save(str(session_path)) except sessions.SessionError: # This seems to happen on some systems only?! pass else: data = session_path.read_text('utf-8') assert r'title: "\uD800"' in data def _set_data(self, browser, tab_id, items): """Helper function for test_long_output.""" history = browser.widgets()[tab_id].page().history() stream, _data, user_data = tabhistory.serialize(items) qtutils.deserialize_stream(stream, history) for i, data in enumerate(user_data): history.itemAt(i).setUserData(data) @pytest.mark.skipif( os.name == 'nt', reason="Test segfaults on Windows, see " "https://github.com/The-Compiler/qutebrowser/issues/895") @webengine_refactoring_xfail def test_long_output(self, fake_windows, tmpdir, sess_man): session_path = tmpdir / 'foo.yml' items1 = [ Item(QUrl('http://www.qutebrowser.org/'), 'test title 1'), Item(QUrl('http://www.example.org/'), 'test title 2', original_url=QUrl('http://www.example.com/'), active=True), ] items2 = [ Item(QUrl('http://www.example.com/?q=foo+bar'), 'test title 3'), Item(QUrl('http://www.example.com/?q=test%20foo'), 'test title 4', active=True), ] items3 = [] items4 = [ Item(QUrl('http://www.github.com/The-Compiler/qutebrowser'), 'test title 5', active=True), ] self._set_data(fake_windows[0], 0, items1) self._set_data(fake_windows[0], 1, items2) self._set_data(fake_windows[1], 0, items3) self._set_data(fake_windows[1], 1, items4) expected = """ windows: - active: true geometry: !!binary | ZmFrZS1nZW9tZXRyeS0w tabs: - history: - title: test title 1 url: http://www.qutebrowser.org/ - active: true original-url: http://www.example.com/ scroll-pos: x: 0 y: 0 title: test title 2 url: http://www.example.org/ zoom: 1.0 - active: true history: - title: test title 3 url: http://www.example.com/?q=foo+bar - active: true scroll-pos: x: 0 y: 0 title: test title 4 url: http://www.example.com/?q=test%20foo zoom: 1.0 - geometry: !!binary | ZmFrZS1nZW9tZXRyeS0x tabs: - history: [] - active: true history: - active: true scroll-pos: x: 0 y: 0 title: test title 5 url: http://www.github.com/The-Compiler/qutebrowser zoom: 1.0 """ sess_man.save(str(session_path)) data = session_path.read_text('utf-8') assert data == textwrap.dedent(expected.strip('\n')) class FakeWebView: """A QWebView fake which provides a "page" with a load_history method. Attributes: loaded_history: The history which has been loaded by load_history, or None. raise_error: The exception to raise on load_history, or None. """ def __init__(self): self.loaded_history = None self.raise_error = None def page(self): return self def load_history(self, data): self.loaded_history = data if self.raise_error is not None: raise self.raise_error # pylint: disable=raising-bad-type @pytest.fixture def fake_webview(): return FakeWebView() @webengine_refactoring_xfail class TestLoadTab: def test_no_history(self, sess_man, fake_webview): sess_man._load_tab(fake_webview, {'history': []}) assert fake_webview.loaded_history == [] def test_load_fail(self, sess_man, fake_webview): fake_webview.raise_error = ValueError with pytest.raises(sessions.SessionError): sess_man._load_tab(fake_webview, {'history': []}) @pytest.mark.parametrize('key, val, expected', [ ('zoom', 1.23, 1.23), ('scroll-pos', {'x': 23, 'y': 42}, QPoint(23, 42)), ]) @pytest.mark.parametrize('in_main_data', [True, False]) def test_user_data(self, sess_man, fake_webview, key, val, expected, in_main_data): item = {'url': 'http://www.example.com/', 'title': 'foo'} if in_main_data: # This information got saved in the main data instead of saving it # per item - make sure the old format can still be read # https://github.com/The-Compiler/qutebrowser/issues/728 d = {'history': [item], key: val} else: item[key] = val d = {'history': [item]} sess_man._load_tab(fake_webview, d) assert len(fake_webview.loaded_history) == 1 assert fake_webview.loaded_history[0].user_data[key] == expected @pytest.mark.parametrize('original_url', ['http://example.org/', None]) def test_urls(self, sess_man, fake_webview, original_url): url = 'http://www.example.com/' item = {'url': url, 'title': 'foo'} if original_url is None: expected = QUrl(url) else: item['original-url'] = original_url expected = QUrl(original_url) d = {'history': [item]} sess_man._load_tab(fake_webview, d) assert len(fake_webview.loaded_history) == 1 loaded_item = fake_webview.loaded_history[0] assert loaded_item.url == QUrl(url) assert loaded_item.original_url == expected class TestDelete: def test_existing(self, sess_man, tmpdir): sess = tmpdir / 'foo.yml' sess.ensure() sess_man.delete(str(sess)) assert not tmpdir.listdir() def test_update_completion_signal(self, sess_man, qtbot, tmpdir): sess = tmpdir / 'foo.yml' sess.ensure() with qtbot.waitSignal(sess_man.update_completion): sess_man.delete(str(sess)) def test_not_existing(self, sess_man, qtbot, tmpdir): sess = tmpdir / 'foo.yml' with pytest.raises(sessions.SessionError): sess_man.delete(str(sess)) class TestListSessions: def test_no_base_path(self, sess_man): assert not sess_man.list_sessions() def test_no_sessions(self, tmpdir): sess_man = sessions.SessionManager(str(tmpdir)) assert not sess_man.list_sessions() def test_with_sessions(self, tmpdir): (tmpdir / 'foo.yml').ensure() (tmpdir / 'bar.yml').ensure() sess_man = sessions.SessionManager(str(tmpdir)) assert sorted(sess_man.list_sessions()) == ['bar', 'foo'] def test_with_other_files(self, tmpdir): (tmpdir / 'foo.yml').ensure() (tmpdir / 'bar.html').ensure() sess_man = sessions.SessionManager(str(tmpdir)) assert sess_man.list_sessions() == ['foo'] class TestSessionSave: @webengine_refactoring_xfail def test_normal_save(self, sess_man, tmpdir, fake_windows): sess_file = tmpdir / 'foo.yml' sess_man.session_save(0, str(sess_file), quiet=True) assert sess_file.read_text('utf-8').startswith('windows:') def test_internal_without_force(self, tmpdir): sess_man = sessions.SessionManager(str(tmpdir)) with pytest.raises(cmdexc.CommandError) as excinfo: sess_man.session_save(0, '_foo') expected_text = ("_foo is an internal session, use --force to " "save anyways.") assert str(excinfo.value) == expected_text assert not (tmpdir / '_foo.yml').exists() @webengine_refactoring_xfail def test_internal_with_force(self, tmpdir, fake_windows): sess_man = sessions.SessionManager(str(tmpdir)) sess_man.session_save(0, '_foo', force=True, quiet=True) assert (tmpdir / '_foo.yml').exists() def test_current_unset(self, tmpdir): sess_man = sessions.SessionManager(str(tmpdir)) with pytest.raises(cmdexc.CommandError) as excinfo: sess_man.session_save(0, current=True) assert str(excinfo.value) == "No session loaded currently!" @webengine_refactoring_xfail def test_current_set(self, tmpdir, fake_windows): sess_man = sessions.SessionManager(str(tmpdir)) sess_man._current = 'foo' sess_man.session_save(0, current=True, quiet=True) assert (tmpdir / 'foo.yml').exists() def test_saving_error(self, sess_man, tmpdir): with pytest.raises(cmdexc.CommandError) as excinfo: sess_man.session_save(0, str(tmpdir)) assert str(excinfo.value).startswith('Error while saving session: ') @webengine_refactoring_xfail def test_message(self, sess_man, tmpdir, message_mock, fake_windows): message_mock.patch('qutebrowser.misc.sessions.message') sess_path = str(tmpdir / 'foo.yml') sess_man.session_save(0, sess_path) expected_text = 'Saved session {}.'.format(sess_path) assert message_mock.getmsg(immediate=True).text == expected_text @webengine_refactoring_xfail def test_message_quiet(self, sess_man, tmpdir, message_mock, fake_windows): message_mock.patch('qutebrowser.misc.sessions.message') sess_path = str(tmpdir / 'foo.yml') sess_man.session_save(0, sess_path, quiet=True) assert not message_mock.messages class TestSessionDelete: def test_internal_without_force(self, tmpdir): sess_man = sessions.SessionManager(str(tmpdir)) sess_file = tmpdir / '_foo.yml' sess_file.ensure() with pytest.raises(cmdexc.CommandError) as excinfo: sess_man.session_delete('_foo') expected_text = ("_foo is an internal session, use --force to " "delete anyways.") assert str(excinfo.value) == expected_text assert sess_file.exists() def test_internal_with_force(self, tmpdir): sess_man = sessions.SessionManager(str(tmpdir)) sess_file = tmpdir / '_foo.yml' sess_file.ensure() sess_man.session_delete('_foo', force=True) assert not tmpdir.listdir() def test_normal_delete(self, tmpdir): sess_man = sessions.SessionManager(str(tmpdir)) sess_file = tmpdir / 'foo.yml' sess_file.ensure() sess_man.session_delete('foo') assert not tmpdir.listdir() def test_session_not_found(self, tmpdir): sess_man = sessions.SessionManager(str(tmpdir)) with pytest.raises(cmdexc.CommandError) as excinfo: sess_man.session_delete('foo') assert str(excinfo.value) == "Session foo not found!" @pytest.mark.posix def test_deletion_error(self, caplog, tmpdir): sess_man = sessions.SessionManager(str(tmpdir)) (tmpdir / 'foo.yml').ensure() tmpdir.chmod(0o555) # unwritable with pytest.raises(cmdexc.CommandError) as excinfo: with caplog.at_level(logging.ERROR): sess_man.session_delete('foo') assert str(excinfo.value).startswith('Error while deleting session: ') assert len(caplog.records) == 1 assert caplog.records[0].message == 'Error while deleting session!'