From 684345e3e760a18c857b3e44f64f6bbc2676e5dd Mon Sep 17 00:00:00 2001 From: Peter Corke Date: Wed, 14 Oct 2026 07:54:00 +1000 Subject: [PATCH 1/2] ci: browser smoke test of the JupyterLite site Run Chromium and WebKit (the Safari engine) against the JupyterLite site via Playwright: wait for the Pyodide kernel, run the bootstrap cell, and require its status line; also fail if JupyterLab plugins fail to activate, which happens before any kernel exists and which the Node Pyodide check cannot see. - docs.yml: check the built site before deploy (not on pull requests; informational until it has run green once on main). - lite-smoke.yml: check the live site after each Docs Publish on main and weekly, since PyPI drift arrives with no commit in this repo. Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/docs.yml | 31 ++++ .github/workflows/lite-smoke.yml | 56 ++++++ docs/lite/browser_check/README.md | 27 +++ .../browser_check/check_lite_in_browser.py | 172 ++++++++++++++++++ 4 files changed, 286 insertions(+) create mode 100644 .github/workflows/lite-smoke.yml create mode 100644 docs/lite/browser_check/README.md create mode 100644 docs/lite/browser_check/check_lite_in_browser.py diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index a554b638..4fa08e36 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -161,6 +161,37 @@ jobs: pip install jupyterlite-core "jupyterlite-pyodide-kernel==0.6.1" jupyter-server cd docs/lite && jupyter lite build --output-dir ../build/html/lite + # Real browsers, pre-deploy: gates publishing, so skipped for pull_request + # (which only builds). The Node/Pyodide check above covers dependency + # drift; this covers JupyterLab itself starting (before any kernel exists) + # and the kernel running the bootstrap cell, in Chromium and WebKit. + - name: Install browsers for the JupyterLite browser check + if: github.event_name != 'pull_request' + run: | + pip install playwright + playwright install --with-deps chromium webkit + + - name: Check the built JupyterLite site starts in a real browser + if: github.event_name != 'pull_request' + # Informational until it has run green once on main (it is skipped for + # pull requests, so its first real CI run is on a publish). Then delete + # this line so a failure blocks the deploy. + continue-on-error: true + run: | + python -m http.server 8000 --directory docs/build/html & + sleep 3 + python docs/lite/browser_check/check_lite_in_browser.py \ + --base-url http://localhost:8000/lite \ + --retries 1 --screenshot-dir browser-check-screenshots + + - name: Upload browser-check screenshots + if: failure() && github.event_name != 'pull_request' + uses: actions/upload-artifact@v7 + with: + name: lite-browser-check-screenshots + path: browser-check-screenshots + if-no-files-found: ignore + - name: Package notebooks zip run: | rm -f docs/build/html/mvtb_notebooks.zip diff --git a/.github/workflows/lite-smoke.yml b/.github/workflows/lite-smoke.yml new file mode 100644 index 00000000..a5a6f64b --- /dev/null +++ b/.github/workflows/lite-smoke.yml @@ -0,0 +1,56 @@ +name: JupyterLite smoke test + +# Opens the *live* JupyterLite site in real browsers and runs a notebook's +# bootstrap cell. Complements the pre-deploy check in docs.yml: +# - after every Docs Publish run on main that deployed, and +# - weekly, because breakage arrives from PyPI (a dependency changing its +# requirements) with no commit in this repo. +# It cannot block a deploy; a failure fails the run and notifies. Never runs +# for pull requests. + +on: + workflow_run: + workflows: ["Docs Publish"] + types: [completed] + branches: [main] + schedule: + - cron: "17 21 * * 1" # Mondays 21:17 UTC + workflow_dispatch: + +permissions: + contents: read + +jobs: + live-site: + runs-on: ubuntu-latest + if: >- + github.event_name != 'workflow_run' || + (github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event != 'pull_request') + + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-python@v7 + with: + python-version: "3.12" + + - name: Install Playwright and browsers + run: | + pip install playwright + playwright install --with-deps chromium webkit + + - name: Check the live JupyterLite site + # retries: GitHub Pages can take a minute or two to serve a new deploy + run: | + python docs/lite/browser_check/check_lite_in_browser.py \ + --base-url https://petercorke.github.io/machinevision-toolbox-python/lite \ + --retries 4 --retry-wait 60 --screenshot-dir browser-check-screenshots + + - name: Upload screenshots + if: failure() + uses: actions/upload-artifact@v7 + with: + name: lite-browser-check-screenshots + path: browser-check-screenshots + if-no-files-found: ignore diff --git a/docs/lite/browser_check/README.md b/docs/lite/browser_check/README.md new file mode 100644 index 00000000..cf56be28 --- /dev/null +++ b/docs/lite/browser_check/README.md @@ -0,0 +1,27 @@ +# JupyterLite browser check + +Opens the JupyterLite site in real browsers (Playwright: Chromium and WebKit, the +engine behind Safari), waits for the Pyodide kernel, runs `intro.ipynb`'s bootstrap +cell, and requires its `Running in browser using MVTB v...` line. It also fails if +JupyterLab itself does not start (`Plugin '...' failed to activate`), which happens +before any kernel exists. + +It complements `../wasm_check/`, which tests the dependency install in Node without +a browser or JupyterLab. + +| Where | When | +|---|---| +| `docs.yml`, after `jupyter lite build` | every publish (`push` to main, `workflow_dispatch`), not pull requests; blocks the deploy on failure | +| `lite-smoke.yml` | after each Docs Publish on main, weekly, or manually; checks the live site and can only alert | + +Run it yourself: + +```bash +pip install playwright && playwright install chromium webkit +python docs/lite/browser_check/check_lite_in_browser.py \ + --base-url https://petercorke.github.io/machinevision-toolbox-python/lite +``` + +What it does **not** reproduce: your own browser's stored state. A browser holding +files from an earlier deploy can fail where a fresh Playwright context passes; see +"Troubleshooting JupyterLite" in the docs. diff --git a/docs/lite/browser_check/check_lite_in_browser.py b/docs/lite/browser_check/check_lite_in_browser.py new file mode 100644 index 00000000..c4ef0cc5 --- /dev/null +++ b/docs/lite/browser_check/check_lite_in_browser.py @@ -0,0 +1,172 @@ +#!/usr/bin/env python +"""Check that the JupyterLite site starts and installs the toolbox in a real browser. + +Opens a notebook in a fresh browser context, waits for the Pyodide kernel, runs the +notebook's bootstrap cell (the first code cell) and requires its status line +``Running in browser using MVTB v...`` to appear. Also fails if JupyterLab itself +could not start (``Plugin '...' failed to activate`` console errors), which happens +before any kernel exists and which the Node/Pyodide install check cannot see. + +Usage:: + + python check_lite_in_browser.py --base-url http://localhost:8000/lite + python check_lite_in_browser.py --base-url https://petercorke.github.io/machinevision-toolbox-python/lite + +Exits 0 if every requested browser passes, 1 otherwise. +""" + +from __future__ import annotations + +import argparse +import sys +import time +from pathlib import Path + +from playwright.sync_api import Error as PlaywrightError +from playwright.sync_api import TimeoutError as PlaywrightTimeout +from playwright.sync_api import sync_playwright + +SUCCESS_TEXT = "Running in browser using MVTB" +APP_FAILURE_MARKERS = ("failed to activate", "No setting registry") + + +class CheckFailed(Exception): + """The site did not behave as expected; the message says how.""" + + +def check_once( + playwright, + browser_name: str, + base_url: str, + notebook: str, + timeout_s: float, + screenshot: Path | None, +) -> str: + """Run the check once in one browser, returning the success status line.""" + browser = getattr(playwright, browser_name).launch() + try: + # a fresh context has no storage, cache or service workers + page = browser.new_context().new_page() + + console_errors: list[str] = [] + http_errors: list[str] = [] + page.on( + "console", + lambda m: console_errors.append(m.text) if m.type == "error" else None, + ) + page.on("pageerror", lambda e: console_errors.append(f"pageerror: {e}")) + page.on( + "response", + lambda r: http_errors.append(f"{r.status} {r.url}") if r.status >= 400 else None, + ) + + def fail(reason: str) -> CheckFailed: + if screenshot is not None: + try: + page.screenshot(path=str(screenshot), full_page=True) + except PlaywrightError: + pass + lines = [reason] + if console_errors: + lines.append("console errors (first 10):") + lines += [f" {t[:200]}" for t in console_errors[:10]] + if http_errors: + lines.append("HTTP errors (first 10):") + lines += [f" {t}" for t in http_errors[:10]] + return CheckFailed("\n".join(lines)) + + url = f"{base_url.rstrip('/')}/lab/index.html?path={notebook}" + page.goto(url, wait_until="load", timeout=timeout_s * 1000) + + try: + page.wait_for_selector(".jp-Notebook .jp-CodeCell", timeout=60_000) + except PlaywrightTimeout: + raise fail(f"JupyterLab did not show the notebook: {url}") from None + + app_failures = [ + t for t in console_errors if any(m in t for m in APP_FAILURE_MARKERS) + ] + if app_failures: + raise fail("JupyterLab plugins failed to activate") + + # Run before the kernel is idle and the request is silently lost, leaving the + # cell at "[ ]:" forever, so wait for it explicitly. + try: + page.get_by_text("Python (Pyodide) | Idle").wait_for(timeout=timeout_s * 1000) + except PlaywrightTimeout: + raise fail("the Pyodide kernel never became idle") from None + + # the first code cell is the generated bootstrap cell + cell = page.locator(".jp-Notebook .jp-CodeCell").first + cell.locator(".jp-InputPrompt").click() + page.locator( + '[data-command="notebook:run-cell-and-select-next"]' + ).first.click() + + deadline = time.monotonic() + timeout_s + while time.monotonic() < deadline: + outputs = " ".join(cell.locator(".jp-OutputArea").all_inner_texts()) + if SUCCESS_TEXT in outputs: + for line in outputs.splitlines(): + if SUCCESS_TEXT in line: + return line.strip() + return SUCCESS_TEXT + if "Traceback" in outputs: + raise fail("the bootstrap cell raised:\n" + outputs[-1500:]) + time.sleep(2) + + outputs = " ".join(cell.locator(".jp-OutputArea").all_inner_texts()) + raise fail( + f"no '{SUCCESS_TEXT}' within {timeout_s:.0f} s; output so far:\n" + + outputs[-800:] + ) + finally: + browser.close() + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__.split("\n")[0]) + parser.add_argument("--base-url", required=True, help="URL of the lite site root") + parser.add_argument("--notebook", default="intro.ipynb") + parser.add_argument( + "--browsers", nargs="+", default=["chromium", "webkit"], help="Playwright browsers" + ) + parser.add_argument("--timeout", type=float, default=300, help="seconds per attempt") + parser.add_argument( + "--retries", + type=int, + default=1, + help="extra attempts after a failure (deploy propagation, network flakiness)", + ) + parser.add_argument("--retry-wait", type=float, default=60, help="seconds between attempts") + parser.add_argument("--screenshot-dir", type=Path, default=None) + args = parser.parse_args() + + if args.screenshot_dir is not None: + args.screenshot_dir.mkdir(parents=True, exist_ok=True) + + ok = True + with sync_playwright() as p: + for name in args.browsers: + shot = args.screenshot_dir / f"{name}.png" if args.screenshot_dir else None + for attempt in range(args.retries + 1): + try: + status = check_once( + p, name, args.base_url, args.notebook, args.timeout, shot + ) + except (CheckFailed, PlaywrightError) as e: + print(f"[{name}] attempt {attempt + 1} FAILED:\n{e}\n", flush=True) + if attempt < args.retries: + time.sleep(args.retry_wait) + continue + ok = False + else: + print(f"[{name}] PASS: {status}", flush=True) + break + + print("PASS" if ok else "FAIL") + return 0 if ok else 1 + + +if __name__ == "__main__": + sys.exit(main()) From 7e54bd26eabf6284f08267c318649fe7925dc80d Mon Sep 17 00:00:00 2001 From: Peter Corke Date: Wed, 14 Oct 2026 07:54:11 +1000 Subject: [PATCH 2/2] docs: browser check README matches the informational gate Co-Authored-By: Claude Sonnet 5.5 --- docs/lite/browser_check/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/lite/browser_check/README.md b/docs/lite/browser_check/README.md index cf56be28..1b67ad47 100644 --- a/docs/lite/browser_check/README.md +++ b/docs/lite/browser_check/README.md @@ -11,7 +11,7 @@ a browser or JupyterLab. | Where | When | |---|---| -| `docs.yml`, after `jupyter lite build` | every publish (`push` to main, `workflow_dispatch`), not pull requests; blocks the deploy on failure | +| `docs.yml`, after `jupyter lite build` | every publish (`push` to main, `workflow_dispatch`), not pull requests; informational (`continue-on-error`) until it has run green once on main, then it blocks the deploy | | `lite-smoke.yml` | after each Docs Publish on main, weekly, or manually; checks the live site and can only alert | Run it yourself: