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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 23 additions & 6 deletions .github/workflows/docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -91,18 +91,13 @@ jobs:
# Copies *.ipynb plus any local asset directories (e.g.
# docs/notebooks/figs/, referenced by relative path from markdown
# cells) -- but not the Makefile-generated *.py script conversions
# or other loose files. ik_benchmark.ipynb is excluded deliberately:
# it benchmarks numeric IK solver performance and uses the
# websocket-based Swift visualizer, neither of which is meaningful
# or functional inside the Pyodide sandbox -- it stays Colab-only.
# ik-symbolic.ipynb is included: SymPy is one of Pyodide's own packages
# or other loose files. ik-symbolic.ipynb is included: SymPy is one of Pyodide's own packages
# and the derivation takes 1 to 10 s without python-flint (timed in
# docs/lite/wasm_check, which checks it on every build).
run: |
mkdir -p docs/lite/files
rsync -a --exclude '.ipynb_checkpoints' --exclude '__pycache__' \
--exclude 'Untitled*.ipynb' --exclude '*.py' --exclude 'Makefile' --exclude 'README.md' \
--exclude 'ik_benchmark.ipynb' \
docs/notebooks/ docs/lite/files/

- name: Adapt staged notebooks for JupyterLite
Expand Down Expand Up @@ -156,6 +151,28 @@ jobs:
pip install jupyterlite-core "jupyterlite-pyodide-kernel==0.6.1" jupyter-server ipympl
cd docs/lite && jupyter lite build --output-dir ../build/html/lite

- name: Check JupyterLite in a real browser (install, widgets, animation)
# Builds a small site with the wheel built above and one notebook, opens it in
# Chromium, and checks that the site installs that wheel (not the last release
# from PyPI), draws an ipywidgets slider and an ipympl figure, and that a
# trajectory animates while its cell is running. See
# docs/lite/browser_check/. Informational (continue-on-error) until it has
# run green on main, then remove that line so that it blocks the deploy.
continue-on-error: true
timeout-minutes: 20
run: |
pip install playwright
playwright install --with-deps chromium
python docs/lite/browser_check/check_lite_in_browser.py --screenshot lite_check_failed.png

- name: Keep the screenshot if the browser check failed
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v7
with:
name: lite-browser-check
path: lite_check_failed.png
if-no-files-found: ignore

- name: Upload Pages artifact
uses: actions/upload-pages-artifact@v5
with:
Expand Down
35 changes: 35 additions & 0 deletions docs/lite/browser_check/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# JupyterLite browser check

Runs JupyterLite in a real browser (Chromium, with Playwright) and checks what the
Node check in `../wasm_check/` cannot see: that the site installs the toolbox that was
built with it, and that plots and widgets work.

`check_lite_in_browser.py` builds a small site from `docs/lite` (its configuration and
the wheel in `docs/lite/pypi`) plus one notebook that it writes, `browser_check.ipynb`,
which starts with the real generated bootstrap cell. It opens that in Chromium and runs
the notebook, which requires:

| Check | Why |
|---|---|
| the bootstrap cell prints `Running in browser using RTB v...` | the install path works at all |
| a hash of the installed `PyPlot.py` equals that of the checkout | the site installs the wheel built with it, not the last release from PyPI (a plain `micropip.install` cannot see the site's own index, `piplite` can; this is what went wrong once and nothing else noticed) |
| an `ipywidgets` slider is drawn | widgets work in JupyterLite |
| an `ipympl` figure of one pose is drawn | `%matplotlib ipympl`, which the notebooks use, works |
| a trajectory shows at least 5 different images while its cell is running | an `ipympl` figure cannot update while a cell runs, so the Toolbox pushes inline frames for an animation (`tests/test_notebook_animation.py` checks the same thing with a kernel and no browser) |

It runs in `docs.yml` after the site is built, as an informational step until it has
passed on `main`. To run it yourself:

```bash
pip install playwright && playwright install chromium
pip install jupyterlite-core "jupyterlite-pyodide-kernel==0.6.1" jupyter-server ipympl
RTB_PURE_WHEEL=1 python -m build --wheel --outdir docs/lite/pypi
python docs/lite/browser_check/check_lite_in_browser.py
```

`CHROMIUM_PATH` selects a Chromium executable, and `--screenshot` says where to save the
page when a check fails. The run takes about two minutes: most of it is building the
site and downloading Pyodide's packages in the browser.

The kernel version is pinned in `docs.yml` (`jupyterlite-pyodide-kernel==0.6.1`) for
browsers without WebAssembly JSPI; see the comment there before changing it.
284 changes: 284 additions & 0 deletions docs/lite/browser_check/check_lite_in_browser.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,284 @@
#!/usr/bin/env python
"""Check JupyterLite in a real browser: the toolbox installs, and plots and widgets work.

The Node/Pyodide check in ``../wasm_check/`` tests the install and the Python side
without a browser. This one builds a small JupyterLite site that contains the wheel
built from this checkout and one notebook, ``browser_check.ipynb``, opens it in
Chromium, and runs the notebook, which does what the notebooks of the site do:

1. runs the generated bootstrap cell and prints the version of the toolbox,
2. imports the toolbox and prints a hash of the source of ``PyPlot.py``,
3. shows an ``ipywidgets`` slider,
4. plots a robot with ``%matplotlib ipympl`` (an interactive figure),
5. animates a trajectory with ``%matplotlib ipympl``.

It requires: the bootstrap line, the hash of the installed ``PyPlot.py`` to be that of
the checkout (the site installs the wheel built with it, not the last release from
PyPI, which is what a plain ``micropip.install`` does), the slider and the figure to be
drawn, and the animation to show at least ``--min-frames`` different images while the
cell is still running (an ``ipympl`` figure cannot update then, so the Toolbox pushes
frames of its own).

Usage::

pip install playwright && playwright install chromium
pip install jupyterlite-core "jupyterlite-pyodide-kernel==0.6.1" jupyter-server ipympl
RTB_PURE_WHEEL=1 python -m build --wheel --outdir docs/lite/pypi
python docs/lite/browser_check/check_lite_in_browser.py

``CHROMIUM_PATH`` selects a particular Chromium executable. A screenshot of the final
page is saved on failure, to ``--screenshot``. Exits 0 if the checks pass, 1 if not.
"""

from __future__ import annotations

import argparse
import hashlib
import json
import os
import shutil
import subprocess

Check notice on line 40 in docs/lite/browser_check/check_lite_in_browser.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

docs/lite/browser_check/check_lite_in_browser.py#L40

Consider possible security implications associated with the subprocess module.
import sys
import tempfile
import time
from pathlib import Path

from playwright.sync_api import Error as PlaywrightError
from playwright.sync_api import sync_playwright

HERE = Path(__file__).resolve().parent
REPO = HERE.parents[2]
NOTEBOOKS = REPO / "docs" / "notebooks"
PYPLOT = REPO / "src" / "roboticstoolbox" / "backends" / "PyPlot" / "PyPlot.py"

BOOTSTRAP_TEXT = "Running in browser using RTB v"
# the cells of the notebook, in order
BOOTSTRAP, SETUP, WIDGET, FIGURE, ANIMATION = range(5)

CELLS = [
None, # the generated bootstrap cell, see make_notebook
"""%matplotlib ipympl
import hashlib, importlib
import roboticstoolbox as rtb
module = importlib.import_module("roboticstoolbox.backends.PyPlot.PyPlot")
print("PYPLOT_MD5", hashlib.md5(open(module.__file__, "rb").read()).hexdigest())
robot = rtb.models.DH.Puma560()
trajectory = rtb.jtraj(robot.qz, robot.qn, 100).q
print("READY")""",
"""import ipywidgets
slider = ipywidgets.IntSlider(value=3, description="n")
display(slider)""",
"robot.plot(robot.qn);",
"robot.plot(trajectory);",
]


class CheckFailed(Exception):
"""A check failed; the message says which."""


def make_notebook(path: Path) -> None:
"""Write browser_check.ipynb, with the real generated bootstrap cell."""
sys.path.insert(0, str(NOTEBOOKS))
import sync_bootstrap

template = (NOTEBOOKS / "_rtb_nb_bootstrap.py").read_text(encoding="utf-8")
sources = list(CELLS)
sources[BOOTSTRAP] = "".join(sync_bootstrap.generated_source(template))
nb = {
"cells": [
{
"cell_type": "code",
"execution_count": None,
"id": f"cell{i}",
"metadata": {},
"outputs": [],
"source": source,
}
for i, source in enumerate(sources)
],
"metadata": {
"kernelspec": {
"name": "python",
"display_name": "Python (Pyodide)",
"language": "python",
}
},
"nbformat": 4,
"nbformat_minor": 5,
}
path.write_text(json.dumps(nb), encoding="utf-8")


def build_site(lite_dir: Path, work: Path) -> Path:
"""Build a JupyterLite site with the wheel in lite_dir/pypi and the notebook."""
source = work / "lite"
(source / "files").mkdir(parents=True)
for name in ("jupyter-lite.json", "jupyter_lite_config.json"):
shutil.copy(lite_dir / name, source / name)
shutil.copytree(lite_dir / "pypi", source / "pypi")
if not list((source / "pypi").glob("roboticstoolbox_python-*.whl")):
raise CheckFailed(f"no wheel in {lite_dir / 'pypi'}: build it first")
make_notebook(source / "files" / "browser_check.ipynb")

out = work / "site"
subprocess.run(

Check failure on line 125 in docs/lite/browser_check/check_lite_in_browser.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

docs/lite/browser_check/check_lite_in_browser.py#L125

Detected subprocess function 'run' without a static string.

Check warning on line 125 in docs/lite/browser_check/check_lite_in_browser.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

docs/lite/browser_check/check_lite_in_browser.py#L125

subprocess call - check for execution of untrusted input.
[sys.executable, "-m", "jupyter", "lite", "build", "--output-dir", str(out)],
cwd=source,
check=True,
capture_output=True,
)
# let the script drive JupyterLab through its commands
config = out / "jupyter-lite.json"
data = json.loads(config.read_text(encoding="utf-8"))
data.setdefault("jupyter-config-data", {})["exposeAppInBrowser"] = True
config.write_text(json.dumps(data), encoding="utf-8")
return out


def run_checks(page, timeout_s: float, min_frames: int) -> list[str]:
"""Run the notebook in the page and check it; returns what passed."""
passed = []
cells = page.locator(".jp-NotebookPanel:not(.lm-mod-hidden) .jp-Cell")

page.wait_for_selector(
".jp-NotebookPanel:not(.lm-mod-hidden) .jp-Notebook", timeout=90_000
)
page.wait_for_function("window.jupyterapp !== undefined", timeout=60_000)
time.sleep(3)
page.evaluate(
"() => { window.jupyterapp.commands.execute('notebook:run-all-cells'); return 1 }"
)

prompts = lambda: page.evaluate( # noqa: E731
"[...document.querySelectorAll("
"'.jp-NotebookPanel:not(.lm-mod-hidden) .jp-Cell .jp-InputPrompt')]"
".map(e => e.textContent.trim())"
)
frames: list[str] = []
deadline = time.monotonic() + timeout_s
finished_at = None
while time.monotonic() < deadline:
time.sleep(1)
state = prompts()
if len(state) == len(CELLS) and "*" in state[ANIMATION]:
# the animation is running: what is on the screen must keep changing
area = cells.nth(ANIMATION).locator(".jp-OutputArea")
try:
if area.count() and area.first.is_visible():
frames.append(
hashlib.md5(area.first.screenshot(timeout=3000)).hexdigest()

Check warning on line 170 in docs/lite/browser_check/check_lite_in_browser.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

docs/lite/browser_check/check_lite_in_browser.py#L170

Detected MD5 hash algorithm which is considered insecure.

Check failure on line 170 in docs/lite/browser_check/check_lite_in_browser.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

docs/lite/browser_check/check_lite_in_browser.py#L170

The application was found using an insecure or risky digest or signature algorithm.

Check warning on line 170 in docs/lite/browser_check/check_lite_in_browser.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

docs/lite/browser_check/check_lite_in_browser.py#L170

Use of weak MD5 hash for security. Consider usedforsecurity=False
)
except PlaywrightError:
pass
if len(state) == len(CELLS) and all(
p and "*" not in p and p != "[ ]:" for p in state
):
finished_at = finished_at or time.monotonic()
if time.monotonic() - finished_at > 3:
break
else:
raise CheckFailed(f"the notebook did not finish within {timeout_s:.0f} s")

outputs = page.evaluate(
"[...document.querySelectorAll('.jp-NotebookPanel:not(.lm-mod-hidden) .jp-Cell')]"
".map(c => { const o = c.querySelector('.jp-OutputArea'); return o ? o.innerText : '' })"
)
for i, text in enumerate(outputs):
if "Traceback" in text or "Error" in text.split("\n")[0]:
raise CheckFailed(f"cell {i + 1} raised:\n{text[-1200:]}")

if BOOTSTRAP_TEXT not in outputs[BOOTSTRAP]:
raise CheckFailed(
f"no '{BOOTSTRAP_TEXT}' from the bootstrap cell:\n{outputs[BOOTSTRAP][-800:]}"
)
passed.append(
next(line for line in outputs[BOOTSTRAP].splitlines() if BOOTSTRAP_TEXT in line)
)

expected = hashlib.md5(PYPLOT.read_bytes()).hexdigest()

Check warning on line 199 in docs/lite/browser_check/check_lite_in_browser.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

docs/lite/browser_check/check_lite_in_browser.py#L199

Detected MD5 hash algorithm which is considered insecure.

Check failure on line 199 in docs/lite/browser_check/check_lite_in_browser.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

docs/lite/browser_check/check_lite_in_browser.py#L199

The application was found using an insecure or risky digest or signature algorithm.

Check warning on line 199 in docs/lite/browser_check/check_lite_in_browser.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

docs/lite/browser_check/check_lite_in_browser.py#L199

Use of weak MD5 hash for security. Consider usedforsecurity=False
reported = [
line.split()[1] for line in outputs[SETUP].splitlines() if "PYPLOT_MD5" in line
]
if reported != [expected]:
raise CheckFailed(
"the toolbox in the browser is not the wheel built from this checkout "
f"(PyPlot.py md5 {reported}, expected {expected}): the site installed "
"the last release from PyPI instead of the wheel built with it"
)
passed.append("the installed toolbox is the wheel built with the site")

if cells.nth(WIDGET).locator(".widget-slider, .jupyter-widgets").count() == 0:
raise CheckFailed("the ipywidgets slider was not drawn")
passed.append("ipywidgets slider drawn")

if cells.nth(FIGURE).locator(".jupyter-matplotlib-canvas-container").count() == 0:
raise CheckFailed("the ipympl figure of a single pose was not drawn")
passed.append("ipympl figure drawn (interactive)")

distinct = len(set(frames))
if distinct < min_frames:
raise CheckFailed(
f"the animation showed {distinct} different images in {len(frames)} "
f"samples while it was running, wanted at least {min_frames}"
)
passed.append(f"animation: {distinct} different images in {len(frames)} samples")
return passed


def main() -> int:
parser = argparse.ArgumentParser(description=__doc__.split("\n")[0])
parser.add_argument("--lite-dir", type=Path, default=REPO / "docs" / "lite")
parser.add_argument("--timeout", type=float, default=420, help="seconds to run")
parser.add_argument("--min-frames", type=int, default=5)
parser.add_argument("--screenshot", type=Path, default=Path("lite_check_failed.png"))
args = parser.parse_args()

with tempfile.TemporaryDirectory() as tmp:
site = build_site(args.lite_dir, Path(tmp))
server = subprocess.Popen(

Check failure on line 239 in docs/lite/browser_check/check_lite_in_browser.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

docs/lite/browser_check/check_lite_in_browser.py#L239

Detected subprocess function 'Popen' without a static string.

Check warning on line 239 in docs/lite/browser_check/check_lite_in_browser.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

docs/lite/browser_check/check_lite_in_browser.py#L239

subprocess call - check for execution of untrusted input.
[sys.executable, "-m", "http.server", "8890", "--directory", str(site)],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
try:
time.sleep(2)
with sync_playwright() as p:
browser = p.chromium.launch(
executable_path=os.environ.get("CHROMIUM_PATH") or None
)
# a tall window, since JupyterLab draws only the cells that are visible
page = browser.new_context(
viewport={"width": 1300, "height": 5000}
).new_page()
errors: list[str] = []
page.on("pageerror", lambda e: errors.append(str(e)[:200]))
try:
page.goto(
"http://localhost:8890/lab/index.html"
"?path=browser_check.ipynb&reset",
wait_until="load",
)
passed = run_checks(page, args.timeout, args.min_frames)
except (CheckFailed, PlaywrightError) as e:
print(f"FAIL: {e}")
for line in errors[:5]:
print(f" page error: {line}")
try:
page.screenshot(path=str(args.screenshot))
print(f"screenshot: {args.screenshot}")
except PlaywrightError:
pass
return 1
finally:
browser.close()
finally:
server.terminate()

for line in passed:
print(f"PASS: {line}")
return 0


if __name__ == "__main__":
sys.exit(main())
Loading
Loading