summaryrefslogtreecommitdiffstats
path: root/tests/integration/test_dev_server.py
diff options
context:
space:
mode:
Diffstat (limited to 'tests/integration/test_dev_server.py')
-rw-r--r--tests/integration/test_dev_server.py223
1 files changed, 223 insertions, 0 deletions
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)