mirror of
https://github.com/zensical/zensical.git
synced 2026-10-23 04:41:01 +00:00
152 lines
5.6 KiB
Python
Vendored
152 lines
5.6 KiB
Python
Vendored
# Copyright (c) 2025-2026 Zensical and contributors
|
|
|
|
# SPDX-License-Identifier: MIT
|
|
# All contributions are certified under the DCO
|
|
|
|
# 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 NON-INFRINGEMENT. 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.
|
|
|
|
"""Test the URLs advertised by the preview server."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
import socket
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
from typing import TYPE_CHECKING
|
|
from urllib.parse import urlsplit
|
|
from urllib.request import ProxyHandler, build_opener
|
|
|
|
import pytest
|
|
|
|
if TYPE_CHECKING:
|
|
from pathlib import Path
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"dev_addr",
|
|
["127.0.0.1:0", "127.0.0.1:00", "[::1]:0", "localhost:0"],
|
|
)
|
|
@pytest.mark.parametrize("from_config", [False, True])
|
|
@pytest.mark.parametrize("open_browser", [False, True])
|
|
def test_serve_advertises_reachable_ephemeral_ports(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
dev_addr: str,
|
|
from_config: bool,
|
|
open_browser: bool,
|
|
) -> None:
|
|
"""Connect to every URL advertised for an ephemeral port."""
|
|
host = dev_addr.rsplit(":", 1)[0].strip("[]")
|
|
resolved = socket.getaddrinfo(host, 0, type=socket.SOCK_STREAM)
|
|
expected_hosts = {addr[0] for _, _, _, _, addr in resolved}
|
|
|
|
# The IPv6 cases are skipped if the loopback interface is unavailable.
|
|
if "::1" in expected_hosts:
|
|
try:
|
|
with socket.socket(socket.AF_INET6, socket.SOCK_STREAM) as probe:
|
|
probe.bind(("::1", 0))
|
|
except OSError:
|
|
pytest.skip("IPv6 loopback is unavailable")
|
|
|
|
# The address is supplied through either configuration or the CLI.
|
|
docs = tmp_path / "docs"
|
|
docs.mkdir()
|
|
(docs / "index.md").write_text("# Ephemeral port test\n", encoding="utf-8")
|
|
config = tmp_path / "zensical.toml"
|
|
configured_addr = dev_addr if from_config else "127.0.0.1:8000"
|
|
config.write_text(
|
|
f"""[project]
|
|
site_name = "Test"
|
|
dev_addr = "{configured_addr}"
|
|
""",
|
|
encoding="utf-8",
|
|
)
|
|
command = [
|
|
sys.executable,
|
|
"-m",
|
|
"zensical",
|
|
"serve",
|
|
"--config-file",
|
|
str(config),
|
|
]
|
|
if not from_config:
|
|
command.extend(["--dev-addr", dev_addr])
|
|
|
|
# On Linux, browser launches can be captured through the BROWSER variable.
|
|
opened_url = tmp_path / "opened-url"
|
|
if open_browser:
|
|
if sys.platform != "linux":
|
|
pytest.skip("Browser capture requires Linux")
|
|
browser = tmp_path / "browser"
|
|
browser.write_text(
|
|
r"""#!/bin/sh
|
|
printf '%s\n' "$1" > "$ZENSICAL_TEST_OPENED_URL"
|
|
""",
|
|
encoding="utf-8",
|
|
)
|
|
browser.chmod(0o755)
|
|
monkeypatch.setenv("BROWSER", str(browser))
|
|
monkeypatch.setenv("ZENSICAL_TEST_OPENED_URL", str(opened_url))
|
|
command.append("--open")
|
|
|
|
# Output is captured until the initial page has been written.
|
|
log_path = tmp_path / "serve.log"
|
|
with log_path.open("w", encoding="utf-8") as log:
|
|
process = subprocess.Popen( # noqa: S603
|
|
command, cwd=tmp_path, stdout=log, stderr=subprocess.STDOUT
|
|
)
|
|
try:
|
|
deadline = time.monotonic() + 30
|
|
while time.monotonic() < deadline:
|
|
output = log_path.read_text(encoding="utf-8")
|
|
page_ready = (tmp_path / "site" / "index.html").is_file()
|
|
browser_ready = not open_browser or opened_url.is_file()
|
|
if page_ready and browser_ready:
|
|
break
|
|
assert process.poll() is None, output
|
|
time.sleep(0.02)
|
|
else:
|
|
pytest.fail(f"Server did not finish building:\n{output}")
|
|
|
|
# Every resolved address must have a concrete, reachable URL.
|
|
urls = re.findall(
|
|
r"^Serving .+ on (http://\S+)$", output, re.MULTILINE
|
|
)
|
|
assert len(urls) == len(expected_hosts), output
|
|
assert {urlsplit(url).hostname for url in urls} == expected_hosts
|
|
if open_browser:
|
|
# A trailing slash may be added by the browser launcher.
|
|
browser_url = opened_url.read_text(encoding="utf-8").strip()
|
|
assert browser_url.rstrip("/") == urls[0]
|
|
opener = build_opener(ProxyHandler({}))
|
|
for url in urls:
|
|
assert urlsplit(url).port != 0
|
|
with opener.open(url, timeout=5) as response:
|
|
assert response.status == 200
|
|
assert b"Ephemeral port test" in response.read()
|
|
finally:
|
|
process.terminate()
|
|
try:
|
|
process.wait(timeout=5)
|
|
except subprocess.TimeoutExpired:
|
|
process.kill()
|
|
process.wait(timeout=5)
|