Skip to content

Commit

Permalink
Introduce a test for the development server. Fix #3718.
Browse files Browse the repository at this point in the history
  • Loading branch information
aknrdureegaesr committed Jan 1, 2024
1 parent 8689cf3 commit 66d8a5a
Show file tree
Hide file tree
Showing 2 changed files with 165 additions and 0 deletions.
9 changes: 9 additions & 0 deletions tests/data/dev_server_sample_output_folder/index.html
Original file line number Diff line number Diff line change
@@ -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>
156 changes: 156 additions & 0 deletions tests/integration/test_dev_server.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import asyncio
import nikola.plugins.command.auto as auto
from nikola.utils import get_logger
import pytest
import pathlib
import socket
from typing import Optional
import urllib.request

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.
SERVER_STARTUP_TIME = 0.1 # Max time, in seconds, we expect the server to need to come up.

# Folder that has the fixture file we expect the server to serve:
OUTPUT_FOLDER = pathlib.Path.cwd() / "tests" / "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()


def test_serves_root_dir(site, expected_text) -> None:
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

async def grab_loop_and_run_test() -> None:
loop = asyncio.get_running_loop()
watchdog_handle = loop.call_later(TEST_MAX_DURATION, lambda: loop.stop())
nonlocal test_problem_description
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:
server_base_uri = f"http://{options['address']}:{options['port']}/"

# First subtest: Does the dev server serve the fixture HTML file as expected?
with urllib.request.urlopen(server_base_uri) as res:
assert res.status == 200, f"dev server access failed: {res.status} {res.reason}"
content_type = res.getheader("Content-Type")
assert "text/html; charset=utf-8" == content_type
html = str(res.read(), encoding="utf-8")
assert expected_text in html

# Second subtest: Does the dev server serve something for the livereload JS?
with urllib.request.urlopen(f"{server_base_uri}livereload.js?snipver=1") as res_js:
assert res_js.status == 200, \
f"dev server access to livereload failed: {res_js.status} {res_js.reason}"
content_type_js = res_js.getheader("Content-Type")
assert "text/javascript" in content_type_js or \
"application/javascript" in content_type_js # Seen under Python 3.9, deprecated.
res_js.read()

test_was_successful = True
test_problem_description = "No problem. All is well."
except AssertionError as ae:
test_inner_error = ae
finally:
LOGGER.info("Test completed, %s: %s",
"successfully" if test_was_successful else "failing",
test_problem_description)
loop.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.call_soon_threadsafe(lambda: loop.call_later(0.05, lambda: loop.stop()))
await loop.run_in_executor(None, run_test)

# We defeat the nikola site building functionality, so this does not actually get called.
# But the setup code sets this up and needs a command list for that.
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

# 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


class MyFakeSite(FakeSite):
def __init__(self, config, configuration_filename):
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


@pytest.fixture(scope="module")
def site() -> MyFakeSite:
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": [],
"OUTPUT_FOLDER": OUTPUT_FOLDER.as_posix(),
}
return MyFakeSite(config, "conf.py")


@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") as html_file:
all_html = html_file.read()
return all_html[all_html.find("<body>"):]

0 comments on commit 66d8a5a

Please sign in to comment.