Skip to content

fix(django-cf): run the django-cf suite in CI - #187

Merged
ryanking13 merged 7 commits into
mainfrom
gyeongjae/django-cf-test
Aug 12, 2026
Merged

fix(django-cf): run the django-cf suite in CI#187
ryanking13 merged 7 commits into
mainfrom
gyeongjae/django-cf-test

Conversation

@ryanking13

Copy link
Copy Markdown
Contributor

Runs existing django-cf tests in CI. We will probably need to adopt some of the test fixtures from runtime-sdk as a follow up.

@ask-bonk

ask-bonk Bot commented Aug 6, 2026

Copy link
Copy Markdown

Good - the R2 server does have __run_migrations__ and __create_admin__ endpoints, so the _seed function will work for all three projects.

Now let me verify the AGENTS.md for django-cf is consistent with the new PR changes (it still references tests/utils.py):

The AGENTS.md file at packages/django-cf/AGENTS.md still references tests/utils.py (which is deleted in this PR) and says "This package has no test job in .github/workflows/tests.yml yet". This is stale after this PR, but updating AGENTS.md is a follow-up concern, not a code correctness issue.

Let me also check if there's a potential TOCTOU issue with the port binding:

The get_free_port() function binds to port 0, gets a free port, closes the socket, then later uses that port for pywrangler dev. There's a small race window where another process could grab that port. This is the same pattern used in runtime-sdk's conftest.py, so it's an accepted trade-off.

I've done a thorough review. The PR is clean - it correctly:

  1. Moves fixtures from tests/utils.py to tests/conftest.py (proper pytest convention)
  2. Maintains the same base_url interface on the DevServer dataclass
  3. Properly handles process groups with start_new_session=True instead of deprecated preexec_fn
  4. Adds proper error handling and log dumping on fixture failures
  5. Excludes flaky e2e tests with clear documentation
  6. Fixes the CPython 3.12+ bytecode layout issue in test_wsgi_handler.py
  7. Correctly moves dev dependencies from [project.optional-dependencies] to [dependency-groups]
  8. The CI job configuration mirrors the existing sdk-test job pattern

LGTM

github run

@ryanking13
ryanking13 force-pushed the gyeongjae/django-cf-fix branch from 7561831 to 1bacdc7 Compare August 10, 2026 15:53
@ryanking13
ryanking13 force-pushed the gyeongjae/django-cf-test branch from be2ae45 to 9ccb6c4 Compare August 10, 2026 15:53
@ryanking13
ryanking13 force-pushed the gyeongjae/django-cf-fix branch from 1bacdc7 to 3c6adff Compare August 10, 2026 16:00
@ryanking13
ryanking13 force-pushed the gyeongjae/django-cf-test branch from 9ccb6c4 to f43dc8e Compare August 10, 2026 16:00
@ryanking13
ryanking13 force-pushed the gyeongjae/django-cf-fix branch from 3c6adff to e5e744e Compare August 11, 2026 04:07
@ryanking13
ryanking13 force-pushed the gyeongjae/django-cf-test branch from f43dc8e to 060234e Compare August 11, 2026 04:07
The runtime does not convert binding results uniformly. D1's `stmt.all()`
comes back dict-like, so `response.meta.rows_read` and
`response.results.to_py()` were reading attributes that are not there,
and `stmt.raw()` is already a list so calling `.to_py()` on it was
wrong too. R2's `arrayBuffer()` yields a `memoryview`, which needs
`bytes()` rather than a conversion call.

Each site here was confirmed against a live Worker by printing the
actual type, not inferred from the SDK's conversion rules, because those
rules vary per call site: `rpc.py` passes unknown host classes through
untouched while converting object literals, so whether a result is a
`dict`, a `JsProxy` or a `memoryview` depends on what the binding
returns. The three `head()` call sites in `storage/r2.py` keep `.to_py()`
deliberately - R2Object is a host class and is genuinely still a JsProxy.

The error handlers in `run_query` are left alone here; they are dealt
with separately.
django-cf had no CI job, and a bare pytest could not stand in for one: it
collected tests/d1, tests/durable_objects, tests/e2e, tests/r2 and
tests/test_date_trunc.py, all of which drive a real `wrangler dev` through
tests/utils.py and need a manually prepared Node toolchain. Only 130 of 208
tests could run unattended.

Add a django-test job and give it fixtures that need no manual setup. 192 tests
now run, up from 130.

The dev dependencies move from [project.optional-dependencies] to
[dependency-groups], because a plain `uv sync` does not install extras and the
job would otherwise start without pytest. package.json's setup-test drops to
`uv sync` for the same reason.

tests/conftest.py ports the dev-server fixtures from runtime-sdk, replacing the
re-imported helpers in tests/utils.py. Each fixture copies its Worker project to
a temp directory, runs pywrangler sync, overwrites the vendored django-cf with
the working tree and serves it on a free port. Copying per run removes the
`npm run setup-test` snapshot step, whose stale copies could silently test old
library code, and resolving the CLI from packages/cli via `uv run --with` means
tests exercise the monorepo checkout rather than the released workers-py.

Unlike the runtime-sdk original the servers run in uv project mode: each
wrangler.jsonc has a build.command that runs collectstatic, which needs the
project virtualenv on PATH, so --no-project fails before the Worker starts.

The fixtures now assert that the migration and admin seeding endpoints
succeeded; previously their responses were discarded, which is how a broken D1
backend stayed hidden. Startup failures dump the dev-server log.

tests/e2e is excluded via addopts, for flakiness rather than tooling. It is the
only suite that holds all three dev servers open at once, and about one run in
three the D1 server exits mid-suite with nothing in its log, failing the rest
with connection errors. Peak workerd RSS was 1.2 GB, so it is not memory
pressure; the root cause is not yet identified. Without it the suite passed
three consecutive runs. Run it deliberately with `pytest tests/e2e`.

test_djangocf_durable_object_get_app_error_message read
get_app.__code__.co_consts[1]. CPython 3.12 stopped emitting the leading None,
so the index ran off the end and the test died with IndexError on main. It now
asserts on the raised exception, which does not depend on bytecode layout.

Moving fixture sharing into a real conftest.py drops the F811 per-file ignore,
and start_new_session replaces preexec_fn, dropping PLW1509.
django-cf had two tiers of tests: 129 that ran on the host under plain
CPython with mocked bindings, and the rest that exercised a real Worker.
The host tier is the one that lies. Binding results come back as `dict`
in one place, `JsProxy` in another and `memoryview` in a third, and the
host mocks happily agreed with whichever shape the test author assumed.
Settling those questions needed live instrumentation against a running
Worker, not the test suite.

So the host tier is gone. `tests/db/`, `tests/middleware/` and
`tests/test_wsgi_handler.py` are replaced by `tests/in_worker/`, which
follows the harness runtime-sdk already uses: pytest runs inside the
worker, driven from the host by `register_in_worker_suites`. Everything
is exercised against all three compat dates, matching the SDK, while the
host stays on a single Python version.

Coverage reconciles exactly: 459 tests now pass where 192 did before.
That is 132 in-worker tests across 3 compat dates, plus the 63
Worker-backed tests that already existed. Nothing was dropped.

The source-inspection tests were deleted rather than ported. They
asserted on django-cf's source text, checking that a function's
exception `handlers` list was empty or that a header expression appeared
in `inspect.getsource`. They only existed because the real behaviour was
not reachable from the host. Where they encoded a real intent it is now
asserted directly: the header transformation is checked by sending
actual headers through the real WSGI stack and reading them back out.

Wall clock goes from 49s to about two minutes, against a 30 minute CI
timeout. Cost is driven by workerd boots, which is files times compat
dates, so the in-worker tests are deliberately consolidated into two
files rather than mirroring the old layout.

`tests/e2e/` stays excluded; it is flaky for unrelated reasons.
@ryanking13
ryanking13 force-pushed the gyeongjae/django-cf-test branch from 060234e to 5878126 Compare August 11, 2026 06:16
@ryanking13
ryanking13 changed the base branch from gyeongjae/django-cf-fix to main August 11, 2026 06:16
@ryanking13 ryanking13 changed the title test(django-cf): run the django-cf suite in CI fix(django-cf): run the django-cf suite in CI Aug 11, 2026
tests/in_worker_harness.py and tests/in_worker/conftest.py were a second copy of
what tests/conftest.py already did: two ways to start `pywrangler dev`, two
readiness probes, two teardowns. They are now one file.

The two start-up modes are not interchangeable and both survive - the app
fixtures need uv project mode for their collectstatic build step, the in-worker
fixture needs --no-project plus hand-vendored libraries - but they now share
port allocation, the readiness poll and the process-group kill that keeps
workerd from being orphaned. `dev_server` becomes `in_worker_server` and points
at tests/in_worker/worker directly, which retires the `worker_project_dir`
indirection.

Python 3.12 leaves the in-worker matrix. That runtime (Pyodide 0.26.0a2) has no
JSPI, so pyodide.ffi.run_sync is missing and all 15 R2Storage tests skip
themselves; the rest duplicates the 3.13 and 3.14 runs. 505 collected tests
become 348 and tests/in_worker drops from ~50s to ~33s. This is not the
runtime-sdk's reason for excluding 3.12: these suites contain no async tests,
so there were no false passes to fix here.

The npm setup-* scripts go too. The harness copies django_cf into a tmpdir per
run, so nothing has to be staged into templates/ or tests/servers/ first, and
AGENTS.md no longer documents a step that does not exist.

346 passed, 2 skipped. Both skips are the strict xfail in
tests/in_worker/worker/src/test_db.py, one per remaining config.
The in-worker migration merged six host test modules into two, and renamed a
lot of what it moved, which made it impossible to check the port against main
one test at a time. Split them back to the names main used:

  test_db.py             -> test_base_engine.py, test_d1_backend.py,
                            test_do_backend.py
  test_middleware_wsgi.py -> test_cloudflare_access.py, test_storage_errors.py,
                            test_wsgi_handler.py

Bodies are moved verbatim; only files, class names and test names change. Each
file carries its own helpers rather than importing a shared module, matching
how the originals looked. Filenames are the suite names the in-worker runner
dispatches on, so this also splits the run into six /run-tests/<suite> calls
per config instead of two, and generated host-side classes are now CamelCase
(TestBaseEngine, not TestBASE_ENGINE).

159 tests on main, 156 now. test_base_engine.py matches main exactly, 68 for
68. The rest:

- 4 source-inspection tests are gone, and should be. They asserted on the text
  of the implementation - that run_query uses `except Exception`, that
  Error.stackTraceLimit is set, that storage.py contains an import. None
  survive contact with a real runtime and none tested behaviour.
- 4 R2 tests that mocked a raising bucket are gone, replaced by real-binding
  equivalents against real missing objects: *_on_exception became
  *_on_not_found. Same intent, different mechanism, so the names changed.
- 2 host-only tests are gone: get_bucket_raises_on_non_worker cannot fail that
  way inside a worker, and the two WSGI header-transformation tests inspected
  handle_wsgi internals the Worker boundary tests now cover end to end.
- 3 genuinely uncovered: size when metadata has no size attribute, save
  preserving content_type, and the mock-only read-raises path.
- 7 added: real D1 read/write round trips, DO storage wiring, and Access user
  provisioning, none of which had host-side counterparts.

Full per-test mapping in /tmp/mig/report_db.md and /tmp/mig/report_middleware.md
(not committed).

348 passed, 2 skipped.
@ryanking13

Copy link
Copy Markdown
Contributor Author

I ended up refactoring the test suite a bit to align with our current unittest structure. Let me clean things up as a follow up but merging for now so that other PRs can land too.

@ryanking13
ryanking13 merged commit dc60b66 into main Aug 12, 2026
20 checks passed
@ryanking13
ryanking13 deleted the gyeongjae/django-cf-test branch August 12, 2026 03:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants