mirror of
https://github.com/kennethreitz/responder.git
synced 2026-06-05 06:46:14 +00:00
c1d789f279
## About That's just maintenance. In this case, streamline the CI configuration for improved ergonomics and future proofing, see GH-605. ## Details - `activate-environment: true` of newer `astral-sh/setup-uv@v7` is the killer feature here, which wasn't available before. - Configuring `cache-dependency-glob: pyproject.toml` isn't needed, because the recipe already employs an excellent default configuration that includes this and other project metadata files. - The adjustments in `test_cli` were needed because of a different PyPy version installed by `setup-uv`. This will yield a report to Astral maintainers, unrelated to this patch.
241 lines
7.7 KiB
Python
241 lines
7.7 KiB
Python
"""
|
|
Test module for Responder CLI functionality.
|
|
|
|
This module tests the following CLI commands:
|
|
- responder --version: Version display
|
|
- responder build: Build command execution
|
|
- responder run: Server execution
|
|
|
|
Requirements:
|
|
- The `docopt-ng` package must be installed
|
|
- Example application must be present at `examples/helloworld.py`
|
|
- This file should implement a basic HTTP server with a "/hello" endpoint
|
|
that returns "hello, world!" as response
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import time
|
|
import typing as t
|
|
from pathlib import Path
|
|
from urllib.request import urlopen
|
|
|
|
import pytest
|
|
from _pytest.capture import CaptureFixture
|
|
|
|
from responder.__version__ import __version__
|
|
from responder.util.cmd import ResponderProgram, ResponderServer
|
|
from tests.util import random_port, wait_server_tcp
|
|
|
|
# Skip test if optional CLI dependency is not installed.
|
|
pytest.importorskip("docopt", reason="docopt-ng package not installed")
|
|
|
|
|
|
# Pseudo-wait for server idleness
|
|
SERVER_IDLE_WAIT = float(os.getenv("RESPONDER_SERVER_IDLE_WAIT", "0.25"))
|
|
|
|
# Maximum time to wait for server startup or teardown (adjust for slower systems)
|
|
SERVER_TIMEOUT = float(os.getenv("RESPONDER_SERVER_TIMEOUT", "5"))
|
|
|
|
# Maximum time to wait for HTTP requests (adjust for slower networks)
|
|
REQUEST_TIMEOUT = float(os.getenv("RESPONDER_REQUEST_TIMEOUT", "5"))
|
|
|
|
# Endpoint to use for `responder run`.
|
|
HELLO_ENDPOINT = "/hello"
|
|
|
|
|
|
def test_cli_version(capfd):
|
|
"""
|
|
Verify that `responder --version` works as expected.
|
|
"""
|
|
try:
|
|
# Suppress security checks for subprocess calls in tests.
|
|
# S603: subprocess call - safe as we use fixed command
|
|
# S607: start process with partial path - safe as we use installed package
|
|
subprocess.check_call(["responder", "--version"]) # noqa: S603, S607
|
|
except subprocess.CalledProcessError as ex:
|
|
pytest.fail(
|
|
f"responder --version failed with exit code {ex.returncode}. Error: {ex}"
|
|
)
|
|
|
|
stdout = capfd.readouterr().out.strip()
|
|
|
|
# TODO: Accommodate PyPy as installed by `uv`, it emits spurious output on stdout before the version number.
|
|
# AssertionError: assert '(_common_types_metatype, 9088, 128, 128)\n(cython_function_or_method, 157568, 128, 128)\n3.6.0' == '3.6.0'
|
|
lines = [line.strip() for line in stdout.splitlines() if line.strip()]
|
|
assert lines, "Expected version output on stdout"
|
|
assert lines[-1] == __version__
|
|
|
|
|
|
def responder_build(path: Path, capfd: CaptureFixture) -> t.Tuple[str, str]:
|
|
"""
|
|
Execute responder build command and capture its output.
|
|
|
|
Args:
|
|
path: Directory containing package.json
|
|
capfd: Pytest fixture for capturing output
|
|
|
|
Returns:
|
|
tuple: (stdout, stderr) containing the captured output
|
|
"""
|
|
|
|
ResponderProgram.build(path=path)
|
|
output = capfd.readouterr()
|
|
|
|
stdout = output.out.strip()
|
|
stderr = output.err.strip()
|
|
|
|
return stdout, stderr
|
|
|
|
|
|
def test_cli_build_success(capfd, tmp_path):
|
|
"""
|
|
Verify that `responder build` works as expected.
|
|
"""
|
|
|
|
# Temporary surrogate `package.json` file.
|
|
package_json = {"scripts": {"build": "echo Hotzenplotz"}}
|
|
package_json_file = tmp_path / "package.json"
|
|
package_json_file.write_text(json.dumps(package_json))
|
|
|
|
# Invoke `responder build`.
|
|
stdout, stderr = responder_build(tmp_path, capfd)
|
|
assert "Hotzenplotz" in stdout
|
|
|
|
|
|
def test_cli_build_missing_package_json(capfd, tmp_path):
|
|
"""
|
|
Verify `responder build`, while `package.json` file is missing.
|
|
"""
|
|
|
|
# Invoke `responder build`.
|
|
stdout, stderr = responder_build(tmp_path, capfd)
|
|
assert "Invalid target directory or missing package.json" in stderr
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"invalid_content,npm_error,expected_error",
|
|
[
|
|
(
|
|
"foobar",
|
|
"code EJSONPARSE",
|
|
["is not valid JSON", "Failed to parse JSON data", "EJSONPARSE"],
|
|
),
|
|
("{", "code EJSONPARSE", ["Unexpected end of JSON", "EJSONPARSE"]),
|
|
('{"scripts": }', "code EJSONPARSE", ["Unexpected token", "EJSONPARSE"]),
|
|
(
|
|
'{"scripts": null}',
|
|
"error",
|
|
[
|
|
"Cannot convert undefined or null",
|
|
"scripts.build",
|
|
"Missing script",
|
|
"null",
|
|
],
|
|
),
|
|
(
|
|
'{"scripts": {"build": null}}',
|
|
"Missing script",
|
|
['"build"', "missing script", "build"],
|
|
),
|
|
(
|
|
'{"scripts": {"build": 123}}',
|
|
"Missing script",
|
|
['"build"', "missing script", "build"],
|
|
),
|
|
],
|
|
ids=[
|
|
"invalid_json_content",
|
|
"incomplete_json",
|
|
"syntax_error",
|
|
"null_scripts",
|
|
"missing_script_null",
|
|
"missing_script_number",
|
|
],
|
|
)
|
|
def test_cli_build_invalid_package_json(
|
|
capfd, tmp_path, invalid_content, npm_error, expected_error
|
|
):
|
|
"""
|
|
Verify `responder build` using an invalid `package.json` file.
|
|
"""
|
|
|
|
# Temporary surrogate `package.json` file.
|
|
package_json_file = tmp_path / "package.json"
|
|
package_json_file.write_text(invalid_content)
|
|
|
|
# Invoke `responder build`.
|
|
stdout, stderr = responder_build(tmp_path, capfd)
|
|
assert npm_error.lower() in stderr.lower()
|
|
if isinstance(expected_error, str):
|
|
expected_error = [expected_error]
|
|
assert any(item.lower() in stderr.lower() for item in expected_error)
|
|
|
|
|
|
sfa_services_valid = [
|
|
str(Path("examples") / "helloworld.py"),
|
|
"https://github.com/kennethreitz/responder/raw/refs/heads/main/examples/helloworld.py",
|
|
]
|
|
|
|
|
|
# The test is marked as flaky due to potential race conditions in server startup
|
|
# and port availability. Known error codes by platform:
|
|
# - macOS: [Errno 61] Connection refused (Failed to establish a new connection)
|
|
# - Linux: [Errno 111] Connection refused (Failed to establish a new connection)
|
|
# - Windows: [WinError 10061] No connection could be made because target machine
|
|
# actively refused it
|
|
@pytest.mark.flaky(reruns=3, reruns_delay=2, only_rerun=["TimeoutError"])
|
|
@pytest.mark.parametrize("target", sfa_services_valid, ids=sfa_services_valid)
|
|
def test_cli_run(capfd, target):
|
|
"""
|
|
Verify that `responder run` works as expected.
|
|
"""
|
|
|
|
# Start a Responder service instance in the background, using its CLI.
|
|
# Make it terminate itself after serving one HTTP request.
|
|
server = ResponderServer(target=str(target), port=random_port(), limit_max_requests=1)
|
|
try:
|
|
# Start server and wait until it responds on TCP.
|
|
server.start()
|
|
wait_server_tcp(server.port)
|
|
|
|
# Submit a single probing HTTP request that also will terminate the server.
|
|
with urlopen( # noqa: S310
|
|
f"http://127.0.0.1:{server.port}{HELLO_ENDPOINT}",
|
|
timeout=REQUEST_TIMEOUT,
|
|
) as response:
|
|
assert "hello, world!" == response.read().decode()
|
|
finally:
|
|
server.join(timeout=SERVER_TIMEOUT)
|
|
|
|
# Capture process output.
|
|
time.sleep(SERVER_IDLE_WAIT)
|
|
output = capfd.readouterr()
|
|
|
|
stdout = output.out.strip()
|
|
assert f'"GET {HELLO_ENDPOINT} HTTP/1.1" 200 OK' in stdout
|
|
|
|
stderr = output.err.strip()
|
|
|
|
# Define expected lifecycle messages in order.
|
|
lifecycle_messages = [
|
|
# Startup phase
|
|
"Started server process",
|
|
"Waiting for application startup",
|
|
"Application startup complete",
|
|
"Uvicorn running",
|
|
# Shutdown phase
|
|
"Shutting down",
|
|
"Waiting for application shutdown",
|
|
"Application shutdown complete",
|
|
"Finished server process",
|
|
]
|
|
|
|
# Verify messages appear in expected order.
|
|
last_pos = -1
|
|
for msg in lifecycle_messages:
|
|
pos = stderr.find(msg)
|
|
assert pos > last_pos, f"Expected '{msg}' to appear after previous message"
|
|
last_pos = pos
|