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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
.git
.github
.venv/
.venv
venv/
__pycache__/
__pycache__
*.py[cod]
.pytest_cache/
*.log
Expand All @@ -10,3 +14,4 @@ logs/
.LSOverride
Thumbs.db
Desktop.ini
.idea
22 changes: 22 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
name: Tests

on:
push:
branches:
- main
pull_request:
workflow_dispatch:

jobs:
docker-tests:
name: Dockerized Python test suite
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v4

- name: Build test image
run: docker build --target test -t propresenter-notes:test .

- name: Run tests in Docker
run: docker run --rm propresenter-notes:test
13 changes: 12 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
FROM python:3.12-slim
FROM python:3.12-slim AS base

ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
Expand All @@ -8,6 +8,17 @@ WORKDIR /app
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt

FROM base AS test

COPY server.py config.json ./
COPY propresenter_notes/ ./propresenter_notes/
COPY scripts/ ./scripts/
COPY tests/ ./tests/

CMD ["python", "-m", "unittest", "discover", "-s", "tests", "-v"]

FROM base AS runtime

COPY server.py config.json ./
COPY propresenter_notes/ ./propresenter_notes/

Expand Down
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,33 @@ Then open:
http://127.0.0.1:3000
```

## Run tests

Run the Python unit test suite locally with:

```bash
python -m unittest discover -s tests -v
```

Run the same test suite in Docker with the test build target used by CI:

```bash
docker build --target test -t propresenter-notes:test .
docker run --rm propresenter-notes:test
```

The test suite includes a local mock ProPresenter API server backed by the captured OpenAPI example payloads in `tests/propresenter_openapi_examples.json`. Those integration tests exercise the real `ProPresenterClient` over HTTP for `/version`, `/v1/libraries`, and `/v1/library/{library_id}` without requiring a running ProPresenter instance. As more Swagger examples are collected, add them to that fixture and extend `tests/mock_propresenter_api.py` so Docker and CI continue validating against the documented API contract.

To capture examples from a real ProPresenter system for review, run:

```bash
python scripts/capture_propresenter_examples.py --base-url http://127.0.0.1:1025 --output propresenter_examples_capture.json
```

The capture script reads `/version`, `/v1/libraries`, `/v1/library/{library_id}` using UUID/name/index forms, selected presentation details, the first slide thumbnail, and slide-status endpoints. It intentionally skips trigger endpoints because they mutate the live ProPresenter state. Review the generated file for sensitive presentation text before sharing it or copying examples into `tests/propresenter_openapi_examples.json`.

GitHub Actions runs the Dockerized test suite on pushes to `main`, pull requests, and manual workflow dispatches.

## Run with Docker

Docker is an optional deployment path; the macOS service scripts above remain supported. Build the image from this folder with:
Expand Down
229 changes: 229 additions & 0 deletions scripts/capture_propresenter_examples.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
#!/usr/bin/env python3
"""Capture ProPresenter REST API examples from a real ProPresenter instance.

The generated JSON is intended to be checked by a human and then copied into
`tests/propresenter_openapi_examples.json` so the mock API tests can run without
requiring a live ProPresenter system.
"""
from __future__ import annotations

import argparse
import base64
import json
import os
import sys
import urllib.error
import urllib.parse
import urllib.request
from datetime import datetime, timezone
from pathlib import Path
from typing import Any

DEFAULT_OUTPUT = Path("propresenter_examples_capture.json")
DEFAULT_TIMEOUT_SECONDS = 5.0


def default_base_url() -> str:
explicit = os.environ.get("PROPRESENTER_BASE_URL", "").strip()
if explicit:
return explicit.rstrip("/")

scheme = os.environ.get("PROPRESENTER_SCHEME", "http").strip() or "http"
host = os.environ.get("PROPRESENTER_HOST", "127.0.0.1").strip() or "127.0.0.1"
port = os.environ.get("PROPRESENTER_PORT", "1025").strip() or "1025"
return f"{scheme}://{host}:{port}".rstrip("/")


def quote_path_value(value: Any) -> str:
return urllib.parse.quote(str(value), safe="")


def decode_body(raw_body: bytes, content_type: str) -> dict[str, Any]:
result: dict[str, Any] = {
"content_type": content_type,
"body_length": len(raw_body),
}
content_type_lower = content_type.lower()

if not raw_body:
result["body"] = None
return result

if content_type_lower.startswith("image/") or "octet-stream" in content_type_lower:
result["body_base64"] = base64.b64encode(raw_body).decode("ascii")
return result

text = raw_body.decode("utf-8", errors="replace")
try:
result["body"] = json.loads(text)
except json.JSONDecodeError:
result["body"] = text
return result


def fetch(base_url: str, api_path: str, timeout_seconds: float) -> dict[str, Any]:
url = f"{base_url.rstrip('/')}{api_path}"
request = urllib.request.Request(url, headers={"Accept": "application/json, image/*, text/plain, */*"})

try:
with urllib.request.urlopen(request, timeout=timeout_seconds) as response:
raw_body = response.read()
content_type = response.headers.get("content-type", "")
payload = decode_body(raw_body, content_type)
payload.update({"status": response.status, "path": api_path, "ok": 200 <= response.status < 300})
return payload
except urllib.error.HTTPError as exc:
raw_body = exc.read()
content_type = exc.headers.get("content-type", "") if exc.headers else ""
payload = decode_body(raw_body, content_type)
payload.update({"status": exc.code, "path": api_path, "ok": False})
return payload
except Exception as exc:
return {"status": None, "path": api_path, "ok": False, "error": str(exc)}


def body_from(result: dict[str, Any]) -> Any:
return result.get("body")


def library_identifiers(library: Any) -> list[str]:
if not isinstance(library, dict):
return [str(library)] if library not in (None, "") else []

identifiers = []
for key in ("uuid", "name", "index"):
value = library.get(key)
if value not in (None, ""):
identifiers.append(str(value))
return identifiers


def first_successful_library(captured_libraries: dict[str, dict[str, Any]]) -> dict[str, Any] | None:
for result in captured_libraries.values():
if result.get("ok"):
return result
return None


def presentation_identifiers(library_result: dict[str, Any] | None) -> list[str]:
if not library_result:
return []

library_body = body_from(library_result)
items = library_body.get("items") if isinstance(library_body, dict) else []
identifiers = []
for item in items if isinstance(items, list) else []:
if not isinstance(item, dict):
continue
for key in ("uuid", "name", "index"):
value = item.get(key)
if value not in (None, ""):
identifiers.append(str(value))
break
return identifiers


def capture_examples(base_url: str, timeout_seconds: float) -> dict[str, Any]:
output: dict[str, Any] = {
"metadata": {
"generated_at": datetime.now(timezone.utc).isoformat(),
"base_url": base_url.rstrip("/"),
"notes": [
"Generated by scripts/capture_propresenter_examples.py.",
"Review before committing; endpoint bodies may include local presentation names or slide text.",
"Trigger endpoints are intentionally not requested because they mutate the live ProPresenter state.",
],
},
"skipped": {
"trigger_next": {
"path": "/v1/trigger/next",
"reason": "Skipped because this advances the live ProPresenter show.",
},
"trigger_previous": {
"path": "/v1/trigger/previous",
"reason": "Skipped because this changes the live ProPresenter show.",
},
"trigger_slide": {
"path": "/v1/presentation/{presentation_id}/{index}/trigger",
"reason": "Skipped because this changes the live ProPresenter show.",
},
},
}

output["version"] = fetch(base_url, "/version", timeout_seconds)
output["libraries"] = fetch(base_url, "/v1/libraries", timeout_seconds)

libraries_body = body_from(output["libraries"])
captured_libraries: dict[str, dict[str, Any]] = {}
for library in libraries_body if isinstance(libraries_body, list) else []:
for identifier in library_identifiers(library):
path = f"/v1/library/{quote_path_value(identifier)}"
captured_libraries[identifier] = fetch(base_url, path, timeout_seconds)

output["libraries_by_id"] = captured_libraries

first_library = first_successful_library(captured_libraries)
if first_library:
output["library"] = {
"status": first_library.get("status"),
"path": first_library.get("path"),
"content_type": first_library.get("content_type"),
"body": first_library.get("body"),
}

captured_presentations: dict[str, dict[str, Any]] = {}
for presentation_id in presentation_identifiers(first_library)[:3]:
path = f"/v1/presentation/{quote_path_value(presentation_id)}"
captured_presentations[presentation_id] = fetch(base_url, path, timeout_seconds)
output["presentations_by_id"] = captured_presentations

first_presentation_id = next(iter(captured_presentations), "")
if first_presentation_id:
thumbnail_path = (
f"/v1/presentation/{quote_path_value(first_presentation_id)}"
"/thumbnail/0?quality=400&thumbnail_type=jpeg"
)
output["thumbnail"] = fetch(base_url, thumbnail_path, timeout_seconds)

output["status_slide"] = fetch(base_url, "/v1/status/slide", timeout_seconds)
output["presentation_active"] = fetch(base_url, "/v1/presentation/active", timeout_seconds)
return output


def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Capture ProPresenter API examples for the mock API test fixture."
)
parser.add_argument(
"--base-url",
default=default_base_url(),
help="Base URL for ProPresenter, for example http://127.0.0.1:1025. "
"Defaults to PROPRESENTER_BASE_URL or PROPRESENTER_SCHEME/HOST/PORT.",
)
parser.add_argument(
"--output",
type=Path,
default=DEFAULT_OUTPUT,
help=f"Output JSON path. Defaults to {DEFAULT_OUTPUT}.",
)
parser.add_argument(
"--timeout",
type=float,
default=DEFAULT_TIMEOUT_SECONDS,
help=f"Per-request timeout in seconds. Defaults to {DEFAULT_TIMEOUT_SECONDS}.",
)
return parser.parse_args()


def main() -> int:
args = parse_args()
examples = capture_examples(args.base_url.rstrip("/"), args.timeout)
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(json.dumps(examples, indent=2, sort_keys=True) + "\n", encoding="utf-8")
print(f"Wrote ProPresenter examples to {args.output}", file=sys.stderr)
print("Please review the file for sensitive presentation text before sharing it.", file=sys.stderr)
return 0


if __name__ == "__main__":
raise SystemExit(main())
Empty file added tests/__init__.py
Empty file.
Loading
Loading