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/.gitignore b/.gitignore index a2fa1fd9f..e63dbb361 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,8 @@ __pycache__ /tests/unit_server/**/output/ +/tests/screencast/output/ +/tests/screencast/temp/ ### LIT/FileCheck integration tests' artifacts. ### /tests/integration/**/Output/** diff --git a/developer/tasks/20260704_screencast/task.md b/developer/tasks/20260704_screencast/task.md new file mode 100644 index 000000000..5916fe140 --- /dev/null +++ b/developer/tasks/20260704_screencast/task.md @@ -0,0 +1,68 @@ +# Screencast generator: move demo-video pipeline into StrictDoc + +## WHAT + +- 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 + +StrictDoc needs a maintainable way to generate product demo videos that stay +synchronized with the actual UI. + +Video scenarios should live next to the features they demonstrate and run +through the same automation pipeline as UI tests. + +A single scenario definition can then serve both as a source for generating +videos and as a regression check for the demonstrated workflow. + +## HOW + +- **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, 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 (a separate, future initiative). 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 + + 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 diff --git a/strictdoc/export/html/_static/content.css b/strictdoc/export/html/_static/content.css index 203e0c0d6..4d4f3230b 100644 --- a/strictdoc/export/html/_static/content.css +++ b/strictdoc/export/html/_static/content.css @@ -85,7 +85,7 @@ [data-viewtype="document"] .content { display: block; max-width: 900px; - margin-bottom: 300px; + /* margin-bottom: 300px; */ margin-left: auto; margin-right: auto; } diff --git a/strictdoc/export/html/_static/element.css b/strictdoc/export/html/_static/element.css index 4eb76fc31..9db368cde 100644 --- a/strictdoc/export/html/_static/element.css +++ b/strictdoc/export/html/_static/element.css @@ -1346,6 +1346,10 @@ a.tree_item, font-size: var(--font-size-sm); font-weight: 700; width: 99%; /* To calculate cite overflow */ + /* Flex items default to min-width: auto, which floors width at the + content's intrinsic size and makes width: 99% overflow the parent on + narrow viewports (flexbox min-width:auto pitfall). */ + min-width: 0; } .document_title[data-file_name]::after { diff --git a/tasks.py b/tasks.py index 5916d5a78..3fc9fa4cf 100644 --- a/tasks.py +++ b/tasks.py @@ -179,6 +179,29 @@ def server(context, input_path=".", config=None, port=None): ) +@task(aliases=["scs"]) +def screencast_server(context, focus=None, edit=False): + """ + 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. + + By default this serves a disposable copy, rebuilt fresh every time, so + nothing done through the UI persists. Pass --edit to serve the real, + persistent files instead (the shared fixture itself, or a generated + project reused across restarts) when intentionally editing them. + """ + + focus_argument = f"--focus {focus}" if focus is not None else "" + edit_argument = "--edit" if edit else "" + + run_invoke_with_tox( + context, + ToxEnvironment.CHECK, + f"python tests/screencast/run_server.py {focus_argument} {edit_argument}", + ) + + @task(aliases=["d"]) def docs(context): run_invoke_with_tox( @@ -378,6 +401,50 @@ 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=["scov"]) +def screencast_optimize_video(context, focus=None): + """ + Converts recorded tests/screencast/output/*.webm into muted, + web-optimized .webm (VP9) and .mp4 (H.264) pairs under + tests/screencast/output/web/, ready to hand off to the product website. + + Run `invoke test-screencast --record-video` first to produce the raw + recordings this reads from. + """ + + focus_argument = f"--focus {focus}" if focus is not None else "" + + run_invoke( + context, f"python tests/screencast/optimize_video.py {focus_argument}" + ) + + @task(aliases=["tu"]) def test_unit(context, coverage=False, focus=None, path=None, output=False): """ diff --git a/tests/screencast/README.md b/tests/screencast/README.md new file mode 100644 index 000000000..e04706ac6 --- /dev/null +++ b/tests/screencast/README.md @@ -0,0 +1,358 @@ +# 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. + +## Quick reference + +Commands: + +| Command | Effect | +| --- | --- | +| `invoke test-screencast` | Run every scenario, fast pass/fail, no video. | +| `invoke test-screencast --focus=` | Run only scenarios matching a pytest `-k` expression. | +| `invoke test-screencast --record-video` | Also (re)generate every scenario's `.webm`. | +| `invoke test-screencast --record-video --focus=` | (Re)generate just one scenario's `.webm`. | +| `invoke screencast-optimize-video` (alias `scov`) | Convert every recorded `.webm` into a web-ready `.webm`/`.mp4` pair, see below. | +| `invoke screencast-optimize-video --focus=` | Convert just one scenario's recording. | +| `invoke screencast-server` (alias `scs`) | Manual server on a disposable copy of the shared fixture, at `http://127.0.0.1:5301`. | +| `invoke screencast-server --focus=` | Manual server on a disposable copy of a specific scenario's project instead. | +| `invoke screencast-server --edit [--focus=]` | Same, but on the real, persistent files — changes through the UI stick around. | +| `playwright install chromium` | One-time setup — see "Setup" below. | + +Where to change what: + +| To change... | Edit... | +| --- | --- | +| Video/viewport resolution | `VIEWPORT_SIZE` in `scenarios/conftest.py`. | +| Pauses between steps | `pause()` calls in a scenario (`helpers/pacing.py`). | +| Pause before a click, or typing speed | `pause_ms`/`delay_ms` args on `Pointer.click`/`move_to`/`type_into` calls. | +| Cursor/highlight look (color, size, shape) | `helpers/pointer.css`. | +| Terminal/editor/IDE scene visuals (layout, colors) | `terminal.html` / `editor.html` / `demo.html` (plain CSS). | +| What a scene shows, or when | The scenario's `test_case.py`, or `helpers/editor_scene.py`. | +| `.sdoc` syntax colors in the editor scene | Not here — it reuses the product's own `strictdoc/export/html/_static/pygments.css`. | +| Which project a manual server (`--focus=`) serves | `manual_scenarios.py`'s `SCENARIOS` registry. | + +Each row is explained in its own section below. + +## 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 +├── manual_scenarios.py # registry used by `invoke screencast-server --focus=...` +├── helpers/ # Playwright Page Object helpers, see below +├── scenarios/ +│ ├── 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 +│ └── 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 +├── optimize_video.py # converts a recording into a web-ready .webm/.mp4 pair +└── output/ # generated .webm videos (gitignored) + └── web/ # web-optimized .webm/.mp4 pairs (gitignored) +``` + +`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 ` + + +
+
+
+

Requirements that stay traceable

+

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

+
+ +
+
+

+      
+
+
+ + diff --git a/tests/screencast/editor.html b/tests/screencast/editor.html new file mode 100644 index 000000000..8c162e6d3 --- /dev/null +++ b/tests/screencast/editor.html @@ -0,0 +1,94 @@ + + + + + StrictDoc editor + + + +
+
+
+
+ + diff --git a/tests/screencast/fixture.py b/tests/screencast/fixture.py new file mode 100644 index 000000000..beb13efb1 --- /dev/null +++ b/tests/screencast/fixture.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +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. +# +# 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 = 5302 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..a84391045 --- /dev/null +++ b/tests/screencast/fixtures/strictdoc-demo-project/README.md @@ -0,0 +1,24 @@ +# StrictDoc Demo Project + +This project is used by the screencast scenario/video generation pipeline +in `tests/screencast/`. + +## 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/helpers/actions_menu.py b/tests/screencast/helpers/actions_menu.py new file mode 100644 index 000000000..e137211e0 --- /dev/null +++ b/tests/screencast/helpers/actions_menu.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from tests.screencast.helpers.pointer import Pointer + + +class ActionsMenu: + """ + Playwright counterpart of + tests/end2end/helpers/components/actions_menu.py, covering only what + the screencast scenarios currently need. + """ + + def __init__(self, pointer: Pointer) -> None: + self.pointer = pointer + + def do_open(self) -> None: + self.pointer.click('[data-testid="header-action-menu-handler"]') + + def do_click_action(self, testid: str) -> None: + self.do_open() + 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..20818db4b --- /dev/null +++ b/tests/screencast/helpers/editor_scene.py @@ -0,0 +1,124 @@ +from __future__ import annotations + +import difflib +from pathlib import Path + +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, 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 lineHtml of lines) { + const row = document.createElement('div'); + row.className = 'line'; + const text = document.createElement('span'); + text.className = 'line-text'; + text.innerHTML = lineHtml; + row.appendChild(text); + code.appendChild(row); + } +} +""" + +_INSERT_LINE_JS = """ +([index, lineHtml]) => { + 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.innerHTML = lineHtml; + row.appendChild(text); + const reference = code.children[index] || null; + code.insertBefore(row, reference); + row.scrollIntoView({ block: 'center' }); +} +""" + + +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, highlighted.""" + css = _PYGMENTS_CSS_PATH.read_text(encoding="utf-8") + page.evaluate(_RENDER_ORIGINAL_JS, [filename, _highlight_lines(text), css]) + + +def reveal_change( + page: Page, original_text: str, final_text: str, *, line_delay_ms: int = 350 +) -> None: + """ + Reveals whatever changed between `original_text` (already rendered by + `render_original`) and `final_text`, one new line at a time, inserted + at its real position (not just appended) — so an edit in the middle + of the file (e.g. a field added to an existing node) reveals in + place, not as a trailing block. + + Only insertions are supported: this is for scenarios that add + content (a new field, a new node), not ones that remove or rewrite + existing lines. `strictdoc`'s save-and-reformat is assumed not to + have touched any line outside the inserted ones; the assertion below + catches it loudly if that assumption ever breaks. + """ + original_lines = original_text.splitlines() + final_lines = final_text.splitlines() + highlighted_final = _highlight_lines(final_text) + + matcher = difflib.SequenceMatcher(a=original_lines, b=final_lines, autojunk=False) + dom_offset = 0 + for tag, a1, _a2, b1, b2 in matcher.get_opcodes(): + if tag == "equal": + continue + assert tag == "insert", ( + f"reveal_change only supports pure insertions, got {tag!r}: " + "the edit changed or removed existing lines, not just added " + "new ones." + ) + for offset, line_index in enumerate(range(b1, b2)): + dom_index = a1 + dom_offset + offset + page.evaluate( + _INSERT_LINE_JS, [dom_index, highlighted_final[line_index]] + ) + page.wait_for_timeout(line_delay_ms) + dom_offset += b2 - b1 diff --git a/tests/screencast/helpers/form.py b/tests/screencast/helpers/form.py new file mode 100644 index 000000000..4d97b33c0 --- /dev/null +++ b/tests/screencast/helpers/form.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +from playwright.sync_api import expect + +from tests.screencast.helpers.pointer import Pointer + + +class Form: + """ + Playwright counterpart of tests/end2end/helpers/form/form.py, covering + only what the screencast scenarios currently need. + """ + + 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.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"]') + self.pointer.click('[data-testid="form-submit-action"]') + 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..fe708405d --- /dev/null +++ b/tests/screencast/helpers/form_edit_requirement.py @@ -0,0 +1,17 @@ +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) + + def do_fill_in_field_rationale(self, field_value: str) -> None: + super().do_fill_in("RATIONALE", field_value) diff --git a/tests/screencast/helpers/node.py b/tests/screencast/helpers/node.py new file mode 100644 index 000000000..2fa5b133f --- /dev/null +++ b/tests/screencast/helpers/node.py @@ -0,0 +1,100 @@ +from __future__ import annotations + +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 + """ + Playwright counterpart of + tests/end2end/helpers/components/node/add_node_menu.py, covering only + what the screencast scenarios currently need. + """ + + 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.pointer.click( + self.node_locator.locator( + '[data-testid="node-add-requirement-first-action"]' + ) + ) + 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: + """ + Playwright counterpart of + tests/end2end/helpers/components/node/node.py, covering only what the + screencast scenarios currently need. + """ + + 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.pointer.move_to(self.node_locator) + self.pointer.click( + self.node_locator.locator('[data-testid="node-menu-handler"]') + ) + return AddNode_Menu(self.pointer, self.node_locator) + + def do_open_form_edit_requirement(self) -> Form_EditRequirement: + self.pointer.move_to(self.node_locator) + self.pointer.click( + self.node_locator.locator('[data-testid="node-edit-action"]') + ) + return Form_EditRequirement(self.pointer) + + def do_click_parent_relation(self) -> None: + """ + Follows the node's "parent relations" link. When the parent lives + in another document, this is a real navigation (no data-testid on + these links — StrictDoc renders them as plain + `a.requirement__link-parent`), so callers should re-locate nodes + via Requirement.with_node(...) etc. on the new page afterwards. + """ + self.pointer.click( + self.node_locator.locator( + 'sdoc-node-field[data-field-label="parent relations"] ' + "a.requirement__link-parent" + ) + ) + + def do_click_child_relation(self) -> None: + """Same as do_click_parent_relation, for a "child relations" link.""" + self.pointer.click( + self.node_locator.locator( + 'sdoc-node-field[data-field-label="child relations"] ' + "a.requirement__link-child" + ) + ) + + +class DocumentRoot(Node): + 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.css b/tests/screencast/helpers/pointer.css new file mode 100644 index 000000000..6582eb380 --- /dev/null +++ b/tests/screencast/helpers/pointer.css @@ -0,0 +1,31 @@ +#__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; + opacity: 0; + transition: left 0.05s linear, top 0.05s linear, opacity 0.15s ease-out; +} + +#__screencast_cursor.__screencast_cursor_visible { + opacity: 1; +} + +.__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 new file mode 100644 index 000000000..066994844 --- /dev/null +++ b/tests/screencast/helpers/pointer.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +import json +from pathlib import Path + +from playwright.sync_api import Locator, Page +from playwright.sync_api import TimeoutError as PlaywrightTimeoutError + +Target = 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 (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 = __CSS__; + + 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(); + } +})(); +""".replace("__CSS__", json.dumps(_CSS)) + + +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}" + # Hidden by default (see pointer.css): scenes that never click + # anything (the terminal/editor reveal scenes) never show a + # cursor frozen in the top-left corner. Revealed here, the first + # time this page actually needs one. + self.page.evaluate( + "document.getElementById('__screencast_cursor')" + ".classList.add('__screencast_cursor_visible')" + ) + 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 new file mode 100644 index 000000000..ef4d0e584 --- /dev/null +++ b/tests/screencast/helpers/project_tree.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +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: + """ + Playwright counterpart of + tests/end2end/helpers/screens/project_index/screen_project_index.py, + covering only what the screencast scenarios currently need. + """ + + 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( + 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.pointer) + + def do_click_on_the_document_with_title(self, document_title: str) -> None: + link = self.page.locator('[data-testid="tree-file-link"]').filter( + has_text=document_title + ) + self.pointer.click(link) 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/manual_scenarios.py b/tests/screencast/manual_scenarios.py new file mode 100644 index 000000000..5cf37d676 --- /dev/null +++ b/tests/screencast/manual_scenarios.py @@ -0,0 +1,72 @@ +""" +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 (given `editable`) to a project directory to serve, +plus an optional config path (None if the project carries its own +strictdoc_config.py): + +- `editable=False` (the default): a disposable copy, freshly rebuilt from + source on every call. Anything done through the UI is discarded the + next time the server is (re)started — nothing you do here can affect + the shared fixture or a previous manual session. +- `editable=True` (`--edit`): the scenario's real, persistent files — + the checked-in shared fixture itself, or a generated project that's + reused (not regenerated) across restarts. Changes made through the UI + are real and stick around. Opt in deliberately, when curating fixture + content is the point. +""" + +from __future__ import annotations + +import shutil +from pathlib import Path +from typing import Callable + +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[[bool], tuple[Path, Path | None]] + + +def _prepare_fixture_project(editable: bool) -> tuple[Path, Path | None]: + if editable: + return FIXTURE_DIR, FIXTURE_CONFIG + + project_dir = MANUAL_SERVER_BUILD_DIR / "fixture" / "readonly" + if project_dir.exists(): + shutil.rmtree(project_dir) + shutil.copytree(FIXTURE_DIR, project_dir) + return project_dir, project_dir / "strictdoc_config.py" + + +def _prepare_hello_world_project(editable: bool) -> tuple[Path, Path | None]: + if editable: + project_dir = MANUAL_SERVER_BUILD_DIR / "hello_world" / "edit" + # 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 (see README.md). + if not (project_dir / "strictdoc_config.py").exists(): + run_strictdoc_new(project_dir) + return project_dir, None + + project_dir = MANUAL_SERVER_BUILD_DIR / "hello_world" / "readonly" + if project_dir.exists(): + shutil.rmtree(project_dir) + 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/optimize_video.py b/tests/screencast/optimize_video.py new file mode 100644 index 000000000..6da9824f6 --- /dev/null +++ b/tests/screencast/optimize_video.py @@ -0,0 +1,149 @@ +from __future__ import annotations + +import argparse +import shutil +import subprocess +import sys +from pathlib import Path + +SCREENCAST_DIR = Path(__file__).resolve().parent +OUTPUT_DIR = SCREENCAST_DIR / "output" +WEB_OUTPUT_DIR = OUTPUT_DIR / "web" + +# Never upscale: a scenario recorded narrower than this (see VIEWPORT_SIZE in +# scenarios/conftest.py) is left at its native width. +MAX_WIDTH = 1280 +SCALE_FILTER = f"scale='min({MAX_WIDTH},iw)':-2" + + +def find_ffmpeg() -> str: + ffmpeg_path = shutil.which("ffmpeg") + if ffmpeg_path is None: + raise OSError( + "ffmpeg was not found on PATH. It is required to produce " + "web-optimized videos from the recorded screencast .webm files. " + "Install it (e.g. `brew install ffmpeg` on macOS, " + "`apt-get install ffmpeg` on Debian/Ubuntu) and ensure it is on " + "PATH, then re-run this command." + ) + return ffmpeg_path + + +def encode_webm(ffmpeg_path: str, source: Path, destination: Path) -> None: + subprocess.run( + [ + ffmpeg_path, + "-y", + "-i", + str(source), + "-vf", + SCALE_FILTER, + "-c:v", + "libvpx-vp9", + "-b:v", + "0", + "-crf", + "32", + "-an", + str(destination), + ], + check=True, + ) + + +def encode_mp4(ffmpeg_path: str, source: Path, destination: Path) -> None: + subprocess.run( + [ + ffmpeg_path, + "-y", + "-i", + str(source), + "-vf", + SCALE_FILTER, + "-c:v", + "libx264", + "-crf", + "23", + "-preset", + "slow", + "-movflags", + "+faststart", + "-an", + str(destination), + ], + check=True, + ) + + +def optimize_one(ffmpeg_path: str, source: Path) -> None: + scenario_name = source.stem + webm_destination = WEB_OUTPUT_DIR / f"{scenario_name}.webm" + mp4_destination = WEB_OUTPUT_DIR / f"{scenario_name}.mp4" + + print(f"🎬 Optimizing {source.name} for the web...") # noqa: T201 + encode_webm(ffmpeg_path, source, webm_destination) + encode_mp4(ffmpeg_path, source, mp4_destination) + + for destination in (webm_destination, mp4_destination): + size_mb = destination.stat().st_size / (1024 * 1024) + print(f" -> {destination} ({size_mb:.2f} MB)") # noqa: T201 + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description=( + "Converts recorded screencast .webm files in " + f"{OUTPUT_DIR.relative_to(SCREENCAST_DIR.parent.parent)} into " + "muted, web-optimized .webm (VP9) and .mp4 (H.264) pairs " + f"under {WEB_OUTPUT_DIR.relative_to(SCREENCAST_DIR.parent.parent)}." + ) + ) + parser.add_argument( + "--focus", + default=None, + help=( + "Only optimize the recording for this scenario name (e.g. " + "hello_world), instead of every .webm in the output directory." + ), + ) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + + try: + ffmpeg_path = find_ffmpeg() + except OSError as error: + print(error, file=sys.stderr) # noqa: T201 + return 1 + + if args.focus is not None: + sources = [OUTPUT_DIR / f"{args.focus}.webm"] + if not sources[0].is_file(): + print( # noqa: T201 + f"No recording found at {sources[0]}. Record it first with " + f"`invoke test-screencast --record-video --focus={args.focus}`.", + file=sys.stderr, + ) + return 1 + else: + sources = sorted(OUTPUT_DIR.glob("*.webm")) + if not sources: + print( # noqa: T201 + f"No .webm recordings found in {OUTPUT_DIR}. Record some " + "first with `invoke test-screencast --record-video`.", + file=sys.stderr, + ) + return 1 + + WEB_OUTPUT_DIR.mkdir(parents=True, exist_ok=True) + + for source in sources: + optimize_one(ffmpeg_path, source) + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/screencast/run_server.py b/tests/screencast/run_server.py new file mode 100644 index 000000000..7586cbce3 --- /dev/null +++ b/tests/screencast/run_server.py @@ -0,0 +1,229 @@ +from __future__ import annotations + +import argparse +import os +import subprocess +import sys +import time +import unicodedata + +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 DEV_SERVER_PORT # noqa: E402 +from tests.screencast.manual_scenarios import ( # noqa: E402 + DEFAULT_SCENARIO, + SCENARIOS, +) + + +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 + # 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]: + 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( # noqa: T201 + 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 _display_width(text: str) -> int: + """ + Terminal column width of `text`: wide characters (most emoji, CJK) + render as 2 columns despite `len()` counting them as 1, which throws + off plain `.center()`/`.ljust()` for a fixed-width box. + """ + return sum( + 2 if unicodedata.east_asian_width(ch) in ("W", "F") else 1 + for ch in text + ) + + +def _center(text: str, width: int) -> str: + padding = max(width - _display_width(text), 0) + left = padding // 2 + return " " * left + text + " " * (padding - left) + + +def _ljust(text: str, width: int) -> str: + return text + " " * max(width - _display_width(text), 0) + + +def print_banner(*, scenario: str, mode_label: str, url: str) -> None: + """ + Same ASCII-box style as StrictDoc's own server startup banner + (strictdoc/server/app.py::print_welcome_message), so this reads as + part of the same product rather than a one-off print. + """ + width = 72 + border = "=" * width + + lines = [ + f" {_center('StrictDoc 🎥 Screencast Manual Server', width - 2)} ", + "", + f" Scenario: {scenario}", + f" Mode: {mode_label}", + f" URL: {url}", + "", + " Press Ctrl+C to stop.", + ] + + banner = ( + f"+{border}+\n" + + "\n".join(f"|{_ljust(line, width)}|" for line in lines) + + f"\n+{border}+" + ) + print(banner) # noqa: T201 + + +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})." + ), + ) + parser.add_argument( + "--edit", + action="store_true", + default=False, + help=( + "Serve the scenario's real, persistent files (the shared " + "fixture itself, or a generated project reused across " + "restarts) instead of a disposable, freshly rebuilt copy. " + "Changes made through the UI are real and stick around — " + "opt in only when that's the point." + ), + ) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + + try: + project_dir, config_path = SCENARIOS[args.focus](args.edit) + + free_dev_port(DEV_SERVER_PORT) + + with SDocTestServer( + input_path=str(project_dir), + config_path=str(config_path) if config_path is not None else None, + port=DEV_SERVER_PORT, + ) as server: + mode_label = ( + "editable (changes persist!)" if args.edit else "read-only copy" + ) + print_banner( + scenario=args.focus, + mode_label=mode_label, + url=server.get_host_and_port(), + ) + + server.process.wait() + + except KeyboardInterrupt: + print("\n🛑 Stopping StrictDoc demo server.") # noqa: T201 + + except OSError as error: + print(error, file=sys.stderr) # noqa: T201 + return 1 + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/screencast/scenarios/conftest.py b/tests/screencast/scenarios/conftest.py new file mode 100644 index 000000000..1415b7583 --- /dev/null +++ b/tests/screencast/scenarios/conftest.py @@ -0,0 +1,62 @@ +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) + +from tests.screencast.fixture import OUTPUT_DIR # noqa: E402 + +VIEWPORT_SIZE = {"width": 1024, "height": 768} + + +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(request) -> Iterator[Page]: + record_video = request.config.getoption("--strictdoc-record-video") + + with sync_playwright() as playwright: + browser: Browser = playwright.chromium.launch(headless=True) + + 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() 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..894836e28 --- /dev/null +++ b/tests/screencast/scenarios/hello_world/test_case.py @@ -0,0 +1,190 @@ +""" +Screencast scenario: creating a project from scratch with `strictdoc new`, +adding a Rationale to an existing Requirement 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). +""" + +from __future__ import annotations + +import os +import subprocess +import sys +from pathlib import Path + +from playwright.sync_api import Locator, Page, expect + +from tests.end2end.server import SDocTestServer +from tests.screencast.fixture import RECORD_SERVER_PORT +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 + +TERMINAL_HTML = os.path.abspath(os.path.join(__file__, "../../../terminal.html")) +EDITOR_HTML = os.path.abspath(os.path.join(__file__, "../../../editor.html")) + +RATIONALE_TEXT = ( + "A printed message is the simplest way to verify the application " + "satisfies the parent requirement." +) + + +def run_strictdoc_new(project_dir: Path) -> str: + env = dict(os.environ) + env["PYTHONPATH"] = os.getcwd() + + # Passed as a relative path, run from its own parent directory, so + # the command's own printed output ("Location: ...", "cd ...") reads + # short and relative — the same as a real user typing `strictdoc new + # hello-world` — instead of leaking pytest's absolute tmp_path. + # subprocess's cwd must already exist (unlike `strictdoc new` itself, + # which happily creates its target); callers like manual_scenarios.py + # may not have created it yet. + project_dir.parent.mkdir(parents=True, exist_ok=True) + result = subprocess.run( + [sys.executable, "-m", "strictdoc.cli.main", "new", project_dir.name], + capture_output=True, + text=True, + env=env, + check=True, + cwd=project_dir.parent, + ) + return result.stdout + + +def wait_for_stable_scroll_height( + locator: Locator, *, poll_ms: int = 100, max_polls: int = 20 +) -> None: + """ + Waits until `locator.scrollHeight` stops changing between polls. + Content that just appeared (e.g. a form that was just opened) can + keep growing for a bit as it settles, so reading scrollHeight too + early undershoots a "scroll to the bottom". + """ + previous_height = None + for _ in range(max_polls): + current_height = locator.evaluate("(el) => el.scrollHeight") + if current_height == previous_height: + return + previous_height = current_height + locator.page.wait_for_timeout(poll_ms) + + +class Test: + def test(self, page: Page, tmp_path: Path) -> None: + pointer = Pointer(page) + + # Scene 1: the real `strictdoc new` output, in a terminal look. + # Navigate here first, before actually running the command below: + # video recording starts as soon as the browser context exists + # (see scenarios/conftest.py), so running the (real, non-trivial) + # subprocess before the first goto() would show a blank white + # about:blank page at the very start of the video. + page.goto(f"file://{TERMINAL_HTML}") + demo_text = page.locator("#demoText") + expect(demo_text).to_be_visible() + + type_text(demo_text, "$ strictdoc new hello-world\n") + + project_dir = tmp_path / "hello-world" + cli_output = run_strictdoc_new(project_dir) + + # The output appears in full, scrolled to the top — read the + # banner first... + demo_text.evaluate( + "(el, text) => { el.textContent += text; }", cli_output + ) + pause(page, 1) + + # ...then a smooth (CSS scroll-behavior) scroll down to the end, + # if it doesn't already fit, so the "Next steps" instructions are + # readable too instead of being cut off below the fold. + demo_text.evaluate("(el) => { el.scrollTop = el.scrollHeight; }") + pause(page, 1) + + llr_path = project_dir / "docs" / "low_level_requirements.sdoc" + original_sdoc_text = llr_path.read_text(encoding="utf-8") + + with SDocTestServer( + input_path=str(project_dir), + port=RECORD_SERVER_PORT, + ) 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(pointer) + project_tree.assert_contains_document("Low-Level Requirements") + pause(page) + + project_tree.do_click_on_the_document_with_title( + "Low-Level Requirements" + ) + screen.assert_on_screen("document") + pause(page) + + # Scene 3: follow the traceability link up to the parent + # requirement (a real cross-document navigation to + # High-Level Requirements), then back down via its child + # relation — showing the link between the two documents is + # real and works both ways. + llr_1 = Requirement.with_node(pointer) + llr_1.do_click_parent_relation() + screen.assert_on_screen("document") + pause(page) + + hlr_1 = Requirement.with_node(pointer) + hlr_1.do_click_child_relation() + screen.assert_on_screen("document") + pause(page) + + # Scene 4: adding a Rationale to the existing Requirement. + llr_1 = Requirement.with_node(pointer) + form_edit_requirement = llr_1.do_open_form_edit_requirement() + + # Scroll the content area to the bottom before typing starts, + # so the (last) Rationale field is settled in view — purely + # cosmetic, .main already has scroll-behavior: smooth. The + # form's height keeps growing for a bit right after it opens + # (its fields animate/settle in), so scrollHeight must be + # given time to stabilize first, or the scroll undershoots + # against the still-growing content. + wait_for_stable_scroll_height(page.locator(".main")) + page.locator(".main").evaluate( + "(el) => { el.scrollTop = el.scrollHeight; }" + ) + pause(page) + + form_edit_requirement.do_fill_in_field_rationale(RATIONALE_TEXT) + pause(page) + form_edit_requirement.do_form_submit() + + expect( + page.locator('[data-field-label="rationale"]').filter( + has_text=RATIONALE_TEXT + ) + ).to_be_visible() + pause(page, 1) + + final_sdoc_text = llr_path.read_text(encoding="utf-8") + assert RATIONALE_TEXT not in original_sdoc_text + assert RATIONALE_TEXT in final_sdoc_text + + # Scene 5: the real resulting .sdoc source, editor-style, with the + # Rationale revealed exactly where it landed in the file. + page.goto(f"file://{EDITOR_HTML}") + editor_scene.render_original( + page, "docs/low_level_requirements.sdoc", original_sdoc_text + ) + pause(page) + editor_scene.reveal_change(page, original_sdoc_text, final_sdoc_text) + pause(page, 2) 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..260e0c42e --- /dev/null +++ b/tests/screencast/scenarios/ide_typing_to_table/test_case.py @@ -0,0 +1,63 @@ +""" +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.helpers.viewtype_selector import ViewTypeSelector +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" + ) + + viewtype_selector = ViewTypeSelector(page) + screen_table = viewtype_selector.go_to_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 new file mode 100644 index 000000000..494e76fe3 --- /dev/null +++ b/tests/screencast/scenarios/strictdoc_ui/test_case.py @@ -0,0 +1,45 @@ +""" +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, +) +from tests.screencast.helpers.viewtype_selector import ViewTypeSelector + + +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" + ) + + viewtype_selector = ViewTypeSelector(page) + screen_table = viewtype_selector.go_to_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() diff --git a/tests/screencast/scenarios/typing.py b/tests/screencast/scenarios/typing.py new file mode 100644 index 000000000..4edcf9077 --- /dev/null +++ b/tests/screencast/scenarios/typing.py @@ -0,0 +1,27 @@ +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; + el.scrollTop = el.scrollHeight; + } + """, + char, + ) + time.sleep(delay_ms / 1000) diff --git a/tests/screencast/terminal.html b/tests/screencast/terminal.html new file mode 100644 index 000000000..6056e1f01 --- /dev/null +++ b/tests/screencast/terminal.html @@ -0,0 +1,67 @@ + + + + + StrictDoc terminal + + + +
+
+
+

+    
+
+ +