From 1c660bedf1e95ef0ae34c944ed17bbb3453f8dab Mon Sep 17 00:00:00 2001 From: Maryna Balioura Date: Sat, 4 Jul 2026 16:41:46 +0200 Subject: [PATCH 01/39] docs(developer/tasks): add task template with WHY-WHAT-HOW structure --- developer/tasks/task.template.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 developer/tasks/task.template.md diff --git a/developer/tasks/task.template.md b/developer/tasks/task.template.md new file mode 100644 index 000000000..679cd63de --- /dev/null +++ b/developer/tasks/task.template.md @@ -0,0 +1,23 @@ +# + + + +## WHAT + + + +## WHY + + + +## HOW + + From 9fc074555847babfa3abc5bb71f65edafe644c77 Mon Sep 17 00:00:00 2001 From: Maryna Balioura Date: Sat, 4 Jul 2026 17:15:55 +0200 Subject: [PATCH 02/39] docs(developer/tasks): add task.md --- developer/tasks/20260704_screencast/task.md | 109 ++++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 developer/tasks/20260704_screencast/task.md diff --git a/developer/tasks/20260704_screencast/task.md b/developer/tasks/20260704_screencast/task.md new file mode 100644 index 000000000..863e716dd --- /dev/null +++ b/developer/tasks/20260704_screencast/task.md @@ -0,0 +1,109 @@ +# Screencast generator: move demo-video pipeline into StrictDoc + +## WHAT + +StrictDoc's product website needs demo screencasts (`.webm` videos) that show +real, up-to-date UI behavior (web UI, CLI, IDE-style scenes). The pipeline +that produces these videos must live inside the StrictDoc repository so it +stays in sync with the actual product, and it must double as a regression +signal: if a recorded scenario fails, the corresponding published video is +considered outdated. + +Success criteria: + +- A `tests/screencast/` sub-project exists in the StrictDoc repository, + written in Python, with no Node.js/npm dependency. +- Each demo scenario is expressed as a scenario/test case that: + - passes or fails like a regular automated check (usable as a staleness + signal for the corresponding video), and + - can optionally be run in "recording" mode to (re)generate the + corresponding `.webm` video file. +- A dedicated `invoke` task (e.g. `invoke test-screencast`) runs the + screencast scenarios; it is not folded into `invoke test-end2end`. +- The scenario harness starts and stops a real StrictDoc server itself + (in-process to the repository, no dependency on a sibling checkout or a + separate `tox` invocation of another repo). +- The demo fixture project used for recording is checked into + `tests/screencast/fixtures/`, cleaned of unrelated build/cache artifacts. +- Delivery of generated videos to the product website + (`strictdoc-project.github.io`) is explicitly OUT OF SCOPE for this task; + this task only produces the videos inside the StrictDoc repository. + +## WHY + +The video generator currently lives in the `strictdoc-project.github.io` +(Hugo) repository and drives a *separate* StrictDoc checkout via `tox` to +record scenes. This means: + +- videos can silently go stale after StrictDoc UI changes, with no signal; +- the generator's environment assumptions (sibling checkout, `tox` on PATH, + hardcoded Python version) are fragile and only make sense from outside the + StrictDoc repo; +- introducing new demo scenarios or updating them requires cross-repo + coordination instead of living next to the code it demonstrates. + +Moving the generator into StrictDoc, and expressing scenarios as ordinary +test cases, ties video freshness directly to the same CI/dev signal already +used for UI regressions, and lets one scenario definition serve both as a +regression check and as a video source. + +## HOW + +### Current state (starting point, not a spec) + +`tests/screencast/` currently holds a raw copy of the website repo's +generator: `demo.js` (Playwright JS, defines scenes as a JSON-like step list +and renders `.webm` via `context.newContext({ recordVideo })`), +`strictdoc_server.py`/`run_server.py`/`run_demo.py` (spawns StrictDoc through +`tox -e py310-development` against a *sibling* `../strictdoc` checkout), and +a copy of the `strictdoc-demo-project` fixture (including stray +`__pycache__`/`.DS_Store`/cache build output). This code is a starting point +to adapt, not a design to preserve as-is. + +### Target design + +- **Language**: rewrite the generator in Python using the Playwright Python + API (`playwright.sync_api`), including `record_video_dir` for video + capture. Drop `demo.js`, `package.json`, `package-lock.json`, + `node_modules` — no Node.js/npm dependency remains in the project. +- **Server lifecycle**: reuse the existing, already-hardened + `tests/end2end/server.py::SDocTestServer` (or a thin adaptation of it) + to start/stop a StrictDoc server directly via + `python -m strictdoc.cli.main server ...` in-process to this repository — + no `tox` subprocess, no assumption of a sibling checkout. +- **Scenario structure**: each demo scene becomes its own pytest test case, + following the existing `tests/end2end/screens/*/test_case.py` convention + (Page Object helpers such as `Screen_*` / `helpers/components/*` reused + where they already exist for the same UI areas). Standalone HTML/IDE-style + scenes (fake typing effect) keep using a local HTML playground, driven from + Python instead of `demo.js`. +- **Dual-purpose run modes**: + - default run: scenario executes as a normal pass/fail check (fast, no + video output) — a failure means the scenario (and its published video) + is stale; + - recording run: an explicit flag/env var (e.g. + `--strictdoc-record-video`) makes the same scenario also capture and + save a `.webm` to `tests/screencast/output/`. +- **Invoke task**: add `invoke test-screencast` (with a short alias) in + `tasks.py`, following the conventions of `test_end2end`/`test_unit_server` + (headless option, focus filter, junit output under `TEST_REPORTS_DIR`). + Kept separate from `invoke test-end2end` since screencast runs are + slower/heavier and produce binary artifacts. +- **Dependencies**: add `playwright` (Python package) to `pyproject.toml` as + a dev/test dependency; document the `playwright install chromium` setup + step (Chromium only, matching current generator's needs). +- **Fixtures**: keep `strictdoc-demo-project` as the demo content, but strip + `__pycache__`, `.DS_Store`, and any generated `output/_cache` before + committing, aligning its shape with other `tests/end2end` fixtures. +- **Out of scope**: publishing/copying generated `.webm` files to + `strictdoc-project.github.io`; migrating the rest of the e2e suite off + SeleniumBase (future direction, only considered here to avoid decisions + that would conflict with it, not implemented). + +### Working agreement for this task + +Implementation proceeds in discrete stages; each stage stops for review and +a commit before starting the next. Open questions encountered during +implementation are raised with the user first rather than resolved by +assumption or open-ended codebase research, unless the user asks for deeper +investigation. From 438703087c24f2ee01c708aae53e135ef04bcb01 Mon Sep 17 00:00:00 2001 From: Maryna Balioura Date: Sat, 4 Jul 2026 17:22:52 +0200 Subject: [PATCH 03/39] chore(.gitignore): added /tests/screencast/output/ so generated .webm videos are never committed --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index a2fa1fd9f..0290ccd02 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,7 @@ __pycache__ /tests/unit_server/**/output/ +/tests/screencast/output/ ### LIT/FileCheck integration tests' artifacts. ### /tests/integration/**/Output/** From 6c6f2aa064311f22684d82b52b3524aff572457a Mon Sep 17 00:00:00 2001 From: Maryna Balioura Date: Sat, 4 Jul 2026 17:24:53 +0200 Subject: [PATCH 04/39] chore(tests/screencast): drop Node.js pipeline and stale fixture cruft MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tests/screencast/ was a raw copy of the demo-video generator from the strictdoc-project.github.io (Hugo) repository. This is the first step of moving that generator into the StrictDoc repo itself (see developer/tasks/20260704_screencast/task.md). Removed as part of this cleanup: - demo.js, package.json, package-lock.json, node_modules/: the JS/Playwright pipeline is being rewritten in Python (Playwright has a full Python API), so StrictDoc gains no new Node.js/npm dependency. - run_demo.py: only orchestrated the now-removed `npm run demo` step. - fixtures/strictdoc-demo-project/.local/: personal working notes with another machine's absolute paths and stray duplicate .sdoc files, not part of the actual demo fixture. - __pycache__/, .DS_Store, and the generated fixtures/.../output/ cache: build artifacts, not source. Still present, to be replaced in a later stage: run_server.py and strictdoc_server.py, which currently start StrictDoc by shelling out to `tox` against a sibling repo checkout — an assumption that only made sense outside the StrictDoc repo. These will be replaced by reusing tests/end2end/server.py::SDocTestServer. No functional change to StrictDoc itself; tests/screencast is not yet wired into any invoke task. --- tests/screencast/README.md | 119 ++++++++++ tests/screencast/demo.html | 90 ++++++++ .../strictdoc-demo-project/.gitignore | 1 + .../fixtures/strictdoc-demo-project/README.md | 23 ++ .../docs/requirements.sdoc | 7 + .../strictdoc_config.py | 17 ++ tests/screencast/run_server.py | 40 ++++ tests/screencast/strictdoc_server.py | 203 ++++++++++++++++++ 8 files changed, 500 insertions(+) create mode 100644 tests/screencast/README.md create mode 100644 tests/screencast/demo.html create mode 100644 tests/screencast/fixtures/strictdoc-demo-project/.gitignore create mode 100644 tests/screencast/fixtures/strictdoc-demo-project/README.md create mode 100644 tests/screencast/fixtures/strictdoc-demo-project/docs/requirements.sdoc create mode 100644 tests/screencast/fixtures/strictdoc-demo-project/strictdoc_config.py create mode 100644 tests/screencast/run_server.py create mode 100644 tests/screencast/strictdoc_server.py diff --git a/tests/screencast/README.md b/tests/screencast/README.md new file mode 100644 index 000000000..08067cb65 --- /dev/null +++ b/tests/screencast/README.md @@ -0,0 +1,119 @@ +# Demo Video Pipeline + +This directory contains the tooling used to generate demo videos for the StrictDoc website. + +The goal is to keep the entire pipeline reproducible: + +- demo pages are defined as HTML/CSS/JS; +- browser interaction is scripted with Playwright; +- videos can be regenerated automatically after UI changes. + +The generated video assets are intended for the Hugo website and are not part of the StrictDoc application itself. + +## Setup + +Install the Node dependencies and the Playwright browser binary,\ +from `strictdoc-project.github.io`: + +```bash +npm install --prefix demo +npx --prefix demo playwright install chromium +``` + +Only Chromium is needed — `demo.js` doesn't use other browsers. + +The demo server scripts also need a local checkout of the +[strictdoc](https://github.com/strictdoc-project/strictdoc) repository and +`tox` to run its dev environment: + +- by default, the StrictDoc checkout is expected as a sibling directory: + `../strictdoc` relative to this repository's root; +- `tox` is expected on `PATH` (e.g. `pip install tox`). + +If your setup differs, override with environment variables instead of +editing the scripts: + +| Variable | Purpose | Default | +| --- | --- | --- | +| `STRICTDOC_ROOT` | Path to the strictdoc checkout | `../strictdoc` (sibling of this repo) | +| `STRICTDOC_TOX` | Path to the `tox` executable | resolved from `PATH`, falling back to a pyenv-versions guess | +| `STRICTDOC_PYTHON_VERSION` | Python version used in the pyenv fallback above | `3.10.16` | +| `STRICTDOC_SITE_URL` | URL for the manual dev server | `http://127.0.0.1:5111/` | +| `STRICTDOC_VIDEO_SITE_URL` | URL for the temporary recording server | `http://127.0.0.1:5112/` | + +## Running the demo server + +For manual development and inspecting the demo in the browser,\ +from `strictdoc-project.github.io`: + +```bash +python3 demo/run_server.py +``` + +This starts the StrictDoc with the demo fixture at **5111**: +`http://127.0.0.1:5111`. + +This server can stay open while developing or inspecting the demo in the browser. + +The script: + +- stops an existing demo server on the same port; +- starts StrictDoc with the fixture project; +- keeps the server running until stopped manually; +- shuts down the complete StrictDoc process tree on exit. + +## Recording the demo video + +To generate the demo video,\ +from `strictdoc-project.github.io`: + +```bash +python3 demo/run_demo.py +``` + +The video recording uses a separate temporary StrictDoc server. + +Using a separate port allows the manual development server to stay open while regenerating videos. + +The recording pipeline: + +- starts a temporary StrictDoc server at **5112**: `http://127.0.0.1:5112`. +- waits until the server is available; +- runs the Playwright recording scenario; +- stops the complete StrictDoc server process tree afterwards. + +The generated videos are written to `demo/output/`. + +Each video scenario defines its own output file name. + +Each scenario defines: + +- the list of recorded steps; +- the output video file name. + +Scenarios can combine different sources, for example standalone HTML scenes +(for terminal or IDE-style recordings) and live StrictDoc UI screens. + +Standalone HTML scenes (`demo.html`) use a fake typing effect: characters are +appended directly to a page element's text content to *look* like typing. +This is a visual effect only — it does not simulate keyboard input and does +not type into a real input, textarea, or code editor. + +## StrictDoc server setup + +The helper scripts start StrictDoc automatically. The server can also be started manually from the StrictDoc repository: + +```bash +cd /path/to/strictdoc + +invoke server \ + --input-path=/path/to/strictdoc-project.github.io/demo/fixtures/strictdoc-demo-project \ + --config=/path/to/strictdoc-project.github.io/demo/fixtures/strictdoc-demo-project/strictdoc_config.py +``` + +The `input-path` argument points to the demo project root. The `--config` argument points to the demo project's configuration file. Both are required. + +*Note*: `invoke server` always uses the default StrictDoc server port. +The video recording script starts StrictDoc through the CLI directly because +it needs a separate temporary port. The `invoke server` wrapper does not expose +port configuration. diff --git a/tests/screencast/demo.html b/tests/screencast/demo.html new file mode 100644 index 000000000..49842bc01 --- /dev/null +++ b/tests/screencast/demo.html @@ -0,0 +1,90 @@ + + + + + StrictDoc demo + + + +
+
+
+

Requirements that stay traceable

+

+ StrictDoc keeps requirements, documentation, and traceability + in one readable source format. +

+
+ +
+
+

+      
+
+
+ + diff --git a/tests/screencast/fixtures/strictdoc-demo-project/.gitignore b/tests/screencast/fixtures/strictdoc-demo-project/.gitignore new file mode 100644 index 000000000..77320b339 --- /dev/null +++ b/tests/screencast/fixtures/strictdoc-demo-project/.gitignore @@ -0,0 +1 @@ +output/* diff --git a/tests/screencast/fixtures/strictdoc-demo-project/README.md b/tests/screencast/fixtures/strictdoc-demo-project/README.md new file mode 100644 index 000000000..60f456946 --- /dev/null +++ b/tests/screencast/fixtures/strictdoc-demo-project/README.md @@ -0,0 +1,23 @@ +# StrictDoc Demo Project + +This project is used by the video generation pipeline in `demo/`. + +## Directory layout + +The demo project must keep its documents under the `docs/` directory. + +```text +strictdoc-demo-project/ +├── docs/ +│ └── requirements.sdoc +└── strictdoc_config.py +``` + +Currently, \ +`include_doc_paths = ["/"]` does not behave as expected for this demo project. Although StrictDoc discovers and parses the documents successfully, the generated project tree is empty. Using an explicit top-level directory such as `/docs/` produces the expected project tree. + +## Notes + +* This fixture is intentionally independent of the main StrictDoc documentation. +* The fixture is used exclusively for reproducible demo video generation. +* The demo project is intended to remain small, deterministic, and independent of the main StrictDoc documentation so that demo videos remain reproducible. diff --git a/tests/screencast/fixtures/strictdoc-demo-project/docs/requirements.sdoc b/tests/screencast/fixtures/strictdoc-demo-project/docs/requirements.sdoc new file mode 100644 index 000000000..146662eb6 --- /dev/null +++ b/tests/screencast/fixtures/strictdoc-demo-project/docs/requirements.sdoc @@ -0,0 +1,7 @@ +[DOCUMENT] +TITLE: StrictDoc + +[REQUIREMENT] +UID: SDOC-HIGH-REQS-MANAGEMENT +TITLE: Requirements management +STATEMENT: StrictDoc shall enable requirements management. diff --git a/tests/screencast/fixtures/strictdoc-demo-project/strictdoc_config.py b/tests/screencast/fixtures/strictdoc-demo-project/strictdoc_config.py new file mode 100644 index 000000000..cef2f3436 --- /dev/null +++ b/tests/screencast/fixtures/strictdoc-demo-project/strictdoc_config.py @@ -0,0 +1,17 @@ +from strictdoc.core.project_config import ProjectConfig + + +def create_config() -> ProjectConfig: + return ProjectConfig( + project_title="StrictDoc Demo Project", + project_features=[ + "TABLE_SCREEN", + "TRACEABILITY_SCREEN", + "DEEP_TRACEABILITY_SCREEN", + "SEARCH", + ], + include_doc_paths=[ + "/docs/", + ], + exclude_doc_paths=[], + ) diff --git a/tests/screencast/run_server.py b/tests/screencast/run_server.py new file mode 100644 index 000000000..52a5aacaf --- /dev/null +++ b/tests/screencast/run_server.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import sys + +from strictdoc_server import ( + SERVER_URL, + start_server, + stop_server, + wait_for_server, +) + + +def main() -> int: + try: + server = start_server() + wait_for_server(SERVER_URL, server) + + print(f"🚀 StrictDoc demo server: {SERVER_URL}") + print(" Press Ctrl+C to stop.") + + server.wait() + + except KeyboardInterrupt: + print("\n🛑 Stopping StrictDoc demo server.") + + except RuntimeError as error: + print(error, file=sys.stderr) + return 1 + + finally: + if "server" in locals(): + stop_server(server) + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/screencast/strictdoc_server.py b/tests/screencast/strictdoc_server.py new file mode 100644 index 000000000..dd6694a95 --- /dev/null +++ b/tests/screencast/strictdoc_server.py @@ -0,0 +1,203 @@ +from __future__ import annotations + +import os +import shutil +import signal +import subprocess +import sys +import time +import urllib.parse +import urllib.request +from pathlib import Path + + +DEMO_DIR = Path(__file__).resolve().parent +REPO_ROOT = DEMO_DIR.parents[1] +DEFAULT_STRICTDOC_ROOT = REPO_ROOT / "strictdoc" + +STRICTDOC_ROOT = Path( + os.environ.get("STRICTDOC_ROOT", DEFAULT_STRICTDOC_ROOT) +).resolve() + +FIXTURE_DIR = DEMO_DIR / "fixtures" / "strictdoc-demo-project" +FIXTURE_CONFIG = FIXTURE_DIR / "strictdoc_config.py" + +SERVER_URL = os.environ.get("STRICTDOC_SITE_URL", "http://127.0.0.1:5111/") +VIDEO_SERVER_URL = os.environ.get( + "STRICTDOC_VIDEO_SITE_URL", "http://127.0.0.1:5112/" +) + +STRICTDOC_PYTHON_VERSION = os.environ.get( + "STRICTDOC_PYTHON_VERSION", "3.10.16" +) + + +def resolve_tox() -> Path | None: + # Order: explicit override, PATH, pyenv guess (for setups without pyenv shims on PATH). + env_override = os.environ.get("STRICTDOC_TOX") + if env_override: + return Path(env_override).resolve() + + on_path = shutil.which("tox") + if on_path: + return Path(on_path).resolve() + + pyenv_guess = ( + Path.home() + / ".pyenv" + / "versions" + / STRICTDOC_PYTHON_VERSION + / "bin" + / "tox" + ) + if pyenv_guess.exists(): + return pyenv_guess.resolve() + + return None + + +STRICTDOC_TOX = resolve_tox() + + +def is_server_available(url: str) -> bool: + try: + with urllib.request.urlopen(url, timeout=1): + return True + except Exception: + return False + + +def get_port_from_url(url: str) -> int: + parsed_url = urllib.parse.urlparse(url) + if parsed_url.port is None: + if parsed_url.scheme == "https": + return 443 + return 80 + return parsed_url.port + + +def find_processes_on_port(port: int) -> list[int]: + result = subprocess.run( + ["lsof", "-ti", f"tcp:{port}"], + check=False, + capture_output=True, + text=True, + ) + + if result.returncode != 0: + return [] + + return [int(pid) for pid in result.stdout.splitlines() if pid.strip()] + + +def stop_existing_server(url: str) -> None: + port = get_port_from_url(url) + process_ids = find_processes_on_port(port) + + if not process_ids: + return + + print(f"⚠️ StrictDoc server URL is already in use: {url}", file=sys.stderr) + print( + f" Stopping existing process(es) on port {port}: " + f"{', '.join(str(pid) for pid in process_ids)}", + file=sys.stderr, + ) + + for process_id in process_ids: + os.kill(process_id, signal.SIGTERM) + + started_at = time.time() + while time.time() - started_at < 10: + if not find_processes_on_port(port): + return + time.sleep(0.2) + + for process_id in find_processes_on_port(port): + os.kill(process_id, signal.SIGKILL) + + +def start_server(url: str = SERVER_URL) -> subprocess.Popen: + if not STRICTDOC_ROOT.exists(): + raise RuntimeError( + f"❌ StrictDoc repository not found: {STRICTDOC_ROOT}\n" + " Set STRICTDOC_ROOT to the path of your strictdoc checkout." + ) + + if STRICTDOC_TOX is None: + raise RuntimeError( + "❌ tox not found on PATH and no pyenv fallback matched.\n" + " Install tox (e.g. `pip install tox`) so it's on PATH, or set " + "STRICTDOC_TOX to its executable path." + ) + + stop_existing_server(url) + + port = get_port_from_url(url) + host = urllib.parse.urlparse(url).hostname or "127.0.0.1" + + env = os.environ.copy() + env["PYENV_VERSION"] = STRICTDOC_PYTHON_VERSION + + return subprocess.Popen( + [ + str(STRICTDOC_TOX), + "-e", + "py310-development", + "--", + "python", + "-m", + "strictdoc.cli.main", + "--debug", + "server", + str(FIXTURE_DIR), + "--config", + str(FIXTURE_CONFIG), + "--host", + host, + "--port", + str(port), + "--reload", + ], + cwd=STRICTDOC_ROOT, + env=env, + start_new_session=True, + ) + + +def wait_for_server( + url: str, server: subprocess.Popen, timeout_seconds: int = 180 +) -> None: + started_at = time.time() + + while time.time() - started_at < timeout_seconds: + if is_server_available(url): + return + + if server.poll() is not None: + raise RuntimeError( + "❌ StrictDoc server process exited before becoming available: " + f"exit code {server.returncode}" + ) + + time.sleep(0.5) + + raise RuntimeError( + f"⏳ Timed out waiting for StrictDoc server after {timeout_seconds}s: {url}" + ) + + +def stop_server(server: subprocess.Popen) -> None: + if server.poll() is not None: + return + + try: + os.killpg(server.pid, signal.SIGTERM) + server.wait(timeout=10) + except ProcessLookupError: + return + except subprocess.TimeoutExpired: + try: + os.killpg(server.pid, signal.SIGKILL) + except ProcessLookupError: + return From ad1484a348952dcf164c98ae712aa999958cd5f3 Mon Sep 17 00:00:00 2001 From: Maryna Balioura Date: Sat, 4 Jul 2026 17:27:38 +0200 Subject: [PATCH 05/39] build(deps): add Playwright to end2end/check dependencies Adds the `playwright` Python package to requirements.check.txt, alongside seleniumbase, since it is installed and used through the same tox `check` environment as the end2end test suite. This is the dependency needed for the tests/screencast video-generation pipeline being rewritten in Python (see developer/tasks/20260704_screencast/task.md). The pipeline code itself and the one-time `playwright install chromium` browser-binary setup step will follow in later stages of that task. --- requirements.check.txt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/requirements.check.txt b/requirements.check.txt index 0c30d6329..d4bbb4f0f 100644 --- a/requirements.check.txt +++ b/requirements.check.txt @@ -28,6 +28,9 @@ openpyxl>=3.0.5 # End2end tests seleniumbase +# Screencast video generation (tests/screencast) +playwright + # psutil is needed to reap Uvicorn's zombie processes when running end2end # tests. One day someone finds a better solution. psutil From a615b5d80638aa1dec7d39624ea067883a09f085 Mon Sep 17 00:00:00 2001 From: Maryna Balioura Date: Sun, 5 Jul 2026 00:37:01 +0200 Subject: [PATCH 06/39] refactor(tests/screencast): start the demo server via SDocTestServer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the ad-hoc strictdoc_server.py (which shelled out to `tox -e py310-development` against a sibling `../strictdoc` checkout — an assumption that only made sense when this generator lived in the strictdoc-project.github.io repo) with the already-hardened tests/end2end/server.py::SDocTestServer, used as-is via its context-manager interface. Adds tests/screencast/fixture.py with the shared fixture-project and port constants (a separate RECORD_SERVER_PORT is reserved for the upcoming video-recording mode, so a manual dev server can stay open while a scenario re-records). run_server.py is rewritten to start/stop the demo server through SDocTestServer directly — no tox subprocess, no sibling-checkout assumption, no STRICTDOC_ROOT/STRICTDOC_TOX/pyenv-version env vars. Manually verified: the server starts against the demo fixture, serves HTTP 200, and shuts down cleanly on exit. --- tests/screencast/fixture.py | 16 +++ tests/screencast/run_server.py | 35 ++--- tests/screencast/strictdoc_server.py | 203 --------------------------- 3 files changed, 35 insertions(+), 219 deletions(-) create mode 100644 tests/screencast/fixture.py delete mode 100644 tests/screencast/strictdoc_server.py diff --git a/tests/screencast/fixture.py b/tests/screencast/fixture.py new file mode 100644 index 000000000..24ad8370c --- /dev/null +++ b/tests/screencast/fixture.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +from pathlib import Path + +SCREENCAST_DIR = Path(__file__).resolve().parent + +FIXTURE_DIR = SCREENCAST_DIR / "fixtures" / "strictdoc-demo-project" +FIXTURE_CONFIG = FIXTURE_DIR / "strictdoc_config.py" + +# Manual dev server: kept open while developing/inspecting a scene in the +# browser. +DEV_SERVER_PORT = 5111 + +# Recording server: a separate port so a manual dev server (above) can stay +# open while scenarios are (re)recorded. +RECORD_SERVER_PORT = 5112 diff --git a/tests/screencast/run_server.py b/tests/screencast/run_server.py index 52a5aacaf..37e04bea9 100644 --- a/tests/screencast/run_server.py +++ b/tests/screencast/run_server.py @@ -2,37 +2,40 @@ from __future__ import annotations +import os import sys -from strictdoc_server import ( - SERVER_URL, - start_server, - stop_server, - wait_for_server, +STRICTDOC_ROOT = os.path.abspath(os.path.join(__file__, "../../..")) +assert os.path.isdir(STRICTDOC_ROOT), STRICTDOC_ROOT +sys.path.insert(0, STRICTDOC_ROOT) + +from tests.end2end.server import SDocTestServer # noqa: E402 +from tests.screencast.fixture import ( # noqa: E402 + DEV_SERVER_PORT, + FIXTURE_CONFIG, + FIXTURE_DIR, ) def main() -> int: try: - server = start_server() - wait_for_server(SERVER_URL, server) - - print(f"🚀 StrictDoc demo server: {SERVER_URL}") - print(" Press Ctrl+C to stop.") + with SDocTestServer( + input_path=str(FIXTURE_DIR), + config_path=str(FIXTURE_CONFIG), + port=DEV_SERVER_PORT, + ) as server: + print(f"🚀 StrictDoc demo server: {server.get_host_and_port()}") + print(" Press Ctrl+C to stop.") - server.wait() + server.process.wait() except KeyboardInterrupt: print("\n🛑 Stopping StrictDoc demo server.") - except RuntimeError as error: + except OSError as error: print(error, file=sys.stderr) return 1 - finally: - if "server" in locals(): - stop_server(server) - return 0 diff --git a/tests/screencast/strictdoc_server.py b/tests/screencast/strictdoc_server.py deleted file mode 100644 index dd6694a95..000000000 --- a/tests/screencast/strictdoc_server.py +++ /dev/null @@ -1,203 +0,0 @@ -from __future__ import annotations - -import os -import shutil -import signal -import subprocess -import sys -import time -import urllib.parse -import urllib.request -from pathlib import Path - - -DEMO_DIR = Path(__file__).resolve().parent -REPO_ROOT = DEMO_DIR.parents[1] -DEFAULT_STRICTDOC_ROOT = REPO_ROOT / "strictdoc" - -STRICTDOC_ROOT = Path( - os.environ.get("STRICTDOC_ROOT", DEFAULT_STRICTDOC_ROOT) -).resolve() - -FIXTURE_DIR = DEMO_DIR / "fixtures" / "strictdoc-demo-project" -FIXTURE_CONFIG = FIXTURE_DIR / "strictdoc_config.py" - -SERVER_URL = os.environ.get("STRICTDOC_SITE_URL", "http://127.0.0.1:5111/") -VIDEO_SERVER_URL = os.environ.get( - "STRICTDOC_VIDEO_SITE_URL", "http://127.0.0.1:5112/" -) - -STRICTDOC_PYTHON_VERSION = os.environ.get( - "STRICTDOC_PYTHON_VERSION", "3.10.16" -) - - -def resolve_tox() -> Path | None: - # Order: explicit override, PATH, pyenv guess (for setups without pyenv shims on PATH). - env_override = os.environ.get("STRICTDOC_TOX") - if env_override: - return Path(env_override).resolve() - - on_path = shutil.which("tox") - if on_path: - return Path(on_path).resolve() - - pyenv_guess = ( - Path.home() - / ".pyenv" - / "versions" - / STRICTDOC_PYTHON_VERSION - / "bin" - / "tox" - ) - if pyenv_guess.exists(): - return pyenv_guess.resolve() - - return None - - -STRICTDOC_TOX = resolve_tox() - - -def is_server_available(url: str) -> bool: - try: - with urllib.request.urlopen(url, timeout=1): - return True - except Exception: - return False - - -def get_port_from_url(url: str) -> int: - parsed_url = urllib.parse.urlparse(url) - if parsed_url.port is None: - if parsed_url.scheme == "https": - return 443 - return 80 - return parsed_url.port - - -def find_processes_on_port(port: int) -> list[int]: - result = subprocess.run( - ["lsof", "-ti", f"tcp:{port}"], - check=False, - capture_output=True, - text=True, - ) - - if result.returncode != 0: - return [] - - return [int(pid) for pid in result.stdout.splitlines() if pid.strip()] - - -def stop_existing_server(url: str) -> None: - port = get_port_from_url(url) - process_ids = find_processes_on_port(port) - - if not process_ids: - return - - print(f"⚠️ StrictDoc server URL is already in use: {url}", file=sys.stderr) - print( - f" Stopping existing process(es) on port {port}: " - f"{', '.join(str(pid) for pid in process_ids)}", - file=sys.stderr, - ) - - for process_id in process_ids: - os.kill(process_id, signal.SIGTERM) - - started_at = time.time() - while time.time() - started_at < 10: - if not find_processes_on_port(port): - return - time.sleep(0.2) - - for process_id in find_processes_on_port(port): - os.kill(process_id, signal.SIGKILL) - - -def start_server(url: str = SERVER_URL) -> subprocess.Popen: - if not STRICTDOC_ROOT.exists(): - raise RuntimeError( - f"❌ StrictDoc repository not found: {STRICTDOC_ROOT}\n" - " Set STRICTDOC_ROOT to the path of your strictdoc checkout." - ) - - if STRICTDOC_TOX is None: - raise RuntimeError( - "❌ tox not found on PATH and no pyenv fallback matched.\n" - " Install tox (e.g. `pip install tox`) so it's on PATH, or set " - "STRICTDOC_TOX to its executable path." - ) - - stop_existing_server(url) - - port = get_port_from_url(url) - host = urllib.parse.urlparse(url).hostname or "127.0.0.1" - - env = os.environ.copy() - env["PYENV_VERSION"] = STRICTDOC_PYTHON_VERSION - - return subprocess.Popen( - [ - str(STRICTDOC_TOX), - "-e", - "py310-development", - "--", - "python", - "-m", - "strictdoc.cli.main", - "--debug", - "server", - str(FIXTURE_DIR), - "--config", - str(FIXTURE_CONFIG), - "--host", - host, - "--port", - str(port), - "--reload", - ], - cwd=STRICTDOC_ROOT, - env=env, - start_new_session=True, - ) - - -def wait_for_server( - url: str, server: subprocess.Popen, timeout_seconds: int = 180 -) -> None: - started_at = time.time() - - while time.time() - started_at < timeout_seconds: - if is_server_available(url): - return - - if server.poll() is not None: - raise RuntimeError( - "❌ StrictDoc server process exited before becoming available: " - f"exit code {server.returncode}" - ) - - time.sleep(0.5) - - raise RuntimeError( - f"⏳ Timed out waiting for StrictDoc server after {timeout_seconds}s: {url}" - ) - - -def stop_server(server: subprocess.Popen) -> None: - if server.poll() is not None: - return - - try: - os.killpg(server.pid, signal.SIGTERM) - server.wait(timeout=10) - except ProcessLookupError: - return - except subprocess.TimeoutExpired: - try: - os.killpg(server.pid, signal.SIGKILL) - except ProcessLookupError: - return From b236d4f275fd22ebde4a27d8d2a6684e6fa8ced9 Mon Sep 17 00:00:00 2001 From: Maryna Balioura Date: Sun, 5 Jul 2026 00:47:08 +0200 Subject: [PATCH 07/39] feat(tests/screencast): port demo scenarios to Playwright Python pytest tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the JSON-step scene definitions in the removed demo.js with two ordinary pytest test cases, following the tests/end2end/screens/*/test_case.py convention: each screencast scenario is now a real regression check, not just a replayed sequence of UI actions. - strictdoc_ui/test_case.py: browse a StrictDoc document, switch to the table view, and assert on document title, viewtype menu, and the resulting table view state. - ide_typing_to_table/test_case.py: the IDE-style fake-typing scene (demo.html) followed by the same table-view flow. - scenarios/conftest.py: a local `page` pytest fixture (headless Chromium via playwright.sync_api directly — no pytest-playwright plugin needed for this scope). - scenarios/typing.py: the fake-typing effect ported from demo.js's typeText() (appends to textContent; not real keyboard input). Note: tests/end2end's Screen_*/component Page Object helpers are tied to SeleniumBase (BaseCase, By.XPATH) and cannot be reused as-is by Playwright code. The same underlying selectors (#viewtype_handler, [data-viewtype_link="table"], body[data-viewtype], .header__document_title) are used directly instead, verified against the actually rendered demo fixture pages. Both scenarios currently run without recording video; recording mode is added in a follow-up stage. Manually verified: both tests pass under the `check` tox environment (playwright + chromium installed) against the demo fixture server. --- tests/screencast/scenarios/conftest.py | 27 ++++++++ .../ide_typing_to_table/test_case.py | 68 +++++++++++++++++++ .../scenarios/strictdoc_ui/test_case.py | 50 ++++++++++++++ tests/screencast/scenarios/typing.py | 21 ++++++ 4 files changed, 166 insertions(+) create mode 100644 tests/screencast/scenarios/conftest.py create mode 100644 tests/screencast/scenarios/ide_typing_to_table/test_case.py create mode 100644 tests/screencast/scenarios/strictdoc_ui/test_case.py create mode 100644 tests/screencast/scenarios/typing.py diff --git a/tests/screencast/scenarios/conftest.py b/tests/screencast/scenarios/conftest.py new file mode 100644 index 000000000..0be419996 --- /dev/null +++ b/tests/screencast/scenarios/conftest.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +import os +import sys +from typing import Iterator + +import pytest +from playwright.sync_api import Browser, Page, sync_playwright + +STRICTDOC_ROOT = os.path.abspath(os.path.join(__file__, "../../../..")) +assert os.path.isdir(STRICTDOC_ROOT), STRICTDOC_ROOT +sys.path.insert(0, STRICTDOC_ROOT) + + +@pytest.fixture +def page() -> Iterator[Page]: + with sync_playwright() as playwright: + browser: Browser = playwright.chromium.launch(headless=True) + context = browser.new_context( + viewport={"width": 1280, "height": 720} + ) + browser_page = context.new_page() + try: + yield browser_page + finally: + context.close() + browser.close() diff --git a/tests/screencast/scenarios/ide_typing_to_table/test_case.py b/tests/screencast/scenarios/ide_typing_to_table/test_case.py new file mode 100644 index 000000000..577277ec4 --- /dev/null +++ b/tests/screencast/scenarios/ide_typing_to_table/test_case.py @@ -0,0 +1,68 @@ +""" +Screencast scenario: an IDE-style typing scene followed by the StrictDoc UI +table view. + +If this test fails, the "ide-to-table" screencast video is stale and must +be re-recorded (see tests/screencast/README.md). +""" + +from __future__ import annotations + +import os + +from playwright.sync_api import Page, expect + +from tests.end2end.server import SDocTestServer +from tests.screencast.fixture import ( + FIXTURE_CONFIG, + FIXTURE_DIR, + RECORD_SERVER_PORT, +) +from tests.screencast.scenarios.typing import type_text + +SCENE_HTML = os.path.abspath( + os.path.join(__file__, "../../../demo.html") +) + +TYPED_TEXT = ( + "[DOCUMENT]\n" + "TITLE: Requirements\n" + "\n" + "[REQUIREMENT]\n" + "UID: REQ-001\n" + "TITLE: Export documentation\n" + "STATEMENT: StrictDoc shall export requirements to HTML.\n" +) + + +class Test: + def test(self, page: Page) -> None: + page.goto(f"file://{SCENE_HTML}") + demo_text = page.locator("#demoText") + expect(demo_text).to_be_visible() + + type_text(demo_text, TYPED_TEXT) + expect(demo_text).to_have_text(TYPED_TEXT) + + with SDocTestServer( + input_path=str(FIXTURE_DIR), + config_path=str(FIXTURE_CONFIG), + port=RECORD_SERVER_PORT, + ) as server: + base_url = server.get_host_and_port() + + page.goto( + f"{base_url}/strictdoc-demo-project/docs/requirements.html" + ) + expect(page.locator(".header__document_title")).to_contain_text( + "StrictDoc" + ) + + page.click("#viewtype_handler") + table_link = page.locator('[data-viewtype_link="table"]') + expect(table_link).to_be_visible() + table_link.click() + + expect(page.locator("body")).to_have_attribute( + "data-viewtype", "table" + ) diff --git a/tests/screencast/scenarios/strictdoc_ui/test_case.py b/tests/screencast/scenarios/strictdoc_ui/test_case.py new file mode 100644 index 000000000..838430564 --- /dev/null +++ b/tests/screencast/scenarios/strictdoc_ui/test_case.py @@ -0,0 +1,50 @@ +""" +Screencast scenario: browsing a StrictDoc document and switching to the +table view. + +If this test fails, the "strictdoc-ui" screencast video is stale and must +be re-recorded (see tests/screencast/README.md). +""" + +from __future__ import annotations + +from playwright.sync_api import Page, expect + +from tests.end2end.server import SDocTestServer +from tests.screencast.fixture import ( + FIXTURE_CONFIG, + FIXTURE_DIR, + RECORD_SERVER_PORT, +) + + +class Test: + def test(self, page: Page) -> None: + with SDocTestServer( + input_path=str(FIXTURE_DIR), + config_path=str(FIXTURE_CONFIG), + port=RECORD_SERVER_PORT, + ) as server: + base_url = server.get_host_and_port() + + page.goto(f"{base_url}/") + expect(page.locator("body")).to_be_visible() + + page.goto( + f"{base_url}/strictdoc-demo-project/docs/requirements.html" + ) + expect(page.locator(".header__document_title")).to_contain_text( + "StrictDoc" + ) + + page.click("#viewtype_handler") + table_link = page.locator('[data-viewtype_link="table"]') + expect(table_link).to_be_visible() + table_link.click() + + expect(page.locator("body")).to_have_attribute( + "data-viewtype", "table" + ) + expect( + page.locator('[data-testid="document-main-placeholder"]') + ).not_to_be_visible() diff --git a/tests/screencast/scenarios/typing.py b/tests/screencast/scenarios/typing.py new file mode 100644 index 000000000..1ca7052bb --- /dev/null +++ b/tests/screencast/scenarios/typing.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +import time + +from playwright.sync_api import Locator + + +def type_text(locator: Locator, text: str, delay_ms: int = 45) -> None: + """ + Fake typing effect for standalone HTML scenes (terminal/IDE look). + + Appends characters to the element's text content directly. This is a + visual effect only: it does not simulate keyboard input and does not + type into a real input, textarea, or code editor. + """ + + for char in text: + locator.evaluate( + "(el, char) => { el.textContent += char; }", char + ) + time.sleep(delay_ms / 1000) From 9c37ed6d1380f4f6b8d8ff20bc0fd96ffc4ac660 Mon Sep 17 00:00:00 2001 From: Maryna Balioura Date: Sun, 5 Jul 2026 01:31:20 +0200 Subject: [PATCH 08/39] feat(tests/screencast): port demo scenarios to Playwright Python pytest tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the JSON-step scene definitions in the removed demo.js with two ordinary pytest test cases, following the tests/end2end/screens/*/test_case.py convention: each screencast scenario is now a real regression check, not just a replayed sequence of UI actions. - strictdoc_ui/test_case.py: browse a StrictDoc document, switch to the table view, and assert on document title, viewtype menu, and the resulting table view state. - ide_typing_to_table/test_case.py: the IDE-style fake-typing scene (demo.html) followed by the same table-view flow. - scenarios/conftest.py: a local `page` pytest fixture (headless Chromium via playwright.sync_api directly — no pytest-playwright plugin needed for this scope). - scenarios/typing.py: the fake-typing effect ported from demo.js's typeText() (appends to textContent; not real keyboard input). tests/end2end's Screen_*/component Page Object helpers are tied to SeleniumBase (BaseCase, By.XPATH) and cannot be called from Playwright code. Instead of inlining locators per test, this adds new Playwright-native helpers under tests/screencast/helpers/ (Screen, ViewTypeSelector), mirroring the existing helpers' names, selectors, and responsibilities — meant for reuse across this suite now and as a starting point for a wider Playwright migration later, per SDG guidance to avoid one-off duplication once more than one call site exists. Both scenarios currently run without recording video; recording mode is added in a follow-up stage. Manually verified: both tests pass under the `check` tox environment (playwright + chromium installed) against the demo fixture server. --- developer/tasks/20260704_screencast/task.md | 17 +++++++++---- tests/screencast/helpers/screen.py | 23 +++++++++++++++++ tests/screencast/helpers/viewtype_selector.py | 25 +++++++++++++++++++ .../ide_typing_to_table/test_case.py | 15 ++++------- .../scenarios/strictdoc_ui/test_case.py | 15 ++++------- 5 files changed, 70 insertions(+), 25 deletions(-) create mode 100644 tests/screencast/helpers/screen.py create mode 100644 tests/screencast/helpers/viewtype_selector.py diff --git a/developer/tasks/20260704_screencast/task.md b/developer/tasks/20260704_screencast/task.md index 863e716dd..dbe218a65 100644 --- a/developer/tasks/20260704_screencast/task.md +++ b/developer/tasks/20260704_screencast/task.md @@ -72,11 +72,18 @@ to adapt, not a design to preserve as-is. `python -m strictdoc.cli.main server ...` in-process to this repository — no `tox` subprocess, no assumption of a sibling checkout. - **Scenario structure**: each demo scene becomes its own pytest test case, - following the existing `tests/end2end/screens/*/test_case.py` convention - (Page Object helpers such as `Screen_*` / `helpers/components/*` reused - where they already exist for the same UI areas). Standalone HTML/IDE-style - scenes (fake typing effect) keep using a local HTML playground, driven from - Python instead of `demo.js`. + following the existing `tests/end2end/screens/*/test_case.py` convention. + The existing `Screen_*` / `helpers/components/*` Page Object classes are + tied to SeleniumBase (`BaseCase`, `By.XPATH`) and cannot be called + directly from Playwright code — instead, new Playwright-native Page + Object helpers are introduced under `tests/screencast/helpers/`, mirroring + the existing helpers' names/selectors/responsibilities (verified against + the actually rendered UI and the existing SeleniumBase tests, which + describe real current UI behavior). These new helpers are written for + reuse across the screencast suite now, and as a base for a wider + Playwright suite later, not as one-off inline locators per test. + Standalone HTML/IDE-style scenes (fake typing effect) keep using a local + HTML playground, driven from Python instead of `demo.js`. - **Dual-purpose run modes**: - default run: scenario executes as a normal pass/fail check (fast, no video output) — a failure means the scenario (and its published video) diff --git a/tests/screencast/helpers/screen.py b/tests/screencast/helpers/screen.py new file mode 100644 index 000000000..d7f7b77e6 --- /dev/null +++ b/tests/screencast/helpers/screen.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +from playwright.sync_api import Page, expect + + +class Screen: + """ + Playwright counterpart of tests/end2end/helpers/screens/screen.py, + covering only what the screencast scenarios currently need. + """ + + def __init__(self, page: Page) -> None: + self.page = page + + def assert_on_screen(self, viewtype: str) -> None: + expect(self.page.locator("body")).to_have_attribute( + "data-viewtype", viewtype + ) + + def assert_header_document_title(self, title: str) -> None: + expect(self.page.locator(".header__document_title")).to_contain_text( + title + ) diff --git a/tests/screencast/helpers/viewtype_selector.py b/tests/screencast/helpers/viewtype_selector.py new file mode 100644 index 000000000..bc3930ae5 --- /dev/null +++ b/tests/screencast/helpers/viewtype_selector.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +from playwright.sync_api import Page, expect + +from tests.screencast.helpers.screen import Screen + + +class ViewTypeSelector: + """ + Playwright counterpart of + tests/end2end/helpers/components/viewtype_selector.py, covering only + what the screencast scenarios currently need. + """ + + def __init__(self, page: Page) -> None: + self.page = page + + def go_to_table(self) -> Screen: + self.page.click("#viewtype_handler") + + table_link = self.page.locator('[data-viewtype_link="table"]') + expect(table_link).to_be_visible() + table_link.click() + + return Screen(self.page) diff --git a/tests/screencast/scenarios/ide_typing_to_table/test_case.py b/tests/screencast/scenarios/ide_typing_to_table/test_case.py index 577277ec4..260e0c42e 100644 --- a/tests/screencast/scenarios/ide_typing_to_table/test_case.py +++ b/tests/screencast/scenarios/ide_typing_to_table/test_case.py @@ -18,6 +18,7 @@ FIXTURE_DIR, RECORD_SERVER_PORT, ) +from tests.screencast.helpers.viewtype_selector import ViewTypeSelector from tests.screencast.scenarios.typing import type_text SCENE_HTML = os.path.abspath( @@ -54,15 +55,9 @@ def test(self, page: Page) -> None: page.goto( f"{base_url}/strictdoc-demo-project/docs/requirements.html" ) - expect(page.locator(".header__document_title")).to_contain_text( - "StrictDoc" - ) - page.click("#viewtype_handler") - table_link = page.locator('[data-viewtype_link="table"]') - expect(table_link).to_be_visible() - table_link.click() + viewtype_selector = ViewTypeSelector(page) + screen_table = viewtype_selector.go_to_table() - expect(page.locator("body")).to_have_attribute( - "data-viewtype", "table" - ) + screen_table.assert_header_document_title("StrictDoc") + screen_table.assert_on_screen("table") diff --git a/tests/screencast/scenarios/strictdoc_ui/test_case.py b/tests/screencast/scenarios/strictdoc_ui/test_case.py index 838430564..494e76fe3 100644 --- a/tests/screencast/scenarios/strictdoc_ui/test_case.py +++ b/tests/screencast/scenarios/strictdoc_ui/test_case.py @@ -16,6 +16,7 @@ FIXTURE_DIR, RECORD_SERVER_PORT, ) +from tests.screencast.helpers.viewtype_selector import ViewTypeSelector class Test: @@ -33,18 +34,12 @@ def test(self, page: Page) -> None: page.goto( f"{base_url}/strictdoc-demo-project/docs/requirements.html" ) - expect(page.locator(".header__document_title")).to_contain_text( - "StrictDoc" - ) - page.click("#viewtype_handler") - table_link = page.locator('[data-viewtype_link="table"]') - expect(table_link).to_be_visible() - table_link.click() + viewtype_selector = ViewTypeSelector(page) + screen_table = viewtype_selector.go_to_table() - expect(page.locator("body")).to_have_attribute( - "data-viewtype", "table" - ) + screen_table.assert_header_document_title("StrictDoc") + screen_table.assert_on_screen("table") expect( page.locator('[data-testid="document-main-placeholder"]') ).not_to_be_visible() From 84436ebc6fe61060034ef8bfd64bc376a83d7935 Mon Sep 17 00:00:00 2001 From: Maryna Balioura Date: Sun, 5 Jul 2026 01:37:34 +0200 Subject: [PATCH 09/39] feat(tests/screencast): add --strictdoc-record-video mode to scenarios MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Makes each screencast scenario dual-purpose, as designed in developer/tasks/20260704_screencast/task.md: by default it's a fast pass/fail regression check with no video output; with --strictdoc-record-video, the same test also captures and saves a .webm. - fixture.py: add OUTPUT_DIR (tests/screencast/output/). - scenarios/conftest.py: add the --strictdoc-record-video pytest option; the `page` fixture enables Playwright's record_video_dir only when the flag is set, and on context close saves the recording as output/.webm (e.g. strictdoc_ui.webm), removing Playwright's temporary video file afterwards. Removed the two leftover .webm files from the old JS pipeline (strictdoc-ui.webm, ide-to-table.webm) — different naming scheme, no longer produced by this generator. Manually verified: scenarios pass both without the flag (no video written) and with it (both named .webm files produced, no stray temp files left in output/). --- tests/screencast/fixture.py | 2 ++ tests/screencast/scenarios/conftest.py | 43 +++++++++++++++++++++++--- 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/tests/screencast/fixture.py b/tests/screencast/fixture.py index 24ad8370c..4d5ae0398 100644 --- a/tests/screencast/fixture.py +++ b/tests/screencast/fixture.py @@ -7,6 +7,8 @@ FIXTURE_DIR = SCREENCAST_DIR / "fixtures" / "strictdoc-demo-project" FIXTURE_CONFIG = FIXTURE_DIR / "strictdoc_config.py" +OUTPUT_DIR = SCREENCAST_DIR / "output" + # Manual dev server: kept open while developing/inspecting a scene in the # browser. DEV_SERVER_PORT = 5111 diff --git a/tests/screencast/scenarios/conftest.py b/tests/screencast/scenarios/conftest.py index 0be419996..f53f13b7d 100644 --- a/tests/screencast/scenarios/conftest.py +++ b/tests/screencast/scenarios/conftest.py @@ -11,17 +11,52 @@ assert os.path.isdir(STRICTDOC_ROOT), STRICTDOC_ROOT sys.path.insert(0, STRICTDOC_ROOT) +from tests.screencast.fixture import OUTPUT_DIR # noqa: E402 + +VIEWPORT_SIZE = {"width": 1280, "height": 720} + + +def pytest_addoption(parser): + parser.addoption( + "--strictdoc-record-video", + action="store_true", + default=False, + help=( + "Record each screencast scenario's .webm video into " + "tests/screencast/output/ (named after the scenario's " + "directory)." + ), + ) + @pytest.fixture -def page() -> Iterator[Page]: +def page(request) -> Iterator[Page]: + record_video = request.config.getoption("--strictdoc-record-video") + with sync_playwright() as playwright: browser: Browser = playwright.chromium.launch(headless=True) - context = browser.new_context( - viewport={"width": 1280, "height": 720} - ) + + context_kwargs = {"viewport": VIEWPORT_SIZE} + if record_video: + OUTPUT_DIR.mkdir(parents=True, exist_ok=True) + context_kwargs["record_video_dir"] = str(OUTPUT_DIR) + context_kwargs["record_video_size"] = VIEWPORT_SIZE + + context = browser.new_context(**context_kwargs) browser_page = context.new_page() + try: yield browser_page finally: + video = browser_page.video context.close() + + if record_video and video is not None: + scenario_name = request.node.path.parent.name + output_video = OUTPUT_DIR / f"{scenario_name}.webm" + if output_video.exists(): + output_video.unlink() + video.save_as(output_video) + video.delete() + browser.close() From cd66b2ab89ec774f495036a515ce7e05f5f63d17 Mon Sep 17 00:00:00 2001 From: Maryna Balioura Date: Sun, 5 Jul 2026 10:49:49 +0200 Subject: [PATCH 10/39] feat(tasks): add invoke test-screencast task Adds `invoke test-screencast` (alias `tsc`) in tasks.py, following the same tox `check`-environment convention as test_end2end/test_unit_server (junit-xml under build/test_reports/, dedicated pytest cache dir). Options: - --focus: pytest -k filter, same as other test-* tasks. - --record-video: forwards --strictdoc-record-video to pytest, so the same invocation both runs the regression checks and (re)generates the .webm videos into tests/screencast/output/. Kept separate from invoke test-end2end per developer/tasks/20260704_screencast/task.md: screencast runs are slower/produce binary artifacts, and the scenarios always run headless (no headed/--headless flag needed, unlike SeleniumBase-based end2end tests). Manually verified: `invoke test-screencast` and `invoke test-screencast --record-video` both pass and behave as expected (2 passed; the latter produces strictdoc_ui.webm and ide_typing_to_table.webm in tests/screencast/output/). --- tasks.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/tasks.py b/tasks.py index 5916d5a78..ab54734c3 100644 --- a/tasks.py +++ b/tasks.py @@ -378,6 +378,34 @@ def test_end2end( ) +@task(aliases=["tsc"]) +def test_screencast(context, *, focus=None, record_video=False): + """ + Runs the tests/screencast scenarios: fast pass/fail checks by default, + or (re)recording of the corresponding .webm videos with --record-video. + """ + + focus_argument = f"-k {focus}" if focus is not None else "" + record_video_argument = ( + "--strictdoc-record-video" if record_video else "" + ) + + Path(TEST_REPORTS_DIR).mkdir(parents=True, exist_ok=True) + + test_command = f""" + pytest + --capture=no + {focus_argument} + {record_video_argument} + --junit-xml={TEST_REPORTS_DIR}/tests_screencast.pytest.junit.xml + -o junit_suite_name="StrictDoc Screencast Tests" + -o cache_dir=build/pytest_screencast + tests/screencast/scenarios + """ + + run_invoke_with_tox(context, ToxEnvironment.CHECK, test_command) + + @task(aliases=["tu"]) def test_unit(context, coverage=False, focus=None, path=None, output=False): """ From 49b14ddf7a74771ec8469ac8868002b43a9f4ca6 Mon Sep 17 00:00:00 2001 From: Maryna Balioura Date: Sun, 5 Jul 2026 11:45:35 +0200 Subject: [PATCH 11/39] docs(tests/screencast): rewrite README for the Python/Playwright pipeline Replaces the README carried over from strictdoc-project.github.io (which documented `npm install --prefix demo`, STRICTDOC_ROOT/STRICTDOC_TOX env vars, and a sibling-checkout setup) with docs matching the pipeline as it now exists in this repo: directory layout, the one-time `playwright install chromium` setup step, `invoke test-screencast` (with --focus/--record-video), the manual dev server via run_server.py, and a short guide for adding new scenarios using the helpers/ Page Objects. A failing scenario means its video can no longer be trusted, not that it should be re-recorded automatically: investigate first (it may be a product bug or an intentional UI change the scenario doesn't reflect yet), and re-record once the scenario is fixed or updated and passing again. Also fixes fixtures/strictdoc-demo-project/README.md, which still referred to a `demo/` directory that no longer exists. Manually verified: `invoke test-screencast` passes (2 passed) as a final end-to-end sanity check of the whole rewritten pipeline. --- tests/screencast/README.md | 166 ++++++++---------- .../fixtures/strictdoc-demo-project/README.md | 3 +- 2 files changed, 76 insertions(+), 93 deletions(-) diff --git a/tests/screencast/README.md b/tests/screencast/README.md index 08067cb65..2c0af33b7 100644 --- a/tests/screencast/README.md +++ b/tests/screencast/README.md @@ -1,119 +1,101 @@ -# Demo Video Pipeline - -This directory contains the tooling used to generate demo videos for the StrictDoc website. - -The goal is to keep the entire pipeline reproducible: - -- demo pages are defined as HTML/CSS/JS; -- browser interaction is scripted with Playwright; -- videos can be regenerated automatically after UI changes. +# Screencast scenarios + +This directory holds the StrictDoc demo-video pipeline: scenarios that +exercise real StrictDoc UI flows with Playwright, written in Python. + +Each scenario is an ordinary pytest test case, and it is dual-purpose: + +- run as a normal test, it verifies that the scenario still reflects real + UI behavior; +- run with `--record-video` (see below), the same test also captures and + saves a `.webm` video of the scenario. + +A failing scenario means its video can no longer be trusted: investigate +before re-recording — it may be a product bug, or an intentional UI change +the scenario doesn't reflect yet. Re-record once the scenario is fixed or +updated and passing again. + +## Directory layout + +```text +tests/screencast/ +├── fixture.py # shared fixture-project and port constants +├── fixtures/ +│ └── strictdoc-demo-project/ # small, self-contained StrictDoc project used by scenarios +├── helpers/ # Playwright Page Object helpers (Screen, ViewTypeSelector, ...) +├── scenarios/ +│ ├── conftest.py # `page` fixture (headless Chromium) + --strictdoc-record-video option +│ ├── typing.py # fake-typing effect for the IDE-style scene +│ ├── strictdoc_ui/test_case.py +│ └── ide_typing_to_table/test_case.py +├── demo.html # standalone HTML playground for IDE/terminal-style scenes +├── run_server.py # manual dev server for inspecting a scene in the browser +└── output/ # generated .webm videos (gitignored) +``` -The generated video assets are intended for the Hugo website and are not part of the StrictDoc application itself. +`tests/screencast/helpers/` contains Playwright Page Object helpers +(`Screen`, `ViewTypeSelector`, ...). New scenarios should use and extend +these rather than inlining locators. ## Setup -Install the Node dependencies and the Playwright browser binary,\ -from `strictdoc-project.github.io`: +Playwright's Python package is installed as part of the project's `check` +dependencies (`requirements.check.txt`). The Chromium browser binary is a +separate, one-time step: ```bash -npm install --prefix demo -npx --prefix demo playwright install chromium +playwright install chromium ``` -Only Chromium is needed — `demo.js` doesn't use other browsers. - -The demo server scripts also need a local checkout of the -[strictdoc](https://github.com/strictdoc-project/strictdoc) repository and -`tox` to run its dev environment: - -- by default, the StrictDoc checkout is expected as a sibling directory: - `../strictdoc` relative to this repository's root; -- `tox` is expected on `PATH` (e.g. `pip install tox`). +(Only Chromium is needed — scenarios don't use other browsers.) -If your setup differs, override with environment variables instead of -editing the scripts: - -| Variable | Purpose | Default | -| --- | --- | --- | -| `STRICTDOC_ROOT` | Path to the strictdoc checkout | `../strictdoc` (sibling of this repo) | -| `STRICTDOC_TOX` | Path to the `tox` executable | resolved from `PATH`, falling back to a pyenv-versions guess | -| `STRICTDOC_PYTHON_VERSION` | Python version used in the pyenv fallback above | `3.10.16` | -| `STRICTDOC_SITE_URL` | URL for the manual dev server | `http://127.0.0.1:5111/` | -| `STRICTDOC_VIDEO_SITE_URL` | URL for the temporary recording server | `http://127.0.0.1:5112/` | - -## Running the demo server - -For manual development and inspecting the demo in the browser,\ -from `strictdoc-project.github.io`: +## Running the scenarios ```bash -python3 demo/run_server.py +invoke test-screencast ``` -This starts the StrictDoc with the demo fixture at **5111**: -`http://127.0.0.1:5111`. +This runs every scenario in `tests/screencast/scenarios/` as a fast +pass/fail check, with no video output. -This server can stay open while developing or inspecting the demo in the browser. +Useful options: -The script: +| Option | Purpose | +| --- | --- | +| `--focus=` | Run only scenarios matching a pytest `-k` expression. | +| `--record-video` | Also (re)generate each scenario's `.webm` into `tests/screencast/output/`, named after the scenario's directory (e.g. `strictdoc_ui.webm`). | -- stops an existing demo server on the same port; -- starts StrictDoc with the fixture project; -- keeps the server running until stopped manually; -- shuts down the complete StrictDoc process tree on exit. - -## Recording the demo video - -To generate the demo video,\ -from `strictdoc-project.github.io`: +Example: ```bash -python3 demo/run_demo.py +invoke test-screencast --record-video ``` -The video recording uses a separate temporary StrictDoc server. - -Using a separate port allows the manual development server to stay open while regenerating videos. +## Running the demo server manually -The recording pipeline: +For inspecting a scene in the browser during development: -- starts a temporary StrictDoc server at **5112**: `http://127.0.0.1:5112`. -- waits until the server is available; -- runs the Playwright recording scenario; -- stops the complete StrictDoc server process tree afterwards. - -The generated videos are written to `demo/output/`. - -Each video scenario defines its own output file name. - -Each scenario defines: - -- the list of recorded steps; -- the output video file name. - -Scenarios can combine different sources, for example standalone HTML scenes -(for terminal or IDE-style recordings) and live StrictDoc UI screens. - -Standalone HTML scenes (`demo.html`) use a fake typing effect: characters are -appended directly to a page element's text content to *look* like typing. -This is a visual effect only — it does not simulate keyboard input and does -not type into a real input, textarea, or code editor. +```bash +python3 tests/screencast/run_server.py +``` -## StrictDoc server setup +This starts StrictDoc with the demo fixture project at +`http://127.0.0.1:5111` and keeps running until stopped with Ctrl+C. -The helper scripts start StrictDoc automatically. The server can also be started manually from the StrictDoc repository: +Scenario test runs use a separate port (`5112`, see `fixture.py`) so a +manual dev server can stay open while scenarios are (re)recorded. -```bash -cd /path/to/strictdoc - -invoke server \ - --input-path=/path/to/strictdoc-project.github.io/demo/fixtures/strictdoc-demo-project \ - --config=/path/to/strictdoc-project.github.io/demo/fixtures/strictdoc-demo-project/strictdoc_config.py -``` +## Adding a new scenario -The `input-path` argument points to the demo project root. The `--config` argument points to the demo project's configuration file. Both are required. +1. Add fixture content under `fixtures/strictdoc-demo-project/`, if needed. +2. Create `tests/screencast/scenarios//test_case.py` with a + `Test.test(self, page)` method, using the `helpers/` Page Objects (add + new ones there if the scenario needs UI areas not covered yet). +3. Run `invoke test-screencast --record-video --focus=` to + verify the recording. -*Note*: `invoke server` always uses the default StrictDoc server port. -The video recording script starts StrictDoc through the CLI directly because -it needs a separate temporary port. The `invoke server` wrapper does not expose -port configuration. +Standalone HTML/IDE-style scenes (fake typing effect, terminal look) use +`demo.html` as a local playground: characters are appended directly to a +page element's text content to *look* like typing. This is a visual effect +only — it does not simulate keyboard input and does not type into a real +input, textarea, or code editor. diff --git a/tests/screencast/fixtures/strictdoc-demo-project/README.md b/tests/screencast/fixtures/strictdoc-demo-project/README.md index 60f456946..a84391045 100644 --- a/tests/screencast/fixtures/strictdoc-demo-project/README.md +++ b/tests/screencast/fixtures/strictdoc-demo-project/README.md @@ -1,6 +1,7 @@ # StrictDoc Demo Project -This project is used by the video generation pipeline in `demo/`. +This project is used by the screencast scenario/video generation pipeline +in `tests/screencast/`. ## Directory layout From b42342a8072e0e1b4e5adc063ffa0563a53ee517 Mon Sep 17 00:00:00 2001 From: Maryna Balioura Date: Sun, 5 Jul 2026 11:47:13 +0200 Subject: [PATCH 12/39] docs(developer/tasks/20260704_screencast): tighten task.md to the template - Rewrites WHAT/WHY/HOW to match developer/tasks/task.template.md - Removes narrative that didn't belong in a task specification --- developer/tasks/20260704_screencast/task.md | 154 +++++++------------- 1 file changed, 53 insertions(+), 101 deletions(-) diff --git a/developer/tasks/20260704_screencast/task.md b/developer/tasks/20260704_screencast/task.md index dbe218a65..5916fe140 100644 --- a/developer/tasks/20260704_screencast/task.md +++ b/developer/tasks/20260704_screencast/task.md @@ -2,115 +2,67 @@ ## WHAT -StrictDoc's product website needs demo screencasts (`.webm` videos) that show -real, up-to-date UI behavior (web UI, CLI, IDE-style scenes). The pipeline -that produces these videos must live inside the StrictDoc repository so it -stays in sync with the actual product, and it must double as a regression -signal: if a recorded scenario fails, the corresponding published video is -considered outdated. - -Success criteria: - -- A `tests/screencast/` sub-project exists in the StrictDoc repository, - written in Python, with no Node.js/npm dependency. -- Each demo scenario is expressed as a scenario/test case that: - - passes or fails like a regular automated check (usable as a staleness - signal for the corresponding video), and - - can optionally be run in "recording" mode to (re)generate the - corresponding `.webm` video file. -- A dedicated `invoke` task (e.g. `invoke test-screencast`) runs the - screencast scenarios; it is not folded into `invoke test-end2end`. -- The scenario harness starts and stops a real StrictDoc server itself - (in-process to the repository, no dependency on a sibling checkout or a - separate `tox` invocation of another repo). -- The demo fixture project used for recording is checked into - `tests/screencast/fixtures/`, cleaned of unrelated build/cache artifacts. -- Delivery of generated videos to the product website - (`strictdoc-project.github.io`) is explicitly OUT OF SCOPE for this task; - this task only produces the videos inside the StrictDoc repository. +- Demo screencasts (`.webm` videos) can be generated entirely from within + the StrictDoc repository, without depending on another repository or a + separate checkout. +- The generator has no Node.js/npm dependency. +- Each demo scenario is an automated check that: + - passes or fails according to whether it still reflects real, current + UI behavior, and + - can optionally run in a recording mode that (re)generates the + scenario's video. +- A failing scenario means its video can no longer be trusted and must be + reviewed before being re-recorded. +- Screencast scenarios run independently from the main end-to-end test + suite, without being folded into it. +- Each scenario runs against a real, live instance of the application. +- The project used for recording is self-contained within the repository. +- Delivering generated videos to the product website is OUT OF SCOPE for + this task; this task only produces the videos within the StrictDoc + repository. ## WHY -The video generator currently lives in the `strictdoc-project.github.io` -(Hugo) repository and drives a *separate* StrictDoc checkout via `tox` to -record scenes. This means: +StrictDoc needs a maintainable way to generate product demo videos that stay +synchronized with the actual UI. -- videos can silently go stale after StrictDoc UI changes, with no signal; -- the generator's environment assumptions (sibling checkout, `tox` on PATH, - hardcoded Python version) are fragile and only make sense from outside the - StrictDoc repo; -- introducing new demo scenarios or updating them requires cross-repo - coordination instead of living next to the code it demonstrates. +Video scenarios should live next to the features they demonstrate and run +through the same automation pipeline as UI tests. -Moving the generator into StrictDoc, and expressing scenarios as ordinary -test cases, ties video freshness directly to the same CI/dev signal already -used for UI regressions, and lets one scenario definition serve both as a -regression check and as a video source. +A single scenario definition can then serve both as a source for generating +videos and as a regression check for the demonstrated workflow. ## HOW -### Current state (starting point, not a spec) - -`tests/screencast/` currently holds a raw copy of the website repo's -generator: `demo.js` (Playwright JS, defines scenes as a JSON-like step list -and renders `.webm` via `context.newContext({ recordVideo })`), -`strictdoc_server.py`/`run_server.py`/`run_demo.py` (spawns StrictDoc through -`tox -e py310-development` against a *sibling* `../strictdoc` checkout), and -a copy of the `strictdoc-demo-project` fixture (including stray -`__pycache__`/`.DS_Store`/cache build output). This code is a starting point -to adapt, not a design to preserve as-is. - -### Target design - -- **Language**: rewrite the generator in Python using the Playwright Python - API (`playwright.sync_api`), including `record_video_dir` for video - capture. Drop `demo.js`, `package.json`, `package-lock.json`, - `node_modules` — no Node.js/npm dependency remains in the project. -- **Server lifecycle**: reuse the existing, already-hardened - `tests/end2end/server.py::SDocTestServer` (or a thin adaptation of it) - to start/stop a StrictDoc server directly via - `python -m strictdoc.cli.main server ...` in-process to this repository — - no `tox` subprocess, no assumption of a sibling checkout. -- **Scenario structure**: each demo scene becomes its own pytest test case, - following the existing `tests/end2end/screens/*/test_case.py` convention. - The existing `Screen_*` / `helpers/components/*` Page Object classes are - tied to SeleniumBase (`BaseCase`, `By.XPATH`) and cannot be called - directly from Playwright code — instead, new Playwright-native Page - Object helpers are introduced under `tests/screencast/helpers/`, mirroring - the existing helpers' names/selectors/responsibilities (verified against - the actually rendered UI and the existing SeleniumBase tests, which - describe real current UI behavior). These new helpers are written for - reuse across the screencast suite now, and as a base for a wider - Playwright suite later, not as one-off inline locators per test. - Standalone HTML/IDE-style scenes (fake typing effect) keep using a local - HTML playground, driven from Python instead of `demo.js`. +- **Language**: the generator is written in Python using the Playwright + Python API (`playwright.sync_api`), including `record_video_dir` for + video capture. No Node.js/npm dependency exists in the project. +- **Server lifecycle**: scenarios start/stop a real StrictDoc server through + `tests/end2end/server.py::SDocTestServer`, in-process to this repository. +- **Scenario structure**: each demo scene is its own pytest test case, + following the `tests/end2end/screens/*/test_case.py` convention. + Playwright-native Page Object helpers live under + `tests/screencast/helpers/` (e.g. `Screen`, `ViewTypeSelector`), for reuse + across screencast scenarios and as a foundation for a wider Playwright + suite later. Standalone HTML/IDE-style scenes (fake typing effect) use a + local HTML playground (`demo.html`). - **Dual-purpose run modes**: - - default run: scenario executes as a normal pass/fail check (fast, no - video output) — a failure means the scenario (and its published video) - is stale; - - recording run: an explicit flag/env var (e.g. - `--strictdoc-record-video`) makes the same scenario also capture and - save a `.webm` to `tests/screencast/output/`. -- **Invoke task**: add `invoke test-screencast` (with a short alias) in - `tasks.py`, following the conventions of `test_end2end`/`test_unit_server` - (headless option, focus filter, junit output under `TEST_REPORTS_DIR`). - Kept separate from `invoke test-end2end` since screencast runs are - slower/heavier and produce binary artifacts. -- **Dependencies**: add `playwright` (Python package) to `pyproject.toml` as - a dev/test dependency; document the `playwright install chromium` setup - step (Chromium only, matching current generator's needs). -- **Fixtures**: keep `strictdoc-demo-project` as the demo content, but strip - `__pycache__`, `.DS_Store`, and any generated `output/_cache` before - committing, aligning its shape with other `tests/end2end` fixtures. + - default run: scenario executes as a normal pass/fail check, fast, with + no video output; + - recording run: the `--strictdoc-record-video` pytest option makes the + same scenario also capture and save a `.webm` to + `tests/screencast/output/`. +- **Invoke task**: `invoke test-screencast` (alias `tsc`) in `tasks.py`, + following the conventions of `test_end2end`/`test_unit_server` (focus + filter, junit output under `TEST_REPORTS_DIR`). Kept separate from + `invoke test-end2end`: screencast runs are slower and produce binary + artifacts. +- **Dependencies**: `playwright` (Python package) is a `check`-environment + dependency (`requirements.check.txt`); the Chromium browser binary is + installed separately via `playwright install chromium`. +- **Fixtures**: `tests/screencast/fixtures/strictdoc-demo-project/` is the + demo content used by scenarios, shaped like other `tests/end2end` + fixtures. - **Out of scope**: publishing/copying generated `.webm` files to `strictdoc-project.github.io`; migrating the rest of the e2e suite off - SeleniumBase (future direction, only considered here to avoid decisions - that would conflict with it, not implemented). - -### Working agreement for this task - -Implementation proceeds in discrete stages; each stage stops for review and -a commit before starting the next. Open questions encountered during -implementation are raised with the user first rather than resolved by -assumption or open-ended codebase research, unless the user asks for deeper -investigation. + SeleniumBase (a separate, future initiative). From 299e1bbe4e6334237b2f42f7d040fbc87767d2fe Mon Sep 17 00:00:00 2001 From: Maryna Balioura Date: Sun, 5 Jul 2026 12:16:33 +0200 Subject: [PATCH 13/39] feat(tests/screencast): auto-stop or safely report the process on the dev port --- tests/screencast/README.md | 5 ++ tests/screencast/run_server.py | 94 ++++++++++++++++++++++++++++++++++ 2 files changed, 99 insertions(+) diff --git a/tests/screencast/README.md b/tests/screencast/README.md index 2c0af33b7..6c3f1d365 100644 --- a/tests/screencast/README.md +++ b/tests/screencast/README.md @@ -82,6 +82,11 @@ python3 tests/screencast/run_server.py This starts StrictDoc with the demo fixture project at `http://127.0.0.1:5111` and keeps running until stopped with Ctrl+C. +If the port is already taken by a previous run of this same demo server, it +is stopped automatically. If it's taken by anything else, `run_server.py` +refuses to touch it and prints the occupying process(es) along with a +`kill` command to stop them yourself. + Scenario test runs use a separate port (`5112`, see `fixture.py`) so a manual dev server can stay open while scenarios are (re)recorded. diff --git a/tests/screencast/run_server.py b/tests/screencast/run_server.py index 37e04bea9..5f817caa1 100644 --- a/tests/screencast/run_server.py +++ b/tests/screencast/run_server.py @@ -3,12 +3,17 @@ from __future__ import annotations import os +import subprocess import sys +import time +from typing import List STRICTDOC_ROOT = os.path.abspath(os.path.join(__file__, "../../..")) assert os.path.isdir(STRICTDOC_ROOT), STRICTDOC_ROOT sys.path.insert(0, STRICTDOC_ROOT) +import psutil # noqa: E402 + from tests.end2end.server import SDocTestServer # noqa: E402 from tests.screencast.fixture import ( # noqa: E402 DEV_SERVER_PORT, @@ -17,8 +22,97 @@ ) +def find_pids_on_port(port: int) -> List[int]: + result = subprocess.run( + ["lsof", "-ti", f"tcp:{port}"], + check=False, + capture_output=True, + text=True, + ) + if result.returncode != 0: + return [] + return [int(pid) for pid in result.stdout.split()] + + +def describe_process(pid: int) -> str: + try: + cmdline = " ".join(psutil.Process(pid).cmdline()) + except psutil.NoSuchProcess: + return f"PID {pid} (no longer running)" + return f"PID {pid}: {cmdline}" + + +def is_previous_demo_server(pid: int) -> bool: + try: + cmdline = " ".join(psutil.Process(pid).cmdline()) + except psutil.NoSuchProcess: + return False + return str(FIXTURE_DIR) in cmdline + + +def collect_pid_tree(pid: int) -> List[int]: + try: + process = psutil.Process(pid) + except psutil.NoSuchProcess: + return [pid] + return [pid] + [child.pid for child in process.children(recursive=True)] + + +def free_dev_port(port: int) -> None: + """ + If `port` is occupied by a previous run of this same demo server + (matched by the fixture path in its command line), stop it. If it's + occupied by anything else, raise instead of touching it. + """ + + pids = find_pids_on_port(port) + if not pids: + return + + foreign_pids = [pid for pid in pids if not is_previous_demo_server(pid)] + if foreign_pids: + details = "\n".join(f" {describe_process(pid)}" for pid in foreign_pids) + + pid_tree: List[int] = [] + for pid in foreign_pids: + pid_tree.extend(collect_pid_tree(pid)) + + kill_command = "kill " + " ".join(str(pid) for pid in pid_tree) + force_kill_command = "kill -9 " + " ".join(str(pid) for pid in pid_tree) + raise OSError( + f"Port {port} is occupied by another process, not a previous " + f"run of this demo server:\n{details}\n" + "Stop it yourself if you want to reuse this port, e.g.:\n" + f" {kill_command}\n" + "If it doesn't respond (e.g. it has an active reloader " + "process), force it:\n" + f" {force_kill_command}" + ) + + for pid in pids: + print(f"🔁 Stopping a previous demo server on port {port} ({pid}).") + try: + psutil.Process(pid).terminate() + except psutil.NoSuchProcess: + continue + + deadline = time.monotonic() + 5 + while time.monotonic() < deadline: + if not find_pids_on_port(port): + return + time.sleep(0.1) + + for pid in find_pids_on_port(port): + try: + psutil.Process(pid).kill() + except psutil.NoSuchProcess: + continue + + def main() -> int: try: + free_dev_port(DEV_SERVER_PORT) + with SDocTestServer( input_path=str(FIXTURE_DIR), config_path=str(FIXTURE_CONFIG), From 7a8c2861c116a9b8d26684c44fbbec9d9da5384e Mon Sep 17 00:00:00 2001 From: Maryna Balioura Date: Sun, 5 Jul 2026 12:48:40 +0200 Subject: [PATCH 14/39] feat(tasks): add invoke screencast-server; fix README's launch instructions run_server.py imports project dependencies (psutil, watchdog via strictdoc itself) that only exist in this project's tox environments, not in a bare system `python3`. Running it directly with `python3 tests/screencast/run_server.py` fails with `ModuleNotFoundError: No module named 'watchdog'`. Adds `invoke screencast-server` (alias `scs`), following the same pattern as the existing `invoke server` task: runs run_server.py through the `check` tox environment (the one with psutil/playwright/etc., matching `invoke test-screencast`), so it has the dependencies it needs. Updates tests/screencast/README.md to document `invoke screencast-server` instead of the bare `python3` invocation. Manually verified: `invoke screencast-server` starts the demo server and serves HTTP 200 against the fixture project; `invoke test-screencast` still passes end-to-end after these changes. --- tasks.py | 14 ++++++++++++++ tests/screencast/README.md | 4 ++-- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/tasks.py b/tasks.py index ab54734c3..111fc4e3d 100644 --- a/tasks.py +++ b/tasks.py @@ -179,6 +179,20 @@ def server(context, input_path=".", config=None, port=None): ) +@task(aliases=["scs"]) +def screencast_server(context): + """ + Manual dev server for tests/screencast scenarios: starts StrictDoc with + the screencast demo fixture, for inspecting a scene in the browser. + """ + + run_invoke_with_tox( + context, + ToxEnvironment.CHECK, + "python tests/screencast/run_server.py", + ) + + @task(aliases=["d"]) def docs(context): run_invoke_with_tox( diff --git a/tests/screencast/README.md b/tests/screencast/README.md index 6c3f1d365..1baa6ef51 100644 --- a/tests/screencast/README.md +++ b/tests/screencast/README.md @@ -76,14 +76,14 @@ invoke test-screencast --record-video For inspecting a scene in the browser during development: ```bash -python3 tests/screencast/run_server.py +invoke screencast-server ``` This starts StrictDoc with the demo fixture project at `http://127.0.0.1:5111` and keeps running until stopped with Ctrl+C. If the port is already taken by a previous run of this same demo server, it -is stopped automatically. If it's taken by anything else, `run_server.py` +is stopped automatically. If it's taken by anything else, the command refuses to touch it and prints the occupying process(es) along with a `kill` command to stop them yourself. From 685ecd02a38c5801f09213fe01c500243536c232 Mon Sep 17 00:00:00 2001 From: Maryna Balioura Date: Sun, 5 Jul 2026 13:14:55 +0200 Subject: [PATCH 15/39] fix(tests/screencast): move dev/record ports off StrictDoc's own defaults MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DEV_SERVER_PORT was 5111 — StrictDoc's own default project server port (ProjectConfigDefault.DEFAULT_SERVER_PORT), the same one `invoke server` uses. Running `invoke screencast-server` and `invoke server` at the same time (or leaving one running) collides on that port; this is what caused `invoke server`'s "Address already in use" failure. RECORD_SERVER_PORT (5112) had the same problem one level down: it's tests/end2end's SDocTestServer default fallback port, used by any end2end test that doesn't pass an explicit port. Moves both ports to 5301/5302, clear of StrictDoc's own default (5111) and the end2end port range (5112-5212). Updates tests/screencast/README.md's port references to match. Manually verified: `invoke test-screencast` (2 passed, server on 5302) and `invoke screencast-server` (serves HTTP 200 on 5301) both work on the new ports, and leave 5111 untouched. --- tests/screencast/README.md | 4 ++-- tests/screencast/fixture.py | 8 ++++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/tests/screencast/README.md b/tests/screencast/README.md index 1baa6ef51..9861e4111 100644 --- a/tests/screencast/README.md +++ b/tests/screencast/README.md @@ -80,14 +80,14 @@ invoke screencast-server ``` This starts StrictDoc with the demo fixture project at -`http://127.0.0.1:5111` and keeps running until stopped with Ctrl+C. +`http://127.0.0.1:5301` and keeps running until stopped with Ctrl+C. If the port is already taken by a previous run of this same demo server, it is stopped automatically. If it's taken by anything else, the command refuses to touch it and prints the occupying process(es) along with a `kill` command to stop them yourself. -Scenario test runs use a separate port (`5112`, see `fixture.py`) so a +Scenario test runs use a separate port (`5302`, see `fixture.py`) so a manual dev server can stay open while scenarios are (re)recorded. ## Adding a new scenario diff --git a/tests/screencast/fixture.py b/tests/screencast/fixture.py index 4d5ae0398..63b65aed8 100644 --- a/tests/screencast/fixture.py +++ b/tests/screencast/fixture.py @@ -11,8 +11,12 @@ # Manual dev server: kept open while developing/inspecting a scene in the # browser. -DEV_SERVER_PORT = 5111 +# +# Deliberately outside both StrictDoc's own default server port (5111) and +# the tests/end2end server port range (5112-5212), so this doesn't collide +# with `invoke server` or the end2end test suite. +DEV_SERVER_PORT = 5301 # Recording server: a separate port so a manual dev server (above) can stay # open while scenarios are (re)recorded. -RECORD_SERVER_PORT = 5112 +RECORD_SERVER_PORT = 5302 From d009d81e397112ffac4b4ad4628ac8b4f60c73a6 Mon Sep 17 00:00:00 2001 From: Maryna Balioura Date: Sun, 5 Jul 2026 20:01:57 +0200 Subject: [PATCH 16/39] ci(screencast): add dedicated GitHub Actions workflow for invoke test-screencast MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds .github/workflows/screencast-tests.yml, separate from end2end-tests.yml: sets up the `check` tox environment, installs Playwright's Chromium binary (not covered by pip install), then runs `invoke test-screencast`. Without this, the WHY in developer/tasks/20260704_screencast/task.md ("ties video freshness directly to the same CI/dev signal already used for UI regressions") was aspirational rather than true — nothing ran these scenarios automatically. Also fixes lint issues in tests/screencast/run_server.py surfaced by `invoke lint`: dropped the unused shebang (the script is only ever invoked via `python tests/screencast/run_server.py`, never executed directly), switched `List[...]` to `list[...]`, and added `# noqa: T201` to its prints, matching the rest of the codebase's convention for intentional console output. Manually verified: each workflow step run locally in sequence (tox --notest, pip_install_strictdoc_deps.py, playwright install chromium, invoke test-screencast) succeeds; `ruff check tests/screencast/` is clean. --- .github/workflows/screencast-tests.yml | 42 ++++++++++++++++++++++++++ tasks.py | 4 +-- tests/screencast/run_server.py | 23 +++++++------- 3 files changed, 55 insertions(+), 14 deletions(-) create mode 100644 .github/workflows/screencast-tests.yml diff --git a/.github/workflows/screencast-tests.yml b/.github/workflows/screencast-tests.yml new file mode 100644 index 000000000..4180c925d --- /dev/null +++ b/.github/workflows/screencast-tests.yml @@ -0,0 +1,42 @@ +name: "StrictDoc - Screencast tests" + +on: + pull_request: + branches: [ "**" ] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + tests_screencast: + name: Screencast + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 5 + + - name: Set up Python 3.10 + uses: actions/setup-python@v6 + with: + python-version: "3.10" + + - name: Upgrade pip + run: | + python -m pip install --upgrade pip + + - name: Install minimal Python packages + run: | + pip install -r requirements.bootstrap.txt + + - name: Prepare the check tox environment and Playwright's browser binary + run: | + tox -e py310-check --notest + ./build/tox/py310-check/bin/python developer/pip_install_strictdoc_deps.py + ./build/tox/py310-check/bin/python -m playwright install --with-deps chromium + + - name: Run screencast tests + run: | + invoke test-screencast diff --git a/tasks.py b/tasks.py index 111fc4e3d..4fea320f1 100644 --- a/tasks.py +++ b/tasks.py @@ -400,9 +400,7 @@ def test_screencast(context, *, focus=None, record_video=False): """ focus_argument = f"-k {focus}" if focus is not None else "" - record_video_argument = ( - "--strictdoc-record-video" if record_video else "" - ) + record_video_argument = "--strictdoc-record-video" if record_video else "" Path(TEST_REPORTS_DIR).mkdir(parents=True, exist_ok=True) diff --git a/tests/screencast/run_server.py b/tests/screencast/run_server.py index 5f817caa1..1508fafa9 100644 --- a/tests/screencast/run_server.py +++ b/tests/screencast/run_server.py @@ -1,12 +1,9 @@ -#!/usr/bin/env python3 - from __future__ import annotations import os import subprocess import sys import time -from typing import List STRICTDOC_ROOT = os.path.abspath(os.path.join(__file__, "../../..")) assert os.path.isdir(STRICTDOC_ROOT), STRICTDOC_ROOT @@ -22,7 +19,7 @@ ) -def find_pids_on_port(port: int) -> List[int]: +def find_pids_on_port(port: int) -> list[int]: result = subprocess.run( ["lsof", "-ti", f"tcp:{port}"], check=False, @@ -50,7 +47,7 @@ def is_previous_demo_server(pid: int) -> bool: return str(FIXTURE_DIR) in cmdline -def collect_pid_tree(pid: int) -> List[int]: +def collect_pid_tree(pid: int) -> list[int]: try: process = psutil.Process(pid) except psutil.NoSuchProcess: @@ -73,7 +70,7 @@ def free_dev_port(port: int) -> None: if foreign_pids: details = "\n".join(f" {describe_process(pid)}" for pid in foreign_pids) - pid_tree: List[int] = [] + pid_tree: list[int] = [] for pid in foreign_pids: pid_tree.extend(collect_pid_tree(pid)) @@ -90,7 +87,9 @@ def free_dev_port(port: int) -> None: ) for pid in pids: - print(f"🔁 Stopping a previous demo server on port {port} ({pid}).") + print( # noqa: T201 + f"🔁 Stopping a previous demo server on port {port} ({pid})." + ) try: psutil.Process(pid).terminate() except psutil.NoSuchProcess: @@ -118,16 +117,18 @@ def main() -> int: config_path=str(FIXTURE_CONFIG), port=DEV_SERVER_PORT, ) as server: - print(f"🚀 StrictDoc demo server: {server.get_host_and_port()}") - print(" Press Ctrl+C to stop.") + print( # noqa: T201 + f"🚀 StrictDoc demo server: {server.get_host_and_port()}" + ) + print(" Press Ctrl+C to stop.") # noqa: T201 server.process.wait() except KeyboardInterrupt: - print("\n🛑 Stopping StrictDoc demo server.") + print("\n🛑 Stopping StrictDoc demo server.") # noqa: T201 except OSError as error: - print(error, file=sys.stderr) + print(error, file=sys.stderr) # noqa: T201 return 1 return 0 From 577806ca2b299eb3982c2618b874f46bc1f28ac7 Mon Sep 17 00:00:00 2001 From: Maryna Balioura Date: Mon, 6 Jul 2026 13:30:46 +0200 Subject: [PATCH 17/39] feat(tests/screencast): add focusable manual server - `invoke screencast-server` gains `--focus=` to manually preview scenarios that don't use the shared fixture project (like hello_world, which generates its own project on demand into the gitignored build/screencast_manual// and reuses it across restarts). --- tasks.py | 11 ++++--- tests/screencast/README.md | 39 ++++++++++++++++++++++ tests/screencast/fixture.py | 8 +++++ tests/screencast/manual_scenarios.py | 48 ++++++++++++++++++++++++++++ tests/screencast/run_server.py | 42 +++++++++++++++++++----- 5 files changed, 136 insertions(+), 12 deletions(-) create mode 100644 tests/screencast/manual_scenarios.py diff --git a/tasks.py b/tasks.py index 4fea320f1..c8b88a8d5 100644 --- a/tasks.py +++ b/tasks.py @@ -180,16 +180,19 @@ def server(context, input_path=".", config=None, port=None): @task(aliases=["scs"]) -def screencast_server(context): +def screencast_server(context, focus=None): """ - Manual dev server for tests/screencast scenarios: starts StrictDoc with - the screencast demo fixture, for inspecting a scene in the browser. + Manual dev server for tests/screencast scenarios: starts StrictDoc on a + scenario's project (the shared demo fixture by default, or another + scenario's project via --focus), for inspecting it in the browser. """ + focus_argument = f"--focus {focus}" if focus is not None else "" + run_invoke_with_tox( context, ToxEnvironment.CHECK, - "python tests/screencast/run_server.py", + f"python tests/screencast/run_server.py {focus_argument}", ) diff --git a/tests/screencast/README.md b/tests/screencast/README.md index 9861e4111..c2aa2429a 100644 --- a/tests/screencast/README.md +++ b/tests/screencast/README.md @@ -90,6 +90,45 @@ refuses to touch it and prints the occupying process(es) along with a Scenario test runs use a separate port (`5302`, see `fixture.py`) so a manual dev server can stay open while scenarios are (re)recorded. +### Previewing a scenario that doesn't use the shared fixture + +Some scenarios (e.g. `hello_world`) don't browse the shared fixture +project — they generate their own project from scratch (via a real +`strictdoc new` call) inside the test itself, in a pytest `tmp_path` that's +deleted right after the test finishes. There's nothing left on disk +afterwards to point a manual server at. + +To preview one of these, pass `--focus=`: + +```bash +invoke screencast-server --focus=hello_world +``` + +This looks up the scenario in `tests/screencast/manual_scenarios.py`, +which knows how to (re)create that scenario's project on demand, and +serves it on the same port/URL as the default case +(`http://127.0.0.1:5301`). + +The generated project is written to +`build/screencast_manual//` — a disposable, gitignored +build artifact (see `/build/` in `.gitignore`), **not** a fixture. It's +left in place between runs (instead of being regenerated every time) so +that: + +- restarting the manual server doesn't rerun the scenario's setup (e.g. + `strictdoc new`) and doesn't hit its refusal to overwrite existing + files; +- any manual edits made through the UI while inspecting the scene survive + a server restart. + +Delete `build/screencast_manual/` (or the specific scenario's +subdirectory) at any time to reset it back to a clean, freshly generated +state on the next run. + +Adding a new scenario that needs this kind of on-demand project also means +adding an entry for it to the `SCENARIOS` registry in +`tests/screencast/manual_scenarios.py`. + ## Adding a new scenario 1. Add fixture content under `fixtures/strictdoc-demo-project/`, if needed. diff --git a/tests/screencast/fixture.py b/tests/screencast/fixture.py index 63b65aed8..beb13efb1 100644 --- a/tests/screencast/fixture.py +++ b/tests/screencast/fixture.py @@ -3,12 +3,20 @@ from pathlib import Path SCREENCAST_DIR = Path(__file__).resolve().parent +STRICTDOC_ROOT = SCREENCAST_DIR.parent.parent FIXTURE_DIR = SCREENCAST_DIR / "fixtures" / "strictdoc-demo-project" FIXTURE_CONFIG = FIXTURE_DIR / "strictdoc_config.py" OUTPUT_DIR = SCREENCAST_DIR / "output" +# Disposable projects generated on demand for `invoke screencast-server +# --focus=` (e.g. by running a scenario's real CLI commands). +# Not checked into git (see /build/ in .gitignore). Left in place between +# runs so a scenario's setup doesn't rerun (and clobber manual edits) every +# time the manual server is restarted; delete the directory to reset it. +MANUAL_SERVER_BUILD_DIR = STRICTDOC_ROOT / "build" / "screencast_manual" + # Manual dev server: kept open while developing/inspecting a scene in the # browser. # diff --git a/tests/screencast/manual_scenarios.py b/tests/screencast/manual_scenarios.py new file mode 100644 index 000000000..8a05f492c --- /dev/null +++ b/tests/screencast/manual_scenarios.py @@ -0,0 +1,48 @@ +""" +Registry of screencast scenarios that can be previewed manually in a real +browser via `invoke screencast-server --focus=` (see +tests/screencast/run_server.py and README.md). + +Each entry resolves to a project directory to serve, plus an optional +config path (None if the project carries its own strictdoc_config.py). +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Callable, Dict, Optional, Tuple + +from tests.screencast.fixture import ( + FIXTURE_CONFIG, + FIXTURE_DIR, + MANUAL_SERVER_BUILD_DIR, +) +from tests.screencast.scenarios.hello_world.test_case import run_strictdoc_new + +DEFAULT_SCENARIO = "fixture" + +ScenarioSetup = Callable[[], Tuple[Path, Optional[Path]]] + + +def _prepare_fixture_project() -> Tuple[Path, Optional[Path]]: + return FIXTURE_DIR, FIXTURE_CONFIG + + +def _prepare_hello_world_project() -> Tuple[Path, Optional[Path]]: + project_dir = MANUAL_SERVER_BUILD_DIR / "hello_world" + + # strictdoc_config.py is the last file `strictdoc new` writes: its + # presence means a previous run already completed successfully. Skip + # regenerating so manual edits made through the UI survive server + # restarts, and so we don't hit `strictdoc new`'s refusal to overwrite + # existing files. + if not (project_dir / "strictdoc_config.py").exists(): + run_strictdoc_new(project_dir) + + return project_dir, None + + +SCENARIOS: Dict[str, ScenarioSetup] = { + "fixture": _prepare_fixture_project, + "hello_world": _prepare_hello_world_project, +} diff --git a/tests/screencast/run_server.py b/tests/screencast/run_server.py index 1508fafa9..e6363724a 100644 --- a/tests/screencast/run_server.py +++ b/tests/screencast/run_server.py @@ -1,5 +1,6 @@ from __future__ import annotations +import argparse import os import subprocess import sys @@ -12,10 +13,10 @@ import psutil # noqa: E402 from tests.end2end.server import SDocTestServer # noqa: E402 -from tests.screencast.fixture import ( # noqa: E402 - DEV_SERVER_PORT, - FIXTURE_CONFIG, - FIXTURE_DIR, +from tests.screencast.fixture import DEV_SERVER_PORT # noqa: E402 +from tests.screencast.manual_scenarios import ( # noqa: E402 + DEFAULT_SCENARIO, + SCENARIOS, ) @@ -44,7 +45,13 @@ def is_previous_demo_server(pid: int) -> bool: cmdline = " ".join(psutil.Process(pid).cmdline()) except psutil.NoSuchProcess: return False - return str(FIXTURE_DIR) in cmdline + # Matched by shape (any strictdoc server bound to this dev port), not by + # project directory: --focus can point at a different project per run. + return ( + "strictdoc.cli.main" in cmdline + and "server" in cmdline + and f"--port {DEV_SERVER_PORT}" in cmdline + ) def collect_pid_tree(pid: int) -> list[int]: @@ -108,17 +115,36 @@ def free_dev_port(port: int) -> None: continue +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument( + "--focus", + default=DEFAULT_SCENARIO, + choices=sorted(SCENARIOS), + help=( + "Which screencast scenario's project to serve " + f"(default: {DEFAULT_SCENARIO})." + ), + ) + return parser.parse_args() + + def main() -> int: + args = parse_args() + try: + project_dir, config_path = SCENARIOS[args.focus]() + free_dev_port(DEV_SERVER_PORT) with SDocTestServer( - input_path=str(FIXTURE_DIR), - config_path=str(FIXTURE_CONFIG), + input_path=str(project_dir), + config_path=str(config_path) if config_path is not None else None, port=DEV_SERVER_PORT, ) as server: print( # noqa: T201 - f"🚀 StrictDoc demo server: {server.get_host_and_port()}" + f"🚀 StrictDoc demo server ({args.focus}): " + f"{server.get_host_and_port()}" ) print(" Press Ctrl+C to stop.") # noqa: T201 From 2c249c01935efd3e4ce23b917c10da0d95b8e785 Mon Sep 17 00:00:00 2001 From: Maryna Balioura Date: Mon, 6 Jul 2026 02:55:31 +0200 Subject: [PATCH 18/39] feat(tests/screencast): add hello_world scenario - New `hello_world` screencast scenario: runs a real `strictdoc new`, types the command and its actual stdout into a terminal-style HTML scene, then drives a live server through creating a document and a requirement via the web UI (tests/screencast/scenarios/hello_world/). - New Playwright helpers ported from the e2e SeleniumBase suite for the add-document and add-requirement flows (tests/screencast/helpers/). --- tests/screencast/helpers/actions_menu.py | 21 ++++ tests/screencast/helpers/form.py | 23 ++++ tests/screencast/helpers/form_add_document.py | 11 ++ .../helpers/form_edit_requirement.py | 14 +++ tests/screencast/helpers/node.py | 47 ++++++++ tests/screencast/helpers/project_tree.py | 35 ++++++ .../scenarios/hello_world/test_case.py | 103 ++++++++++++++++++ tests/screencast/terminal.html | 58 ++++++++++ 8 files changed, 312 insertions(+) create mode 100644 tests/screencast/helpers/actions_menu.py create mode 100644 tests/screencast/helpers/form.py create mode 100644 tests/screencast/helpers/form_add_document.py create mode 100644 tests/screencast/helpers/form_edit_requirement.py create mode 100644 tests/screencast/helpers/node.py create mode 100644 tests/screencast/helpers/project_tree.py create mode 100644 tests/screencast/scenarios/hello_world/test_case.py create mode 100644 tests/screencast/terminal.html diff --git a/tests/screencast/helpers/actions_menu.py b/tests/screencast/helpers/actions_menu.py new file mode 100644 index 000000000..500229be3 --- /dev/null +++ b/tests/screencast/helpers/actions_menu.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from playwright.sync_api import Page + + +class ActionsMenu: + """ + Playwright counterpart of + tests/end2end/helpers/components/actions_menu.py, covering only what + the screencast scenarios currently need. + """ + + def __init__(self, page: Page) -> None: + self.page = page + + def do_open(self) -> None: + self.page.click('[data-testid="header-action-menu-handler"]') + + def do_click_action(self, testid: str) -> None: + self.do_open() + self.page.click(f'[data-testid="{testid}"]') diff --git a/tests/screencast/helpers/form.py b/tests/screencast/helpers/form.py new file mode 100644 index 000000000..a89e0fe19 --- /dev/null +++ b/tests/screencast/helpers/form.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +from playwright.sync_api import Page, expect + + +class Form: + """ + Playwright counterpart of tests/end2end/helpers/form/form.py, covering + only what the screencast scenarios currently need. + """ + + def __init__(self, page: Page) -> None: + self.page = page + + def do_fill_in(self, field_name: str, field_value: str) -> None: + self.page.locator(f'[data-testid="form-field-{field_name}"]').fill( + field_value + ) + + def do_form_submit(self) -> None: + submit_action = self.page.locator('[data-testid="form-submit-action"]') + submit_action.click() + expect(submit_action).not_to_be_visible() diff --git a/tests/screencast/helpers/form_add_document.py b/tests/screencast/helpers/form_add_document.py new file mode 100644 index 000000000..fdbe9f55f --- /dev/null +++ b/tests/screencast/helpers/form_add_document.py @@ -0,0 +1,11 @@ +from __future__ import annotations + +from tests.screencast.helpers.form import Form + + +class Form_AddDocument(Form): + def do_fill_in_title(self, field_value: str) -> None: + super().do_fill_in("document_title", field_value) + + def do_fill_in_path(self, field_value: str) -> None: + super().do_fill_in("document_path", field_value) diff --git a/tests/screencast/helpers/form_edit_requirement.py b/tests/screencast/helpers/form_edit_requirement.py new file mode 100644 index 000000000..0e00da659 --- /dev/null +++ b/tests/screencast/helpers/form_edit_requirement.py @@ -0,0 +1,14 @@ +from __future__ import annotations + +from tests.screencast.helpers.form import Form + + +class Form_EditRequirement(Form): + def do_fill_in_field_uid(self, field_value: str) -> None: + super().do_fill_in("UID", field_value) + + def do_fill_in_field_title(self, field_value: str) -> None: + super().do_fill_in("TITLE", field_value) + + def do_fill_in_field_statement(self, field_value: str) -> None: + super().do_fill_in("STATEMENT", field_value) diff --git a/tests/screencast/helpers/node.py b/tests/screencast/helpers/node.py new file mode 100644 index 000000000..7dcb0429d --- /dev/null +++ b/tests/screencast/helpers/node.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +from playwright.sync_api import Page + +from tests.screencast.helpers.form_edit_requirement import Form_EditRequirement + + +class AddNode_Menu: # noqa: N801 + """ + Playwright counterpart of + tests/end2end/helpers/components/node/add_node_menu.py, covering only + what the screencast scenarios currently need. + """ + + def __init__(self, page: Page, node_selector: str) -> None: + self.page = page + self.node_selector = node_selector + + def do_node_add_requirement_first(self) -> Form_EditRequirement: + self.page.click( + f'{self.node_selector} [data-testid="node-add-requirement-first-action"]' + ) + return Form_EditRequirement(self.page) + + +class Node: + """ + Playwright counterpart of + tests/end2end/helpers/components/node/node.py, covering only what the + screencast scenarios currently need. + """ + + def __init__(self, page: Page, node_selector: str) -> None: + self.page = page + self.node_selector = node_selector + + def do_open_node_menu(self) -> AddNode_Menu: + self.page.hover(self.node_selector) + self.page.click( + f'{self.node_selector} [data-testid="node-menu-handler"]' + ) + return AddNode_Menu(self.page, self.node_selector) + + +class DocumentRoot(Node): + def __init__(self, page: Page) -> None: + super().__init__(page, '[data-testid="node-root"]') diff --git a/tests/screencast/helpers/project_tree.py b/tests/screencast/helpers/project_tree.py new file mode 100644 index 000000000..97cae138a --- /dev/null +++ b/tests/screencast/helpers/project_tree.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +from playwright.sync_api import Page, expect + +from tests.screencast.helpers.actions_menu import ActionsMenu +from tests.screencast.helpers.form_add_document import Form_AddDocument + + +class ProjectTree: + """ + Playwright counterpart of + tests/end2end/helpers/screens/project_index/screen_project_index.py, + covering only what the screencast scenarios currently need. + """ + + def __init__(self, page: Page) -> None: + self.page = page + self.actions_menu = ActionsMenu(page) + + def assert_contains_document(self, document_title: str) -> None: + expect( + self.page.locator('[data-testid="tree-file-link"]').filter( + has_text=document_title + ) + ).to_be_visible() + + def do_open_modal_form_add_document(self) -> Form_AddDocument: + self.actions_menu.do_click_action("tree-add-document-action") + expect(self.page.locator("sdoc-modal")).to_be_visible() + return Form_AddDocument(self.page) + + def do_click_on_the_document_with_title(self, document_title: str) -> None: + self.page.locator('[data-testid="tree-file-link"]').filter( + has_text=document_title + ).click() diff --git a/tests/screencast/scenarios/hello_world/test_case.py b/tests/screencast/scenarios/hello_world/test_case.py new file mode 100644 index 000000000..33e93e196 --- /dev/null +++ b/tests/screencast/scenarios/hello_world/test_case.py @@ -0,0 +1,103 @@ +""" +Screencast scenario: creating a project from scratch with `strictdoc new`, +then browsing to it in the web UI, adding a new document, and adding a +requirement to it. + +If this test fails, the "hello-world" screencast video is stale and must +be re-recorded (see tests/screencast/README.md). +""" + +from __future__ import annotations + +import os +import subprocess +import sys +from pathlib import Path + +from playwright.sync_api import Page, expect + +from tests.end2end.server import SDocTestServer +from tests.screencast.fixture import RECORD_SERVER_PORT +from tests.screencast.helpers.node import DocumentRoot +from tests.screencast.helpers.project_tree import ProjectTree +from tests.screencast.helpers.screen import Screen +from tests.screencast.scenarios.typing import type_text + +SCENE_HTML = os.path.abspath(os.path.join(__file__, "../../../terminal.html")) + + +def run_strictdoc_new(project_dir: Path) -> str: + env = dict(os.environ) + env["PYTHONPATH"] = os.getcwd() + + result = subprocess.run( + [sys.executable, "-m", "strictdoc.cli.main", "new", str(project_dir)], + capture_output=True, + text=True, + env=env, + check=True, + ) + return result.stdout + + +class Test: + def test(self, page: Page, tmp_path: Path) -> None: + project_dir = tmp_path / "hello-world" + cli_output = run_strictdoc_new(project_dir) + + page.goto(f"file://{SCENE_HTML}") + demo_text = page.locator("#demoText") + expect(demo_text).to_be_visible() + + type_text(demo_text, "$ strictdoc new hello-world\n") + demo_text.evaluate( + "(el, text) => { el.textContent += text; }", cli_output + ) + page.wait_for_timeout(1500) + + with SDocTestServer( + input_path=str(project_dir), + port=RECORD_SERVER_PORT, + ) as server: + base_url = server.get_host_and_port() + + page.goto(f"{base_url}/") + screen = Screen(page) + screen.assert_on_screen("document-tree") + + project_tree = ProjectTree(page) + project_tree.assert_contains_document("High-Level Requirements") + project_tree.assert_contains_document("Low-Level Requirements") + + form_add_document = project_tree.do_open_modal_form_add_document() + form_add_document.do_fill_in_title("Demo Requirements") + form_add_document.do_fill_in_path("docs/demo_requirements.sdoc") + form_add_document.do_form_submit() + + project_tree.assert_contains_document("Demo Requirements") + project_tree.do_click_on_the_document_with_title( + "Demo Requirements" + ) + + screen.assert_on_screen("document") + + document_root = DocumentRoot(page) + add_node_menu = document_root.do_open_node_menu() + form_edit_requirement = ( + add_node_menu.do_node_add_requirement_first() + ) + form_edit_requirement.do_fill_in_field_uid("DEMO-1") + form_edit_requirement.do_fill_in_field_title( + "Hello world requirement" + ) + form_edit_requirement.do_fill_in_field_statement( + "StrictDoc shall greet the world." + ) + form_edit_requirement.do_form_submit() + + expect( + page.locator("sdoc-node-title", has_text="Hello world requirement") + ).to_be_visible() + expect( + page.locator('[data-field-label="statement"]') + ).to_contain_text("StrictDoc shall greet the world.") diff --git a/tests/screencast/terminal.html b/tests/screencast/terminal.html new file mode 100644 index 000000000..e0518ab46 --- /dev/null +++ b/tests/screencast/terminal.html @@ -0,0 +1,58 @@ + + + + + StrictDoc terminal + + + +
+
+
+

+    
+
+ + From d0b82eba87e7d4e35362e06f48e582a0ccad913a Mon Sep 17 00:00:00 2001 From: Maryna Balioura Date: Tue, 7 Jul 2026 10:17:32 +0200 Subject: [PATCH 19/39] feat(tests/screencast): add cursor/highlight pointer and pacing helpers Adds Pointer (fake mouse cursor + click-target highlight, since Playwright renders no real cursor) and pause() pacing helper; reworks the hello_world scenario to skip document creation, add a Requirement to the existing High-Level Requirements doc instead, and close with an editor-style scene revealing the real appended .sdoc lines. --- tests/screencast/editor.html | 95 +++++++++++++++ tests/screencast/helpers/actions_menu.py | 10 +- tests/screencast/helpers/editor_scene.py | 59 ++++++++++ tests/screencast/helpers/form.py | 15 ++- tests/screencast/helpers/node.py | 54 ++++++--- tests/screencast/helpers/pacing.py | 8 ++ tests/screencast/helpers/pointer.py | 109 ++++++++++++++++++ tests/screencast/helpers/project_tree.py | 17 +-- .../scenarios/hello_world/test_case.py | 86 +++++++++----- 9 files changed, 390 insertions(+), 63 deletions(-) create mode 100644 tests/screencast/editor.html create mode 100644 tests/screencast/helpers/editor_scene.py create mode 100644 tests/screencast/helpers/pacing.py create mode 100644 tests/screencast/helpers/pointer.py diff --git a/tests/screencast/editor.html b/tests/screencast/editor.html new file mode 100644 index 000000000..5f75812f2 --- /dev/null +++ b/tests/screencast/editor.html @@ -0,0 +1,95 @@ + + + + + StrictDoc editor + + + +
+
+
+
+ + diff --git a/tests/screencast/helpers/actions_menu.py b/tests/screencast/helpers/actions_menu.py index 500229be3..e137211e0 100644 --- a/tests/screencast/helpers/actions_menu.py +++ b/tests/screencast/helpers/actions_menu.py @@ -1,6 +1,6 @@ from __future__ import annotations -from playwright.sync_api import Page +from tests.screencast.helpers.pointer import Pointer class ActionsMenu: @@ -10,12 +10,12 @@ class ActionsMenu: the screencast scenarios currently need. """ - def __init__(self, page: Page) -> None: - self.page = page + def __init__(self, pointer: Pointer) -> None: + self.pointer = pointer def do_open(self) -> None: - self.page.click('[data-testid="header-action-menu-handler"]') + self.pointer.click('[data-testid="header-action-menu-handler"]') def do_click_action(self, testid: str) -> None: self.do_open() - self.page.click(f'[data-testid="{testid}"]') + self.pointer.click(f'[data-testid="{testid}"]') diff --git a/tests/screencast/helpers/editor_scene.py b/tests/screencast/helpers/editor_scene.py new file mode 100644 index 000000000..f08d45f85 --- /dev/null +++ b/tests/screencast/helpers/editor_scene.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +from typing import List + +from playwright.sync_api import Page + +_RENDER_ORIGINAL_JS = """ +([filename, lines]) => { + document.getElementById('fileTab').textContent = filename; + const code = document.getElementById('code'); + code.innerHTML = ''; + for (const line of lines) { + const row = document.createElement('div'); + row.className = 'line'; + const text = document.createElement('span'); + text.className = 'line-text'; + text.textContent = line; + row.appendChild(text); + code.appendChild(row); + } +} +""" + +_APPEND_LINE_JS = """ +(line) => { + const code = document.getElementById('code'); + const previous = code.querySelector('.line.cursor-line'); + if (previous) { + previous.classList.remove('cursor-line'); + } + const row = document.createElement('div'); + row.className = 'line added cursor-line'; + const text = document.createElement('span'); + text.className = 'line-text'; + text.textContent = line; + row.appendChild(text); + code.appendChild(row); + row.scrollIntoView({ block: 'end' }); +} +""" + + +def render_original(page: Page, filename: str, text: str) -> None: + """Renders the file's pre-existing content instantly, unhighlighted.""" + lines = text.splitlines() + page.evaluate(_RENDER_ORIGINAL_JS, [filename, lines]) + + +def reveal_added_lines( + page: Page, added_text: str, *, line_delay_ms: int = 350 +) -> None: + """ + Appends the newly written lines one at a time, highlighted, so the + viewer sees exactly what the UI action just wrote to disk. + """ + lines: List[str] = added_text.splitlines() + for line in lines: + page.evaluate(_APPEND_LINE_JS, line) + page.wait_for_timeout(line_delay_ms) diff --git a/tests/screencast/helpers/form.py b/tests/screencast/helpers/form.py index a89e0fe19..4d97b33c0 100644 --- a/tests/screencast/helpers/form.py +++ b/tests/screencast/helpers/form.py @@ -1,6 +1,8 @@ from __future__ import annotations -from playwright.sync_api import Page, expect +from playwright.sync_api import expect + +from tests.screencast.helpers.pointer import Pointer class Form: @@ -9,15 +11,16 @@ class Form: only what the screencast scenarios currently need. """ - def __init__(self, page: Page) -> None: - self.page = page + def __init__(self, pointer: Pointer) -> None: + self.pointer = pointer + self.page = pointer.page def do_fill_in(self, field_name: str, field_value: str) -> None: - self.page.locator(f'[data-testid="form-field-{field_name}"]').fill( - field_value + self.pointer.type_into( + f'[data-testid="form-field-{field_name}"]', field_value ) def do_form_submit(self) -> None: submit_action = self.page.locator('[data-testid="form-submit-action"]') - submit_action.click() + self.pointer.click('[data-testid="form-submit-action"]') expect(submit_action).not_to_be_visible() diff --git a/tests/screencast/helpers/node.py b/tests/screencast/helpers/node.py index 7dcb0429d..733fa86cc 100644 --- a/tests/screencast/helpers/node.py +++ b/tests/screencast/helpers/node.py @@ -1,8 +1,9 @@ from __future__ import annotations -from playwright.sync_api import Page +from playwright.sync_api import Locator from tests.screencast.helpers.form_edit_requirement import Form_EditRequirement +from tests.screencast.helpers.pointer import Pointer class AddNode_Menu: # noqa: N801 @@ -12,15 +13,25 @@ class AddNode_Menu: # noqa: N801 what the screencast scenarios currently need. """ - def __init__(self, page: Page, node_selector: str) -> None: - self.page = page - self.node_selector = node_selector + def __init__(self, pointer: Pointer, node_locator: Locator) -> None: + self.pointer = pointer + self.node_locator = node_locator def do_node_add_requirement_first(self) -> Form_EditRequirement: - self.page.click( - f'{self.node_selector} [data-testid="node-add-requirement-first-action"]' + self.pointer.click( + self.node_locator.locator( + '[data-testid="node-add-requirement-first-action"]' + ) ) - return Form_EditRequirement(self.page) + return Form_EditRequirement(self.pointer) + + def do_node_add_requirement_below(self) -> Form_EditRequirement: + self.pointer.click( + self.node_locator.locator( + '[data-testid="node-add-requirement-below-action"]' + ) + ) + return Form_EditRequirement(self.pointer) class Node: @@ -30,18 +41,29 @@ class Node: screencast scenarios currently need. """ - def __init__(self, page: Page, node_selector: str) -> None: - self.page = page - self.node_selector = node_selector + def __init__(self, pointer: Pointer, node_locator: Locator) -> None: + self.pointer = pointer + self.node_locator = node_locator def do_open_node_menu(self) -> AddNode_Menu: - self.page.hover(self.node_selector) - self.page.click( - f'{self.node_selector} [data-testid="node-menu-handler"]' + self.pointer.move_to(self.node_locator) + self.pointer.click( + self.node_locator.locator('[data-testid="node-menu-handler"]') ) - return AddNode_Menu(self.page, self.node_selector) + return AddNode_Menu(self.pointer, self.node_locator) class DocumentRoot(Node): - def __init__(self, page: Page) -> None: - super().__init__(page, '[data-testid="node-root"]') + def __init__(self, pointer: Pointer) -> None: + super().__init__( + pointer, pointer.page.locator('[data-testid="node-root"]') + ) + + +class Requirement(Node): + @staticmethod + def with_node(pointer: Pointer, node_order: int = 1) -> "Requirement": + locator = pointer.page.locator( + '[data-testid="node-requirement"]' + ).nth(node_order - 1) + return Requirement(pointer, locator) diff --git a/tests/screencast/helpers/pacing.py b/tests/screencast/helpers/pacing.py new file mode 100644 index 000000000..522c35f14 --- /dev/null +++ b/tests/screencast/helpers/pacing.py @@ -0,0 +1,8 @@ +from __future__ import annotations + +from playwright.sync_api import Page + + +def pause(page: Page, seconds: float = 1.0) -> None: + """A deliberate beat, for viewers to register what just happened.""" + page.wait_for_timeout(int(seconds * 1000)) diff --git a/tests/screencast/helpers/pointer.py b/tests/screencast/helpers/pointer.py new file mode 100644 index 000000000..cb54cf3c2 --- /dev/null +++ b/tests/screencast/helpers/pointer.py @@ -0,0 +1,109 @@ +from __future__ import annotations + +from typing import Union + +from playwright.sync_api import Locator, Page +from playwright.sync_api import TimeoutError as PlaywrightTimeoutError + +Target = Union[str, Locator] + +# Playwright doesn't render a real OS mouse cursor, so clicks are invisible +# in a recorded video unless we fake one. This injects a small dot that +# tracks real `mousemove` events (dispatched by page.mouse.move below), and +# a pulsing outline style used to highlight a click's target just before +# clicking it. Registered via add_init_script so it re-applies on every +# navigation within this page (file:// scenes, the live server, ...). +_INIT_SCRIPT = """ +(() => { + const style = document.createElement('style'); + style.textContent = ` + #__screencast_cursor { + position: fixed; top: 0; left: 0; width: 18px; height: 18px; + margin: -9px 0 0 -9px; + border-radius: 50%; background: rgba(255, 90, 0, 0.85); + border: 2px solid white; box-shadow: 0 0 6px rgba(0,0,0,0.5); + pointer-events: none; z-index: 2147483647; + transition: left 0.05s linear, top 0.05s linear; + } + .__screencast_highlight { + outline: 3px solid rgba(255, 90, 0, 0.9) !important; + outline-offset: 2px !important; + animation: __screencast_pulse 0.6s ease-in-out infinite alternate; + } + @keyframes __screencast_pulse { + from { outline-color: rgba(255, 90, 0, 0.9); } + to { outline-color: rgba(255, 90, 0, 0.35); } + } + `; + + const attach = () => { + document.head.appendChild(style); + const cursor = document.createElement('div'); + cursor.id = '__screencast_cursor'; + document.documentElement.appendChild(cursor); + window.addEventListener('mousemove', (event) => { + cursor.style.left = event.clientX + 'px'; + cursor.style.top = event.clientY + 'px'; + }, true); + }; + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', attach); + } else { + attach(); + } +})(); +""" + + +class Pointer: + """ + Makes Playwright-driven interactions visible in recorded screencasts: + a fake cursor travels to the target before every click, and the target + is briefly highlighted so the viewer's eye doesn't miss it. + """ + + def __init__(self, page: Page) -> None: + self.page = page + page.add_init_script(script=_INIT_SCRIPT) + + def _locator(self, target: Target) -> Locator: + if isinstance(target, str): + return self.page.locator(target).first + return target + + def move_to(self, target: Target, *, pause_ms: int = 300) -> None: + locator = self._locator(target) + locator.scroll_into_view_if_needed() + box = locator.bounding_box() + assert box is not None, f"Not visible: {target}" + self.page.mouse.move( + box["x"] + box["width"] / 2, + box["y"] + box["height"] / 2, + steps=25, + ) + self.page.wait_for_timeout(pause_ms) + + def click(self, target: Target, *, pause_ms: int = 400) -> None: + locator = self._locator(target) + self.move_to(locator) + locator.evaluate("(el) => el.classList.add('__screencast_highlight')") + self.page.wait_for_timeout(pause_ms) + locator.click() + try: + # A click that navigates away (e.g. a tree/document link) + # leaves nothing left to unhighlight on the new page. + locator.evaluate( + "(el) => el.classList.remove('__screencast_highlight')", + timeout=1000, + ) + except PlaywrightTimeoutError: + pass + + def type_into(self, target: Target, text: str, *, delay_ms: int = 35) -> None: + locator = self._locator(target) + self.click(locator) + # Some fields (e.g. UID) come pre-filled with a suggested value; + # replace it rather than typing alongside it. + locator.evaluate("(el) => { el.textContent = ''; }") + locator.press_sequentially(text, delay=delay_ms) diff --git a/tests/screencast/helpers/project_tree.py b/tests/screencast/helpers/project_tree.py index 97cae138a..ef4d0e584 100644 --- a/tests/screencast/helpers/project_tree.py +++ b/tests/screencast/helpers/project_tree.py @@ -1,9 +1,10 @@ from __future__ import annotations -from playwright.sync_api import Page, expect +from playwright.sync_api import expect from tests.screencast.helpers.actions_menu import ActionsMenu from tests.screencast.helpers.form_add_document import Form_AddDocument +from tests.screencast.helpers.pointer import Pointer class ProjectTree: @@ -13,9 +14,10 @@ class ProjectTree: covering only what the screencast scenarios currently need. """ - def __init__(self, page: Page) -> None: - self.page = page - self.actions_menu = ActionsMenu(page) + def __init__(self, pointer: Pointer) -> None: + self.pointer = pointer + self.page = pointer.page + self.actions_menu = ActionsMenu(pointer) def assert_contains_document(self, document_title: str) -> None: expect( @@ -27,9 +29,10 @@ def assert_contains_document(self, document_title: str) -> None: def do_open_modal_form_add_document(self) -> Form_AddDocument: self.actions_menu.do_click_action("tree-add-document-action") expect(self.page.locator("sdoc-modal")).to_be_visible() - return Form_AddDocument(self.page) + return Form_AddDocument(self.pointer) def do_click_on_the_document_with_title(self, document_title: str) -> None: - self.page.locator('[data-testid="tree-file-link"]').filter( + link = self.page.locator('[data-testid="tree-file-link"]').filter( has_text=document_title - ).click() + ) + self.pointer.click(link) diff --git a/tests/screencast/scenarios/hello_world/test_case.py b/tests/screencast/scenarios/hello_world/test_case.py index 33e93e196..a68ad4d5b 100644 --- a/tests/screencast/scenarios/hello_world/test_case.py +++ b/tests/screencast/scenarios/hello_world/test_case.py @@ -1,7 +1,7 @@ """ Screencast scenario: creating a project from scratch with `strictdoc new`, -then browsing to it in the web UI, adding a new document, and adding a -requirement to it. +adding a new Requirement to an existing document through the web UI, then +revealing the real resulting .sdoc source in an editor-style scene. If this test fails, the "hello-world" screencast video is stale and must be re-recorded (see tests/screencast/README.md). @@ -18,12 +18,20 @@ from tests.end2end.server import SDocTestServer from tests.screencast.fixture import RECORD_SERVER_PORT -from tests.screencast.helpers.node import DocumentRoot +from tests.screencast.helpers import editor_scene +from tests.screencast.helpers.node import Requirement +from tests.screencast.helpers.pacing import pause +from tests.screencast.helpers.pointer import Pointer from tests.screencast.helpers.project_tree import ProjectTree from tests.screencast.helpers.screen import Screen from tests.screencast.scenarios.typing import type_text -SCENE_HTML = os.path.abspath(os.path.join(__file__, "../../../terminal.html")) +TERMINAL_HTML = os.path.abspath(os.path.join(__file__, "../../../terminal.html")) +EDITOR_HTML = os.path.abspath(os.path.join(__file__, "../../../editor.html")) + +REQUIREMENT_UID = "HLR-2" +REQUIREMENT_TITLE = "Hello world requirement" +REQUIREMENT_STATEMENT = "StrictDoc shall greet the world." def run_strictdoc_new(project_dir: Path) -> str: @@ -42,10 +50,13 @@ def run_strictdoc_new(project_dir: Path) -> str: class Test: def test(self, page: Page, tmp_path: Path) -> None: + pointer = Pointer(page) + project_dir = tmp_path / "hello-world" cli_output = run_strictdoc_new(project_dir) - page.goto(f"file://{SCENE_HTML}") + # Scene 1: the real `strictdoc new` output, in a terminal look. + page.goto(f"file://{TERMINAL_HTML}") demo_text = page.locator("#demoText") expect(demo_text).to_be_visible() @@ -53,7 +64,10 @@ def test(self, page: Page, tmp_path: Path) -> None: demo_text.evaluate( "(el, text) => { el.textContent += text; }", cli_output ) - page.wait_for_timeout(1500) + pause(page, 2) + + hlr_path = project_dir / "docs" / "high_level_requirements.sdoc" + original_sdoc_text = hlr_path.read_text(encoding="utf-8") with SDocTestServer( input_path=str(project_dir), @@ -61,43 +75,57 @@ def test(self, page: Page, tmp_path: Path) -> None: ) as server: base_url = server.get_host_and_port() + # Scene 2: the web UI, browsing to the existing document. page.goto(f"{base_url}/") screen = Screen(page) screen.assert_on_screen("document-tree") - project_tree = ProjectTree(page) + project_tree = ProjectTree(pointer) project_tree.assert_contains_document("High-Level Requirements") - project_tree.assert_contains_document("Low-Level Requirements") - - form_add_document = project_tree.do_open_modal_form_add_document() - form_add_document.do_fill_in_title("Demo Requirements") - form_add_document.do_fill_in_path("docs/demo_requirements.sdoc") - form_add_document.do_form_submit() + pause(page) - project_tree.assert_contains_document("Demo Requirements") project_tree.do_click_on_the_document_with_title( - "Demo Requirements" + "High-Level Requirements" ) - screen.assert_on_screen("document") + pause(page) - document_root = DocumentRoot(page) - add_node_menu = document_root.do_open_node_menu() - form_edit_requirement = ( - add_node_menu.do_node_add_requirement_first() - ) - form_edit_requirement.do_fill_in_field_uid("DEMO-1") - form_edit_requirement.do_fill_in_field_title( - "Hello world requirement" - ) + # Scene 3: adding a new Requirement below the existing one. + hlr_1 = Requirement.with_node(pointer) + node_menu = hlr_1.do_open_node_menu() + form_edit_requirement = node_menu.do_node_add_requirement_below() + + form_edit_requirement.do_fill_in_field_uid(REQUIREMENT_UID) + form_edit_requirement.do_fill_in_field_title(REQUIREMENT_TITLE) form_edit_requirement.do_fill_in_field_statement( - "StrictDoc shall greet the world." + REQUIREMENT_STATEMENT ) + pause(page) form_edit_requirement.do_form_submit() expect( - page.locator("sdoc-node-title", has_text="Hello world requirement") + page.locator("sdoc-node-title", has_text=REQUIREMENT_TITLE) ).to_be_visible() expect( - page.locator('[data-field-label="statement"]') - ).to_contain_text("StrictDoc shall greet the world.") + page.locator('[data-field-label="statement"]').filter( + has_text=REQUIREMENT_STATEMENT + ) + ).to_be_visible() + pause(page, 2) + + final_sdoc_text = hlr_path.read_text(encoding="utf-8") + assert final_sdoc_text.startswith(original_sdoc_text), ( + "Expected the new requirement to be appended after the " + "document's existing content." + ) + added_sdoc_text = final_sdoc_text[len(original_sdoc_text) :] + assert REQUIREMENT_UID in added_sdoc_text + + # Scene 4: the real resulting .sdoc source, editor-style. + page.goto(f"file://{EDITOR_HTML}") + editor_scene.render_original( + page, "docs/high_level_requirements.sdoc", original_sdoc_text + ) + pause(page) + editor_scene.reveal_added_lines(page, added_sdoc_text) + pause(page, 2) From cd6c7072f63a0bf04bfa22c01b7568ce90cf8f84 Mon Sep 17 00:00:00 2001 From: Maryna Balioura Date: Tue, 7 Jul 2026 13:06:24 +0200 Subject: [PATCH 20/39] refactor(tests/screencast): extract pointer styles into pointer.css Moves the cursor/highlight CSS out of the inline JS template string in pointer.py into pointer.css, embedded via json.dumps() instead of repr() so the CSS can safely contain quotes/backslashes without breaking the generated JS. --- tests/screencast/helpers/pointer.css | 26 ++++++++++++++++++++ tests/screencast/helpers/pointer.py | 36 ++++++++++------------------ 2 files changed, 39 insertions(+), 23 deletions(-) create mode 100644 tests/screencast/helpers/pointer.css diff --git a/tests/screencast/helpers/pointer.css b/tests/screencast/helpers/pointer.css new file mode 100644 index 000000000..f61981965 --- /dev/null +++ b/tests/screencast/helpers/pointer.css @@ -0,0 +1,26 @@ +#__screencast_cursor { + position: fixed; + top: 0; + left: 0; + width: 18px; + height: 18px; + margin: -9px 0 0 -9px; + border-radius: 50%; + background: rgba(255, 90, 0, 0.85); + border: 2px solid white; + box-shadow: 0 0 6px rgba(0, 0, 0, 0.5); + pointer-events: none; + z-index: 2147483647; + transition: left 0.05s linear, top 0.05s linear; +} + +.__screencast_highlight { + outline: 3px solid rgba(255, 90, 0, 0.9) !important; + outline-offset: 2px !important; + animation: __screencast_pulse 0.6s ease-in-out infinite alternate; +} + +@keyframes __screencast_pulse { + from { outline-color: rgba(255, 90, 0, 0.9); } + to { outline-color: rgba(255, 90, 0, 0.35); } +} diff --git a/tests/screencast/helpers/pointer.py b/tests/screencast/helpers/pointer.py index cb54cf3c2..ea9753674 100644 --- a/tests/screencast/helpers/pointer.py +++ b/tests/screencast/helpers/pointer.py @@ -1,5 +1,7 @@ from __future__ import annotations +import json +from pathlib import Path from typing import Union from playwright.sync_api import Locator, Page @@ -7,34 +9,22 @@ Target = Union[str, Locator] +_CSS = (Path(__file__).parent / "pointer.css").read_text(encoding="utf-8") + # Playwright doesn't render a real OS mouse cursor, so clicks are invisible # in a recorded video unless we fake one. This injects a small dot that # tracks real `mousemove` events (dispatched by page.mouse.move below), and -# a pulsing outline style used to highlight a click's target just before -# clicking it. Registered via add_init_script so it re-applies on every -# navigation within this page (file:// scenes, the live server, ...). +# a pulsing outline style (pointer.css) used to highlight a click's target +# just before clicking it. Registered via add_init_script so it re-applies +# on every navigation within this page (file:// scenes, the live server, +# ...). The CSS is spliced in as a JS string via json.dumps() (not an +# f-string, to avoid escaping every brace in the JS below): a JSON string +# is always a valid JS string literal, so pointer.css can contain quotes, +# backslashes, anything, without needing to keep that in sync here. _INIT_SCRIPT = """ (() => { const style = document.createElement('style'); - style.textContent = ` - #__screencast_cursor { - position: fixed; top: 0; left: 0; width: 18px; height: 18px; - margin: -9px 0 0 -9px; - border-radius: 50%; background: rgba(255, 90, 0, 0.85); - border: 2px solid white; box-shadow: 0 0 6px rgba(0,0,0,0.5); - pointer-events: none; z-index: 2147483647; - transition: left 0.05s linear, top 0.05s linear; - } - .__screencast_highlight { - outline: 3px solid rgba(255, 90, 0, 0.9) !important; - outline-offset: 2px !important; - animation: __screencast_pulse 0.6s ease-in-out infinite alternate; - } - @keyframes __screencast_pulse { - from { outline-color: rgba(255, 90, 0, 0.9); } - to { outline-color: rgba(255, 90, 0, 0.35); } - } - `; + style.textContent = __CSS__; const attach = () => { document.head.appendChild(style); @@ -53,7 +43,7 @@ attach(); } })(); -""" +""".replace("__CSS__", json.dumps(_CSS)) class Pointer: From 183853e6129fe0dcd79e557823fa4a8b19f196f7 Mon Sep 17 00:00:00 2001 From: Maryna Balioura Date: Tue, 7 Jul 2026 13:42:00 +0200 Subject: [PATCH 21/39] feat(tests/screencast): syntax-highlight the editor scene with StrictDocLexer Reuses StrictDoc's own Pygments lexer and pygments.css (same ones used to highlight `.. code:: strictdoc` blocks in the docs) to render the .sdoc source in the editor scene, instead of plain unhighlighted text. --- tests/screencast/editor.html | 7 +-- tests/screencast/helpers/editor_scene.py | 59 +++++++++++++++++++----- 2 files changed, 50 insertions(+), 16 deletions(-) diff --git a/tests/screencast/editor.html b/tests/screencast/editor.html index 5f75812f2..998f00616 100644 --- a/tests/screencast/editor.html +++ b/tests/screencast/editor.html @@ -45,6 +45,7 @@ #code { counter-reset: line; + background: transparent; } .line { @@ -72,10 +73,6 @@ background: rgba(46, 160, 67, 0.18); } - .line.added .line-text { - color: #7ee787; - } - .cursor-line .line-text::after { content: "▌"; animation: blink 0.8s infinite; @@ -89,7 +86,7 @@
-
+
diff --git a/tests/screencast/helpers/editor_scene.py b/tests/screencast/helpers/editor_scene.py index f08d45f85..3a044bab6 100644 --- a/tests/screencast/helpers/editor_scene.py +++ b/tests/screencast/helpers/editor_scene.py @@ -1,20 +1,46 @@ from __future__ import annotations +from pathlib import Path from typing import List from playwright.sync_api import Page +from pygments import highlight +from pygments.formatters import HtmlFormatter + +from strictdoc.backend.rst.strictdoc_lexer import StrictDocLexer + +# Reuses StrictDoc's own .sdoc syntax highlighter (the same Pygments lexer +# used to highlight `.. code:: strictdoc` blocks in the docs) and the +# product's own token colors, instead of hand-marking up keywords here. +_LEXER = StrictDocLexer() +_FORMATTER = HtmlFormatter(nowrap=True) + +_PYGMENTS_CSS_PATH = ( + Path(__file__).resolve().parents[3] + / "strictdoc" + / "export" + / "html" + / "_static" + / "pygments.css" +) _RENDER_ORIGINAL_JS = """ -([filename, lines]) => { +([filename, lines, css]) => { document.getElementById('fileTab').textContent = filename; + if (!document.getElementById('__pygments_css')) { + const style = document.createElement('style'); + style.id = '__pygments_css'; + style.textContent = css; + document.head.appendChild(style); + } const code = document.getElementById('code'); code.innerHTML = ''; - for (const line of lines) { + for (const lineHtml of lines) { const row = document.createElement('div'); row.className = 'line'; const text = document.createElement('span'); text.className = 'line-text'; - text.textContent = line; + text.innerHTML = lineHtml; row.appendChild(text); code.appendChild(row); } @@ -22,7 +48,7 @@ """ _APPEND_LINE_JS = """ -(line) => { +(lineHtml) => { const code = document.getElementById('code'); const previous = code.querySelector('.line.cursor-line'); if (previous) { @@ -32,7 +58,7 @@ row.className = 'line added cursor-line'; const text = document.createElement('span'); text.className = 'line-text'; - text.textContent = line; + text.innerHTML = lineHtml; row.appendChild(text); code.appendChild(row); row.scrollIntoView({ block: 'end' }); @@ -40,10 +66,22 @@ """ +def _highlight_lines(text: str) -> List[str]: + """ + One highlighted HTML fragment per source line (StrictDocLexer's rules + each match exactly one line, so this lines up 1:1 with `text`'s lines). + """ + html = highlight(text, _LEXER, _FORMATTER) + lines = html.split("\n") + if lines and lines[-1] == "": + lines.pop() + return lines + + def render_original(page: Page, filename: str, text: str) -> None: - """Renders the file's pre-existing content instantly, unhighlighted.""" - lines = text.splitlines() - page.evaluate(_RENDER_ORIGINAL_JS, [filename, lines]) + """Renders the file's pre-existing content instantly, highlighted.""" + css = _PYGMENTS_CSS_PATH.read_text(encoding="utf-8") + page.evaluate(_RENDER_ORIGINAL_JS, [filename, _highlight_lines(text), css]) def reveal_added_lines( @@ -53,7 +91,6 @@ def reveal_added_lines( Appends the newly written lines one at a time, highlighted, so the viewer sees exactly what the UI action just wrote to disk. """ - lines: List[str] = added_text.splitlines() - for line in lines: - page.evaluate(_APPEND_LINE_JS, line) + for line_html in _highlight_lines(added_text): + page.evaluate(_APPEND_LINE_JS, line_html) page.wait_for_timeout(line_delay_ms) From 254f4f9a1642cd854c4ac860310b5a2d4b774dc8 Mon Sep 17 00:00:00 2001 From: Maryna Balioura Date: Tue, 7 Jul 2026 13:52:11 +0200 Subject: [PATCH 22/39] docs(tests/screencast): document Pointer, pacing, scene styling, and syntax highlighting in README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds coverage in tests/screencast/README.md for the helpers table, video resolution (VIEWPORT_SIZE), pacing/pause knobs, the fake cursor/highlight mechanism (pointer.py/pointer.css), editing terminal/editor scene styles, and the real StrictDocLexer-based syntax highlighting — none of which was documented after the recent Pointer/hello_world work. --- tests/screencast/README.md | 136 +++++++++++++++++++++++++++++++++---- 1 file changed, 121 insertions(+), 15 deletions(-) diff --git a/tests/screencast/README.md b/tests/screencast/README.md index c2aa2429a..9d00d8c61 100644 --- a/tests/screencast/README.md +++ b/tests/screencast/README.md @@ -22,20 +22,117 @@ tests/screencast/ ├── fixture.py # shared fixture-project and port constants ├── fixtures/ │ └── strictdoc-demo-project/ # small, self-contained StrictDoc project used by scenarios -├── helpers/ # Playwright Page Object helpers (Screen, ViewTypeSelector, ...) +├── manual_scenarios.py # registry used by `invoke screencast-server --focus=...` +├── helpers/ # Playwright Page Object helpers, see below ├── scenarios/ -│ ├── conftest.py # `page` fixture (headless Chromium) + --strictdoc-record-video option +│ ├── conftest.py # `page` fixture (headless Chromium, VIEWPORT_SIZE) + --strictdoc-record-video option │ ├── typing.py # fake-typing effect for the IDE-style scene │ ├── strictdoc_ui/test_case.py -│ └── ide_typing_to_table/test_case.py -├── demo.html # standalone HTML playground for IDE/terminal-style scenes +│ ├── ide_typing_to_table/test_case.py +│ └── hello_world/test_case.py # strictdoc new -> web UI -> editor reveal, see below +├── demo.html # standalone HTML playground for the IDE-style scene +├── terminal.html # standalone HTML playground: full-frame terminal look +├── editor.html # standalone HTML playground: code-editor look (tab + line numbers) ├── run_server.py # manual dev server for inspecting a scene in the browser └── output/ # generated .webm videos (gitignored) ``` -`tests/screencast/helpers/` contains Playwright Page Object helpers -(`Screen`, `ViewTypeSelector`, ...). New scenarios should use and extend -these rather than inlining locators. +`tests/screencast/helpers/` contains Playwright Page Object helpers. New +scenarios should use and extend these rather than inlining locators or +raw `page.click()`/`page.fill()` calls: + +| Helper | Covers | +| --- | --- | +| `screen.py` (`Screen`) | Generic viewtype/header assertions. | +| `viewtype_selector.py` | Switching between document viewtypes (table, ...). | +| `project_tree.py` (`ProjectTree`) | Project index: document list, "add document" modal. | +| `actions_menu.py` (`ActionsMenu`) | The header's `...` actions menu. | +| `form.py` / `form_add_document.py` / `form_edit_requirement.py` | Filling in and submitting `sdoc-form` modals. | +| `node.py` (`Node`, `DocumentRoot`, `Requirement`, `AddNode_Menu`) | Opening a node's menu and adding a child/sibling node. | +| `pointer.py` (`Pointer`) | Makes clicks/typing visible on camera — see below. | +| `pacing.py` (`pause`) | Deliberate beats between steps — see below. | +| `editor_scene.py` | Drives the `editor.html` code-reveal scene — see below. | + +## Video resolution + +Both the browser viewport and the recorded `.webm`'s resolution come from +a single constant, `VIEWPORT_SIZE` in `scenarios/conftest.py`. Change it +there to change every scenario's output size; there's no per-scenario +override. + +## Pacing: controlling pauses + +Screencasts need deliberate pauses that a functional test wouldn't +(there's no viewer to wait for otherwise). There are a few independent +knobs: + +| What | Where | Default | +| --- | --- | --- | +| A beat between steps/scenes | `pause(page, seconds=...)` from `helpers/pacing.py`, called explicitly in a scenario | 1.0s | +| Pause after the cursor arrives, before clicking | `Pointer.click(target, pause_ms=...)` | 400ms | +| Pause after moving to a target (`Pointer.move_to`) | `Pointer.move_to(target, pause_ms=...)` | 300ms | +| Typing speed in form fields | `Pointer.type_into(target, text, delay_ms=...)` | 35ms/char | +| Typing speed in `demo.html`/`terminal.html` scenes | `type_text(locator, text, delay_ms=...)` in `scenarios/typing.py` | 45ms/char | +| Line-reveal speed in the editor scene | `editor_scene.reveal_added_lines(page, text, line_delay_ms=...)` | 350ms/line | + +None of this belongs in a real (future) Playwright e2e suite — there, +actions should run at Playwright's normal speed with its built-in +actionability waits, not with artificial pauses. `Pointer`/`pause()` are +screencast-only, for the benefit of a human watching the video. + +## Making clicks visible: the fake cursor and highlight + +Playwright doesn't render a real OS mouse cursor, so a driven click is +otherwise invisible in a recording. `helpers/pointer.py`'s `Pointer` +injects: + +- a small dot that follows real `mousemove` events (dispatched by + `page.mouse.move(..., steps=N)`, so it visibly travels rather than + teleporting), and +- a pulsing outline that highlights the click target just before the + click. + +This is registered via `page.add_init_script()`, so it re-applies +automatically on every navigation within a scenario (a `file://` scene, +the live server, another `file://` scene, ...) — no need to re-inject it +per page. + +To change how the cursor or highlight *look* (color, size, shape, +animation), edit `helpers/pointer.css` — plain CSS, no build step. Its +content is read and spliced into the injected script at import time (via +`json.dumps()`, so any CSS content is safe to use there, including quotes +or backslashes). + +## Editing scene visuals (terminal, editor, IDE) + +`demo.html`, `terminal.html`, and `editor.html` are self-contained HTML +files with an inline `