Skip to content

Commit d32059f

Browse files
committed
test: use pytest-xprocess to manage test servers
Remove our bespoke test server management, use pytest-xprocess instead. Ensures that pytest doesn't hang if a test fails, and that the tests run correctly in VS Code (maybe also on Windows, still to be determined). Also fixes a couple of issues identified by pylint.
1 parent 56e672e commit d32059f

File tree

14 files changed

+359
-286
lines changed

14 files changed

+359
-286
lines changed

_appmap/metadata.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
"""Shared metadata gathering"""
22

33
import platform
4-
import re
54
from functools import lru_cache
65

76
from . import utils

_appmap/test/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import pytest
2+
3+
# Make sure assertions in web_framework get rewritten (e.g. to show
4+
# diffs in generated appmaps)
5+
pytest.register_assert_rewrite("_appmap.test.web_framework")

_appmap/test/bin/server_runner

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
#!/usr/bin/env bash
2+
3+
set -x
4+
5+
cd "$1"; shift
6+
7+
set -a
8+
PYTHONUNBUFFERED=1
9+
APPMAP_OUTPUT_DIR=/tmp
10+
PYTHONPATH=./init
11+
12+
exec $@

_appmap/test/conftest.py

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,21 @@
11
import importlib
2+
import os
3+
import socket
24
import sys
35
from distutils.dir_util import copy_tree
4-
from functools import partialmethod
6+
from functools import partial, partialmethod
7+
from pathlib import Path
8+
from typing import Any
59

610
import pytest
711
import yaml
12+
from attr import dataclass
13+
from xprocess import ProcessStarter
814

915
import _appmap
1016
import appmap
17+
from _appmap.env import Env
18+
from _appmap.test.web_framework import TEST_HOST, TEST_PORT
1119
from appmap import generation
1220

1321
from .. import utils
@@ -128,3 +136,76 @@ def _generate(check_fn, method_name):
128136
return generation.dump(rec)
129137

130138
return _generate
139+
140+
141+
@dataclass
142+
class ServerInfo:
143+
name: str = ""
144+
debug: bool = False
145+
host: str = ""
146+
port: int = 0
147+
cmd: str = ""
148+
pattern: str = ""
149+
env: dict = {}
150+
factory: Any = None
151+
152+
153+
class _ServerStarter(ProcessStarter):
154+
@property
155+
def args(self):
156+
return self._args
157+
158+
@property
159+
def pattern(self):
160+
return self._pattern
161+
162+
def startup_check(self):
163+
try:
164+
s = socket.socket()
165+
s.connect((self._host, self._port))
166+
return True
167+
except ConnectionRefusedError:
168+
pass
169+
return False
170+
171+
def __init__(self, info: ServerInfo, controldir, xprocess):
172+
super().__init__(controldir, xprocess)
173+
self._host = info.host
174+
self._port = info.port
175+
# Can't set popen_kwargs["cwd"] on a ProcessStarter until
176+
# https://github.com/pytest-dev/pytest-xprocess/issues/89 is fixed.
177+
#
178+
# In the meantime, pass the desired directory to server_runner, which
179+
# will handle changing the working directory.
180+
self._args = [
181+
(Path(__file__).parent / "bin" / "server_runner").as_posix(),
182+
(Path(__file__).parent / "data" / info.name).as_posix(),
183+
f"{Path(sys.executable).as_posix()} {info.cmd}",
184+
]
185+
self._pattern = info.pattern
186+
self.env = {**info.env}
187+
self.terminate_on_interrupt = True
188+
189+
190+
def server_starter(info, name, cmd, pattern, env=None):
191+
def _starter(controldir, xprocess):
192+
info.name = name
193+
info.cmd = cmd
194+
if env is not None:
195+
info.env = {**env, **info.env}
196+
info.pattern = pattern
197+
return _ServerStarter(info, controldir, xprocess)
198+
199+
return _starter
200+
201+
202+
@pytest.fixture(name="server_base")
203+
def server_base_fixture(request):
204+
marker = request.node.get_closest_marker("server")
205+
debug = marker.kwargs.get("debug", False)
206+
server_env = os.environ.copy()
207+
server_env.update(marker.kwargs.get("env", {}))
208+
209+
info = ServerInfo(debug=debug, host=TEST_HOST, port=TEST_PORT, env=server_env)
210+
info.factory = partial(server_starter, info)
211+
return info

_appmap/test/helpers.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,14 @@ class DictIncluding(dict):
1515

1616
def __eq__(self, other):
1717
return other.items() >= self.items()
18+
19+
20+
class HeadersIncluding(dict):
21+
"""Like DictIncluding, but key comparison is case-insensitive."""
22+
23+
def __eq__(self, other):
24+
for k in self.keys():
25+
v = other.get(k, other.get(k.lower(), None))
26+
if v is None:
27+
return False
28+
return True

_appmap/test/test_django.py

Lines changed: 48 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,10 @@
33

44
import json
55
import os
6+
import socket
67
import sys
78
from pathlib import Path
8-
from threading import Thread
9+
from types import SimpleNamespace as NS
910

1011
import django
1112
import django.core.handlers.exception
@@ -15,30 +16,47 @@
1516
import pytest
1617
from django.template.loader import render_to_string
1718
from django.test.client import MULTIPART_CONTENT
19+
from xprocess import ProcessStarter
1820

1921
import appmap
2022
import appmap.django # noqa: F401
2123
from _appmap.metadata import Metadata
2224

2325
from ..test.helpers import DictIncluding
24-
25-
# Make sure assertions in web_framework get rewritten (e.g. to show
26-
# diffs in generated appmaps)
27-
pytest.register_assert_rewrite("_appmap.test.web_framework")
28-
29-
# pylint: disable=unused-import,wrong-import-position
30-
from .web_framework import TestRemoteRecording # pyright:ignore
31-
from .web_framework import TestRequestCapture # pyright: ignore
32-
from .web_framework import _TestRecordRequests, exec_cmd, wait_until_port_is
33-
34-
# pylint: enable=unused-import
26+
from .web_framework import (
27+
_TestFormCapture,
28+
_TestFormData,
29+
_TestRecordRequests,
30+
_TestRemoteRecording,
31+
_TestRequestCapture,
32+
)
3533

3634
sys.path += [str(Path(__file__).parent / "data" / "django")]
3735

3836
# Import app just for the side-effects. It must happen after sys.path has been modified.
3937
import djangoapp # pyright: ignore pylint: disable=import-error, unused-import,wrong-import-order,wrong-import-position
4038

4139

40+
class TestFormCapture(_TestFormCapture):
41+
pass
42+
43+
44+
class TestFormTest(_TestFormData):
45+
pass
46+
47+
48+
class TestRecordRequests(_TestRecordRequests):
49+
pass
50+
51+
52+
class TestRemoteRecording(_TestRemoteRecording):
53+
pass
54+
55+
56+
class TestRequestCapture(_TestRequestCapture):
57+
pass
58+
59+
4260
@pytest.mark.django_db
4361
@pytest.mark.appmap_enabled(appmap_enabled=False)
4462
def test_sql_capture(events):
@@ -200,55 +218,21 @@ def test_disabled(self, pytester, monkeypatch):
200218
assert not (pytester.path / "tmp").exists()
201219

202220

203-
class TestRecordRequestsDjango(_TestRecordRequests):
204-
def server_start_thread(self, debug=True):
205-
# Use appmap from our working copy, not the module installed by virtualenv. Add the init
206-
# directory so the sitecustomize.py file it contains will be loaded on startup. This
207-
# simulates a real installation.
208-
settings = "settings_dev" if debug else "settings"
209-
exec_cmd(
210-
"""
211-
export PYTHONPATH="$PWD"
212-
213-
cd _appmap/test/data/django/
214-
PYTHONPATH="$PYTHONPATH:$PWD/init"
215-
"""
216-
+ f" APPMAP_OUTPUT_DIR=/tmp DJANGO_SETTINGS_MODULE=djangoapp.{settings}"
217-
+ " python manage.py runserver"
218-
+ f" 127.0.0.1:{_TestRecordRequests.server_port}"
219-
)
220-
221-
def server_start(self, debug=True):
222-
def start_with_debug():
223-
self.server_start_thread(debug)
224-
225-
# start as background thread so running the tests can continue
226-
thread = Thread(target=start_with_debug)
227-
thread.start()
228-
wait_until_port_is("127.0.0.1", _TestRecordRequests.server_port, "open")
229-
230-
def server_stop(self):
231-
exec_cmd(
232-
"ps -ef"
233-
+ "| grep -i 'manage.py runserver'"
234-
+ "| grep -v grep"
235-
+ "| awk '{ print $2 }'"
236-
+ "| xargs kill -9"
237-
)
238-
wait_until_port_is("127.0.0.1", _TestRecordRequests.server_port, "closed")
239-
240-
def test_record_request_appmap_enabled_requests_enabled_no_remote(self):
241-
self.server_stop() # ensure it's not running
242-
self.server_start()
243-
self.record_request(False)
244-
self.server_stop()
245-
246-
def test_record_request_appmap_enabled_requests_enabled_and_remote(self):
247-
self.server_stop() # ensure it's not running
248-
self.server_start()
249-
self.record_request(True)
250-
self.server_stop()
251-
252-
# it's not possible to test for
253-
# appmap_not_enabled_requests_enabled_and_remote because when
254-
# APPMAP=false the routes for remote recording are disabled.
221+
@pytest.fixture(name="server")
222+
def django_server(xprocess, server_base):
223+
debug = server_base.debug
224+
host = server_base.host
225+
port = server_base.port
226+
settings = "settings_dev" if debug else "settings"
227+
228+
name = "django"
229+
pattern = f"server at http://{host}:{port}"
230+
cmd = f"manage.py runserver {host}:{port}"
231+
env = {"DJANGO_SETTINGS_MODULE": f"djangoapp.{settings}"}
232+
233+
xprocess.ensure(name, server_base.factory(name, cmd, pattern, env))
234+
235+
url = f"http://{server_base.host}:{port}"
236+
yield NS(debug=debug, url=url)
237+
238+
xprocess.getinfo(name).terminate()

0 commit comments

Comments
 (0)