aboutsummaryrefslogtreecommitdiffstats
path: root/tests
diff options
context:
space:
mode:
authorLibravatarUnit 193 <unit193@unit193.net>2024-04-23 00:37:58 -0400
committerLibravatarUnit 193 <unit193@unit193.net>2024-04-23 00:37:58 -0400
commit9b0e86a8e74768c4fe848fb5ce8d754292db4e3e (patch)
treecfd424be8ecb68357e6e572033f08bc534bf724f /tests
parent393aa58f2c5afd51f92fd9bd4b6dfd0dc90cea41 (diff)
New upstream version 8.3.0.upstream/8.3.0upstream
Diffstat (limited to 'tests')
-rw-r--r--tests/data/dev_server_sample_output_folder/index.html9
-rw-r--r--tests/data/plugin_manager/broken.plugin12
-rw-r--r--tests/data/plugin_manager/broken.py42
-rw-r--r--tests/data/plugin_manager/first.plugin13
-rw-r--r--tests/data/plugin_manager/one.py47
-rw-r--r--tests/data/plugin_manager/second/second.plugin13
-rw-r--r--tests/data/plugin_manager/second/two/__init__.py41
-rw-r--r--tests/helper.py56
-rw-r--r--tests/integration/test_demo_build.py136
-rw-r--r--tests/integration/test_dev_server.py223
-rw-r--r--tests/integration/test_relative_links.py4
-rw-r--r--tests/integration/test_relative_links_with_pages_in_root.py4
-rw-r--r--tests/test_compile_markdown.py7
-rw-r--r--tests/test_locale.py8
-rw-r--r--tests/test_plugin_manager.py142
-rw-r--r--tests/test_template_shortcodes.py2
-rw-r--r--tests/test_test_helper.py22
17 files changed, 729 insertions, 52 deletions
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 @@
+<html>
+ <head>
+ <title>Dev server test fixture</title>
+ </head>
+ <body>
+ <h1>This is a dummy file for the dev server to serve</h1>
+ <p>...during the tests ✔.</p>
+ </body>
+</html>
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 <head>,
+ so this only returns everything after the opening <body> tag.
+ """
+ with open(OUTPUT_FOLDER / "index.html", encoding="utf-8") as html_file:
+ all_html = html_file.read()
+ return all_html[all_html.find("<body>"):]
+
+
+@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 <source> 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 = '</source>' 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]",
- '<p><audio controls=""><source src="https://archive.org/download/Rebeldes_Stereotipos/rs20120609_1.mp3" type="audio/mpeg"></source></audio></p>',
+ '<p><audio controls=""><source src="https://archive.org/download/Rebeldes_Stereotipos/rs20120609_1.mp3" type="audio/mpeg">' + SOURCE_CLOSE_TAG + '</audio></p>',
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 <class 'tests.data.plugin_manager.broken.BrokenPlugin'> 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