Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions packages/django-cf/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
PACKAGE_DIR: Path = TEST_DIR.parent
WORKERS_PY: Path = PACKAGE_DIR.parent / "cli"
WORKERS_RUNTIME_SDK: Path = PACKAGE_DIR.parent / "runtime-sdk" / "src"
TESTLIB: Path = PACKAGE_DIR.parent / "testlib"
DJANGO_CF_SRC: Path = PACKAGE_DIR / "django_cf"

D1_PROJECT: Path = PACKAGE_DIR / "templates" / "d1"
Expand Down Expand Up @@ -255,6 +256,11 @@ def inject_compat_flags(file: Path, extra_flags: list[str]) -> None:
file.write_text(content)


@pytest.fixture(scope="session", autouse=True)
def build_testlib():
subprocess.run(["uv", "build"], cwd=TESTLIB, check=True)


@pytest.fixture(
scope="module",
params=COMPAT_CONFIGS,
Expand All @@ -277,6 +283,7 @@ def in_worker_server(
tmp_path = tmp_path_factory.mktemp("in_worker")
target = tmp_path / IN_WORKER_PROJECT.name
shutil.copytree(IN_WORKER_PROJECT, target, ignore=GENERATED)
shutil.copytree(TESTLIB, tmp_path / "testlib", ignore=GENERATED)

wrangler_jsonc = target / "wrangler.jsonc"
replace_compat_date(wrangler_jsonc, compat_config.compat_date)
Expand Down
4 changes: 4 additions & 0 deletions packages/django-cf/tests/in_worker/worker/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,8 @@ dependencies = [
"pytest",
"pytest-asyncio<1.2.0",
"sqlparse",
"testlib",
]

[tool.uv.sources]
testlib = { path = "../testlib/dist/testlib-0.0.0-py3-none-any.whl" }
114 changes: 4 additions & 110 deletions packages/django-cf/tests/in_worker/worker/src/worker.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,5 @@
# pyright: reportMissingImports=false

import asyncio
import contextlib
import importlib.util
import io
import os
from pathlib import Path
from urllib.parse import urlparse
Expand All @@ -14,22 +10,13 @@
from _django_app import R2_LOCATION, django_wsgi_app
from django.http import HttpResponse, JsonResponse, StreamingHttpResponse
from django.urls import path
from pyodide.webloop import WebLoop
from testlib.entrypoint import TestRunnerEntrypoint
from worker_durable_object import TestDurableObject # noqa: F401
from workers import Response, WorkerEntrypoint

BASE_DIR = Path(__file__).parent
os.environ.setdefault("DJANGO_ALLOW_ASYNC_UNSAFE", "true")


async def _noop(*args):
pass


# pytest-asyncio relies on these methods, which older Pyodide WebLoops omit.
WebLoop.shutdown_asyncgens = _noop
WebLoop.shutdown_default_executor = _noop

if not django.conf.settings.configured:
django.conf.settings.configure(
DEBUG=False,
Expand Down Expand Up @@ -149,61 +136,6 @@ def _django_headers_view(request):
]


class ResultCollector:
def __init__(self):
self.results = {}

@staticmethod
def _key(item):
normalized = []
if item.cls is not None:
normalized.append(item.cls.__name__)
name = getattr(item, "originalname", None) or item.name
normalized.append(name[len("test_") :] if name.startswith("test_") else name)
return "__".join(normalized)

@pytest.hookimpl(hookwrapper=True)
def pytest_runtest_makereport(self, item, call):
outcome = yield
report = outcome.get_result()
key = self._key(item)

if report.when == "call":
if report.passed:
self.results[key] = {"status": "passed"}
elif report.skipped:
self.results[key] = {
"status": "skipped",
"reason": str(report.longrepr),
}
elif report.failed:
excinfo = call.excinfo
if excinfo is not None and excinfo.errisinstance(AssertionError):
self.results[key] = {
"status": "failed",
"error": str(excinfo.value),
}
else:
self.results[key] = {
"status": "error",
"error": f"{excinfo.typename}: {excinfo.value}"
if excinfo is not None
else "unknown error",
"traceback": report.longreprtext,
}
elif report.when in ("setup", "teardown") and report.skipped:
self.results[key] = {
"status": "skipped",
"reason": str(report.longrepr),
}
elif report.when in ("setup", "teardown") and report.failed:
self.results[key] = {
"status": "error",
"error": report.longreprtext,
"traceback": report.longreprtext,
}


class EnvPlugin:
def __init__(self, env):
self._env = env
Expand All @@ -213,50 +145,12 @@ def env(self):
return self._env


class Default(DjangoCF, WorkerEntrypoint):
class Default(DjangoCF, TestRunnerEntrypoint):
def get_app(self):
return django_wsgi_app()

async def fetch(self, request):
path = urlparse(request.url).path

if path.startswith("/run-tests/"):
suite_name = path[len("/run-tests/") :]
return self._run_suite(suite_name)
if path == "/health":
return Response.json({"ok": True})
if path.startswith("/django/"):
return await super().fetch(request)
return Response.json({"error": "not found"}, status=404)

def _run_suite(self, suite_name):
module = f"test_{suite_name}"
if importlib.util.find_spec(module) is None:
return Response.json(
{"error": f"Unknown suite '{suite_name}' (no module '{module}')"},
status=404,
)

collector = ResultCollector()
saved_loop = asyncio.events._get_running_loop()
output = io.StringIO()
try:
with contextlib.redirect_stdout(output), contextlib.redirect_stderr(output):
exit_code = pytest.main(
["--pyargs", module, "-p", "no:cacheprovider"],
plugins=[collector, EnvPlugin(self.env)],
)
finally:
asyncio.events._set_running_loop(saved_loop)
if exit_code != 0 and not collector.results:
return Response.json(
{
"__session__": {
"status": "error",
"error": f"pytest exit code {exit_code}",
"traceback": output.getvalue(),
}
},
status=500,
)
return Response.json(collector.results)
return await DjangoCF.fetch(self, request)
return await TestRunnerEntrypoint.fetch(self, request)
5 changes: 4 additions & 1 deletion packages/runtime-sdk/tests/bindings-test/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,7 @@
name = "bindings-test"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = ["pytest", "pytest-asyncio<1.2.0", "pg8000", "pymysql", "cryptography"]
dependencies = ["pytest", "pytest-asyncio<1.2.0", "pg8000", "pymysql", "cryptography", "testlib"]

[tool.uv.sources]
testlib = { path = "../testlib/dist/testlib-0.0.0-py3-none-any.whl" }
158 changes: 2 additions & 156 deletions packages/runtime-sdk/tests/bindings-test/src/worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,146 +8,18 @@
To add a new binding: create `src/test_<binding>.py` with pytest tests.
"""

import asyncio
import importlib.util
import sys
from asyncio import InvalidStateError

import pytest
from pyodide.webloop import WebLoop
from testlib.entrypoint import TestRunnerEntrypoint
from worker_durable_object import (
TestDurableObject, # noqa: F401 - import to trigger side effect of registering the Durable Object
)
from worker_workflow import (
TestWorkflow, # noqa: F401 - import to trigger side effect of registering the Workflow
)

from workers import Response, WorkerEntrypoint


async def _noop(*args):
pass


# pytest-asyncio relies on these but in Pyodide < 0.29 WebLoop does not implement them.
WebLoop.shutdown_asyncgens = _noop
WebLoop.shutdown_default_executor = _noop

# Pyodide 0.26.0a2's WebLoop causes InvalidStateError when the
# _cancel_all_tasks calls task.exception() on done-but-not-cancelled tasks.
# Replace with a version that cancels tasks but tolerates that error.
if sys.version_info < (3, 13):

def _cancel_all_tasks(loop):
to_cancel = asyncio.tasks.all_tasks(loop)
if not to_cancel:
return
for task in to_cancel:
task.cancel()
loop.run_until_complete(
asyncio.tasks.gather(*to_cancel, return_exceptions=True)
)
for task in to_cancel:
if task.cancelled():
continue
try:
if task.exception() is not None:
loop.call_exception_handler(
{
"message": "unhandled exception during asyncio.run() shutdown",
"exception": task.exception(),
"task": task,
}
)
# Note: This exception catch is added from the original implementation
except (InvalidStateError, RuntimeError):
pass

asyncio.runners._cancel_all_tasks = _cancel_all_tasks # type: ignore[attr-defined]


class ResultCollector:
"""pytest plugin that records each test's outcome keyed by its short name.

The "test_" prefix is stripped so keys match the names registered in
tests/test_bindings.py (e.g. test_put_and_get -> "put_and_get").
"""

def __init__(self):
self.results = {}

@staticmethod
def _key(item):
name = item.name
return name[len("test_") :] if name.startswith("test_") else name

@pytest.hookimpl(hookwrapper=True)
def pytest_runtest_makereport(self, item, call):
outcome = yield
report = outcome.get_result()
key = self._key(item)

if report.when == "call":
if report.passed:
self.results[key] = {"status": "passed"}
elif report.skipped:
self.results[key] = {
"status": "skipped",
"reason": str(report.longrepr),
}
elif report.failed:
excinfo = call.excinfo
if excinfo is not None and excinfo.errisinstance(AssertionError):
self.results[key] = {
"status": "failed",
"error": str(excinfo.value),
}
else:
self.results[key] = {
"status": "error",
"error": f"{excinfo.typename}: {excinfo.value}"
if excinfo is not None
else "unknown error",
"traceback": report.longreprtext,
}
elif report.when in ("setup", "teardown") and report.skipped:
self.results[key] = {
"status": "skipped",
"reason": str(report.longrepr),
}
elif report.when in ("setup", "teardown") and report.failed:
self.results[key] = {
"status": "error",
"error": report.longreprtext,
"traceback": report.longreprtext,
}


class EnvPlugin:
def __init__(self, env):
self._env = env

@pytest.fixture
def env(self):
return self._env


RECEIVED_MESSAGES = []


class Default(WorkerEntrypoint):
async def fetch(self, request):
from urllib.parse import urlparse

path = urlparse(request.url).path

if path.startswith("/run-tests/"):
suite_name = path[len("/run-tests/") :]
return self._run_suite(suite_name)
if path == "/health":
return Response.json({"ok": True})
return Response.json({"error": "not found"}, status=404)

class Default(TestRunnerEntrypoint):
async def queue(self, batch, env, ctx):
for message in batch.messages:
RECEIVED_MESSAGES.append(
Expand All @@ -158,29 +30,3 @@ async def queue(self, batch, env, ctx):
}
)
message.ack()

def _run_suite(self, suite_name):
module = f"test_{suite_name}"
if importlib.util.find_spec(module) is None:
return Response.json(
{"error": f"Unknown suite '{suite_name}' (no module '{module}')"},
status=404,
)

collector = ResultCollector()
# pytest-asyncio drives each test through asyncio.Runner, which calls
# asyncio.new_event_loop(). In Pyodide that constructs a WebLoop whose
# __init__ calls asyncio._set_running_loop(self) and is never restored on
# close(), so after pytest.main() the running loop points at an abandoned
# WebLoop. Save and restore it so the next request's fetch coroutine runs
# on the real workerd-driven loop instead of hanging on a dead one.
# TODO: fix this behavior in Pyodide
saved_loop = asyncio.events._get_running_loop()
try:
pytest.main(
["--pyargs", module, "-p", "no:cacheprovider"],
plugins=[collector, EnvPlugin(self.env)],
)
finally:
asyncio.events._set_running_loop(saved_loop)
return Response.json(collector.results)
Loading
Loading