From 9b0e86a8e74768c4fe848fb5ce8d754292db4e3e Mon Sep 17 00:00:00 2001 From: Unit 193 Date: Tue, 23 Apr 2024 00:37:58 -0400 Subject: New upstream version 8.3.0. --- .../dev_server_sample_output_folder/index.html | 9 + tests/data/plugin_manager/broken.plugin | 12 ++ tests/data/plugin_manager/broken.py | 42 ++++ tests/data/plugin_manager/first.plugin | 13 ++ tests/data/plugin_manager/one.py | 47 +++++ tests/data/plugin_manager/second/second.plugin | 13 ++ tests/data/plugin_manager/second/two/__init__.py | 41 ++++ tests/helper.py | 56 ++---- tests/integration/test_demo_build.py | 136 ++++++++++++- tests/integration/test_dev_server.py | 223 +++++++++++++++++++++ tests/integration/test_relative_links.py | 4 +- .../test_relative_links_with_pages_in_root.py | 4 +- tests/test_compile_markdown.py | 7 +- tests/test_locale.py | 8 +- tests/test_plugin_manager.py | 142 +++++++++++++ tests/test_template_shortcodes.py | 2 +- tests/test_test_helper.py | 22 ++ 17 files changed, 729 insertions(+), 52 deletions(-) create mode 100644 tests/data/dev_server_sample_output_folder/index.html create mode 100644 tests/data/plugin_manager/broken.plugin create mode 100644 tests/data/plugin_manager/broken.py create mode 100644 tests/data/plugin_manager/first.plugin create mode 100644 tests/data/plugin_manager/one.py create mode 100644 tests/data/plugin_manager/second/second.plugin create mode 100644 tests/data/plugin_manager/second/two/__init__.py create mode 100644 tests/integration/test_dev_server.py create mode 100644 tests/test_plugin_manager.py create mode 100644 tests/test_test_helper.py (limited to 'tests') diff --git a/tests/data/dev_server_sample_output_folder/index.html b/tests/data/dev_server_sample_output_folder/index.html new file mode 100644 index 0000000..501f7f3 --- /dev/null +++ b/tests/data/dev_server_sample_output_folder/index.html @@ -0,0 +1,9 @@ + + + Dev server test fixture + + +

This is a dummy file for the dev server to serve

+

...during the tests ✔.

+ + diff --git a/tests/data/plugin_manager/broken.plugin b/tests/data/plugin_manager/broken.plugin new file mode 100644 index 0000000..1a5f9e0 --- /dev/null +++ b/tests/data/plugin_manager/broken.plugin @@ -0,0 +1,12 @@ +[Core] +name = broken +module = broken + +[Documentation] +author = Chris Warrick +version = 1.0 +website = https://getnikola.com/ +description = Broken (wrong category) + +[Nikola] +PluginCategory = Task diff --git a/tests/data/plugin_manager/broken.py b/tests/data/plugin_manager/broken.py new file mode 100644 index 0000000..68af857 --- /dev/null +++ b/tests/data/plugin_manager/broken.py @@ -0,0 +1,42 @@ +# -*- coding: utf-8 -*- + +# Copyright © 2012-2024 Chris Warrick and others. + +# Permission is hereby granted, free of charge, to any +# person obtaining a copy of this software and associated +# documentation files (the "Software"), to deal in the +# Software without restriction, including without limitation +# the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the +# Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice +# shall be included in all copies or substantial portions of +# the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY +# KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE +# WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +# PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS +# OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR +# OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +# OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +"""The second plugin.""" + +from nikola.plugin_categories import TemplateSystem + + +class BrokenPlugin(TemplateSystem): + """The TemplateSystem plugin whose .plugin file says it’s a Task plugin.""" + + name = "broken" + broken_site_set = False + + def set_site(self, site): + super().set_site(site) + print("Site for broken was set") + self.broken_site_set = True + raise Exception("Site for broken was set") diff --git a/tests/data/plugin_manager/first.plugin b/tests/data/plugin_manager/first.plugin new file mode 100644 index 0000000..cae4442 --- /dev/null +++ b/tests/data/plugin_manager/first.plugin @@ -0,0 +1,13 @@ +[Core] +name = first +module = one + +[Documentation] +author = Chris Warrick +version = 1.0 +website = https://getnikola.com/ +description = Do one thing + +[Nikola] +PluginCategory = Command +compiler = foo diff --git a/tests/data/plugin_manager/one.py b/tests/data/plugin_manager/one.py new file mode 100644 index 0000000..817bd07 --- /dev/null +++ b/tests/data/plugin_manager/one.py @@ -0,0 +1,47 @@ +# -*- coding: utf-8 -*- + +# Copyright © 2012-2024 Chris Warrick and others. + +# Permission is hereby granted, free of charge, to any +# person obtaining a copy of this software and associated +# documentation files (the "Software"), to deal in the +# Software without restriction, including without limitation +# the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the +# Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice +# shall be included in all copies or substantial portions of +# the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY +# KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE +# WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +# PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS +# OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR +# OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +# OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +"""The first command.""" + +from nikola.plugin_categories import Command + + +class CommandOne(Command): + """The first command.""" + + name = "one" + doc_purpose = "do one thing" + doc_description = "Do a thing." + one_site_set = False + + def set_site(self, site): + super().set_site(site) + print("Site for 1 was set") + self.one_site_set = True + + def _execute(self, options, args): + """Run the command.""" + print("Hello world!") diff --git a/tests/data/plugin_manager/second/second.plugin b/tests/data/plugin_manager/second/second.plugin new file mode 100644 index 0000000..ad1e4b8 --- /dev/null +++ b/tests/data/plugin_manager/second/second.plugin @@ -0,0 +1,13 @@ +[Core] +name = 2nd +module = two + +[Documentation] +author = Chris Warrick +version = 1.0 +website = https://getnikola.com/ +description = Do another thing + +[Nikola] +PluginCategory = ConfigPlugin + diff --git a/tests/data/plugin_manager/second/two/__init__.py b/tests/data/plugin_manager/second/two/__init__.py new file mode 100644 index 0000000..14794c0 --- /dev/null +++ b/tests/data/plugin_manager/second/two/__init__.py @@ -0,0 +1,41 @@ +# -*- coding: utf-8 -*- + +# Copyright © 2012-2024 Chris Warrick and others. + +# Permission is hereby granted, free of charge, to any +# person obtaining a copy of this software and associated +# documentation files (the "Software"), to deal in the +# Software without restriction, including without limitation +# the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the +# Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice +# shall be included in all copies or substantial portions of +# the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY +# KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE +# WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +# PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS +# OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR +# OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +# OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +"""The second plugin.""" + +from nikola.plugin_categories import ConfigPlugin + + +class TwoConfigPlugin(ConfigPlugin): + """The second plugin.""" + + name = "2nd" + two_site_set = False + + def set_site(self, site): + super().set_site(site) + print("Site for 2 was set") + self.two_site_set = True diff --git a/tests/helper.py b/tests/helper.py index 36558da..8fbebdb 100644 --- a/tests/helper.py +++ b/tests/helper.py @@ -6,23 +6,12 @@ a Site substitute for rendering tests. """ import os +import pathlib from contextlib import contextmanager -from yapsy.PluginManager import PluginManager - -import nikola.utils import nikola.shortcodes -from nikola.plugin_categories import ( - Command, - Task, - LateTask, - TemplateSystem, - PageCompiler, - TaskMultiplier, - CompilerExtension, - MarkdownExtension, - RestExtension, -) +import nikola.utils +from nikola.plugin_manager import PluginManager __all__ = ["cd", "FakeSite"] @@ -30,9 +19,11 @@ __all__ = ["cd", "FakeSite"] @contextmanager def cd(path): old_dir = os.getcwd() - os.chdir(path) - yield - os.chdir(old_dir) + try: + os.chdir(path) + yield + finally: + os.chdir(old_dir) class FakeSite: @@ -53,24 +44,11 @@ class FakeSite: "TRANSLATIONS": {"en": ""}, } self.EXTRA_PLUGINS = self.config["EXTRA_PLUGINS"] - self.plugin_manager = PluginManager( - categories_filter={ - "Command": Command, - "Task": Task, - "LateTask": LateTask, - "TemplateSystem": TemplateSystem, - "PageCompiler": PageCompiler, - "TaskMultiplier": TaskMultiplier, - "CompilerExtension": CompilerExtension, - "MarkdownExtension": MarkdownExtension, - "RestExtension": RestExtension, - } - ) + places = [pathlib.Path(nikola.utils.__file__).parent / "plugins"] + self.plugin_manager = PluginManager(plugin_places=places) self.shortcode_registry = {} - self.plugin_manager.setPluginInfoExtension("plugin") - places = [os.path.join(os.path.dirname(nikola.utils.__file__), "plugins")] - self.plugin_manager.setPluginPlaces(places) - self.plugin_manager.collectPlugins() + candidates = self.plugin_manager.locate_plugins() + self.plugin_manager.load_plugins(candidates) self.compiler_extensions = self._activate_plugins_of_category( "CompilerExtension" ) @@ -86,13 +64,9 @@ class FakeSite: """Activate all the plugins of a given category and return them.""" # this code duplicated in nikola/nikola.py plugins = [] - for plugin_info in self.plugin_manager.getPluginsOfCategory(category): - if plugin_info.name in self.config.get("DISABLED_PLUGINS"): - self.plugin_manager.removePluginFromCategory(plugin_info, category) - else: - self.plugin_manager.activatePluginByName(plugin_info.name) - plugin_info.plugin_object.set_site(self) - plugins.append(plugin_info) + for plugin_info in self.plugin_manager.get_plugins_of_category(category): + plugin_info.plugin_object.set_site(self) + plugins.append(plugin_info) return plugins def render_template(self, name, _, context): diff --git a/tests/integration/test_demo_build.py b/tests/integration/test_demo_build.py index 57a1807..3a36d28 100644 --- a/tests/integration/test_demo_build.py +++ b/tests/integration/test_demo_build.py @@ -7,8 +7,14 @@ In this case these are tested against the demo site with default settings. """ +import datetime +import email +import itertools import os +import time +import feedparser +import freezegun import pytest import nikola.plugins.command.init @@ -23,6 +29,133 @@ from .test_empty_build import ( # NOQA test_index_in_sitemap, ) +BUILDTIME = datetime.datetime(2023, 4, 5, 23, 59, 58, tzinfo=datetime.timezone.utc) + + +class Any: + """Compare equal with anything. Use for expected values we don't care about.""" + def __eq__(self, _): + return True + + +def rfc822(t): + """Format a datetime according to RFC822, eg 'Wed, 05 Apr 2023 23:59:58 GMT'""" + return email.utils.formatdate( + time.mktime(BUILDTIME.astimezone().timetuple()), + usegmt=True, + ) + + +def test_gallery_rss(build, output_dir): + # Given a build of the demo samplesite in 'output_dir' + # When we look for the RSS file of the "Demo" gallery + rss_path = os.path.join(output_dir, 'galleries', 'demo', 'rss.xml') + + # Then it exists + assert os.path.isfile(rss_path) + # and it contains text + with open(rss_path) as fp: + content = fp.read() + assert isinstance(content, str) + assert len(content) > 0 + # and the text can be parsed as valid RSS + parsed = feedparser.parse(content) + # and the RSS contains top level attributes: + assert parsed.version == 'rss20' + # and the RSS contains feed attributes, about the gallery: + assert parsed.feed.language == 'en' + assert parsed.feed.link == 'https://example.com/galleries/demo/rss.xml' + # TODO I think the following is a bug: The feed title should be the Gallery name, + # not the name of the gallery's final image. + assert parsed.feed.title == 'Tesla tower1 lg' + # TODO I think the following is a bug: The feed's subtitle (aka description) should + # contain the content of the gallery's index.txt. + assert parsed.feed.subtitle == '' # From the XML field 'description' + assert parsed.feed.updated == rfc822(BUILDTIME) + # and the images, as items in the RSS feed, are: + expected_items = [ + dict( + id='galleries/demo/tesla4_lg.jpg', + link='https://example.com/galleries/demo/tesla4_lg.jpg', + links=[ + Any(), + dict( + href='https://example.com/galleries/demo/tesla4_lg.jpg', + length='30200', + rel='enclosure', + type='image/jpeg', + ), + ], + published='Wed, 01 Jan 2014 00:01:00 GMT', + title='Tesla4 lg', + ), + dict( + id='galleries/demo/tesla_conducts_lg.webp', + link='https://example.com/galleries/demo/tesla_conducts_lg.webp', + links=[ + Any(), + dict( + href='https://example.com/galleries/demo/tesla_conducts_lg.webp', + length='9620', + rel='enclosure', + type='image/webp', + ), + ], + published='Wed, 01 Jan 2014 00:02:00 GMT', + title='Tesla conducts lg', + ), + dict( + id='galleries/demo/tesla_lightning1_lg.jpg', + link='https://example.com/galleries/demo/tesla_lightning1_lg.jpg', + links=[ + Any(), + dict( + href='https://example.com/galleries/demo/tesla_lightning1_lg.jpg', + length='41123', + rel='enclosure', + type='image/jpeg', + ), + ], + published='Wed, 01 Jan 2014 00:03:00 GMT', + title='Tesla lightning1 lg', + ), + dict( + id='galleries/demo/tesla_lightning2_lg.jpg', + link='https://example.com/galleries/demo/tesla_lightning2_lg.jpg', + links=[ + Any(), + dict( + href='https://example.com/galleries/demo/tesla_lightning2_lg.jpg', + length='36994', + rel='enclosure', + type='image/jpeg', + ), + ], + published='Wed, 01 Jan 2014 00:04:00 GMT', + title='Tesla lightning2 lg', + ), + dict( + id='galleries/demo/tesla_tower1_lg.jpg', + link='https://example.com/galleries/demo/tesla_tower1_lg.jpg', + links=[ + Any(), + dict( + href='https://example.com/galleries/demo/tesla_tower1_lg.jpg', + length='18105', + rel='enclosure', + type='image/jpeg', + ) + ], + published='Wed, 01 Jan 2014 00:05:00 GMT', + title='Tesla tower1 lg', + ), + ] + for index, (actual, expected) in enumerate( + itertools.zip_longest(parsed.entries, expected_items) + ): + for key, value in expected.items(): + assert actual[key] == value, f'item [{index}][{key!r}] {actual}' + @pytest.fixture(scope="module") def build(target_dir): @@ -30,7 +163,8 @@ def build(target_dir): prepare_demo_site(target_dir) with cd(target_dir): - __main__.main(["build"]) + with freezegun.freeze_time(BUILDTIME): + __main__.main(["build"]) def prepare_demo_site(target_dir): diff --git a/tests/integration/test_dev_server.py b/tests/integration/test_dev_server.py new file mode 100644 index 0000000..769678f --- /dev/null +++ b/tests/integration/test_dev_server.py @@ -0,0 +1,223 @@ +import asyncio +import nikola.plugins.command.auto as auto +from nikola.utils import get_logger +import pytest +import pathlib +import requests +import socket +import sys +from typing import Optional, Tuple, Any, Dict + +from ..helper import FakeSite + +SERVER_ADDRESS = "localhost" +TEST_MAX_DURATION = 10 # Watchdog: Give up the test if it did not succeed during this time span. + +# Folder that has the fixture file we expect the server to serve: +OUTPUT_FOLDER = pathlib.Path(__file__).parent.parent / "data" / "dev_server_sample_output_folder" + +LOGGER = get_logger("test_dev_server") + + +def find_unused_port() -> int: + """Ask the OS for a currently unused port number. + + (More precisely, a port that can be used for a TCP server servicing SERVER_ADDRESS.) + We use a method here rather than a fixture to minimize side effects of failing tests. + """ + s = socket.socket() + try: + ANY_PORT = 0 + s.bind((SERVER_ADDRESS, ANY_PORT)) + address, port = s.getsockname() + LOGGER.info("Trying to set up dev server on http://%s:%i/", address, port) + return port + finally: + s.close() + + +class MyFakeSite(FakeSite): + def __init__(self, config: Dict[str, Any], configuration_filename="conf.py"): + self.configured = True + self.debug = True + self.THEMES = [] + self._plugin_places = [] + self.registered_auto_watched_folders = set() + self.config = config + self.configuration_filename = configuration_filename + + +def test_serves_root_dir( + site_and_base_path: Tuple[MyFakeSite, str], expected_text: str +) -> None: + site, base_path = site_and_base_path + command_auto = auto.CommandAuto() + command_auto.set_site(site) + options = { + "browser": False, + "ipv6": False, + "address": SERVER_ADDRESS, + "port": find_unused_port(), + "db-file": "/dev/null", + "backend": "No backend", + "no-server": False + } + + # We start an event loop, run the test in an executor, + # and wait for the event loop to terminate. + # These variables help to transport the test result to + # the main thread outside the event loop: + test_was_successful = False + test_problem_description = "Async test setup apparently broken" + test_inner_error: Optional[BaseException] = None + loop_for_this_test = None + + async def grab_loop_and_run_test() -> None: + nonlocal test_problem_description, loop_for_this_test + + loop_for_this_test = asyncio.get_running_loop() + watchdog_handle = loop_for_this_test.call_later(TEST_MAX_DURATION, lambda: loop_for_this_test.stop()) + test_problem_description = f"Test did not complete within {TEST_MAX_DURATION} seconds." + + def run_test() -> None: + nonlocal test_was_successful, test_problem_description, test_inner_error + try: + with requests.Session() as session: + server_root_uri = f"http://{options['address']}:{options['port']}" + + # First subtest: Grab the document root index.html file: + server_base_uri = f"{server_root_uri}{base_path}" + LOGGER.info("Attempting to fetch HTML from %s", server_base_uri) + res = session.get(server_base_uri) + res.raise_for_status() + assert "text/html; charset=utf-8" == res.headers['content-type'] + assert expected_text in res.text + + # Second subtest: Does the dev server serve something for the livereload JS? + js_uri = f"{server_root_uri}/livereload.js?snipver=1" + LOGGER.info("Attempting to fetch JS from %s", js_uri) + res_js = session.get(js_uri) + res_js.raise_for_status() + content_type_js = res_js.headers['content-type'] + assert "javascript" in content_type_js + + test_was_successful = True + test_problem_description = "No problem. All is well." + except BaseException as be: + LOGGER.error("Could not receive HTTP as expected.", exc_info=True) + test_inner_error = be + test_was_successful = False + test_problem_description = "(see exception)" + finally: + if test_was_successful: + LOGGER.info("Test completed successfully.") + else: + LOGGER.error("Test failed: %s", test_problem_description) + loop_for_this_test.call_soon_threadsafe(lambda: watchdog_handle.cancel()) + + # We give the outer grab_loop_and_run_test a chance to complete + # before burning the bridge: + loop_for_this_test.call_soon_threadsafe(lambda: loop_for_this_test.call_later(0.05, lambda: loop_for_this_test.stop())) + + await loop_for_this_test.run_in_executor(None, run_test) + + # We defeat the nikola site building functionality, so this does not actually get called. + # But the code setting up site building wants a command list: + command_auto.nikola_cmd = ["echo"] + + # Defeat the site building functionality, and instead insert the test: + command_auto.run_initial_rebuild = grab_loop_and_run_test + + try: + # Start the development server + # which under the hood runs our test when trying to build the site: + command_auto.execute(options=options) + + # Verify the test succeeded: + if test_inner_error is not None: + raise test_inner_error + assert test_was_successful, test_problem_description + finally: + # Nikola is written with the assumption that it can + # create the event loop at will without ever cleaning it up. + # As this tests runs several times in succession, + # that assumption becomes a problem. + LOGGER.info("Cleaning up loop.") + # Loop cleanup: + assert loop_for_this_test is not None + assert not loop_for_this_test.is_running() + loop_for_this_test.close() + asyncio.set_event_loop(None) + # We would like to leave it at that, + # but doing so causes the next test to fail. + # + # We did not find asyncio - API to reset the loop + # to "back to square one, as if just freshly started". + # + # The following code does not feel right, it's a kludge, + # but it apparently works for now: + if sys.platform == 'win32': + # For this case, the auto module has special code + # (at module load time! 😟) which we reluctantly reproduce here: + asyncio.set_event_loop(asyncio.ProactorEventLoop()) + else: + asyncio.set_event_loop(asyncio.new_event_loop()) + + +@pytest.fixture(scope="module", + params=["https://example.org", + "https://example.org:1234/blog", + "https://example.org:3456/blog/", + "http://example.org/deep/down/a/rabbit/hole" + ]) +def site_and_base_path(request) -> Tuple[MyFakeSite, str]: + """Return a fake site and the base_path (root) the dev server should be serving.""" + assert OUTPUT_FOLDER.is_dir(), \ + f"Could not find dev server test fixture {OUTPUT_FOLDER.as_posix()}" + + config = { + "post_pages": [], + "FILES_FOLDERS": [], + "GALLERY_FOLDERS": [], + "LISTINGS_FOLDERS": [], + "IMAGE_FOLDERS": [], + "SITE_URL": request.param, + "OUTPUT_FOLDER": OUTPUT_FOLDER.as_posix(), + } + return (MyFakeSite(config), auto.base_path_from_siteuri(request.param)) + + +@pytest.fixture(scope="module") +def expected_text(): + """Read the index.html file from the fixture folder and return most of it. + + For life reload, the server will fiddle with HTML , + so this only returns everything after the opening tag. + """ + with open(OUTPUT_FOLDER / "index.html", encoding="utf-8") as html_file: + all_html = html_file.read() + return all_html[all_html.find(""):] + + +@pytest.mark.parametrize(("uri", "expected_basepath"), [ + ("http://localhost", ""), + ("http://local.host", ""), + ("http://localhost/", ""), + ("http://local.host/", ""), + ("http://localhost:123/", ""), + ("http://local.host:456/", ""), + ("https://localhost", ""), + ("https://local.host", ""), + ("https://localhost/", ""), + ("https://local.host/", ""), + ("https://localhost:123/", ""), + ("https://local.host:456/", ""), + ("http://example.org/blog", "/blog"), + ("https://lorem.ipsum/dolet/", "/dolet"), + ("http://example.org:124/blog", "/blog"), + ("http://example.org:124/Deep/Rab_bit/hol.e/", "/Deep/Rab_bit/hol.e"), + # Would anybody in a sane mind actually do this? + ("http://example.org:124/blog?lorem=ipsum&dol=et", "/blog"), +]) +def test_basepath(uri: str, expected_basepath: Optional[str]) -> None: + assert expected_basepath == auto.base_path_from_siteuri(uri) diff --git a/tests/integration/test_relative_links.py b/tests/integration/test_relative_links.py index 3b158cf..ea15455 100644 --- a/tests/integration/test_relative_links.py +++ b/tests/integration/test_relative_links.py @@ -3,7 +3,7 @@ import io import os -import lxml +import lxml.html import pytest from nikola import __main__ @@ -27,7 +27,7 @@ def test_relative_links(build, output_dir): assert not any( url.startswith("..") - for _, _, url, _ in lxml.html.iterlinks(data) + for _, _, url, _ in lxml.html.fromstring(data).iterlinks() if url.endswith("css") ) diff --git a/tests/integration/test_relative_links_with_pages_in_root.py b/tests/integration/test_relative_links_with_pages_in_root.py index 16f9d6f..b072682 100644 --- a/tests/integration/test_relative_links_with_pages_in_root.py +++ b/tests/integration/test_relative_links_with_pages_in_root.py @@ -3,7 +3,7 @@ import io import os -import lxml +import lxml.html import pytest from nikola import __main__ @@ -26,7 +26,7 @@ def test_relative_links(build, output_dir): assert not any( url.startswith("..") - for _, _, url, _ in lxml.html.iterlinks(data) + for _, _, url, _ in lxml.html.fromstring(data).iterlinks() if url.endswith("css") ) diff --git a/tests/test_compile_markdown.py b/tests/test_compile_markdown.py index a902347..eb28e3e 100644 --- a/tests/test_compile_markdown.py +++ b/tests/test_compile_markdown.py @@ -1,4 +1,5 @@ import io +import sys from os import path import pytest @@ -8,13 +9,17 @@ from nikola.plugins.compile.markdown import CompileMarkdown from .helper import FakeSite +# The tag should not have a closing tag, but it wasn't included in xml.etree.ElementTree.HTML_EMPTY before Python 3.11 +SOURCE_CLOSE_TAG = '' if sys.version_info < (3, 11) else '' + + @pytest.mark.parametrize( "input_str, expected_output", [ pytest.param("", "", id="empty"), pytest.param( "[podcast]https://archive.org/download/Rebeldes_Stereotipos/rs20120609_1.mp3[/podcast]", - '

', + '

', id="mdx podcast", ), pytest.param( diff --git a/tests/test_locale.py b/tests/test_locale.py index eaeb8e1..ab2ddee 100644 --- a/tests/test_locale.py +++ b/tests/test_locale.py @@ -13,7 +13,7 @@ from nikola.utils import ( TESLA_BIRTHDAY = datetime.date(1856, 7, 10) TESLA_BIRTHDAY_DT = datetime.datetime(1856, 7, 10, 12, 34, 56) -DT_EN_US = "July 10, 1856 at 12:34:56 PM UTC" +DT_EN_US = "July 10, 1856, 12:34:56\u202fPM UTC" DT_PL = "10 lipca 1856 12:34:56 UTC" @@ -87,20 +87,20 @@ def test_format_date_timezone(base_config): 2006, 7, 10, 12, 34, 56, tzinfo=dateutil.tz.gettz("America/New_York") ) formatted_date = LocaleBorg().formatted_date("long", tesla_150_birthday_dtz) - assert formatted_date == "July 10, 2006 at 12:34:56 PM -0400" + assert formatted_date == "July 10, 2006, 12:34:56\u202fPM -0400" nodst = datetime.datetime( 2006, 1, 10, 12, 34, 56, tzinfo=dateutil.tz.gettz("America/New_York") ) formatted_date = LocaleBorg().formatted_date("long", nodst) - assert formatted_date == "January 10, 2006 at 12:34:56 PM -0500" + assert formatted_date == "January 10, 2006, 12:34:56\u202fPM -0500" @pytest.mark.parametrize( "english_variant, expected_date", [ pytest.param("en_US", DT_EN_US, id="US"), - pytest.param("en_GB", "10 July 1856 at 12:34:56 UTC", id="GB"), + pytest.param("en_GB", "10 July 1856, 12:34:56 UTC", id="GB"), ], ) def test_format_date_locale_variants(english_variant, expected_date): diff --git a/tests/test_plugin_manager.py b/tests/test_plugin_manager.py new file mode 100644 index 0000000..8deb7a6 --- /dev/null +++ b/tests/test_plugin_manager.py @@ -0,0 +1,142 @@ +# -*- coding: utf-8 -*- + +# Copyright © 2012-2024 Chris Warrick and others. + +# Permission is hereby granted, free of charge, to any +# person obtaining a copy of this software and associated +# documentation files (the "Software"), to deal in the +# Software without restriction, including without limitation +# the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the +# Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice +# shall be included in all copies or substantial portions of +# the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY +# KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE +# WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +# PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS +# OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR +# OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +# OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +import nikola.plugin_manager + +from .helper import FakeSite +from nikola.plugin_manager import PluginManager +from pathlib import Path + + +def test_locate_plugins_finds_core_plugins(): + """Ensure that locate_plugins can find some core plugins.""" + places = [Path(nikola.plugin_manager.__file__).parent / "plugins"] + plugin_manager = PluginManager(places) + candidates = plugin_manager.locate_plugins() + plugin_names = [p.name for p in candidates] + assert plugin_manager.candidates == candidates + + assert "emoji" in plugin_names + assert "copy_assets" in plugin_names + assert "scan_posts" in plugin_names + + template_plugins = [p for p in candidates if p.category == "TemplateSystem"] + template_plugins.sort(key=lambda p: p.name) + assert len(template_plugins) == 2 + assert template_plugins[0].name == "jinja" + assert template_plugins[1].name == "mako" + + +def test_locate_plugins_finds_core_and_custom_plugins(): + """Ensure that locate_plugins can find some custom plugins.""" + places = [ + Path(nikola.plugin_manager.__file__).parent / "plugins", + Path(__file__).parent / "data" / "plugin_manager", + ] + plugin_manager = PluginManager(places) + candidates = plugin_manager.locate_plugins() + plugin_names = [p.name for p in candidates] + assert plugin_manager.candidates == candidates + + assert "emoji" in plugin_names + assert "copy_assets" in plugin_names + assert "scan_posts" in plugin_names + + assert "first" in plugin_names + assert "2nd" in plugin_names + + first_plugin = next(p for p in candidates if p.name == "first") + second_plugin = next(p for p in candidates if p.name == "2nd") + + assert first_plugin.category == "Command" + assert first_plugin.compiler == "foo" + assert first_plugin.source_dir == places[1] + + assert second_plugin.category == "ConfigPlugin" + assert second_plugin.compiler is None + assert second_plugin.source_dir == places[1] / "second" + + +def test_load_plugins(): + """Ensure that locate_plugins can load some core and custom plugins.""" + places = [ + Path(nikola.plugin_manager.__file__).parent / "plugins", + Path(__file__).parent / "data" / "plugin_manager", + ] + plugin_manager = PluginManager(places) + candidates = plugin_manager.locate_plugins() + plugins_to_load = [p for p in candidates if p.name in {"first", "2nd", "emoji"}] + + plugin_manager.load_plugins(plugins_to_load) + + assert len(plugin_manager.plugins) == 3 + assert plugin_manager._plugins_by_category["ShortcodePlugin"][0].name == "emoji" + assert plugin_manager._plugins_by_category["Command"][0].name == "first" + assert plugin_manager._plugins_by_category["ConfigPlugin"][0].name == "2nd" + + site = FakeSite() + for plugin in plugin_manager.plugins: + plugin.plugin_object.set_site(site) + + assert "emoji" in site.shortcode_registry + assert plugin_manager.get_plugin_by_name("first", "Command").plugin_object.one_site_set + assert plugin_manager.get_plugin_by_name("2nd").plugin_object.two_site_set + assert plugin_manager.get_plugin_by_name("2nd", "Command") is None + + +def test_load_plugins_twice(): + """Ensure that extra plugins can be added.""" + places = [ + Path(nikola.plugin_manager.__file__).parent / "plugins", + Path(__file__).parent / "data" / "plugin_manager", + ] + plugin_manager = PluginManager(places) + candidates = plugin_manager.locate_plugins() + plugins_to_load_first = [p for p in candidates if p.name in {"first", "emoji"}] + plugins_to_load_second = [p for p in candidates if p.name in {"2nd"}] + + plugin_manager.load_plugins(plugins_to_load_first) + assert len(plugin_manager.plugins) == 2 + plugin_manager.load_plugins(plugins_to_load_second) + assert len(plugin_manager.plugins) == 3 + + +def test_load_plugins_skip_mismatching_category(caplog): + """If a plugin specifies a different category than it actually implements, refuse to load it.""" + places = [ + Path(__file__).parent / "data" / "plugin_manager", + ] + plugin_manager = PluginManager(places) + candidates = plugin_manager.locate_plugins() + plugins_to_load = [p for p in candidates if p.name in {"broken"}] + plugin_to_load = plugins_to_load[0] + assert len(plugins_to_load) == 1 + + plugin_manager.load_plugins(plugins_to_load) + + py_file = plugin_to_load.source_dir / "broken.py" + assert f"{plugin_to_load.plugin_id} ({py_file}) has category '{plugin_to_load.category}' in the .plugin file, but the implementation class does not inherit from this category - plugin will not be loaded" in caplog.text + assert len(plugin_manager.plugins) == 0 diff --git a/tests/test_template_shortcodes.py b/tests/test_template_shortcodes.py index c6c948d..148f7e6 100644 --- a/tests/test_template_shortcodes.py +++ b/tests/test_template_shortcodes.py @@ -67,7 +67,7 @@ class ShortcodeFakeSite(Nikola): def _get_template_system(self): if self._template_system is None: # Load template plugin - self._template_system = self.plugin_manager.getPluginByName( + self._template_system = self.plugin_manager.get_plugin_by_name( "jinja", "TemplateSystem" ).plugin_object self._template_system.set_directories(".", "cache") diff --git a/tests/test_test_helper.py b/tests/test_test_helper.py new file mode 100644 index 0000000..e836ad1 --- /dev/null +++ b/tests/test_test_helper.py @@ -0,0 +1,22 @@ +import os + +from .helper import cd + + +class SomeTestError(Exception): + """An arbitrary error to be thrown by the test.""" + pass + + +def test_test_helper(): + """Check that the cd test helper duly resets the directory even in spite of an error.""" + old_dir = os.getcwd() + exception_seen = False + try: + with cd(".."): + raise SomeTestError("Just raising an exception, as failing tests sometimes do.") + except SomeTestError: + now_dir = os.getcwd() + assert old_dir == now_dir + exception_seen = True + assert exception_seen -- cgit v1.2.3