diff --git a/README.asciidoc b/README.asciidoc index 4a06335d5..449945971 100644 --- a/README.asciidoc +++ b/README.asciidoc @@ -102,6 +102,9 @@ The following software and libraries are required to run qutebrowser: * http://pygments.org/[pygments] * http://pyyaml.org/wiki/PyYAML[PyYAML] +The following libraries are optional and provide a better user experience: +* http://cthedot.de/cssutils/[cssutils] + To generate the documentation for the `:help` command, when using the git repository (rather than a release), http://asciidoc.org/[asciidoc] is needed. diff --git a/doc/help/commands.asciidoc b/doc/help/commands.asciidoc index 559011aaf..6d4787528 100644 --- a/doc/help/commands.asciidoc +++ b/doc/help/commands.asciidoc @@ -146,13 +146,19 @@ Close the current window. [[download]] === download -Syntax: +:download ['url'] ['dest']+ +Syntax: +:download [*--mhtml*] [*--dest* 'DEST'] ['url'] ['dest-old']+ Download a given URL, or current page if no URL given. +The form `:download [url] [dest]` is deprecated, use `:download --dest [dest] [url]` instead. + ==== positional arguments * +'url'+: The URL to download. If not given, download the current page. -* +'dest'+: The file path to write the download to, or not given to ask. +* +'dest-old'+: (deprecated) Same as dest. + +==== optional arguments +* +*-m*+, +*--mhtml*+: Download the current page and all assets as mhtml file. +* +*-d*+, +*--dest*+: The file path to write the download to, or not given to ask. [[download-cancel]] === download-cancel diff --git a/qutebrowser/browser/commands.py b/qutebrowser/browser/commands.py index 3cd4bcd9f..74febf62a 100644 --- a/qutebrowser/browser/commands.py +++ b/qutebrowser/browser/commands.py @@ -37,7 +37,7 @@ import pygments.formatters from qutebrowser.commands import userscripts, cmdexc, cmdutils, runners from qutebrowser.config import config, configexc -from qutebrowser.browser import webelem, inspector, urlmarks +from qutebrowser.browser import webelem, inspector, urlmarks, downloads, mhtml from qutebrowser.keyinput import modeman from qutebrowser.utils import (message, usertypes, log, qtutils, urlutils, objreg, utils) @@ -1139,22 +1139,66 @@ class CommandDispatcher: cur.inspector.show() @cmdutils.register(instance='command-dispatcher', scope='window') - def download(self, url=None, dest=None): + def download(self, url=None, dest_old: {'hide': True}=None, *, + mhtml_=False, dest=None): """Download a given URL, or current page if no URL given. + The form `:download [url] [dest]` is deprecated, use `:download --dest + [dest] [url]` instead. + Args: url: The URL to download. If not given, download the current page. + dest_old: (deprecated) Same as dest. dest: The file path to write the download to, or None to ask. + mhtml_: Download the current page and all assets as mhtml file. """ + if dest_old is not None: + message.warning( + self._win_id, ":download [url] [dest] is deprecated - use" + " download --dest [dest] [url]") + if dest is not None: + raise cmdexc.CommandError("Can't give two destinations for the" + " download.") + dest = dest_old + download_manager = objreg.get('download-manager', scope='window', window=self._win_id) if url: + if mhtml_: + raise cmdexc.CommandError("Can only download the current page" + " as mhtml.") url = urlutils.qurl_from_user_input(url) urlutils.raise_cmdexc_if_invalid(url) download_manager.get(url, filename=dest) else: - page = self._current_widget().page() - download_manager.get(self._current_url(), page=page) + if mhtml_: + self._download_mhtml(dest) + else: + page = self._current_widget().page() + download_manager.get(self._current_url(), page=page, + filename=dest) + + def _download_mhtml(self, dest=None): + """Download the current page as a MHTML file, including all assets. + + Args: + dest: The file path to write the download to. + """ + web_view = self._current_widget() + if dest is None: + suggested_fn = self._current_title() + ".mht" + suggested_fn = utils.sanitize_filename(suggested_fn) + filename, q = downloads.ask_for_filename( + suggested_fn, self._win_id, parent=web_view, + ) + if filename is not None: + mhtml.start_download_checked(filename, web_view=web_view) + else: + q.answered.connect(functools.partial( + mhtml.start_download_checked, web_view=web_view)) + q.ask() + else: + mhtml.start_download_checked(dest, web_view=web_view) @cmdutils.register(instance='command-dispatcher', scope='window', deprecated="Use :download instead.") diff --git a/qutebrowser/browser/downloads.py b/qutebrowser/browser/downloads.py index e87b3e3a2..7a321eae7 100644 --- a/qutebrowser/browser/downloads.py +++ b/qutebrowser/browser/downloads.py @@ -48,8 +48,13 @@ ModelRole = usertypes.enum('ModelRole', ['item'], start=Qt.UserRole, RetryInfo = collections.namedtuple('RetryInfo', ['request', 'manager']) + +DownloadPath = collections.namedtuple('DownloadPath', ['filename', + 'question']) + + # Remember the last used directory -_last_used_directory = None +last_used_directory = None # All REFRESH_INTERVAL milliseconds, speeds will be recalculated and downloads @@ -57,20 +62,20 @@ _last_used_directory = None REFRESH_INTERVAL = 500 -def _download_dir(): +def download_dir(): """Get the download directory to use.""" directory = config.get('storage', 'download-directory') remember_dir = config.get('storage', 'remember-download-directory') - if remember_dir and _last_used_directory is not None: - return _last_used_directory + if remember_dir and last_used_directory is not None: + return last_used_directory elif directory is None: return standarddir.download() else: return directory -def _path_suggestion(filename): +def path_suggestion(filename): """Get the suggested file path. Args: @@ -79,15 +84,79 @@ def _path_suggestion(filename): suggestion = config.get('completion', 'download-path-suggestion') if suggestion == 'path': # add trailing '/' if not present - return os.path.join(_download_dir(), '') + return os.path.join(download_dir(), '') elif suggestion == 'filename': return filename elif suggestion == 'both': - return os.path.join(_download_dir(), filename) + return os.path.join(download_dir(), filename) else: raise ValueError("Invalid suggestion value {}!".format(suggestion)) +def create_full_filename(basename, filename): + """Create a full filename based on the given basename and filename. + + Args: + basename: The basename to use if filename is a directory. + filename: The path to a folder or file where you want to save. + + Return: + The full absolute path, or None if filename creation was not possible. + """ + if os.path.isabs(filename) and os.path.isdir(filename): + # We got an absolute directory from the user, so we save it under + # the default filename in that directory. + return os.path.join(filename, basename) + elif os.path.isabs(filename): + # We got an absolute filename from the user, so we save it under + # that filename. + return filename + return None + + +def ask_for_filename(suggested_filename, win_id, *, parent=None, + prompt_download_directory=None): + """Prepare a question for a download-path. + + If a filename can be determined directly, it is returned instead. + + Returns a (filename, question)-namedtuple, in which one component is + None. filename is a string, question is a usertypes.Question. The + question has a special .ask() method that takes no arguments for + convenience, as this function does not yet ask the question, it + only prepares it. + + Args: + suggested_filename: The "default"-name that is pre-entered as path. + win_id: The window where the question will be asked. + parent: The parent of the question (a QObject). + prompt_download_directory: If this is something else than None, it + will overwrite the + storage->prompt-download-directory setting. + """ + if prompt_download_directory is None: + prompt_download_directory = config.get('storage', + 'prompt-download-directory') + + if not prompt_download_directory: + return DownloadPath(filename=download_dir(), question=None) + + encoding = sys.getfilesystemencoding() + suggested_filename = utils.force_encoding(suggested_filename, + encoding) + + q = usertypes.Question(parent) + q.text = "Save file to:" + q.mode = usertypes.PromptMode.text + q.completed.connect(q.deleteLater) + q.default = path_suggestion(suggested_filename) + + message_bridge = objreg.get('message-bridge', scope='window', + window=win_id) + q.ask = lambda: message_bridge.ask(q, blocking=False) + return DownloadPath(filename=None, question=q) + + class DownloadItemStats(QObject): """Statistics (bytes done, total bytes, time, etc.) about a download. @@ -201,6 +270,7 @@ class DownloadItem(QObject): fileobj: The file object to download the file to. reply: The QNetworkReply associated with this download. retry_info: A RetryInfo instance. + raw_headers: The headers sent by the server. _filename: The filename of the download. _redirects: How many time we were redirected already. _buffer: A BytesIO object to buffer incoming data until we know the @@ -255,6 +325,7 @@ class DownloadItem(QObject): self._filename = None self.init_reply(reply) self._win_id = win_id + self.raw_headers = {} def __repr__(self): return utils.get_repr(self, basename=self.basename) @@ -354,6 +425,7 @@ class DownloadItem(QObject): reply.finished.connect(self.on_reply_finished) reply.error.connect(self.on_reply_error) reply.readyRead.connect(self.on_ready_read) + reply.metaDataChanged.connect(self.on_meta_data_changed) self.retry_info = RetryInfo(request=reply.request(), manager=reply.manager()) if not self.fileobj: @@ -444,7 +516,7 @@ class DownloadItem(QObject): filename: The full filename to save the download to. None: special value to stop the download. """ - global _last_used_directory + global last_used_directory if self.fileobj is not None: raise ValueError("fileobj was already set! filename: {}, " "existing: {}, fileobj {}".format( @@ -454,13 +526,16 @@ class DownloadItem(QObject): # See https://github.com/The-Compiler/qutebrowser/issues/427 encoding = sys.getfilesystemencoding() filename = utils.force_encoding(filename, encoding) - if not self._create_full_filename(filename): + self._filename = create_full_filename(self.basename, filename) + if self._filename is None: # We only got a filename (without directory) or a relative path # from the user, so we append that to the default directory and # try again. - self._create_full_filename(os.path.join(_download_dir(), filename)) + self._filename = create_full_filename( + self.basename, os.path.join(download_dir(), filename)) - _last_used_directory = os.path.dirname(self._filename) + self.basename = os.path.basename(self._filename) + last_used_directory = os.path.dirname(self._filename) log.downloads.debug("Setting filename to {}".format(filename)) if os.path.isfile(self._filename): @@ -477,25 +552,6 @@ class DownloadItem(QObject): else: self._create_fileobj() - def _create_full_filename(self, filename): - """Try to create the full filename. - - Return: - True if the full filename was created, False otherwise. - """ - if os.path.isabs(filename) and os.path.isdir(filename): - # We got an absolute directory from the user, so we save it under - # the default filename in that directory. - self._filename = os.path.join(filename, self.basename) - return True - elif os.path.isabs(filename): - # We got an absolute filename from the user, so we save it under - # that filename. - self._filename = filename - self.basename = os.path.basename(self._filename) - return True - return False - def set_fileobj(self, fileobj): """"Set the file object to write the download to. @@ -593,6 +649,15 @@ class DownloadItem(QObject): if data is not None: self._buffer.write(data) + @pyqtSlot() + def on_meta_data_changed(self): + """Update the download's metadata.""" + if self.reply is None: + return + self.raw_headers = {} + for key, value in self.reply.rawHeaderPairs(): + self.raw_headers[bytes(key)] = bytes(value) + def _handle_redirect(self): """Handle a HTTP redirect. @@ -651,15 +716,10 @@ class DownloadManager(QAbstractListModel): def __repr__(self): return utils.get_repr(self, downloads=len(self.downloads)) - def _prepare_question(self): - """Prepare a Question object to be asked.""" - q = usertypes.Question(self) - q.text = "Save file to:" - q.mode = usertypes.PromptMode.text - q.completed.connect(q.deleteLater) + def _postprocess_question(self, q): + """Postprocess a Question object that is asked.""" q.destroyed.connect(functools.partial(self.questions.remove, q)) self.questions.append(q) - return q @pyqtSlot() def update_gui(self): @@ -716,11 +776,12 @@ class DownloadManager(QAbstractListModel): QNetworkRequest.AlwaysNetwork) suggested_fn = urlutils.filename_from_url(request.url()) - if prompt_download_directory is None: - prompt_download_directory = config.get( - 'storage', 'prompt-download-directory') - if not prompt_download_directory and not fileobj: - filename = _download_dir() + # We won't need a question if a filename or fileobj is already given + if fileobj is None and filename is None: + filename, q = ask_for_filename( + suggested_fn, self._win_id, parent=self, + prompt_download_directory=prompt_download_directory + ) if fileobj is not None or filename is not None: return self.fetch_request(request, @@ -728,22 +789,13 @@ class DownloadManager(QAbstractListModel): filename=filename, suggested_filename=suggested_fn, **kwargs) - if suggested_fn is None: - suggested_fn = 'qutebrowser-download' - else: - encoding = sys.getfilesystemencoding() - suggested_fn = utils.force_encoding(suggested_fn, encoding) - - q = self._prepare_question() - q.default = _path_suggestion(suggested_fn) - message_bridge = objreg.get('message-bridge', scope='window', - window=self._win_id) q.answered.connect( lambda fn: self.fetch_request(request, filename=fn, suggested_filename=suggested_fn, **kwargs)) - message_bridge.ask(q, blocking=False) + self._postprocess_question(q) + q.ask() return None def fetch_request(self, request, *, page=None, **kwargs): @@ -817,26 +869,33 @@ class DownloadManager(QAbstractListModel): if not self._update_timer.isActive(): self._update_timer.start() - prompt_download_directory = config.get('storage', - 'prompt-download-directory') - if not prompt_download_directory and not fileobj: - filename = _download_dir() + if fileobj is not None: + download.set_fileobj(fileobj) + download.autoclose = False + return download if filename is not None: download.set_filename(filename) - elif fileobj is not None: - download.set_fileobj(fileobj) - download.autoclose = False - else: - q = self._prepare_question() - q.default = _path_suggestion(suggested_filename) - q.answered.connect(download.set_filename) - q.cancelled.connect(download.cancel) - download.cancelled.connect(q.abort) - download.error.connect(q.abort) - message_bridge = objreg.get('message-bridge', scope='window', - window=self._win_id) - message_bridge.ask(q, blocking=False) + return download + + # Neither filename nor fileobj were given, prepare a question + filename, q = ask_for_filename( + suggested_filename, self._win_id, parent=self, + prompt_download_directory=prompt_download_directory, + ) + + # User doesn't want to be asked, so just use the download_dir + if filename is not None: + download.set_filename(filename) + return download + + # Ask the user for a filename + self._postprocess_question(q) + q.answered.connect(download.set_filename) + q.cancelled.connect(download.cancel) + download.cancelled.connect(q.abort) + download.error.connect(q.abort) + q.ask() return download diff --git a/qutebrowser/browser/mhtml.py b/qutebrowser/browser/mhtml.py new file mode 100644 index 000000000..a202b5639 --- /dev/null +++ b/qutebrowser/browser/mhtml.py @@ -0,0 +1,532 @@ +# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: + +# Copyright 2015 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 . + +"""Utils for writing a MHTML file.""" + +import functools +import io +import os +import re +import sys +import collections +import uuid +import email.policy +import email.generator +import email.encoders +import email.mime.multipart + +from PyQt5.QtCore import QUrl + +from qutebrowser.browser import webelem, downloads +from qutebrowser.utils import log, objreg, message, usertypes, utils, urlutils + +try: + import cssutils +except (ImportError, re.error): + # Catching re.error because cssutils in earlier releases (<= 1.0) is broken + # on Python 3.5 + # See https://bitbucket.org/cthedot/cssutils/issues/52 + cssutils = None + +_File = collections.namedtuple('_File', + ['content', 'content_type', 'content_location', + 'transfer_encoding']) + + +_CSS_URL_PATTERNS = [re.compile(x) for x in [ + r"@import\s+'(?P[^']+)'", + r'@import\s+"(?P[^"]+)"', + r'''url\((?P[^'"][^)]*)\)''', + r'url\("(?P[^"]+)"\)', + r"url\('(?P[^']+)'\)", +]] + + +def _get_css_imports_regex(data): + """Return all assets that are referenced in the given CSS document. + + The returned URLs are relative to the stylesheet's URL. + + Args: + data: The content of the stylesheet to scan as string. + """ + urls = [] + for pattern in _CSS_URL_PATTERNS: + for match in pattern.finditer(data): + url = match.group("url") + if url: + urls.append(url) + return urls + + +def _get_css_imports_cssutils(data, inline=False): + """Return all assets that are referenced in the given CSS document. + + The returned URLs are relative to the stylesheet's URL. + + Args: + data: The content of the stylesheet to scan as string. + inline: True if the argument is a inline HTML style attribute. + """ + # We don't care about invalid CSS data, this will only litter the log + # output with CSS errors + parser = cssutils.CSSParser(loglevel=100, + fetcher=lambda url: (None, ""), validate=False) + if not inline: + sheet = parser.parseString(data) + return list(cssutils.getUrls(sheet)) + else: + urls = [] + declaration = parser.parseStyle(data) + # prop = background, color, margin, ... + for prop in declaration: + # value = red, 10px, url(foobar), ... + for value in prop.propertyValue: + if isinstance(value, cssutils.css.URIValue): + if value.uri: + urls.append(value.uri) + return urls + + +def _get_css_imports(data, inline=False): + """Return all assets that are referenced in the given CSS document. + + The returned URLs are relative to the stylesheet's URL. + + Args: + data: The content of the stylesheet to scan as string. + inline: True if the argument is a inline HTML style attribute. + """ + if cssutils is None: + return _get_css_imports_regex(data) + else: + return _get_css_imports_cssutils(data, inline) + + +def _check_rel(element): + """Return true if the element's rel attribute fits our criteria. + + rel has to contain 'stylesheet' or 'icon'. Also returns True if the rel + attribute is unset. + + Args: + element: The WebElementWrapper which should be checked. + """ + if 'rel' not in element: + return True + must_have = {'stylesheet', 'icon'} + rels = [rel.lower() for rel in element['rel'].split(' ')] + return any(rel in rels for rel in must_have) + + +MHTMLPolicy = email.policy.default.clone(linesep='\r\n', max_line_length=0) + + +# Encode the file using base64 encoding. +E_BASE64 = email.encoders.encode_base64 + + +# Encode the file using MIME quoted-printable encoding. +E_QUOPRI = email.encoders.encode_quopri + + +class MHTMLWriter: + + """A class for outputting multiple files to a MHTML document. + + Attributes: + root_content: The root content as bytes. + content_location: The url of the page as str. + content_type: The MIME-type of the root content as str. + _files: Mapping of location->_File namedtuple. + """ + + def __init__(self, root_content, content_location, content_type): + self.root_content = root_content + self.content_location = content_location + self.content_type = content_type + self._files = {} + + def add_file(self, location, content, content_type=None, + transfer_encoding=E_QUOPRI): + """Add a file to the given MHTML collection. + + Args: + location: The original location (URL) of the file. + content: The binary content of the file. + content_type: The MIME-type of the content (if available) + transfer_encoding: The transfer encoding to use for this file. + """ + self._files[location] = _File( + content=content, content_type=content_type, + content_location=location, transfer_encoding=transfer_encoding, + ) + + def write_to(self, fp): + """Output the MHTML file to the given file-like object. + + Args: + fp: The file-object, opened in "wb" mode. + """ + msg = email.mime.multipart.MIMEMultipart( + 'related', '---=_qute-{}'.format(uuid.uuid4())) + + root = self._create_root_file() + msg.attach(root) + + for _, file_data in sorted(self._files.items()): + msg.attach(self._create_file(file_data)) + + gen = email.generator.BytesGenerator(fp, policy=MHTMLPolicy) + gen.flatten(msg) + + def _create_root_file(self): + """Return the root document as MIMEMultipart.""" + root_file = _File( + content=self.root_content, content_type=self.content_type, + content_location=self.content_location, transfer_encoding=E_QUOPRI, + ) + return self._create_file(root_file) + + def _create_file(self, f): + """Return the single given file as MIMEMultipart.""" + msg = email.mime.multipart.MIMEMultipart() + msg['Content-Location'] = f.content_location + # Get rid of the default type multipart/mixed + del msg['Content-Type'] + if f.content_type: + msg.set_type(f.content_type) + msg.set_payload(f.content) + f.transfer_encoding(msg) + return msg + + +class _Downloader: + + """A class to download whole websites. + + Attributes: + web_view: The QWebView which contains the website that will be saved. + dest: Destination filename. + writer: The MHTMLWriter object which is used to save the page. + loaded_urls: A set of QUrls of finished asset downloads. + pending_downloads: A set of unfinished (url, DownloadItem) tuples. + _finished_file: A flag indicating if the file has already been + written. + _used: A flag indicating if the downloader has already been used. + _win_id: The window this downloader belongs to. + """ + + def __init__(self, web_view, dest): + self.web_view = web_view + self.dest = dest + self.writer = None + self.loaded_urls = {web_view.url()} + self.pending_downloads = set() + self._finished_file = False + self._used = False + self._win_id = web_view.win_id + + def run(self): + """Download and save the page. + + The object must not be reused, you should create a new one if + you want to download another page. + """ + if self._used: + raise ValueError("Downloader already used") + self._used = True + web_url = self.web_view.url() + web_frame = self.web_view.page().mainFrame() + + self.writer = MHTMLWriter( + web_frame.toHtml().encode('utf-8'), + content_location=urlutils.encoded_url(web_url), + # I've found no way of getting the content type of a QWebView, but + # since we're using .toHtml, it's probably safe to say that the + # content-type is HTML + content_type='text/html; charset="UTF-8"', + ) + # Currently only downloading (stylesheets), + + + + + + + +

foobar

+ + + + diff --git a/tests/integration/data/downloads/mhtml/complex/complex.mht b/tests/integration/data/downloads/mhtml/complex/complex.mht new file mode 100644 index 000000000..1bce2f10a --- /dev/null +++ b/tests/integration/data/downloads/mhtml/complex/complex.mht @@ -0,0 +1,569 @@ +Content-Type: multipart/related; boundary="---=_qute-986aec6d-ef0d-4d36-8a81-0b9c0f826384" +MIME-Version: 1.0 + +-----=_qute-986aec6d-ef0d-4d36-8a81-0b9c0f826384 +Content-Location: http://localhost:1234/data/downloads/mhtml/complex/complex.html +MIME-Version: 1.0 +Content-Type: text/html; charset="UTF-8" +Content-Transfer-Encoding: quoted-printable + + +=20=20=20=20=20=20=20=20 +=20=20=20=20=20=20=20=20 +=20=20=20=20=20=20=20=20 +=20=20=20=20=20=20=20=20 +=20=20=20=20=20=20=20=20 +=20=20=20=20=20=20=20=20 +@import=20"actually-it's-css"; +=20=20=20=20=20=20=20=20 +=20=20=20=20=20=20=20=20 +=20=20=20=20=20=20=20=20 +=20=20=20=20=20=20=20=20 +=20=20=20=20=20=20=20=20 +=20=20=20=20=20=20=20=20 +=20=20=20=20=20=20=20=20 +=20=20=20=20=20=20=20=20 +=20=20=20=20=20=20=20=20 +=20=20=20=20 +=20=20=20=20 +=20=20=20=20=20=20=20=20 +=20=20=20=20=20=20=20=20 +=20=20=20=20=20=20=20=20 +=20=20=20=20=20=20=20=20foobar

+=20=20=20=20=20=20=20=20 +=20=20=20=20=20=20=20=20 +=20=20=20=20 + + +-----=_qute-986aec6d-ef0d-4d36-8a81-0b9c0f826384 +Content-Location: http://localhost:1234/data/downloads/mhtml/complex/background.png +MIME-Version: 1.0 +Content-Type: image/png +Content-Transfer-Encoding: base64 + +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 +4n8BgjXlKCMEM1EAAAAASUVORK5CYII= + +-----=_qute-986aec6d-ef0d-4d36-8a81-0b9c0f826384 +Content-Location: http://localhost:1234/data/downloads/mhtml/complex/base.css +MIME-Version: 1.0 +Content-Type: text/css; charset=utf-8 +Content-Transfer-Encoding: quoted-printable + +div.fancy=20{ +=20=20=20=20background-color:=20url("div-image.png"); +} + +-----=_qute-986aec6d-ef0d-4d36-8a81-0b9c0f826384 +Content-Location: http://localhost:1234/data/downloads/mhtml/complex/div-image.png +MIME-Version: 1.0 +Content-Type: image/png +Content-Transfer-Encoding: base64 + +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 +m+48GgAAAABJRU5ErkJggg== + +-----=_qute-986aec6d-ef0d-4d36-8a81-0b9c0f826384 +Content-Location: http://localhost:1234/data/downloads/mhtml/complex/extern-css.css +MIME-Version: 1.0 +Content-Type: text/css; charset=utf-8 +Content-Transfer-Encoding: quoted-printable + +@import=20"external-in-external.css"; +p=20{ +=20=20=20=20font-family:=20"Monospace"; +} + +-----=_qute-986aec6d-ef0d-4d36-8a81-0b9c0f826384 +Content-Location: http://localhost:1234/data/downloads/mhtml/complex/external-in-external.css +MIME-Version: 1.0 +Content-Type: text/css; charset=utf-8 +Content-Transfer-Encoding: quoted-printable + +img=20{ +=20=20=20=20width:=20100%; +} + +-----=_qute-986aec6d-ef0d-4d36-8a81-0b9c0f826384 +Content-Location: http://localhost:1234/data/downloads/mhtml/complex/favicon.png +MIME-Version: 1.0 +Content-Type: image/png +Content-Transfer-Encoding: base64 + +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 +rkJggg== + +-----=_qute-986aec6d-ef0d-4d36-8a81-0b9c0f826384 +Content-Location: http://localhost:1234/data/downloads/mhtml/complex/image.gif +MIME-Version: 1.0 +Content-Type: image/gif +Content-Transfer-Encoding: base64 + +R0lGODlhhQBkAPcAAAAAAAMADAoBDwIBFAsCEwQBGwsEHAwIGhIFHRUGGgYBIgsEJAwEKw4JKBMF +IxMKJRIELBoGLBQLKxwKLBoIJRsRLRcQJw0IMxQGMxULMxwMNBQLOxwNOxgGNR0SNB0SOxsVPCML +LSIZLCQLMywMMyYOOSMUPCsTPCQZPSobPCMXNTcUOzEMMCshOzEmPEUUOxoOQhQMQx0SRB0USxcT +RhwVUiMVRCkWQiUaRCsbRCIWSiUaTCsdSzIaRDUcSCUbUyseUiQbWSwhQy4hTDMkTTcnSi0hUywi +WyYhXDMkUzUpUzoqVTMmWzUqXDsrWzkmVT4yXT0yVD0yTy0lYTQrYTstYjEnZT0zZDszazw1dEcc +REknSVIsTUIuXEUrVVQqVUIzXEc3Wlg3WU8zTUM0Y0Q6ZUs7ZEQ2a0U6a0s8a0w3Z1k5ZUY7c0k1 +ZmI9ZGA7X1ZHXE5CZU1CbEdAZ1JDbFhIaUxCc0xEe1JFc1ZKdFpMdFRKeVtMe1dFdV1SfF1SdGRH +aWNMdWdKdWJTfWdXeXJYe2VWbmxkenxqf0U+gl1Tg1xTiFNKhGNUgmVZg2pdhGRai2tdi2xUgXNc +h2pekmtihW1ijHJkjHRqjHlniGxjk3JlknRqk3lslXVrm3ltmnxylXxzm396m3xzinx1o3x2pnRs +oYN1m4R7m4Z5l4Jxj4R8pIl9pYR7qIyBnYyKmo2Hk4yDpIaCrIyErIyHqJOLrJSJqJmTqo2LtIqF +sZONtJeMs5WRtJyUtJWTuZyZvJyXt6Kas6Kcu6SataukvKilubSxvJ+cwKOewqaiw6qlwqypxa2p +ybKsw7Ouy7CnwbWyy7u6zLe1yL270rq10sG9y8G91L/Ex8XCzcTC1cbD2snG3MzL3cjL09LT3dXb +3c7P2tHO4dPU49zd5dvc6tfY5c3N4N7h69zj5d/i8OHl7OTq7uXr6u797+n07eXr8eru8+vz9O/9 +8O72+fD+8fX6+vD/7wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACH5BAAAAAAALAAAAACFAGQA +AAj+AAEIHEiwoMGDCBMqXMiwocOHECNKnEixosWLGDNq3Mixo8ePIEOKHEmypMmTKFOqXMmypcuX +MGN+FCAggE2ZOC0GEEDgQQgVN3LwGIrCw4QDAXIqTRiAAIIQJXLkGLLEyRMfQJ4occK1SAoNBJaK +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 + +-----=_qute-986aec6d-ef0d-4d36-8a81-0b9c0f826384 +Content-Location: http://localhost:1234/data/downloads/mhtml/complex/inline.png +MIME-Version: 1.0 +Content-Type: image/png +Content-Transfer-Encoding: base64 + +iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAABGdBTUEAALGPC/xhBQAAACBjSFJN +AAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAAABmJLR0QA/wD/AP+gvaeTAAAA +CXBIWXMAABeBAAAXgQGmAW9mAAAAB3RJTUUH3wsMDBMAek8yBQAAEOVJREFUeNrVm3t01dWVxz/n +nN/vPvMk74RHeAUBiSiKAgWE0vGNOlXb2lE7HW2nFduZdpxpS21LSx+zukZXtdhV6/Rhq12OYx/i +q9aiozNWQVBEISFAgCQkgZDHTe7z9zjzR8jjkuTmdxNwOnuts9Zdv98+++z9Pfvss88+vys4y/T9 +WkoPReSd7Ql9YQIqYjbFUZu8XptQl43paI1P4ISUsH58nnp+dkge0OhjIA4l46lt5/wPvWdTP3E2 +hN4y1VhtaWfDsSSX1vfp4pTrrd9nqhV3zJDDH6VAvITWvzdc46lp2+Itf7EA3D6TsqgjfvpODx9s +TWi/134SWF4k2N6l+fUSg+rQ2Cpp2An6cb/P3lL1NLG/CABuLCFHBtXPt3e61/fayEy883MF60oE +r57ULM4XFPlg+RTB1jbNnzs1Dy1WhJQntVsRetPhhPXva17G/r8CQH1smrx/d4RPdaS04aVDsQ8+ +PVOxrkQigKCCXxx1ebDRAfrBuG+RJ1EDVC/gK9XPp37zvgLQdJl/zk+b7Bd+dNidmU0/v4Snl5nk +DrPxvYjmcFxzKKppTcB3FnhygdPpdeWKW6b9IXngrAPwuXnGPzfH3M2NUczVxYIDUc2ubu15sI9M +lRT5BNUhwYqiMxqDu4QQN814JvliNp1kFrzqk7PUE5eViu++16vNublwW7WiIigQgjFbZVBw01TJ +3FxBbYGg2C9YWSxYUSz6ERmrSYGvZgG5V9+QmW+oFWr080eu8t+VDQCepuD6Kop6tXzrSzVq2r0H +HPb0aEr9gqCCIzHNwjxBSMGOriFPyDHgexfmsWRaATqZYE9zFwtyPcxIOIfAeUsJnH8JKr8Qp6eL +zi3fycYmQDzc0Zr87IU7scbjHDfifHIFuUcPywOHorrgx4cd3oto/BLWV0oa+jTHEpA/ey5rLlnI +LUUFlBTlUVqUT3lRHsoYEr/KsnA62knWv0Pi3V24vT1p45hTqwlesBz/ObWgTosDWa8UfXtxpTnz +zSXWFRc+lBmEjKIvBSNYJQ/W9enpp78ryAtz3YeWcuMVy5lRVZKdepZFz388jNV0iMB5SwkuWYFR +Wjkqr9PTReePsvWAgYHEw9Vbk3dkYsnoAf6p4rX6Pj1dDIOpoqSQz912FX+1cjE+M6sta5CEaZJ7 +5U10PvSv5Ky9BuEPZGCmP5hMaCBuf3aN7+iVL6W+NRbLmEHwppny/oNRLpICBtrS2jk8fv8XuXrt +hRM2fpCU9O7a3oLgqC3m8I3b5xmbswLg7gXG5Xt72SCFYKB9/NrVPPTtz1KYnzM5w0837CwDsHiK +kB+ZLu9qWu+fM5roEdM4Zw7+t3r1k7ZGSAFCCL72uY+yft3SM2c43j17MisAwBRQkyvy3uh0fwcs +AtKSlhEesESrnx+L69CA299y3eozbnyadZ7Rmnj780mXe+udhRsWGPedLjrNA/56BhUHovqmAcTP +rZnBnbdd7UlHnUpiNR3E6TmJ29OJ09OJG+vDN6MGX00tRknF5ACYIP2u2eWhg/1njd09+s675vC1 +Bw4QGRUAn2n8KhV3pRSQGw7y7btvxVCZc3M31kf8rVdJ7H4dnYyPeG+3NxHb/id8s+aTe9XfIAzz +fQMg4cDPGx3kqf7dljb6ctWvwFk/wDO4BDbMo7It4a6WAkIG3Pv3V1BROiXjAFbrEbp/eS/xHS+j +Uwky5cSpxjoiT/2CtCXodXFnyrUztNdOalyGdjEp4L0effXdCygf4QH/tDj4cYFWzTHNlLwcpi27 +JKNOqcN19D79S7Rje54hq6kBp+skqrB4aI16mf0JeMCLbS4/2m8Pzv6gDhohA8YWsD+cBoDA/QhC +MDUsCC1dke6qp5Eb7aX3hcfRrnfjh2i4B2QBwimSwTCqpAqjtBKjpArh86OTcdy+HqyWRhLNjWze +2cfbXf11ODmK/PqIe9nAbwOg6cZglSv1EgBhmARrl2fUJ/pfv0cnY5OvJ2URA9SUMsLLLsc3a8GY +bMELVpPjutyw413afvY72ju6RuVrjhHeUGus/eE79jYDQJvu+gFtzGmzEf7gmIM4kS6Sje+emWqi +p9mXhFddQ7B2haeYIaVk1cW1LKyp5svf/QkNjaPXUWMuXwa29QdBIa4diB2+6vkZB0jt34VATzQu +pRntJQbKnHyC530g62yoqDCPH3xzA7XzZ6YFwYHWlWQpgDzxSXIRrBlYj74Z8zIKtk80Ty4xydYD +JkHBgJ9/vOMGlBQjADiR1HlbLqVcxu3QWoTwIQTdIojMLcwMQEfrhLelEbM4mRzXI1VPq+CySy8a +oYYGei3fpyRCz0JA0oXf9GTe93Uyjhvt/n/jAQN0202XE/CbI7ygLcFy+dgh5/KH6m027rIQ+cWZ +AUglJp2XD5ATOTlhALRt4fZ2get44i+eUsD1l68cAUB3yp1pvNLqrIqfkuPY49wxGOakZ00nokRf +fZVE3Q58M+ZnzDdOJzcaIbb9OZINbwMaGcolvHw9vlmLxu275gNLePLpbWnPHE2BsaxcHX+l1Znu +aohE+jIKkcEcZDgXNzbx+8rIsw8jc6eQd8XfYk6t8QaaY5N45xXib7+MtlODk+DGe+n906P4Wy4i +Z+WHM8qYMbWcgrwwkd7o4DNbEzZuq1GJcwsFv2iw6ekd3zCjZCqpo/smZLzwBwmdv47AgmUgvVXk +U417iL7xLG7fqaRmFA9s3r2d3pz5XHT+2EmSEIJF82fz2o53Bp8lXfwGUlQuKZVcUOqj2U56A6Ap +SwCEJHDOUkIXfAgRCHvqYp88Ruz1rVhtjWMaPkDFQcGWx5/KCADArBlVvP7mEAAuwjCkNIR2bAQw +3UyAdkGMPTu+6nOJv/0S2nWo73ap79bMKxBoDfMLR/YzK2YRuuQa1JRyvJAb7yO+8wWS+98ErREe +go4A1uV0caztBJXlY1eoiwrzkcPkBQyFgaQNl9n9jzR2RwtGybQxhajCMuwFq7n7J88Ts8GU8GxT +/7vziyXFAcHsPMEht4Brrr+GKfPO9WQ4rkNi72vE397Wv9sMWOaRVlZKOg7XQQYACgvy0lZejg/b +EEq3aWcAALBaGjICAFC0ZC3TXthHw8EjoIeKCrs7XPx+HyUXr+MTl6/G9Fg5tprqiG1/BifSkbXh +w2mq6Mn4fkphXloNMGSQNISQ7Yih/dQ6tp/g4rUZBSlDsekrd/LMH15hx649NDW3YZoGi2vP4eYb +r6awIM+Twk7PcWLbn8Fq2Y8w/ZPfYp1UxvehYCAt+QwYImag5PHhA9sdTTiRDlRe5qRIKcX6K9ew +/so1E1Y48vQWQBNc/EHMafOJPP3DyQFgp8blGV4fyAvKDkMocSIdeZf4zmfJWXPrpJTxpDAuuR/8 +BGb5bJzIiTOQGo9zTZ+KsbBIUhIULK+QTC/x/6ch/UaLSEm0M/QlU6p5H1ZLPWbVPM4mPXPY4WOG +b+jBJAGQwcyXNvlWJ5+p7Y9LQkmk32iRgYTvCeX3Wacfl/pe/TV2R9NZBWBHu4M7OGmTOGGeakZx +5uDt9HYO8iq/zwokfE/I/PuaO4Vh7Dr90KKdFL0v/Qynq/WsAZBwIGqNUiOcSJNyXI91+04O8gvD +2JV/X3OnBJBKvSiVHHl0txL0bvspic62rAzr64tyqPHouHxSQGRY3JqMA5gl0xG+YMbx3J7jCAFS +SaRSL8KpLVyY8hHpN0dFVqeiPPnjH/CrR5+kuyeScYBUyuL1N3byjW/9G42NRzwAIAaXwMm45tF9 +Nk19ekIe4J+3LONYOhnF7uqvZkm/iTDlI3CqKlyyqWV/+8ayBieZnDta59n5mp/99595/Y03WXze +QsrLyygrKyEQ8NPV1U1XVw/Hj59gz7t1JJPjnyeGe8CggsCeDpfGiGZOgaAiR5DvF9RMkYTGyafM +ynn4pmfOOK1j9f2jCJA+s6FkU8v+QQAAhJTPSb9vrpsauZfu6XARAmzb4s2db2ccaCDR8HTzO0qR +NGppdp/Q7D7R/yxowPq5BotLRz+fqMIqwss/li5oFEoe3nXKeB9CyucGJ2FQUHfyHiMY7EaKEe4V +tRi1spqpednRhvOFQkEMJUfISTrwRJ3NT3ZbNHS5aXrJ3CJyVt2KGL6VjkL2yaPYJw6BFBjBYLfq +Tt4zAoCiBzojGv2ACgRGRJir5hoUBkR2AHgpeQ/jyc3NYdklF44p72hE80qTO6iTr/p88j60ARkY +/9OzxN6X+re+QACNfqDogc7BYJbmV6WicLMK+FvFwOcrp1pxSHDDfAOVBQBe6HRPufnmG7nuuqsI ++H2jyuyzNKqwgtzVf0d46Y3954dxKHVkF1b7foSSqIC/tVQUpn0ukxZexKa9qfavl29WwcAWO5b+ +MXZ1oWRRmeTd496+ffe6BNJjgGDdujWsXbua9rZ2jhxporW1FaUU+fn5nLdgNnmlFR6lgxM5Tuyt +p/qv1oIBtOtsFpv2pgW5EfG1bFPbg8e/WfkPyu+b65wWENfNMqg/mcL2gIGnkv8YPFJKKiorqKis +8CBkdHJj3UTfeBTtWii/D2kaDaVfO/bgiLFG1UvpjxrBYEyZRlo4KAgKVkxXZyUInkmyOxrpfflB +3L4OlGlgBIMxofRHR+MddYct2di6q+3rZXcYodAvrWif1O7QlK+qNqjr0JyMjXPyGsUyrTUtzS3U +7atj3959uLZDPB7njJHrkDz4GvG9L4B2EUpihEKuY9t3lG9q3+UZAIDyTe2PHd9UXmuEc/7FivaC +7jdYClgzU/HbffbAo4z2JxIJGur3U7evjvp9dfQOqzxLATve2M68cyZ76tSkmt8hse+PuLGuQeFG +OAftut8v39T+WBbzlE7HN1c9i+teYcej6GEWN3a6/PGgTecYnjC3pgbHcThy+DCOk/kG5+prr+ED +q1ZlbbYb78ZqqyN1dCdOz9ChTQiBEQyDlM+VfrXlykwyxgWg4S78+WVTd4BeZCei6GHGaKChw+UP ++23ilrf/DIxF5RUVrFi1kkW1tfj86dubdh10sg+d6MVNRnB6WrHa9uFERh7ShFIYgTAg9vS0N180 +9wEy5uaeYlDDXfjzy6t+K4S4wk7Gca303aEnoXn0LYtYanIgDJBhGITCYQKBAIYT4+aFFuNWewBp ++jD8QbTWz/W0tVw/nvGeARig498u/56Qxt2OlZLOaZ/EPV9ns7fd22VlNjS7WHLtwvHvD5U/iDJ9 +rnbt75dubPuSV/nZ/GOE0o1tX3Jc6xZl+mJmKAdpGIPZYmludqmy13bJDCPjMVgaBmYoB2X6Yo5r +3ZKN8VkDAFC+sf0xlLNSKNlgBMOYwTBCKZSadEVrRFs8VVGWJ0Y1XCiFGQxjBMMIJRtQzsryjWNH ++7FoUnlI+3fLPyuFcY+A8iMdSbbu7iORmtwyqMiTXFytqMiTmKN8pCqkRJkBpDLR0OZq+1tlX257 +MPuRzgAAAPrrC3wdga6vdsblF/a2OeFowqarz6I7miKezA6M5bMMLpg2MjURUiGVgVAmUio09ODa +9xcnCkfk9u87AAO09daiqmCeeiTliGWWK4NaQ9Jy6Y6m6IlaJC0Hy9a4Y2RPAvjMaj8gEEIgpERI +A6lMxKkLPa11o8DdKmKhe4o2HYh41+59AGA4vfr5ooujKX2P5RqrXFTagd1xNSnbxbJdLKcfDFMJ +TENyaY0PQw0/HuJorfcIx3lG++QjJV/sL2OdSTorAAynrZ/Om+s31OeFkPNclwpXqyKNyHU1ftcV +SguE47pu2KTvgmrfLq11m4Bjrnb3B3zqifwvNHeeTf3+F5TINA20Q+qtAAAAJXRFWHRkYXRlOmNy +ZWF0ZQAyMDE1LTExLTEyVDEyOjE5OjAwKzAxOjAw+wc6YwAAACV0RVh0ZGF0ZTptb2RpZnkAMjAx +NS0xMS0xMlQxMjoxOTowMCswMTowMIpagt8AAAAZdEVYdFNvZnR3YXJlAHd3dy5pbmtzY2FwZS5v +cmeb7jwaAAAAAElFTkSuQmCC + +-----=_qute-986aec6d-ef0d-4d36-8a81-0b9c0f826384 +Content-Location: http://localhost:1234/data/downloads/mhtml/complex/script.js +MIME-Version: 1.0 +Content-Type: application/javascript +Content-Transfer-Encoding: base64 + +ZnVuY3Rpb24gbm9vcCgpIHt9Cm5vb3AoKTsK + +-----=_qute-986aec6d-ef0d-4d36-8a81-0b9c0f826384-- diff --git a/tests/integration/data/downloads/mhtml/complex/div-image.png b/tests/integration/data/downloads/mhtml/complex/div-image.png new file mode 100644 index 000000000..c65a1cf65 Binary files /dev/null and b/tests/integration/data/downloads/mhtml/complex/div-image.png differ diff --git a/tests/integration/data/downloads/mhtml/complex/extern-css.css b/tests/integration/data/downloads/mhtml/complex/extern-css.css new file mode 100644 index 000000000..7c4fe14b8 --- /dev/null +++ b/tests/integration/data/downloads/mhtml/complex/extern-css.css @@ -0,0 +1,4 @@ +@import "external-in-external.css"; +p { + font-family: "Monospace"; +} diff --git a/tests/integration/data/downloads/mhtml/complex/external-in-external.css b/tests/integration/data/downloads/mhtml/complex/external-in-external.css new file mode 100644 index 000000000..b4b5ef324 --- /dev/null +++ b/tests/integration/data/downloads/mhtml/complex/external-in-external.css @@ -0,0 +1,3 @@ +img { + width: 100%; +} diff --git a/tests/integration/data/downloads/mhtml/complex/favicon.png b/tests/integration/data/downloads/mhtml/complex/favicon.png new file mode 100644 index 000000000..1f6095657 Binary files /dev/null and b/tests/integration/data/downloads/mhtml/complex/favicon.png differ diff --git a/tests/integration/data/downloads/mhtml/complex/image.gif b/tests/integration/data/downloads/mhtml/complex/image.gif new file mode 100644 index 000000000..3acc7b851 Binary files /dev/null and b/tests/integration/data/downloads/mhtml/complex/image.gif differ diff --git a/tests/integration/data/downloads/mhtml/complex/inline.png b/tests/integration/data/downloads/mhtml/complex/inline.png new file mode 100644 index 000000000..4414857c5 Binary files /dev/null and b/tests/integration/data/downloads/mhtml/complex/inline.png differ diff --git a/tests/integration/data/downloads/mhtml/complex/requests b/tests/integration/data/downloads/mhtml/complex/requests new file mode 100644 index 000000000..2c1efd144 --- /dev/null +++ b/tests/integration/data/downloads/mhtml/complex/requests @@ -0,0 +1,9 @@ +background.png +base.css +div-image.png +extern-css.css +external-in-external.css +favicon.png +image.gif +inline.png +script.js diff --git a/tests/integration/data/downloads/mhtml/complex/script.js b/tests/integration/data/downloads/mhtml/complex/script.js new file mode 100644 index 000000000..15103db67 --- /dev/null +++ b/tests/integration/data/downloads/mhtml/complex/script.js @@ -0,0 +1,2 @@ +function noop() {} +noop(); diff --git a/tests/integration/data/downloads/mhtml/simple/requests b/tests/integration/data/downloads/mhtml/simple/requests new file mode 100644 index 000000000..e69de29bb diff --git a/tests/integration/data/downloads/mhtml/simple/simple.html b/tests/integration/data/downloads/mhtml/simple/simple.html new file mode 100644 index 000000000..7584c3f91 --- /dev/null +++ b/tests/integration/data/downloads/mhtml/simple/simple.html @@ -0,0 +1,10 @@ + + + + + Simple MHTML test + + + normal link to another page + + diff --git a/tests/integration/data/downloads/mhtml/simple/simple.mht b/tests/integration/data/downloads/mhtml/simple/simple.mht new file mode 100644 index 000000000..d0b7a7c48 --- /dev/null +++ b/tests/integration/data/downloads/mhtml/simple/simple.mht @@ -0,0 +1,20 @@ +Content-Type: multipart/related; boundary="---=_qute-6d584056-b1e4-4882-91e6-d4a6d23adb67" +MIME-Version: 1.0 + +-----=_qute-6d584056-b1e4-4882-91e6-d4a6d23adb67 +Content-Location: http://localhost:1234/data/downloads/mhtml/simple/simple.html +MIME-Version: 1.0 +Content-Type: text/html; charset="UTF-8" +Content-Transfer-Encoding: quoted-printable + + +=20=20=20=20=20=20=20=20 +=20=20=20=20=20=20=20=20Simple=20MHTML=20test +=20=20=20=20 +=20=20=20=20 +=20=20=20=20=20=20=20=20normal=20link=20to=20another=20page= + +=20=20=20=20 + + +-----=_qute-6d584056-b1e4-4882-91e6-d4a6d23adb67-- diff --git a/tests/integration/features/downloads.feature b/tests/integration/features/downloads.feature index f81b5677d..6c5bbac58 100644 --- a/tests/integration/features/downloads.feature +++ b/tests/integration/features/downloads.feature @@ -42,3 +42,28 @@ Feature: Downloading things from a website. Scenario: Retrying with no downloads When I run :download-retry Then the error "No failed downloads!" should be shown. + + Scenario: :download with deprecated dest-old argument + When I run :download http://localhost:(port)/ deprecated-argument + Then the warning ":download [url] [dest] is deprecated - use download --dest [dest] [url]" should be shown. + + Scenario: Two destinations given + When I run :download --dest destination2 http://localhost:(port)/ destination1 + Then the warning ":download [url] [dest] is deprecated - use download --dest [dest] [url]" should be shown. + And the error "Can't give two destinations for the download." should be shown. + + Scenario: :download --mhtml with an URL given + When I run :download --mhtml http://foobar/ + Then the error "Can only download the current page as mhtml." should be shown. + + Scenario: Downloading as mhtml is available + When I open html + And I run :download --mhtml + And I wait for "File successfully written." in the log + Then no crash should happen + + Scenario: Downloading as mhtml with non-ASCII headers + When I open response-headers?Content-Type=text%2Fpl%C3%A4in + And I run :download --mhtml --dest mhtml-response-headers.mht + And I wait for "File successfully written." in the log + Then no crash should happen diff --git a/tests/integration/quteprocess.py b/tests/integration/quteprocess.py index 72cf5cc35..7c0adeea3 100644 --- a/tests/integration/quteprocess.py +++ b/tests/integration/quteprocess.py @@ -32,11 +32,11 @@ import tempfile import yaml import pytest -from PyQt5.QtCore import pyqtSignal +from PyQt5.QtCore import pyqtSignal, QUrl import testprocess # pylint: disable=import-error from qutebrowser.misc import ipc -from qutebrowser.utils import log +from qutebrowser.utils import log, utils def is_ignored_qt_message(message): @@ -225,6 +225,9 @@ class QuteProc(testprocess.Process): def wait_for_load_finished(self, path, timeout=15000): """Wait until any tab has finished loading.""" url = self._path_to_url(path) + # We really need the same representation that the webview uses in its + # __repr__ + url = utils.elide(QUrl(url).toDisplayString(QUrl.EncodeUnicode), 100) pattern = re.compile( r"(load status for : LoadStatus.success|fetch: " diff --git a/tests/integration/test_mhtml_e2e.py b/tests/integration/test_mhtml_e2e.py new file mode 100644 index 000000000..090937186 --- /dev/null +++ b/tests/integration/test_mhtml_e2e.py @@ -0,0 +1,116 @@ +# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: + +# Copyright 2015 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 . + +"""Test mhtml downloads based on sample files.""" + +import os +import re +import os.path +import collections + +import pytest + + +def collect_tests(): + basedir = os.path.dirname(__file__) + datadir = os.path.join(basedir, 'data', 'downloads', 'mhtml') + files = os.listdir(datadir) + return files + + +def normalize_line(line): + line = line.rstrip('\n') + line = re.sub('boundary="---=_qute-[0-9a-f-]+"', + 'boundary="---=_qute-UUID"', line) + line = re.sub('^-----=_qute-[0-9a-f-]+$', '-----=_qute-UUID', line) + line = re.sub(r'localhost:\d{1,5}', 'localhost:(port)', line) + + # Depending on Python's mimetypes module/the system's mime files, .js + # files could be either identified as x-javascript or just javascript + line = line.replace('Content-Type: application/x-javascript', + 'Content-Type: application/javascript') + + return line + + +class DownloadDir: + + """Abstraction over a download directory.""" + + def __init__(self, tmpdir): + self._tmpdir = tmpdir + self.location = str(tmpdir) + + def read_file(self): + files = self._tmpdir.listdir() + assert len(files) == 1 + + with open(str(files[0]), 'r', encoding='utf-8') as f: + return f.readlines() + + def compare_mhtml(self, filename): + with open(filename, 'r', encoding='utf-8') as f: + expected_data = [normalize_line(line) for line in f] + actual_data = self.read_file() + actual_data = [normalize_line(line) for line in actual_data] + assert actual_data == expected_data + + +@pytest.fixture +def download_dir(tmpdir): + return DownloadDir(tmpdir) + + +@pytest.mark.parametrize('test_name', collect_tests()) +def test_mhtml(test_name, download_dir, quteproc, httpbin): + quteproc.set_setting('storage', 'download-directory', + download_dir.location) + quteproc.set_setting('storage', 'prompt-download-directory', 'false') + + test_dir = os.path.join(os.path.abspath(os.path.dirname(__file__)), + 'data', 'downloads', 'mhtml', test_name) + test_path = 'data/downloads/mhtml/{}'.format(test_name) + + quteproc.open_path('{}/{}.html'.format(test_path, test_name)) + download_dest = os.path.join(download_dir.location, + '{}-downloaded.mht'.format(test_name)) + # Discard all requests that were necessary to display the page + httpbin.clear_data() + quteproc.send_cmd(':download --mhtml --dest "{}"'.format(download_dest)) + quteproc.wait_for(category='downloads', module='mhtml', + function='_finish_file', + message='File successfully written.') + + expected_file = os.path.join(test_dir, '{}.mht'.format(test_name)) + download_dir.compare_mhtml(expected_file) + + with open(os.path.join(test_dir, 'requests'), encoding='utf-8') as f: + expected_requests = [] + for line in f: + if line.startswith('#'): + continue + path = '/{}/{}'.format(test_path, line.strip()) + expected_requests.append(httpbin.ExpectedRequest('GET', path)) + + actual_requests = httpbin.get_requests() + # Requests are not hashable, we need to convert to ExpectedRequests + actual_requests = map(httpbin.ExpectedRequest.from_request, + actual_requests) + assert (collections.Counter(actual_requests) == + collections.Counter(expected_requests)) diff --git a/tests/integration/testprocess.py b/tests/integration/testprocess.py index e6ead16c6..325383932 100644 --- a/tests/integration/testprocess.py +++ b/tests/integration/testprocess.py @@ -182,13 +182,17 @@ class Process(QObject): time.sleep(1) # Exit the process to make sure we're in a defined state again self.terminate() - self._data.clear() + self.clear_data() raise InvalidLine(self._invalid) - self._data.clear() + self.clear_data() if not self.is_running(): raise ProcessExited + def clear_data(self): + """Clear the collected data.""" + self._data.clear() + def terminate(self): """Clean up and shut down the process.""" self.proc.terminate() diff --git a/tests/integration/webserver.py b/tests/integration/webserver.py index 5932e9042..9afb575e8 100644 --- a/tests/integration/webserver.py +++ b/tests/integration/webserver.py @@ -97,6 +97,11 @@ class ExpectedRequest: self.verb = verb self.path = path + @classmethod + def from_request(cls, request): + """Create an ExpectedRequest from a Request.""" + return cls(request.verb, request.path) + def __eq__(self, other): if isinstance(other, (Request, ExpectedRequest)): return (self.verb == other.verb and @@ -104,6 +109,13 @@ class ExpectedRequest: else: return NotImplemented + def __hash__(self): + return hash(('ExpectedRequest', self.verb, self.path)) + + def __repr__(self): + return ('ExpectedRequest(verb={!r}, path={!r})' + .format(self.verb, self.path)) + class HTTPBin(testprocess.Process): diff --git a/tests/integration/webserver_sub.py b/tests/integration/webserver_sub.py index b8d75b041..27e052e81 100644 --- a/tests/integration/webserver_sub.py +++ b/tests/integration/webserver_sub.py @@ -24,10 +24,13 @@ This script gets called as a QProcess from integration/conftest.py. import sys import time -import os.path +import signal +import os +from datetime import datetime from httpbin.core import app from httpbin.structures import CaseInsensitiveDict +import cherrypy.wsgiserver import flask @@ -51,11 +54,63 @@ def redirect_later(): return flask.redirect('/') +@app.after_request +def log_request(response): + request = flask.request + template = '127.0.0.1 - - [{date}] "{verb} {path} {http}" {status} -' + print(template.format( + date=datetime.now().strftime('%d/%b/%Y %H:%M:%S'), + verb=request.method, + path=request.full_path if request.query_string else request.path, + http=request.environ['SERVER_PROTOCOL'], + status=response.status_code, + ), file=sys.stderr, flush=True) + return response + + +class WSGIServer(cherrypy.wsgiserver.CherryPyWSGIServer): + + """A custom WSGIServer that prints a line on stderr when it's ready.""" + + # pylint: disable=no-member + # WORKAROUND for https://bitbucket.org/logilab/pylint/issues/702 + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._ready = False + self._printed_ready = False + + @property + def ready(self): + return self._ready + + @ready.setter + def ready(self, value): + if value and not self._printed_ready: + print(' * Running on http://127.0.0.1:{}/ (Press CTRL+C to quit)' + .format(self.bind_addr[1]), file=sys.stderr, flush=True) + self._printed_ready = True + self._ready = value + + def main(): + # pylint: disable=no-member + # WORKAROUND for https://bitbucket.org/logilab/pylint/issues/702 + # "Instance of 'WSGIServer' has no 'start' member (no-member)" + # "Instance of 'WSGIServer' has no 'stop' member (no-member)" + if hasattr(sys, 'frozen'): basedir = os.path.realpath(os.path.dirname(sys.executable)) app.template_folder = os.path.join(basedir, 'integration', 'templates') - app.run(port=int(sys.argv[1]), debug=True, use_reloader=False) + port = int(sys.argv[1]) + server = WSGIServer(('127.0.0.1', port), app) + + signal.signal(signal.SIGTERM, lambda *args: server.stop()) + + try: + server.start() + except KeyboardInterrupt: + server.stop() if __name__ == '__main__': diff --git a/tests/unit/browser/test_mhtml.py b/tests/unit/browser/test_mhtml.py new file mode 100644 index 000000000..5a6912863 --- /dev/null +++ b/tests/unit/browser/test_mhtml.py @@ -0,0 +1,298 @@ +# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: + +# Copyright 2015 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 . + +"""Tests for qutebrowser.browser.mhtml.""" + +import io +import textwrap +import re + +import pytest + +from qutebrowser.browser import mhtml + +@pytest.fixture(autouse=True) +def patch_uuid(monkeypatch): + monkeypatch.setattr("uuid.uuid4", lambda: "UUID") + + +class Checker: + + """A helper to check mhtml output. + + Attrs: + fp: A BytesIO object for passing to MHTMLWriter.write_to. + """ + + def __init__(self): + self.fp = io.BytesIO() + + @property + def value(self): + return self.fp.getvalue() + + def expect(self, expected): + actual = self.value.decode('ascii') + # Make sure there are no stray \r or \n + assert re.search(r'\r[^\n]', actual) is None + assert re.search(r'[^\r]\n', actual) is None + actual = actual.replace('\r\n', '\n') + expected = textwrap.dedent(expected).lstrip('\n') + assert expected == actual + + +@pytest.fixture +def checker(): + return Checker() + + +def test_quoted_printable_umlauts(checker): + content = 'Die süße Hündin läuft in die Höhle des Bären' + content = content.encode('iso-8859-1') + writer = mhtml.MHTMLWriter(root_content=content, + content_location='localhost', + content_type='text/plain') + writer.write_to(checker.fp) + checker.expect(""" + Content-Type: multipart/related; boundary="---=_qute-UUID" + MIME-Version: 1.0 + + -----=_qute-UUID + Content-Location: localhost + MIME-Version: 1.0 + Content-Type: text/plain + Content-Transfer-Encoding: quoted-printable + + Die=20s=FC=DFe=20H=FCndin=20l=E4uft=20in=20die=20H=F6hle=20des=20B=E4ren + -----=_qute-UUID-- + """) + + +@pytest.mark.parametrize('header, value', [ + ('content_location', 'http://brötli.com'), + ('content_type', 'text/pläin'), +]) +def test_refuses_non_ascii_header_value(checker, header, value): + defaults = { + 'root_content': b'', + 'content_location': 'http://example.com', + 'content_type': 'text/plain', + } + defaults[header] = value + writer = mhtml.MHTMLWriter(**defaults) + with pytest.raises(UnicodeEncodeError) as excinfo: + writer.write_to(checker.fp) + assert "'ascii' codec can't encode" in str(excinfo.value) + + +def test_file_encoded_as_base64(checker): + content = b'Image file attached' + writer = mhtml.MHTMLWriter(root_content=content, content_type='text/plain', + content_location='http://example.com') + writer.add_file(location='http://a.example.com/image.png', + content='\U0001F601 image data'.encode('utf-8'), + content_type='image/png', + transfer_encoding=mhtml.E_BASE64) + writer.write_to(checker.fp) + checker.expect(""" + Content-Type: multipart/related; boundary="---=_qute-UUID" + MIME-Version: 1.0 + + -----=_qute-UUID + Content-Location: http://example.com + MIME-Version: 1.0 + Content-Type: text/plain + Content-Transfer-Encoding: quoted-printable + + Image=20file=20attached + -----=_qute-UUID + Content-Location: http://a.example.com/image.png + MIME-Version: 1.0 + Content-Type: image/png + Content-Transfer-Encoding: base64 + + 8J+YgSBpbWFnZSBkYXRh + + -----=_qute-UUID-- + """) + + +@pytest.mark.parametrize('transfer_encoding', [mhtml.E_BASE64, mhtml.E_QUOPRI], + ids=['base64', 'quoted-printable']) +def test_payload_lines_wrap(checker, transfer_encoding): + payload = b'1234567890' * 10 + writer = mhtml.MHTMLWriter(root_content=b'', content_type='text/plain', + content_location='http://example.com') + writer.add_file(location='http://example.com/payload', content=payload, + content_type='text/plain', + transfer_encoding=transfer_encoding) + writer.write_to(checker.fp) + for line in checker.value.split(b'\r\n'): + assert len(line) < 77 + + +def test_files_appear_sorted(checker): + writer = mhtml.MHTMLWriter(root_content=b'root file', + content_type='text/plain', + content_location='http://www.example.com/') + for subdomain in 'ahgbizt': + writer.add_file(location='http://{}.example.com/'.format(subdomain), + content='file {}'.format(subdomain).encode('utf-8'), + content_type='text/plain', + transfer_encoding=mhtml.E_QUOPRI) + writer.write_to(checker.fp) + checker.expect(""" + Content-Type: multipart/related; boundary="---=_qute-UUID" + MIME-Version: 1.0 + + -----=_qute-UUID + Content-Location: http://www.example.com/ + MIME-Version: 1.0 + Content-Type: text/plain + Content-Transfer-Encoding: quoted-printable + + root=20file + -----=_qute-UUID + Content-Location: http://a.example.com/ + MIME-Version: 1.0 + Content-Type: text/plain + Content-Transfer-Encoding: quoted-printable + + file=20a + -----=_qute-UUID + Content-Location: http://b.example.com/ + MIME-Version: 1.0 + Content-Type: text/plain + Content-Transfer-Encoding: quoted-printable + + file=20b + -----=_qute-UUID + Content-Location: http://g.example.com/ + MIME-Version: 1.0 + Content-Type: text/plain + Content-Transfer-Encoding: quoted-printable + + file=20g + -----=_qute-UUID + Content-Location: http://h.example.com/ + MIME-Version: 1.0 + Content-Type: text/plain + Content-Transfer-Encoding: quoted-printable + + file=20h + -----=_qute-UUID + Content-Location: http://i.example.com/ + MIME-Version: 1.0 + Content-Type: text/plain + Content-Transfer-Encoding: quoted-printable + + file=20i + -----=_qute-UUID + Content-Location: http://t.example.com/ + MIME-Version: 1.0 + Content-Type: text/plain + Content-Transfer-Encoding: quoted-printable + + file=20t + -----=_qute-UUID + Content-Location: http://z.example.com/ + MIME-Version: 1.0 + Content-Type: text/plain + Content-Transfer-Encoding: quoted-printable + + file=20z + -----=_qute-UUID-- + """) + + +def test_empty_content_type(checker): + writer = mhtml.MHTMLWriter(root_content=b'', + content_location='http://example.com/', + content_type='text/plain') + writer.add_file('http://example.com/file', b'file content') + writer.write_to(checker.fp) + checker.expect(""" + Content-Type: multipart/related; boundary="---=_qute-UUID" + MIME-Version: 1.0 + + -----=_qute-UUID + Content-Location: http://example.com/ + MIME-Version: 1.0 + Content-Type: text/plain + Content-Transfer-Encoding: quoted-printable + + + -----=_qute-UUID + MIME-Version: 1.0 + Content-Location: http://example.com/file + Content-Transfer-Encoding: quoted-printable + + file=20content + -----=_qute-UUID-- + """) + + +@pytest.mark.parametrize('has_cssutils', [ + pytest.mark.skipif(mhtml.cssutils is None, + reason="requires cssutils")(True), + False, +], ids=['with_cssutils', 'no_cssutils']) +@pytest.mark.parametrize('inline, style, expected_urls', [ + (False, "@import 'default.css'", ['default.css']), + (False, '@import "default.css"', ['default.css']), + (False, "@import \t 'tabbed.css'", ['tabbed.css']), + (False, "@import url('default.css')", ['default.css']), + (False, """body { + background: url("/bg-img.png") + }""", ['/bg-img.png']), + (True, 'background: url(folder/file.png) no-repeat', ['folder/file.png']), + (True, 'content: url()', []), +]) +def test_css_url_scanner(monkeypatch, has_cssutils, inline, style, + expected_urls): + if not has_cssutils: + monkeypatch.setattr('qutebrowser.browser.mhtml.cssutils', None) + expected_urls.sort() + urls = mhtml._get_css_imports(style, inline=inline) + urls.sort() + assert urls == expected_urls + + +class TestNoCloseBytesIO: + # WORKAROUND for https://bitbucket.org/logilab/pylint/issues/540/ + # pylint: disable=no-member + + def test_fake_close(self): + fp = mhtml._NoCloseBytesIO() + fp.write(b'Value') + fp.close() + assert fp.getvalue() == b'Value' + fp.write(b'Eulav') + assert fp.getvalue() == b'ValueEulav' + + def test_actual_close(self): + fp = mhtml._NoCloseBytesIO() + fp.write(b'Value') + fp.actual_close() + with pytest.raises(ValueError) as excinfo: + fp.getvalue() + assert str(excinfo.value) == 'I/O operation on closed file.' + with pytest.raises(ValueError) as excinfo: + fp.write(b'Closed') + assert str(excinfo.value) == 'I/O operation on closed file.' diff --git a/tests/unit/utils/test_urlutils.py b/tests/unit/utils/test_urlutils.py index 4a19df689..5496a019b 100644 --- a/tests/unit/utils/test_urlutils.py +++ b/tests/unit/utils/test_urlutils.py @@ -527,6 +527,19 @@ def test_same_domain_invalid_url(url1, url2): with pytest.raises(urlutils.InvalidUrlError): urlutils.same_domain(QUrl(url1), QUrl(url2)) + +@pytest.mark.parametrize('url, expected', [ + ('http://example.com', 'http://example.com'), + ('http://ünicode.com', 'http://xn--nicode-2ya.com'), + ('http://foo.bar/?header=text/pläin', + 'http://foo.bar/?header=text/pl%C3%A4in'), +]) +def test_encoded_url(url, expected): + """Test encoded_url""" + url = QUrl(url) + assert urlutils.encoded_url(url) == expected + + class TestIncDecNumber: """Tests for urlutils.incdec_number().""" diff --git a/tests/unit/utils/test_utils.py b/tests/unit/utils/test_utils.py index 499d86ef2..b75169900 100644 --- a/tests/unit/utils/test_utils.py +++ b/tests/unit/utils/test_utils.py @@ -880,6 +880,20 @@ def test_force_encoding(inp, enc, expected): assert utils.force_encoding(inp, enc) == expected +@pytest.mark.parametrize('inp, expected', [ + ('normal.txt', 'normal.txt'), + ('user/repo issues.mht', 'user_repo issues.mht'), + (' - "*?:|', '_Test_File_ - _____'), +]) +def test_sanitize_filename(inp, expected): + assert utils.sanitize_filename(inp) == expected + + +def test_sanitize_filename_empty_replacement(): + name = '//' + assert utils.sanitize_filename(name, replacement=None) == 'Bad File' + + class TestNewestSlice: """Test newest_slice.""" diff --git a/tests/unit/utils/test_version.py b/tests/unit/utils/test_version.py index 769049172..8dfbd4869 100644 --- a/tests/unit/utils/test_version.py +++ b/tests/unit/utils/test_version.py @@ -324,6 +324,7 @@ class ImportFake: 'jinja2': True, 'pygments': True, 'yaml': True, + 'cssutils': True, } self.version_attribute = '__version__' self.version = '1.2.3' @@ -383,12 +384,13 @@ class TestModuleVersions: """Test with all modules present in version 1.2.3.""" expected = ['sip: yes', 'colorlog: yes', 'colorama: 1.2.3', 'pypeg2: 1.2.3', 'jinja2: 1.2.3', 'pygments: 1.2.3', - 'yaml: 1.2.3'] + 'yaml: 1.2.3', 'cssutils: 1.2.3'] assert version._module_versions() == expected @pytest.mark.parametrize('module, idx, expected', [ ('colorlog', 1, 'colorlog: no'), ('colorama', 2, 'colorama: no'), + ('cssutils', 7, 'cssutils: no'), ]) def test_missing_module(self, module, idx, expected, import_fake): """Test with a module missing. @@ -404,12 +406,13 @@ class TestModuleVersions: @pytest.mark.parametrize('value, expected', [ ('VERSION', ['sip: yes', 'colorlog: yes', 'colorama: 1.2.3', 'pypeg2: yes', 'jinja2: yes', 'pygments: yes', - 'yaml: yes']), + 'yaml: yes', 'cssutils: yes']), ('SIP_VERSION_STR', ['sip: 1.2.3', 'colorlog: yes', 'colorama: yes', 'pypeg2: yes', 'jinja2: yes', 'pygments: yes', - 'yaml: yes']), + 'yaml: yes', 'cssutils: yes']), (None, ['sip: yes', 'colorlog: yes', 'colorama: yes', 'pypeg2: yes', - 'jinja2: yes', 'pygments: yes', 'yaml: yes']), + 'jinja2: yes', 'pygments: yes', 'yaml: yes', + 'cssutils: yes']), ]) def test_version_attribute(self, value, expected, import_fake): """Test with a different version attribute. @@ -432,6 +435,7 @@ class TestModuleVersions: ('jinja2', True), ('pygments', True), ('yaml', True), + ('cssutils', True), ]) def test_existing_attributes(self, name, has_version): """Check if all dependencies have an expected __version__ attribute. diff --git a/tox.ini b/tox.ini index 2b0def206..8e13255c3 100644 --- a/tox.ini +++ b/tox.ini @@ -42,6 +42,7 @@ deps = Werkzeug==0.11.2 wheel==0.26.0 xvfbwrapper==0.2.5 + cherrypy==3.8.0 commands = {envpython} scripts/link_pyqt.py --tox {envdir} {envpython} -m py.test --strict -rfEsw --faulthandler-timeout=70 --cov --cov-report xml --cov-report=html --cov-report= {posargs:tests}