-
Notifications
You must be signed in to change notification settings - Fork 134
Spike: Docker Compose VNC test servers for compatibility testing #334
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
81722ce
Add Docker Compose VNC server fleet spike
claude bbfd188
Fix screenshot-capture step hanging in CI
claude 46a1b8b
Merge main into server spike branch
claude 6122763
Keep test server screenshots and drop the fleet naming
claude 72eec57
Fix capture_screenshots.py import outside an installed checkout
claude d5de4a7
Note the GitHub Pages screenshot gallery follow-up
claude ae0ed44
Collapse the test server images into one layered Dockerfile
claude 578699d
Gate test server readiness on drawn content, not just an open port
claude File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,78 @@ | ||
| 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 | ||
|
|
||
| # 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 | ||
|
|
||
| - 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 | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -11,3 +11,4 @@ docs/_build | |
|
|
||
| *~ | ||
| #* | ||
| tests/servers/screenshots/ | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -7,6 +7,10 @@ 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" | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. servers-down?
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| @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" | ||
| @echo "release: tag and push current version to trigger PyPI release" | ||
|
|
||
|
|
@@ -41,4 +45,6 @@ include libvncserver.mk | |
|
|
||
| test-func: libvnc-examples test-libvnc | ||
|
|
||
| include tests/servers/servers.mk | ||
|
|
||
| include Makefile.venv | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
|
|
||
|
|
||
|
|
||
|
|
||
|
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,160 @@ | ||
| #!/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 | ||
|
|
||
| _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, | ||
| 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"<p class='missing'>{item.status}</p>" | ||
| else: | ||
| encoded = base64.b64encode(item.path.read_bytes()).decode("ascii") | ||
| body = ( | ||
| f"<p class='meta'>{describe(item.path)}</p>" | ||
| f"<img alt='{item.server.name} screenshot' " | ||
| f"src='data:image/png;base64,{encoded}'>" | ||
| ) | ||
| sections.append( | ||
| f"<section><h2>{item.server.name}" | ||
| f" <small>port {item.server.port}</small></h2>{body}</section>" | ||
| ) | ||
|
|
||
| index = directory / "index.html" | ||
| index.write_text( | ||
| "<!doctype html>\n" | ||
| "<html lang='en'><head><meta charset='utf-8'>" | ||
| "<title>vncdotool test server screenshots</title>" | ||
| "<style>" | ||
| "body{font-family:sans-serif;margin:2rem;background:#fff;color:#111}" | ||
| "section{margin-bottom:2.5rem}" | ||
| "h2{margin-bottom:.25rem}" | ||
| "small{font-weight:normal;color:#666}" | ||
| ".meta{margin:.25rem 0;color:#666}" | ||
| ".missing{color:#a00}" | ||
| "img{max-width:100%;border:1px solid #ccc}" | ||
| "</style></head><body>" | ||
| "<h1>vncdotool test server screenshots</h1>" | ||
| + "".join(sections) | ||
| + "</body></html>\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()) |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
why if always()?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Because those trailing steps are diagnostics and teardown, and the case you most want them in is the failing one: when a test fails, the container status, a screenshot of what the server was actually showing, and the server logs are the evidence you need — without
if: always()a failed test step skips all of it, anddownwould leak containers. Added a comment in the workflow saying so.The steps that are part of the test itself (start servers, run tests) deliberately don't have it, so a failure there still fails the job.
Generated by Claude Code