diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 037a184..35ab678 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -38,3 +38,30 @@ jobs: - name: Run Pytest Suite run: uv run pytest + + e2e: + name: Browser E2E on Ubuntu + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + + - name: Set up Python + run: uv python install 3.12 + + - name: Create virtual environment + run: uv venv --python 3.12 + + - name: Install dependencies + run: uv pip install -e ".[dev,images]" + + - name: Install Playwright browser + run: uv run python -m playwright install --with-deps chromium + + - name: Run browser E2E suite + run: uv run pytest -m e2e tests/e2e -q diff --git a/README.md b/README.md index b41cb96..d15661e 100644 --- a/README.md +++ b/README.md @@ -370,11 +370,13 @@ Common tasks: ```bash uv run python scripts/dev.py install # bootstrap .venv -uv run python scripts/dev.py test # run the full test suite +uv run python scripts/dev.py test # run the default test suite (excludes browser e2e) uv run python scripts/dev.py test -- tests/test_check.py -q uv run python scripts/dev.py run # defaults to: glissade demo uv run python scripts/dev.py run -- start uv run python scripts/dev.py build # build dist/ packages +uv run python scripts/dev.py e2e-install # install Playwright's browser binaries +uv run python scripts/dev.py e2e # run browser end-to-end tests ``` If you prefer the raw `uv` commands: @@ -384,12 +386,18 @@ uv python install 3.12 uv venv .venv --python 3.12 uv pip install --python .venv/bin/python -e ".[dev,images]" # Windows: .venv\Scripts\python.exe uv run --python .venv/bin/python pytest -q # Windows: .venv\Scripts\python.exe +uv run --python .venv/bin/python python -m playwright install chromium +uv run --python .venv/bin/python pytest -m e2e tests/e2e -q ``` `src/glissade/` is the package; `templates/` holds the deck and remote HTML; `data/` holds the themes, JSON schema, `init` scaffold and demo decks — all shipped inside the wheel so the tool works from any directory. +Browser behavior is covered by Playwright end-to-end tests in `tests/e2e/`. +They start a real `glissade` server, drive both the projector and remote UIs, +and verify live navigation, live notes, and watch-mode reloads. + ```bash uv build # sdist + universal wheel into dist/ ``` diff --git a/pyproject.toml b/pyproject.toml index a6af6e2..039df58 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "glissade" -version = "0.13.0" +version = "0.14.0" description = "Presentation decks written as JSON, driven from your phone, built into a single self-contained HTML file" readme = "README.md" requires-python = ">=3.10" @@ -46,12 +46,17 @@ images = ["pillow>=10.0"] dev = [ "pytest>=8.0", "httpx>=0.27", + "playwright>=1.54.0", "ruff>=0.6.0", ] [tool.pytest.ini_options] +addopts = ["-m", "not e2e"] testpaths = ["tests"] pythonpath = ["src"] +markers = [ + "e2e: browser end-to-end tests that require Playwright", +] [project.urls] Homepage = "https://github.com/techmuch/glissade" diff --git a/scripts/dev.py b/scripts/dev.py index 86d74c6..827fb57 100644 --- a/scripts/dev.py +++ b/scripts/dev.py @@ -78,6 +78,22 @@ def cmd_build(args: argparse.Namespace) -> None: run([uv(), "build", *passthrough(args.args)]) +def cmd_e2e_install(args: argparse.Namespace) -> None: + python = require_venv(ROOT / args.venv) + run([str(python), "-m", "playwright", "install", args.browser]) + + +def cmd_e2e(args: argparse.Namespace) -> None: + python = require_venv(ROOT / args.venv) + extra = passthrough(args.args) + cmd = [str(python), "-m", "pytest", "-m", "e2e"] + if not extra: + cmd.extend(["tests/e2e", "-q"]) + else: + cmd.extend(extra) + run(cmd) + + def parser() -> argparse.ArgumentParser: p = argparse.ArgumentParser( description="Cross-platform developer tasks for Glissade." @@ -145,6 +161,38 @@ def parser() -> argparse.ArgumentParser: ) build.set_defaults(func=cmd_build) + e2e_install = sub.add_parser( + "e2e-install", + help="Install Playwright browser binaries into the local .venv.", + ) + e2e_install.add_argument( + "--venv", + default=".venv", + help="Virtualenv directory (default: .venv)", + ) + e2e_install.add_argument( + "--browser", + default="chromium", + help="Browser to install for Playwright (default: chromium)", + ) + e2e_install.set_defaults(func=cmd_e2e_install) + + e2e = sub.add_parser( + "e2e", + help="Run browser end-to-end tests inside the local .venv.", + ) + e2e.add_argument( + "--venv", + default=".venv", + help="Virtualenv directory (default: .venv)", + ) + e2e.add_argument( + "args", + nargs=argparse.REMAINDER, + help="Arguments passed through to pytest for e2e tests", + ) + e2e.set_defaults(func=cmd_e2e) + return p diff --git a/src/glissade/__init__.py b/src/glissade/__init__.py index 6c2b36f..4b22c44 100644 --- a/src/glissade/__init__.py +++ b/src/glissade/__init__.py @@ -13,7 +13,7 @@ glissade check validate decks before you rely on them """ -__version__ = "0.13.0" +__version__ = "0.14.0" from .project import ( # noqa: F401 DATA_DIR, diff --git a/src/glissade/data/schema.json b/src/glissade/data/schema.json index 4918aa8..0a5734b 100644 --- a/src/glissade/data/schema.json +++ b/src/glissade/data/schema.json @@ -2,7 +2,7 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "https://glissade.dev/schema/v1.json", "x-glissade-schema": 1, - "x-glissade-version": "0.13.0", + "x-glissade-version": "0.14.0", "title": "Glissade deck", "description": "A presentation deck. Either an object with metadata and a slides array, or a bare array of slides.", "oneOf": [ diff --git a/src/glissade/templates/control.html b/src/glissade/templates/control.html index f1cceb2..4dcf962 100644 --- a/src/glissade/templates/control.html +++ b/src/glissade/templates/control.html @@ -380,10 +380,6 @@

No live notes yet. -<<<<<<< HEAD - -======= ->>>>>>> main @@ -436,11 +432,7 @@

let SLIDES = [], THEMES = [], DECKS = []; let cur = 1, blanked = false, scale = 1, minScale = 0.7, maxScale = 1.6; let painted = null, serverHasScale = true, themeId = null, deckId = null, rev = null; -<<<<<<< HEAD let liveNoteText = '', liveNotesVisible = false, liveNoteSaveTimer = null, liveNoteDirty = false, allLiveNotes = []; -======= -let liveNoteText = '', liveNotesVisible = false, liveNoteSaveTimer = null, liveNoteDirty = false; ->>>>>>> main const $ = id => document.getElementById(id); @@ -484,10 +476,7 @@

$('lnstatus').className = liveNoteDirty ? '' : (liveNoteText ? 'ok' : ''); $('showlive').classList.toggle('on', !!liveNotesVisible); $('showlive').textContent = liveNotesVisible ? 'Hide on projector' : 'Show on projector'; -<<<<<<< HEAD renderLiveNotesReview(); -======= ->>>>>>> main if(nx){ $('nextbox').style.display = ''; @@ -694,7 +683,6 @@

for(const k in map) if(map[k]) r.setProperty(k, map[k]); } -<<<<<<< HEAD function renderLiveNotesReview(){ const boxes = [$('lnreview'), $('lnreviewpanel')].filter(Boolean); for(const box of boxes){ @@ -719,8 +707,6 @@

} } -======= ->>>>>>> main function queueLiveNoteSave(){ liveNoteDirty = true; paint(); @@ -728,13 +714,9 @@

liveNoteSaveTimer = setTimeout(() => { post('/api/live-notes', {n: cur, text: $('livenotes').value}).then(r => { liveNoteDirty = false; -<<<<<<< HEAD if(r.ok) allLiveNotes = (r.all_live_notes || []); if(!r.ok) $('lnstatus').textContent = 'Could not save live notes.'; renderLiveNotesReview(); -======= - if(!r.ok) $('lnstatus').textContent = 'Could not save live notes.'; ->>>>>>> main paint(); }); }, 350); @@ -875,10 +857,7 @@

if(st.deck != null) deckId = st.deck; liveNoteText = st.live_note || ''; liveNotesVisible = !!st.live_notes_visible; -<<<<<<< HEAD if(st.all_live_notes) allLiveNotes = st.all_live_notes; -======= ->>>>>>> main /* An older server reports no scale. Flag it as soon as we connect rather than letting the presenter discover it by pressing a dead button. */ serverHasScale = st.scale != null; diff --git a/tests/e2e/test_browser.py b/tests/e2e/test_browser.py new file mode 100644 index 0000000..bc3d08b --- /dev/null +++ b/tests/e2e/test_browser.py @@ -0,0 +1,176 @@ +from __future__ import annotations + +import json +import os +import socket +import subprocess +import sys +import time +from contextlib import asynccontextmanager, contextmanager +from pathlib import Path + +import pytest +from playwright.async_api import Page, async_playwright, expect + + +pytestmark = [pytest.mark.e2e, pytest.mark.anyio] + + +def _free_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.bind(("127.0.0.1", 0)) + return int(sock.getsockname()[1]) + + +def _wait_for_http(url: str, timeout: float = 15.0) -> None: + import urllib.request + + deadline = time.time() + timeout + last_error: Exception | None = None + while time.time() < deadline: + try: + with urllib.request.urlopen(url, timeout=1.0) as response: + if response.status == 200: + return + except Exception as exc: # pragma: no cover - only on startup failure + last_error = exc + time.sleep(0.1) + raise RuntimeError(f"Timed out waiting for {url}: {last_error}") + + +@contextmanager +def running_server(tmp_path: Path, sample_deck_data: dict): + decks_dir = tmp_path / "decks" + decks_dir.mkdir(parents=True, exist_ok=True) + deck_path = decks_dir / "talk.json" + deck_path.write_text(json.dumps(sample_deck_data, indent=2), encoding="utf-8") + + port = _free_port() + env = dict(os.environ) + env["PYTHONPATH"] = str(Path("src").resolve()) + proc = subprocess.Popen( + [ + sys.executable, + "-m", + "glissade", + "start", + "-C", + str(tmp_path), + "--host", + "127.0.0.1", + "--port", + str(port), + ], + cwd=Path(__file__).resolve().parents[2], + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + + base_url = f"http://127.0.0.1:{port}" + try: + _wait_for_http(base_url + "/") + yield { + "base_url": base_url, + "deck_path": deck_path, + "proc": proc, + } + finally: + proc.terminate() + try: + proc.wait(timeout=10) + except subprocess.TimeoutExpired: # pragma: no cover + proc.kill() + proc.wait(timeout=5) + + +async def _goto_projector(page: Page, url: str) -> None: + await page.goto(url, wait_until="domcontentloaded") + await expect(page.locator(".slide.active")).to_have_count(1) + + +async def _goto_remote(page: Page, url: str) -> None: + await page.goto(url, wait_until="domcontentloaded") + await expect(page.locator("#nowtitle")).not_to_have_text("Connecting…") + + +@asynccontextmanager +async def open_pages(base_url: str): + playwright = await async_playwright().start() + browser = await playwright.chromium.launch() + projector = await browser.new_page(viewport={"width": 1440, "height": 900}) + remote = await browser.new_page(viewport={"width": 430, "height": 932}) + + try: + await _goto_projector(projector, base_url + "/") + await _goto_remote(remote, base_url + "/control") + yield projector, remote + finally: + await browser.close() + await playwright.stop() + + +async def test_remote_navigation_updates_projector(tmp_path: Path, sample_deck_data: dict): + with running_server(tmp_path, sample_deck_data) as server: + async with open_pages(server["base_url"]) as (projector, remote): + await expect(projector.locator(".slide.active")).to_contain_text("Welcome to Glissade") + await expect(remote.locator("#count")).to_have_text("1 / 2") + + await remote.locator("#next").click() + + await expect(remote.locator("#count")).to_have_text("2 / 2") + await expect(projector.locator(".slide.active")).to_contain_text("Self-contained decks") + + await remote.locator("#prev").click() + + await expect(remote.locator("#count")).to_have_text("1 / 2") + await expect(projector.locator(".slide.active")).to_contain_text("Welcome to Glissade") + + +async def test_live_notes_round_trip_to_remote_and_projector(tmp_path: Path, sample_deck_data: dict): + with running_server(tmp_path, sample_deck_data) as server: + async with open_pages(server["base_url"]) as (projector, remote): + note = "Ask about rollout timing" + await remote.locator("#livenotes").fill(note) + await expect(remote.locator("#lnstatus")).to_have_text("Saving…") + await expect(remote.locator("#lnstatus")).to_have_text("Saved for this slide.") + + await remote.locator("#showlive").click() + + await expect(projector.locator("#livenotes")).to_have_class("on") + await expect(projector.locator("#lnbody")).to_contain_text(note) + + +async def test_live_reload_refreshes_open_pages(tmp_path: Path, sample_deck_data: dict): + with running_server(tmp_path, sample_deck_data) as server: + async with open_pages(server["base_url"]) as (projector, remote): + deck_path = server["deck_path"] + + await expect(projector.locator(".slide.active")).to_contain_text("Welcome to Glissade") + await expect(remote.locator("#nowtitle")).to_have_text("Welcome Slide") + + data = json.loads(deck_path.read_text(encoding="utf-8")) + data["slides"][0]["heading"] = "Updated from watch mode" + data["slides"][0]["title"] = "Updated Slide Title" + deck_path.write_text(json.dumps(data, indent=2), encoding="utf-8") + + await expect(projector.locator(".slide.active")).to_contain_text("Updated from watch mode") + await expect(remote.locator("#nowtitle")).to_have_text("Updated Slide Title") + + +async def test_notes_drawer_lists_captured_notes_and_jumps(tmp_path: Path, sample_deck_data: dict): + with running_server(tmp_path, sample_deck_data) as server: + async with open_pages(server["base_url"]) as (_, remote): + await remote.locator("#livenotes").fill("Remember the intro story") + await expect(remote.locator("#lnstatus")).to_have_text("Saved for this slide.") + + await remote.locator("#next").click() + await expect(remote.locator("#count")).to_have_text("2 / 2") + + await remote.locator("#notesbtn").click() + await expect(remote.locator("#drawertitle")).to_have_text("All captured notes") + await expect(remote.locator("#lnreviewpanel")).to_contain_text("Remember the intro story") + + await remote.locator("#lnreviewpanel button[data-n='1']").click() + await expect(remote.locator("#count")).to_have_text("1 / 2") diff --git a/website/app.js b/website/app.js index fa3b14f..685cc56 100644 --- a/website/app.js +++ b/website/app.js @@ -198,7 +198,7 @@ const LAYOUT_DATA = { "notes": "Introduce the core philosophy of Glissade." }`, html: ` -
RELEASE 0.13.0
+
RELEASE 0.14.0

Presentation decks written as JSON

Driven from your phone

` diff --git a/website/index.html b/website/index.html index 187c896..5f387d1 100644 --- a/website/index.html +++ b/website/index.html @@ -62,7 +62,7 @@ glissade - v0.13.0 + v0.14.0