From 81722ce5bf819e77f4f975f925597d7391449e11 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 18:11:18 +0000 Subject: [PATCH 1/7] Add Docker Compose VNC server fleet spike Adds a compatibility test bed of small, version-pinned Linux VNC servers (tigervnc no-auth, tigervnc VNC-password-auth, x11vnc) built from in-repo Dockerfiles, driven by tests/servers/fleet.mk (servers-up/servers-down/ test-fleet, included from the main Makefile) and exercised by tests/functional/test_fleet.py, which connects with vncdotool.api.connect, sends a key, and captures a screenshot per server -- skipping cleanly per server when its port isn't up. Wires a spike-fleet GitHub Actions workflow that builds/starts the fleet with compose healthchecks, runs the tests, and uploads captured screenshots as artifacts. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01STWkReabCK9sJW2qQ45brM --- .github/workflows/spike-fleet.yml | 97 ++++++++++++++++++++++ Makefile | 2 + tests/functional/test_fleet.py | 99 +++++++++++++++++++++++ tests/servers/docker-compose.yml | 45 +++++++++++ tests/servers/fleet.mk | 15 ++++ tests/servers/tigervnc-auth/Dockerfile | 28 +++++++ tests/servers/tigervnc-auth/entrypoint.sh | 31 +++++++ tests/servers/tigervnc/Dockerfile | 25 ++++++ tests/servers/tigervnc/entrypoint.sh | 25 ++++++ tests/servers/x11vnc/Dockerfile | 21 +++++ tests/servers/x11vnc/entrypoint.sh | 18 +++++ 11 files changed, 406 insertions(+) create mode 100644 .github/workflows/spike-fleet.yml create mode 100644 tests/functional/test_fleet.py create mode 100644 tests/servers/docker-compose.yml create mode 100644 tests/servers/fleet.mk create mode 100644 tests/servers/tigervnc-auth/Dockerfile create mode 100755 tests/servers/tigervnc-auth/entrypoint.sh create mode 100644 tests/servers/tigervnc/Dockerfile create mode 100755 tests/servers/tigervnc/entrypoint.sh create mode 100644 tests/servers/x11vnc/Dockerfile create mode 100755 tests/servers/x11vnc/entrypoint.sh diff --git a/.github/workflows/spike-fleet.yml b/.github/workflows/spike-fleet.yml new file mode 100644 index 0000000..0bf0229 --- /dev/null +++ b/.github/workflows/spike-fleet.yml @@ -0,0 +1,97 @@ +name: Spike - VNC server fleet + +on: + push: + branches: + - claude/spike-server-fleet + workflow_dispatch: + +permissions: + contents: read + +env: + PIP_DISABLE_PIP_VERSION_CHECK: '1' + PIP_NO_PYTHON_VERSION_WARNING: '1' + +defaults: + run: + shell: bash + +jobs: + fleet: + name: Fleet functional tests + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Check out source + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.13' + cache: pip + cache-dependency-path: requirements-dev.txt + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements-dev.txt + + - name: Build and start the VNC server fleet + run: | + docker compose -f tests/servers/docker-compose.yml up -d --build --wait + + - name: Show fleet status + if: always() + run: docker compose -f tests/servers/docker-compose.yml ps + + - name: Run fleet functional tests + run: | + mkdir -p fleet-screenshots + python -m unittest discover -v -s tests/functional -t . -p 'test_fleet.py' + + - name: Capture fleet screenshots for the artifact upload + if: always() + run: | + python - <<'PY' + import socket + import sys + + sys.path.insert(0, "tests/functional") + from test_fleet import FLEET_SERVERS # noqa: E402 + + from vncdotool import api # noqa: E402 + + for server in FLEET_SERVERS: + dest = f"fleet-screenshots/{server.name}.png" + try: + with socket.create_connection(("127.0.0.1", server.port), timeout=1): + pass + except OSError: + print(f"skip {server.name}: port {server.port} not open") + continue + try: + with api.connect(f"127.0.0.1::{server.port}", password=server.password) as client: + client.timeout = 5 + client.captureScreen(dest) + print(f"captured {dest}") + except Exception as exc: # noqa: BLE001 - best effort artifact capture + print(f"failed to capture {server.name}: {exc}") + PY + + - name: Dump fleet container logs + if: always() + run: docker compose -f tests/servers/docker-compose.yml logs + + - name: Upload fleet screenshots + if: always() + uses: actions/upload-artifact@v4 + with: + name: fleet-screenshots + path: fleet-screenshots/ + if-no-files-found: ignore + + - name: Stop the VNC server fleet + if: always() + run: docker compose -f tests/servers/docker-compose.yml down diff --git a/Makefile b/Makefile index 4b2d05b..ec3707d 100644 --- a/Makefile +++ b/Makefile @@ -41,4 +41,6 @@ include libvncserver.mk test-func: libvnc-examples test-libvnc +include tests/servers/fleet.mk + include Makefile.venv diff --git a/tests/functional/test_fleet.py b/tests/functional/test_fleet.py new file mode 100644 index 0000000..d24806b --- /dev/null +++ b/tests/functional/test_fleet.py @@ -0,0 +1,99 @@ +"""Functional tests against the Docker Compose VNC server fleet. + +See tests/servers/docker-compose.yml and tests/servers/fleet.mk. The fleet +servers are expected to already be running (e.g. via ``make servers-up``) +before this module executes; a server whose port isn't open is skipped with +a clear message rather than failing the whole run, so this module is also +safe to run outside of that make target. +""" + +import socket +import tempfile +from typing import NamedTuple, Optional +from unittest import TestCase + +from vncdotool import api + +HOST = "127.0.0.1" +CONNECT_TIMEOUT = 5.0 +PORT_PROBE_TIMEOUT = 1.0 + + +class FleetServer(NamedTuple): + name: str + port: int + password: Optional[str] + + +# (name, port, password) for each service in tests/servers/docker-compose.yml +FLEET_SERVERS = [ + FleetServer("tigervnc", 5931, None), + FleetServer("tigervnc-auth", 5932, "vncdotool"), + FleetServer("x11vnc", 5933, None), +] + +PNG_MAGIC = b"\x89PNG\r\n\x1a\n" + + +def _port_open(host: str, port: int, timeout: float = PORT_PROBE_TIMEOUT) -> bool: + try: + with socket.create_connection((host, port), timeout=timeout): + return True + except OSError: + return False + + +class _FleetServerTestMixin: + """Shared test body, parameterized per-server by _make_fleet_test_case below. + + Deliberately does NOT subclass TestCase: only the dynamically generated + per-server classes below should (otherwise `unittest discover` also + collects this shared base as its own, serverless test case). + """ + + server: FleetServer + + def setUp(self) -> None: + if not _port_open(HOST, self.server.port): + self.skipTest( + f"{self.server.name} not reachable on {HOST}:{self.server.port} -- " + "start the fleet first with `make servers-up`" + ) + + def test_connect_key_and_capture(self) -> None: + address = f"{HOST}::{self.server.port}" + with api.connect(address, password=self.server.password) as client: + client.timeout = CONNECT_TIMEOUT + client.keyPress("x") + + with tempfile.NamedTemporaryFile(prefix="fleet_", suffix=".png") as png: + client.captureScreen(png.name) + data = png.read() + + self.assertTrue(data, f"{self.server.name}: captured screenshot is empty") + self.assertEqual( + data[:8], + PNG_MAGIC, + f"{self.server.name}: captured file is not a valid PNG", + ) + + +def _make_fleet_test_case(server: FleetServer) -> type: + class_name = "TestFleet_" + server.name.replace("-", "_") + return type(class_name, (_FleetServerTestMixin, TestCase), {"server": server}) + + +# Register one TestCase subclass per fleet server so `unittest discover` +# reports pass/fail/skip separately for each server. +for _server in FLEET_SERVERS: + _test_case = _make_fleet_test_case(_server) + globals()[_test_case.__name__] = _test_case +del _server, _test_case + + +def tearDownModule() -> None: + # api.connect() starts a background Twisted reactor thread that + # outlives any individual client connection. Without stopping it here, + # the interpreter (and `unittest discover`) hangs on exit after all + # tests in this module have finished. + api.shutdown() diff --git a/tests/servers/docker-compose.yml b/tests/servers/docker-compose.yml new file mode 100644 index 0000000..39ce1ef --- /dev/null +++ b/tests/servers/docker-compose.yml @@ -0,0 +1,45 @@ +# Docker Compose fleet of small, version-controlled Linux VNC servers used +# as a compatibility test bed for vncdotool. See tests/servers/fleet.mk for +# the make targets that drive this file and tests/functional/test_fleet.py +# for the tests that exercise it. +# +# Each service is built from an in-repo Dockerfile (FROM debian:bookworm-slim) +# rather than a third-party desktop image, so server versions are pinned by +# the base image + apt rather than by whatever an upstream image happens to +# bundle. Every service publishes its RFB port to localhost only. + +services: + tigervnc: + build: ./tigervnc + ports: + - "127.0.0.1:5931:5900" + healthcheck: + test: ["CMD", "nc", "-z", "127.0.0.1", "5900"] + interval: 2s + timeout: 2s + retries: 30 + start_period: 5s + + tigervnc-auth: + build: ./tigervnc-auth + environment: + VNC_PASSWORD: vncdotool + ports: + - "127.0.0.1:5932:5900" + healthcheck: + test: ["CMD", "nc", "-z", "127.0.0.1", "5900"] + interval: 2s + timeout: 2s + retries: 30 + start_period: 5s + + x11vnc: + build: ./x11vnc + ports: + - "127.0.0.1:5933:5900" + healthcheck: + test: ["CMD", "nc", "-z", "127.0.0.1", "5900"] + interval: 2s + timeout: 2s + retries: 30 + start_period: 5s diff --git a/tests/servers/fleet.mk b/tests/servers/fleet.mk new file mode 100644 index 0000000..4e1a7b3 --- /dev/null +++ b/tests/servers/fleet.mk @@ -0,0 +1,15 @@ +FLEET_COMPOSE?=tests/servers/docker-compose.yml +DOCKER_COMPOSE?=docker compose +PYTHON?=python3 + +.PHONY: servers-up +servers-up: + $(DOCKER_COMPOSE) -f $(FLEET_COMPOSE) up -d --build --wait + +.PHONY: servers-down +servers-down: + $(DOCKER_COMPOSE) -f $(FLEET_COMPOSE) down + +.PHONY: test-fleet +test-fleet: + $(PYTHON) -m unittest discover $(UNITTEST_ARGS) -s tests/functional -t . -p 'test_fleet.py' diff --git a/tests/servers/tigervnc-auth/Dockerfile b/tests/servers/tigervnc-auth/Dockerfile new file mode 100644 index 0000000..96fc160 --- /dev/null +++ b/tests/servers/tigervnc-auth/Dockerfile @@ -0,0 +1,28 @@ +# Minimal TigerVNC (Xvnc) server with VNC password authentication. +# +# The password is set at container start via the VNC_PASSWORD environment +# variable (default "vncdotool") using the standard vncpasswd tool, so it +# is never baked into the image. +FROM debian:bookworm-slim + +RUN apt-get update && apt-get install -y --no-install-recommends \ + tigervnc-standalone-server \ + tigervnc-common \ + tigervnc-tools \ + x11-apps \ + xauth \ + netcat-openbsd \ + procps \ + && rm -rf /var/lib/apt/lists/* + +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +ENV VNC_PASSWORD=vncdotool + +EXPOSE 5900 + +HEALTHCHECK --interval=2s --timeout=2s --start-period=5s --retries=30 \ + CMD nc -z 127.0.0.1 5900 + +ENTRYPOINT ["/entrypoint.sh"] diff --git a/tests/servers/tigervnc-auth/entrypoint.sh b/tests/servers/tigervnc-auth/entrypoint.sh new file mode 100755 index 0000000..c2b3b35 --- /dev/null +++ b/tests/servers/tigervnc-auth/entrypoint.sh @@ -0,0 +1,31 @@ +#!/bin/sh +# Start a TigerVNC (Xvnc) server with classic VNC password authentication +# and draw a trivial X client on it so captures aren't pure black. +set -e + +trap 'kill -TERM "$XVNC_PID" 2>/dev/null; exit 0' TERM INT + +VNC_PASSWORD="${VNC_PASSWORD:-vncdotool}" +mkdir -p /root/.vnc +printf '%s' "$VNC_PASSWORD" | vncpasswd -f > /root/.vnc/passwd +chmod 600 /root/.vnc/passwd + +Xvnc :0 \ + -SecurityTypes VncAuth \ + -PasswordFile /root/.vnc/passwd \ + -rfbport 5900 \ + -geometry 1024x768 \ + -depth 24 \ + -AlwaysShared \ + -localhost=0 & +XVNC_PID=$! + +export DISPLAY=:0 +for _ in $(seq 1 30); do + xdpyinfo >/dev/null 2>&1 && break + sleep 0.5 +done + +xlogo -geometry 200x200+50+50 & + +wait "$XVNC_PID" diff --git a/tests/servers/tigervnc/Dockerfile b/tests/servers/tigervnc/Dockerfile new file mode 100644 index 0000000..5e160f3 --- /dev/null +++ b/tests/servers/tigervnc/Dockerfile @@ -0,0 +1,25 @@ +# Minimal TigerVNC (Xvnc) server, no authentication. +# +# Deliberately built from a small in-repo Dockerfile rather than a +# third-party desktop image so the exact server version is controlled by +# apt in the base distro rather than by whatever a random upstream image +# happens to bundle. +FROM debian:bookworm-slim + +RUN apt-get update && apt-get install -y --no-install-recommends \ + tigervnc-standalone-server \ + x11-apps \ + xauth \ + netcat-openbsd \ + procps \ + && rm -rf /var/lib/apt/lists/* + +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +EXPOSE 5900 + +HEALTHCHECK --interval=2s --timeout=2s --start-period=5s --retries=30 \ + CMD nc -z 127.0.0.1 5900 + +ENTRYPOINT ["/entrypoint.sh"] diff --git a/tests/servers/tigervnc/entrypoint.sh b/tests/servers/tigervnc/entrypoint.sh new file mode 100755 index 0000000..35033c5 --- /dev/null +++ b/tests/servers/tigervnc/entrypoint.sh @@ -0,0 +1,25 @@ +#!/bin/sh +# Start a bare TigerVNC (Xvnc) server with no authentication and draw a +# trivial X client on it so captures aren't pure black. +set -e + +trap 'kill -TERM "$XVNC_PID" 2>/dev/null; exit 0' TERM INT + +Xvnc :0 \ + -SecurityTypes None \ + -rfbport 5900 \ + -geometry 1024x768 \ + -depth 24 \ + -AlwaysShared \ + -localhost=0 & +XVNC_PID=$! + +export DISPLAY=:0 +for _ in $(seq 1 30); do + xdpyinfo >/dev/null 2>&1 && break + sleep 0.5 +done + +xlogo -geometry 200x200+50+50 & + +wait "$XVNC_PID" diff --git a/tests/servers/x11vnc/Dockerfile b/tests/servers/x11vnc/Dockerfile new file mode 100644 index 0000000..69d3f7f --- /dev/null +++ b/tests/servers/x11vnc/Dockerfile @@ -0,0 +1,21 @@ +# Minimal Xvfb + x11vnc server, no authentication. +FROM debian:bookworm-slim + +RUN apt-get update && apt-get install -y --no-install-recommends \ + xvfb \ + x11vnc \ + x11-apps \ + xauth \ + netcat-openbsd \ + procps \ + && rm -rf /var/lib/apt/lists/* + +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +EXPOSE 5900 + +HEALTHCHECK --interval=2s --timeout=2s --start-period=5s --retries=30 \ + CMD nc -z 127.0.0.1 5900 + +ENTRYPOINT ["/entrypoint.sh"] diff --git a/tests/servers/x11vnc/entrypoint.sh b/tests/servers/x11vnc/entrypoint.sh new file mode 100755 index 0000000..f784e7d --- /dev/null +++ b/tests/servers/x11vnc/entrypoint.sh @@ -0,0 +1,18 @@ +#!/bin/sh +# Start a virtual X display (Xvfb) with x11vnc serving it, no authentication. +# Draw a trivial X client on it so captures aren't pure black. +set -e + +Xvfb :0 -screen 0 1024x768x24 & +XVFB_PID=$! +trap 'kill -TERM "$XVFB_PID" 2>/dev/null; exit 0' TERM INT + +export DISPLAY=:0 +for _ in $(seq 1 30); do + xdpyinfo >/dev/null 2>&1 && break + sleep 0.5 +done + +xlogo -geometry 200x200+50+50 & + +exec x11vnc -display :0 -forever -shared -nopw -rfbport 5900 -quiet From bbfd1889c773b533a8ee2b7839478c3619920218 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 18:14:17 +0000 Subject: [PATCH 2/7] Fix screenshot-capture step hanging in CI The inline artifact-capture script never called vncdotool.api.shutdown(), so the background Twisted reactor thread api.connect() starts kept the step's python process alive indefinitely after the loop finished. Also cap the step at 3 minutes so a similar regression fails fast instead of eating the whole job timeout. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01STWkReabCK9sJW2qQ45brM --- .github/workflows/spike-fleet.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/spike-fleet.yml b/.github/workflows/spike-fleet.yml index 0bf0229..bbe27d9 100644 --- a/.github/workflows/spike-fleet.yml +++ b/.github/workflows/spike-fleet.yml @@ -53,6 +53,7 @@ jobs: - name: Capture fleet screenshots for the artifact upload if: always() + timeout-minutes: 3 run: | python - <<'PY' import socket @@ -78,6 +79,11 @@ jobs: print(f"captured {dest}") except Exception as exc: # noqa: BLE001 - best effort artifact capture print(f"failed to capture {server.name}: {exc}") + + # api.connect() starts a background Twisted reactor thread that + # outlives any individual client connection -- without stopping it + # here this script (and the job step) hangs on exit. + api.shutdown() PY - name: Dump fleet container logs From 61227633794f8bd81df5d00fd1e0c9e2abff1f51 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 21:46:25 +0000 Subject: [PATCH 3/7] Keep test server screenshots and drop the fleet naming Address review on the server spike: - rename "fleet" throughout: spike-servers.yml workflow, servers.mk, tests/functional/test_servers.py, make test-servers - replace the inline heredoc python in the workflow with tests/functional/capture_screenshots.py Screenshots are now easy to look at rather than thrown away in a tempfile: tests write them to tests/servers/screenshots (override with VNCDOTOOL_SCREENSHOT_DIR), the capture script builds a self-contained index.html gallery of every server, and in CI it writes a summary table to the job summary so the run page shows what was captured without downloading the artifact. `make screenshots` does the same locally. Also make the per-server test say what it proves: the capture must match the size the server serves and must not be a single flat colour, so a handshake that yields no framebuffer content fails instead of passing on PNG magic alone. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01YJeE1y3aj3j6L7ByKEtaZj --- .github/workflows/spike-fleet.yml | 103 ---------------- .github/workflows/spike-servers.yml | 72 +++++++++++ .gitignore | 1 + Makefile | 5 +- docs/server-compatibility-plan.md | 4 +- tests/functional/capture_screenshots.py | 157 ++++++++++++++++++++++++ tests/functional/test_fleet.py | 99 --------------- tests/functional/test_servers.py | 152 +++++++++++++++++++++++ tests/servers/docker-compose.yml | 6 +- tests/servers/fleet.mk | 15 --- tests/servers/servers.mk | 23 ++++ 11 files changed, 414 insertions(+), 223 deletions(-) delete mode 100644 .github/workflows/spike-fleet.yml create mode 100644 .github/workflows/spike-servers.yml create mode 100644 tests/functional/capture_screenshots.py delete mode 100644 tests/functional/test_fleet.py create mode 100644 tests/functional/test_servers.py delete mode 100644 tests/servers/fleet.mk create mode 100644 tests/servers/servers.mk diff --git a/.github/workflows/spike-fleet.yml b/.github/workflows/spike-fleet.yml deleted file mode 100644 index bbe27d9..0000000 --- a/.github/workflows/spike-fleet.yml +++ /dev/null @@ -1,103 +0,0 @@ -name: Spike - VNC server fleet - -on: - push: - branches: - - claude/spike-server-fleet - workflow_dispatch: - -permissions: - contents: read - -env: - PIP_DISABLE_PIP_VERSION_CHECK: '1' - PIP_NO_PYTHON_VERSION_WARNING: '1' - -defaults: - run: - shell: bash - -jobs: - fleet: - name: Fleet functional tests - runs-on: ubuntu-latest - timeout-minutes: 20 - steps: - - name: Check out source - uses: actions/checkout@v4 - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: '3.13' - cache: pip - cache-dependency-path: requirements-dev.txt - - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -r requirements-dev.txt - - - name: Build and start the VNC server fleet - run: | - docker compose -f tests/servers/docker-compose.yml up -d --build --wait - - - name: Show fleet status - if: always() - run: docker compose -f tests/servers/docker-compose.yml ps - - - name: Run fleet functional tests - run: | - mkdir -p fleet-screenshots - python -m unittest discover -v -s tests/functional -t . -p 'test_fleet.py' - - - name: Capture fleet screenshots for the artifact upload - if: always() - timeout-minutes: 3 - run: | - python - <<'PY' - import socket - import sys - - sys.path.insert(0, "tests/functional") - from test_fleet import FLEET_SERVERS # noqa: E402 - - from vncdotool import api # noqa: E402 - - for server in FLEET_SERVERS: - dest = f"fleet-screenshots/{server.name}.png" - try: - with socket.create_connection(("127.0.0.1", server.port), timeout=1): - pass - except OSError: - print(f"skip {server.name}: port {server.port} not open") - continue - try: - with api.connect(f"127.0.0.1::{server.port}", password=server.password) as client: - client.timeout = 5 - client.captureScreen(dest) - print(f"captured {dest}") - except Exception as exc: # noqa: BLE001 - best effort artifact capture - print(f"failed to capture {server.name}: {exc}") - - # api.connect() starts a background Twisted reactor thread that - # outlives any individual client connection -- without stopping it - # here this script (and the job step) hangs on exit. - api.shutdown() - PY - - - name: Dump fleet container logs - if: always() - run: docker compose -f tests/servers/docker-compose.yml logs - - - name: Upload fleet screenshots - if: always() - uses: actions/upload-artifact@v4 - with: - name: fleet-screenshots - path: fleet-screenshots/ - if-no-files-found: ignore - - - name: Stop the VNC server fleet - if: always() - run: docker compose -f tests/servers/docker-compose.yml down diff --git a/.github/workflows/spike-servers.yml b/.github/workflows/spike-servers.yml new file mode 100644 index 0000000..d66c6ca --- /dev/null +++ b/.github/workflows/spike-servers.yml @@ -0,0 +1,72 @@ +name: Spike - VNC test servers + +on: + push: + branches: + - claude/spike-server-fleet + - claude/docker-vnc-screenshot-access-5zpj5f + workflow_dispatch: + +permissions: + contents: read + +env: + PIP_DISABLE_PIP_VERSION_CHECK: '1' + PIP_NO_PYTHON_VERSION_WARNING: '1' + VNCDOTOOL_SCREENSHOT_DIR: screenshots + +defaults: + run: + shell: bash + +jobs: + servers: + name: Test server functional tests + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Check out source + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.13' + cache: pip + cache-dependency-path: requirements-dev.txt + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements-dev.txt + + - name: Build and start the VNC test servers + run: docker compose -f tests/servers/docker-compose.yml up -d --build --wait + + - name: Show test server status + if: always() + run: docker compose -f tests/servers/docker-compose.yml ps + + - name: Run test server functional tests + run: python -m unittest discover -v -s tests/functional -t . -p 'test_servers.py' + + - name: Capture screenshots and build the gallery + if: always() + timeout-minutes: 3 + run: python tests/functional/capture_screenshots.py + + - name: Dump test server container logs + if: always() + run: docker compose -f tests/servers/docker-compose.yml logs + + - name: Upload screenshots + if: always() + uses: actions/upload-artifact@v4 + with: + name: screenshots + path: screenshots/ + if-no-files-found: ignore + + - name: Stop the VNC test servers + if: always() + run: docker compose -f tests/servers/docker-compose.yml down diff --git a/.gitignore b/.gitignore index 9661fd4..bd00c6a 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ docs/_build *~ #* +tests/servers/screenshots/ diff --git a/Makefile b/Makefile index ec3707d..350dfb8 100644 --- a/Makefile +++ b/Makefile @@ -7,6 +7,9 @@ REQUIREMENTS_TXT?=requirements-dev.txt help: @echo "test: run unit tests" @echo "test-func: run functional tests" + @echo "servers-up: start the docker VNC test servers" + @echo "test-servers: run functional tests against the VNC test servers" + @echo "screenshots: screenshot each running VNC test server into a gallery" @echo "docs: build documentation" @echo "release: tag and push current version to trigger PyPI release" @@ -41,6 +44,6 @@ include libvncserver.mk test-func: libvnc-examples test-libvnc -include tests/servers/fleet.mk +include tests/servers/servers.mk include Makefile.venv diff --git a/docs/server-compatibility-plan.md b/docs/server-compatibility-plan.md index 498f175..aaa6ccc 100644 --- a/docs/server-compatibility-plan.md +++ b/docs/server-compatibility-plan.md @@ -322,8 +322,8 @@ contributing a fingerprint must be a paved road: `bbfd188`) holds a working proof: three in-repo Dockerfile-based services (`tigervnc` no-auth, `tigervnc-auth` VNC-password, `x11vnc` over Xvfb) defined in `tests/servers/docker-compose.yml` with `nc`-based -healthchecks, `make servers-up`/`servers-down`/`test-fleet` wrappers, and -a parameterized `tests/functional/test_fleet.py` that connects, types, +healthchecks, `make servers-up`/`servers-down`/`test-servers` wrappers, and +a parameterized `tests/functional/test_servers.py` that connects, types, and captures against each server. GitHub Actions run [31729730724](https://github.com/sibson/vncdotool/actions/runs/31729730724) is green end-to-end: image builds + healthcheck-gated `up --wait` in diff --git a/tests/functional/capture_screenshots.py b/tests/functional/capture_screenshots.py new file mode 100644 index 0000000..4827711 --- /dev/null +++ b/tests/functional/capture_screenshots.py @@ -0,0 +1,157 @@ +#!/usr/bin/env python3 +"""Capture a screenshot of every running Docker Compose VNC test server. + +Screenshots land in the screenshots directory (``tests/servers/screenshots`` +by default, override with ``VNCDOTOOL_SCREENSHOT_DIR``) alongside a +self-contained ``index.html`` gallery with every capture inlined, so the +whole set can be eyeballed by opening one file -- locally with +``make screenshots``, or in CI by downloading the screenshots artifact. + +When run inside GitHub Actions a summary table is also appended to the job +summary, so the run page says which servers were captured and how big each +screen was without downloading anything. + +Servers that aren't running are skipped, and a capture failure is reported +rather than raised: this is a diagnostic aid and must not fail a build. +""" + +import base64 +import os +import sys +from pathlib import Path +from typing import List, NamedTuple, Optional + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from test_servers import ( # noqa: E402 + HOST, + VNC_SERVERS, + VNCServer, + port_open, + screenshot_dir, +) + +from vncdotool import api # noqa: E402 + +CAPTURE_TIMEOUT = 5.0 + + +class Capture(NamedTuple): + server: VNCServer + path: Optional[Path] + status: str + + +def capture(server: VNCServer, directory: Path) -> Capture: + if not port_open(HOST, server.port): + return Capture(server, None, f"skipped, nothing listening on port {server.port}") + + path = directory / f"{server.name}.png" + try: + with api.connect(f"{HOST}::{server.port}", password=server.password) as client: + client.timeout = CAPTURE_TIMEOUT + client.captureScreen(str(path)) + except Exception as exc: # noqa: BLE001 - diagnostics must not fail the build + return Capture(server, None, f"failed, {exc}") + + return Capture(server, path, "captured") + + +def describe(path: Path) -> str: + """Human readable size of a captured screenshot, e.g. ``1024x768, 12.3 KiB``.""" + kib = path.stat().st_size / 1024 + try: + from PIL import Image + + with Image.open(path) as image: + return f"{image.width}x{image.height}, {kib:.1f} KiB" + except Exception: # noqa: BLE001 - fall back to the size we can always report + return f"{kib:.1f} KiB" + + +def write_gallery(captures: List[Capture], directory: Path) -> Path: + """Write a single self-contained HTML page showing every screenshot.""" + sections = [] + for item in captures: + if item.path is None: + body = f"

{item.status}

" + else: + encoded = base64.b64encode(item.path.read_bytes()).decode("ascii") + body = ( + f"

{describe(item.path)}

" + f"{item.server.name} screenshot" + ) + sections.append( + f"

{item.server.name}" + f" port {item.server.port}

{body}
" + ) + + index = directory / "index.html" + index.write_text( + "\n" + "" + "vncdotool test server screenshots" + "" + "

vncdotool test server screenshots

" + + "".join(sections) + + "\n", + encoding="utf-8", + ) + return index + + +def write_job_summary(captures: List[Capture]) -> None: + """Append a result table to the GitHub Actions job summary, if we're in one.""" + summary = os.environ.get("GITHUB_STEP_SUMMARY") + if not summary: + return + + rows = [ + "## VNC test server screenshots", + "", + "| server | port | result |", + "| --- | --- | --- |", + ] + for item in captures: + detail = describe(item.path) if item.path else item.status + rows.append(f"| {item.server.name} | {item.server.port} | {detail} |") + rows += [ + "", + "Full size images are in the `screenshots` artifact for this run; " + "open `index.html` from it to see them all on one page.", + "", + ] + with open(summary, "a", encoding="utf-8") as handle: + handle.write("\n".join(rows)) + + +def main() -> int: + directory = screenshot_dir() + captures = [capture(server, directory) for server in VNC_SERVERS] + + # api.connect() starts a background Twisted reactor thread that outlives + # any individual client connection -- without stopping it here this + # script hangs on exit instead of finishing. + api.shutdown() + + for item in captures: + location = item.path if item.path else item.status + print(f"{item.server.name}: {location}") + + index = write_gallery(captures, directory) + write_job_summary(captures) + print(f"gallery: {index}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/functional/test_fleet.py b/tests/functional/test_fleet.py deleted file mode 100644 index d24806b..0000000 --- a/tests/functional/test_fleet.py +++ /dev/null @@ -1,99 +0,0 @@ -"""Functional tests against the Docker Compose VNC server fleet. - -See tests/servers/docker-compose.yml and tests/servers/fleet.mk. The fleet -servers are expected to already be running (e.g. via ``make servers-up``) -before this module executes; a server whose port isn't open is skipped with -a clear message rather than failing the whole run, so this module is also -safe to run outside of that make target. -""" - -import socket -import tempfile -from typing import NamedTuple, Optional -from unittest import TestCase - -from vncdotool import api - -HOST = "127.0.0.1" -CONNECT_TIMEOUT = 5.0 -PORT_PROBE_TIMEOUT = 1.0 - - -class FleetServer(NamedTuple): - name: str - port: int - password: Optional[str] - - -# (name, port, password) for each service in tests/servers/docker-compose.yml -FLEET_SERVERS = [ - FleetServer("tigervnc", 5931, None), - FleetServer("tigervnc-auth", 5932, "vncdotool"), - FleetServer("x11vnc", 5933, None), -] - -PNG_MAGIC = b"\x89PNG\r\n\x1a\n" - - -def _port_open(host: str, port: int, timeout: float = PORT_PROBE_TIMEOUT) -> bool: - try: - with socket.create_connection((host, port), timeout=timeout): - return True - except OSError: - return False - - -class _FleetServerTestMixin: - """Shared test body, parameterized per-server by _make_fleet_test_case below. - - Deliberately does NOT subclass TestCase: only the dynamically generated - per-server classes below should (otherwise `unittest discover` also - collects this shared base as its own, serverless test case). - """ - - server: FleetServer - - def setUp(self) -> None: - if not _port_open(HOST, self.server.port): - self.skipTest( - f"{self.server.name} not reachable on {HOST}:{self.server.port} -- " - "start the fleet first with `make servers-up`" - ) - - def test_connect_key_and_capture(self) -> None: - address = f"{HOST}::{self.server.port}" - with api.connect(address, password=self.server.password) as client: - client.timeout = CONNECT_TIMEOUT - client.keyPress("x") - - with tempfile.NamedTemporaryFile(prefix="fleet_", suffix=".png") as png: - client.captureScreen(png.name) - data = png.read() - - self.assertTrue(data, f"{self.server.name}: captured screenshot is empty") - self.assertEqual( - data[:8], - PNG_MAGIC, - f"{self.server.name}: captured file is not a valid PNG", - ) - - -def _make_fleet_test_case(server: FleetServer) -> type: - class_name = "TestFleet_" + server.name.replace("-", "_") - return type(class_name, (_FleetServerTestMixin, TestCase), {"server": server}) - - -# Register one TestCase subclass per fleet server so `unittest discover` -# reports pass/fail/skip separately for each server. -for _server in FLEET_SERVERS: - _test_case = _make_fleet_test_case(_server) - globals()[_test_case.__name__] = _test_case -del _server, _test_case - - -def tearDownModule() -> None: - # api.connect() starts a background Twisted reactor thread that - # outlives any individual client connection. Without stopping it here, - # the interpreter (and `unittest discover`) hangs on exit after all - # tests in this module have finished. - api.shutdown() diff --git a/tests/functional/test_servers.py b/tests/functional/test_servers.py new file mode 100644 index 0000000..2220b07 --- /dev/null +++ b/tests/functional/test_servers.py @@ -0,0 +1,152 @@ +"""Functional tests against the Docker Compose VNC test servers. + +See tests/servers/docker-compose.yml and tests/servers/servers.mk. The test +servers are expected to already be running (e.g. via ``make servers-up``) +before this module executes; a server whose port isn't open is skipped with +a clear message rather than failing the whole run, so this module is also +safe to run outside of that make target. + +Screenshots captured here are kept rather than thrown away: each one is +written to the screenshots directory (``tests/servers/screenshots`` by +default, override with ``VNCDOTOOL_SCREENSHOT_DIR``) so that a failing or +suspicious capture can be looked at directly after the run. +""" + +import os +import socket +from pathlib import Path +from typing import NamedTuple, Optional, Tuple +from unittest import TestCase + +from PIL import Image + +from vncdotool import api + +HOST = "127.0.0.1" +CONNECT_TIMEOUT = 5.0 +PORT_PROBE_TIMEOUT = 1.0 + +DEFAULT_SCREENSHOT_DIR = Path(__file__).resolve().parents[1] / "servers" / "screenshots" + + +class VNCServer(NamedTuple): + name: str + port: int + password: Optional[str] + # Screen size the server's entrypoint configures, so a capture can be + # checked against what the server said it was serving. + size: Tuple[int, int] = (1024, 768) + + +# (name, port, password) for each service in tests/servers/docker-compose.yml +VNC_SERVERS = [ + VNCServer("tigervnc", 5931, None), + VNCServer("tigervnc-auth", 5932, "vncdotool"), + VNCServer("x11vnc", 5933, None), +] + +PNG_MAGIC = b"\x89PNG\r\n\x1a\n" +# getcolors() returns None above this many distinct colours, which is itself +# proof the capture isn't a flat colour, so the cap only needs to be cheap. +MAX_COLOURS = 256 + + +def screenshot_dir() -> Path: + """Directory screenshots are written to, created if needed.""" + path = Path(os.environ.get("VNCDOTOOL_SCREENSHOT_DIR", DEFAULT_SCREENSHOT_DIR)) + path.mkdir(parents=True, exist_ok=True) + return path + + +def port_open(host: str, port: int, timeout: float = PORT_PROBE_TIMEOUT) -> bool: + try: + with socket.create_connection((host, port), timeout=timeout): + return True + except OSError: + return False + + +class _VNCServerTestMixin: + """Shared test body, parameterized per-server by _make_server_test_case below. + + Deliberately does NOT subclass TestCase: only the dynamically generated + per-server classes below should (otherwise `unittest discover` also + collects this shared base as its own, serverless test case). + """ + + server: VNCServer + + def setUp(self) -> None: + if not port_open(HOST, self.server.port): + self.skipTest( + f"{self.server.name} not reachable on {HOST}:{self.server.port} -- " + "start the servers first with `make servers-up`" + ) + + def test_connect_key_and_capture(self) -> None: + """End-to-end round trip against one real server. + + Passing means, in order: + + * the RFB handshake completed against this server's protocol version + and security type (None, or VNC password auth for tigervnc-auth); + * a key event was accepted without the server dropping the session; + * a framebuffer update was received and encoded to a PNG file; + * that PNG is the size the server's entrypoint configured, and is not + a single flat colour -- i.e. we decoded real screen content + (the entrypoints run xlogo for exactly this reason) rather than + the all-black framebuffer you get when updates never arrive. + """ + address = f"{HOST}::{self.server.port}" + png = screenshot_dir() / f"{self.server.name}.png" + + with api.connect(address, password=self.server.password) as client: + client.timeout = CONNECT_TIMEOUT + client.keyPress("x") + client.captureScreen(str(png)) + + data = png.read_bytes() + print(f"{self.server.name}: screenshot written to {png}") + + self.assertTrue(data, f"{self.server.name}: captured screenshot is empty") + self.assertEqual( + data[:8], + PNG_MAGIC, + f"{self.server.name}: captured file is not a valid PNG", + ) + + with Image.open(png) as image: + self.assertEqual( + image.size, + self.server.size, + f"{self.server.name}: capture is not the size the server serves", + ) + colours = image.convert("RGB").getcolors(maxcolors=MAX_COLOURS) + + self.assertNotEqual( + colours if colours is None else len(colours), + 1, + f"{self.server.name}: capture is a single flat colour, " + "no screen content was decoded", + ) + + +def _make_server_test_case(server: VNCServer) -> type: + class_name = "TestServer_" + server.name.replace("-", "_") + return type(class_name, (_VNCServerTestMixin, TestCase), {"server": server}) + + +# Register one TestCase subclass per test server so `unittest discover` +# reports pass/fail/skip separately for each server. +for _server in VNC_SERVERS: + _test_case = _make_server_test_case(_server) + globals()[_test_case.__name__] = _test_case +del _server, _test_case + + +def tearDownModule() -> None: + # api.connect() starts a background Twisted reactor thread that + # outlives any individual client connection. Without stopping it here, + # the interpreter (and `unittest discover`) hangs on exit after all + # tests in this module have finished. + api.shutdown() diff --git a/tests/servers/docker-compose.yml b/tests/servers/docker-compose.yml index 39ce1ef..6c32a65 100644 --- a/tests/servers/docker-compose.yml +++ b/tests/servers/docker-compose.yml @@ -1,6 +1,6 @@ -# Docker Compose fleet of small, version-controlled Linux VNC servers used -# as a compatibility test bed for vncdotool. See tests/servers/fleet.mk for -# the make targets that drive this file and tests/functional/test_fleet.py +# Docker Compose set of small, version-controlled Linux VNC servers used +# as a compatibility test bed for vncdotool. See tests/servers/servers.mk for +# the make targets that drive this file and tests/functional/test_servers.py # for the tests that exercise it. # # Each service is built from an in-repo Dockerfile (FROM debian:bookworm-slim) diff --git a/tests/servers/fleet.mk b/tests/servers/fleet.mk deleted file mode 100644 index 4e1a7b3..0000000 --- a/tests/servers/fleet.mk +++ /dev/null @@ -1,15 +0,0 @@ -FLEET_COMPOSE?=tests/servers/docker-compose.yml -DOCKER_COMPOSE?=docker compose -PYTHON?=python3 - -.PHONY: servers-up -servers-up: - $(DOCKER_COMPOSE) -f $(FLEET_COMPOSE) up -d --build --wait - -.PHONY: servers-down -servers-down: - $(DOCKER_COMPOSE) -f $(FLEET_COMPOSE) down - -.PHONY: test-fleet -test-fleet: - $(PYTHON) -m unittest discover $(UNITTEST_ARGS) -s tests/functional -t . -p 'test_fleet.py' diff --git a/tests/servers/servers.mk b/tests/servers/servers.mk new file mode 100644 index 0000000..ee83fdc --- /dev/null +++ b/tests/servers/servers.mk @@ -0,0 +1,23 @@ +SERVERS_COMPOSE?=tests/servers/docker-compose.yml +DOCKER_COMPOSE?=docker compose +PYTHON?=python3 +SCREENSHOT_DIR?=tests/servers/screenshots + +.PHONY: servers-up +servers-up: + $(DOCKER_COMPOSE) -f $(SERVERS_COMPOSE) up -d --build --wait + +.PHONY: servers-down +servers-down: + $(DOCKER_COMPOSE) -f $(SERVERS_COMPOSE) down + +.PHONY: test-servers +test-servers: + $(PYTHON) -m unittest discover $(UNITTEST_ARGS) -s tests/functional -t . -p 'test_servers.py' + +# Screenshot every running test server into $(SCREENSHOT_DIR), including an +# index.html gallery of them all, for eyeballing what the servers render. +.PHONY: screenshots +screenshots: + VNCDOTOOL_SCREENSHOT_DIR=$(SCREENSHOT_DIR) $(PYTHON) tests/functional/capture_screenshots.py + @echo "open $(SCREENSHOT_DIR)/index.html" From 72eec571d87fe0137cec45a8f2531f2717bdd3a3 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 21:50:33 +0000 Subject: [PATCH 4/7] Fix capture_screenshots.py import outside an installed checkout Running it as a script puts tests/functional on sys.path but not the repo root, so the CI step failed with ModuleNotFoundError: vncdotool -- CI installs requirements-dev.txt, not the package itself. Put the repo root on sys.path too. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01YJeE1y3aj3j6L7ByKEtaZj --- tests/functional/capture_screenshots.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/functional/capture_screenshots.py b/tests/functional/capture_screenshots.py index 4827711..dc0423c 100644 --- a/tests/functional/capture_screenshots.py +++ b/tests/functional/capture_screenshots.py @@ -21,7 +21,10 @@ from pathlib import Path from typing import List, NamedTuple, Optional -sys.path.insert(0, str(Path(__file__).resolve().parent)) +_HERE = Path(__file__).resolve().parent +# This module's own directory, for test_servers, plus the repo root, so the +# script works from a checkout without vncdotool having been pip installed. +sys.path[:0] = [str(_HERE), str(_HERE.parents[1])] from test_servers import ( # noqa: E402 HOST, From d5de4a759be04f9b861f745b9d9dc2bc7732f825 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 22:08:13 +0000 Subject: [PATCH 5/7] Note the GitHub Pages screenshot gallery follow-up Screenshots are only reachable from the web as a zipped artifact today. Record publishing the generated gallery to Pages as the Tier 1 follow-up, with the constraints found while looking at it (no artifact preview, data: URIs stripped from job summaries) and the orphan-branch interim option. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01YJeE1y3aj3j6L7ByKEtaZj --- docs/server-compatibility-plan.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/docs/server-compatibility-plan.md b/docs/server-compatibility-plan.md index aaa6ccc..189802d 100644 --- a/docs/server-compatibility-plan.md +++ b/docs/server-compatibility-plan.md @@ -346,6 +346,23 @@ Findings worth keeping: - Images stay small and layer-cached (~15–20s builds); healthchecks make `up --wait` a reliable barrier. +Tier 1 follow-ups: +- **Publish the screenshot gallery to GitHub Pages.** Captures currently + reach the web only as a zipped artifact: the job summary carries a + server/port/resolution table, but seeing the pixels means download → + unzip → open `index.html`. Actions has no inline preview for artifact + contents, and inlining the PNGs as `data:` URIs doesn't help — the + markdown sanitizer strips them. Deploying the generated gallery to + Pages gives a stable URL to link from the job summary (and outlives + artifact expiry, which matters once fixtures reference these images). + Needs Pages enabled on the repo, plus a decision on whether runs + overwrite one `latest/` gallery or are namespaced by run ID. Interim + option if Pages is unwanted: push captures to an orphan branch and have + the workflow post/update a PR comment with `raw.githubusercontent.com` + image links, so they render inline where review happens. +- Pin base images by digest and fold this workflow into the main CI one. +- Deepen what the per-server scenario actually asserts (see Phase 0). + **Tier 2 — VIABLE on both OSes**, proven on branch `claude/spike-os-servers` (commit `2de3252`, workflow `spike-os-servers.yml`); final run From ae0ed44551298b8cebd52f14f9756c26e83713c7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 22:37:14 +0000 Subject: [PATCH 6/7] Collapse the test server images into one layered Dockerfile Address review: - one multi-stage tests/servers/Dockerfile: a shared base stage (X client, healthcheck) with a stage per server, selected by compose `target:`, instead of three near-identical Dockerfiles - one TigerVNC entrypoint and image for both the no-auth and password services; VNC_PASSWORD unset means SecurityTypes None, set means VncAuth. The tigervnc-auth image is gone - shared draw-content.sh for the wait-for-X-then-draw logic both servers had - drop netcat: the healthcheck uses bash's /dev/tcp for the same probe - explain in the workflow why the diagnostic steps carry `if: always()` - Makefile help was missing servers-down Also record the CI image build tax (empty Docker cache per run, ~35s) as a plan follow-up with the gha-cache/GHCR options, per review. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01YJeE1y3aj3j6L7ByKEtaZj --- .github/workflows/spike-servers.yml | 6 ++ Makefile | 1 + docs/server-compatibility-plan.md | 6 ++ ep.sh | 34 +++++++++++ tests/servers/Dockerfile | 61 +++++++++++++++++++ tests/servers/docker-compose.yml | 45 ++++++-------- tests/servers/draw-content.sh | 15 +++++ tests/servers/tigervnc-auth/Dockerfile | 28 --------- tests/servers/tigervnc-auth/entrypoint.sh | 31 ---------- tests/servers/tigervnc-entrypoint.sh | 34 +++++++++++ tests/servers/tigervnc/Dockerfile | 25 -------- tests/servers/tigervnc/entrypoint.sh | 25 -------- .../entrypoint.sh => x11vnc-entrypoint.sh} | 9 +-- tests/servers/x11vnc/Dockerfile | 21 ------- 14 files changed, 178 insertions(+), 163 deletions(-) create mode 100644 ep.sh create mode 100644 tests/servers/Dockerfile create mode 100644 tests/servers/draw-content.sh delete mode 100644 tests/servers/tigervnc-auth/Dockerfile delete mode 100755 tests/servers/tigervnc-auth/entrypoint.sh create mode 100644 tests/servers/tigervnc-entrypoint.sh delete mode 100644 tests/servers/tigervnc/Dockerfile delete mode 100755 tests/servers/tigervnc/entrypoint.sh rename tests/servers/{x11vnc/entrypoint.sh => x11vnc-entrypoint.sh} (57%) mode change 100755 => 100644 delete mode 100644 tests/servers/x11vnc/Dockerfile diff --git a/.github/workflows/spike-servers.yml b/.github/workflows/spike-servers.yml index d66c6ca..5afcde3 100644 --- a/.github/workflows/spike-servers.yml +++ b/.github/workflows/spike-servers.yml @@ -43,6 +43,12 @@ jobs: - name: Build and start the VNC test servers run: docker compose -f tests/servers/docker-compose.yml up -d --build --wait + # The steps below are diagnostics and teardown, so they carry + # `if: always()`: when a test fails, the container status, the + # screenshot of what the server was actually showing, and the server + # logs are exactly what's needed to debug it -- skipping them on + # failure would throw away the evidence. `down` likewise has to run + # whatever happened. - name: Show test server status if: always() run: docker compose -f tests/servers/docker-compose.yml ps diff --git a/Makefile b/Makefile index 350dfb8..2dfa6f9 100644 --- a/Makefile +++ b/Makefile @@ -8,6 +8,7 @@ help: @echo "test: run unit tests" @echo "test-func: run functional tests" @echo "servers-up: start the docker VNC test servers" + @echo "servers-down: stop the docker VNC test servers" @echo "test-servers: run functional tests against the VNC test servers" @echo "screenshots: screenshot each running VNC test server into a gallery" @echo "docs: build documentation" diff --git a/docs/server-compatibility-plan.md b/docs/server-compatibility-plan.md index 189802d..7d0a447 100644 --- a/docs/server-compatibility-plan.md +++ b/docs/server-compatibility-plan.md @@ -360,6 +360,12 @@ Tier 1 follow-ups: option if Pages is unwanted: push captures to an orphan branch and have the workflow post/update a PR comment with `raw.githubusercontent.com` image links, so they render inline where review happens. +- **Stop paying the image build tax on every run.** GitHub-hosted runners + start with an empty Docker cache, so layer caching only helps within a + run — each CI run rebuilds from scratch (~35s of the ~70s total). + Options: `docker/build-push-action` with `cache-from/to: type=gha`, or + publish the images to GHCR once and have CI pull pinned digests, which + folds into the digest-pinning item below. - Pin base images by digest and fold this workflow into the main CI one. - Deepen what the per-server scenario actually asserts (see Phase 0). diff --git a/ep.sh b/ep.sh new file mode 100644 index 0000000..e549e6b --- /dev/null +++ b/ep.sh @@ -0,0 +1,34 @@ +#!/bin/sh +# Start a TigerVNC (Xvnc) server, with or without authentication. +# +# VNC_PASSWORD unset or empty -> SecurityTypes None. +# VNC_PASSWORD set -> classic VNC password auth, with the +# password written at start-up by vncpasswd +# so it is never baked into the image. +# That is the only difference between the two TigerVNC services in +# docker-compose.yml, so they share this entrypoint and the image. +set -e + + + +if [ -n "$VNC_PASSWORD" ]; then + mkdir -p /root/.vnc + printf '%s' "$VNC_PASSWORD" | vncpasswd -f > /root/.vnc/passwd + chmod 600 /root/.vnc/passwd + set -- -SecurityTypes VncAuth -PasswordFile /root/.vnc/passwd +else + set -- -SecurityTypes None +fi + +echo Xvnc :0 \ + "$@" \ + -rfbport 5900 \ + -geometry 1024x768 \ + -depth 24 \ + -AlwaysShared \ + -localhost=0 + + + + + diff --git a/tests/servers/Dockerfile b/tests/servers/Dockerfile new file mode 100644 index 0000000..a9ef02f --- /dev/null +++ b/tests/servers/Dockerfile @@ -0,0 +1,61 @@ +# Every VNC test server image, as stages off one shared base. +# +# Deliberately built from a small in-repo Dockerfile rather than a +# third-party desktop image so the exact server version is controlled by +# apt in the base distro rather than by whatever a random upstream image +# happens to bundle. +# +# docker-compose.yml selects a stage per service with `target:`. The base +# stage holds everything the servers have in common -- an X client to draw +# so captures aren't pure black, and the readiness probe -- so adding a +# server means one stage, not another near-copy of this file. + +FROM debian:bookworm-slim AS base + +RUN apt-get update && apt-get install -y --no-install-recommends \ + x11-apps \ + xauth \ + procps \ + && rm -rf /var/lib/apt/lists/* + +COPY draw-content.sh /draw-content.sh +RUN chmod +x /draw-content.sh + +EXPOSE 5900 + +# bash's /dev/tcp rather than `nc`: same "is the RFB port accepting +# connections" probe without pulling netcat into every image. +HEALTHCHECK --interval=2s --timeout=2s --start-period=5s --retries=30 \ + CMD bash -c 'exec 3<>/dev/tcp/127.0.0.1/5900' + + +# TigerVNC's own X server. Serves with no authentication by default, or +# with classic VNC password auth when VNC_PASSWORD is set -- see +# tigervnc-entrypoint.sh. tigervnc-tools is where Debian keeps vncpasswd. +FROM base AS tigervnc + +RUN apt-get update && apt-get install -y --no-install-recommends \ + tigervnc-standalone-server \ + tigervnc-common \ + tigervnc-tools \ + && rm -rf /var/lib/apt/lists/* + +COPY tigervnc-entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +ENTRYPOINT ["/entrypoint.sh"] + + +# x11vnc exporting a plain Xvfb display -- a different shape from TigerVNC +# (a VNC server bolted onto an existing X server) and so worth covering. +FROM base AS x11vnc + +RUN apt-get update && apt-get install -y --no-install-recommends \ + xvfb \ + x11vnc \ + && rm -rf /var/lib/apt/lists/* + +COPY x11vnc-entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +ENTRYPOINT ["/entrypoint.sh"] diff --git a/tests/servers/docker-compose.yml b/tests/servers/docker-compose.yml index 6c32a65..581ea6d 100644 --- a/tests/servers/docker-compose.yml +++ b/tests/servers/docker-compose.yml @@ -3,43 +3,38 @@ # the make targets that drive this file and tests/functional/test_servers.py # for the tests that exercise it. # -# Each service is built from an in-repo Dockerfile (FROM debian:bookworm-slim) -# rather than a third-party desktop image, so server versions are pinned by -# the base image + apt rather than by whatever an upstream image happens to -# bundle. Every service publishes its RFB port to localhost only. +# Every service is a stage of the shared tests/servers/Dockerfile, so the +# images differ only by the server they install. The two TigerVNC services +# are the same image twice, distinguished purely by VNC_PASSWORD -- naming +# the built image keeps that a single build rather than two. +# +# Readiness is the HEALTHCHECK baked into the image, which makes +# `up --wait` a reliable barrier. Every service publishes its RFB port to +# localhost only. services: tigervnc: - build: ./tigervnc + build: + context: . + target: tigervnc + image: vncdotool-test-tigervnc ports: - "127.0.0.1:5931:5900" - healthcheck: - test: ["CMD", "nc", "-z", "127.0.0.1", "5900"] - interval: 2s - timeout: 2s - retries: 30 - start_period: 5s tigervnc-auth: - build: ./tigervnc-auth + build: + context: . + target: tigervnc + image: vncdotool-test-tigervnc environment: VNC_PASSWORD: vncdotool ports: - "127.0.0.1:5932:5900" - healthcheck: - test: ["CMD", "nc", "-z", "127.0.0.1", "5900"] - interval: 2s - timeout: 2s - retries: 30 - start_period: 5s x11vnc: - build: ./x11vnc + build: + context: . + target: x11vnc + image: vncdotool-test-x11vnc ports: - "127.0.0.1:5933:5900" - healthcheck: - test: ["CMD", "nc", "-z", "127.0.0.1", "5900"] - interval: 2s - timeout: 2s - retries: 30 - start_period: 5s diff --git a/tests/servers/draw-content.sh b/tests/servers/draw-content.sh new file mode 100644 index 0000000..e1d16a5 --- /dev/null +++ b/tests/servers/draw-content.sh @@ -0,0 +1,15 @@ +#!/bin/sh +# Wait for the X display to accept connections, then draw a trivial X +# client on it. Without a client the framebuffer is pure black, and a +# black capture is indistinguishable from "we never received an update" +# -- tests/functional/test_servers.py asserts captures aren't flat. +set -e + +export DISPLAY="${DISPLAY:-:0}" + +for _ in $(seq 1 30); do + xdpyinfo >/dev/null 2>&1 && break + sleep 0.5 +done + +exec xlogo -geometry 200x200+50+50 diff --git a/tests/servers/tigervnc-auth/Dockerfile b/tests/servers/tigervnc-auth/Dockerfile deleted file mode 100644 index 96fc160..0000000 --- a/tests/servers/tigervnc-auth/Dockerfile +++ /dev/null @@ -1,28 +0,0 @@ -# Minimal TigerVNC (Xvnc) server with VNC password authentication. -# -# The password is set at container start via the VNC_PASSWORD environment -# variable (default "vncdotool") using the standard vncpasswd tool, so it -# is never baked into the image. -FROM debian:bookworm-slim - -RUN apt-get update && apt-get install -y --no-install-recommends \ - tigervnc-standalone-server \ - tigervnc-common \ - tigervnc-tools \ - x11-apps \ - xauth \ - netcat-openbsd \ - procps \ - && rm -rf /var/lib/apt/lists/* - -COPY entrypoint.sh /entrypoint.sh -RUN chmod +x /entrypoint.sh - -ENV VNC_PASSWORD=vncdotool - -EXPOSE 5900 - -HEALTHCHECK --interval=2s --timeout=2s --start-period=5s --retries=30 \ - CMD nc -z 127.0.0.1 5900 - -ENTRYPOINT ["/entrypoint.sh"] diff --git a/tests/servers/tigervnc-auth/entrypoint.sh b/tests/servers/tigervnc-auth/entrypoint.sh deleted file mode 100755 index c2b3b35..0000000 --- a/tests/servers/tigervnc-auth/entrypoint.sh +++ /dev/null @@ -1,31 +0,0 @@ -#!/bin/sh -# Start a TigerVNC (Xvnc) server with classic VNC password authentication -# and draw a trivial X client on it so captures aren't pure black. -set -e - -trap 'kill -TERM "$XVNC_PID" 2>/dev/null; exit 0' TERM INT - -VNC_PASSWORD="${VNC_PASSWORD:-vncdotool}" -mkdir -p /root/.vnc -printf '%s' "$VNC_PASSWORD" | vncpasswd -f > /root/.vnc/passwd -chmod 600 /root/.vnc/passwd - -Xvnc :0 \ - -SecurityTypes VncAuth \ - -PasswordFile /root/.vnc/passwd \ - -rfbport 5900 \ - -geometry 1024x768 \ - -depth 24 \ - -AlwaysShared \ - -localhost=0 & -XVNC_PID=$! - -export DISPLAY=:0 -for _ in $(seq 1 30); do - xdpyinfo >/dev/null 2>&1 && break - sleep 0.5 -done - -xlogo -geometry 200x200+50+50 & - -wait "$XVNC_PID" diff --git a/tests/servers/tigervnc-entrypoint.sh b/tests/servers/tigervnc-entrypoint.sh new file mode 100644 index 0000000..c81ebd6 --- /dev/null +++ b/tests/servers/tigervnc-entrypoint.sh @@ -0,0 +1,34 @@ +#!/bin/sh +# Start a TigerVNC (Xvnc) server, with or without authentication. +# +# VNC_PASSWORD unset or empty -> SecurityTypes None. +# VNC_PASSWORD set -> classic VNC password auth, with the +# password written at start-up by vncpasswd +# so it is never baked into the image. +# That is the only difference between the two TigerVNC services in +# docker-compose.yml, so they share this entrypoint and the image. +set -e + +trap 'kill -TERM "$XVNC_PID" 2>/dev/null; exit 0' TERM INT + +if [ -n "$VNC_PASSWORD" ]; then + mkdir -p /root/.vnc + printf '%s' "$VNC_PASSWORD" | vncpasswd -f > /root/.vnc/passwd + chmod 600 /root/.vnc/passwd + set -- -SecurityTypes VncAuth -PasswordFile /root/.vnc/passwd +else + set -- -SecurityTypes None +fi + +Xvnc :0 \ + "$@" \ + -rfbport 5900 \ + -geometry 1024x768 \ + -depth 24 \ + -AlwaysShared \ + -localhost=0 & +XVNC_PID=$! + +DISPLAY=:0 /draw-content.sh & + +wait "$XVNC_PID" diff --git a/tests/servers/tigervnc/Dockerfile b/tests/servers/tigervnc/Dockerfile deleted file mode 100644 index 5e160f3..0000000 --- a/tests/servers/tigervnc/Dockerfile +++ /dev/null @@ -1,25 +0,0 @@ -# Minimal TigerVNC (Xvnc) server, no authentication. -# -# Deliberately built from a small in-repo Dockerfile rather than a -# third-party desktop image so the exact server version is controlled by -# apt in the base distro rather than by whatever a random upstream image -# happens to bundle. -FROM debian:bookworm-slim - -RUN apt-get update && apt-get install -y --no-install-recommends \ - tigervnc-standalone-server \ - x11-apps \ - xauth \ - netcat-openbsd \ - procps \ - && rm -rf /var/lib/apt/lists/* - -COPY entrypoint.sh /entrypoint.sh -RUN chmod +x /entrypoint.sh - -EXPOSE 5900 - -HEALTHCHECK --interval=2s --timeout=2s --start-period=5s --retries=30 \ - CMD nc -z 127.0.0.1 5900 - -ENTRYPOINT ["/entrypoint.sh"] diff --git a/tests/servers/tigervnc/entrypoint.sh b/tests/servers/tigervnc/entrypoint.sh deleted file mode 100755 index 35033c5..0000000 --- a/tests/servers/tigervnc/entrypoint.sh +++ /dev/null @@ -1,25 +0,0 @@ -#!/bin/sh -# Start a bare TigerVNC (Xvnc) server with no authentication and draw a -# trivial X client on it so captures aren't pure black. -set -e - -trap 'kill -TERM "$XVNC_PID" 2>/dev/null; exit 0' TERM INT - -Xvnc :0 \ - -SecurityTypes None \ - -rfbport 5900 \ - -geometry 1024x768 \ - -depth 24 \ - -AlwaysShared \ - -localhost=0 & -XVNC_PID=$! - -export DISPLAY=:0 -for _ in $(seq 1 30); do - xdpyinfo >/dev/null 2>&1 && break - sleep 0.5 -done - -xlogo -geometry 200x200+50+50 & - -wait "$XVNC_PID" diff --git a/tests/servers/x11vnc/entrypoint.sh b/tests/servers/x11vnc-entrypoint.sh old mode 100755 new mode 100644 similarity index 57% rename from tests/servers/x11vnc/entrypoint.sh rename to tests/servers/x11vnc-entrypoint.sh index f784e7d..4792914 --- a/tests/servers/x11vnc/entrypoint.sh +++ b/tests/servers/x11vnc-entrypoint.sh @@ -1,18 +1,11 @@ #!/bin/sh # Start a virtual X display (Xvfb) with x11vnc serving it, no authentication. -# Draw a trivial X client on it so captures aren't pure black. set -e Xvfb :0 -screen 0 1024x768x24 & XVFB_PID=$! trap 'kill -TERM "$XVFB_PID" 2>/dev/null; exit 0' TERM INT -export DISPLAY=:0 -for _ in $(seq 1 30); do - xdpyinfo >/dev/null 2>&1 && break - sleep 0.5 -done - -xlogo -geometry 200x200+50+50 & +DISPLAY=:0 /draw-content.sh & exec x11vnc -display :0 -forever -shared -nopw -rfbport 5900 -quiet diff --git a/tests/servers/x11vnc/Dockerfile b/tests/servers/x11vnc/Dockerfile deleted file mode 100644 index 69d3f7f..0000000 --- a/tests/servers/x11vnc/Dockerfile +++ /dev/null @@ -1,21 +0,0 @@ -# Minimal Xvfb + x11vnc server, no authentication. -FROM debian:bookworm-slim - -RUN apt-get update && apt-get install -y --no-install-recommends \ - xvfb \ - x11vnc \ - x11-apps \ - xauth \ - netcat-openbsd \ - procps \ - && rm -rf /var/lib/apt/lists/* - -COPY entrypoint.sh /entrypoint.sh -RUN chmod +x /entrypoint.sh - -EXPOSE 5900 - -HEALTHCHECK --interval=2s --timeout=2s --start-period=5s --retries=30 \ - CMD nc -z 127.0.0.1 5900 - -ENTRYPOINT ["/entrypoint.sh"] From 578699dc116faa22930b4985fddc42ed0253956f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 22:41:44 +0000 Subject: [PATCH 7/7] Gate test server readiness on drawn content, not just an open port Test server functional tests was failing on tigervnc and tigervnc-auth with "capture is a single flat colour": the image HEALTHCHECK (and therefore `docker compose up --wait`) only probed that the RFB TCP port accepted a connection. For Xvnc that port opens the instant the process starts, well before the backgrounded draw-content.sh has waited for the display and mapped its xlogo window, so the compose wait can return before there is anything on screen. x11vnc happened to be slow enough starting up (Xvfb, then attaching x11vnc to it) that xlogo usually won the race, masking the same bug there. draw-content.sh now waits for the xlogo window to actually be mapped (via xwininfo) before touching a /tmp/draw-content-ready marker, and the Dockerfile HEALTHCHECK requires that marker in addition to the open port. This ties container health to real screen content for all three servers instead of relying on incidental startup-time differences. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01YJeE1y3aj3j6L7ByKEtaZj --- tests/servers/Dockerfile | 10 ++++++++-- tests/servers/draw-content.sh | 31 ++++++++++++++++++++++++++----- 2 files changed, 34 insertions(+), 7 deletions(-) mode change 100644 => 100755 tests/servers/draw-content.sh diff --git a/tests/servers/Dockerfile b/tests/servers/Dockerfile index a9ef02f..5318518 100644 --- a/tests/servers/Dockerfile +++ b/tests/servers/Dockerfile @@ -24,9 +24,15 @@ RUN chmod +x /draw-content.sh EXPOSE 5900 # bash's /dev/tcp rather than `nc`: same "is the RFB port accepting -# connections" probe without pulling netcat into every image. +# connections" probe without pulling netcat into every image. That alone +# isn't sufficient readiness, though -- Xvnc in particular starts +# accepting connections the instant it launches, before draw-content.sh's +# backgrounded xlogo has drawn anything, so a port-only check can report +# healthy against a still-flat framebuffer. draw-content.sh only creates +# /tmp/draw-content-ready once xlogo's window is actually mapped, so the +# check also requires that file to exist. HEALTHCHECK --interval=2s --timeout=2s --start-period=5s --retries=30 \ - CMD bash -c 'exec 3<>/dev/tcp/127.0.0.1/5900' + CMD bash -c 'exec 3<>/dev/tcp/127.0.0.1/5900 && test -f /tmp/draw-content-ready' # TigerVNC's own X server. Serves with no authentication by default, or diff --git a/tests/servers/draw-content.sh b/tests/servers/draw-content.sh old mode 100644 new mode 100755 index e1d16a5..124cfa3 --- a/tests/servers/draw-content.sh +++ b/tests/servers/draw-content.sh @@ -1,15 +1,36 @@ #!/bin/sh -# Wait for the X display to accept connections, then draw a trivial X -# client on it. Without a client the framebuffer is pure black, and a -# black capture is indistinguishable from "we never received an update" -# -- tests/functional/test_servers.py asserts captures aren't flat. +# Wait for the X display to accept connections, draw a trivial X client on +# it, and only then signal readiness via a marker file. Without a client +# the framebuffer is pure black, and a black capture is indistinguishable +# from "we never received an update" -- tests/functional/test_servers.py +# asserts captures aren't flat. +# +# The marker file matters because "the RFB port accepts connections" is +# not the same thing as "there is content on screen": Xvnc in particular +# starts accepting connections the instant it launches, well before this +# script's backgrounded xlogo has mapped a window, so a probe of the port +# alone lets the image report healthy while the framebuffer is still +# flat. The image HEALTHCHECK checks for this file's existence in +# addition to the port so `docker compose up --wait` only reports the +# container ready once there is actually something to capture. set -e export DISPLAY="${DISPLAY:-:0}" +READY_FILE="${DRAW_CONTENT_READY_FILE:-/tmp/draw-content-ready}" for _ in $(seq 1 30); do xdpyinfo >/dev/null 2>&1 && break sleep 0.5 done -exec xlogo -geometry 200x200+50+50 +xlogo -geometry 200x200+50+50 & +XLOGO_PID=$! + +for _ in $(seq 1 30); do + xwininfo -name xlogo >/dev/null 2>&1 && break + sleep 0.2 +done + +touch "$READY_FILE" + +wait "$XLOGO_PID"