Skip to content

Add cross-platform mDNS integration test (Docker Compose + Python) - #19

Open
ZayanKhan-12 wants to merge 4 commits into
youtube:mainfrom
ZayanKhan-12:feat/cross-platform-integration-test
Open

ZayanKhan-12 wants to merge 4 commits into
youtube:mainfrom
ZayanKhan-12:feat/cross-platform-integration-test

Conversation

@ZayanKhan-12

Copy link
Copy Markdown

Summary

Re-implements the integration test dropped in #6, as a Docker Compose stack driven by a Python runner.

Closes #7.

The two reasons the old integration-test.sh was removed are addressed directly:

  • mDNS was unreliable on Actions runners — both agents now sit on a user-defined bridge network, so they get a Linux bridge of their own and multicast behaves predictably instead of depending on the runner's host network.
  • Bash isn't portable — orchestration is Compose, and the runner is Python using only the standard library, so there's no pip install step on a fresh Windows or macOS checkout.

It works. From the CI run on this branch:

  receiver name : openscreen-ci-b8e094cf6fb9
receiver-1  | OK: mDNS service published: openscreen-ci-b8e094cf6fb9._openscreen._udp.local.
sender-1    | OK: Found 1 receiver(s)
sender-1    | -> Selected: openscreen-ci-b8e094cf6fb9
sender-1    | WAIT: Connecting to receiver (QUIC + TLS + SPAKE2)... INFO QUIC connection
              established to 172.18.0.2:4433 (fingerprint verified during TLS handshake)
receiver-1  |   OK: Client authenticated!
sender-1    | Display Name:        openscreen-ci-b8e094cf6fb9
sender-1    | OK: Test completed successfully!

  ok  sender discovered the receiver over mDNS
  ok  QUIC + TLS + SPAKE2 handshake completed
  ok  agent-info exchange returned the expected display name

INTEGRATION TEST PASSED

Found 1 receiver(s) is worth noting: the bridge network plus the non-standard mDNS port means the test sees exactly its own receiver and nothing else.

What it asserts, and why not just the exit code

app-sender selects services[0], so "the sender exited 0" is not by itself evidence that this run's receiver was the one discovered — a stale container or a concurrent run could satisfy it.

So the receiver name is generated fresh per run (openscreen-ci-<random>) and the test requires that name to appear:

  1. in the sender's discovery output — proving mDNS discovery actually happened, rather than a direct-connection fallback or a stale receiver;
  2. on the Display Name: line of the agent-info response — only reachable after QUIC, TLS, SPAKE2 and the application exchange all succeed.

Plus the sender exiting 0 and the receiver reporting an authenticated client. Both container logs are printed on failure before teardown.

Design notes

mDNS port 5454, not 5353. So the test never contends with the host's own responder — macOS always has Bonjour, and many Linux desktops run Avahi.

A healthcheck, not a sleep. depends_on alone waits for the receiver's container to start, not for it to be advertising, which is exactly the kind of race that makes an integration test flaky. app-receiver publishes its mDNS service before binding the QUIC socket, so a listening UDP socket on the app port means both are ready; the sender waits on service_healthy. (This ordering is now written down in CLAUDE.md, since reordering those two steps would silently break the healthcheck.)

up --exit-code-from sender implies --abort-on-container-exit, so the run ends when the sender finishes and its status becomes the test's status — the receiver would otherwise run forever.

NO_COLOR=1 in both containers, since the binaries colourise through the colored crate and the test asserts on those strings.

No docker-in-docker. The issue floated it, but GitHub's hosted Ubuntu runner already exposes a working Docker daemon. What was actually needed was for the containers to talk over their own bridge rather than the runner's host network. Dropping DinD keeps the workflow considerably simpler.

Rust stable, not a pinned version, in the Dockerfile — Cargo.lock is .gitignored here, so dependencies resolve fresh on every build and a pinned older toolchain would drift out of date. This matches dtolnay/rust-toolchain@stable in the existing jobs.

Cross-platform, and how that's kept honest

The runner and the Compose stack behave identically on Linux, macOS and Windows for local development.

In CI the end-to-end job is Linux-only by necessity: GitHub's hosted macOS runners ship no Docker daemon, and its Windows runners can run Windows containers but not the Linux containers this stack builds from.

Rather than claim cross-platform support and test it on one platform, the assertion logic is a pure function and a runner-self-test job runs run.py --self-test on all three platforms — covering argument handling, path handling and the log assertions, including the negative cases (stale receiver name, missing success marker, unauthenticated receiver, wrong agent-info name). That job is green on ubuntu, macos and windows.

Usage

python tests/integration/run.py              # full run
python tests/integration/run.py --verbose    # stream container logs
python tests/integration/run.py --keep       # leave containers up to debug
python tests/integration/run.py --self-test  # runner logic only, no Docker

Compose defaults are baked into docker-compose.yml, so docker compose up in that directory also works for poking at the stack by hand. See tests/integration/README.md.

Also included

CLAUDE.md, per the repo's request for one — the mDNS-before-QUIC ordering, that the binaries' println! markers are asserted on (and are listed as constants in one place), that Cargo.lock is gitignored so builds track stable, and the debugging notes that otherwise cost time to rediscover: --discovery-timeout is a flat sleep rather than a poll, and the sender takes services[0].

__pycache__/ added to .gitignore, since this is the repo's first Python.

Pre-existing CI failure, not from this PR

The Lint / Pre-commit job is red on this branch, and it is red before it too. This PR changes 0 Rust files; the clippy errors are in openscreen-crypto-rustcrypto/src/lib.rs and openscreen-network/src/state_machine/*.rs, and those exact lines are present unchanged on main:

error: called `map(<f>).unwrap_or_else(<g>)` on an `Option` value
  --> openscreen-crypto-rustcrypto/src/lib.rs:106:28
error: variables can be used directly in the `format!` string
  --> openscreen-network/src/state_machine/awaiting_capabilities.rs:77:17

Clippy 1.98 tightened map_unwrap_or and uninlined_format_args; combined with -D warnings and an unpinned stable toolchain, previously-clean code now fails. Deliberately left alone here to keep this PR to one concern — happy to send a separate PR for it.

Everything else is green: Build & Test, mDNS discovery (Docker Compose), and Runner self-test on ubuntu, macos and windows.

🤖 Generated with Claude Code

zk-khan and others added 4 commits September 15, 2026 00:48
Re-implements the integration test that was dropped in youtube#6, using Docker
Compose for orchestration and Python for the runner.

Docker, because mDNS discovery is unreliable on a GitHub Actions runner's
host network. Putting both agents on a user-defined bridge network gives
them a Linux bridge of their own, where multicast behaves predictably.

Python and Compose, because the two together behave identically on
Linux, macOS and Windows. The runner imports only the standard library,
so there is no pip install step on a fresh checkout.

The receiver name is generated per run (openscreen-ci-<random>) and the
test asserts that name appears both in the sender's discovery output and
on the Display Name line of the agent-info response. Checking the name
rather than just the exit status is what distinguishes real mDNS
discovery from a direct-connection fallback or a stale receiver left by a
concurrent run -- app-sender selects services[0], so "it exited 0" alone
is not evidence that this run's receiver was the one found.

Details worth noting:

  * Both agents use mDNS port 5454 rather than 5353, so the test never
    contends with the host's own responder (Bonjour, Avahi).

  * The receiver has a healthcheck and the sender waits for
    service_healthy. depends_on alone waits only for the container to
    start, not for the service to be advertised. app-receiver publishes
    its mDNS service before binding the QUIC socket, so a listening UDP
    socket on the app port implies both are ready.

  * `up --exit-code-from sender` implies --abort-on-container-exit, so
    the run ends when the sender finishes and its status becomes the
    test's status. The receiver would otherwise run forever.

  * Both containers set NO_COLOR, since the binaries colourise through
    the `colored` crate and the test asserts on those strings.

The assertion logic is a pure function so that --self-test can exercise
it, including the negative cases, without Docker.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two jobs:

  * integration-test runs the full Compose stack on ubuntu-latest. No
    docker-in-docker setup is needed -- the GitHub-hosted Ubuntu runner
    already exposes a working Docker daemon, and the containers reach
    each other over their own bridge network rather than the runner's
    host network, which is what makes mDNS work here.

  * runner-self-test runs `run.py --self-test` on Linux, macOS and
    Windows.

The end-to-end job is Linux-only by necessity: GitHub's hosted macOS
runners ship no Docker daemon, and its Windows runners can run Windows
containers but not the Linux containers this stack builds. Rather than
claim cross-platform support and test it on one platform, the self-test
job exercises the runner's own portability -- argument handling, path
handling, log assertions -- on all three.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Records what is not obvious from reading the tree: which crates exist to
keep tests off the real network, that app-receiver publishes mDNS before
binding QUIC (the integration test's healthcheck depends on that
ordering), that the binaries' println markers are asserted on by the
test, and that Cargo.lock is gitignored so builds track Rust stable
rather than a pinned toolchain.

Also the debugging notes that cost time to rediscover: the sender sleeps
rather than polls for discovery, and it selects services[0].

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The integration test runner is the first Python in this repository, so
__pycache__/ was not ignored yet. Also drops a .pyc that was committed by
accident.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@google-cla

google-cla Bot commented Sep 15, 2026

Copy link
Copy Markdown

Thanks for your pull request! It looks like this may be your first contribution to a Google open source project. Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA).

View this failed invocation of the CLA check for more information.

For the most up to date status, view the checks section at the bottom of the pull request.

ZayanKhan-12 added a commit to ZayanKhan-12/openscreen-rs that referenced this pull request Sep 15, 2026
Merges the branch proposed upstream as youtube#19. Integration test, Build & Test, and runner self-test on Linux/macOS/Windows all green.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a cross-platform integration test suite for the Open Screen Protocol implementation, including a Dockerfile, a Docker Compose configuration, a Python test runner, and comprehensive documentation. The review feedback points out a compatibility issue in the Python runner where the --no-log-prefix flag is used with docker compose logs, which might fail on older Docker Compose versions, and suggests programmatically stripping the prefix instead.

Comment thread tests/integration/run.py
Comment on lines +182 to +193
def service_logs(compose: list[str], env: dict[str, str], service: str) -> str:
"""Return one service's logs with Compose's ``service |`` prefix stripped."""
result = subprocess.run(
[*compose, "logs", "--no-log-prefix", service],
env=env,
cwd=HERE,
capture_output=True,
text=True,
errors="replace",
check=False,
)
return result.stdout + result.stderr

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The --no-log-prefix option was introduced in Docker Compose v2.20.0. Using it directly will cause the integration test to fail on environments running older versions of Docker Compose v2, or those using Docker Compose v1 (docker-compose), which do not support this flag.

To maximize compatibility across different environments and platforms, we can retrieve the logs without this flag and strip the service | prefix programmatically in Python.

Suggested change
def service_logs(compose: list[str], env: dict[str, str], service: str) -> str:
"""Return one service's logs with Compose's ``service |`` prefix stripped."""
result = subprocess.run(
[*compose, "logs", "--no-log-prefix", service],
env=env,
cwd=HERE,
capture_output=True,
text=True,
errors="replace",
check=False,
)
return result.stdout + result.stderr
def service_logs(compose: list[str], env: dict[str, str], service: str) -> str:
"""Return one service's logs with Compose's ``service |`` prefix stripped."""
result = subprocess.run(
[*compose, "logs", service],
env=env,
cwd=HERE,
capture_output=True,
text=True,
errors="replace",
check=False,
)
raw_logs = result.stdout + result.stderr
stripped_lines = []
for line in raw_logs.splitlines():
if " | " in line:
stripped_lines.append(line.split(" | ", 1)[1])
else:
stripped_lines.append(line)
return "\n".join(stripped_lines)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

2 participants