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
4 changes: 3 additions & 1 deletion build_scripts/inject_version_picker.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
* <script>...inlined picker.js...</script> (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 <script src="..."> AND why no <link>?

Expand Down Expand Up @@ -40,6 +41,7 @@
from pathlib import Path

INJECT_MARKER = "<!-- pyrit-version-picker -->"
SKIP_MARKER = "<!-- pyrit-no-version-picker -->"
ASSETS_SOURCE_DIR = Path(__file__).resolve().parent / "version_picker_assets"

# The same marker string is used in compose_docs_dist.py's 404 template. Both
Expand All @@ -54,7 +56,7 @@ def _head_block(base: str, picker_js: str) -> str:


def _inject(html: str, base: str, picker_js: str) -> tuple[str, bool]:
if INJECT_MARKER in html:
if INJECT_MARKER in html or SKIP_MARKER in html:
return html, False
head_block = _head_block(base, picker_js)
new = html.replace("</head>", f"{head_block}</head>", 1) if "</head>" in html else head_block + html
Expand Down
17 changes: 17 additions & 0 deletions doc/assets/videos/copyrit-player.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<!doctype html>
<!-- pyrit-no-version-picker -->
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>CoPyRIT GUI walkthrough</title>
<link rel="stylesheet" href="player.css">
<script src="player.js" defer></script>
</head>
<body>
<video controls playsinline preload="none" src="copyrit-walkthrough.mp4"
poster="../copyrit-demo.png" aria-label="CoPyRIT GUI walkthrough">
<a href="copyrit-walkthrough.mp4">Open the CoPyRIT GUI walkthrough directly</a>.
</video>
</body>
</html>
14 changes: 14 additions & 0 deletions doc/assets/videos/player.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
html,
body {
width: 100%;
height: 100%;
margin: 0;
background: #0b0b0b;
}

video {
display: block;
width: 100%;
height: 100%;
object-fit: contain;
}
36 changes: 36 additions & 0 deletions doc/assets/videos/player.js
Original file line number Diff line number Diff line change
@@ -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);
})();
17 changes: 17 additions & 0 deletions doc/assets/videos/scanner-player.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<!doctype html>
<!-- pyrit-no-version-picker -->
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>PyRIT Scanner walkthrough</title>
<link rel="stylesheet" href="player.css">
<script src="player.js" defer></script>
</head>
<body>
<video controls playsinline preload="none" src="scanner-walkthrough.mp4"
poster="../scanner-demo.png" aria-label="PyRIT Scanner walkthrough">
<a href="scanner-walkthrough.mp4">Open the Scanner walkthrough directly</a>.
</video>
</body>
</html>
1 change: 0 additions & 1 deletion doc/css/custom.css
Original file line number Diff line number Diff line change
Expand Up @@ -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%;
Expand Down
4 changes: 2 additions & 2 deletions doc/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
5 changes: 5 additions & 0 deletions doc/myst.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
14 changes: 14 additions & 0 deletions tests/unit/build_scripts/test_inject_version_picker.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -99,3 +103,13 @@ def test_handles_html_without_head(injector_module, tmp_path):
out = (site / "fragment.html").read_text(encoding="utf-8")
assert '<meta name="pyrit-docs-base" content="/PyRIT">' in out
assert "pyrit-version-picker" in out


def test_embedded_player_opts_out(*, injector_module: ModuleType, tmp_path: Path) -> None:
html = (
"<!doctype html>\n<!-- pyrit-no-version-picker -->\n"
"<html><head></head><body><video controls></video></body></html>"
)
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
117 changes: 117 additions & 0 deletions tests/unit/build_scripts/test_landing_video_players.py
Original file line number Diff line number Diff line change
@@ -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"<video\b([^>]*)>", 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 "<!-- pyrit-no-version-picker -->" 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
Loading