diff --git a/build_scripts/inject_version_picker.py b/build_scripts/inject_version_picker.py index cbfc4852cd..e2d802b791 100644 --- a/build_scripts/inject_version_picker.py +++ b/build_scripts/inject_version_picker.py @@ -12,6 +12,7 @@ * (CSS is bundled inside) Idempotent: files that already contain our marker are skipped. +Embedded players opt out with the ``pyrit-no-version-picker`` HTML comment. Why inline the JS instead of + + + + + diff --git a/doc/assets/videos/player.css b/doc/assets/videos/player.css new file mode 100644 index 0000000000..dde8f1d643 --- /dev/null +++ b/doc/assets/videos/player.css @@ -0,0 +1,14 @@ +html, +body { + width: 100%; + height: 100%; + margin: 0; + background: #0b0b0b; +} + +video { + display: block; + width: 100%; + height: 100%; + object-fit: contain; +} diff --git a/doc/assets/videos/player.js b/doc/assets/videos/player.js new file mode 100644 index 0000000000..51da77d69a --- /dev/null +++ b/doc/assets/videos/player.js @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +(function () { + "use strict"; + + const video = document.querySelector("video"); + const frame = window.frameElement; + const tabSet = frame?.closest(".myst-tab-set"); + if (!tabSet) return; + + video.addEventListener("play", () => { + if (frame.getClientRects().length === 0) { + video.pause(); + return; + } + for (const sibling of tabSet.querySelectorAll(".landing-demo-video iframe")) { + if (sibling !== frame) { + sibling.contentDocument?.querySelector("video")?.pause(); + } + } + }); + + const observer = new MutationObserver(() => { + if (frame.getClientRects().length === 0) video.pause(); + }); + function observeTabSet() { + observer.observe(tabSet, { + attributes: true, + attributeFilter: ["class", "hidden", "style"], + subtree: true, + }); + } + observeTabSet(); + window.addEventListener("pagehide", () => observer.disconnect()); + window.addEventListener("pageshow", observeTabSet); +})(); diff --git a/doc/assets/videos/scanner-player.html b/doc/assets/videos/scanner-player.html new file mode 100644 index 0000000000..f870bfbda1 --- /dev/null +++ b/doc/assets/videos/scanner-player.html @@ -0,0 +1,17 @@ + + + + + + + PyRIT Scanner walkthrough + + + + + + + diff --git a/doc/css/custom.css b/doc/css/custom.css index 1d71444d4a..aa74720dfc 100644 --- a/doc/css/custom.css +++ b/doc/css/custom.css @@ -178,7 +178,6 @@ html.dark .myst-card > .myst-card-body > img.ecosystem-logo { margin-bottom: 0.5rem; } -/* Landing-page walkthroughs retain lightweight poster images until played. */ .landing-demo-video { display: block; width: 100%; diff --git a/doc/index.md b/doc/index.md index 22d4003790..cdc898e3ce 100644 --- a/doc/index.md +++ b/doc/index.md @@ -133,7 +133,7 @@ Run security assessments from the command line with `pyrit_scan` or the interact pyrit_scan run airt.scam --target openai_chat ``` -```{iframe} https://commandline.microsoft.com/wp-content/uploads/2026/08/scanner_walkthrough.mp4 +```{iframe} videos/scanner-player.html :width: 100% :title: PyRIT Scanner walkthrough :placeholder: scanner-demo.png @@ -154,7 +154,7 @@ Start the local web app and give it a try: ```bash pyrit_backend # serves webapp on http://localhost:8000/ ``` -```{iframe} https://commandline.microsoft.com/wp-content/uploads/2026/08/CoPyRIT-GUI-walkthrough.mp4 +```{iframe} videos/copyrit-player.html :width: 100% :title: CoPyRIT GUI walkthrough :placeholder: copyrit-demo.png diff --git a/doc/myst.yml b/doc/myst.yml index 7e8a8c59f8..6a8754afc7 100644 --- a/doc/myst.yml +++ b/doc/myst.yml @@ -11,6 +11,11 @@ project: - format: pdf template: plain_latex_book output: exports/book.pdf + # MyST copies these to the site root, preserving the videos directory. + static_files: + - assets/videos + - scanner-demo.png + - copyrit-demo.png # See https://mystmd.org/guide for error_rules schema. # Rule IDs come from https://github.com/jupyter-book/mystmd # (packages/myst-common/src/ruleids.ts). diff --git a/tests/unit/build_scripts/test_inject_version_picker.py b/tests/unit/build_scripts/test_inject_version_picker.py index d760684889..4de04609b2 100644 --- a/tests/unit/build_scripts/test_inject_version_picker.py +++ b/tests/unit/build_scripts/test_inject_version_picker.py @@ -7,9 +7,13 @@ import importlib.util import sys from pathlib import Path +from typing import TYPE_CHECKING import pytest +if TYPE_CHECKING: + from types import ModuleType + REPO_ROOT = Path(__file__).resolve().parents[3] SCRIPT = REPO_ROOT / "build_scripts" / "inject_version_picker.py" @@ -99,3 +103,13 @@ def test_handles_html_without_head(injector_module, tmp_path): out = (site / "fragment.html").read_text(encoding="utf-8") assert '' in out assert "pyrit-version-picker" in out + + +def test_embedded_player_opts_out(*, injector_module: ModuleType, tmp_path: Path) -> None: + html = ( + "\n\n" + "" + ) + site = _build_site(tmp_path, html) + assert injector_module.main(["--site-dir", str(site), "--base", "/PyRIT/latest"]) == 0 + assert (site / "index.html").read_text(encoding="utf-8") == html diff --git a/tests/unit/build_scripts/test_landing_video_players.py b/tests/unit/build_scripts/test_landing_video_players.py new file mode 100644 index 0000000000..cb69fb5170 --- /dev/null +++ b/tests/unit/build_scripts/test_landing_video_players.py @@ -0,0 +1,117 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. +"""Regression tests for the landing page's embedded walkthroughs.""" + +import re +import shutil +import subprocess +from pathlib import Path + +import pytest +import yaml + +DOC_ROOT = Path(__file__).resolve().parents[3] / "doc" + + +@pytest.mark.parametrize("name", ["scanner", "copyrit"]) +def test_player_requires_manual_playback(name: str) -> None: + html = (DOC_ROOT / "assets" / "videos" / f"{name}-player.html").read_text(encoding="utf-8") + video = re.search(r"]*)>", html) + assert video is not None + attributes = dict(re.findall(r'([\w-]+)(?:="([^"]*)")?', video.group(1))) + assert "controls" in attributes + assert "playsinline" in attributes + assert attributes["preload"] == "none" + assert not {"autoplay", "loop", "muted"} & attributes.keys() + assert attributes["src"] == f"{name}-walkthrough.mp4" + assert attributes["poster"] == f"../{name}-demo.png" + assert "" in html + + +def test_player_assets_are_published() -> None: + config = yaml.safe_load((DOC_ROOT / "myst.yml").read_text(encoding="utf-8")) + assert {"assets/videos", "scanner-demo.png", "copyrit-demo.png"} <= set(config["project"]["static_files"]) + for name in ["scanner", "copyrit"]: + assert (DOC_ROOT / "assets" / "videos" / f"{name}-walkthrough.mp4").is_file() + assert (DOC_ROOT / f"{name}-demo.png").is_file() + assert f"{{iframe}} videos/{name}-player.html" in (DOC_ROOT / "index.md").read_text(encoding="utf-8") + for asset in ["player.js", "player.css"]: + assert (DOC_ROOT / "assets" / "videos" / asset).is_file() + + +@pytest.mark.skipif(shutil.which("node") is None, reason="node not installed") +@pytest.mark.parametrize("scenario", ["play", "hidden_play", "hide", "visible", "standalone", "pagehide", "restore"]) +def test_player_coordinates_playback(scenario: str) -> None: + program = """ +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const vm = require("node:vm"); +const scenario = process.argv[2]; +const events = {}; +const windowEvents = {}; +const video = { + paused: false, + pause() { this.paused = true; }, + addEventListener(name, callback) { events[name] = callback; }, +}; +const siblingVideo = { paused: false, pause() { this.paused = true; } }; +const sibling = { contentDocument: { querySelector: () => siblingVideo } }; +const unloaded = { contentDocument: null }; +let visible = true; +const tabSet = { querySelectorAll: () => [frame, sibling, unloaded] }; +const frame = { + closest: () => tabSet, + getClientRects: () => visible ? [{}] : [], +}; +let observer; +class MutationObserver { + constructor(callback) { this.callback = callback; observer = this; } + observe(target, options) { + assert.equal(target, tabSet); + assert.deepEqual(Array.from(options.attributeFilter), ["class", "hidden", "style"]); + assert.equal(options.subtree, true); + this.disconnected = false; + } + disconnect() { this.disconnected = true; } +} +vm.runInNewContext(fs.readFileSync(process.argv[1], "utf8"), { + document: { querySelector: () => video }, + window: { + frameElement: scenario === "standalone" ? null : frame, + addEventListener(name, callback) { windowEvents[name] = callback; }, + }, + MutationObserver, +}); +assert.equal(video.paused, false); +assert.equal(siblingVideo.paused, false); +if (scenario === "standalone") { + assert.equal(observer, undefined); +} else if (scenario === "pagehide") { + windowEvents.pagehide(); + assert.equal(observer.disconnected, true); +} else if (scenario === "restore") { + windowEvents.pagehide(); + windowEvents.pageshow(); + assert.equal(observer.disconnected, false); + visible = false; + observer.callback(); + assert.equal(video.paused, true); +} else if (scenario === "play" || scenario === "hidden_play") { + visible = scenario === "play"; + events.play(); + assert.equal(video.paused, !visible); + assert.equal(siblingVideo.paused, visible); +} else { + visible = scenario === "visible"; + observer.callback(); + assert.equal(video.paused, !visible); + assert.equal(siblingVideo.paused, false); +} +""" + result = subprocess.run( + ["node", "-e", program, str(DOC_ROOT / "assets" / "videos" / "player.js"), scenario], + capture_output=True, + text=True, + timeout=10, + ) + assert result.returncode == 0, result.stderr